Compare commits

..
Author SHA1 Message Date
Teknium fb5649f2e8 Merge remote-tracking branch 'origin/main' into fix/egress-review-fixes 2026-06-14 13:16:34 -07:00
Teknium e433c41014 fix(egress): harden Docker proxy UX and enforcement 2026-06-14 13:07:22 -07:00
Teknium 4f65d5509f chore: empty commit to mint fresh merge SHA (CI runner tree-corruption on shard 5) 2026-06-10 09:15:41 -07:00
Teknium e2c2e41137 fix(egress): v4 round — bridge bind on Linux, listener-role split, fallback gate, audit.log truth
Addresses GodsBoy's May 30 follow-up review on PR #30179.

P0 — Linux sandboxes could not reach the proxy:
- _default_http_listen now binds the docker bridge gateway on Linux
  (host.docker.internal resolves to the bridge gateway there; the old
  loopback-only bind was unreachable from containers). Loopback stays
  the default on Docker Desktop platforms; bridge-less Linux falls back
  to loopback with a warning.
- Live-testing the fix against the real v0.39 binary surfaced a second
  latent bug the host-side E2E had masked: v0.39's http_listen does NOT
  terminate CONNECT — tunnel_listen does. HTTPS_PROXY traffic through
  http_listen got 400s from upstream. build_proxy_config now binds
  tunnel_listen (CONNECT/MITM) on tunnel_port and http_listen
  (plain-HTTP forwards) on tunnel_port+1; docker.py points HTTPS_PROXY
  at tunnel_port and HTTP_PROXY at tunnel_port+1.
- Liveness probes (start_proxy poll loop, get_status) now probe the
  CONFIGURED bind host via _read_http_listen_from_config() instead of
  hardcoded loopback, which would have killed a healthy bridge-bound
  daemon as 'never came up'.

P2 — allow_env_fallback dead on the partial-secret path:
- The missing-secret branch in _build_proxy_subprocess_env now honors
  proxy.allow_env_fallback exactly as its own error message promises.
- cmd_start refuses (or warns, with the fallback flag) when
  credential_source=bitwarden but secrets.bitwarden is disabled/missing
  — closing the silent-degrade-to-host-env hole.

P2 — audit.log decoy on v0.39:
- Wizard pre-create failure downgraded to a warning (file is
  non-load-bearing until the version bump); success line qualified as
  'reserved'; user + dev docs stop telling operators to wire monitoring
  to the path today.

P3 — metrics comment no longer claims 'hermes egress status' surfaces
the ephemeral metrics port (it can't; :0 is random and unrecorded).

Validation: 180 unit tests pass (9 new), gated E2E passes, and a live
end-to-end run against the real binary verified CONNECT-MITM with
Authorization swap on tunnel_listen, plain-HTTP swap on tunnel_port+1,
403 on non-allowlisted hosts, and bind-host-aware status probing.
Docs gain a Linux firewall troubleshooting section (container→docker0
INPUT drops, e.g. ufw default-deny).
2026-06-10 05:12:37 -07:00
Teknium 42139c3cf0 Merge remote-tracking branch 'origin/main' into feat/iron-proxy
# Conflicts:
#	hermes_cli/main.py
#	tools/environments/docker.py
2026-06-10 04:38:13 -07:00
teknium1 bde66ef37a docs(egress): align user + dev docs with iron-proxy v0.39 actual behavior
The previous docs round (906b1da57) described the integration the way
we wanted it to work — `http_listens` plural with a docker bridge
bind, dedicated `audit.log` for per-request JSON records.  Live
testing against the real v0.39.0 binary in [905ce58a1] surfaced that
neither field exists in v0.39's config schema, and the docs were
making promises the daemon couldn't keep.

This commit walks every claim in the docs back to what the binary
actually does today, while keeping the upgrade path explicit so the
docs stay coherent when the pinned `_IRON_PROXY_VERSION` bumps:

website/docs/user-guide/egress/iron-proxy.md
- Bind policy section: rewritten.  Was "loopback + docker bridge IP
  on Linux"; now "loopback only" with an explicit explanation that
  v0.39 only supports one bind per daemon and that
  host.docker.internal -> host-gateway mapping is what sandboxes
  use to reach the loopback bind.
- Bind policy section adds a note on the metrics-port pin that the
  previous round of docs didn't even mention.
- State directory layout table: `audit.log` description rewritten
  to acknowledge it's a pre-created sentinel for future binary
  versions, NOT something the v0.39 daemon writes to.
- New section "Logging on iron-proxy v0.39" replaces the old
  "Audit log vs daemon log" section.  Explicitly tells operators
  the daemon log is the single source of truth for both audiences
  on v0.39, with the upgrade path called out.
- Data-flow diagram step 7: rewritten to send per-request records
  to `iron-proxy.log` on v0.39 with cross-link to the new logging
  section.
- Diagram caption updated.
- Security-model "allowlisted-host exfiltration" line: "audit log
  captures" -> "daemon log captures".
- Security-model "LAN peer leak" line: removed the docker-bridge
  claim.
- Troubleshooting section's per-request-inspection recipes:
  rewritten to use `iron-proxy.log` and explain when the split
  stream will land.
- Limitations list gets a new bullet calling out the
  single-bind + combined-log v0.39 constraints + the auto-upgrade
  posture.

website/docs/developer-guide/egress-internals.md
- Bind policy invariant: documents the singular `http_listen` v0.39
  schema constraint + dead-code-until-upgrade status of the
  bridge-bind path.
- New "Metrics port collision" invariant documenting why
  `metrics.listen: 127.0.0.1:0` is non-negotiable.
- Audit log fail-loud invariant adds the v0.39 schema constraint
  note + the new
  `test_audit_log_kwarg_does_not_inject_audit_path_v039` regression
  test.
- "Subscribing to per-request audit events" section updated to
  send watchers at `iron-proxy.log` for v0.39 with the upgrade
  pivot called out.

website/docs/reference/cli-commands.md
- Diagnostic shortcut for tailing the audit log: `tail audit.log |
  jq` -> `tail iron-proxy.log | jq` with the v0.39 note inline.

Build verification:
- `npx docusaurus build` succeeds across all three locales
  (en + zh-Hans + ko).
- New `#logging-on-iron-proxy-v039` anchor lands in the rendered
  HTML and the in-page cross-references resolve.
- No new broken anchors introduced (pre-existing warnings on
  unrelated zh-Hans pages are unchanged).
- No leftover stale `#audit-log-vs-daemon-log` or `#http_listens`
  references anywhere on the egress pages.
2026-05-29 00:34:36 -07:00
teknium1 905ce58a12 fix(egress): align proxy.yaml with iron-proxy v0.39 actual schema +
propagate handler exit codes

Live testing the full wizard against the real v0.39.0 binary
(downloaded + extracted via our own install_iron_proxy()) surfaced
three real bugs that the unit tests couldn't catch:

1. `proxy.http_listens` (plural) — NOT a field in v0.39's config struct.
   Our code emitted both `http_listen` (string) and `http_listens`
   (list) believing v0.39 accepts both forms.  The binary actually
   rejects with "field http_listens not found in type config.Proxy"
   at YAML unmarshal time, so the daemon fails to start.  Confirmed
   via strings(1) audit of the v0.39 binary — only `http_listen` is
   tagged.

2. `log.audit_path` — NOT a field in v0.39's config.Log struct.  Same
   class of error: "field audit_path not found in type config.Log".
   Per-request audit-log records are not separable from server-level
   logs at this binary version.

3. `metrics.listen` defaults to ":9090" — which is the SAME port as
   our default `tunnel_port: 9090`.  Result: every operator who runs
   `hermes egress setup` followed by `hermes egress start` gets
   "bind: address already in use" because the proxy listener and the
   metrics listener fight for port 9090.  We now explicitly pin
   `metrics.listen: 127.0.0.1:0` to give it an ephemeral loopback
   port that can never collide with tunnel_port regardless of what
   operator sets.

Plus a fourth bug — pre-existing but surfaced by the egress live
test — that affects every Hermes subcommand:

4. `hermes_cli/main.py` calls `args.func(args)` at the bottom of
   main() but discards the return value.  Every subcommand handler
   that returns a non-zero exit code (cmd_start refusing because
   `fail_on_uncovered_providers=true`, cmd_setup refusing because
   --from-bitwarden but BWS unreachable, etc.) was silently exiting 0.
   Fix: capture the handler's return value and `sys.exit(rc)` when
   it's a non-zero int.  Other subcommands' contracts unchanged
   because they either return 0/None or don't return at all.

Validation:
- 188/188 in test_iron_proxy.py + test_iron_proxy_cli.py +
  test_config.py pass post-fix.
- 5333/5337 in tests/hermes_cli/ pass; the 4 unrelated failures
  (test_managed_installs.py + test_update_hangup_protection.py)
  are pre-existing on main, not touched by this PR.
- Manual wizard run end-to-end with the v0.39.0 binary in an
  isolated HERMES_HOME:
    * `egress install` — downloads + SHA-256 verifies + extracts
    * `egress setup` — generates CA, mints tokens, writes
      proxy.yaml that the binary now accepts (no http_listens,
      no audit_path, metrics pinned to 127.0.0.1:0)
    * `egress start` — daemon binds 127.0.0.1:9090, listens=yes
    * `egress status` — shows pid + listening + mappings
    * `egress stop` — clean shutdown, pidfile + nonce removed
    * Idempotent re-start returns the running pid without spawning
    * curl through the proxy with the openrouter token gets
      forwarded; an attacker host gets HTTP 403 (allowlist works);
      169.254.169.254 gets HTTP 403 (deny CIDR works)
    * Refuse-start paths exit 1 with actionable messages:
      - `fail_on_uncovered_providers=true` + ANTHROPIC_API_KEY set
      - `credential_source=bitwarden` + BWS_ACCESS_TOKEN unset
    * `--rotate-tokens` confirmation gate fires via pty:
      typing 'cancel' aborts; typing 'rotate' proceeds and
      creates a mappings.json.rotated-<timestamp> backup

Test updates:
- `test_default_bind_is_loopback_not_zero_zero` — asserts the
  singular `http_listen` is loopback AND asserts `http_listens`
  (plural) is NOT in the rendered yaml.
- `test_default_bind_uses_loopback_on_linux` — replaces
  `test_default_bind_includes_docker_bridge_on_linux`.  v0.39
  only supports one bind per daemon process, so the docker bridge
  augmentation is dropped from the rendered config; sandboxes
  reach the daemon via host.docker.internal -> host-gateway
  mapping, so loopback-only is functional.
- `test_metrics_listener_pinned_to_loopback_ephemeral` — new
  regression test asserting `metrics.listen == "127.0.0.1:0"`.
- `test_audit_log_kwarg_does_not_inject_audit_path_v039` —
  replaces `test_audit_log_path_lands_in_yaml`.  audit_log kwarg
  is still accepted for forward compatibility but does NOT emit
  log.audit_path until upstream supports it.
2026-05-28 23:45:44 -07:00
teknium1 ec108c625e Merge origin/main into feat/iron-proxy
Single content conflict in hermes_cli/config.py — kept BOTH the
paste_collapse_threshold knobs from main and the proxy section from
this branch (they're independent additions to DEFAULT_CONFIG).

All 187 tests in test_iron_proxy.py + test_iron_proxy_cli.py +
test_config.py pass post-merge.
2026-05-25 18:37:06 -07:00
teknium1 906b1da57f docs(egress): comprehensive expansion — setup, config, troubleshooting,
internals reference

Pre-v3 the egress docs were 175 lines covering the basics: quick start,
slash commands, security model, failure modes.  After three rounds of
PR review we added a half-dozen new config knobs, two new flags, a
strict/warn tier split for uncovered providers, persisted-nonce
cross-process defense, audit-log + log-file separation, NODE_OPTIONS
append-merge, docker_env collision detection, etc. — none of which
the user-facing doc reflected.

This commit closes that gap end-to-end:

website/docs/user-guide/egress/iron-proxy.md (175 → 567 lines)
- Configuration section expanded with every new knob:
  fail_on_uncovered_providers, allow_env_fallback, upstream_deny_cidrs.
- Tables for default allowed hosts + default deny CIDRs.
- Bind policy section (loopback + docker bridge, NOT 0.0.0.0) with the
  operator-facing "why can't I hit the proxy from my LAN" answer.
- Uncovered providers section with the strict tier (Anthropic / Azure
  / Gemini — block when fail_on_uncovered_providers=true) vs warn tier
  (AWS, GCP appdefault — present on every dev laptop, never block).
- Bitwarden integration expanded: rotation semantics, fail-loud at
  start, the allow_env_fallback escape hatch, --no-bitwarden flag, the
  preserve-existing-source rule on plain re-setup.
- Slash commands section with --no-bitwarden, --rotate-tokens, and the
  token-rotation operator playbook (confirmation gate, backup file
  naming, restart-required caveat).
- State directory layout table covering all 9 files we create + their
  modes.
- Audit log vs daemon log distinction (the arshkumarsingh #2 fix that
  motivated the corrected diagram).
- CA distribution into the sandbox: full table of injected env vars,
  the Python/curl REPLACE vs Node ADD asymmetry caveat with the
  NODE_OPTIONS=--use-openssl-ca mitigation.
- docker_env collision detection: what gets blocked, what gets warned,
  the migration escape hatch.
- PID + nonce defense section explaining how iron-proxy.nonce works
  cross-CLI and the SIGKILL-suppress-on-recycle path.
- Security model expanded with the new defenses
  (IPv4-mapped-v6 IMDS bypass closure, env-var leakage prevention,
  LAN-peer-with-token-leak coverage).
- Failure modes extended for every new refuse-start path.
- Troubleshooting section (180 new lines) with grep-friendly error
  matchers for each common failure: BWS token missing, uncovered
  provider refused, port collision, slow bind, 403 from proxy, SSL
  verification errors inside the sandbox, 401 from upstreams, address-
  in-use orphan recovery, per-request audit log inspection.

website/docs/getting-started/quickstart.md
- One-paragraph mention of the egress proxy under "Sandboxed terminal"
  so operators discover the feature when they enable Docker isolation.

website/docs/reference/cli-commands.md
- Top-level command table now lists `hermes egress` alongside `hermes
  proxy` (different purpose, different direction — call it out).
- New `## hermes egress` section with full subcommand syntax, common
  flows (first-time setup, switching credential source, rotating
  tokens, adding upstream), and diagnostic shortcuts.

website/docs/reference/environment-variables.md
- New "Egress proxy (sandbox-injected)" section documenting every env
  var the Docker backend injects: HERMES_EGRESS_PROXY,
  HERMES_PROXY_TOKEN_<NAME>, HTTPS_PROXY/HTTP_PROXY/NO_PROXY,
  REQUESTS_CA_BUNDLE/SSL_CERT_FILE/CURL_CA_BUNDLE/NODE_EXTRA_CA_CERTS,
  NODE_OPTIONS append-merge, HERMES_IRON_PROXY_NONCE.
- Also fixes a stale layout issue with the Persistent Shell table that
  had two trailing rows getting orphaned in the v3 commit.

website/docs/developer-guide/egress-internals.md (NEW, 363 lines)
- Module layout map (which file owns what).
- Full lifecycle walkthrough for install / setup / start / stop with
  the actual function calls in order.
- "Security invariants" section enumerating every load-bearing property
  with the regression test name that guards it.  These are the rules
  contributors must preserve when touching the module:
  - filesystem perms (0o700 dir, 0o600 secrets, O_NOFOLLOW everywhere)
  - subprocess env minimisation (no os.environ.copy)
  - bind policy (loopback + docker bridge, never 0.0.0.0)
  - default deny CIDR coverage
  - audit log fail-loud
  - bitwarden fail-loud
  - docker_env collision detection
  - PID recycling defense
  - token preservation on re-setup
  - credential_source preservation
- Extension points: adding a bearer-token provider, adding a
  non-bearer provider, wiring iron-proxy into a non-Docker backend,
  subscribing to per-request audit events.
- Testing recipe (hermetic + E2E + CLI smoke).

website/sidebars.ts
- New `developer-guide/egress-internals` entry under Developer Guide
  → Internals (alongside acp-internals, cron-internals,
  trajectory-format).

Build verification
- `cd website && npm install && npx docusaurus build` succeeds locally.
- All three new pages render to static HTML in all three locales
  (en + zh-Hans + ko).
- No new broken links or broken anchors introduced (pre-existing
  warnings on translation stubs are unrelated).
2026-05-25 15:05:16 -07:00
teknium1 fa4e87b253 fix(egress): v3 round — GodsBoy/stephenschoettler/arshkumarsingh findings
GodsBoy 2nd-round P1 (all 4 addressed):
- _detect_docker_bridge_ip: replace `ip.count('.') == 3` heuristic with
  ipaddress.IPv4Address validation + reject unspecified/loopback/multicast/
  reserved/link-local/global addresses.  Hostile `ip` shim on PATH used to
  be able to inject 0.0.0.0 here and re-open INADDR_ANY binding.
- cmd_setup credential_source preservation: re-running `hermes egress
  setup` without --from-bitwarden no longer silently downgrades a previous
  bitwarden config back to env.  Require --no-bitwarden to switch
  explicitly; otherwise preserve the existing mode and surface the
  decision.
- fail_on_uncovered_providers docstring/default mismatch: docstring used
  to claim default=True; behavior was default=False.  Resolved by
  truth-in-advertising — docstring now correctly states default=False —
  AND splitting providers into a strict LLM-specific tier
  (_LLM_SPECIFIC_NON_BEARER_PROVIDERS, used by start blocking) and a
  generic uncovered tier (used by wizard warnings).  Generic cloud creds
  (AWS_*, GOOGLE_APPLICATION_CREDENTIALS) no longer trip refuse-start
  for operators using terraform/gcloud alongside Hermes.  New
  discover_blocked_providers() returns the strict subset.
- start_proxy poll-loop must verify listening before pidfile:
  previously fell through deadline-expired as success and wrote a
  pidfile for a non-listening daemon.  Refactored into a do-while
  shape, require `listening=True` for success, kill the child + unlink
  the pidfile on failure paths.

GodsBoy 2nd-round P2 (the worth-keeping subset):
- O_NOFOLLOW + 0o600 + st_uid check on iron-proxy.log open (symmetric
  with the pidfile and audit-log paths the same PR hardens).
- pidfile O_EXCL: refactored pidfile-write into _write_pidfile_safely
  which uses O_EXCL to detect concurrent starts.  EEXIST with a live
  pid means "another start in progress" — refuse with actionable
  message; EEXIST with a dead pid means "stale crash" — unlink and
  retry once.  Discriminates rather than racing.
- _VERSION_CACHE: invalidate on install_iron_proxy success;
  don't cache empty stdout (would poison `hermes egress status` for
  the lifetime of the process if first probe hit a corrupt binary).
- ensure_audit_log now RAISES on OSError instead of swallowing it as
  a warning.  Previous behavior let the daemon create the file under
  the default umask, exactly the world-readable scenario the helper
  was built to prevent.  cmd_setup catches the new RuntimeError and
  surfaces "✗" with the actionable message.
- SIGINT/SIGTERM handler scoped around the start_proxy poll loop:
  Ctrl-C while waiting for `hermes egress start` no longer leaks an
  orphan daemon with the port bound.  Handler kills the child +
  unlinks the pidfile before re-raising.
- pidfile written IMMEDIATELY after Popen, BEFORE the listening
  verification.  Parent dying during the poll loop now leaves a
  pidfile pointing at the orphan so the next `hermes egress stop` can
  clean up.  Failure paths in the poll loop explicitly unlink.
- _DEFAULT_UPSTREAM_DENY_CIDRS: add ::ffff:0:0/96 (IPv4-mapped IPv6 —
  closes the v6-resolved IMDS bypass), 100.64.0.0/10 (CGNAT / cloud
  overlays / K8s pod networks), 198.18.0.0/15 (RFC2544 benchmark).
- _NON_BEARER_PROVIDERS split into LLM-specific (Anthropic / Azure /
  Gemini — block when strict) vs generic-cloud (AWS_*, GCP appdefault
  — warn-only).
- docker.py except narrowing: load_config can raise yaml.YAMLError on
  a malformed config.yaml, not just ImportError.  Two callsites
  (collision check + precedence resolution) now catch yaml.YAMLError
  via a sentinel `import yaml` and fail-safe to enforced mode.

GodsBoy 2nd-round P3:
- _reset_for_tests: was a no-op claiming symmetry with bitwarden;
  now actually clears _VERSION_CACHE and _proxy_nonce so in-process
  callers (notebooks, pytest -p no:xdist) don't see state leakage.
- tests/test_iron_proxy_cli.py: replaced hardcoded Path("/tmp/...")
  with hermes_home/-derived fixtures.  Matches the same cleanup we
  did for test_iron_proxy.py in the previous round.
- --rotate-tokens confirmation gate: when there are existing tokens,
  prompt for "rotate" confirmation (skipped when stdin isn't a tty
  so CI/scripted use still works) AND back up the mappings to a
  timestamped sibling before overwriting.  Surface a no-op note when
  rotate is requested with no existing tokens.

stephenschoettler (runtime-boundary review):
- #1 BWS silent degrade at proxy start: when credential_source=bitwarden
  but the BWS access token or project_id is missing OR the fetch
  returns no values for mapped providers, raise instead of silently
  falling back to host env.  cmd_start also pre-checks at the wizard
  layer for actionable error messages.  Opt-in escape hatch via new
  `proxy.allow_env_fallback: true` config for migration scenarios.
- #2 docker_env collision detection extended: `docker_env:
  {OPENROUTER_API_KEY: sk-real}` in config.yaml with enforce_on_docker:
  true now raises just like an HTTPS_PROXY collision would.  The
  collision check pulls mapped provider names from load_mappings() at
  call time.
- #3 PID nonce persisted to disk: cross-CLI-invocation stale-pidfile
  defense now works.  start_proxy writes the nonce next to the pidfile
  (sibling 0o600), stop_proxy reads it back via _read_persisted_nonce()
  and uses it as a _pid_alive signal in the new process.  Falls back
  to argv0 basename matching when the file is missing (legacy install).

arshkumarsingh:
- #1 NODE_OPTIONS append-merge: egress dict no longer sets NODE_OPTIONS
  directly (would clobber the operator's --max-old-space-size etc.).
  Carry the egress flag in a sentinel key
  _HERMES_EGRESS_NODE_OPTIONS_APPEND; DockerEnvironment merges into the
  existing NODE_OPTIONS in env_args computation with de-duplication.
- #2 docs: structured per-request audit log is at audit.log, not
  iron-proxy.log (the latter is daemon stdout/stderr).  Diagram and
  step-7 text corrected; both file roles are now documented separately.

Tests
- Added 12 new tests in test_iron_proxy.py covering bridge-IP rejection
  (parametrized over 8 dangerous inputs), default deny-list adjacency
  (IPv4-mapped-v6 + CGNAT), blocked-providers strict-subset property,
  _pid_proc_starttime parser with paren-containing comm,
  stop_proxy SIGKILL suppression on starttime drift, _reset_for_tests
  clear behavior, iron_proxy_version don't-cache-empty, NODE_OPTIONS
  sentinel verification, ensure_audit_log raise-on-OSError, and
  persisted-nonce roundtrip.
- Added 1 new test in test_iron_proxy_cli.py covering cmd_start
  BWS-token-missing fail-loud.
- All 100 tests in test_iron_proxy + test_iron_proxy_cli pass; all 78
  tests in test_docker_environment + test_config still pass.

Acknowledged but not addressed:
- GodsBoy P3 dead-code `extra_env` kwarg: kept (removing is a breaking
  change for any out-of-tree caller; the kwarg is documented and works).
- Residual risks GodsBoy called out: iron-proxy in-memory secret
  zeroisation (Go-binary territory, out of scope); _PROXY_SUBPROCESS_ENV
  _ALLOWLIST cosmetic gaps (RUST_LOG, GOMAXPROCS); follow-up.
2026-05-24 04:22:53 -07:00
teknium1 4833acf046 fix(egress): silence CodeQL clear-text-logging on bws warning strings
The bws helper's warnings list contains non-secret status messages
('rate limited', 'project not found', etc.), but CodeQL's taint
analyzer can't distinguish those from the secrets dict returned by
the same call.  Log the count instead of the strings — the warnings
are still observable via 'hermes secrets bitwarden status'.
2026-05-23 23:13:03 -07:00
teknium1 128a6837b7 fix(egress): address PR review findings — P0/P1/P2/P3 + CI greens
P0 — must-fix
- iron_proxy: emit default upstream_deny_cidrs (loopback, IMDS
  169.254.0.0/16, RFC1918) when caller passes None.  Honours the docs
  promise that cloud-metadata IPs are refused regardless of allowlist.
- iron_proxy: bind 127.0.0.1 (+ docker0 bridge IP on Linux) instead of
  INADDR_ANY (':9090').  LAN peers with a leaked sandbox token could
  otherwise spend the operator's API quota against any allowlisted
  upstream.
- ensure_ca_cert: write the CA private key via os.open(..., 0o600)
  instead of shutil.copy2+os.chmod — closes the TOCTOU window where
  the key existed under the default umask.
- discover_uncovered_providers + proxy.fail_on_uncovered_providers
  config: refuse to start (when strict) if env vars for non-bearer
  providers (Anthropic native x-api-key, AWS SigV4, Azure OpenAI,
  etc.) are present.  Surfaces a wizard warning in non-strict mode.

P1 — should-fix
- start_proxy: build a minimal subprocess env (PATH/HOME/locale +
  only the env names referenced by mappings) instead of os.environ
  .copy().  Strips proxy-recursion vars (HTTPS_PROXY etc.).  Stops
  the proxy's /proc/<pid>/environ from leaking every host secret
  to same-uid local processes.
- start_proxy: optional Bitwarden refresh path
  (refresh_secrets_from_bitwarden=True, bitwarden_config=...).
  When credential_source=bitwarden, cmd_start wires it in — that's
  what delivers the rotation guarantee the docs make.
- build_proxy_config: wire audit_log into the rendered yaml
  (log.audit_path).  Parameter was accepted but never used.
- ensure_audit_log: pre-create the audit log with 0o600 perms so
  iron-proxy inherits tight permissions instead of relying on umask.
- Rename 'hermes proxy ...' → 'hermes egress ...' in user-facing
  strings (docstring, RuntimeError messages, post-setup banner).
- start_proxy: open log file with 0o600 perms and close the parent
  fd immediately after Popen — fixes the per-restart fd leak.
- DockerEnvironment: detect collisions between docker_env and the
  egress-controlling env vars (HTTPS_PROXY, SSL_CERT_FILE, etc.).
  When enforce_on_docker=true, fail loud rather than silently
  inverting the isolation; when false, warn and let docker_env win.
- proxy_cli: merge_mappings preserves existing tokens on re-setup;
  --rotate-tokens flag re-mints all of them.  Stops re-running
  `hermes egress setup` from invalidating tokens baked into
  already-running sandboxes.
- proxy_cli: --from-bitwarden fail-loud on disabled BW config,
  missing access token, or empty vault.  Previously fell through to
  the env path while still writing credential_source: bitwarden.
- docker.py: narrow `except Exception` → `except ImportError`;
  iron_proxy._read_tunnel_port_from_config: same.  Bare excepts
  were masking real config-load bugs.
- start_proxy: write pidfile via os.open with O_NOFOLLOW + 0o600
  + st_uid check.  Refuses to follow a pre-existing symlink at the
  pidfile path.
- mint_proxy_token docstring: document the 128-bit suffix entropy
  explicitly (sha256 truncated to 32 hex chars).

P2 — follow-up
- start_proxy: poll-with-timeout (100ms cadence on _port_listening)
  instead of an unconditional 5s sleep.  Saves several seconds per
  Docker container create when enforce_on_docker=true.
- docker.py: apply enforce_on_docker semantics when CA file vanishes
  between status.configured check and CA mount.  Previously returned
  empty args silently.
- docker.py: refuse to mount when mappings.json is empty/corrupt
  (was indistinguishable from upstream outage from inside the
  sandbox).
- install_iron_proxy: tarfile.extract(..., filter='data') to silence
  the PEP 706 deprecation and opt into the 3.14+ default.
- _proxy_state_dir: chmod 0o700 unconditionally; add
  _proxy_state_dir_ro() so read-only callers don't create the dir.
- stop_proxy: re-verify pid before SIGKILL via /proc/<pid>/stat
  starttime AND _pid_alive.  Prevents SIGKILL'ing a recycled pid.
- _pid_alive: tightened cmdline check — basename match on argv[0]
  plus an in-process nonce env var ('iron-proxy' in cmdline matched
  'tail iron-proxy.log' and editors with the log open).
- docker.py: NODE_OPTIONS=--use-openssl-ca so Node.js routes through
  the OpenSSL CA store SSL_CERT_FILE controls, narrowing the
  Python/curl-replace vs Node-add asymmetry waefrebeorn flagged.

P3 — polish
- proxy_cli: dest='egress_command' (was 'proxy_command' which
  collided lexically with the inbound OAuth subparser).
- iron_proxy_version: cache by binary path — get_status is called
  per Docker container create, version is constant per binary.
- Drop unused `import sys` from iron_proxy.
- proxy_cli: `is not None` check on --tunnel-port (was treating 0
  as falsy and silently substituting the default).
- proxy_cli cmd_disable: use get_status().pid instead of reaching
  into ip._read_pid() (stale pidfile from a crashed run would have
  fired a spurious "still running" warning).
- Tests: replace hardcoded /tmp/ca.* paths with tmp_path-derived
  fixtures so tests are hermetic across hosts.

CI
- Windows footguns scanner: os.kill(pid, 0) is now gated behind
  platform.system() != 'Windows' with a windows-footgun: ok marker;
  signal.SIGKILL falls back to SIGTERM on Windows via
  getattr(signal, 'SIGKILL', signal.SIGTERM).
- docs MDX compilation: replace bare `<https://…>` URLs with
  `[text](url)` syntax (MDX-jsx parser rejects the angle-bracket
  form).

Tests
- 32 new tests covering default deny CIDRs, bind policy, audit log
  wiring, subprocess env minimization, CA TOCTOU 0o600, state dir
  0o700, empty-mappings refusal, CA-vanished refusal, docker_env
  collision detection, token preservation/rotate, uncovered provider
  detection, and the proxy_cli command handlers + argparse wiring.
- All 156 tests in test_iron_proxy + test_iron_proxy_cli +
  test_docker_environment + test_config pass locally.

Acknowledged but not addressed in this revision
- E2E test for HTTPS CONNECT + TLS-MITM path: existing E2E exercises
  plain HTTP; full MITM coverage needs separate CI infra (real iron-
  proxy binary + curl with custom CA).  Tracked as follow-up.
- Cosign-style supply-chain verification for the binary checksum:
  upstream iron-proxy doesn't sign releases yet.  Accepted pattern
  (same as Bitwarden integration); tracked as follow-up.
- CA rotation CLI (`hermes egress rotate-ca`): scope-cut to a
  follow-up.

Reviewers: @annguyenNous @waefrebeorn @GodsBoy @erhnysr
2026-05-23 20:38:27 -07:00
Teknium 7a74492134 chore(infographic): add iron-proxy-egress bento-grid bold-graphic 2026-05-23 20:38:27 -07:00
Teknium 69ffb9cfd4 feat(egress): iron-proxy credential-injection firewall for sandboxes
Adds a TLS-intercepting egress proxy for remote terminal sandboxes (Docker
v1; Modal/SSH to follow).  When enabled, the sandbox holds opaque proxy
tokens; iron-proxy swaps them for real provider API keys at the egress
boundary.  Compromising the sandbox leaks tokens that only work from behind
the proxy.

Wraps ironsh/iron-proxy (Apache-2.0, Go binary).  Same lazy-install pattern
as the recently merged Bitwarden Secrets Manager integration — pinned
version, SHA-256 verified download into ~/.hermes/bin/iron-proxy, no apt
or sudo required.

Disabled by default.  Run `hermes egress setup` to mint tokens and
`hermes egress start` to launch.  The Docker backend then automatically
mounts the CA, sets HTTPS_PROXY + CA-bundle env vars, and adds the
host-gateway hostmap.

New surfaces:
  hermes egress install   — download the pinned iron-proxy binary
  hermes egress setup     — interactive wizard (supports --from-bitwarden)
  hermes egress start     — spawn the managed proxy daemon
  hermes egress stop      — SIGTERM (+SIGKILL after 5s grace)
  hermes egress status    — binary + config + pid + listening + mappings
  hermes egress disable   — flip proxy.enabled = false
  hermes egress config    — print the path to the generated proxy.yaml

Optional Bitwarden integration: `--from-bitwarden` sources the real
upstream credentials from a BSM project at proxy startup, so rotating a
key in the Bitwarden web app propagates to sandboxes on the next proxy
start without touching .env.

Hermes-side scope (v1):
  agent/proxy_sources/iron_proxy.py   — install + CA + config + lifecycle
  hermes_cli/proxy_cli.py             — `hermes egress` subcommand tree
  hermes_cli/config.py                — "proxy:" section in DEFAULT_CONFIG
  hermes_cli/main.py                  — argparse wiring (uses 'egress'
                                         because 'proxy' is the existing
                                         inbound OAuth reverse proxy)
  tools/environments/docker.py        — CA mount, HTTPS_PROXY, CA-bundle
                                         env vars, --add-host wiring

Hermetic tests cover the full lifecycle: token mint, mapping discovery,
config + mappings I/O, install pipeline (HTTP + tar + checksum all mocked),
subprocess lifecycle (Popen mocked), Docker backend arg builder.

A live E2E test (gated on HERMES_RUN_E2E=1) downloads the real iron-proxy
binary, spawns it, routes a curl request through it against a local fake
upstream, and verifies the Authorization header was swapped from the proxy
token to the real secret value (and the proxy token did NOT leak through
to upstream).

Failures (binary missing, port collision, bad token) never block agent
startup — they emit a warning and continue.  The Docker backend refuses to
start a sandbox when proxy.enabled=true but the daemon is dead, unless
proxy.enforce_on_docker is explicitly set to false.

Docs: website/docs/user-guide/egress/{index,iron-proxy}.md
Tests: tests/test_iron_proxy.py (35), tests/test_iron_proxy_e2e.py (1)
2026-05-23 20:38:27 -07:00
414 changed files with 18083 additions and 27100 deletions
+3 -2
View File
@@ -1,11 +1,12 @@
name: Contributor Attribution Check
on:
pull_request:
branches: [main]
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
permissions:
contents: read
+5 -4
View File
@@ -18,12 +18,13 @@ on:
- docker/**
- .hadolint.yaml
- .github/workflows/docker-lint.yml
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
paths:
- Dockerfile
- docker/**
- .hadolint.yaml
- .github/workflows/docker-lint.yml
permissions:
contents: read
+8 -5
View File
@@ -11,13 +11,16 @@ on:
- 'docker/**'
- '.github/workflows/docker-publish.yml'
- '.github/actions/hermes-smoke-test/**'
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
paths:
- '**/*.py'
- 'pyproject.toml'
- 'uv.lock'
- 'Dockerfile'
- 'docker/**'
- '.github/workflows/docker-publish.yml'
- '.github/actions/hermes-smoke-test/**'
release:
types: [published]
+7 -9
View File
@@ -1,12 +1,10 @@
name: Docs Site Checks
on:
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
paths:
- 'website/**'
- '.github/workflows/docs-site-checks.yml'
workflow_dispatch:
permissions:
@@ -16,9 +14,9 @@ jobs:
docs-site-checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
@@ -28,9 +26,9 @@ jobs:
run: npm ci
working-directory: website
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
python-version: '3.11'
- name: Install ascii-guard
run: python -m pip install ascii-guard==2.3.0 pyyaml==6.0.3
+2 -5
View File
@@ -14,9 +14,6 @@ name: History Check
# the PR head and main to be non-empty.
on:
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
@@ -27,9 +24,9 @@ jobs:
check-common-ancestor:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0 # full history both sides for merge-base
fetch-depth: 0 # full history both sides for merge-base
- name: Reject PRs with no common ancestor on main
run: |
+5 -4
View File
@@ -15,12 +15,12 @@ on:
- "**/*.md"
- "docs/**"
- "website/**"
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
paths-ignore:
- "**/*.md"
- "docs/**"
- "website/**"
permissions:
contents: read
@@ -154,6 +154,7 @@ jobs:
});
}
ruff-blocking:
# Enforce the rules in pyproject.toml [tool.ruff.lint.select]. Currently
# PLW1514 (unspecified-encoding) — catches bare ``open()`` /
+255
View File
@@ -0,0 +1,255 @@
name: Nix Lockfile Fix
on:
push:
branches: [main]
paths:
- 'package-lock.json'
- 'package.json'
- 'ui-tui/package.json'
- 'apps/desktop/package.json'
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to fix (leave empty to run on the selected branch)'
required: false
type: string
issue_comment:
types: [edited]
permissions:
contents: write
pull-requests: write
concurrency:
group: nix-lockfile-fix-${{ github.event.issue.number || github.event.inputs.pr_number || github.ref }}
cancel-in-progress: false
jobs:
# ── Auto-fix on main ───────────────────────────────────────────────
# Fires when a push to main touches package.json or package-lock.json.
# Runs fix-lockfiles and pushes the hash update commit directly to main
# so Nix builds never stay broken.
#
# Safety invariants:
# 1. The fix commit only touches nix/*.nix files, which are NOT in
# the paths filter above, so this cannot re-trigger itself.
# 2. An explicit file-whitelist check before commit aborts if
# fix-lockfiles ever modifies unexpected files.
# 3. Job-level concurrency with cancel-in-progress: true ensures
# back-to-back pushes collapse to the newest; ref: main checkout
# always operates on the latest branch state.
# 4. Uses a GitHub App token (not GITHUB_TOKEN) so the fix commit
# triggers downstream nix.yml verification.
auto-fix-main:
if: github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 25
concurrency:
group: auto-fix-main
cancel-in-progress: true
steps:
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@7bfa3a4717ef143a604ee0a99d859b8886a96d00 # v1.9.3
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: main
token: ${{ steps.app-token.outputs.token }}
- uses: ./.github/actions/nix-setup
with:
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
- name: Apply lockfile hashes
id: apply
run: nix run .#fix-lockfiles -- --apply
- name: Commit & push
if: steps.apply.outputs.changed == 'true'
shell: bash
run: |
set -euo pipefail
# Ensure only nix/lib.nix (home of the single npmDepsHash) was
# modified — prevents accidental self-triggering if fix-lockfiles
# ever touches package files.
unexpected="$(git diff --name-only | grep -Ev '^nix/lib\.nix$' || true)"
if [ -n "$unexpected" ]; then
echo "::error::Unexpected modified files: $unexpected"
exit 1
fi
# Record the base SHA before committing — used to detect package
# file changes if we need to rebase after a non-fast-forward push.
BASE_SHA="$(git rev-parse HEAD)"
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git add nix/lib.nix
git commit -m "fix(nix): auto-refresh npm lockfile hashes" \
-m "Source: $GITHUB_SHA" \
-m "Run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
# Retry push with rebase in case main advanced with an unrelated
# commit during the nix build. Without this, a non-fast-forward
# rejection silently loses the fix. If package files changed during
# the rebase, abort — a fresh auto-fix run will handle the new state.
for attempt in 1 2 3; do
if git push origin HEAD:main; then
exit 0
fi
echo "::warning::Push attempt $attempt failed (non-fast-forward?), rebasing…"
git fetch origin main
# If package files changed between our base and the new main,
# our computed hashes are stale. Abort and let the next triggered
# run recompute from the correct package-lock state.
pkg_changed="$(git diff --name-only "$BASE_SHA"..origin/main -- \
'package-lock.json' 'package.json' \
'ui-tui/package.json' 'apps/desktop/package.json' || true)"
if [ -n "$pkg_changed" ]; then
echo "::warning::Package files changed since hash computation — aborting; a fresh run will recompute"
exit 0
fi
git rebase origin/main
done
echo "::error::Failed to push after 3 rebase attempts"
exit 1
# ── PR fix (manual / checkbox) ─────────────────────────────────────
# Existing behavior: run on manual dispatch OR when a task-list
# checkbox in the sticky lockfile-check comment flips from [ ] to [x].
fix:
if: |
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'issue_comment'
&& github.event.issue.pull_request != null
&& contains(github.event.comment.body, '[x] **Apply lockfile fix**')
&& !contains(github.event.changes.body.from, '[x] **Apply lockfile fix**'))
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- name: Authorize & resolve PR
id: resolve
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
// 1. Verify the actor has write access — applies to both checkbox
// clicks and manual dispatch.
const { data: perm } =
await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: context.actor,
});
if (!['admin', 'write', 'maintain'].includes(perm.permission)) {
core.setFailed(
`${context.actor} lacks write access (has: ${perm.permission})`
);
return;
}
// 2. Resolve which ref to check out.
let prNumber = '';
if (context.eventName === 'issue_comment') {
prNumber = String(context.payload.issue.number);
} else if (context.eventName === 'workflow_dispatch') {
prNumber = context.payload.inputs.pr_number || '';
}
if (!prNumber) {
core.setOutput('ref', context.ref.replace(/^refs\/heads\//, ''));
core.setOutput('repo', context.repo.repo);
core.setOutput('owner', context.repo.owner);
core.setOutput('pr', '');
return;
}
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: Number(prNumber),
});
core.setOutput('ref', pr.head.ref);
core.setOutput('repo', pr.head.repo.name);
core.setOutput('owner', pr.head.repo.owner.login);
core.setOutput('pr', String(pr.number));
# Wipe the sticky lockfile-check comment to a "running" state as soon
# as the job is authorized, so the user sees their click was picked up
# before the ~minute of nix build work.
- name: Mark sticky as running
if: steps.resolve.outputs.pr != ''
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
with:
header: nix-lockfile-check
number: ${{ steps.resolve.outputs.pr }}
message: |
### 🔄 Applying lockfile fix…
Triggered by @${{ github.actor }} — [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: ${{ steps.resolve.outputs.owner }}/${{ steps.resolve.outputs.repo }}
ref: ${{ steps.resolve.outputs.ref }}
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- uses: ./.github/actions/nix-setup
with:
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
- name: Apply lockfile hashes
id: apply
run: nix run .#fix-lockfiles
- name: Commit & push
if: steps.apply.outputs.changed == 'true'
shell: bash
run: |
set -euo pipefail
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git add nix/lib.nix
git commit -m "fix(nix): refresh npm lockfile hashes"
git push
- name: Update sticky (applied)
if: steps.apply.outputs.changed == 'true' && steps.resolve.outputs.pr != ''
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
with:
header: nix-lockfile-check
number: ${{ steps.resolve.outputs.pr }}
message: |
### ✅ Lockfile fix applied
Pushed a commit refreshing the npm lockfile hashes — [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
- name: Update sticky (already current)
if: steps.apply.outputs.changed == 'false' && steps.resolve.outputs.pr != ''
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
with:
header: nix-lockfile-check
number: ${{ steps.resolve.outputs.pr }}
message: |
### ✅ Lockfile hashes already current
Nothing to commit — [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
- name: Update sticky (failed)
if: failure() && steps.resolve.outputs.pr != ''
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
with:
header: nix-lockfile-check
number: ${{ steps.resolve.outputs.pr }}
message: |
### ❌ Lockfile fix failed
See the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for logs.
+105
View File
@@ -0,0 +1,105 @@
name: Nix
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
pull-requests: write
concurrency:
group: nix-${{ github.ref }}
cancel-in-progress: true
jobs:
nix:
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 30
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/actions/nix-setup
with:
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
- name: Resolve head SHA
if: github.event_name == 'pull_request'
id: sha
shell: bash
run: |
FULL="${{ github.event.pull_request.head.sha || github.sha }}"
echo "full=$FULL" >> "$GITHUB_OUTPUT"
echo "short=${FULL:0:7}" >> "$GITHUB_OUTPUT"
- name: Check flake
id: flake
continue-on-error: true
run: nix flake check --print-build-logs
# When the flake check fails, run a targeted diagnostic to see if
# the failure is specifically a stale npm lockfile hash in one of the
# known npm subpackages (tui / web). This avoids surfacing a generic
# "build failed" message when the fix is a single known command.
- name: Diagnose npm lockfile hashes
id: hash_check
if: steps.flake.outcome == 'failure' && runner.os == 'Linux'
continue-on-error: true
env:
LINK_SHA: ${{ steps.sha.outputs.full }}
run: nix run .#fix-lockfiles -- --check
# If fix-lockfiles itself crashes (infrastructure blip, cache throttle,
# etc.) it won't set stale=true/false. Treat that as a distinct failure
# mode rather than silently ignoring it.
- name: Fail if hash check crashed without reporting
if: steps.hash_check.outcome == 'failure' && steps.hash_check.outputs.stale != 'true' && steps.hash_check.outputs.stale != 'false'
run: |
echo "::error::fix-lockfiles exited without reporting stale status — likely an infrastructure or script failure"
exit 1
- name: Post sticky PR comment (stale hashes)
if: steps.hash_check.outputs.stale == 'true' && github.event_name == 'pull_request'
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
with:
header: nix-lockfile-check
message: |
### ⚠️ npm lockfile hash out of date
Checked against commit [`${{ steps.sha.outputs.short }}`](${{ github.server_url }}/${{ github.repository }}/commit/${{ steps.sha.outputs.full }}) (PR head at check time).
The `hash = "sha256-..."` line in these nix files no longer matches the committed `package-lock.json`:
${{ steps.hash_check.outputs.report }}
#### Apply the fix
- [ ] **Apply lockfile fix** — tick to push a commit with the correct hashes to this PR branch
- Or [run the Nix Lockfile Fix workflow](${{ github.server_url }}/${{ github.repository }}/actions/workflows/nix-lockfile-fix.yml) manually (pass PR `#${{ github.event.pull_request.number }}`)
- Or locally: `nix run .#fix-lockfiles` and commit the diff
# Clear the sticky comment when either the flake check passed outright (no
# hash check needed) or the hash check explicitly returned stale=false
# (check failed for a non-hash reason).
- name: Clear sticky PR comment (resolved)
if: |
github.event_name == 'pull_request' &&
(steps.hash_check.outputs.stale == 'false' ||
steps.flake.outcome == 'success')
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
with:
header: nix-lockfile-check
delete: true
- name: Final fail if flake check failed
if: steps.flake.outcome == 'failure'
run: |
if [ "${{ steps.hash_check.outputs.stale }}" == "true" ]; then
echo "::error::Nix build failed due to stale npm lockfile hash. Run: nix run .#fix-lockfiles"
else
echo "::error::Nix flake check failed. See logs above."
fi
exit 1
+16 -10
View File
@@ -20,23 +20,29 @@ name: OSV-Scanner
# vulnerabilities in pinned deps that we may need to patch deliberately.
on:
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
paths:
- 'uv.lock'
- 'pyproject.toml'
- 'package.json'
- 'package-lock.json'
- 'ui-tui/package.json'
- 'website/package.json'
- 'website/package-lock.json'
- '.github/workflows/osv-scanner.yml'
push:
branches: [main]
paths:
- "uv.lock"
- "pyproject.toml"
- "package.json"
- "package-lock.json"
- "website/package-lock.json"
- 'uv.lock'
- 'pyproject.toml'
- 'package.json'
- 'package-lock.json'
- 'website/package-lock.json'
schedule:
# Weekly scan against main — catches CVEs published after merge for
# deps that haven't changed since.
- cron: "0 9 * * 1"
- cron: '0 9 * * 1'
workflow_dispatch:
permissions:
@@ -48,7 +54,7 @@ permissions:
jobs:
scan:
name: Scan lockfiles
uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8
uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8
with:
# Scan explicit lockfiles rather than recursing, so we only look at
# the three sources of truth and skip vendored / test / worktree dirs.
+6 -6
View File
@@ -1,11 +1,11 @@
name: Supply Chain Audit
on:
pull_request:
types: [opened, synchronize, reopened]
# No paths filter — the jobs must always run so required checks
# report a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
types: [opened, synchronize, reopened]
permissions:
pull-requests: write
@@ -32,7 +32,7 @@ jobs:
# True when the curated MCP catalog / bundled MCP manifests changed.
mcp_catalog: ${{ steps.filter.outputs.mcp_catalog }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Check for relevant file changes
@@ -72,7 +72,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
@@ -207,7 +207,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
@@ -286,7 +286,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
+4 -4
View File
@@ -6,11 +6,11 @@ on:
paths-ignore:
- "**/*.md"
- "docs/**"
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
paths-ignore:
- "**/*.md"
- "docs/**"
permissions:
contents: read
@@ -219,4 +219,4 @@ jobs:
env:
OPENROUTER_API_KEY: ""
OPENAI_API_KEY: ""
NOUS_API_KEY: ""
NOUS_API_KEY: ""
-20
View File
@@ -4,9 +4,6 @@ name: Typecheck
on:
push:
branches: [main]
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
@@ -26,20 +23,3 @@ jobs:
cache: npm
- run: npm ci
- run: npm run --prefix ${{ matrix.package }} typecheck
# Production build of the desktop renderer. `typecheck` runs `tsc` only,
# which does NOT exercise Vite/Rolldown module resolution — so an
# unresolvable package export (e.g. a transitive @assistant-ui/tap that no
# longer exports "./react-shim") slips past typecheck and only explodes when
# users build apps/desktop from source on install/update. Run the real
# `vite build` here so that class of break fails in CI instead.
desktop-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run --prefix apps/desktop build
+9 -9
View File
@@ -47,15 +47,15 @@ on:
push:
branches: [main]
paths:
- "pyproject.toml"
- "uv.lock"
- ".github/workflows/uv-lockfile-check.yml"
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
- 'pyproject.toml'
- 'uv.lock'
- '.github/workflows/uv-lockfile-check.yml'
pull_request:
branches: [main]
paths:
- 'pyproject.toml'
- 'uv.lock'
- '.github/workflows/uv-lockfile-check.yml'
permissions:
contents: read
@@ -71,10 +71,10 @@ jobs:
timeout-minutes: 5
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install uv
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
# `uv lock --check` re-resolves the project from pyproject.toml and
# compares the result to uv.lock, exiting non-zero if they disagree.
+6 -44
View File
@@ -78,41 +78,7 @@ This isn't a quality bar — it's a coupling-and-maintenance decision. Memory pr
| **uv** | Fast Python package manager ([install](https://docs.astral.sh/uv/)) |
| **Node.js 20+** | Optional — needed for browser tools and WhatsApp bridge (matches root `package.json` engines) |
### Install with the standard installer
For most contributors, the best development bootstrap is the same path users
take: run the standard installer, then work inside the repository it cloned.
The installer creates the Hermes venv, wires the `hermes` command, stamps the
install method for `hermes update`, and clones the full git project into
`$HERMES_HOME/hermes-agent` (usually `~/.hermes/hermes-agent`). That keeps your
development environment on the same layout the CLI, updater, lazy dependency
installer, gateway, and docs assume.
```bash
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
cd "${HERMES_HOME:-$HOME/.hermes}/hermes-agent"
# Add dev/test extras on top of the standard install.
uv pip install -e ".[all,dev]"
# Optional: browser tools / docs site dependencies.
npm install
```
After that, create branches and run tests from that checkout:
```bash
git checkout -b fix/description
scripts/run_tests.sh
```
### Manual clone fallback
Use this only if you intentionally do not want Hermes' managed install layout
(for example, a throwaway clone inside a container or CI job). If you install
this way, make sure you run the `hermes` entrypoint from this venv; running the
system `python3 -m hermes_cli.main` can pick up unrelated system Python
packages.
### Clone and install
```bash
git clone https://github.com/NousResearch/hermes-agent.git
@@ -143,17 +109,13 @@ echo "OPENROUTER_API_KEY=***" >> ~/.hermes/.env
### Run
```bash
# The standard installer already put `hermes` on PATH.
hermes doctor
hermes chat -q "Hello"
```
If you used the manual clone fallback, run `./hermes` from the checkout or
symlink this clone's venv explicitly:
```bash
# Symlink for global access
mkdir -p ~/.local/bin
ln -sf "$(pwd)/venv/bin/hermes" ~/.local/bin/hermes
# Verify
hermes doctor
hermes chat -q "Hello"
```
### Run tests
+6 -10
View File
@@ -181,20 +181,16 @@ See `hermes claw migrate --help` for all options, or use the `openclaw-migration
We welcome contributions! See the [Contributing Guide](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) for development setup, code style, and PR process.
Quick start for contributors — use the standard installer, then work from the
full git checkout it creates at `$HERMES_HOME/hermes-agent` (usually
`~/.hermes/hermes-agent`). This matches the layout used by `hermes update`, the
managed venv, lazy dependencies, gateway, and docs tooling.
Quick start for contributors — clone and go with `setup-hermes.sh`:
```bash
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
cd "${HERMES_HOME:-$HOME/.hermes}/hermes-agent"
uv pip install -e ".[all,dev]"
scripts/run_tests.sh
git clone https://github.com/NousResearch/hermes-agent.git
cd hermes-agent
./setup-hermes.sh # installs uv, creates venv, installs .[all], symlinks ~/.local/bin/hermes
./hermes # auto-detects the venv, no need to `source` first
```
Manual clone fallback (for throwaway clones/CI where you intentionally do not
want the managed install layout):
Manual path (equivalent to the above):
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
+6 -8
View File
@@ -164,18 +164,16 @@ hermes claw migrate --overwrite # 覆盖已有冲突
欢迎贡献!请参阅 [贡献指南](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) 了解开发设置、代码风格和 PR 流程。
贡献者快速开始——使用标准安装器,然后在它创建的完整 git checkout 中开发
`$HERMES_HOME/hermes-agent`(通常是 `~/.hermes/hermes-agent`)。这会匹配
`hermes update`、托管 venv、lazy dependencies、gateway 和 docs tooling 使用的布局。
贡献者快速开始——克隆并使用 `setup-hermes.sh`
```bash
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
cd "${HERMES_HOME:-$HOME/.hermes}/hermes-agent"
uv pip install -e ".[all,dev]"
scripts/run_tests.sh
git clone https://github.com/NousResearch/hermes-agent.git
cd hermes-agent
./setup-hermes.sh # 安装 uv、创建 venv、安装 .[all]、创建符号链接 ~/.local/bin/hermes
./hermes # 自动检测 venv,无需先 source
```
手动克隆备用路径(用于一次性 clone / CI,或你明确不想使用 managed install layout 时):
手动安装(等效于上述命令):
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
+1 -4
View File
@@ -27,7 +27,7 @@ import threading
import time
import uuid
from datetime import datetime
from typing import Any, Callable, Dict, List, Optional
from typing import Any, Dict, List, Optional
from urllib.parse import urlparse, parse_qs, urlunparse
from agent.context_compressor import ContextCompressor
@@ -195,7 +195,6 @@ def init_agent(
status_callback: callable = None,
notice_callback: callable = None,
notice_clear_callback: callable = None,
event_callback: Optional[Callable[[str, dict], None]] = None,
max_tokens: int = None,
reasoning_config: Dict[str, Any] = None,
service_tier: str = None,
@@ -300,7 +299,6 @@ def init_agent(
# would mangle the escape sequences. None = use builtins.print.
agent._print_fn = None
agent.background_review_callback = None # Optional sync callback for gateway delivery
agent.memory_notifications = "on" # Memory update notifications: "off", "on", "verbose"
agent.skip_context_files = skip_context_files
agent.load_soul_identity = load_soul_identity
agent.pass_session_id = pass_session_id
@@ -427,7 +425,6 @@ def init_agent(
agent.status_callback = status_callback
agent.notice_callback = notice_callback
agent.notice_clear_callback = notice_clear_callback
agent.event_callback = event_callback
agent.tool_gen_callback = tool_gen_callback
+2 -13
View File
@@ -1217,23 +1217,12 @@ def dump_api_request_debug(
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
dump_file = agent.logs_dir / f"request_dump_{agent.session_id}_{timestamp}.json"
# Redact secrets before persisting/printing. This dump captures the
# full request body (system prompt, tool defs, context-embedded
# values), and this path fires unconditionally on API errors — so it
# otherwise lands any context-embedded secret in cleartext on disk.
# Run the serialized dump through the same scrubber used for logs/tool
# output, then hand the resulting payload back to the shared atomic
# JSON writer so request dumps keep the same write semantics as before.
from agent.redact import redact_sensitive_text
_serialized = json.dumps(dump_payload, ensure_ascii=False, indent=2, default=str)
_redacted_payload = json.loads(redact_sensitive_text(_serialized, force=True))
atomic_json_write(dump_file, _redacted_payload, default=str)
atomic_json_write(dump_file, dump_payload, default=str)
agent._vprint(f"{agent.log_prefix}🧾 Request debug dump written to: {dump_file}")
if env_var_enabled("HERMES_DUMP_REQUEST_STDOUT"):
print(json.dumps(_redacted_payload, ensure_ascii=False, indent=2, default=str))
print(json.dumps(dump_payload, ensure_ascii=False, indent=2, default=str))
return dump_file
except Exception as dump_error:
+27 -159
View File
@@ -3079,20 +3079,23 @@ def _try_configured_fallback_chain(
if not fb_provider or fb_provider.lower() == skip:
continue
fb_model = str(entry.get("model", "")).strip() or None
fb_base_url = str(entry.get("base_url", "")).strip() or None
fb_api_key = str(entry.get("api_key", "")).strip() or None
label = f"fallback_chain[{i}]({fb_provider})"
try:
fb_client, resolved_model = _resolve_fallback_entry(entry)
fb_client = _resolve_single_provider(
fb_provider, fb_model, fb_base_url, fb_api_key)
except Exception:
fb_client, resolved_model = None, None
fb_client = None
if fb_client is not None:
logger.info(
"Auxiliary %s: %s on %s — configured fallback to %s (%s)",
task, reason, failed_provider, label, resolved_model or fb_model or "default",
task, reason, failed_provider, label, fb_model or "default",
)
return fb_client, resolved_model or fb_model, label
return fb_client, fb_model, label
tried.append(label)
if tried:
@@ -3103,103 +3106,6 @@ def _try_configured_fallback_chain(
return None, None, ""
def _fallback_entry_api_key(entry: Dict[str, Any]) -> Optional[str]:
"""Resolve inline or env-backed API key from a fallback-chain entry."""
explicit = str(entry.get("api_key") or "").strip()
if explicit:
return explicit
key_env = str(entry.get("key_env") or entry.get("api_key_env") or "").strip()
if key_env:
return os.getenv(key_env, "").strip() or None
return None
def _resolve_fallback_entry(entry: Dict[str, Any]) -> Tuple[Optional[Any], Optional[str]]:
"""Resolve one fallback entry through the central provider router."""
provider = str(entry.get("provider") or "").strip()
model = str(entry.get("model") or "").strip() or None
if not provider or not model:
return None, None
base_url = str(entry.get("base_url") or "").strip() or None
api_key = _fallback_entry_api_key(entry)
api_mode = str(entry.get("api_mode") or entry.get("transport") or "").strip() or None
return resolve_provider_client(
provider,
model=model,
explicit_base_url=base_url,
explicit_api_key=api_key,
api_mode=api_mode,
)
def _try_main_fallback_chain(
task: Optional[str],
failed_provider: str = "",
reason: str = "error",
) -> Tuple[Optional[Any], Optional[str], str]:
"""Try the top-level main-agent fallback chain for an auxiliary call.
``provider: auto`` auxiliary tasks should respect the user's declared
main fallback policy before dropping into Hermes' built-in discovery
chain. The top-level chain is read through ``get_fallback_chain`` so
both modern ``fallback_providers`` and legacy ``fallback_model`` entries
participate in the same order as the main agent.
"""
try:
from hermes_cli.config import load_config
from hermes_cli.fallback_config import get_fallback_chain
chain = get_fallback_chain(load_config())
except Exception as exc:
logger.debug("Auxiliary %s: could not load main fallback chain: %s", task or "call", exc)
return None, None, ""
if not chain:
return None, None, ""
failed_norm = (failed_provider or "").strip().lower()
main_norm = (_read_main_provider() or "").strip().lower()
skip = {p for p in (failed_norm, main_norm, "auto") if p}
tried: List[str] = []
for i, entry in enumerate(chain):
if not isinstance(entry, dict):
continue
fb_provider = str(entry.get("provider") or "").strip()
fb_model = str(entry.get("model") or "").strip()
if not fb_provider or not fb_model:
continue
fb_norm = fb_provider.lower()
label = f"fallback_providers[{i}]({fb_provider})"
if fb_norm in skip:
tried.append(f"{label} (skipped)")
continue
if _is_provider_unhealthy(fb_norm):
_log_skip_unhealthy(fb_norm, task)
tried.append(f"{label} (unhealthy)")
continue
try:
fb_client, resolved_model = _resolve_fallback_entry(entry)
except Exception as exc:
logger.debug("Auxiliary %s: main fallback %s failed to resolve: %s", task or "call", label, exc)
fb_client, resolved_model = None, None
if fb_client is not None:
logger.info(
"Auxiliary %s: %s on %s — main fallback chain to %s (%s)",
task or "call", reason, failed_provider or "auto", label,
resolved_model or fb_model,
)
return fb_client, resolved_model or fb_model, fb_provider
tried.append(label)
if tried:
logger.debug(
"Auxiliary %s: main fallback chain exhausted (tried: %s)",
task or "call", ", ".join(tried),
)
return None, None, ""
def _resolve_single_provider(
provider: str,
model: Optional[str] = None,
@@ -3210,19 +3116,16 @@ def _resolve_single_provider(
Uses the existing provider resolution infrastructure where possible.
"""
# Reuse resolve_provider_client which handles provider→client mapping.
# Reuse resolve_provider_client which handles provider→client mapping
client, resolved_model = resolve_provider_client(
provider=provider,
model=model,
explicit_base_url=base_url,
explicit_api_key=api_key,
base_url=base_url,
api_key=api_key,
)
return client
def _resolve_auto(
main_runtime: Optional[Dict[str, Any]] = None,
task: Optional[str] = None,
) -> Tuple[Optional[OpenAI], Optional[str]]:
def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Optional[OpenAI], Optional[str]]:
"""Full auto-detection chain.
Priority:
@@ -3320,22 +3223,7 @@ def _resolve_auto(
main_provider, resolved or main_model)
return client, resolved or main_model
# ── Step 2: user-configured fallback policy ─────────────────────────
# In auto mode, respect the task-specific fallback chain first, then the
# main agent's top-level fallback_providers/fallback_model chain. The
# hardcoded provider discovery chain below is only the convenience default
# for users who have not declared a fallback policy.
if task:
fb_client, fb_model, _fb_label = _try_configured_fallback_chain(
task, main_provider or "auto", reason="main provider unavailable")
if fb_client is not None:
return fb_client, fb_model
fb_client, fb_model, _fb_label = _try_main_fallback_chain(
task, main_provider or "auto", reason="main provider unavailable")
if fb_client is not None:
return fb_client, fb_model
# ── Step 3: aggregator / fallback chain ──────────────────────────────
# ── Step 2: aggregator / fallback chain ──────────────────────────────
tried = []
for label, try_fn in _get_provider_chain():
if _is_provider_unhealthy(label):
@@ -3456,7 +3344,6 @@ def resolve_provider_client(
api_mode: str = None,
main_runtime: Optional[Dict[str, Any]] = None,
is_vision: bool = False,
task: Optional[str] = None,
) -> Tuple[Optional[Any], Optional[str]]:
"""Central router: given a provider name and optional model, return a
configured client with the correct auth, base URL, and API format.
@@ -3577,7 +3464,7 @@ def resolve_provider_client(
# ── Auto: try all providers in priority order ────────────────────
if provider == "auto":
client, resolved = _resolve_auto(main_runtime=main_runtime, task=task)
client, resolved = _resolve_auto(main_runtime=main_runtime)
if client is None:
return None, None
# When auto-detection lands on a non-OpenRouter provider (e.g. a
@@ -4470,16 +4357,11 @@ def _client_cache_key(
api_mode: Optional[str] = None,
main_runtime: Optional[Dict[str, Any]] = None,
is_vision: bool = False,
task: Optional[str] = None,
) -> tuple:
runtime = _normalize_main_runtime(main_runtime)
runtime_key = tuple(runtime.get(field, "") for field in _MAIN_RUNTIME_FIELDS) if provider == "auto" else ()
# `auto` can now resolve through task-specific or main fallback policy,
# so the task participates in the cache key. Non-auto providers keep the
# old cache shape because the explicit provider/model tuple is sufficient.
task_key = (task or "") if provider == "auto" else ""
pool_hint = _pool_cache_hint(provider, main_runtime=main_runtime)
return (provider, async_mode, base_url or "", api_key or "", api_mode or "", runtime_key, is_vision, task_key, pool_hint)
return (provider, async_mode, base_url or "", api_key or "", api_mode or "", runtime_key, is_vision, pool_hint)
def _store_cached_client(cache_key: tuple, client: Any, default_model: Optional[str], *, bound_loop: Any = None) -> None:
@@ -4672,7 +4554,6 @@ def _get_cached_client(
api_mode: str = None,
main_runtime: Optional[Dict[str, Any]] = None,
is_vision: bool = False,
task: Optional[str] = None,
) -> Tuple[Optional[Any], Optional[str]]:
"""Get or create a cached client for the given provider.
@@ -4710,7 +4591,6 @@ def _get_cached_client(
api_mode=api_mode,
main_runtime=main_runtime,
is_vision=is_vision,
task=task,
)
with _client_cache_lock:
if cache_key in _client_cache:
@@ -4755,7 +4635,6 @@ def _get_cached_client(
api_mode=api_mode,
main_runtime=runtime,
is_vision=is_vision,
task=task,
)
if client is not None:
# For async clients, remember which loop they were created on so we
@@ -5261,7 +5140,7 @@ def call_llm(
if not resolved_base_url:
logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain",
task or "call", resolved_provider)
client, final_model = _get_cached_client("auto", main_runtime=main_runtime, task=task)
client, final_model = _get_cached_client("auto", main_runtime=main_runtime)
if client is None:
raise RuntimeError(
f"No LLM provider configured for task={task} provider={resolved_provider}. "
@@ -5587,19 +5466,14 @@ def call_llm(
# Fallback order (#26882, #26803):
# 1. User-configured fallback_chain (per-task) if set
# 2. For auto: top-level main fallback_providers/fallback_model
# 3. For auto: built-in auxiliary discovery chain
# 4. For explicit aux providers: main agent model safety net
# 2. Main agent model (last-resort safety net)
# For auto users (no explicit aux provider), use the full
# auto-detection chain instead — its Step 1 IS the main agent
# model, so users on `auto` already get main-model fallback.
fb_client, fb_model, fb_label = (None, None, "")
if is_auto:
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
task, resolved_provider or "auto", reason=reason)
if fb_client is None:
fb_client, fb_model, fb_label = _try_main_fallback_chain(
task, resolved_provider or "auto", reason=reason)
if fb_client is None:
fb_client, fb_model, fb_label = _try_payment_fallback(
resolved_provider, task, reason=reason)
fb_client, fb_model, fb_label = _try_payment_fallback(
resolved_provider, task, reason=reason)
else:
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
task, resolved_provider or "auto", reason=reason)
@@ -5762,7 +5636,7 @@ async def async_call_llm(
if not resolved_base_url:
logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain",
task or "call", resolved_provider)
client, final_model = _get_cached_client("auto", async_mode=True, main_runtime=main_runtime, task=task)
client, final_model = _get_cached_client("auto", async_mode=True)
if client is None:
raise RuntimeError(
f"No LLM provider configured for task={task} provider={resolved_provider}. "
@@ -6030,19 +5904,13 @@ async def async_call_llm(
# Fallback order (#26882, #26803):
# 1. User-configured fallback_chain (per-task) if set
# 2. For auto: top-level main fallback_providers/fallback_model
# 3. For auto: built-in auxiliary discovery chain
# 4. For explicit aux providers: main agent model safety net
# 2. Main agent model (last-resort safety net)
# Auto users get the full auto-detection chain instead — its
# Step 1 IS the main agent model.
fb_client, fb_model, fb_label = (None, None, "")
if is_auto:
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
task, resolved_provider or "auto", reason=reason)
if fb_client is None:
fb_client, fb_model, fb_label = _try_main_fallback_chain(
task, resolved_provider or "auto", reason=reason)
if fb_client is None:
fb_client, fb_model, fb_label = _try_payment_fallback(
resolved_provider, task, reason=reason)
fb_client, fb_model, fb_label = _try_payment_fallback(
resolved_provider, task, reason=reason)
else:
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
task, resolved_provider or "auto", reason=reason)
+19 -121
View File
@@ -237,25 +237,18 @@ _COMBINED_REVIEW_PROMPT = (
def summarize_background_review_actions(
review_messages: List[Dict],
prior_snapshot: List[Dict],
notification_mode: str = "on",
) -> List[str]:
"""Build the human-facing action summary for a background review pass.
Walks the review agent's session messages and collects successful memory
and skill-management actions to surface to the user. Tool messages already
present in ``prior_snapshot`` are skipped so stale inherited results are
not re-surfaced as fresh background work (issue #14944).
Walks the review agent's session messages and collects "successful tool
action" descriptions to surface to the user (e.g. "Memory updated").
Tool messages already present in ``prior_snapshot`` are skipped so we
don't re-surface stale results from the prior conversation that the
review agent inherited via ``conversation_history`` (issue #14944).
``notification_mode`` controls display detail:
- ``off``: return no actions.
- ``on``: generic "Memory updated"/tool messages.
- ``verbose``: include compact content previews from tool-call arguments.
Matching is by ``tool_call_id`` when available, with a content-equality
fallback for tool messages that lack one.
"""
mode = str(notification_mode or "on").lower()
if mode == "off":
return []
verbose = mode == "verbose"
existing_tool_call_ids = set()
existing_tool_contents = set()
for prior in prior_snapshot or []:
@@ -269,42 +262,6 @@ def summarize_background_review_actions(
if isinstance(content, str):
existing_tool_contents.add(content)
# Map review-agent tool results back to the calls that produced them. The
# result JSON only says "Entry added"; the call arguments contain action,
# target, and content previews. Restricting to notify_tools also prevents
# helper tools from surfacing as memory work just because they succeeded.
notify_tools = {"memory", "skill_manage"}
all_tool_call_ids: set = set()
call_details: dict = {}
for msg in review_messages or []:
if not isinstance(msg, dict) or msg.get("role") != "assistant":
continue
for tc in msg.get("tool_calls", []) or []:
if not isinstance(tc, dict):
continue
fn = tc.get("function", {}) or {}
fn_name = fn.get("name", "")
tcid = tc.get("id")
if tcid:
all_tool_call_ids.add(tcid)
if fn_name not in notify_tools:
continue
try:
args = json.loads(fn.get("arguments", "{}"))
except (json.JSONDecodeError, TypeError):
args = {}
if tcid:
call_details[tcid] = {
"tool": fn_name,
"action": args.get("action", "?"),
"target": args.get("target", "memory"),
"content": args.get("content", ""),
"old_text": args.get("old_text", ""),
"name": args.get("name", ""),
"old_string": args.get("old_string", ""),
"new_string": args.get("new_string", ""),
}
actions: List[str] = []
for msg in review_messages or []:
if not isinstance(msg, dict) or msg.get("role") != "tool":
@@ -316,8 +273,6 @@ def summarize_background_review_actions(
content_str = msg.get("content")
if isinstance(content_str, str) and content_str in existing_tool_contents:
continue
if tcid and all_tool_call_ids and tcid not in call_details:
continue
try:
data = json.loads(msg.get("content", "{}"))
except (json.JSONDecodeError, TypeError):
@@ -325,75 +280,19 @@ def summarize_background_review_actions(
if not isinstance(data, dict) or not data.get("success"):
continue
message = data.get("message", "")
detail = call_details.get(tcid, {})
target = data.get("target", "") or detail.get("target", "")
is_skill = detail.get("tool") == "skill_manage"
message_lower = message.lower()
if not verbose:
if "created" in message_lower:
actions.append(message)
continue
if "updated" in message_lower:
actions.append(message)
continue
if is_skill and "patched" in message_lower:
actions.append(message)
continue
if is_skill:
label = "Skill"
elif target:
target = data.get("target", "")
if "created" in message.lower():
actions.append(message)
elif "updated" in message.lower():
actions.append(message)
elif "added" in message.lower() or (target and "add" in message.lower()):
label = "Memory" if target == "memory" else "User profile" if target == "user" else target
actions.append(f"{label} updated")
elif "Entry added" in message:
label = "Memory" if target == "memory" else "User profile" if target == "user" else target
actions.append(f"{label} updated")
elif "removed" in message.lower() or "replaced" in message.lower():
label = "Memory" if target == "memory" else "User profile" if target == "user" else target
else:
continue
if verbose:
action = detail.get("action", "")
content = detail.get("content", "")
old_text = detail.get("old_text", "")
skill_name = detail.get("name", "")
max_preview = 120
if is_skill:
change = data.get("_change", {})
old_string = change.get("old", "") or detail.get("old_string", "")
new_string = change.get("new", "") or detail.get("new_string", "")
description = change.get("description", "")
if action == "patch" and (old_string or new_string):
old_preview = old_string[:80].replace("\n", " ") + (
"" if len(old_string) > 80 else ""
)
new_preview = new_string[:80].replace("\n", " ") + (
"" if len(new_string) > 80 else ""
)
actions.append(
f"📝 Skill '{skill_name}' patched: "
f"\"{old_preview}\"\"{new_preview}\""
)
elif action == "create" and description:
actions.append(f"📝 Skill '{skill_name}' created: {description}")
elif action == "edit" and description:
actions.append(f"📝 Skill '{skill_name}' rewritten: {description}")
else:
actions.append(f"📝 {message}" if message else f"Skill {action}")
elif action == "add" and content:
preview = content[:max_preview] + ("" if len(content) > max_preview else "")
actions.append(f"{label} {preview}")
elif action == "replace" and content:
preview = content[:max_preview] + ("" if len(content) > max_preview else "")
actions.append(f"{label} ✏️ {preview}")
elif action == "remove" and old_text:
preview = old_text[:60] + ("" if len(old_text) > 60 else "")
actions.append(f"{label} {preview}")
else:
actions.append(f"{label} updated")
elif (
"added" in message_lower
or "replaced" in message_lower
or "removed" in message_lower
or (target and "add" in message.lower())
or "Entry added" in message
):
actions.append(f"{label} updated")
return actions
@@ -623,7 +522,6 @@ def _run_review_in_thread(
actions = summarize_background_review_actions(
review_messages,
messages_snapshot,
notification_mode=getattr(agent, "memory_notifications", "on"),
)
if actions:
+2 -19
View File
@@ -58,34 +58,17 @@ _bedrock_runtime_client_cache: Dict[str, Any] = {}
_bedrock_control_client_cache: Dict[str, Any] = {}
_MIN_BOTO3_VERSION = (1, 34, 59)
def _require_boto3():
"""Import boto3, raising a clear error if not installed or too old."""
"""Import boto3, raising a clear error if not installed."""
try:
import boto3
return boto3
except ImportError:
raise ImportError(
"The 'boto3' package is required for the AWS Bedrock provider. "
"Install it with: pip install boto3\n"
"Or install Hermes with Bedrock support: pip install -e '.[bedrock]'"
)
# converse() / converse_stream() were added in boto3 1.34.59.
# When Hermes is installed editable into system Python, the system boto3
# (e.g. Ubuntu 24.04 ships 1.34.46) may take precedence over the venv
# version pinned in pyproject.toml.
try:
version = tuple(int(x) for x in boto3.__version__.split(".")[:3])
except (AttributeError, ValueError):
return boto3 # can't parse — don't block on version check
if version < _MIN_BOTO3_VERSION:
raise RuntimeError(
f"boto3 {boto3.__version__} does not support converse_stream "
f"(minimum 1.34.59 required). Upgrade with: "
f"pip install --upgrade boto3"
)
return boto3
def _get_bedrock_runtime_client(region: str):
-14
View File
@@ -603,20 +603,6 @@ def compress_context(
force=True,
)
# Emit session:compress event so hooks (e.g. MemPalace sync) can ingest
# the completed old session before its details are lost.
_old_sid_for_event = locals().get("old_session_id")
if getattr(agent, "event_callback", None):
try:
agent.event_callback("session:compress", {
"platform": agent.platform or "",
"session_id": agent.session_id,
"old_session_id": _old_sid_for_event or "",
"compression_count": agent.context_compressor.compression_count,
})
except Exception as e:
logger.debug("event_callback error on session:compress: %s", e)
# Keep the post-compression rough estimate for diagnostics, but do not
# treat it as provider-reported prompt usage. Schema-heavy rough estimates
# can remain above threshold even after the next real API request fits.
+1 -34
View File
@@ -300,20 +300,11 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
agent.session_id, exc,
)
if stored_prompt and _stored_prompt_matches_runtime(agent, stored_prompt):
if stored_prompt:
# Continuing session — reuse the exact system prompt from the
# previous turn so the Anthropic cache prefix matches.
agent._cached_system_prompt = stored_prompt
return
if stored_prompt:
stored_state = "stale_runtime"
logger.info(
"Stored system prompt for session %s has stale runtime identity; "
"rebuilding for model=%s provider=%s.",
agent.session_id,
getattr(agent, "model", "") or "",
getattr(agent, "provider", "") or "",
)
if conversation_history and stored_state in ("null", "empty"):
# Continuing session whose stored prompt is unusable. The
@@ -375,30 +366,6 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
)
def _stored_prompt_matches_runtime(agent, prompt: str) -> bool:
"""Return False when the persisted Model/Provider lines are stale."""
def line_value(label: str) -> str:
prefix = f"{label}:"
value = ""
for line in prompt.splitlines():
if line.startswith(prefix):
value = line[len(prefix):].strip()
return value
stored_model = line_value("Model")
current_model = str(getattr(agent, "model", "") or "").strip()
if stored_model and current_model and stored_model != current_model:
return False
stored_provider = line_value("Provider")
current_provider = str(getattr(agent, "provider", "") or "").strip()
if stored_provider and current_provider and stored_provider != current_provider:
return False
return True
def _get_continuation_prompt(is_partial_stub: bool, dropped_tools: Optional[List[str]] = None) -> str:
if is_partial_stub and dropped_tools:
tool_list = ", ".join(dropped_tools[:3])
+3 -3
View File
@@ -454,16 +454,16 @@ def _restore_cron_skill_links(snapshot_dir: Path) -> Dict[str, Any]:
report["attempted"] = True # we tried but there was nothing to do
return report
# Load and rewrite the live jobs under the scheduler's cross-process lock.
# Load and rewrite the live jobs under the scheduler's lock.
try:
from cron.jobs import load_jobs, save_jobs, _jobs_lock
from cron.jobs import load_jobs, save_jobs, _jobs_file_lock
except ImportError as e:
report["error"] = f"cron module unavailable: {e}"
return report
report["attempted"] = True
try:
with _jobs_lock():
with _jobs_file_lock:
live_jobs = load_jobs()
changed = False
+20 -45
View File
@@ -12,7 +12,6 @@ import time
from dataclasses import dataclass, field
from difflib import unified_diff
from pathlib import Path
from typing import Any
from utils import safe_json_loads
from agent.tool_result_classification import file_mutation_result_landed
@@ -169,27 +168,6 @@ def _oneline(text: str) -> str:
return " ".join(text.split())
def _truncate_preview(text: str, max_len: int | None) -> str:
if max_len and max_len > 0 and len(text) > max_len:
if max_len <= 3:
return "." * max_len
return text[:max_len - 3] + "..."
return text
def _delegate_task_goal_parts(tasks: Any, *, per_goal_len: int) -> tuple[int, list[str]]:
if not isinstance(tasks, list):
return 0, []
goals: list[str] = []
for task in tasks:
if not isinstance(task, dict):
continue
raw_goal = task.get("goal")
goal = "?" if raw_goal is None else _oneline(str(raw_goal))
goals.append(_truncate_preview(goal or "?", per_goal_len))
return len(goals), goals
def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -> str | None:
"""Build a short preview of a tool call's primary argument for display.
@@ -213,22 +191,6 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
"clarify": "question", "skill_manage": "name",
}
# delegate_task: show goal (single) or individual task goals (batch)
if tool_name == "delegate_task":
tasks = args.get("tasks")
if tasks and isinstance(tasks, list):
task_count, goals = _delegate_task_goal_parts(tasks, per_goal_len=40)
preview = (
f"{task_count} tasks: " + " | ".join(goals)
if goals else f"{len(tasks)} parallel tasks"
)
return _truncate_preview(preview, max_len)
goal = args.get("goal", "")
if goal is None:
return None
preview = _oneline(str(goal))
return _truncate_preview(preview, max_len) if preview else None
if tool_name == "process":
action = args.get("action", "")
sid = args.get("session_id", "")
@@ -896,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:
@@ -933,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":
@@ -1057,10 +1035,7 @@ def get_cute_tool_message(
if tool_name == "delegate_task":
tasks = args.get("tasks")
if tasks and isinstance(tasks, list):
task_count, goals = _delegate_task_goal_parts(tasks, per_goal_len=30)
detail = " | ".join(goals) if goals else "parallel"
count_label = task_count or len(tasks)
return _wrap(f"┊ 🔀 delegate {count_label}x: {_trunc(detail, 35)} {dur}")
return _wrap(f"┊ 🔀 delegate {len(tasks)} parallel tasks {dur}")
return _wrap(f"┊ 🔀 delegate {_trunc(args.get('goal', ''), 35)} {dur}")
preview = build_tool_preview(tool_name, args) or ""
+2 -33
View File
@@ -33,7 +33,6 @@ from concurrent.futures import ThreadPoolExecutor
from typing import Any, Dict, List, Optional
from agent.memory_provider import MemoryProvider
from agent.skill_commands import extract_user_instruction_from_skill_message
from tools.registry import tool_error
logger = logging.getLogger(__name__)
@@ -431,37 +430,16 @@ class MemoryManager:
# -- Prefetch / recall ---------------------------------------------------
@staticmethod
def _strip_skill_scaffolding(text: str) -> Optional[str]:
"""Return memory-worthy user text, or None to skip the turn.
When a user invokes a /skill or /bundle, Hermes expands the turn into
a model-facing message that embeds the entire skill body. Feeding that
verbatim to memory providers pollutes their stores/embeddings with
prompt scaffolding instead of what the user actually asked. We recover
just the user's instruction here, once, for every provider — so this
is fixed for the whole provider fan-out, not per backend.
- Non-skill messages pass through unchanged.
- Skill turns with a user instruction return that instruction.
- Bare skill invocations (no instruction) return None callers skip
the turn, since there is no user content worth remembering.
"""
return extract_user_instruction_from_skill_message(text)
def prefetch_all(self, query: str, *, session_id: str = "") -> str:
"""Collect prefetch context from all providers.
Returns merged context text labeled by provider. Empty providers
are skipped. Failures in one provider don't block others.
"""
clean_query = self._strip_skill_scaffolding(query)
if not clean_query:
return ""
parts = []
for provider in self._providers:
try:
result = provider.prefetch(clean_query, session_id=session_id)
result = provider.prefetch(query, session_id=session_id)
if result and result.strip():
parts.append(result)
except Exception as e:
@@ -482,14 +460,10 @@ class MemoryManager:
if not providers:
return
clean_query = self._strip_skill_scaffolding(query)
if not clean_query:
return
def _run() -> None:
for provider in providers:
try:
provider.queue_prefetch(clean_query, session_id=session_id)
provider.queue_prefetch(query, session_id=session_id)
except Exception as e:
logger.debug(
"Memory provider '%s' queue_prefetch failed (non-fatal): %s",
@@ -541,11 +515,6 @@ class MemoryManager:
if not providers:
return
clean_user_content = self._strip_skill_scaffolding(user_content)
if not clean_user_content:
return
user_content = clean_user_content
def _run() -> None:
for provider in providers:
try:
+1 -7
View File
@@ -261,13 +261,7 @@ DEFAULT_CONTEXT_LENGTHS = {
# https://platform.minimax.io/docs/api-reference/text-chat-openai
"minimax-m3": 1000000,
"minimax": 204800,
# GLM — GLM-5.2 ships with a 1M context window (verified empirically:
# needle-in-a-haystack retrieval at 789K prompt tokens succeeded with
# zero errors on api.z.ai/api/coding/paas/v4). Older GLM models
# (5, 5.1, 5-turbo) are ~202K. Longest-key-first substring matching
# ensures "glm-5.2" resolves to 1M while older variants still hit the
# generic 202K fallback.
"glm-5.2": 1_048_576,
# GLM
"glm": 202752,
# xAI Grok — xAI /v1/models does not return context_length metadata,
# so these hardcoded fallbacks prevent Hermes from probing-down to
-51
View File
@@ -1,51 +0,0 @@
"""Petdex pet engine — shared core for the CLI, TUI, and desktop surfaces.
Petdex (https://github.com/crafter-station/petdex) is a public gallery of
animated sprite "pets" for coding agents. Each pet is a ``pet.json`` plus a
``spritesheet.{webp,png}`` of 192×208 px cells. Current Codex/petdex sheets use
an 8-column × 9-row atlas; older Hermes/petdex sheets used an 8-row atlas.
Hermes infers the row taxonomy from the sheet and maps agent activity onto
idle/run/review/failed/wave/jump.
This package is the **single source of truth** for the feature so the base
CLI (Python) and TUI (Ink, via ``tui_gateway``) never duplicate the hard
parts:
- :mod:`agent.pet.constants` frame geometry + the :class:`PetState` enum.
- :mod:`agent.pet.state` map agent activity a :class:`PetState`.
- :mod:`agent.pet.manifest` fetch the public petdex manifest.
- :mod:`agent.pet.store` install / list / resolve pets on disk
(profile-aware via ``get_hermes_home()``).
- :mod:`agent.pet.render` decode a spritesheet and encode frames for a
terminal (kitty / iTerm2 / sixel graphics
protocols, with a Unicode half-block
fallback).
Rendering in the Electron desktop is necessarily TypeScript (canvas), but it
reuses the same on-disk store and the same state semantics.
The whole feature is a *display* concern: it adds no model tool, mutates no
system prompt or toolset, and therefore has zero effect on prompt caching.
"""
from agent.pet.constants import (
DEFAULT_SCALE,
FRAME_H,
FRAME_W,
FRAMES_PER_STATE,
LOOP_MS,
STATE_ROWS,
PetState,
)
from agent.pet.state import derive_pet_state
__all__ = [
"DEFAULT_SCALE",
"FRAME_H",
"FRAME_W",
"FRAMES_PER_STATE",
"LOOP_MS",
"STATE_ROWS",
"PetState",
"derive_pet_state",
]
-167
View File
@@ -1,167 +0,0 @@
"""Pet sprite geometry + animation-state taxonomy.
These values are the common petdex/Codex pet geometry. The real ``pet.json``
usually only carries ``id``/``displayName``/``description``/``spritesheetPath``;
row taxonomy is inferred from the atlas shape so Hermes can render both legacy
8-row sheets and current 9-row Codex sheets.
"""
from __future__ import annotations
from enum import Enum
# Frame geometry (pixels). Current Codex/petdex spritesheets are 8 columns x 9
# rows (1536x1872), while older Hermes/petdex sheets used 9 columns x 8 rows
# (1728x1664). Renderers derive both row taxonomy and real column count from the
# concrete sheet, so either shape works.
FRAME_W = 192
FRAME_H = 208
# Frames consumed per animation state (the petdex web app uses CSS
# ``steps(6)``). A sheet may physically contain more columns; we only step
# through the first ``FRAMES_PER_STATE``.
FRAMES_PER_STATE = 6
# Full-loop duration for one state, milliseconds (petdex default).
LOOP_MS = 1100
# Default on-screen scale relative to native frame size. ``display.pet.scale``
# is the single master scalar: the desktop canvas multiplies its native pixels
# by it and every terminal surface derives its half-block/kitty column width
# from it (see :func:`cols_for_scale`), so one number shrinks all three
# interfaces together. (petdex's own clients render at 0.7; we default smaller
# so the kitty/GUI mascot stays a glanceable corner sprite. The half-block
# fallback can't shrink as far — see ``UNICODE_MIN_COLS`` — and clamps to its
# legibility floor instead.)
DEFAULT_SCALE = 0.33
# User-settable scale bounds (``/pet scale``, desktop slider). Floor keeps the
# pet clickable/visible; ceiling stops a fat-fingered value from filling the
# screen. The unicode fallback additionally clamps to ``UNICODE_MIN_COLS``.
MIN_SCALE = 0.1
MAX_SCALE = 3.0
def clamp_scale(scale: float) -> float:
"""Clamp *scale* to ``[MIN_SCALE, MAX_SCALE]`` (the single validation point)."""
return max(MIN_SCALE, min(MAX_SCALE, scale))
# Terminal cells one native frame spans at ``scale == 1.0``. A cell is ~8px
# wide, a frame is ``FRAME_W`` (192) px → 24 cells. This mirrors the kitty
# graphics placement (``scaled_px // 8``) so at full scale every renderer agrees.
BASE_UNICODE_COLS = FRAME_W // 8
# Legibility floor for the half-block fallback. A half-block cell samples the
# sprite at only 1 horizontal + 2 vertical taps, so below this width a 192×208
# pet collapses into an unreadable blob *regardless* of scale. kitty/GUI draw
# true pixels and have no such floor — that's why the same ``scale: 0.33`` is
# crisp there but mush in half-blocks. ``scale`` shrinks the unicode pet down
# TO this floor (and grows it above), instead of past it into noise.
UNICODE_MIN_COLS = 16
def cols_for_scale(scale: float) -> int:
"""Half-block width implied by *scale*, clamped to the legibility floor.
Above the floor it tracks the kitty cell box (``scaled_px // 8``) so the two
renderers converge at larger sizes; below it the floor keeps the sprite
readable rather than letting it devolve into a blob.
"""
return max(UNICODE_MIN_COLS, round(BASE_UNICODE_COLS * (scale or DEFAULT_SCALE)))
def resolve_cols(scale: float, unicode_cols: int = 0) -> int:
"""Resolve terminal width: explicit *unicode_cols* override, else from *scale*."""
return int(unicode_cols) if unicode_cols and int(unicode_cols) > 0 else cols_for_scale(scale)
class PetState(str, Enum):
"""Animation state a pet can be shown in.
These are Hermes' activity state names. They are not always identical to the
source atlas row names: Codex-format pets use rows like ``jumping`` /
``running`` while the UI keeps the shorter ``jump`` / ``run`` names.
"""
IDLE = "idle"
WAVE = "wave"
RUN = "run"
FAILED = "failed"
REVIEW = "review"
JUMP = "jump"
WAITING = "waiting"
# Legacy Hermes/petdex row order (top -> bottom) used by the older 8-row,
# 9-column atlas shape.
LEGACY_STATE_ROWS: list[str] = [
PetState.IDLE.value,
PetState.WAVE.value,
PetState.RUN.value,
PetState.FAILED.value,
PetState.REVIEW.value,
PetState.JUMP.value,
"extra1",
"extra2",
]
# Current Petdex row order (top -> bottom) used by 1536x1872 atlases:
# 8 columns x 9 rows of 192x208 cells.
CODEX_STATE_ROWS: list[str] = [
PetState.IDLE.value,
"running-right",
"running-left",
"waving",
"jumping",
PetState.FAILED.value,
PetState.WAITING.value,
"running",
PetState.REVIEW.value,
]
# Default/fallback for callers without a sheet. Prefer the current 9-row Codex
# format because generated pets and the public Codex pet contract use it.
STATE_ROWS: list[str] = CODEX_STATE_ROWS
# Canonical Hermes activity names -> accepted row-name aliases in descending
# preference. This keeps our internal state names stable (`wave`/`jump`/`run`)
# while matching Petdex's current `waving`/`jumping`/`running` taxonomy.
STATE_ALIASES: dict[str, tuple[str, ...]] = {
PetState.IDLE.value: (PetState.IDLE.value,),
PetState.WAVE.value: (PetState.WAVE.value, "waving"),
PetState.JUMP.value: (PetState.JUMP.value, "jumping"),
PetState.RUN.value: (PetState.RUN.value, "running"),
PetState.FAILED.value: (PetState.FAILED.value,),
PetState.REVIEW.value: (PetState.REVIEW.value,),
PetState.WAITING.value: (PetState.WAITING.value,),
}
def state_aliases_for(state: "PetState | str") -> tuple[str, ...]:
"""Return accepted row-name aliases for *state* (always non-empty)."""
value = state.value if isinstance(state, PetState) else str(state)
aliases = STATE_ALIASES.get(value)
return aliases if aliases else (value,)
def state_rows_for_grid(row_count: int | None) -> list[str]:
"""Return the row taxonomy for a spritesheet with *row_count* rows."""
try:
rows = int(row_count or 0)
except (TypeError, ValueError):
rows = 0
if rows >= len(CODEX_STATE_ROWS):
return CODEX_STATE_ROWS
return LEGACY_STATE_ROWS
def state_row_index(state: "PetState | str", row_count: int | None = None) -> int:
"""Return the spritesheet row index for *state* (clamped, never raises)."""
rows = state_rows_for_grid(row_count)
for name in state_aliases_for(state):
try:
return rows.index(name)
except ValueError:
continue
return 0 # fall back to the idle row
-128
View File
@@ -1,128 +0,0 @@
"""Fetch the public petdex manifest.
``https://petdex.dev/api/manifest`` 307-redirects to a JSON document on R2:
{
"generatedAt": "...",
"total": 2926,
"pets": [
{"slug": "boba", "displayName": "Boba", "kind": "creature",
"submittedBy": "railly",
"spritesheetUrl": "https://assets.petdex.dev/.../spritesheet.webp",
"petJsonUrl": "https://assets.petdex.dev/.../pet.json",
"zipUrl": "https://assets.petdex.dev/.../boba.zip"},
...
]
}
Read-only and unauthenticated; no credentials involved.
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass
logger = logging.getLogger(__name__)
MANIFEST_URL = "https://petdex.dev/api/manifest"
_DEFAULT_TIMEOUT = 20.0
# In-process cache for the (large, slow, identical-per-call) manifest. The list
# is a static CDN object that barely changes, yet a single session can ask for
# it many times — every gallery open, plus a full re-fetch per install/select
# (``find_entry``). A short TTL collapses those into one network hit without
# going stale for long. Cleared by :func:`clear_cache` (tests).
_MANIFEST_TTL = 300.0
_cache: tuple[float, list[ManifestEntry]] | None = None
def clear_cache() -> None:
"""Drop the cached manifest (forces the next fetch to hit the network)."""
global _cache
_cache = None
@dataclass(frozen=True)
class ManifestEntry:
"""A single pet's row in the manifest."""
slug: str
display_name: str
kind: str
submitted_by: str
spritesheet_url: str
pet_json_url: str
zip_url: str
@classmethod
def from_dict(cls, data: dict) -> "ManifestEntry":
return cls(
slug=str(data.get("slug", "")).strip(),
display_name=str(data.get("displayName", "") or data.get("slug", "")),
kind=str(data.get("kind", "") or "pet"),
submitted_by=str(data.get("submittedBy", "") or ""),
spritesheet_url=str(data.get("spritesheetUrl", "") or ""),
pet_json_url=str(data.get("petJsonUrl", "") or ""),
zip_url=str(data.get("zipUrl", "") or ""),
)
class ManifestError(RuntimeError):
"""Raised when the manifest can't be fetched or parsed."""
def fetch_manifest(*, timeout: float = _DEFAULT_TIMEOUT, force: bool = False) -> list[ManifestEntry]:
"""Return every approved pet from the public manifest.
Cached in-process for ``_MANIFEST_TTL`` seconds (pass ``force=True`` to
bypass). Follows the 307 redirect to R2. Raises :class:`ManifestError` on
any network/parse failure so callers can surface a clean message.
"""
global _cache
if not force and _cache is not None and time.monotonic() - _cache[0] < _MANIFEST_TTL:
return _cache[1]
try:
import httpx
except ImportError as exc: # pragma: no cover - httpx is a core dep
raise ManifestError("httpx is required to fetch the petdex manifest") from exc
try:
resp = httpx.get(
MANIFEST_URL,
timeout=timeout,
follow_redirects=True,
headers={"User-Agent": "hermes-agent-petdex"},
)
resp.raise_for_status()
payload = resp.json()
except Exception as exc: # noqa: BLE001 - normalize to one error type
raise ManifestError(f"could not fetch petdex manifest: {exc}") from exc
pets = payload.get("pets") if isinstance(payload, dict) else None
if not isinstance(pets, list):
raise ManifestError("petdex manifest had no 'pets' array")
entries: list[ManifestEntry] = []
for raw in pets:
if not isinstance(raw, dict):
continue
entry = ManifestEntry.from_dict(raw)
if entry.slug and entry.spritesheet_url:
entries.append(entry)
_cache = (time.monotonic(), entries)
return entries
def find_entry(slug: str, *, timeout: float = _DEFAULT_TIMEOUT) -> ManifestEntry | None:
"""Return the manifest entry for *slug*, or ``None`` if not listed."""
slug = slug.strip().lower()
for entry in fetch_manifest(timeout=timeout):
if entry.slug.lower() == slug:
return entry
return None
-618
View File
@@ -1,618 +0,0 @@
"""Decode a pet spritesheet and encode frames for a terminal.
Shared by the base CLI (writes the escape bytes to its own stdout) and the
TUI (``tui_gateway`` ships the encoded bytes to Ink, which writes them) so the
decode + capability-detection + protocol-encoding logic exists exactly once.
Supported output modes, in fidelity order:
- ``kitty`` the kitty graphics protocol (kitty, Ghostty, WezTerm).
- ``iterm`` iTerm2 inline images (iTerm2, WezTerm).
- ``sixel`` DEC sixel (xterm -ti vt340, foot, mlterm, WezTerm, ).
- ``unicode`` 24-bit half-block downscale; works in any truecolor terminal.
Frame decoding requires Pillow (a core Hermes dependency). If Pillow or the
spritesheet is unavailable the renderer degrades to ``unicode`` text or an
empty string rather than raising.
"""
from __future__ import annotations
import base64
import io
import logging
import os
import sys
from functools import lru_cache
from pathlib import Path
from agent.pet.constants import (
DEFAULT_SCALE,
FRAME_H,
FRAME_W,
FRAMES_PER_STATE,
PetState,
state_row_index,
)
logger = logging.getLogger(__name__)
# Public render-mode names accepted by ``display.pet.render_mode``.
RENDER_MODES = ("auto", "kitty", "iterm", "sixel", "unicode", "off")
# ─────────────────────────────────────────────────────────────────────────
# Terminal capability detection
# ─────────────────────────────────────────────────────────────────────────
def detect_terminal_graphics() -> str:
"""Best-effort detection of the richest graphics protocol available.
Env-based (non-blocking we never issue a DA1/terminal query that could
hang a pipe). Returns one of ``kitty`` / ``iterm`` / ``sixel`` /
``unicode``. Conservative: unknown terminals get ``unicode``, which works
anywhere with truecolor.
"""
term = os.environ.get("TERM", "").lower()
term_program = os.environ.get("TERM_PROGRAM", "").lower()
# The VS Code / Cursor integrated terminal sets TERM_PROGRAM=vscode
# authoritatively but does NOT scrub the terminal env vars it inherits when
# launched from another emulator (ITERM_SESSION_ID, KITTY_WINDOW_ID, …).
# Trusting those leaks emits an image protocol the embedded xterm.js can't
# display — you get a blank frame. Inline images there are opt-in
# (terminal.integrated.enableImages), so default to half-blocks, which
# always render in its truecolor grid. Users who enabled images can pin
# display.pet.render_mode explicitly.
if term_program == "vscode":
return "unicode"
# kitty graphics protocol
if os.environ.get("KITTY_WINDOW_ID") or "kitty" in term or "ghostty" in term:
return "kitty"
if term_program in {"ghostty"}:
return "kitty"
# WezTerm speaks both kitty and iterm; prefer kitty (richer placement).
if term_program == "wezterm" or os.environ.get("WEZTERM_PANE"):
return "kitty"
# iTerm2 inline images
if term_program == "iterm.app" or os.environ.get("ITERM_SESSION_ID"):
return "iterm"
# sixel-capable terminals (env heuristics only)
if term_program in {"mintty"} or "foot" in term or "mlterm" in term:
return "sixel"
if "sixel" in term:
return "sixel"
return "unicode"
def resolve_mode(configured: str | None, *, stream=None) -> str:
"""Resolve the effective render mode from config + the environment.
``configured`` is ``display.pet.render_mode`` (``auto`` detect). Returns
``off`` when not attached to a TTY (no point emitting graphics into a pipe
or logfile).
"""
mode = (configured or "auto").strip().lower()
if mode not in RENDER_MODES:
mode = "auto"
if mode == "off":
return "off"
stream = stream or sys.stdout
try:
if not (hasattr(stream, "isatty") and stream.isatty()):
return "off"
except (ValueError, OSError):
return "off"
if mode == "auto":
return detect_terminal_graphics()
return mode
# ─────────────────────────────────────────────────────────────────────────
# Frame decoding
# ─────────────────────────────────────────────────────────────────────────
def _open_sheet(path: Path):
from PIL import Image
img = Image.open(path)
return img.convert("RGBA")
# Max alpha at/below which a frame counts as blank padding. petdex sheets are
# left-packed: a state with fewer real frames than ``FRAMES_PER_STATE`` fills
# the trailing columns with fully transparent cells. Animating into one flashes
# the pet blank, so we stop the row at the first such gap.
_BLANK_ALPHA = 8
def _frame_is_blank(frame) -> bool:
"""True if *frame* has no meaningfully opaque pixel (transparent padding)."""
return frame.getchannel("A").getextrema()[1] <= _BLANK_ALPHA
@lru_cache(maxsize=16)
def _raw_frames(
sheet_path: str,
state_value: str,
frame_w: int,
frame_h: int,
frames_per_state: int,
) -> tuple:
"""Cropped, padding-trimmed RGBA frames for one state row (unscaled).
Steps across the row until the first blank column so pets with ragged
per-state frame counts never animate into empty padding. Cached; returns
``()`` on any decode failure.
"""
try:
sheet = _open_sheet(Path(sheet_path))
cols = max(1, sheet.width // frame_w)
rows = max(1, sheet.height // frame_h)
row = state_row_index(state_value, rows)
top = row * frame_h
# Clamp the row to the sheet (some pets ship fewer rows than the 8 the
# taxonomy reserves).
if top + frame_h > sheet.height:
top = max(0, sheet.height - frame_h)
frames = []
for i in range(min(frames_per_state, cols)):
left = i * frame_w
frame = sheet.crop((left, top, left + frame_w, top + frame_h))
if _frame_is_blank(frame):
break # trailing transparent padding — real frames end here
frames.append(frame)
return tuple(frames)
except Exception as exc: # noqa: BLE001 - cosmetic feature, never fatal
logger.debug("pet frame decode failed (%s, %s): %s", sheet_path, state_value, exc)
return ()
@lru_cache(maxsize=8)
def _frames_for(
sheet_path: str,
state_value: str,
frame_w: int,
frame_h: int,
frames_per_state: int,
scale_w: int,
scale_h: int,
):
"""Return padding-trimmed RGBA frames for one state row, scaled.
Thin scaling layer over :func:`_raw_frames`; both are cached so repeated
frame requests during animation are free.
"""
raw = _raw_frames(sheet_path, state_value, frame_w, frame_h, frames_per_state)
if not raw or (scale_w, scale_h) == (frame_w, frame_h):
return list(raw)
from PIL import Image
return [f.resize((scale_w, scale_h), Image.LANCZOS) for f in raw]
def state_frame_counts(
sheet_path: str | Path,
*,
frame_w: int = FRAME_W,
frame_h: int = FRAME_H,
frames_per_state: int = FRAMES_PER_STATE,
) -> dict[str, int]:
"""Map each driven :class:`PetState` → its real (padding-trimmed) frame count.
The single source of truth for "how many frames does this state actually
have?". The CLI/TUI consume the trimmed frame lists directly; the gateway
ships this map to the desktop canvas, which steps its own loop.
"""
return {
state.value: len(
_raw_frames(str(sheet_path), state.value, frame_w, frame_h, frames_per_state)
)
for state in PetState
}
# ─────────────────────────────────────────────────────────────────────────
# Encoders
# ─────────────────────────────────────────────────────────────────────────
def _png_bytes(frame) -> bytes:
buf = io.BytesIO()
frame.save(buf, format="PNG")
return buf.getvalue()
def _kitty_apc(ctrl: str, data: str) -> str:
"""Emit a kitty APC escape for *data*, chunked into ≤4096-byte ``m`` pieces."""
chunk = 4096
if len(data) <= chunk:
return f"\x1b_G{ctrl},m=0;{data}\x1b\\"
out = [f"\x1b_G{ctrl},m=1;{data[:chunk]}\x1b\\"]
rest = data[chunk:]
while rest:
piece, rest = rest[:chunk], rest[chunk:]
out.append(f"\x1b_Gm={1 if rest else 0};{piece}\x1b\\")
return "".join(out)
def _encode_kitty(frame, *, cell_cols: int | None = None, cell_rows: int | None = None) -> str:
"""Encode one frame via the kitty graphics protocol (transmit + display).
``a=T`` transmits & displays at the cursor; ``c``/``r`` request a display
box in terminal cells so successive frames overwrite the same area.
"""
ctrl = "f=100,a=T,q=2"
if cell_cols:
ctrl += f",c={cell_cols}"
if cell_rows:
ctrl += f",r={cell_rows}"
return _kitty_apc(ctrl, base64.standard_b64encode(_png_bytes(frame)).decode("ascii"))
# ─────────────────────────────────────────────────────────────────────────
# kitty Unicode placeholders
#
# Ink (the TUI's React-for-terminal layer) owns the screen and measures every
# cell's width, so it can't host raw kitty image escapes (no width to count,
# clobbered on the next repaint). kitty's *Unicode placeholder* protocol is the
# grid-safe path: transmit the image once (q=2, virtual placement U=1), then the
# host app prints ordinary-width placeholder cells (U+10EEEE + diacritics) whose
# foreground color encodes the image id. Ink counts those as width-1 text, so
# layout stays correct and the terminal paints the image underneath.
# https://sw.kovidgoyal.net/kitty/graphics-protocol/#unicode-placeholders
# ─────────────────────────────────────────────────────────────────────────
_KITTY_PLACEHOLDER = "\U0010eeee"
# Row/column diacritics, in order (index → diacritic). Verbatim from kitty's
# gen/rowcolumn-diacritics.txt (Unicode 6.0.0, combining class 230). Index i is
# the diacritic that encodes the number i; we only ever need the row index.
_ROWCOL_DIACRITICS: tuple[int, ...] = (
0x0305, 0x030D, 0x030E, 0x0310, 0x0312, 0x033D, 0x033E, 0x033F, 0x0346, 0x034A,
0x034B, 0x034C, 0x0350, 0x0351, 0x0352, 0x0357, 0x035B, 0x0363, 0x0364, 0x0365,
0x0366, 0x0367, 0x0368, 0x0369, 0x036A, 0x036B, 0x036C, 0x036D, 0x036E, 0x036F,
0x0483, 0x0484, 0x0485, 0x0486, 0x0487, 0x0592, 0x0593, 0x0594, 0x0595, 0x0597,
0x0598, 0x0599, 0x059C, 0x059D, 0x059E, 0x059F, 0x05A0, 0x05A1, 0x05A8, 0x05A9,
0x05AB, 0x05AC, 0x05AF, 0x05C4, 0x0610, 0x0611, 0x0612, 0x0613, 0x0614, 0x0615,
0x0616, 0x0617, 0x0657, 0x0658, 0x0659, 0x065A, 0x065B, 0x065D, 0x065E, 0x06D6,
0x06D7, 0x06D8, 0x06D9, 0x06DA, 0x06DB, 0x06DC, 0x06DF, 0x06E0, 0x06E1, 0x06E2,
0x06E4, 0x06E7, 0x06E8, 0x06EB, 0x06EC, 0x0730, 0x0732, 0x0733, 0x0735, 0x0736,
0x073A, 0x073D, 0x073F, 0x0740, 0x0741, 0x0743, 0x0745, 0x0747, 0x0749, 0x074A,
0x07EB, 0x07EC, 0x07ED, 0x07EE, 0x07EF, 0x07F0, 0x07F1, 0x07F3, 0x0816, 0x0817,
0x0818, 0x0819, 0x081B, 0x081C, 0x081D, 0x081E, 0x081F, 0x0820, 0x0821, 0x0822,
0x0823, 0x0825, 0x0826, 0x0827, 0x0829, 0x082A, 0x082B, 0x082C, 0x082D, 0x0951,
0x0953, 0x0954, 0x0F82, 0x0F83, 0x0F86, 0x0F87, 0x135D, 0x135E, 0x135F, 0x17DD,
0x193A, 0x1A17, 0x1A75, 0x1A76, 0x1A77, 0x1A78, 0x1A79, 0x1A7A, 0x1A7B, 0x1A7C,
0x1B6B, 0x1B6D, 0x1B6E, 0x1B6F, 0x1B70, 0x1B71, 0x1B72, 0x1B73, 0x1CD0, 0x1CD1,
0x1CD2, 0x1CDA, 0x1CDB, 0x1CE0, 0x1DC0, 0x1DC1, 0x1DC3, 0x1DC4, 0x1DC5, 0x1DC6,
0x1DC7, 0x1DC8, 0x1DC9, 0x1DCB, 0x1DCC, 0x1DD1, 0x1DD2, 0x1DD3, 0x1DD4, 0x1DD5,
0x1DD6, 0x1DD7, 0x1DD8, 0x1DD9, 0x1DDA, 0x1DDB, 0x1DDC, 0x1DDD, 0x1DDE, 0x1DDF,
0x1DE0, 0x1DE1, 0x1DE2, 0x1DE3, 0x1DE4, 0x1DE5, 0x1DE6, 0x1DFE, 0x20D0, 0x20D1,
0x20D4, 0x20D5, 0x20D6, 0x20D7, 0x20DB, 0x20DC, 0x20E1, 0x20E7, 0x20E9, 0x20F0,
0x2CEF, 0x2CF0, 0x2CF1, 0x2DE0, 0x2DE1, 0x2DE2, 0x2DE3, 0x2DE4, 0x2DE5, 0x2DE6,
0x2DE7, 0x2DE8, 0x2DE9, 0x2DEA, 0x2DEB, 0x2DEC, 0x2DED, 0x2DEE, 0x2DEF, 0x2DF0,
0x2DF1, 0x2DF2, 0x2DF3, 0x2DF4, 0x2DF5, 0x2DF6, 0x2DF7, 0x2DF8, 0x2DF9, 0x2DFA,
0x2DFB, 0x2DFC, 0x2DFD, 0x2DFE, 0x2DFF, 0xA66F, 0xA67C, 0xA67D, 0xA6F0, 0xA6F1,
0xA8E0, 0xA8E1, 0xA8E2, 0xA8E3, 0xA8E4, 0xA8E5, 0xA8E6, 0xA8E7, 0xA8E8, 0xA8E9,
0xA8EA, 0xA8EB, 0xA8EC, 0xA8ED, 0xA8EE, 0xA8EF, 0xA8F0, 0xA8F1, 0xAAB0, 0xAAB2,
0xAAB3, 0xAAB7, 0xAAB8, 0xAABE, 0xAABF, 0xAAC1, 0xFE20, 0xFE21, 0xFE22, 0xFE23,
0xFE24, 0xFE25, 0xFE26, 0x10A0F, 0x10A38, 0x1D185, 0x1D186, 0x1D187, 0x1D188,
0x1D189, 0x1D1AA, 0x1D1AB, 0x1D1AC, 0x1D1AD, 0x1D242, 0x1D243, 0x1D244,
)
def kitty_image_id(slug: str) -> int:
"""Stable per-pet image id in ``[1, 0x7FFF]``.
The id is encoded in the placeholder's 24-bit foreground color, so it must
be non-zero and fit comfortably under ``0xFFFFFF``. A small CRC keeps it
deterministic per slug (so re-renders reuse the same terminal-side image)
while making collisions between two different pets unlikely.
"""
import zlib
return (zlib.crc32(slug.encode("utf-8")) % 0x7FFE) + 1
def kitty_color_hex(image_id: int) -> str:
"""Hex foreground color (``#rrggbb``) that encodes *image_id* for kitty."""
return "#%06x" % (image_id & 0xFFFFFF)
def kitty_placeholder_rows(cols: int, rows: int) -> list[str]:
"""Build the placeholder text grid for an *rows*×*cols* image.
Each line is one row of the grid: the first cell carries the row diacritic
(column defaults to 0), and the remaining ``cols-1`` bare placeholders let
the terminal auto-increment the column. The foreground color (the image id)
is applied by the caller / Ink, not embedded here.
"""
cols = max(1, cols)
out: list[str] = []
for r in range(max(1, rows)):
idx = min(r, len(_ROWCOL_DIACRITICS) - 1)
first = _KITTY_PLACEHOLDER + chr(_ROWCOL_DIACRITICS[idx])
out.append(first + _KITTY_PLACEHOLDER * (cols - 1))
return out
def _encode_kitty_virtual(frame, *, image_id: int, cols: int, rows: int) -> str:
"""Transmit a frame as a kitty *virtual* placement for Unicode placeholders.
``a=T`` transmits and creates the placement in one shot; ``U=1`` marks it
virtual (no on-screen output, cursor untouched); ``q=2`` suppresses the
terminal's OK/error replies that would otherwise corrupt the host app's
output. Re-sending with the same ``i`` replaces the image, so the static
placeholder cells animate underneath.
"""
ctrl = f"a=T,U=1,i={image_id},c={cols},r={rows},f=100,q=2"
return _kitty_apc(ctrl, base64.standard_b64encode(_png_bytes(frame)).decode("ascii"))
def _encode_iterm(frame, *, cell_cols: int | None = None, cell_rows: int | None = None) -> str:
"""Encode one frame as an iTerm2 inline image (OSC 1337 File)."""
payload = base64.standard_b64encode(_png_bytes(frame)).decode("ascii")
size = len(payload)
args = [f"inline=1", f"size={size}", "preserveAspectRatio=1"]
if cell_cols:
args.append(f"width={cell_cols}")
if cell_rows:
args.append(f"height={cell_rows}")
return f"\x1b]1337;File={';'.join(args)}:{payload}\x07"
def _encode_sixel(frame) -> str:
"""Encode one frame as DEC sixel.
Quantizes to an adaptive palette (255 colors) and emits the sixel band
stream. Pillow has no sixel writer, so this is a compact hand-rolled
encoder. Transparent pixels render as background (color register skipped).
"""
from PIL import Image
rgba = frame
# Composite onto transparent-as-skip: track alpha to decide background.
pal = rgba.convert("RGB").quantize(colors=255, method=Image.MEDIANCUT)
palette = pal.getpalette() or []
px = pal.load()
alpha = rgba.getchannel("A").load()
w, h = pal.size
out = ["\x1bP0;1;0q", '"1;1;%d;%d' % (w, h)]
# Color register definitions (sixel uses 0..100 scale).
used = sorted({px[x, y] for y in range(h) for x in range(w)})
for idx in used:
r = palette[idx * 3] if idx * 3 < len(palette) else 0
g = palette[idx * 3 + 1] if idx * 3 + 1 < len(palette) else 0
b = palette[idx * 3 + 2] if idx * 3 + 2 < len(palette) else 0
out.append("#%d;2;%d;%d;%d" % (idx, r * 100 // 255, g * 100 // 255, b * 100 // 255))
# Emit in 6-row bands.
for band in range(0, h, 6):
for color_idx in used:
line = ["#%d" % color_idx]
run_char = None
run_len = 0
def flush():
nonlocal run_char, run_len
if run_char is None:
return
if run_len > 3:
line.append("!%d%s" % (run_len, run_char))
else:
line.append(run_char * run_len)
run_char, run_len = None, 0
for x in range(w):
bits = 0
for bit in range(6):
y = band + bit
if y < h and alpha[x, y] > 32 and px[x, y] == color_idx:
bits |= 1 << bit
ch = chr(63 + bits)
if ch == run_char:
run_len += 1
else:
flush()
run_char, run_len = ch, 1
flush()
out.append("".join(line) + "$") # carriage return within band
out.append("-") # next band
out.append("\x1b\\")
return "".join(out)
_HALF_BLOCK = ""
# A single half-block cell: top pixel + bottom pixel as (r, g, b, a) tuples.
Cell = tuple[tuple[int, int, int, int], tuple[int, int, int, int]]
def _downscale_cells(frame, *, target_cols: int) -> list[list[Cell]]:
"""Downscale a frame to a grid of half-block cells.
Each cell pairs a top and bottom pixel so one terminal row encodes two
pixel rows. Returns rows of ``((tr,tg,tb,ta),(br,bg,bb,ba))`` the
framework-neutral representation shared by the ANSI encoder (CLI) and the
structured ``cells`` API (Ink).
"""
from PIL import Image
target_cols = max(4, target_cols)
aspect = frame.height / max(1, frame.width)
target_rows = max(2, int(round(target_cols * aspect * 0.5)) * 2)
small = frame.resize((target_cols, target_rows), Image.LANCZOS).convert("RGBA")
px = small.load()
grid: list[list[Cell]] = []
for y in range(0, target_rows, 2):
row: list[Cell] = []
for x in range(target_cols):
top = px[x, y]
bottom = px[x, y + 1] if y + 1 < target_rows else (0, 0, 0, 0)
row.append((top, bottom))
grid.append(row)
return grid
def _encode_unicode(frame, *, target_cols: int) -> str:
"""Downscale to truecolor ANSI half-blocks (one char = 2 vertical pixels)."""
lines: list[str] = []
for row in _downscale_cells(frame, target_cols=target_cols):
cells: list[str] = []
for (tr, tg, tb, ta), (br, bg, bb, ba) in row:
if ta < 32 and ba < 32:
cells.append("\x1b[0m ") # fully transparent → blank
continue
cells.append(f"\x1b[38;2;{tr};{tg};{tb}m\x1b[48;2;{br};{bg};{bb}m{_HALF_BLOCK}")
lines.append("".join(cells) + "\x1b[0m")
return "\n".join(lines)
# ─────────────────────────────────────────────────────────────────────────
# Public renderer
# ─────────────────────────────────────────────────────────────────────────
class PetRenderer:
"""Holds a pet's spritesheet and yields encoded frames per (state, index).
Construct once per pet, then call :meth:`frame` on an animation timer.
Cheap to call repeatedly decoded frames are cached.
"""
def __init__(
self,
spritesheet: str | Path,
*,
mode: str = "unicode",
scale: float = DEFAULT_SCALE,
unicode_cols: int = 20,
frame_w: int = FRAME_W,
frame_h: int = FRAME_H,
frames_per_state: int = FRAMES_PER_STATE,
) -> None:
self.spritesheet = str(spritesheet)
self.mode = mode if mode in RENDER_MODES else "unicode"
self.scale = scale
self.unicode_cols = unicode_cols
self.frame_w = frame_w
self.frame_h = frame_h
self.frames_per_state = frames_per_state
@property
def available(self) -> bool:
return self.mode != "off" and Path(self.spritesheet).is_file()
def frame_count(self, state: PetState | str) -> int:
return len(self._frames(state))
def _frames(self, state: PetState | str):
value = state.value if isinstance(state, PetState) else str(state)
scale_w = max(1, int(self.frame_w * self.scale))
scale_h = max(1, int(self.frame_h * self.scale))
return _frames_for(
self.spritesheet,
value,
self.frame_w,
self.frame_h,
self.frames_per_state,
scale_w,
scale_h,
)
def cells(self, state: PetState | str, index: int, *, cols: int | None = None) -> list[list[Cell]]:
"""Return one frame as a half-block cell grid (framework-neutral).
Used by the TUI, which renders the grid with native Ink color props
instead of raw ANSI. Returns ``[]`` when no frame is available.
"""
frames = self._frames(state)
if not frames:
return []
frame = frames[index % len(frames)]
return _downscale_cells(frame, target_cols=cols or self.unicode_cols)
def _cell_box(self, frame) -> tuple[int, int]:
"""Terminal cell box for a scaled frame (~8×16 px per cell).
Must match :meth:`frame` graphics sizing kitty stretches the image to
fill ``c``×``r`` cells, so these must reflect the scaled pixel
dimensions, not a native-aspect column count (that upscales small pets).
"""
return max(1, frame.width // 8), max(1, frame.height // 16)
def kitty_payload(self, state: PetState | str, *, image_id: int) -> dict | None:
"""Build the kitty Unicode-placeholder payload for one state.
Returns ``{cols, rows, placeholder, frames}`` where ``frames`` is a
list of transmit escapes (one per animation frame, all reusing
``image_id``) and ``placeholder`` is the static text grid Ink paints.
Placement geometry is derived from the scaled frame pixels (via
:meth:`_cell_box`), not ``unicode_cols`` kitty upscales to fill
``c``×``r`` cells. ``None`` when no frame is available.
"""
frames = self._frames(state)
if not frames:
return None
cols, rows = self._cell_box(frames[0])
return {
"cols": cols,
"rows": rows,
"placeholder": kitty_placeholder_rows(cols, rows),
"frames": [
_encode_kitty_virtual(f, image_id=image_id, cols=cols, rows=rows) for f in frames
],
}
def frame(self, state: PetState | str, index: int) -> str:
"""Return the encoded escape string for one frame, or ``""``.
``index`` is taken modulo the available frame count so callers can pass
a free-running counter.
"""
if self.mode == "off":
return ""
frames = self._frames(state)
if not frames:
return ""
frame = frames[index % len(frames)]
cell_cols, cell_rows = self._cell_box(frame)
try:
if self.mode == "kitty":
return _encode_kitty(frame, cell_cols=cell_cols, cell_rows=cell_rows)
if self.mode == "iterm":
return _encode_iterm(frame, cell_cols=cell_cols, cell_rows=cell_rows)
if self.mode == "sixel":
return _encode_sixel(frame)
return _encode_unicode(frame, target_cols=self.unicode_cols)
except Exception as exc: # noqa: BLE001 - degrade silently
logger.debug("pet frame encode failed (mode=%s): %s", self.mode, exc)
return ""
def build_renderer(
spritesheet: str | Path,
*,
configured_mode: str | None = None,
scale: float = DEFAULT_SCALE,
unicode_cols: int = 20,
stream=None,
) -> PetRenderer:
"""Convenience factory: resolve the mode from config+env, then construct."""
mode = resolve_mode(configured_mode, stream=stream)
return PetRenderer(
spritesheet,
mode=mode,
scale=scale,
unicode_cols=unicode_cols,
)
-81
View File
@@ -1,81 +0,0 @@
"""Map agent activity → a :class:`PetState`.
This is the one place the "what is the agent doing right now?" "which
animation row?" decision lives. Each surface feeds it the signals it already
tracks:
- CLI ``KawaiiSpinner`` waiting/thinking state + tool outcomes.
- TUI gateway ``tool.start/complete`` + ``message.delta/complete`` events.
- Desktop the ``$busy``/``$awaitingResponse``/tool-event nanostores
(re-implemented in TS, but mirroring this priority order).
Keeping the priority order here (and documenting it) lets the TypeScript
mirror stay faithful without a second design.
"""
from __future__ import annotations
from collections.abc import Iterable
from typing import Any
from agent.pet.constants import PetState
def todos_all_done(todos: Iterable[Any] | None) -> bool:
"""True iff there's ≥1 todo and every one is completed/cancelled.
The "celebrate" beat (``JUMP``) fires when a plan finishes; this mirrors
the TUI's ``isTodoDone`` so the trigger is defined once across surfaces.
Accepts dicts (``{"status": ...}``) or objects with a ``status`` attr.
"""
items = list(todos or [])
if not items:
return False
def _status(t: Any) -> Any:
return t.get("status") if isinstance(t, dict) else getattr(t, "status", None)
return all(_status(t) in ("completed", "cancelled") for t in items)
def derive_pet_state(
*,
busy: bool = False,
awaiting_input: bool = False,
error: bool = False,
celebrate: bool = False,
just_completed: bool = False,
tool_running: bool = False,
reasoning: bool = False,
) -> PetState:
"""Resolve the animation state from coarse activity signals.
Priority (highest first) only one row can show at a time, so the most
salient signal wins:
1. ``error`` ``FAILED`` (a tool/turn just failed)
2. ``celebrate`` ``JUMP`` (explicit success beat, e.g. todos done)
3. ``just_completed`` ``WAVE`` (turn finished cleanly / greeting)
4. ``awaiting_input`` ``WAITING`` (blocked on the user a clarify/approval
prompt is open; this outranks the in-flight signals below because the turn
is paused on *you*, even though a tool is technically mid-call)
5. ``tool_running`` ``RUN`` (a tool is executing)
6. ``reasoning`` ``REVIEW`` (model is thinking / reading)
7. ``busy`` ``RUN`` (turn in flight, unspecified work)
8. otherwise ``IDLE``
"""
if error:
return PetState.FAILED
if celebrate:
return PetState.JUMP
if just_completed:
return PetState.WAVE
if awaiting_input:
return PetState.WAITING
if tool_running:
return PetState.RUN
if reasoning:
return PetState.REVIEW
if busy:
return PetState.RUN
return PetState.IDLE
-316
View File
@@ -1,316 +0,0 @@
"""On-disk pet store — install / list / resolve pets.
Pets live under ``get_hermes_home()/pets/<slug>/`` so every profile gets its
own set (we deliberately do **not** reuse petdex's ``~/.codex/pets`` default —
that's owned by the petdex npm CLI and isn't profile-aware). Each installed
pet directory holds:
pets/<slug>/
pet.json # {id, displayName, description, spritesheetPath}
spritesheet.webp # (or .png)
The active pet is resolved from the caller-supplied ``display.pet.slug`` config
value (falling back to the first installed pet), so this module stays free of
the config loader.
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from pathlib import Path
from hermes_constants import get_hermes_home
logger = logging.getLogger(__name__)
_DOWNLOAD_TIMEOUT = 60.0
class PetStoreError(RuntimeError):
"""Raised on install/IO failures."""
@dataclass(frozen=True)
class InstalledPet:
"""A pet present on disk."""
slug: str
display_name: str
description: str
directory: Path
spritesheet: Path
@property
def exists(self) -> bool:
return self.spritesheet.is_file()
def pets_dir() -> Path:
"""Return the profile-scoped pets directory (created on demand)."""
path = get_hermes_home() / "pets"
path.mkdir(parents=True, exist_ok=True)
return path
def _read_pet_json(directory: Path) -> dict:
pet_json = directory / "pet.json"
if not pet_json.is_file():
return {}
try:
return json.loads(pet_json.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
logger.debug("unreadable pet.json in %s: %s", directory, exc)
return {}
def _resolve_spritesheet(directory: Path, meta: dict) -> Path:
"""Find the spritesheet for a pet dir.
Honors ``spritesheetPath`` from pet.json, else probes the conventional
filenames (``spritesheet.{webp,png}`` and petdex R2's ``sprite.webp``).
"""
declared = str(meta.get("spritesheetPath", "") or "").strip()
if declared:
candidate = directory / declared
if candidate.is_file():
return candidate
for name in ("spritesheet.webp", "spritesheet.png", "sprite.webp", "sprite.png"):
candidate = directory / name
if candidate.is_file():
return candidate
# Default expectation even if missing, so callers get a stable path.
return directory / "spritesheet.webp"
def load_pet(slug: str) -> InstalledPet | None:
"""Return the :class:`InstalledPet` for *slug*, or ``None`` if absent."""
slug = slug.strip()
directory = pets_dir() / slug
if not directory.is_dir():
return None
meta = _read_pet_json(directory)
return InstalledPet(
slug=slug,
display_name=str(meta.get("displayName", "") or slug),
description=str(meta.get("description", "") or ""),
directory=directory,
spritesheet=_resolve_spritesheet(directory, meta),
)
def installed_pets() -> list[InstalledPet]:
"""Return every installed pet (dirs containing a usable spritesheet)."""
out: list[InstalledPet] = []
for child in sorted(pets_dir().iterdir()):
if not child.is_dir():
continue
pet = load_pet(child.name)
if pet and pet.exists:
out.append(pet)
return out
def resolve_active_pet(configured_slug: str | None = None) -> InstalledPet | None:
"""Resolve which pet to display.
Precedence: the configured slug (``display.pet.slug``) if it's installed,
otherwise the first installed pet alphabetically, otherwise ``None``.
"""
if configured_slug:
pet = load_pet(configured_slug.strip())
if pet and pet.exists:
return pet
pets = installed_pets()
return pets[0] if pets else None
def install_pet(slug: str, *, force: bool = False, timeout: float = _DOWNLOAD_TIMEOUT) -> InstalledPet:
"""Download *slug* from the manifest into the pets directory.
Idempotent: a fully-installed pet is returned as-is unless *force*. Raises
:class:`PetStoreError` / :class:`~agent.pet.manifest.ManifestError` on
failure.
"""
from agent.pet.manifest import find_entry
slug = slug.strip()
existing = load_pet(slug)
if existing and existing.exists and not force:
return existing
entry = find_entry(slug, timeout=timeout)
if entry is None:
raise PetStoreError(f"pet '{slug}' is not in the petdex manifest")
directory = pets_dir() / slug
directory.mkdir(parents=True, exist_ok=True)
sprite_ext = ".png" if entry.spritesheet_url.lower().split("?")[0].endswith(".png") else ".webp"
sprite_path = directory / f"spritesheet{sprite_ext}"
_download(entry.spritesheet_url, sprite_path, timeout=timeout)
# Fetch the upstream pet.json if present; otherwise synthesize a minimal
# one so the local layout is self-describing.
meta: dict = {}
if entry.pet_json_url:
try:
meta = _download_json(entry.pet_json_url, timeout=timeout)
except Exception as exc: # noqa: BLE001 - non-fatal, fall back below
logger.debug("pet.json fetch failed for %s: %s", slug, exc)
if not isinstance(meta, dict) or not meta:
meta = {"id": slug, "displayName": entry.display_name, "description": ""}
meta["spritesheetPath"] = sprite_path.name
meta.setdefault("id", slug)
meta.setdefault("displayName", entry.display_name)
(directory / "pet.json").write_text(json.dumps(meta, indent=2), encoding="utf-8")
pet = load_pet(slug)
if pet is None or not pet.exists:
raise PetStoreError(f"install of '{slug}' did not produce a spritesheet")
return pet
_THUMB_FRAME_W = 192
_THUMB_FRAME_H = 208
_THUMB_W = 96 # rendered ~40px; 2x+ keeps it crisp on HiDPI
def _thumbs_dir() -> Path:
path = pets_dir() / ".thumbs"
path.mkdir(parents=True, exist_ok=True)
return path
def _is_petdex_host(url: str) -> bool:
"""True only for petdex.dev hosts — bounds server-side fetch (anti-SSRF)."""
from urllib.parse import urlparse
try:
host = (urlparse(url).hostname or "").lower()
except ValueError:
return False
return host == "petdex.dev" or host.endswith(".petdex.dev")
def thumbnail_png(slug: str, *, source_url: str = "", timeout: float = 30.0) -> bytes | None:
"""Return a small idle-frame PNG for *slug*, cached on disk.
Crops the top-left (idle, frame 0) cell of the spritesheet and downsamples
it to a thumbnail. Source preference: an installed spritesheet on disk, else
*source_url* but only when it points at petdex (so the gateway never
fetches an arbitrary client-supplied URL). Returns ``None`` when there's no
usable source or Pillow/network fails; callers render a placeholder.
Doing this server-side sidesteps the renderer's CSP / R2 hotlink limits that
break a direct ``<img src=cdn>`` and lets the result ride the authenticated
gateway as a same-origin data URL.
"""
slug = slug.strip()
if not slug:
return None
cache = _thumbs_dir() / f"{slug}.png"
if cache.is_file():
try:
return cache.read_bytes()
except OSError:
pass
sheet_bytes: bytes | None = None
pet = load_pet(slug)
if pet and pet.exists:
try:
sheet_bytes = pet.spritesheet.read_bytes()
except OSError:
sheet_bytes = None
if sheet_bytes is None and source_url and _is_petdex_host(source_url):
try:
import httpx
resp = httpx.get(
source_url,
timeout=timeout,
follow_redirects=True,
headers={"User-Agent": "hermes-agent-petdex"},
)
resp.raise_for_status()
sheet_bytes = resp.content
except Exception as exc: # noqa: BLE001 - cosmetic, degrade to placeholder
logger.debug("thumb fetch failed for %s: %s", slug, exc)
if not sheet_bytes:
return None
try:
import io
from PIL import Image
with Image.open(io.BytesIO(sheet_bytes)) as im:
frame = im.convert("RGBA").crop(
(0, 0, min(_THUMB_FRAME_W, im.width), min(_THUMB_FRAME_H, im.height))
)
height = round(_THUMB_W * _THUMB_FRAME_H / _THUMB_FRAME_W)
frame = frame.resize((_THUMB_W, height), Image.NEAREST)
buf = io.BytesIO()
frame.save(buf, format="PNG")
data = buf.getvalue()
except Exception as exc: # noqa: BLE001
logger.debug("thumb crop failed for %s: %s", slug, exc)
return None
try:
cache.write_bytes(data)
except OSError:
pass
return data
def remove_pet(slug: str) -> bool:
"""Delete an installed pet directory. Returns True if anything was removed."""
import shutil
directory = pets_dir() / slug.strip()
if not directory.is_dir():
return False
shutil.rmtree(directory, ignore_errors=True)
return not directory.exists()
def _download(url: str, dest: Path, *, timeout: float) -> None:
import httpx
try:
with httpx.stream(
"GET",
url,
timeout=timeout,
follow_redirects=True,
headers={"User-Agent": "hermes-agent-petdex"},
) as resp:
resp.raise_for_status()
tmp = dest.with_suffix(dest.suffix + ".part")
with tmp.open("wb") as fh:
for chunk in resp.iter_bytes():
fh.write(chunk)
tmp.replace(dest)
except Exception as exc: # noqa: BLE001
raise PetStoreError(f"download failed for {url}: {exc}") from exc
def _download_json(url: str, *, timeout: float) -> dict:
import httpx
resp = httpx.get(
url,
timeout=timeout,
follow_redirects=True,
headers={"User-Agent": "hermes-agent-petdex"},
)
resp.raise_for_status()
data = resp.json()
return data if isinstance(data, dict) else {}
+1 -57
View File
@@ -8,7 +8,6 @@ import json
import logging
import os
import threading
import contextvars
from collections import OrderedDict
from pathlib import Path
@@ -959,52 +958,6 @@ CONTEXT_TRUNCATE_HEAD_RATIO = 0.7
CONTEXT_TRUNCATE_TAIL_RATIO = 0.2
def _get_context_file_max_chars() -> int:
"""Return the configured context-file truncation limit.
``CONTEXT_FILE_MAX_CHARS`` remains the upstream-compatible default and
fallback. Users with larger context windows can raise
``context_file_max_chars`` in config.yaml without patching Hermes.
"""
try:
from hermes_cli.config import load_config
val = load_config().get("context_file_max_chars")
if isinstance(val, (int, float)) and val > 0:
return int(val)
except Exception as e:
logger.debug("Could not read context_file_max_chars from config: %s", e)
return CONTEXT_FILE_MAX_CHARS
# Collect truncation warnings so the caller (run_agent) can surface them.
# A ContextVar (not a module-global list) isolates accumulation per thread /
# per async task, so concurrent gateway-session prompt builds can't drain or
# clear each other's pending warnings (cross-session leak). Each build runs in
# its own context, collects its own warnings, and drains them synchronously.
_truncation_warnings: "contextvars.ContextVar[Optional[list]]" = contextvars.ContextVar(
"context_file_truncation_warnings", default=None
)
def _record_truncation_warning(msg: str) -> None:
"""Append a truncation warning to the current context's accumulator."""
warnings = _truncation_warnings.get()
if warnings is None:
warnings = []
_truncation_warnings.set(warnings)
warnings.append(msg)
def drain_truncation_warnings() -> list:
"""Return and clear any truncation warnings accumulated in this context."""
warnings = _truncation_warnings.get()
if not warnings:
return []
drained = list(warnings)
warnings.clear()
return drained
# =========================================================================
# Skills prompt cache
# =========================================================================
@@ -1510,19 +1463,10 @@ def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) -
# Context files (SOUL.md, AGENTS.md, .cursorrules)
# =========================================================================
def _truncate_content(content: str, filename: str, max_chars: Optional[int] = None) -> str:
def _truncate_content(content: str, filename: str, max_chars: int = CONTEXT_FILE_MAX_CHARS) -> str:
"""Head/tail truncation with a marker in the middle."""
if max_chars is None:
max_chars = _get_context_file_max_chars()
if len(content) <= max_chars:
return content
msg = (
f"⚠️ Context file {filename} TRUNCATED: "
f"{len(content)} chars exceeds limit of {max_chars}"
f"increase context_file_max_chars or trim the file!"
)
logger.warning(msg)
_record_truncation_warning(msg)
head_chars = int(max_chars * CONTEXT_TRUNCATE_HEAD_RATIO)
tail_chars = int(max_chars * CONTEXT_TRUNCATE_TAIL_RATIO)
head = content[:head_chars]
+8
View File
@@ -0,0 +1,8 @@
"""Egress proxy integrations.
Currently ships an iron-proxy (ironsh/iron-proxy) wrapper that intercepts
outbound traffic from remote terminal sandboxes and swaps proxy tokens
for real upstream credentials at the network edge.
Design notes live in :mod:`agent.proxy_sources.iron_proxy`.
"""
File diff suppressed because it is too large Load Diff
-1
View File
@@ -104,7 +104,6 @@ _PREFIX_PATTERNS = [
r"mem0_[A-Za-z0-9]{10,}", # Mem0 Platform API key
r"brv_[A-Za-z0-9]{10,}", # ByteRover API key
r"xai-[A-Za-z0-9]{30,}", # xAI (Grok) API key
r"ntn_[A-Za-z0-9]{10,}", # Notion internal integration token
]
# ENV assignment patterns: KEY=value where KEY contains a secret-like name
-85
View File
@@ -26,91 +26,6 @@ _skill_commands_platform: Optional[str] = None
_SKILL_INVALID_CHARS = re.compile(r"[^a-z0-9-]")
_SKILL_MULTI_HYPHEN = re.compile(r"-{2,}")
# ---------------------------------------------------------------------------
# Skill-scaffolding markers and the canonical extractor.
#
# When a user invokes a /skill (or /bundle), Hermes expands the turn into a
# model-facing message that embeds the full skill body plus scaffolding. That
# expanded text is what flows into the agent loop — and into memory providers
# via MemoryManager. Providers that store or embed the raw user turn (mem0,
# openviking, hindsight, retaindb, byterover, honcho, supermemory) would
# otherwise capture the entire skill body instead of what the user actually
# asked. ``extract_user_instruction_from_skill_message`` recovers just the
# user's instruction so memory stays clean.
#
# These markers MUST stay byte-identical to the builders below
# (``_build_skill_message`` here, ``build_bundle_invocation_message`` in
# agent/skill_bundles.py). They are co-located with the single-skill builder
# on purpose, and the bundle markers are asserted against the bundle builder in
# tests/openviking_plugin/test_openviking.py::test_skill_markers_match_hermes_scaffolding.
# ---------------------------------------------------------------------------
_SKILL_INVOCATION_PREFIX = "[IMPORTANT: The user has invoked the "
_SINGLE_SKILL_MARKER = "The full skill content is loaded below.]"
_SINGLE_SKILL_INSTRUCTION = (
"The user has provided the following instruction alongside the skill invocation: "
)
_RUNTIME_NOTE = "\n\n[Runtime note:"
_BUNDLE_MARKER = " skill bundle,"
_BUNDLE_USER_INSTRUCTION = "\nUser instruction: "
_BUNDLE_FIRST_SKILL_BLOCK = "\n\n[Loaded as part of the "
def extract_user_instruction_from_skill_message(content: Any) -> Optional[str]:
"""Recover the user's instruction from a slash-skill-expanded turn.
Returns:
- The original string unchanged when it is NOT skill scaffolding
(a normal user message passes straight through).
- The extracted user instruction when the scaffolding carried one.
- ``None`` when the content is skill scaffolding with no user
instruction (i.e. a bare ``/skill`` invocation). Callers that feed
memory providers should skip the turn in that case there is no
user content worth storing.
"""
if not isinstance(content, str):
return None
if not content.startswith(_SKILL_INVOCATION_PREFIX):
return content
if _BUNDLE_MARKER in content:
return _extract_bundle_user_instruction(content)
if _SINGLE_SKILL_MARKER in content:
return _extract_single_skill_user_instruction(content)
return None
def _extract_single_skill_user_instruction(message: str) -> Optional[str]:
# Single-skill format appends the user instruction after the skill body, so
# the last occurrence is the user-provided one; the body may quote this text.
marker_idx = message.rfind(_SINGLE_SKILL_INSTRUCTION)
if marker_idx < 0:
return None
instruction = message[marker_idx + len(_SINGLE_SKILL_INSTRUCTION):]
runtime_idx = instruction.find(_RUNTIME_NOTE)
if runtime_idx >= 0:
instruction = instruction[:runtime_idx]
instruction = instruction.strip()
return instruction or None
def _extract_bundle_user_instruction(message: str) -> Optional[str]:
# Bundle format puts the user instruction before the loaded skills, so the
# first occurrence is the user-provided one.
marker_idx = message.find(_BUNDLE_USER_INSTRUCTION)
if marker_idx < 0:
return None
instruction = message[marker_idx + len(_BUNDLE_USER_INSTRUCTION):]
first_skill_idx = instruction.find(_BUNDLE_FIRST_SKILL_BLOCK)
if first_skill_idx >= 0:
instruction = instruction[:first_skill_idx]
instruction = instruction.strip()
return instruction or None
def _resolve_skill_commands_platform() -> Optional[str]:
"""Return the current platform scope used for disabled-skill filtering.
+9 -53
View File
@@ -43,20 +43,14 @@ EXCLUDED_SKILL_DIRS = frozenset(
)
)
# Supporting files live inside a skill package and are loaded explicitly via
# skill_view(skill, file_path=...). They are not standalone skills and must not
# be scanned for active SKILL.md/DESCRIPTION.md entries, even if a Curator or
# archive workflow preserves a complete old skill package under references/.
SKILL_SUPPORT_DIRS = frozenset(("references", "templates", "assets", "scripts"))
def is_excluded_skill_path(path) -> bool:
"""True if *path* should be skipped by active skill scanners.
"""True if any component of *path* is in EXCLUDED_SKILL_DIRS.
Use this on every ``SKILL.md`` path produced by direct ``rglob`` scans to
prune dependency, virtualenv, VCS, cache, and progressive-disclosure
support-package paths. Centralising the check here keeps every
skill-scanning site in sync with the shared exclusion set.
Use this on every SKILL.md path produced by ``rglob`` to prune
dependency, virtualenv, VCS, and cache directories. Centralising the
check here keeps every skill-scanning site in sync with the shared
exclusion set.
Accepts a Path or string.
"""
@@ -65,36 +59,7 @@ def is_excluded_skill_path(path) -> bool:
except AttributeError:
from pathlib import PurePath
parts = PurePath(str(path)).parts
return any(part in EXCLUDED_SKILL_DIRS for part in parts) or is_skill_support_path(
path
)
def is_skill_support_path(path) -> bool:
"""True if *path* is under a support dir of an actual skill root.
``references/``, ``templates/``, ``assets/``, and ``scripts/`` are
progressive-disclosure support areas when they sit directly inside a skill
directory containing ``SKILL.md``. They are not active discovery roots for
standalone skills. A preserved package such as
``some-skill/references/old-skill-package/SKILL.md`` is documentation data
unless the caller explicitly loads it via ``file_path``.
Legitimate categories or skill names such as ``skills/scripts/foo`` remain
discoverable because their ``scripts`` component is not directly under a
directory that contains ``SKILL.md``.
"""
path_obj = path if isinstance(path, Path) else Path(str(path))
parts = path_obj.parts
# Last component may be a file or candidate skill directory name. Only
# components before the leaf can be containing support directories.
for idx, part in enumerate(parts[:-1]):
if part not in SKILL_SUPPORT_DIRS or idx == 0:
continue
skill_root = Path(*parts[:idx])
if (skill_root / "SKILL.md").exists():
return True
return False
return any(part in EXCLUDED_SKILL_DIRS for part in parts)
# ── Lazy YAML loader ─────────────────────────────────────────────────────
@@ -696,21 +661,12 @@ def extract_skill_description(frontmatter: Dict[str, Any]) -> str:
def iter_skill_index_files(skills_dir: Path, filename: str):
"""Walk skills_dir yielding sorted paths matching *filename*.
Excludes Hermes metadata, VCS, virtualenv/dependency, cache, and skill
support directories. Support directories (references/templates/assets/
scripts) can contain arbitrary markdown and even archived package
``SKILL.md`` files, but they are progressive-disclosure data loaded through
``skill_view(..., file_path=...)`` rather than active skill roots.
Excludes Hermes metadata, VCS, virtualenv/dependency, and cache
directories so dependencies cannot register nested skills.
"""
matches = []
for root, dirs, files in os.walk(skills_dir, followlinks=True):
has_skill_md = "SKILL.md" in files
dirs[:] = [
d
for d in dirs
if d not in EXCLUDED_SKILL_DIRS
and not (has_skill_md and d in SKILL_SUPPORT_DIRS)
]
dirs[:] = [d for d in dirs if d not in EXCLUDED_SKILL_DIRS]
if filename in files:
matches.append(Path(root) / filename)
for path in sorted(matches, key=lambda p: str(p.relative_to(skills_dir))):
+1 -9
View File
@@ -40,7 +40,6 @@ from agent.prompt_builder import (
TASK_COMPLETION_GUIDANCE,
TOOL_USE_ENFORCEMENT_GUIDANCE,
TOOL_USE_ENFORCEMENT_MODELS,
drain_truncation_warnings,
)
from agent.runtime_cwd import resolve_context_cwd
@@ -401,14 +400,7 @@ def build_system_prompt(agent: Any, system_message: Optional[str] = None) -> str
warm across turns.
"""
parts = build_system_prompt_parts(agent, system_message=system_message)
joined = "\n\n".join(p for p in (parts["stable"], parts["context"], parts["volatile"]) if p)
# Surface context-file truncation warnings through the normal agent status
# channel so gateway/CLI users see them in chat instead of only in logs.
for warning in drain_truncation_warnings():
agent._emit_status(warning)
return joined
return "\n\n".join(p for p in (parts["stable"], parts["context"], parts["volatile"]) if p)
def invalidate_system_prompt(agent: Any) -> None:
-1
View File
@@ -531,7 +531,6 @@ class ChatCompletionsTransport(ProviderTransport):
supports_reasoning=params.get("supports_reasoning", False),
qwen_session_metadata=params.get("qwen_session_metadata"),
model=model,
base_url=params.get("base_url"),
ollama_num_ctx=params.get("ollama_num_ctx"),
session_id=params.get("session_id"),
)
+3 -3
View File
@@ -16,7 +16,7 @@
},
"dependencies": {
"@nous-research/ui": "0.16.0",
"@tailwindcss/vite": "^4.2.4",
"@tailwindcss/vite": "^4.2.1",
"@tailwindcss/typography": "^0.5.19",
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
@@ -40,8 +40,8 @@
"@tauri-apps/cli": "^2.0.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"@vitejs/plugin-react": "^5.2.0",
"typescript": "^6.0.3",
"vite": "^8.0.16"
"vite": "^7.3.1"
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"compilerOptions": {
"target": "ES2023",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
+1 -1
View File
@@ -34,7 +34,7 @@ It builds and launches the GUI against your existing install — same config, ke
### Prebuilt installers
Prebuilt installers are built and distributed via [the Hermes Desktop website.](https://hermes-agent.nousresearch.com/).
Prebuilt installers are built and distributed via [the Hermes Desktop website.](https://hermes-agent.nousresearch.com/desktop).
---
@@ -166,39 +166,6 @@ function profileRemoteOverride(config, profile) {
return { url, authMode: normAuthMode(entry.authMode), token: entry.token }
}
/**
* In global-remote mode one backend serves every Desktop profile, so REST calls
* that are scoped by renderer-side `request.profile` must carry that scope as a
* query parameter. Local pooled backends and per-profile remote overrides do not
* need this: they already run against a backend scoped to the target profile.
*/
function pathWithGlobalRemoteProfile(path, profile, opts = {}) {
const scopedProfile = connectionScopeKey(profile)
if (!scopedProfile || !opts.globalRemote || opts.profileRemoteOverride) {
return path
}
const rawPath = String(path || '')
if (!rawPath) {
return path
}
let parsed
try {
parsed = new URL(rawPath, 'http://hermes.local')
} catch {
return path
}
if (parsed.searchParams.has('profile')) {
return path
}
parsed.searchParams.set('profile', scopedProfile)
return `${parsed.pathname}${parsed.search}${parsed.hash}`
}
function tokenPreview(value) {
const raw = String(value || '')
@@ -280,7 +247,6 @@ module.exports = {
cookiesHaveLiveSession,
normAuthMode,
normalizeRemoteBaseUrl,
pathWithGlobalRemoteProfile,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
@@ -24,7 +24,6 @@ const {
cookiesHaveLiveSession,
normAuthMode,
normalizeRemoteBaseUrl,
pathWithGlobalRemoteProfile,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
@@ -91,72 +90,6 @@ test('profileRemoteOverride tolerates a missing/!object profiles map', () => {
assert.equal(profileRemoteOverride(null, 'coder'), null)
})
// --- pathWithGlobalRemoteProfile ---
test('pathWithGlobalRemoteProfile appends profile in global remote mode', () => {
assert.equal(
pathWithGlobalRemoteProfile('/api/model/info', 'iris', {
globalRemote: true,
profileRemoteOverride: false
}),
'/api/model/info?profile=iris'
)
})
test('pathWithGlobalRemoteProfile preserves existing query params', () => {
assert.equal(
pathWithGlobalRemoteProfile('/api/model/options?force=1', 'iris', {
globalRemote: true,
profileRemoteOverride: false
}),
'/api/model/options?force=1&profile=iris'
)
})
test('pathWithGlobalRemoteProfile does not replace an explicit profile query', () => {
assert.equal(
pathWithGlobalRemoteProfile('/api/model/info?profile=default', 'iris', {
globalRemote: true,
profileRemoteOverride: false
}),
'/api/model/info?profile=default'
)
})
test('pathWithGlobalRemoteProfile skips local and per-profile remote override paths', () => {
assert.equal(
pathWithGlobalRemoteProfile('/api/model/info', 'iris', {
globalRemote: false,
profileRemoteOverride: false
}),
'/api/model/info'
)
assert.equal(
pathWithGlobalRemoteProfile('/api/model/info', 'iris', {
globalRemote: true,
profileRemoteOverride: true
}),
'/api/model/info'
)
})
test('pathWithGlobalRemoteProfile skips empty profile/path safely', () => {
assert.equal(
pathWithGlobalRemoteProfile('/api/model/info', '', {
globalRemote: true,
profileRemoteOverride: false
}),
'/api/model/info'
)
assert.equal(
pathWithGlobalRemoteProfile('', 'iris', {
globalRemote: true,
profileRemoteOverride: false
}),
''
)
})
// --- normalizeRemoteBaseUrl ---
test('normalizeRemoteBaseUrl strips trailing slashes, hash, and query', () => {
+51 -338
View File
@@ -39,7 +39,6 @@ const { waitForDashboardPort } = require('./backend-ready.cjs')
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs')
const { readWindowsUserEnvVar } = require('./windows-user-env.cjs')
const { readDirForIpc } = require('./fs-read-dir.cjs')
const { gitRootForIpc } = require('./git-root.cjs')
const { worktreesForIpc } = require('./git-worktrees.cjs')
@@ -63,7 +62,6 @@ const {
cookiesHaveLiveSession,
normAuthMode,
normalizeRemoteBaseUrl,
pathWithGlobalRemoteProfile,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
@@ -244,16 +242,6 @@ if (INSTALL_STAMP) {
function resolveHermesHome() {
if (process.env.HERMES_HOME) return normalizeHermesHomeRoot(process.env.HERMES_HOME)
if (USER_DATA_OVERRIDE) return path.join(path.resolve(USER_DATA_OVERRIDE), 'hermes-home')
if (IS_WINDOWS) {
// A GUI app launched from Explorer inherits the environment block captured
// at login, so a HERMES_HOME set via `setx` AFTER login is invisible in
// process.env even though the CLI (a fresh shell) sees it. Without this the
// backend silently falls back to %LOCALAPPDATA%\hermes and reports "No
// inference provider configured" despite a valid configured home (#45471).
// Consult the live User-scoped registry value before the default below.
const fromRegistry = readWindowsUserEnvVar('HERMES_HOME')
if (fromRegistry) return normalizeHermesHomeRoot(fromRegistry)
}
if (IS_WINDOWS && process.env.LOCALAPPDATA) {
const localappdata = path.join(process.env.LOCALAPPDATA, 'hermes')
const legacy = path.join(app.getPath('home'), '.hermes')
@@ -5084,211 +5072,65 @@ function focusWindow(win) {
win.focus()
}
function spawnSecondaryWindow({ sessionId, watch, newSession } = {}) {
const icon = getAppIconPath()
const win = new BrowserWindow({
width: SESSION_WINDOW_MIN_WIDTH,
height: SESSION_WINDOW_MIN_HEIGHT,
minWidth: SESSION_WINDOW_MIN_WIDTH,
minHeight: SESSION_WINDOW_MIN_HEIGHT,
title: 'Hermes',
titleBarStyle: 'hidden',
titleBarOverlay: getTitleBarOverlayOptions(),
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
vibrancy: IS_MAC ? 'sidebar' : undefined,
opacity: windowOpacity(),
icon,
// Don't show until the renderer's first themed paint is ready. macOS
// `vibrancy` ignores `backgroundColor` and paints a translucent OS
// material (which follows the OS appearance, not the app theme), so a
// dark-themed app on a light-mode Mac flashes white until the renderer
// covers it. ready-to-show fires after the boot-time paint in
// themes/context.tsx, so the window appears already themed.
show: false,
backgroundColor: getWindowBackgroundColor(),
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
webviewTag: true,
sandbox: true,
nodeIntegration: false,
devTools: true
}
})
if (IS_MAC) {
win.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION)
}
win.once('ready-to-show', () => {
if (!win.isDestroyed()) win.show()
})
win.on('will-enter-full-screen', () => sendWindowStateChanged(true))
win.on('enter-full-screen', () => sendWindowStateChanged(true))
win.on('will-leave-full-screen', () => sendWindowStateChanged(false))
win.on('leave-full-screen', () => sendWindowStateChanged(false))
wireCommonWindowHandlers(win)
win.loadURL(
buildSessionWindowUrl(sessionId, {
devServer: DEV_SERVER,
rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex(),
watch,
newSession
})
)
return win
}
// Open (or focus) a standalone window for a single chat session.
function createSessionWindow(sessionId, { watch = false } = {}) {
return sessionWindows.openOrFocus(sessionId, () => spawnSecondaryWindow({ sessionId, watch }))
}
return sessionWindows.openOrFocus(sessionId, () => {
const icon = getAppIconPath()
const win = new BrowserWindow({
width: SESSION_WINDOW_MIN_WIDTH,
height: SESSION_WINDOW_MIN_HEIGHT,
minWidth: SESSION_WINDOW_MIN_WIDTH,
minHeight: SESSION_WINDOW_MIN_HEIGHT,
title: 'Hermes',
titleBarStyle: 'hidden',
titleBarOverlay: getTitleBarOverlayOptions(),
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
vibrancy: IS_MAC ? 'sidebar' : undefined,
opacity: windowOpacity(),
icon,
// Don't show until the renderer's first themed paint is ready. macOS
// `vibrancy` ignores `backgroundColor` and paints a translucent OS
// material (which follows the OS appearance, not the app theme), so a
// dark-themed app on a light-mode Mac flashes white until the renderer
// covers it. ready-to-show fires after the boot-time paint in
// themes/context.tsx, so the window appears already themed.
show: false,
backgroundColor: getWindowBackgroundColor(),
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
webviewTag: true,
sandbox: true,
nodeIntegration: false,
devTools: true
}
})
// Open a fresh compact window on the new-session draft (#/). Not registry-keyed:
// like ⌘N in a browser, every press opens a new window — and a draft window that
// later converts to a real session must not get refocused as if it were blank.
function createNewSessionWindow() {
return spawnSecondaryWindow({ newSession: true })
}
// The pet overlay: a single transparent, frameless, always-on-top window that
// hosts ONLY the floating mascot. Shift-clicking the in-window pet "pops it out"
// here so it can leave the app's bounds and stay visible while Hermes is
// minimized (Codex-style task-completion glance). It carries no gateway
// connection of its own — the main renderer is the single source of truth and
// pushes pet state over IPC (hermes:pet-overlay:state); the overlay just renders
// it. Control flows back (pop-in, composer submit) via hermes:pet-overlay:control.
let petOverlayWindow = null
function petOverlayUrl() {
if (DEV_SERVER) {
return `${DEV_SERVER.endsWith('/') ? DEV_SERVER.slice(0, -1) : DEV_SERVER}/?win=overlay#/`
}
return `${pathToFileURL(resolveRendererIndex()).toString()}?win=overlay#/`
}
function spawnPetOverlayWindow(bounds) {
const win = new BrowserWindow({
width: Math.max(80, Math.round(bounds?.width || 220)),
height: Math.max(80, Math.round(bounds?.height || 220)),
x: Number.isFinite(bounds?.x) ? Math.round(bounds.x) : undefined,
y: Number.isFinite(bounds?.y) ? Math.round(bounds.y) : undefined,
frame: false,
transparent: true,
resizable: false,
movable: true,
minimizable: false,
maximizable: false,
fullscreenable: false,
// Windows/Linux need this so the helper window does not get its own
// taskbar/alt-tab entry. On macOS, cmd-tab is app-level and this can make
// the whole app look like it vanished when the only newly-created visible
// window is a frameless overlay. Use NSPanel + Mission Control hiding below
// instead, leaving the main Hermes app as the Dock/cmd-tab anchor.
skipTaskbar: !IS_MAC,
hasShadow: false,
alwaysOnTop: true,
// macOS panels are non-activating helper windows and can float over full
// screen spaces without becoming the app's main switcher window.
type: IS_MAC ? 'panel' : undefined,
hiddenInMissionControl: IS_MAC,
// Non-activating: the overlay must never become the app's key/main window,
// or it (a frameless, taskbar-skipping panel) becomes the app's switcher
// anchor and the Hermes icon drops out of cmd/alt-tab — especially when the
// main window is minimized. We flip this on only while the composer needs
// the keyboard (see hermes:pet-overlay:set-focusable).
focusable: false,
show: false,
// Fully transparent — the renderer paints only the sprite + bubble.
backgroundColor: '#00000000',
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
sandbox: true,
nodeIntegration: false,
devTools: true,
// Keep the sprite animating + bubble updating while the main window is
// minimized/blurred — the whole point of the overlay.
backgroundThrottling: false
}
})
// Float above other apps and follow the user across desktops so the pet is
// always reachable. `floating` + `type: panel` is the macOS NSPanel path; the
// more aggressive `screen-saver` level can interfere with normal app/window
// switching semantics.
win.setAlwaysOnTop(true, IS_MAC ? 'floating' : 'screen-saver')
win.setHiddenInMissionControl?.(true)
try {
// Electron docs: macOS may transform process type on each
// setVisibleOnAllWorkspaces() call unless skipTransformProcessType=true,
// which briefly hides the Dock/cmd-tab presence. Keep Hermes in the normal
// ForegroundApplication class so shift-clicking the pet never drops the app
// out of app switchers.
win.setVisibleOnAllWorkspaces(
true,
IS_MAC ? { visibleOnFullScreen: true, skipTransformProcessType: true } : undefined
)
} catch {
// Not supported everywhere — best effort.
}
wireCommonWindowHandlers(win)
win.once('ready-to-show', () => {
if (!win.isDestroyed()) win.showInactive()
})
win.on('closed', () => {
if (petOverlayWindow === win) {
petOverlayWindow = null
if (IS_MAC) {
win.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION)
}
// If the overlay went away on its own (e.g. ⌘W), tell the main renderer to
// pop the pet back in so it doesn't stay hidden. Harmless echo when we're
// the ones who closed it (popInPet already cleared the active flag).
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('hermes:pet-overlay:control', { type: 'pop-in' })
}
})
win.once('ready-to-show', () => {
if (!win.isDestroyed()) win.show()
})
win.loadURL(petOverlayUrl())
win.on('will-enter-full-screen', () => sendWindowStateChanged(true))
win.on('enter-full-screen', () => sendWindowStateChanged(true))
win.on('will-leave-full-screen', () => sendWindowStateChanged(false))
win.on('leave-full-screen', () => sendWindowStateChanged(false))
return win
}
wireCommonWindowHandlers(win)
function openPetOverlay(bounds) {
if (petOverlayWindow && !petOverlayWindow.isDestroyed()) {
if (bounds) {
petOverlayWindow.setBounds({
x: Math.round(bounds.x),
y: Math.round(bounds.y),
width: Math.max(80, Math.round(bounds.width)),
height: Math.max(80, Math.round(bounds.height))
win.loadURL(
buildSessionWindowUrl(sessionId, {
devServer: DEV_SERVER,
rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex(),
watch
})
}
)
petOverlayWindow.showInactive()
return petOverlayWindow
}
petOverlayWindow = spawnPetOverlayWindow(bounds)
return petOverlayWindow
}
function closePetOverlay() {
if (petOverlayWindow && !petOverlayWindow.isDestroyed()) {
petOverlayWindow.close()
}
petOverlayWindow = null
return win
})
}
function createWindow() {
@@ -5360,11 +5202,6 @@ function createWindow() {
mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false))
mainWindow.on('leave-full-screen', () => sendWindowStateChanged(false))
// The overlay rides the main window — closing the app's primary window must
// tear it down too (otherwise it strands as an orphan that blocks
// window-all-closed from quitting on Windows/Linux).
mainWindow.on('closed', () => closePetOverlay())
wireCommonWindowHandlers(mainWindow)
mainWindow.webContents.on('render-process-gone', (_event, details) => {
@@ -5480,121 +5317,6 @@ ipcMain.handle('hermes:window:openSession', async (_event, sessionId, opts) => {
return { ok: true }
})
ipcMain.handle('hermes:window:openNewSession', async () => {
createNewSessionWindow()
return { ok: true }
})
// --- Pet overlay (pop-out mascot) -----------------------------------------
// `request` is `{ bounds, screen }`. A fresh pop-out passes viewport-space
// bounds (screen=false): convert to screen space by adding the main window's
// content origin so the pet lands where it sat in-window. A remembered/dragged
// spot passes screen-space bounds (screen=true) and is used as-is. We return the
// resolved screen bounds so the renderer can persist exactly where it opened.
ipcMain.handle('hermes:pet-overlay:open', async (_event, request) => {
const bounds = request && request.bounds ? request.bounds : request
const isScreen = Boolean(request && request.screen)
let screenBounds = bounds
try {
if (bounds && !isScreen && mainWindow && !mainWindow.isDestroyed()) {
const content = mainWindow.getContentBounds()
screenBounds = {
x: content.x + (bounds.x || 0),
y: content.y + (bounds.y || 0),
width: bounds.width,
height: bounds.height
}
}
} catch {
// Fall back to raw bounds if the window geometry is unavailable.
}
openPetOverlay(screenBounds)
return { ok: true, bounds: screenBounds }
})
ipcMain.handle('hermes:pet-overlay:close', async () => {
closePetOverlay()
return { ok: true }
})
// Drag: the overlay reports a new absolute screen position (it already knows the
// pointer's screen coords), we just move the window.
ipcMain.on('hermes:pet-overlay:set-bounds', (_event, bounds) => {
if (!petOverlayWindow || petOverlayWindow.isDestroyed() || !bounds) {
return
}
petOverlayWindow.setBounds({
x: Math.round(bounds.x),
y: Math.round(bounds.y),
width: Math.max(80, Math.round(bounds.width)),
height: Math.max(80, Math.round(bounds.height))
})
})
// Click-through: the overlay window is a full rectangle but only the pet pixels
// should be interactive. The renderer toggles this as the cursor enters/leaves
// the sprite so transparent margins pass clicks to whatever is behind.
ipcMain.on('hermes:pet-overlay:ignore-mouse', (_event, ignore) => {
if (petOverlayWindow && !petOverlayWindow.isDestroyed()) {
petOverlayWindow.setIgnoreMouseEvents(Boolean(ignore), { forward: true })
}
})
// The overlay is a non-activating panel (focusable:false) so it never steals
// the app's cmd/alt-tab anchor from the main window. But the pop-up composer
// needs the keyboard, so the renderer asks us to flip it focusable + focus it
// while the composer is open, then back to non-activating when it closes.
ipcMain.on('hermes:pet-overlay:set-focusable', (_event, focusable) => {
if (!petOverlayWindow || petOverlayWindow.isDestroyed()) {
return
}
petOverlayWindow.setFocusable(Boolean(focusable))
if (focusable) {
petOverlayWindow.focus()
}
})
// Main renderer → overlay: forward the latest pet state for the overlay to render.
ipcMain.on('hermes:pet-overlay:state', (_event, payload) => {
if (petOverlayWindow && !petOverlayWindow.isDestroyed()) {
petOverlayWindow.webContents.send('hermes:pet-overlay:state', payload)
}
})
// Overlay → main renderer: control messages (pop back in, composer submit).
ipcMain.on('hermes:pet-overlay:control', (_event, payload) => {
if (!mainWindow || mainWindow.isDestroyed()) {
return
}
// Double-click toggles the app window: hide it away if it's up front, bring it
// back if it's minimized/buried. Pure window control — nothing for the
// renderer to do, so don't forward it.
if (payload && payload.type === 'toggle-app') {
if (mainWindow.isMinimized() || !mainWindow.isVisible()) {
mainWindow.show()
mainWindow.focus()
} else {
mainWindow.minimize()
}
return
}
// The mail icon means "take me to the app": raise the main window (it may be
// minimized or buried) before the renderer navigates to the latest thread.
if (payload && payload.type === 'open-app') {
if (mainWindow.isMinimized()) {
mainWindow.restore()
}
mainWindow.show()
mainWindow.focus()
}
mainWindow.webContents.send('hermes:pet-overlay:control', payload)
})
ipcMain.handle('hermes:bootstrap:reset', async () => {
// Renderer's "Reload and retry" path. Clear the latched failure and
// reset connection state so the next startHermes() call restarts the
@@ -5864,14 +5586,9 @@ ipcMain.handle('hermes:api', async (_event, request) => {
await prepareProfileDeleteRequest(request)
const profile = request?.profile
const connection = await ensureBackend(profile)
const connection = await ensureBackend(request?.profile)
const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
const requestPath = pathWithGlobalRemoteProfile(request.path, profile, {
globalRemote: globalRemoteActive(),
profileRemoteOverride: profileHasRemoteOverride(profile)
})
const url = `${connection.baseUrl}${requestPath}`
const url = `${connection.baseUrl}${request.path}`
// OAuth gateways authenticate REST via the HttpOnly session cookie held in
// the OAuth partition — route through Electron's net stack bound to that
// session so the cookie attaches automatically. Token/local modes keep using
@@ -6799,10 +6516,6 @@ function configureSpellChecker() {
}
app.on('before-quit', () => {
// The always-on-top overlay isn't a "real" app window; close it so a stray
// pet can't keep the process alive or float over a quit app.
closePetOverlay()
// Quitting mid-install should stop the installer, not orphan it.
if (bootstrapAbortController) {
try {
-27
View File
@@ -6,33 +6,6 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts),
openNewSessionWindow: () => ipcRenderer.invoke('hermes:window:openNewSession'),
petOverlay: {
// Main renderer → main process: window lifecycle + drag. `request` is
// `{ bounds, screen }`; resolves with the screen bounds it actually used.
open: request => ipcRenderer.invoke('hermes:pet-overlay:open', request),
close: () => ipcRenderer.invoke('hermes:pet-overlay:close'),
setBounds: bounds => ipcRenderer.send('hermes:pet-overlay:set-bounds', bounds),
setIgnoreMouse: ignore => ipcRenderer.send('hermes:pet-overlay:ignore-mouse', ignore),
// Flip the overlay focusable (and focus it) while the composer needs keys.
setFocusable: focusable => ipcRenderer.send('hermes:pet-overlay:set-focusable', focusable),
// Main renderer → overlay (forwarded by main): push the latest pet state.
pushState: payload => ipcRenderer.send('hermes:pet-overlay:state', payload),
// Overlay → main renderer (forwarded by main): pop back in / composer submit.
control: payload => ipcRenderer.send('hermes:pet-overlay:control', payload),
// Overlay subscribes to state pushes.
onState: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:pet-overlay:state', listener)
return () => ipcRenderer.removeListener('hermes:pet-overlay:state', listener)
},
// Main renderer subscribes to overlay control messages.
onControl: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:pet-overlay:control', listener)
return () => ipcRenderer.removeListener('hermes:pet-overlay:control', listener)
}
},
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile),
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
+6 -7
View File
@@ -15,13 +15,12 @@ const SESSION_WINDOW_MIN_HEIGHT = 620
// flag MUST sit in the query string BEFORE the '#': anything after the '#' is
// treated as the route by HashRouter and would break routeSessionId(). The
// renderer reads the flag from window.location.search to suppress the install /
// onboarding overlays and the global session sidebar. `new=1` marks the compact
// scratch window; `watch=1` marks a spectator window (e.g. a running subagent's
// session): the renderer resumes it lazily so the gateway never builds an agent
// just to stream into it.
function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath, watch, newSession } = {}) {
const query = `?win=secondary${newSession ? '&new=1' : ''}${watch ? '&watch=1' : ''}`
const route = newSession ? '#/' : `#/${encodeURIComponent(sessionId)}`
// onboarding overlays and the global session sidebar. `watch=1` marks a
// spectator window (e.g. a running subagent's session): the renderer resumes
// it lazily so the gateway never builds an agent just to stream into it.
function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath, watch } = {}) {
const query = `?win=secondary${watch ? '&watch=1' : ''}`
const route = `#/${encodeURIComponent(sessionId)}`
if (devServer) {
const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer
@@ -82,12 +82,6 @@ test('buildSessionWindowUrl adds the watch flag for spectator windows, before th
assert.equal(url, 'http://localhost:5173/?win=secondary&watch=1#/abc')
})
test('buildSessionWindowUrl routes new-session windows to the draft (#/)', () => {
const url = buildSessionWindowUrl(null, { devServer: 'http://localhost:5173', newSession: true })
assert.equal(url, 'http://localhost:5173/?win=secondary&new=1#/')
})
test('registry opens one window per session and focuses on re-open', () => {
const registry = createSessionWindowRegistry()
let built = 0
@@ -1,76 +0,0 @@
// windows-user-env.cjs
//
// Read a User-scoped environment variable straight from the Windows registry
// (HKCU\Environment).
//
// A GUI app launched from Explorer inherits the environment block captured at
// login, so a variable set via `setx` AFTER login is invisible in process.env
// even though a fresh shell — and the Hermes CLI — sees it immediately. The
// desktop's HERMES_HOME resolution relies on process.env, so that stale-snapshot
// gap silently sends the backend to the default %LOCALAPPDATA%\hermes. Reading
// the live registry value closes the gap. See #45471.
const { execFileSync } = require('node:child_process')
// Parse the output of `reg query HKCU\Environment /v <name>`, which looks like:
//
// HKEY_CURRENT_USER\Environment
// HERMES_HOME REG_SZ F:\Hermes\data
//
// Returns the raw value string (spaces inside the value preserved), or null when
// the requested value line isn't present.
function parseRegQueryValue(stdout, name) {
if (!stdout || !name) return null
const typePattern =
/^(\S+)\s+(?:REG_SZ|REG_EXPAND_SZ|REG_MULTI_SZ|REG_DWORD|REG_QWORD|REG_BINARY|REG_NONE)\s+(.*)$/
for (const rawLine of String(stdout).split(/\r?\n/)) {
const line = rawLine.trim()
const match = line.match(typePattern)
if (match && match[1].toLowerCase() === name.toLowerCase()) {
return match[2]
}
}
return null
}
// Expand %VAR% references against an env map. REG_EXPAND_SZ values store
// unexpanded references; plain REG_SZ paths have none, so this is a no-op for
// the common F:\... case. Unknown references are left verbatim.
function expandWindowsEnvRefs(value, env = process.env) {
if (!value) return value
return value.replace(/%([^%]+)%/g, (whole, name) => {
const key = Object.keys(env).find(k => k.toUpperCase() === String(name).toUpperCase())
return key != null && env[key] != null ? env[key] : whole
})
}
// Read a User-scoped env var from HKCU\Environment. Windows-only: returns null
// off-Windows (without spawning), on any spawn error, when `reg` exits non-zero
// (the value doesn't exist), or when the value is empty.
function readWindowsUserEnvVar(
name,
{ platform = process.platform, env = process.env, exec = execFileSync } = {}
) {
if (platform !== 'win32' || !name) return null
let stdout
try {
stdout = exec('reg', ['query', 'HKCU\\Environment', '/v', name], {
encoding: 'utf8',
windowsHide: true,
timeout: 5000
})
} catch {
// `reg` missing, or value absent (reg exits 1) — caller falls back.
return null
}
const raw = parseRegQueryValue(stdout, name)
if (raw == null) return null
const expanded = expandWindowsEnvRefs(raw, env).trim()
return expanded || null
}
module.exports = {
expandWindowsEnvRefs,
parseRegQueryValue,
readWindowsUserEnvVar
}
@@ -1,90 +0,0 @@
const assert = require('node:assert/strict')
const { test } = require('node:test')
const {
expandWindowsEnvRefs,
parseRegQueryValue,
readWindowsUserEnvVar
} = require('./windows-user-env.cjs')
// ── parseRegQueryValue ─────────────────────────────────────────────────────
test('parseRegQueryValue extracts a REG_SZ value', () => {
const out = [
'',
'HKEY_CURRENT_USER\\Environment',
' HERMES_HOME REG_SZ F:\\Hermes\\data',
''
].join('\r\n')
assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), 'F:\\Hermes\\data')
})
test('parseRegQueryValue matches the name case-insensitively', () => {
const out = 'HKEY_CURRENT_USER\\Environment\r\n Hermes_Home REG_EXPAND_SZ %USERPROFILE%\\h\r\n'
assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), '%USERPROFILE%\\h')
})
test('parseRegQueryValue preserves spaces inside the value', () => {
const out = ' HERMES_HOME REG_SZ C:\\Program Files\\Hermes\r\n'
assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), 'C:\\Program Files\\Hermes')
})
test('parseRegQueryValue returns null when the value line is absent', () => {
const out = 'HKEY_CURRENT_USER\\Environment\r\n Path REG_SZ C:\\x\r\n'
assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), null)
assert.equal(parseRegQueryValue('', 'HERMES_HOME'), null)
assert.equal(parseRegQueryValue('garbage', 'HERMES_HOME'), null)
})
// ── expandWindowsEnvRefs ───────────────────────────────────────────────────
test('expandWindowsEnvRefs expands %VAR% case-insensitively', () => {
assert.equal(
expandWindowsEnvRefs('%UserProfile%\\h', { USERPROFILE: 'C:\\Users\\jeff' }),
'C:\\Users\\jeff\\h'
)
})
test('expandWindowsEnvRefs leaves literal paths and unknown refs intact', () => {
assert.equal(expandWindowsEnvRefs('F:\\Hermes\\data', {}), 'F:\\Hermes\\data')
assert.equal(expandWindowsEnvRefs('%NOPE%\\x', {}), '%NOPE%\\x')
})
// ── readWindowsUserEnvVar ──────────────────────────────────────────────────
test('readWindowsUserEnvVar returns null off Windows without spawning', () => {
let spawned = false
const exec = () => {
spawned = true
return ''
}
assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'linux', exec }), null)
assert.equal(spawned, false)
})
test('readWindowsUserEnvVar queries HKCU\\Environment and expands the value', () => {
const calls = []
const exec = (cmd, args) => {
calls.push([cmd, args])
return 'HKEY_CURRENT_USER\\Environment\r\n HERMES_HOME REG_EXPAND_SZ %DRIVE%\\Hermes\r\n'
}
const value = readWindowsUserEnvVar('HERMES_HOME', {
platform: 'win32',
env: { DRIVE: 'F:' },
exec
})
assert.equal(value, 'F:\\Hermes')
assert.deepEqual(calls, [['reg', ['query', 'HKCU\\Environment', '/v', 'HERMES_HOME']]])
})
test('readWindowsUserEnvVar returns null when reg exits non-zero (value missing)', () => {
const exec = () => {
throw new Error('reg exited 1')
}
assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'win32', exec }), null)
})
test('readWindowsUserEnvVar returns null for an empty value', () => {
const exec = () => ' HERMES_HOME REG_SZ \r\n'
assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'win32', exec }), null)
})
+1 -3
View File
@@ -20,7 +20,6 @@
"start": "npm run build && electron .",
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && npm run postbuild",
"postbuild": "node scripts/assert-dist-built.cjs",
"prebuilder": "node scripts/patch-electron-builder-mac-binary.cjs",
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 electron-builder",
"pack": "npm run build && npm run builder -- --dir",
"dist": "npm run build && npm run builder",
@@ -37,7 +36,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/windows-user-env.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs",
"typecheck": "tsc -p . --noEmit",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
@@ -135,7 +134,6 @@
},
"build": {
"electronVersion": "40.9.3",
"electronDist": "../../node_modules/electron/dist",
"appId": "com.nousresearch.hermes",
"productName": "Hermes",
"executableName": "Hermes",
@@ -1,59 +0,0 @@
const fs = require('node:fs')
const path = require('node:path')
if (process.platform !== 'darwin') {
process.exit(0)
}
const desktopRoot = path.resolve(__dirname, '..')
const repoRoot = path.resolve(desktopRoot, '..', '..')
const electronMacPath = path.join(repoRoot, 'node_modules', 'app-builder-lib', 'out', 'electron', 'electronMac.js')
const marker = 'hermes-macos-electron-binary-fallback'
const needle = ` await Promise.all([
doRename(path.join(contentsPath, "MacOS"), electronBranding.productName, appPlist.CFBundleExecutable),
(0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSE")),
(0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSES.chromium.html")),
]);`
const replacement = ` // ${marker}: electron-builder 26.8.x can sometimes copy
// Electron.app without its main MacOS/Electron binary before this rename.
// Restore it from the installed Electron runtime so local desktop installs
// do not fail with ENOENT during macOS arm64 packaging.
const macosDir = path.join(contentsPath, "MacOS");
const bundledElectronBinary = path.join(macosDir, electronBranding.productName);
if (!fs.existsSync(bundledElectronBinary)) {
const candidates = [
path.join(packager.info.framework.distMacOsAppName, "Contents", "MacOS", electronBranding.productName),
path.join(process.cwd(), "..", "..", "node_modules", "electron", "dist", "Electron.app", "Contents", "MacOS", electronBranding.productName),
];
const sourceBinary = candidates.find(candidate => fs.existsSync(candidate));
if (sourceBinary == null) {
throw new Error("Electron binary missing from packaged app and Electron runtime: " + bundledElectronBinary);
}
await (0, promises_1.copyFile)(sourceBinary, bundledElectronBinary);
await (0, promises_1.chmod)(bundledElectronBinary, 0o755);
}
await Promise.all([
doRename(macosDir, electronBranding.productName, appPlist.CFBundleExecutable),
(0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSE")),
(0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSES.chromium.html")),
]);`
if (!fs.existsSync(electronMacPath)) {
console.warn(`[patch-electron-builder] skipped: ${electronMacPath} not found`)
process.exit(0)
}
const source = fs.readFileSync(electronMacPath, 'utf8')
if (source.includes(marker)) {
console.log('[patch-electron-builder] macOS Electron binary fallback already applied')
process.exit(0)
}
if (!source.includes(needle)) {
console.warn('[patch-electron-builder] skipped: expected electronMac.js shape not found')
process.exit(0)
}
fs.writeFileSync(electronMacPath, source.replace(needle, replacement))
console.log('[patch-electron-builder] applied macOS Electron binary fallback')
+8 -4
View File
@@ -23,7 +23,6 @@ import { type Translations, useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
import { FileImage, FileText, FolderOpen, Link2 } from '@/lib/icons'
import { mediaExternalUrl } from '@/lib/media'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import type { SessionInfo, SessionMessage } from '@/types/hermes'
@@ -125,12 +124,17 @@ function artifactKind(value: string): ArtifactKind {
}
function artifactHref(value: string): string {
if (value.startsWith('http://') || value.startsWith('https://') || value.startsWith('data:')) {
if (
value.startsWith('http://') ||
value.startsWith('https://') ||
value.startsWith('file://') ||
value.startsWith('data:')
) {
return value
}
if (value.startsWith('file://') || value.startsWith('/')) {
return mediaExternalUrl(value)
if (value.startsWith('/')) {
return `file://${encodeURI(value)}`
}
return value
@@ -9,7 +9,6 @@ import { formatCombo } from '@/lib/keybinds/combo'
import { cn } from '@/lib/utils'
import type { ConversationStatus } from './hooks/use-voice-conversation'
import { ModelPill } from './model-pill'
import type { ChatBarState, VoiceStatus } from './types'
export const ICON_BTN = 'size-(--composer-control-size) shrink-0 rounded-md'
@@ -67,7 +66,6 @@ export function ComposerControls({
const c = t.composer
const steerCombo = formatCombo('mod+enter')
const steerLabel = `${c.steer} (${steerCombo})`
const steerTip = (
<span className="inline-flex items-center gap-1.5">
{c.steer}
@@ -83,10 +81,8 @@ export function ComposerControls({
return (
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
<ModelPill disabled={disabled} model={state.model} />
{/* While the agent runs and the user is typing, steer takes over the mic's
slot rather than crowding the row with an extra button. */}
{canSteer ? (
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
{canSteer && (
<Tip label={steerTip}>
<Button
aria-label={steerLabel}
@@ -100,8 +96,6 @@ export function ComposerControls({
<SteeringWheel size={16} />
</Button>
</Tip>
) : (
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
)}
{showVoicePrimary ? (
<Tip label={c.startVoice}>
@@ -1,86 +0,0 @@
import { useStore } from '@nanostores/react'
import { useState } from 'react'
import { ModelMenuCloseContext } from '@/app/shell/model-menu-panel'
import { Button } from '@/components/ui/button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { useI18n } from '@/i18n'
import { ChevronDown } from '@/lib/icons'
import { formatModelStatusLabel } from '@/lib/model-status-label'
import { cn } from '@/lib/utils'
import {
$currentFastMode,
$currentModel,
$currentProvider,
$currentReasoningEffort,
setModelPickerOpen
} from '@/store/session'
import type { ChatBarState } from './types'
const PILL = cn(
'h-(--composer-control-size) max-w-40 shrink-0 gap-1 rounded-md px-2 text-xs font-normal',
'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)
/**
* Composer model selector the relocated status-bar pill. Reuses the live
* `model.options` dropdown (`modelMenuContent`) verbatim; falls back to the
* full picker when the gateway is closed and no live menu exists.
*/
export function ModelPill({ disabled, model }: { disabled: boolean; model: ChatBarState['model'] }) {
const copy = useI18n().t.shell.statusbar
const currentModel = useStore($currentModel)
const currentProvider = useStore($currentProvider)
const fastMode = useStore($currentFastMode)
const reasoningEffort = useStore($currentReasoningEffort)
const [open, setOpen] = useState(false)
// The model resolves a beat after the gateway/session comes up. Rather than
// flash a literal "No model", show a quiet loader (inherits the pill text
// color at half opacity) until a model lands.
const label = (
<>
{currentModel.trim() ? (
<span className="truncate">{formatModelStatusLabel(currentModel, { fastMode, reasoningEffort })}</span>
) : (
<GlyphSpinner className="opacity-50" spinner="braille" />
)}
<ChevronDown className="size-2.5 shrink-0 opacity-50" />
</>
)
const title = currentProvider ? copy.modelTitle(currentProvider, currentModel || copy.modelNone) : copy.switchModel
if (!model.modelMenuContent) {
return (
<Button
aria-label={copy.openModelPicker}
className={PILL}
disabled={disabled}
onClick={() => setModelPickerOpen(true)}
title={copy.openModelPicker}
type="button"
variant="ghost"
>
{label}
</Button>
)
}
return (
<DropdownMenu onOpenChange={setOpen} open={open}>
<DropdownMenuTrigger asChild>
<Button aria-label={title} className={PILL} disabled={disabled} title={title} type="button" variant="ghost">
{label}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64 p-0" side="top" sideOffset={8}>
<ModelMenuCloseContext.Provider value={() => setOpen(false)}>
{model.modelMenuContent}
</ModelMenuCloseContext.Provider>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -1,5 +1,3 @@
import type { ReactNode } from 'react'
import type { HermesGateway } from '@/hermes'
import type { ComposerAttachment } from '@/store/composer'
@@ -24,8 +22,6 @@ export interface ChatBarState {
canSwitch: boolean
loading?: boolean
quickModels?: QuickModelOption[]
/** Reused status-bar dropdown (built with gateway + selectModel upstream). */
modelMenuContent?: ReactNode
}
tools: { enabled: boolean; label: string; suggestions?: ContextSuggestion[] }
voice: { enabled: boolean; active: boolean }
+6 -13
View File
@@ -42,7 +42,6 @@ import {
$sessions,
sessionPinId
} from '@/store/session'
import { isSecondaryWindow } from '@/store/windows'
import type { ModelOptionsResponse } from '@/types/hermes'
import { routeSessionId } from '../routes'
@@ -62,7 +61,6 @@ import { threadLoadingState } from './thread-loading'
interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
gateway: HermesGateway | null
modelMenuContent?: React.ReactNode
onToggleSelectedPin: () => void
onDeleteSelectedSession: () => void
onCancel: () => Promise<void> | void
@@ -121,10 +119,10 @@ function ChatHeader({
? pinnedSessionIds.includes(selectedSessionId)
: false
// Secondary windows (new-session scratch, subagent watch, cmd-click pop-out)
// are compact side panels — they drop the session-actions header + border
// entirely. A brand-new draft has nothing to pin/delete/rename either.
if (isSecondaryWindow() || (!selectedSessionId && !activeSessionId && !isRoutedSessionView)) {
// A brand-new session has no session to pin/delete/rename, so the header is
// just a dead "New session" label + chevron. Drop it (and its border)
// entirely until there's a real session to act on.
if (!selectedSessionId && !activeSessionId && !isRoutedSessionView) {
return null
}
@@ -251,7 +249,6 @@ function ChatRuntimeBoundary({
export function ChatView({
className,
gateway,
modelMenuContent,
onToggleSelectedPin,
onDeleteSelectedSession,
onCancel,
@@ -305,10 +302,7 @@ export function ChatView({
// waiting for the resume effect (which paints a frame later) to clear them.
const routeSessionMismatch = isRoutedSessionView && routedSessionId !== selectedSessionId
// The compact new-session pop-out skips the wordmark/tagline intro — it's a
// scratch window, not the full-height empty state.
const showIntro =
!isSecondaryWindow() && freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messagesEmpty
const showIntro = freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messagesEmpty
// Session is still loading if the route references a session we haven't
// resumed yet. Once `activeSessionId` is set (runtime has resumed), the
@@ -348,7 +342,6 @@ export function ChatView({
provider: currentProvider,
canSwitch: gatewayOpen,
loading: !gatewayOpen || (!currentModel && !currentProvider),
modelMenuContent,
quickModels
},
tools: {
@@ -361,7 +354,7 @@ export function ChatView({
active: false
}
}),
[contextSuggestions, currentModel, currentProvider, gatewayOpen, modelMenuContent, quickModels]
[contextSuggestions, currentModel, currentProvider, gatewayOpen, quickModels]
)
// Drop files anywhere in the conversation area, not just on the composer
+36 -66
View File
@@ -29,7 +29,6 @@ import {
Moon,
Package,
Palette,
PawPrint,
Plus,
Settings,
Settings2,
@@ -40,7 +39,7 @@ import {
Zap
} from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $commandPaletteOpen, $commandPalettePage, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
import { $bindings } from '@/store/keybinds'
import { luminance } from '@/themes/color'
import { type ThemeMode, useTheme } from '@/themes/context'
@@ -63,7 +62,6 @@ import { fieldCopyForSchemaKey } from '../settings/field-copy'
import { prettyName } from '../settings/helpers'
import { MarketplaceThemePage } from './marketplace-theme-page'
import { PetInlineToggle, PetPalettePage } from './pet-palette-page'
interface PaletteItem {
/** Keybind action id — its live combo renders as a hotkey hint. */
@@ -156,7 +154,7 @@ const NON_CONFIG_SETTINGS: ReadonlyArray<{
},
{
icon: KeyRound,
keywords: ['providers', 'api key', 'keys', 'secrets', 'tokens'],
keywords: ['providers', 'api key', 'keys', 'secrets', 'tokens', 'egress', 'iron proxy', 'sandbox proxy'],
labelKey: 'providerApiKeys',
tab: 'providers&pview=keys'
},
@@ -169,7 +167,7 @@ const NON_CONFIG_SETTINGS: ReadonlyArray<{
},
{
icon: Settings2,
keywords: ['gateway', 'proxy', 'server', 'webhook', 'env'],
keywords: ['gateway', 'proxy', 'server', 'webhook', 'env', 'egress proxy', 'iron proxy'],
labelKey: 'keysSettings',
tab: 'keys&kview=settings'
},
@@ -207,7 +205,6 @@ function themeSupportsMode(name: string, target: 'light' | 'dark'): boolean {
export function CommandPalette() {
const { t } = useI18n()
const open = useStore($commandPaletteOpen)
const pendingPage = useStore($commandPalettePage)
const bindings = useStore($bindings)
const navigate = useNavigate()
const { availableThemes, resolvedMode, setMode, setTheme, themeName } = useTheme()
@@ -253,14 +250,6 @@ export function CommandPalette() {
}
}, [open])
// Deep-link into a nested page (e.g. `/pet list` → pets picker).
useEffect(() => {
if (open && pendingPage) {
setPage(pendingPage)
$commandPalettePage.set(null)
}
}, [open, pendingPage])
const go = useCallback((path: string) => () => navigate(path), [navigate])
// Step up one nested page (or back to the root list), clearing the filter so
@@ -393,13 +382,6 @@ export function CommandPalette() {
keywords: ['appearance', 'color mode', 'brightness', 'dark', 'light', 'system'],
label: cc.changeColorMode,
to: 'color-mode'
},
{
icon: PawPrint,
id: 'appearance-pets',
keywords: ['pet', 'petdex', 'mascot', 'pets', '/pet', 'paw'],
label: cc.pets.title,
to: 'pets'
}
]
},
@@ -568,12 +550,6 @@ export function CommandPalette() {
}
]
},
// Server-driven page: browse petdex gallery, adopt/switch, toggle off.
pets: {
title: t.commandCenter.pets.title,
placeholder: t.commandCenter.pets.placeholder,
groups: []
},
// Server-driven page: items come from the Marketplace, rendered by
// <MarketplaceThemePage> (loader + live search + per-row install).
'install-theme': {
@@ -648,51 +624,45 @@ export function CommandPalette() {
}}
onValueChange={setSearch}
placeholder={placeholder}
right={page === 'pets' ? <PetInlineToggle /> : undefined}
value={search}
/>
<CommandList className="dt-portal-scrollbar max-h-[min(20rem,56vh)]">
{/* Server-driven pages render their own list; the rest show groups. */}
{page === 'pets' ? (
<PetPalettePage search={search} />
) : page === 'install-theme' ? (
{page === 'install-theme' ? (
<MarketplaceThemePage onPickTheme={setTheme} search={search} />
) : (
<>
<CommandEmpty>{t.commandCenter.noResults}</CommandEmpty>
{visibleGroups.map((group, index) => (
<CommandGroup
className={HUD_HEADING}
heading={group.heading}
key={group.heading ?? `palette-group-${index}`}
>
{group.items.map(item => {
const Icon = item.icon
const combo = item.action ? bindings[item.action]?.[0] : undefined
return (
<CommandItem
className={cn(HUD_ITEM, HUD_TEXT)}
key={item.id}
keywords={item.keywords}
onSelect={() => handleSelect(item)}
value={`${item.label} ${item.keywords?.join(' ') ?? ''} ${item.id}`}
>
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{item.label}</span>
{combo && <KbdCombo className="ml-auto opacity-55" combo={combo} size="sm" />}
{item.to && (
<ChevronRight
className={cn('size-3.5 shrink-0 text-muted-foreground/70', !combo && 'ml-auto')}
/>
)}
</CommandItem>
)
})}
</CommandGroup>
))}
</>
<CommandEmpty>{t.commandCenter.noResults}</CommandEmpty>
)}
{visibleGroups.map((group, index) => (
<CommandGroup
className={HUD_HEADING}
heading={group.heading}
key={group.heading ?? `palette-group-${index}`}
>
{group.items.map(item => {
const Icon = item.icon
const combo = item.action ? bindings[item.action]?.[0] : undefined
return (
<CommandItem
className={cn(HUD_ITEM, HUD_TEXT)}
key={item.id}
keywords={item.keywords}
onSelect={() => handleSelect(item)}
value={`${item.label} ${item.keywords?.join(' ') ?? ''} ${item.id}`}
>
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{item.label}</span>
{combo && <KbdCombo className="ml-auto opacity-55" combo={combo} size="sm" />}
{item.to && (
<ChevronRight
className={cn('size-3.5 shrink-0 text-muted-foreground/70', !combo && 'ml-auto')}
/>
)}
</CommandItem>
)
})}
</CommandGroup>
))}
</CommandList>
</Command>
</DialogPrimitive.Content>
@@ -1,185 +0,0 @@
/**
* Cmd-K "Pets…" page browse the petdex gallery, adopt/switch, toggle off.
*
* A thin view over the `pet-gallery` store: it subscribes to the shared atoms
* and calls the store's actions. The store owns fetching, caching, the thumb
* cache, and optimistic mutations, so reopening this page is instant and a
* toggle never re-pulls the network gallery.
*/
import { useStore } from '@nanostores/react'
import { useEffect, useMemo } from 'react'
import { HUD_ITEM, HUD_TEXT } from '@/app/floating-hud'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { PetThumb } from '@/components/pet/pet-thumb'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Check, Loader2, PawPrint } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$petBusy,
$petGallery,
$petGalleryError,
$petGalleryStatus,
adoptPet,
loadPetGallery,
loadPetThumb,
rankedGalleryPets,
setPetEnabled
} from '@/store/pet-gallery'
interface PetPalettePageProps {
search: string
}
export function PetPalettePage({ search }: PetPalettePageProps) {
const { t } = useI18n()
const copy = t.commandCenter.pets
const { requestGateway } = useGatewayRequest()
const gallery = useStore($petGallery)
const status = useStore($petGalleryStatus)
const error = useStore($petGalleryError)
const busy = useStore($petBusy)
useEffect(() => {
void loadPetGallery(requestGateway)
}, [requestGateway])
const enabled = gallery?.enabled ?? false
const active = gallery?.active ?? ''
const shown = useMemo(() => rankedGalleryPets(gallery, search).slice(0, 50), [gallery, search])
const adopt = (slug: string) => {
void adoptPet(requestGateway, slug, copy.adoptFailed).then(ok => ok && triggerHaptic('crisp'))
}
if (status === 'loading' && !gallery) {
return <Status icon={<Loader2 className="size-3.5 animate-spin" />} text={copy.loading} />
}
if (status === 'stale') {
return <Status text={copy.staleBackend} tone="error" />
}
if (!gallery?.pets.length && error) {
return <Status text={error} tone="error" />
}
const mutating = Boolean(busy)
return (
<div role="listbox">
{error && <p className="px-2 pb-1 pt-1.5 text-[0.6875rem] text-(--ui-red)">{error}</p>}
{shown.length === 0 ? (
<Status text={copy.empty} />
) : (
shown.map(pet => {
const isActive = enabled && pet.slug === active
const isBusy = busy === pet.slug
return (
<button
className={cn(
'flex w-full items-center gap-2 rounded-md text-left transition-colors hover:bg-(--chrome-action-hover) disabled:opacity-60',
HUD_ITEM,
HUD_TEXT,
isActive && 'bg-(--chrome-action-hover)/70'
)}
disabled={mutating && !isBusy}
key={pet.slug}
onClick={() => adopt(pet.slug)}
onMouseDown={event => event.preventDefault()}
role="option"
type="button"
>
<PetThumb
alt={pet.displayName}
load={(slug, url) => loadPetThumb(requestGateway, slug, url)}
size={32}
slug={pet.slug}
url={pet.spritesheetUrl}
/>
<span className="flex min-w-0 flex-col">
<span className="truncate font-medium">{pet.displayName}</span>
<span className="truncate text-[0.6875rem] text-muted-foreground/80">
{pet.slug}
{pet.installed ? ` · ${copy.installed}` : ''}
</span>
</span>
<span className="ml-auto flex shrink-0 items-center text-[0.6875rem] text-muted-foreground">
{isBusy ? (
<Loader2 className="size-3 animate-spin" />
) : isActive ? (
<Check className="size-3.5 text-foreground" />
) : null}
</span>
</button>
)
})
)}
</div>
)
}
/**
* Single on/off toggle, rendered inline on the palette's search row (see
* `CommandInput`'s `right` slot). The paw lights up when pets are on. Reads the
* same shared gallery atoms, so it stays in sync with the list below.
*/
export function PetInlineToggle() {
const { t } = useI18n()
const copy = t.commandCenter.pets
const { requestGateway } = useGatewayRequest()
const gallery = useStore($petGallery)
const busy = useStore($petBusy)
if (!gallery) {
return null
}
const enabled = gallery.enabled
const toggle = () => {
void setPetEnabled(requestGateway, !enabled, {
noneAvailable: copy.noneAvailable,
fallback: copy.toggleFailed
}).then(ok => ok && triggerHaptic('crisp'))
}
return (
<button
aria-label={enabled ? copy.turnOff : copy.turnOn}
aria-pressed={enabled}
className={cn(
'flex shrink-0 items-center justify-center rounded-md p-1.5 transition-colors disabled:opacity-50',
enabled ? 'bg-(--chrome-action-hover) text-foreground' : 'text-muted-foreground hover:bg-(--chrome-action-hover)/60'
)}
disabled={Boolean(busy)}
onClick={toggle}
// Don't steal focus from the search input on click.
onMouseDown={event => event.preventDefault()}
title={enabled ? copy.turnOff : copy.turnOn}
type="button"
>
{busy ? <Loader2 className="size-4 animate-spin" /> : <PawPrint className="size-4" />}
</button>
)
}
function Status({ icon, text, tone }: { icon?: React.ReactNode; text: string; tone?: 'error' }) {
return (
<div
className={cn(
'flex items-center justify-center gap-2 px-2 py-6 text-xs',
tone === 'error' ? 'text-(--ui-red)' : 'text-muted-foreground'
)}
>
{icon}
{text}
</div>
)
}
+2 -66
View File
@@ -38,8 +38,6 @@ import {
unpinSession
} from '../store/layout'
import { respondToApprovalAction } from '../store/native-notifications'
import { setPetActivity } from '../store/pet'
import { setPetOverlayOpenAppHandler, setPetOverlaySubmitHandler } from '../store/pet-overlay'
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
import {
$activeGatewayProfile,
@@ -51,7 +49,6 @@ import {
} from '../store/profile'
import {
$activeSessionId,
$attentionSessionIds,
$currentCwd,
$freshDraftReady,
$gatewayState,
@@ -80,7 +77,6 @@ import {
setSessionsLoading,
setSessionsTotal
} from '../store/session'
import { onSessionsChanged } from '../store/session-sync'
import { clearSessionTodos, setSessionTodos, todoListActive } from '../store/todos'
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '../store/updates'
import { isSecondaryWindow } from '../store/windows'
@@ -468,17 +464,6 @@ export function DesktopController() {
void refreshSessions()
}, [refreshSessions])
// Another window mutated the shared session list (e.g. a chat started in the
// pop-out). Re-pull so the sidebar reflects it. Pop-outs have no sidebar, so
// only real windows bother.
useEffect(() => {
if (isSecondaryWindow()) {
return
}
return onSessionsChanged(() => void refreshSessions().catch(() => undefined))
}, [refreshSessions])
// ALL-profiles view pages one profile at a time: fetch that profile's next
// page and merge it in place, leaving every other profile's rows untouched.
const loadMoreSessionsForProfile = useCallback(async (profile: string) => {
@@ -714,9 +699,7 @@ export function DesktopController() {
}
lastGatewayProfileRef.current = activeGatewayProfile
// Force: the new profile has its own default, so reseed even if the composer
// already shows the previous profile's model.
void refreshCurrentModel(true)
void refreshCurrentModel()
void refreshActiveProfile()
}, [activeGatewayProfile, refreshCurrentModel])
@@ -789,53 +772,6 @@ export function DesktopController() {
updateSessionState
})
// The popped-out pet drives two actions back into the app: send a prompt, and
// open the most recent thread. Both are registered ONCE through refs that track
// the latest callbacks — re-registering on every `submitText`/`resumeSession`
// identity change left a brief window where the handler was nulled (cleanup
// before re-register), which could drop a submit fired from the overlay (e.g.
// creating a session from the new-session screen). The ref form keeps a stable,
// always-current handler. Primary window only — it owns the overlay.
const submitTextRef = useRef(submitText)
submitTextRef.current = submitText
const resumeSessionRef = useRef(resumeSession)
resumeSessionRef.current = resumeSession
useEffect(() => {
if (isSecondaryWindow()) {
return
}
setPetOverlaySubmitHandler(text => void submitTextRef.current(text))
// Mail icon: $sessions is ordered most-recent-first; the pet is global (not
// per session) so "most recent" is the right target. main.cjs already raised
// the window before forwarding this.
setPetOverlayOpenAppHandler(() => {
const recent = $sessions.get()[0]
if (recent?.id) {
void resumeSessionRef.current(recent.id)
}
})
return () => {
setPetOverlaySubmitHandler(null)
setPetOverlayOpenAppHandler(null)
}
}, [])
// Mirror "a session is blocked on the user" (clarify/approval) into the pet's
// awaitingInput flag so it shows the `waiting` pose. Lives on $petActivity so
// it rides the same atom the pop-out overlay mirrors — no session list needed
// there. Every window keeps its own in-window pet in sync.
useEffect(() => {
const sync = () => setPetActivity({ awaitingInput: $attentionSessionIds.get().length > 0 })
sync()
return $attentionSessionIds.listen(sync)
}, [])
useGatewayBoot({
handleGatewayEvent: handleDesktopGatewayEvent,
onConnectionReady: c => {
@@ -911,6 +847,7 @@ export function DesktopController() {
gatewayLogLines,
gatewayState,
inferenceStatus,
modelMenuContent,
openAgents,
freshDraftReady,
openCommandCenterSection,
@@ -1032,7 +969,6 @@ export function DesktopController() {
<ChatView
gateway={gatewayRef.current}
maxVoiceRecordingSeconds={voiceMaxRecordingSeconds}
modelMenuContent={modelMenuContent}
onAddContextRef={composer.addContextRefAttachment}
onAddUrl={url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)}
onAttachDroppedItems={composer.attachDroppedItems}
@@ -37,7 +37,6 @@ import {
switcherActive,
switcherJustClosed
} from '@/store/session-switcher'
import { openNewSessionInNewWindow } from '@/store/windows'
import { useTheme } from '@/themes/context'
import { requestComposerFocus } from '../chat/composer/focus'
@@ -133,7 +132,6 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
deps.startFreshSession()
window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut'))
},
'session.newWindow': () => void openNewSessionInNewWindow(),
'session.next': () => stepSession(1),
'session.prev': () => stepSession(-1),
...sessionSlotHandlers,
@@ -1,38 +0,0 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { ErrorBoundary } from '@/components/error-boundary'
import { ThemeProvider } from '@/themes/context'
import { PetOverlayApp } from './pet-overlay-app'
/**
* Boot the pet-overlay window. Loaded by the same bundle as the main app but
* via `?win=overlay`, so it shares CSS/atoms while mounting a minimal, transparent
* surface (no app shell, no gateway, no I18n the bubble strings are inline).
*
* The index.html boot script paints an OPAQUE themed background to avoid a flash
* in normal windows; the overlay must be see-through, so we force every host
* layer transparent with a late, high-specificity style tag.
*/
export function mountPetOverlay(): void {
const style = document.createElement('style')
style.textContent = 'html,body,#root{background:transparent !important;}'
document.head.appendChild(style)
const root = document.getElementById('root')
if (!root) {
return
}
createRoot(root).render(
<StrictMode>
<ErrorBoundary label="pet-overlay">
<ThemeProvider>
<PetOverlayApp />
</ThemeProvider>
</ErrorBoundary>
</StrictMode>
)
}
@@ -1,345 +0,0 @@
import { useStore } from '@nanostores/react'
import { useEffect, useRef, useState } from 'react'
import { PetBubble } from '@/components/pet/pet-bubble'
import { PetSprite } from '@/components/pet/pet-sprite'
import { Mail } from '@/lib/icons'
import { $petActivity, $petInfo, setPetInfo } from '@/store/pet'
import { setAwaitingResponse, setBusy } from '@/store/session'
/**
* The pop-out overlay's only view: a transparent, draggable mascot with a mini
* composer.
*
* This runs in a separate, gateway-less BrowserWindow (`?win=overlay`). It is a
* pure puppet the main renderer pushes the live pet state over IPC and we
* mirror it into the same atoms the in-window pet reads, so `PetSprite` /
* `PetBubble` render identically with zero extra logic.
*
* The window is a full rectangle but mostly transparent; we toggle OS-level
* mouse click-through so only the sprite (or the open composer) is interactive
* and the empty margins pass clicks through to whatever is behind.
*
* Gestures on the pet: drag to move it anywhere on screen (even outside the
* app), shift-click to pop it back into the window, single-click to open a small
* composer, double-click to toggle the app window (minimize restore). A mail
* icon (shown only when a turn finished while you were away) raises the app on
* the most recent thread.
*/
// Below this much pointer travel, a press counts as a click, not a drag.
const CLICK_SLOP_PX = 3
// A second click within this window is a double-click (raise app) and cancels
// the deferred single-click (open composer), so a double never flashes it open.
const DOUBLE_CLICK_MS = 250
interface DragState {
startX: number
startY: number
offX: number
offY: number
width: number
height: number
moved: boolean
}
export function PetOverlayApp() {
const info = useStore($petInfo)
const [composerOpen, setComposerOpen] = useState(false)
const [draft, setDraft] = useState('')
// Mirrored from the main renderer: a finish landed while you were away.
const [unread, setUnread] = useState(false)
const dragRef = useRef<DragState | null>(null)
const petRef = useRef<HTMLDivElement | null>(null)
const inputRef = useRef<HTMLInputElement | null>(null)
const ignoreRef = useRef(true)
const composerOpenRef = useRef(false)
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const setIgnore = (ignore: boolean) => {
if (ignoreRef.current !== ignore) {
ignoreRef.current = ignore
window.hermesDesktop?.petOverlay?.setIgnoreMouse(ignore)
}
}
// Mirror pushed state into the shared atoms so PetSprite/PetBubble just work.
useEffect(() => {
const off = window.hermesDesktop?.petOverlay?.onState(payload => {
setPetInfo(payload.info)
$petActivity.set(payload.activity ?? {})
setBusy(Boolean(payload.busy))
setAwaitingResponse(Boolean(payload.awaiting))
setUnread(Boolean(payload.unread))
})
// Tell the main renderer we're mounted so it pushes the current frame (the
// subscribe-time pushes during open() can land before this view exists).
window.hermesDesktop?.petOverlay?.control({ type: 'ready' })
return off
}, [])
// Click-through: make only the sprite (or an open composer) interactive. With
// ignore+forward, the renderer still receives mousemove so we can re-enable
// hit-testing the moment the cursor returns to the pet.
useEffect(() => {
setIgnore(true)
const onMove = (ev: MouseEvent) => {
if (dragRef.current || composerOpenRef.current) {
setIgnore(false)
return
}
const el = petRef.current
if (!el) {
return
}
const r = el.getBoundingClientRect()
const over = ev.clientX >= r.left && ev.clientX <= r.right && ev.clientY >= r.top && ev.clientY <= r.bottom
setIgnore(!over)
}
window.addEventListener('mousemove', onMove)
return () => {
window.removeEventListener('mousemove', onMove)
clearTimeout(clickTimerRef.current)
}
}, [])
// The whole window must stay interactive while the composer is open (so the
// input keeps focus); focus it on open. The overlay is a non-activating panel
// (so it never steals the app's cmd/alt-tab anchor) — flip it focusable while
// the composer needs the keyboard, then back to non-activating when it closes.
useEffect(() => {
composerOpenRef.current = composerOpen
window.hermesDesktop?.petOverlay?.setFocusable(composerOpen)
if (composerOpen) {
setIgnore(false)
// The OS window has to become key first (setFocusable + focus happen in
// the main process), so focus the input on the next frame.
requestAnimationFrame(() => inputRef.current?.focus())
}
}, [composerOpen])
const onPetPointerDown = (e: React.PointerEvent) => {
if (e.button !== 0) {
return
}
;(e.target as Element).setPointerCapture?.(e.pointerId)
dragRef.current = {
height: window.outerHeight,
moved: false,
offX: e.screenX - window.screenX,
offY: e.screenY - window.screenY,
startX: e.screenX,
startY: e.screenY,
width: window.outerWidth
}
}
const onPetPointerMove = (e: React.PointerEvent) => {
const drag = dragRef.current
if (!drag) {
return
}
if (Math.hypot(e.screenX - drag.startX, e.screenY - drag.startY) > CLICK_SLOP_PX) {
drag.moved = true
}
window.hermesDesktop?.petOverlay?.setBounds({
height: drag.height,
width: drag.width,
x: e.screenX - drag.offX,
y: e.screenY - drag.offY
})
}
const onPetPointerUp = (e: React.PointerEvent) => {
const drag = dragRef.current
dragRef.current = null
;(e.target as Element).releasePointerCapture?.(e.pointerId)
if (!drag) {
return
}
if (drag.moved) {
// A drag cancels any deferred single-click so the composer can't pop open
// after you reposition the pet.
clearTimeout(clickTimerRef.current)
clickTimerRef.current = undefined
// Remember the spot on the desktop (screen coords) so the pet reopens here
// next time / after a restart.
window.hermesDesktop?.petOverlay?.control({
bounds: { height: drag.height, width: drag.width, x: e.screenX - drag.offX, y: e.screenY - drag.offY },
type: 'bounds'
})
return
}
// Shift-click always pops the pet back in (no double-click ambiguity).
if (e.shiftKey) {
window.hermesDesktop?.petOverlay?.control({ type: 'pop-in' })
return
}
// Double-click toggles the app window (minimize ↔ restore); defer the
// single-click composer toggle so a double never flashes the composer open.
if (clickTimerRef.current) {
clearTimeout(clickTimerRef.current)
clickTimerRef.current = undefined
window.hermesDesktop?.petOverlay?.control({ type: 'toggle-app' })
return
}
clickTimerRef.current = setTimeout(() => {
clickTimerRef.current = undefined
setComposerOpen(open => !open)
}, DOUBLE_CLICK_MS)
}
const send = () => {
const text = draft.trim()
if (text) {
window.hermesDesktop?.petOverlay?.control({ text, type: 'submit' })
}
setDraft('')
setComposerOpen(false)
}
const openApp = () => {
// Hide the icon immediately; the main renderer also clears the source flag.
setUnread(false)
window.hermesDesktop?.petOverlay?.control({ type: 'open-app' })
}
if (!info.enabled || !info.spritesheetBase64) {
return null
}
return (
<div
onPointerDown={e => {
// Click on the transparent backdrop (not the pet/composer) dismisses
// the composer.
if (composerOpen && e.target === e.currentTarget) {
setComposerOpen(false)
}
}}
style={{
alignItems: 'center',
background: 'transparent',
display: 'flex',
flexDirection: 'column',
height: '100vh',
justifyContent: 'flex-end',
paddingBottom: 24,
userSelect: 'none',
width: '100vw'
}}
>
{composerOpen && (
<input
onChange={e => setDraft(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
send()
} else if (e.key === 'Escape') {
setComposerOpen(false)
}
}}
placeholder="Message…"
ref={inputRef}
style={{
background: 'var(--ui-bg-elevated)',
border: '1px solid var(--ui-stroke-secondary)',
borderRadius: 2,
boxShadow: '0 6px 18px rgba(0,0,0,0.28)',
color: 'var(--foreground)',
fontSize: 12,
marginBottom: 8,
outline: 'none',
padding: '4px 8px',
width: 184
}}
value={draft}
/>
)}
<div
onPointerDown={onPetPointerDown}
onPointerMove={onPetPointerMove}
onPointerUp={onPetPointerUp}
ref={petRef}
style={{
alignItems: 'center',
cursor: 'grab',
display: 'flex',
flexDirection: 'column',
position: 'relative',
touchAction: 'none'
}}
>
<div style={{ marginBottom: 4 }}>
<PetBubble />
</div>
<div style={{ lineHeight: 0, position: 'relative' }}>
<PetSprite info={info} />
{/* Mail icon: only when a finish landed while you were away. Jumps to
the app's most recent thread. Anchored to the sprite (kept inside
its box so the overlay's click-through hit-test still catches it);
stopPropagation keeps a click from starting a window drag. */}
{unread && (
<button
aria-label="Open in Hermes"
onClick={openApp}
onPointerDown={e => e.stopPropagation()}
onPointerUp={e => e.stopPropagation()}
style={{
alignItems: 'center',
background: 'var(--ui-bg-elevated)',
border: '1px solid var(--ui-stroke-secondary)',
borderRadius: 999,
boxShadow: '0 4px 14px rgba(0,0,0,0.22)',
color: 'var(--foreground)',
cursor: 'pointer',
display: 'inline-flex',
height: 24,
justifyContent: 'center',
padding: 0,
position: 'absolute',
right: 0,
top: 0,
width: 24
}}
title="Open in Hermes"
type="button"
>
<Mail style={{ height: 13, width: 13 }} />
</button>
)}
</div>
</div>
</div>
)
}
@@ -1,75 +0,0 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { HermesReadDirResult } from '@/global'
import { $connection, setCurrentCwd } from '@/store/session'
import { resetProjectTreeState } from './files/use-project-tree'
import { RightSidebarPane } from './index'
const readDir = vi.fn<(path: string) => Promise<HermesReadDirResult>>()
const selectPaths = vi.fn()
function ok(entries: { name: string; path: string; isDirectory: boolean }[]): HermesReadDirResult {
return { entries }
}
function installBridge() {
;(
window as unknown as {
hermesDesktop: {
readDir: typeof readDir
selectPaths: typeof selectPaths
}
}
).hermesDesktop = { readDir, selectPaths }
}
describe('RightSidebarPane', () => {
beforeEach(() => {
$connection.set(null)
resetProjectTreeState()
setCurrentCwd('/repo')
readDir.mockReset()
selectPaths.mockReset()
readDir.mockResolvedValue(ok([{ name: 'README.md', path: '/repo/README.md', isDirectory: false }]))
selectPaths.mockResolvedValue(['/repo-next'])
installBridge()
})
afterEach(() => {
cleanup()
$connection.set(null)
setCurrentCwd('')
resetProjectTreeState()
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
it('refreshes the current tree without opening the folder picker', async () => {
const onChangeCwd = vi.fn()
render(<RightSidebarPane onActivateFile={vi.fn()} onActivateFolder={vi.fn()} onChangeCwd={onChangeCwd} />)
await waitFor(() => expect(screen.getByRole('button', { name: 'Refresh tree' }).hasAttribute('disabled')).toBe(false))
readDir.mockClear()
fireEvent.click(screen.getByRole('button', { name: 'Refresh tree' }))
await waitFor(() => expect(readDir).toHaveBeenCalledWith('/repo'))
expect(selectPaths).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Open folder' }))
await waitFor(() =>
expect(selectPaths).toHaveBeenCalledWith({
defaultPath: '/repo',
directories: true,
multiple: false,
title: 'Change working directory'
})
)
await waitFor(() => expect(onChangeCwd).toHaveBeenCalledWith('/repo-next'))
})
})
+5 -5
View File
@@ -126,12 +126,12 @@ interface FilesystemTabProps extends FileTreeBodyProps {
onRefresh: () => void
}
// Sidebar palette + hover-reveal: header actions stay reachable while moving
// from the project label to the action buttons.
// Sidebar palette + hover-reveal: refresh tracks label hover; collapse-all
// stays visible while any folder is expanded.
const HEADER_ACTION_CLASS =
'text-sidebar-foreground/70 hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:ring-sidebar-ring'
const HEADER_ACTION_LABEL_REVEAL = `${HEADER_ACTION_CLASS} pointer-events-none opacity-0 transition-opacity focus-visible:pointer-events-auto focus-visible:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100`
const HEADER_ACTION_LABEL_REVEAL = `${HEADER_ACTION_CLASS} pointer-events-none opacity-0 transition-opacity focus-visible:pointer-events-auto focus-visible:opacity-100 peer-focus-visible/project-label:pointer-events-auto peer-focus-visible/project-label:opacity-100 peer-hover/project-label:pointer-events-auto peer-hover/project-label:opacity-100`
function FilesystemTab({
canCollapse,
@@ -158,7 +158,7 @@ function FilesystemTab({
return (
<div className="flex min-h-0 flex-1 flex-col">
<RightSidebarSectionHeader>
<div className="flex min-w-0 flex-1">
<div className="peer/project-label flex min-w-0 flex-1">
<button
className="flex w-full min-w-0 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
onClick={() => void onChangeFolder()}
@@ -216,7 +216,7 @@ function FilesystemTab({
}
export function RightSidebarSectionHeader({ children }: { children: ReactNode }) {
return <div className="group/project-header flex h-7 shrink-0 items-center px-2.5">{children}</div>
return <div className="flex h-7 shrink-0 items-center px-2.5">{children}</div>
}
interface FileTreeBodyProps {
@@ -9,22 +9,3 @@ export const $terminalTakeover = atom(storedBoolean(TAKEOVER_KEY, false))
$terminalTakeover.subscribe(active => persistBoolean(TAKEOVER_KEY, active))
export const setTerminalTakeover = (active: boolean) => $terminalTakeover.set(active)
/** A command queued to run in the embedded terminal. The terminal pane flushes
* (and clears) it once its session is live, so a value set before the pane
* mounts still runs. Cleared after flush so a later remount can't replay it. */
export const $terminalInjection = atom<null | string>(null)
/** Open the terminal pane and run a command in it. Used to disconnect external
* (CLI-managed) providers, which Hermes can't clear via the API the user
* sees exactly what runs instead of Hermes silently deleting their creds. */
export const runInTerminal = (command: string) => {
const trimmed = command.trim()
if (!trimmed) {
return
}
setTerminalTakeover(true)
$terminalInjection.set(trimmed)
}
@@ -10,8 +10,6 @@ import { triggerHaptic } from '@/lib/haptics'
import { $filePreviewTarget, $previewTarget } from '@/store/preview'
import { useTheme } from '@/themes/context'
import { $terminalInjection } from '../store'
import { makeTerminalReader, setActiveTerminalReader } from './buffer'
import {
isAddSelectionShortcut,
@@ -677,28 +675,6 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
return () => cancelAnimationFrame(raf)
}, [activeTheme, themeName])
// Flush a queued command (e.g. a provider-disconnect) into the live session.
// Only active while open; the subscribe fires immediately, so a command set
// before this pane mounted runs as soon as the session is ready. Clearing the
// atom after writing stops a later remount from replaying a stale command.
useEffect(() => {
if (status !== 'open') {
return
}
return $terminalInjection.subscribe(command => {
const id = sessionIdRef.current
if (!command || !id) {
return
}
void window.hermesDesktop?.terminal?.write(id, `${command}\r`)
$terminalInjection.set(null)
termRef.current?.focus()
})
}, [status])
return {
addSelectionToChat,
hostRef,
@@ -33,7 +33,6 @@ import { $gateway } from '@/store/gateway'
import { dispatchNativeNotification } from '@/store/native-notifications'
import { notify } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { flashPetActivity, markPetUnread, setPetActivity } from '@/store/pet'
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
import {
setCurrentBranch,
@@ -48,7 +47,6 @@ import {
setTurnStartedAt,
setYoloActive
} from '@/store/session'
import { broadcastSessionsChanged } from '@/store/session-sync'
import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents'
import { setSessionTodos } from '@/store/todos'
import { recordToolDiff } from '@/store/tool-diffs'
@@ -643,9 +641,6 @@ export function useMessageStream({
})
void refreshSessions().catch(() => undefined)
// Sync the freshly-titled row to other windows (e.g. main, when the turn
// ran in the pop-out).
broadcastSessionsChanged()
if (compactedTurnRef.current.delete(sessionId)) {
shouldHydrate = false
@@ -870,18 +865,10 @@ export function useMessageStream({
if (sessionId) {
appendReasoningDelta(sessionId, coerceThinkingText(payload?.text))
}
if (isActiveEvent) {
setPetActivity({ reasoning: true })
}
} else if (event.type === 'reasoning.available') {
if (sessionId) {
appendReasoningDelta(sessionId, coerceThinkingText(payload?.text), true)
}
if (isActiveEvent) {
setPetActivity({ reasoning: true })
}
} else if (event.type === 'message.complete') {
if (!sessionId) {
return
@@ -903,20 +890,6 @@ export function useMessageStream({
if (isActiveEvent) {
setTurnStartedAt(null)
// Pet beat: a finished turn always celebrates — go straight to the
// jump, never linger on the run/reason pose. One atom update (clears
// toolRunning/reasoning AND sets celebrate together) so no stray "run"
// frame leaks to the sprite — including the popped-out overlay, which
// mirrors each activity change. The jump runs ~2 loops, then settles.
flashPetActivity({ celebrate: true, reasoning: false, toolRunning: false }, 2200)
// Light up the pet's mail icon if the user wasn't looking when the turn
// finished — a glanceable "new message" hint on the popped-out overlay.
// Cleared when they open the app via the mail icon or refocus the window.
if (typeof document !== 'undefined' && !document.hasFocus()) {
markPetUnread()
}
}
if (payload?.usage) {
@@ -929,19 +902,10 @@ export function useMessageStream({
flushQueuedDeltas(sessionId)
upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'running', event.type)
if (isActiveEvent) {
setPetActivity({ reasoning: false, toolRunning: true })
}
} else if (event.type === 'tool.complete') {
if (sessionId) {
flushQueuedDeltas(sessionId)
upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'complete', event.type)
if (isActiveEvent) {
setPetActivity({ toolRunning: false })
}
// A pending clarify blocks the turn, so the first tool.complete after
// one is the clarify resolving — drop the "needs input" flag here so
// the sidebar indicator clears as soon as it's answered, not only at
@@ -1125,11 +1089,6 @@ export function useMessageStream({
compactedTurnRef.current.delete(sessionId)
}
if (isActiveEvent) {
setPetActivity({ reasoning: false, toolRunning: false })
flashPetActivity({ error: true })
}
dispatchNativeNotification({
body: errorMessage,
kind: 'turnError',
@@ -1,5 +1,5 @@
import { renderHook } from '@testing-library/react'
import { QueryClient } from '@tanstack/react-query'
import { cleanup, render, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getGlobalModelInfo } from '@/hermes'
@@ -13,51 +13,12 @@ import {
import { useModelControls } from './use-model-controls'
const setGlobalModel = vi.fn()
const notifyError = vi.fn()
vi.mock('@/hermes', () => ({
getGlobalModelInfo: vi.fn(),
setGlobalModel: (...args: Parameters<typeof setGlobalModel>) => setGlobalModel(...args)
setGlobalModel: vi.fn()
}))
vi.mock('@/i18n', () => ({
useI18n: () => ({
t: {
desktop: {
modelSwitchFailed: 'Model switch failed'
}
}
})
}))
vi.mock('@/store/notifications', () => ({
notifyError: (...args: Parameters<typeof notifyError>) => notifyError(...args)
}))
type Controls = ReturnType<typeof useModelControls>
function Harness({
activeSessionId,
onReady,
requestGateway
}: {
activeSessionId: string | null
onReady: (controls: Controls) => void
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
}) {
const controls = useModelControls({
activeSessionId,
queryClient: new QueryClient(),
requestGateway
})
onReady(controls)
return null
}
describe('useModelControls', () => {
describe('useModelControls.refreshCurrentModel', () => {
beforeEach(() => {
$activeSessionId.set(null)
setCurrentModel('')
@@ -65,7 +26,6 @@ describe('useModelControls', () => {
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
$activeSessionId.set(null)
setCurrentModel('')
@@ -114,85 +74,4 @@ describe('useModelControls', () => {
expect($currentModel.get()).toBe('deepseek/deepseek-v4-pro')
expect($currentProvider.get()).toBe('deepseek')
})
it('routes active-session picker changes through config.set with an explicit provider', async () => {
const requestGateway = vi.fn(async () => ({ key: 'model', value: 'claude-sonnet-4.6' }) as never)
let controls!: Controls
render(
<Harness
activeSessionId="session-1"
onReady={value => (controls = value)}
requestGateway={requestGateway}
/>
)
await expect(
controls.selectModel({
model: 'claude-sonnet-4.6',
provider: 'anthropic'
})
).resolves.toBe(true)
expect(requestGateway).toHaveBeenCalledWith('config.set', {
session_id: 'session-1',
key: 'model',
value: 'claude-sonnet-4.6 --provider anthropic'
})
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
})
it('stores a no-session pick as UI state with no gateway or global write', async () => {
const requestGateway = vi.fn()
let controls!: Controls
render(
<Harness
activeSessionId={null}
onReady={value => (controls = value)}
requestGateway={requestGateway}
/>
)
await expect(
controls.selectModel({
model: 'claude-sonnet-4.6',
provider: 'anthropic'
})
).resolves.toBe(true)
// The pick is plain UI state; session.create ships it later. Nothing touches
// the gateway or the profile default here.
expect($currentModel.get()).toBe('claude-sonnet-4.6')
expect($currentProvider.get()).toBe('anthropic')
expect(requestGateway).not.toHaveBeenCalled()
expect(setGlobalModel).not.toHaveBeenCalled()
})
it('seeds an empty composer model from global but never clobbers a pick', 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()
})
)
// Empty → seeds the default.
await result.current.refreshCurrentModel()
expect($currentModel.get()).toBe('openai/gpt-5.5')
// A user pick must survive the lifecycle refreshes that fire on boot / fresh
// draft / session events.
setCurrentModel('anthropic/claude-sonnet-4.6')
setCurrentProvider('anthropic')
await result.current.refreshCurrentModel()
expect($currentModel.get()).toBe('anthropic/claude-sonnet-4.6')
// A profile swap forces a reseed to the new profile's default.
await result.current.refreshCurrentModel(true)
expect($currentModel.get()).toBe('openai/gpt-5.5')
})
})
@@ -1,7 +1,7 @@
import { type QueryClient } from '@tanstack/react-query'
import { useCallback } from 'react'
import { getGlobalModelInfo } from '@/hermes'
import { getGlobalModelInfo, setGlobalModel } from '@/hermes'
import { useI18n } from '@/i18n'
import { notifyError } from '@/store/notifications'
import {
@@ -15,6 +15,7 @@ import type { ModelOptionsResponse } from '@/types/hermes'
interface ModelSelection {
model: string
persistGlobal: boolean
provider: string
}
@@ -27,7 +28,6 @@ interface ModelControlsOptions {
export function useModelControls({ activeSessionId, queryClient, requestGateway }: ModelControlsOptions) {
const { t } = useI18n()
const copy = t.desktop
const updateModelOptionsCache = useCallback(
(provider: string, model: string, includeGlobal: boolean) => {
const patch = (prev: ModelOptionsResponse | undefined) => ({ ...(prev ?? {}), provider, model })
@@ -41,24 +41,14 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
[activeSessionId, queryClient]
)
// Seed the composer's model state from the profile default. `force` reseeds
// for a profile swap (the new profile has its own default); otherwise this
// only fills an EMPTY selection so a user's pick (plain UI state in
// $currentModel) survives the lifecycle refreshes that fire on boot / fresh
// draft / session events. A live session owns the footer, so skip entirely.
const refreshCurrentModel = useCallback(async (force = false) => {
const refreshCurrentModel = useCallback(async () => {
try {
if ($activeSessionId.get()) {
return
}
if (!force && $currentModel.get()) {
return
}
const result = await getGlobalModelInfo()
if ($activeSessionId.get() || (!force && $currentModel.get())) {
// 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
}
@@ -74,14 +64,12 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
}
}, [])
// Returns whether the switch succeeded so callers can await it before applying
// follow-up changes. The composer model is plain UI state: with no live
// session it's just stored (and shipped on the next session.create); with one
// it's scoped to that session via config.set. It NEVER writes the profile
// default — that lives in Settings → Model — so picking a model here can't
// silently mutate global config.
// Returns whether the switch succeeded so callers can await it before
// applying follow-up changes (e.g. editing a model's reasoning/fast must land
// on the right active model — bail rather than write to the previous one).
const selectModel = useCallback(
async (selection: ModelSelection): Promise<boolean> => {
const includeGlobal = selection.persistGlobal || !activeSessionId
// Snapshot for rollback: the switch is applied optimistically, so a
// failure must restore the prior model/provider (store + query cache)
// rather than leave the UI showing a model the backend never selected.
@@ -90,34 +78,41 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
setCurrentModel(selection.model)
setCurrentProvider(selection.provider)
updateModelOptionsCache(selection.provider, selection.model, !activeSessionId)
// No live session yet: the pick is pure UI state. session.create reads
// $currentModel/$currentProvider and applies it as that session's override.
if (!activeSessionId) {
return true
}
updateModelOptionsCache(selection.provider, selection.model, includeGlobal)
try {
await requestGateway('config.set', {
session_id: activeSessionId,
key: 'model',
value: `${selection.model} --provider ${selection.provider}`
})
if (activeSessionId) {
await requestGateway('slash.exec', {
session_id: activeSessionId,
command: `/model ${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}`
})
void queryClient.invalidateQueries({ queryKey: ['model-options', activeSessionId] })
if (selection.persistGlobal) {
void refreshCurrentModel()
}
void queryClient.invalidateQueries({
queryKey: selection.persistGlobal ? ['model-options'] : ['model-options', activeSessionId]
})
return true
}
await setGlobalModel(selection.provider, selection.model)
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
return true
} catch (err) {
setCurrentModel(prevModel)
setCurrentProvider(prevProvider)
updateModelOptionsCache(prevProvider, prevModel, !activeSessionId)
updateModelOptionsCache(prevProvider, prevModel, includeGlobal)
notifyError(err, copy.modelSwitchFailed)
return false
}
},
[activeSessionId, copy.modelSwitchFailed, queryClient, requestGateway, updateModelOptionsCache]
[activeSessionId, copy.modelSwitchFailed, queryClient, refreshCurrentModel, requestGateway, updateModelOptionsCache]
)
return { refreshCurrentModel, selectModel, updateModelOptionsCache }
@@ -27,7 +27,6 @@ import { triggerHaptic } from '@/lib/haptics'
import { setMutableRef } from '@/lib/mutable-ref'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { setSessionYolo } from '@/lib/yolo-session'
import { openCommandPalettePage } from '@/store/command-palette'
import {
$composerAttachments,
clearComposerAttachments,
@@ -39,7 +38,6 @@ import {
import { resetSessionBackground } from '@/store/composer-status'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { setPetScale } from '@/store/pet-gallery'
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
import {
$busy,
@@ -59,7 +57,6 @@ import { clearSessionSubagents } from '@/store/subagents'
import { clearSessionTodos } from '@/store/todos'
import type {
BrowserManageResponse,
ClientSessionState,
FileAttachResponse,
HandoffFailResponse,
@@ -1144,110 +1141,6 @@ export function usePromptActions({
} catch (err) {
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
},
pet: async ctx => {
const [sub = '', rawValue = ''] = ctx.arg.trim().split(/\s+/)
const lower = sub.toLowerCase()
if (lower === 'list' || lower === 'gallery' || lower === 'browse' || lower === 'all') {
openCommandPalettePage('pets')
return
}
// `/pet scale <n>` resizes the floating pet locally (instant) and
// persists via the store — no round-trip to the slash worker.
if (lower === 'scale') {
const value = Number(rawValue)
if (!rawValue || Number.isNaN(value)) {
const resolved = await withSlashOutput(ctx)
resolved?.render('usage: /pet scale <factor> (e.g. /pet scale 0.5)')
return
}
setPetScale(requestGateway, value)
return
}
await runExec(ctx)
},
// /browser connect|disconnect|status manages the live CDP connection on
// the gateway host, mirroring the TUI's browser.manage RPC. It mutates
// BROWSER_CDP_URL (and may launch Chrome) in the gateway process — only
// meaningful when that process runs on this machine, so it's gated to
// local connections. A remote gateway would act on the wrong host.
browser: async ctx => {
const resolved = await withSlashOutput(ctx)
if (!resolved) {
return
}
const { render: renderSlashOutput, sessionId } = resolved
if ($connection.get()?.mode === 'remote') {
renderSlashOutput(
'/browser manages a Chromium-family browser on the gateway host — only available when connected to a local gateway.'
)
return
}
const [rawAction = 'status', ...rest] = ctx.arg.trim().split(/\s+/).filter(Boolean)
const cmdAction = rawAction.toLowerCase()
if (!['connect', 'disconnect', 'status'].includes(cmdAction)) {
renderSlashOutput(
'usage: /browser [connect|disconnect|status] [url] · persistent: set browser.cdp_url in config.yaml'
)
return
}
const url = cmdAction === 'connect' ? rest.join(' ').trim() || 'http://127.0.0.1:9222' : undefined
if (url) {
renderSlashOutput(`checking Chromium-family browser remote debugging at ${url}...`)
}
try {
const result = await requestGateway<BrowserManageResponse>('browser.manage', {
action: cmdAction,
session_id: sessionId,
...(url && { url })
})
// Without a streamed session subscription, the gateway bundles its
// progress lines into `messages` — flush them inline.
result?.messages?.forEach(message => renderSlashOutput(message))
if (cmdAction === 'status') {
renderSlashOutput(
result?.connected
? `browser connected: ${result.url || '(url unavailable)'}`
: 'browser not connected (try /browser connect <url> or set browser.cdp_url in config.yaml)'
)
return
}
if (cmdAction === 'disconnect') {
renderSlashOutput('browser disconnected')
return
}
if (result?.connected) {
renderSlashOutput('Browser connected to live Chromium-family browser via CDP')
renderSlashOutput(`Endpoint: ${result.url || '(url unavailable)'}`)
renderSlashOutput('next browser tool call will use this CDP endpoint')
}
} catch (err) {
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
}
}
@@ -1390,7 +1283,6 @@ export function usePromptActions({
const cancelRun = useCallback(async () => {
const sessionId = activeSessionId || activeSessionIdRef.current
const releaseBusy = () => {
setMutableRef(busyRef, false)
setBusy(false)
@@ -15,10 +15,6 @@ import { requestDesktopOnboarding } from '@/store/onboarding'
import { $activeGatewayProfile, $newChatProfile, $profiles, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
import {
$currentCwd,
$currentFastMode,
$currentModel,
$currentProvider,
$currentReasoningEffort,
$messages,
$sessions,
$yoloActive,
@@ -46,7 +42,6 @@ import {
setYoloActive,
workspaceCwdForNewSession
} from '@/store/session'
import { broadcastSessionsChanged } from '@/store/session-sync'
import { reportBackendContract } from '@/store/updates'
import { isWatchWindow } from '@/store/windows'
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, SessionRuntimeInfo, UsageStats } from '@/types/hermes'
@@ -411,13 +406,13 @@ export function useSessionActions({
})
setSessionStartedAt(null)
setTurnStartedAt(null)
// The composer's model/effort/fast is sticky UI state (persisted in
// localStorage) — a new chat FOLLOWS your last pick instead of snapping
// back to the profile default, so we deliberately don't reset it here. The
// profile default still owns first-run seeding and profile switches (see
// refreshCurrentModel). Only $currentServiceTier (a live-session mirror)
// is cleared.
// 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('')
@@ -447,23 +442,11 @@ export function useSessionActions({
const newChatProfile = $newChatProfile.get() ?? normalizeProfileKey($activeGatewayProfile.get())
await ensureGatewayProfile(newChatProfile)
const cwd = $currentCwd.get().trim() || workspaceCwdForNewSession()
// The composer's model/effort/fast is sticky UI state ($currentModel,
// $currentProvider, $currentReasoningEffort, $currentFastMode). Ship it
// with every session.create so the new chat opens on whatever the picker
// shows — applied as per-session overrides, never written to the profile
// default (that lives in Settings → Model).
const uiModel = $currentModel.get().trim()
const uiProvider = $currentProvider.get().trim()
const uiEffort = $currentReasoningEffort.get().trim()
const uiFast = $currentFastMode.get()
const created = await requestGateway<SessionCreateResponse>('session.create', {
cols: 96,
...(cwd && { cwd }),
...(newChatProfile ? { profile: newChatProfile } : {}),
...(uiModel ? { model: uiModel, ...(uiProvider ? { provider: uiProvider } : {}) } : {}),
...(uiEffort ? { reasoning_effort: uiEffort } : {}),
...(uiFast ? { fast: true } : {})
...(newChatProfile ? { profile: newChatProfile } : {})
})
const stored = created.stored_session_id ?? null
@@ -489,9 +472,6 @@ export function useSessionActions({
// server later returns its own preview/title and supersedes this.
upsertOptimisticSession(created, stored, null, preview?.trim() || null)
navigate(sessionRoute(stored), { replace: true })
// Other windows (e.g. the main window when this is the pop-out) can't
// see this session until they re-pull the shared list.
broadcastSessionsChanged()
}
setFreshDraftReady(false)
@@ -1,31 +1,30 @@
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useState } from 'react'
import { LanguageSwitcher } from '@/components/language-switcher'
import { SegmentedControl } from '@/components/ui/segmented-control'
import type { DesktopMarketplaceSearchItem } from '@/global'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Check, Download, Loader2, Palette, Trash2 } from '@/lib/icons'
import { selectableCardClass } from '@/lib/selectable-card'
import { cn } from '@/lib/utils'
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
import { $translucency, setTranslucency } from '@/store/translucency'
import { getBaseColors, useTheme } from '@/themes/context'
import { useTheme } from '@/themes/context'
import { installVscodeThemeFromMarketplace } from '@/themes/install'
import { isUserTheme, removeUserTheme } from '@/themes/user-themes'
import { isUserTheme, removeUserTheme, resolveTheme } from '@/themes/user-themes'
import { MODE_OPTIONS } from './constants'
import { PetSettings } from './pet-settings'
import { ListRow, SectionHeading, SettingsContent } from './primitives'
function ThemePreview({ name, mode }: { name: string; mode: 'light' | 'dark' }) {
// Preview in the *current* mode: the dark palette in Dark, and the light
// palette in Light — synthesizing one for dark-only themes — so every card
// tracks the Light/Dark toggle, exactly like the app itself does.
const c = getBaseColors(name, mode)
function ThemePreview({ name }: { name: string }) {
const t = resolveTheme(name)
if (!t) {
return null
}
const c = t.colors
return (
<div
@@ -58,200 +57,90 @@ function ThemePreview({ name, mode }: { name: string; mode: 'light' | 'dark' })
)
}
function useDebounced<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const handle = setTimeout(() => setDebounced(value), delayMs)
return () => clearTimeout(handle)
}, [value, delayMs])
return debounced
}
const compactNumber = new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 })
/**
* Live VS Code Marketplace theme search (the same backend as the Cmd-K "Install
* theme" page). Renders below the local grid when there's a query: each row
* downloads + converts + installs via `installVscodeThemeFromMarketplace` and
* activates it. Extensions already imported locally are marked installed.
*/
function MarketplaceThemeResults({
query,
installedExtIds,
onInstalled
}: {
query: string
installedExtIds: Set<string>
onInstalled: (name: string) => void
}) {
function VscodeThemeInstaller() {
const { t } = useI18n()
const copy = t.commandCenter.installTheme
const debounced = useDebounced(query.trim(), 300)
const [installingId, setInstallingId] = useState<string | null>(null)
const [installedHere, setInstalledHere] = useState<Record<string, true>>({})
const [error, setError] = useState<string | null>(null)
const { setTheme } = useTheme()
const a = t.settings.appearance
const [id, setId] = useState('')
const [busy, setBusy] = useState(false)
const [status, setStatus] = useState<{ kind: 'error' | 'success'; text: string } | null>(null)
const search = useQuery({
enabled: debounced.length > 0,
queryFn: () => window.hermesDesktop?.themes?.searchMarketplace(debounced) ?? Promise.resolve([]),
queryKey: ['marketplace-themes-settings', debounced],
staleTime: 5 * 60 * 1000
})
const install = async () => {
const trimmed = id.trim()
const install = async (item: DesktopMarketplaceSearchItem) => {
if (installingId) {
if (!trimmed || busy) {
return
}
setInstallingId(item.extensionId)
setError(null)
setBusy(true)
setStatus(null)
try {
const theme = await installVscodeThemeFromMarketplace(item.extensionId)
const theme = await installVscodeThemeFromMarketplace(trimmed)
triggerHaptic('crisp')
setInstalledHere(prev => ({ ...prev, [item.extensionId]: true }))
onInstalled(theme.name)
} catch (e) {
setError(e instanceof Error ? e.message : copy.error)
setTheme(theme.name)
setStatus({ kind: 'success', text: a.installed(theme.label) })
setId('')
} catch (error) {
setStatus({ kind: 'error', text: error instanceof Error ? error.message : a.installError })
} finally {
setInstallingId(null)
setBusy(false)
}
}
if (!debounced) {
return null
}
const header = (
<p className="mb-2 mt-4 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
From the VS Code Marketplace
</p>
)
if (search.isLoading) {
return (
<>
{header}
<p className="flex items-center gap-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<Loader2 className="size-3.5 animate-spin" />
{copy.loading}
</p>
</>
)
}
if (search.isError) {
return (
<>
{header}
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-red)">{copy.error}</p>
</>
)
}
const results = search.data ?? []
if (results.length === 0) {
return (
<>
{header}
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">{copy.empty}</p>
</>
)
}
return (
<>
{header}
{error && <p className="mb-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-red)">{error}</p>}
<div className="grid gap-2 sm:grid-cols-2">
{results.map(item => {
const busy = installingId === item.extensionId
const done = installedHere[item.extensionId] || installedExtIds.has(item.extensionId)
return (
<button
className={cn(
'flex items-center gap-2.5 px-2.5 py-2 text-left disabled:opacity-60',
selectableCardClass({ prominent: done })
)}
disabled={Boolean(installingId) && !busy}
key={item.extensionId}
onClick={() => void install(item)}
type="button"
>
<Palette className="size-4 shrink-0 text-(--ui-text-tertiary)" />
<span className="min-w-0 flex-1">
<span className="block truncate text-[length:var(--conversation-text-font-size)] font-medium">
{item.displayName}
</span>
<span className="block truncate text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{item.publisher}
{item.installs > 0 ? ` · ${copy.installs(compactNumber.format(item.installs))}` : ''}
</span>
</span>
<span className="shrink-0 text-(--ui-text-tertiary)">
{busy ? (
<Loader2 className="size-4 animate-spin" />
) : done ? (
<Check className="size-4 text-(--ui-green)" />
) : (
<Download className="size-4" />
)}
</span>
</button>
)
})}
<div className="mt-3">
<div className="flex flex-wrap items-center gap-2">
<input
className="min-w-0 flex-1 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-1.5 font-mono text-[length:var(--conversation-caption-font-size)] outline-none placeholder:text-(--ui-text-tertiary) focus:border-(--ui-stroke-secondary)"
disabled={busy}
onChange={event => {
setId(event.target.value)
setStatus(null)
}}
onKeyDown={event => {
if (event.key === 'Enter') {
void install()
}
}}
placeholder={a.installPlaceholder}
spellCheck={false}
value={id}
/>
<button
className="inline-flex items-center gap-1.5 rounded-lg border border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary) px-3 py-1.5 text-[length:var(--conversation-caption-font-size)] font-medium transition hover:bg-(--chrome-action-hover) disabled:opacity-50"
disabled={busy || !id.trim()}
onClick={() => void install()}
type="button"
>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />}
{busy ? a.installing : a.installButton}
</button>
</div>
</>
{status && (
<p
className={cn(
'mt-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height)',
status.kind === 'error' ? 'text-(--ui-red)' : 'text-(--ui-text-tertiary)'
)}
>
{status.text}
</p>
)}
</div>
)
}
export function AppearanceSettings() {
const { t, isSavingLocale } = useI18n()
const { themeName, mode, resolvedMode, availableThemes, setTheme, setMode } = useTheme()
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
const toolViewMode = useStore($toolViewMode)
const translucency = useStore($translucency)
const profiles = useStore($profiles)
const activeProfileKey = normalizeProfileKey(useStore($activeGatewayProfile))
const a = t.settings.appearance
const [query, setQuery] = useState('')
// One box does double duty: filter installed themes live (below), and run a
// name search against the VS Code Marketplace (the Cmd-K "Install theme…"
// backend) for anything not already installed.
const needle = query.trim().toLowerCase()
const filteredThemes = availableThemes
.filter(
theme =>
!needle ||
theme.label.toLowerCase().includes(needle) ||
theme.name.toLowerCase().includes(needle) ||
theme.description.toLowerCase().includes(needle)
)
// Active theme first; stable sort keeps the rest in their original order.
.sort((a, b) => Number(b.name === themeName) - Number(a.name === themeName))
// Marketplace imports describe themselves as "VS Code · <publisher.extension>";
// pull those ids back out so search results already imported show as installed.
const MARKETPLACE_DESC_PREFIX = 'VS Code · '
const installedExtIds = new Set(
availableThemes
.map(theme =>
theme.description.startsWith(MARKETPLACE_DESC_PREFIX)
? theme.description.slice(MARKETPLACE_DESC_PREFIX.length)
: ''
)
.filter(Boolean)
)
// Themes save per profile. Surface that only when the user actually has more
// than one profile (single-profile installs never see the distinction).
const showProfileNote = profiles.length > 1
@@ -274,7 +163,7 @@ export function AppearanceSettings() {
{a.intro}
</p>
<div className="mt-2">
<div className="mt-2 divide-y divide-(--ui-stroke-tertiary)">
<ListRow
action={<LanguageSwitcher />}
description={isSavingLocale ? t.language.saving : t.language.description}
@@ -282,107 +171,18 @@ export function AppearanceSettings() {
/>
<ListRow
below={
<>
{/* One search box: filters your installed themes (the grid)
and live-searches the VS Code Marketplace below. */}
<div className="mt-3">
<input
className="w-full rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-1.5 text-[length:var(--conversation-caption-font-size)] outline-none placeholder:text-(--ui-text-tertiary) focus:border-(--ui-stroke-secondary)"
onChange={event => setQuery(event.target.value)}
placeholder="Search your themes or the VS Code Marketplace…"
spellCheck={false}
value={query}
/>
</div>
{/* Fixed-height scroll area so the (growing) theme list never
runs the page long; the grid scrolls inside it. */}
<div className="mt-3 max-h-96 overflow-y-auto pr-1">
{filteredThemes.length === 0 ? (
needle ? (
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
No installed themes match "{query.trim()}".
</p>
) : null
) : (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{filteredThemes.map(theme => {
const active = themeName === theme.name
const removable = isUserTheme(theme.name)
return (
<div className="group relative" key={theme.name}>
<button
className={cn('w-full p-2 text-left', selectableCardClass({ active, prominent: true }))}
onClick={() => {
triggerHaptic('crisp')
setTheme(theme.name)
}}
type="button"
>
<ThemePreview mode={resolvedMode} name={theme.name} />
<div className="mt-3 px-1">
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
{theme.label}
</div>
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{theme.description}
</div>
</div>
</button>
{removable && (
<button
aria-label={a.removeTheme}
className="absolute right-1.5 top-1.5 grid size-6 place-items-center rounded-md bg-(--ui-bg-elevated)/80 text-(--ui-text-tertiary) opacity-0 backdrop-blur-sm transition hover:text-(--ui-red) focus-visible:opacity-100 group-hover:opacity-100"
onClick={() => {
triggerHaptic('crisp')
removeUserTheme(theme.name)
// Re-normalize off the now-missing skin → default.
if (active) {
setTheme(theme.name)
}
}}
title={a.removeTheme}
type="button"
>
<Trash2 className="size-3.5" />
</button>
)}
</div>
)
})}
</div>
)}
<MarketplaceThemeResults
installedExtIds={installedExtIds}
onInstalled={name => setTheme(name)}
query={query}
/>
</div>
{showProfileNote && (
<p className="mt-3 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{a.themeProfileNote(activeProfileName)}
</p>
)}
</>
action={
<SegmentedControl
onChange={id => {
triggerHaptic('crisp')
setMode(id)
}}
options={modeOptions}
value={mode}
/>
}
description={a.themeDesc}
title={
<div className="flex items-center justify-between gap-3">
<span>{a.themeTitle}</span>
<SegmentedControl
onChange={id => {
triggerHaptic('crisp')
setMode(id)
}}
options={modeOptions}
value={mode}
/>
</div>
}
wide
description={a.colorModeDesc}
title={a.colorMode}
/>
<ListRow
@@ -411,6 +211,80 @@ export function AppearanceSettings() {
title={a.translucencyTitle}
/>
<ListRow
below={
<>
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{availableThemes.map(theme => {
const active = themeName === theme.name
const removable = isUserTheme(theme.name)
return (
<div className="group relative" key={theme.name}>
<button
className={cn(
'w-full rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
onClick={() => {
triggerHaptic('crisp')
setTheme(theme.name)
}}
type="button"
>
<ThemePreview name={theme.name} />
<div className="mt-3 flex items-start justify-between gap-3 px-1">
<div className="min-w-0">
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
{theme.label}
</div>
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{theme.description}
</div>
</div>
{active && (
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div>
</button>
{removable && (
<button
aria-label={a.removeTheme}
className="absolute right-1.5 top-1.5 grid size-6 place-items-center rounded-md bg-(--ui-bg-elevated)/80 text-(--ui-text-tertiary) opacity-0 backdrop-blur-sm transition hover:text-(--ui-red) focus-visible:opacity-100 group-hover:opacity-100"
onClick={() => {
triggerHaptic('crisp')
removeUserTheme(theme.name)
// Re-normalize off the now-missing skin → default.
if (active) {
setTheme(theme.name)
}
}}
title={a.removeTheme}
type="button"
>
<Trash2 className="size-3.5" />
</button>
)}
</div>
)
})}
</div>
<VscodeThemeInstaller />
{showProfileNote && (
<p className="mt-3 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{a.themeProfileNote(activeProfileName)}
</p>
)}
</>
}
description={a.themeDesc}
title={a.themeTitle}
wide
/>
<ListRow
action={
<SegmentedControl
@@ -427,10 +301,6 @@ export function AppearanceSettings() {
/>
</div>
</div>
<div className="mt-6">
<PetSettings />
</div>
</SettingsContent>
)
}
+1 -1
View File
@@ -228,7 +228,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
onMainModelChanged={onMainModelChanged}
/>
) : activeView === 'providers' ? (
<ProvidersSettings onClose={onClose} onViewChange={setProviderView} view={providerView} />
<ProvidersSettings onViewChange={setProviderView} view={providerView} />
) : activeView === 'keys' ? (
<KeysSettings view={keysView} />
) : activeView === 'mcp' ? (
@@ -1,231 +0,0 @@
import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { PetThumb } from '@/components/pet/pet-thumb'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Loader2, PawPrint, Trash2 } from '@/lib/icons'
import { selectableCardClass } from '@/lib/selectable-card'
import { cn } from '@/lib/utils'
import { $petInfo } from '@/store/pet'
import {
$petBusy,
$petGallery,
$petGalleryError,
$petGalleryStatus,
adoptPet,
loadPetGallery,
loadPetThumb,
PET_SCALE_DEFAULT,
PET_SCALE_MAX,
PET_SCALE_MIN,
rankedGalleryPets,
removePet as removePetAction,
setPetEnabled,
setPetScale
} from '@/store/pet-gallery'
import { $gatewayState } from '@/store/session'
import { ListRow, SectionHeading } from './primitives'
/**
* Appearance opt-in for the floating petdex mascot. A thin view over the shared
* `pet-gallery` store it subscribes to the atoms and calls the store actions,
* so the gallery is fetched once + cached and adopt/toggle/remove patch local
* state instead of re-pulling the network gallery. The floating mascot polls
* `pet.info`, so picking a pet here lights it up within a couple seconds.
*/
export function PetSettings() {
const { t } = useI18n()
const copy = t.settings.appearance.pet
const { requestGateway } = useGatewayRequest()
const gatewayState = useStore($gatewayState)
const gallery = useStore($petGallery)
const status = useStore($petGalleryStatus)
const error = useStore($petGalleryError)
const busySlug = useStore($petBusy)
const petInfo = useStore($petInfo)
const [query, setQuery] = useState('')
const scale = petInfo.scale ?? PET_SCALE_DEFAULT
useEffect(() => {
if (gatewayState !== 'open') {
return
}
void loadPetGallery(requestGateway)
}, [gatewayState, requestGateway])
const enabled = gallery?.enabled ?? false
const active = gallery?.active ?? ''
const pets = gallery?.pets ?? []
const staleBackend = status === 'stale'
const selectPet = (slug: string) => {
void adoptPet(requestGateway, slug, copy.adoptFailed(slug)).then(ok => ok && triggerHaptic('crisp'))
}
const removePet = (slug: string) => {
void removePetAction(requestGateway, slug, copy.uninstallFailed(slug)).then(ok => ok && triggerHaptic('crisp'))
}
const toggle = (on: boolean) => {
void setPetEnabled(requestGateway, on, {
noneAvailable: copy.noneAvailable,
fallback: on ? copy.turnOnFailed : copy.turnOffFailed
}).then(ok => ok && triggerHaptic('crisp'))
}
// The petdex catalog is thousands of entries, so rank + cap how many render.
const RENDER_CAP = 60
const sorted = rankedGalleryPets(gallery, query)
const shown = sorted.slice(0, RENDER_CAP)
return (
<div>
<SectionHeading icon={PawPrint} title={copy.title} />
<p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{copy.intro}
</p>
{staleBackend && (
<p className="mt-2 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{copy.restartHint}
</p>
)}
<div className="mt-2">
<ListRow
below={
<>
<input
className="mt-3 w-full rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-1.5 text-[length:var(--conversation-caption-font-size)] outline-none placeholder:text-(--ui-text-tertiary) focus:border-(--ui-stroke-secondary)"
onChange={event => setQuery(event.target.value)}
placeholder={copy.searchPlaceholder}
spellCheck={false}
value={query}
/>
{/* Fixed-height scroll area so filtering never grows/shrinks the
page (no layout thrash); the grid scrolls inside it. */}
<div className="mt-3 h-72 overflow-y-auto pr-1">
{pets.length === 0 ? (
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{copy.unreachable}
</p>
) : shown.length === 0 ? (
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{copy.noMatch(query)}
</p>
) : (
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
{shown.map(pet => {
const isActive = enabled && active === pet.slug
const isBusy = busySlug === pet.slug
return (
<div className="group relative" key={pet.slug}>
<button
className={cn(
'flex w-full items-center gap-2.5 px-2.5 py-2 text-left disabled:opacity-50',
selectableCardClass({ active: isActive, prominent: pet.installed })
)}
disabled={isBusy}
onClick={() => void selectPet(pet.slug)}
type="button"
>
<PetThumb
alt={pet.displayName}
load={(slug, url) => loadPetThumb(requestGateway, slug, url)}
slug={pet.slug}
url={pet.spritesheetUrl}
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-[length:var(--conversation-text-font-size)] font-medium">
{pet.displayName}
</span>
<span className="block truncate text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{pet.slug}
{pet.installed ? ` · ${copy.installedTag}` : ''}
</span>
</span>
{isBusy && <Loader2 className="size-4 shrink-0 animate-spin text-(--ui-text-tertiary)" />}
</button>
{pet.installed && !isBusy && (
<button
aria-label={copy.uninstall(pet.displayName)}
className="absolute right-1.5 top-1.5 grid size-6 place-items-center rounded-md bg-(--ui-bg-elevated)/80 text-(--ui-text-tertiary) opacity-0 backdrop-blur-sm transition hover:text-(--ui-red) focus-visible:opacity-100 group-hover:opacity-100"
onClick={() => void removePet(pet.slug)}
title={copy.uninstall(pet.displayName)}
type="button"
>
<Trash2 className="size-3.5" />
</button>
)}
</div>
)
})}
</div>
)}
</div>
{/* Always-present status line so its appearance never shifts layout. */}
<p className="mt-2 min-h-4 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{error ? (
<span className="text-(--ui-red)">{error}</span>
) : sorted.length > RENDER_CAP ? (
copy.countCapped(RENDER_CAP, sorted.length)
) : (
copy.count(sorted.length)
)}
</p>
</>
}
description={copy.chooseDesc}
title={
<div className="flex items-center justify-between gap-3">
<span>{copy.chooseTitle}</span>
<SegmentedControl
onChange={id => void toggle(id === 'on')}
options={[
{ id: 'off', label: copy.off },
{ id: 'on', label: copy.on }
]}
value={enabled ? 'on' : 'off'}
/>
</div>
}
wide
/>
{enabled && (
<ListRow
action={
<div className="flex items-center gap-3">
<input
aria-label={copy.scaleTitle}
className="h-1 w-40 cursor-pointer appearance-none rounded-full bg-(--ui-stroke-tertiary)"
max={PET_SCALE_MAX}
min={PET_SCALE_MIN}
onChange={event => {
triggerHaptic('selection')
setPetScale(requestGateway, Number(event.target.value))
}}
step={0.05}
style={{ accentColor: 'var(--dt-primary)' }}
type="range"
value={scale}
/>
<span className="w-9 text-right text-[length:var(--conversation-caption-font-size)] tabular-nums text-(--ui-text-tertiary)">
{`${Math.round(scale * 100)}%`}
</span>
</div>
}
description={copy.scaleDesc}
title={copy.scaleTitle}
/>
)}
</div>
</div>
)
}
@@ -55,7 +55,7 @@ afterEach(() => {
async function renderProvidersSettings() {
const { ProvidersSettings } = await import('./providers-settings')
return render(<ProvidersSettings onClose={vi.fn()} onViewChange={vi.fn()} view="accounts" />)
return render(<ProvidersSettings onViewChange={vi.fn()} view="accounts" />)
}
describe('ProvidersSettings', () => {
@@ -95,6 +95,6 @@ describe('ProvidersSettings', () => {
expect(await screen.findByText('Qwen Code')).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Remove Qwen Code' })).toBeNull()
expect(screen.getByText(/managed by its own CLI/)).toBeTruthy()
expect(screen.getByText(/managed outside Hermes/)).toBeTruthy()
})
})
@@ -1,8 +1,6 @@
import { useStore } from '@nanostores/react'
import type { ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { runInTerminal } from '@/app/right-sidebar/store'
import {
FEATURED_ID,
FeaturedProviderRow,
@@ -25,20 +23,6 @@ import { SettingsCategoryHeading, useEnvCredentials } from './env-credentials'
import { providerGroup, providerMeta, providerPriority } from './helpers'
import { LoadingState, SettingsContent } from './primitives'
// The embedded terminal (and thus the "run disconnect command" path) only
// exists in the Electron desktop shell, not the web dashboard.
const canRunInTerminal = () => typeof window !== 'undefined' && Boolean(window.hermesDesktop?.terminal)
// Parallel group headers ("Connected", "Other providers") so the expanded list
// reads as its own section instead of bleeding into the connected group.
function GroupLabel({ children }: { children: ReactNode }) {
return (
<p className="mt-3 px-0.5 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
{children}
</p>
)
}
// Sub-views surfaced as a sidebar subnav: account sign-in vs raw API keys.
export const PROVIDER_VIEWS = ['accounts', 'keys'] as const
@@ -106,13 +90,11 @@ function buildProviderKeyGroups(vars: Record<string, EnvVarInfo>): ProviderKeyGr
function OAuthPicker({
disconnecting,
onDisconnect,
onTerminalDisconnect,
onWantApiKey,
providers
}: {
disconnecting: null | string
onDisconnect: (provider: OAuthProvider) => void
onTerminalDisconnect: (provider: OAuthProvider) => void
onWantApiKey: () => void
providers: OAuthProvider[]
}) {
@@ -156,14 +138,15 @@ function OAuthPicker({
{featured && <FeaturedProviderRow onSelect={select} provider={featured} />}
{connected.length > 0 && (
<>
<GroupLabel>{p.connected}</GroupLabel>
<p className="mt-1 px-0.5 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
{p.connected}
</p>
{connected.map(p => (
<ConnectedProviderRow
disconnecting={disconnecting === p.id}
key={p.id}
onDisconnect={onDisconnect}
onSelect={select}
onTerminalDisconnect={onTerminalDisconnect}
provider={p}
/>
))}
@@ -171,7 +154,6 @@ function OAuthPicker({
)}
{showOthers && (
<>
{connected.length > 0 && <GroupLabel>{p.otherProviders}</GroupLabel>}
{others.map(p => (
<ProviderRow key={p.id} onSelect={select} provider={p} />
))}
@@ -198,26 +180,21 @@ function ConnectedProviderRow({
disconnecting,
onDisconnect,
onSelect,
onTerminalDisconnect,
provider
}: {
disconnecting: boolean
onDisconnect: (provider: OAuthProvider) => void
onSelect: (provider: OAuthProvider) => void
onTerminalDisconnect: (provider: OAuthProvider) => void
provider: OAuthProvider
}) {
const { t } = useI18n()
const copy = t.settings.providers
const title = providerTitle(provider)
const Trail = provider.flow === 'external' ? Terminal : ChevronRight
// Hermes can clear this provider's creds via the API.
const canDisconnect = provider.disconnectable ?? provider.flow !== 'external'
// External (CLI-managed) provider Hermes can't clear via the API, but ships a
// command we can run in the embedded terminal (Electron shell only).
const terminalDisconnect = !canDisconnect && Boolean(provider.disconnect_command) && canRunInTerminal()
// Only fall back to a static "remove it elsewhere" hint when we offer no button.
const showHint = !canDisconnect && !terminalDisconnect
const disconnectHint = provider.flow === 'external'
? t.settings.providers.removeExternal(title, provider.cli_command)
: t.settings.providers.removeKeyManaged(title)
return (
<div className="group grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1 rounded-[6px] transition-colors hover:bg-(--ui-control-hover-background)">
@@ -226,13 +203,13 @@ function ConnectedProviderRow({
<span className="truncate text-[length:var(--conversation-text-font-size)] font-semibold">{title}</span>
<span className="inline-flex shrink-0 items-center gap-1 bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<Check className="size-3" />
{copy.connected}
{t.settings.providers.connected}
</span>
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.flowSubtitles[provider.flow]}</p>
{showHint && (
{!canDisconnect && (
<p className="mt-0.5 truncate text-[0.68rem] leading-5 text-muted-foreground/70">
{provider.flow === 'external' ? copy.removeExternalGeneric(title) : copy.removeKeyManaged(title)}
{disconnectHint}
</p>
)}
</button>
@@ -251,18 +228,6 @@ function ConnectedProviderRow({
{disconnecting ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />}
</Button>
)}
{terminalDisconnect && (
<Button
aria-label={`${copy.disconnect} ${title}`}
onClick={() => onTerminalDisconnect(provider)}
size="icon-xs"
title={copy.disconnectInTerminal}
type="button"
variant="ghost"
>
<Trash2 className="size-3" />
</Button>
)}
</div>
</div>
)
@@ -278,7 +243,7 @@ function NoProviderKeys() {
)
}
export function ProvidersSettings({ onClose, onViewChange, view }: ProvidersSettingsProps) {
export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps) {
const { t } = useI18n()
const { rowProps, vars } = useEnvCredentials()
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([])
@@ -317,29 +282,6 @@ export function ProvidersSettings({ onClose, onViewChange, view }: ProvidersSett
return () => void (cancelled = true)
}, [onboardingActive])
// External (CLI-managed) providers can't be cleared via the API by design —
// Hermes never deletes creds another tool owns behind a silent API call.
// Instead we run the documented removal command in the embedded terminal so
// the user sees exactly what executes, then return them to chat to watch it.
function handleTerminalDisconnect(provider: OAuthProvider) {
const command = provider.disconnect_command
if (!command) {
return
}
const name = providerTitle(provider)
if (!window.confirm(t.settings.providers.removeTerminalConfirm(name, command))) {
return
}
// Leave the settings overlay so the terminal pane (chat-only) is visible.
onClose()
runInTerminal(command)
notify({ kind: 'info', title: t.settings.providers.removedTitle, message: t.settings.providers.removeTerminalRunning(name) })
}
async function handleDisconnect(provider: OAuthProvider) {
const name = providerTitle(provider)
@@ -399,7 +341,6 @@ export function ProvidersSettings({ onClose, onViewChange, view }: ProvidersSett
<OAuthPicker
disconnecting={disconnecting}
onDisconnect={provider => void handleDisconnect(provider)}
onTerminalDisconnect={handleTerminalDisconnect}
onWantApiKey={() => onViewChange('keys')}
providers={oauthProviders}
/>
@@ -418,7 +359,6 @@ interface ProviderKeyGroup {
}
interface ProvidersSettingsProps {
onClose: () => void
onViewChange: (view: ProviderView) => void
view: ProviderView
}
+2 -15
View File
@@ -4,7 +4,6 @@ import { useSyncExternalStore } from 'react'
import { NotificationStack } from '@/components/notifications'
import { PaneShell } from '@/components/pane-shell'
import { FloatingPet } from '@/components/pet/floating-pet'
import { SidebarProvider } from '@/components/ui/sidebar'
import { useMediaQuery } from '@/hooks/use-media-query'
import {
@@ -81,10 +80,6 @@ export function AppShell({
const connection = useStore($connection)
const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false)
const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen
// Every secondary window (new-session scratch, subagent watch, cmd-click
// pop-out) is a compact side panel — none of them carry the full titlebar
// tool cluster. Gate on isSecondaryWindow, never the narrower new-session flag.
const hideTitlebarControls = isSecondaryWindow()
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen)
// Width Windows/Linux reserve for the OS-painted min/max/close overlay (zero
// on macOS, where window controls sit on the left and are reported via
@@ -167,9 +162,7 @@ export function AppShell({
} as CSSProperties
}
>
{!hideTitlebarControls && (
<TitlebarControls leftTools={leftTitlebarTools} onOpenSettings={onOpenSettings} tools={titlebarTools} />
)}
<TitlebarControls leftTools={leftTitlebarTools} onOpenSettings={onOpenSettings} tools={titlebarTools} />
<main className="relative z-3 flex min-h-0 w-full flex-1 flex-col overflow-hidden transition-none">
<PaneShell className="min-h-0 flex-1">
@@ -190,9 +183,7 @@ export function AppShell({
the panes' z-20 resize handles, keeping every pane resizable. */}
{mainOverlays}
{/* The compact pop-out drops the statusbar it's a scratch window, not
the full shell. */}
{!isSecondaryWindow() && <StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />}
<StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />
</main>
{overlays}
@@ -203,10 +194,6 @@ export function AppShell({
{/* Mounted at the shell root (after overlays) so success/error toasts
surface above every route and overlay not just the chat view. */}
<NotificationStack />
{/* Petdex floating mascot in-window, always-on-top, reactive to agent
activity. Renders nothing unless a pet is installed + enabled. */}
<FloatingPet />
</SidebarProvider>
)
}
@@ -1,4 +1,5 @@
import { useStore } from '@nanostores/react'
import type { ReactNode } from 'react'
import { useCallback, useMemo } from 'react'
import type { CommandCenterSection } from '@/app/command-center'
@@ -8,6 +9,7 @@ import { useI18n } from '@/i18n'
import {
Activity,
AlertCircle,
ChevronDown,
Clock,
Command,
Hash,
@@ -17,6 +19,7 @@ import {
Zap,
ZapFilled
} from '@/lib/icons'
import { formatModelStatusLabel } from '@/lib/model-status-label'
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
import { cn } from '@/lib/utils'
@@ -27,11 +30,16 @@ import {
$activeSessionId,
$busy,
$connection,
$currentFastMode,
$currentModel,
$currentProvider,
$currentReasoningEffort,
$currentUsage,
$sessionStartedAt,
$turnStartedAt,
$workingSessionIds,
$yoloActive,
setModelPickerOpen,
setYoloActive
} from '@/store/session'
import { $subagentsBySession, activeSubagentCount } from '@/store/subagents'
@@ -57,6 +65,7 @@ interface StatusbarItemsOptions {
gatewayLogLines: readonly string[]
gatewayState: string
inferenceStatus: RuntimeReadinessResult | null
modelMenuContent?: ReactNode
openAgents: () => void
openCommandCenterSection: (section: CommandCenterSection) => void
freshDraftReady: boolean
@@ -74,6 +83,7 @@ export function useStatusbarItems({
gatewayLogLines,
gatewayState,
inferenceStatus,
modelMenuContent,
openAgents,
openCommandCenterSection,
freshDraftReady,
@@ -87,6 +97,10 @@ export function useStatusbarItems({
const terminalTakeover = useStore($terminalTakeover)
const yoloActive = useStore($yoloActive)
const busy = useStore($busy)
const currentFastMode = useStore($currentFastMode)
const currentModel = useStore($currentModel)
const currentProvider = useStore($currentProvider)
const currentReasoningEffort = useStore($currentReasoningEffort)
const currentUsage = useStore($currentUsage)
const desktopActionTasks = useStore($desktopActionTasks)
const previewServerRestartStatus = useStore($previewServerRestartStatus)
@@ -402,6 +416,37 @@ export function useStatusbarItems({
title: yoloActive ? copy.yoloOn : copy.yoloOff,
variant: 'action'
},
{
id: 'model-summary',
label: (
<span className="inline-flex min-w-0 items-center gap-0.5">
<span className="truncate">
{formatModelStatusLabel(currentModel, {
fastMode: currentFastMode,
reasoningEffort: currentReasoningEffort
})}
</span>
<ChevronDown className="size-2.5 shrink-0 opacity-50" />
</span>
),
...(modelMenuContent
? {
menuAlign: 'end' as const,
menuClassName: 'w-64',
menuContent: modelMenuContent,
title: currentProvider
? copy.modelTitle(currentProvider, currentModel || copy.modelNone)
: copy.switchModel,
variant: 'menu' as const
}
: {
onSelect: () => setModelPickerOpen(true),
title: currentProvider
? copy.providerModelTitle(currentProvider, currentModel || copy.noModel)
: copy.openModelPicker,
variant: 'action' as const
})
},
{
className: `w-7 justify-center px-0${terminalTakeover ? ' bg-accent/55 text-foreground' : ''}`,
hidden: !chatOpen,
@@ -420,6 +465,11 @@ export function useStatusbarItems({
contextBar,
contextUsage,
copy,
currentFastMode,
currentModel,
currentProvider,
currentReasoningEffort,
modelMenuContent,
sessionStartedAt,
showYoloToggle,
terminalTakeover,
@@ -1,84 +0,0 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { DropdownMenu, DropdownMenuContent, DropdownMenuSub, DropdownMenuSubTrigger } from '@/components/ui/dropdown-menu'
import { $modelPresets, getModelPreset } from '@/store/model-presets'
import { $activeSessionId } from '@/store/session'
import { type FastControl, ModelEditSubmenu } from './model-edit-submenu'
// Radix calls these on open; jsdom doesn't implement them.
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()
Element.prototype.hasPointerCapture = vi.fn(() => false)
Element.prototype.releasePointerCapture = vi.fn()
})
beforeEach(() => {
$modelPresets.set({})
$activeSessionId.set(null)
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
// Render the submenu inside an open menu/sub so its content (switches) mounts.
function renderSubmenu(opts: { fastControl: FastControl; reasoning: boolean; requestGateway: () => Promise<unknown> }) {
return render(
<DropdownMenu open>
<DropdownMenuContent>
<DropdownMenuSub open>
<DropdownMenuSubTrigger>edit</DropdownMenuSubTrigger>
<ModelEditSubmenu
effort="medium"
fastControl={opts.fastControl}
isActive
model="m1"
onSelectModel={vi.fn()}
provider="p1"
reasoning={opts.reasoning}
requestGateway={opts.requestGateway as never}
/>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
)
}
// Regression: editing the active row before a live session exists must stay
// preset-only — the gateway's config.set falls back to global config when no
// session matches, so it must not be called. (Caught in the second review.)
describe('ModelEditSubmenu no-session guard', () => {
it('param fast: records the preset but skips the gateway without a session', () => {
const requestGateway = vi.fn().mockResolvedValue({})
renderSubmenu({ fastControl: { kind: 'param', on: false }, reasoning: false, requestGateway })
fireEvent.click(screen.getByRole('switch'))
expect(getModelPreset('p1', 'm1').fast).toBe(true)
expect(requestGateway).not.toHaveBeenCalled()
})
it('reasoning: records the preset but skips the gateway without a session', () => {
const requestGateway = vi.fn().mockResolvedValue({})
renderSubmenu({ fastControl: { kind: 'none' }, reasoning: true, requestGateway })
// Thinking starts on (medium); toggling it off routes through patchReasoning.
fireEvent.click(screen.getByRole('switch'))
expect(getModelPreset('p1', 'm1').effort).toBe('none')
expect(requestGateway).not.toHaveBeenCalled()
})
it('param fast: pushes to the gateway once a session is active', async () => {
const requestGateway = vi.fn().mockResolvedValue({})
$activeSessionId.set('sess1')
renderSubmenu({ fastControl: { kind: 'param', on: false }, reasoning: false, requestGateway })
fireEvent.click(screen.getByRole('switch'))
expect(requestGateway).toHaveBeenCalledWith('config.set', { key: 'fast', session_id: 'sess1', value: 'fast' })
})
})
@@ -12,9 +12,13 @@ import {
} from '@/components/ui/dropdown-menu'
import { Switch } from '@/components/ui/switch'
import { useI18n } from '@/i18n'
import { setModelPreset } from '@/store/model-presets'
import { notifyError } from '@/store/notifications'
import { $activeSessionId, setCurrentFastMode, setCurrentReasoningEffort } from '@/store/session'
import {
$activeSessionId,
$currentReasoningEffort,
setCurrentFastMode,
setCurrentReasoningEffort
} from '@/store/session'
// Hermes' real reasoning levels (see VALID_REASONING_EFFORTS); `none` is owned
// by the Thinking toggle, not the radio.
@@ -72,104 +76,96 @@ export function resolveFastControl(
}
interface ModelEditSubmenuProps {
/** This row's effective reasoning effort (live for the active model, else its
* preset) the submenu shows and edits from this, never the raw session. */
effort: string
/** How fast mode is offered for this model (param toggle vs. variant swap). */
fastControl: FastControl
/** Whether this row's model is the active one. */
isActive: boolean
/** This row's model id — edits persist as its global preset. */
model: string
/** Switch to this model (resolves false on failure). Awaited before applying
* edits when not active so a failed switch doesn't write to the old model. */
onActivate: () => Promise<boolean> | void
/** Switch to a specific model id (used to swap base ⇄ -fast variant). */
onSelectModel: (model: string) => Promise<boolean> | void
/** This row's provider slug — edits persist as its global preset. */
provider: string
/** Whether this model supports reasoning effort. */
reasoning: boolean
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}
export function ModelEditSubmenu({
effort,
fastControl,
isActive,
model,
onActivate,
onSelectModel,
provider,
reasoning,
requestGateway
}: ModelEditSubmenuProps) {
const { t } = useI18n()
const copy = t.shell.modelOptions
// Reactive session state comes straight from the stores rather than being
// drilled through the panel, so editing it re-renders only this submenu.
const activeSessionId = useStore($activeSessionId)
const currentReasoningEffort = useStore($currentReasoningEffort)
const effortValue = normalizeEffort(effort)
const thinkingOn = isThinkingEnabled(effort)
const effort = normalizeEffort(currentReasoningEffort)
const thinkingOn = isThinkingEnabled(currentReasoningEffort)
// Editing always records the model's global preset; the active model also gets
// it pushed onto the live session. Non-active edits stay preset-only — they do
// not switch you to that model.
const patchReasoning = async (next: string) => {
setModelPreset(provider, model, { effort: next })
if (!isActive) {
return
// Reasoning/fast are session-scoped (they apply to the active model), so
// editing a non-active model first switches to it. Returns false if the
// switch failed, so callers skip applying to the wrong (previous) model.
const ensureActive = async (): Promise<boolean> => {
if (isActive) {
return true
}
return (await onActivate()) !== false
}
const patchReasoning = async (next: string, rollback: string) => {
setCurrentReasoningEffort(next)
// Preset-only without a session: `isActive` holds for the global/default
// row pre-session, and the gateway's `config.set` falls back to global
// config when none matches — so don't reach it (preset + optimistic store
// are the whole effect). Same guard in applyModelPreset / toggleFast.
if (!activeSessionId) {
return
}
try {
await requestGateway('config.set', { key: 'reasoning', session_id: activeSessionId, value: next })
if (!(await ensureActive())) {
setCurrentReasoningEffort(rollback)
return
}
await requestGateway('config.set', {
key: 'reasoning',
session_id: activeSessionId ?? '',
value: next
})
} catch (err) {
setCurrentReasoningEffort(effort)
setModelPreset(provider, model, { effort })
setCurrentReasoningEffort(rollback)
notifyError(err, copy.updateFailed)
}
}
const toggleFast = (enabled: boolean) => {
if (fastControl.kind === 'variant') {
// Fast is a separate model id. Record the choice on the base model's
// preset (selectFamily picks the `-fast` sibling later when set), and
// only swap models now if this is the active row — inactive edits must
// stay preset-only, same as the param path below.
setModelPreset(provider, fastControl.baseId, { fast: enabled })
if (isActive) {
void onSelectModel(enabled ? fastControl.fastId : fastControl.baseId)
}
// Fast is a separate model id — swap to it (or back to the base).
void onSelectModel(enabled ? fastControl.fastId : fastControl.baseId)
return
}
if (fastControl.kind === 'param') {
setModelPreset(provider, model, { fast: enabled })
if (!isActive) {
return
}
setCurrentFastMode(enabled)
// Preset-only without a session (see patchReasoning).
if (!activeSessionId) {
return
}
void (async () => {
try {
await requestGateway('config.set', { key: 'fast', session_id: activeSessionId, value: enabled ? 'fast' : 'normal' })
if (!(await ensureActive())) {
setCurrentFastMode(!enabled)
return
}
await requestGateway('config.set', {
key: 'fast',
session_id: activeSessionId ?? '',
value: enabled ? 'fast' : 'normal'
})
} catch (err) {
setCurrentFastMode(!enabled)
setModelPreset(provider, model, { fast: !enabled })
notifyError(err, copy.fastFailed)
}
})()
@@ -192,7 +188,9 @@ export function ModelEditSubmenu({
<Switch
checked={thinkingOn}
className="ml-auto"
onCheckedChange={checked => void patchReasoning(checked ? effortValue || 'medium' : 'none')}
onCheckedChange={checked =>
void patchReasoning(checked ? effort || 'medium' : 'none', currentReasoningEffort)
}
size="xs"
/>
</DropdownMenuItem>
@@ -207,7 +205,10 @@ export function ModelEditSubmenu({
<>
<DropdownMenuSeparator className="mx-0" />
<DropdownMenuLabel className={dropdownMenuSectionLabel}>{copy.effort}</DropdownMenuLabel>
<DropdownMenuRadioGroup onValueChange={value => void patchReasoning(value)} value={effortValue}>
<DropdownMenuRadioGroup
onValueChange={value => void patchReasoning(value, currentReasoningEffort)}
value={effort}
>
{EFFORT_OPTIONS.map(option => (
<DropdownMenuRadioItem
className={dropdownMenuRow}
+25 -68
View File
@@ -1,6 +1,6 @@
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { createContext, useContext, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import {
@@ -20,7 +20,6 @@ import { getGlobalModelOptions } from '@/hermes'
import { useI18n } from '@/i18n'
import { displayModelName, modelDisplayParts, reasoningEffortLabel } from '@/lib/model-status-label'
import { cn } from '@/lib/utils'
import { $modelPresets, applyModelPreset, modelPresetKey } from '@/store/model-presets'
import {
$visibleModels,
collapseModelFamilies,
@@ -41,14 +40,9 @@ import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
import { ModelEditSubmenu, resolveFastControl } from './model-edit-submenu'
// Lets the host dropdown (model-pill) hand the panel a way to dismiss itself so
// clicking a model row commits + closes, while the hover-revealed edit submenu
// (reasoning/fast) stays open to play with (its items preventDefault on select).
export const ModelMenuCloseContext = createContext<() => void>(() => {})
interface ModelMenuPanelProps {
gateway?: HermesGateway
onSelectModel: (selection: { model: string; provider: string }) => Promise<boolean> | void
onSelectModel: (selection: { model: string; persistGlobal: boolean; provider: string }) => Promise<boolean> | void
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}
@@ -60,7 +54,6 @@ interface ProviderGroup {
export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: ModelMenuPanelProps) {
const { t } = useI18n()
const copy = t.shell.modelMenu
const closeMenu = useContext(ModelMenuCloseContext)
const [search, setSearch] = useState('')
// Reactive session state is read from the stores here (not drilled in), so
// toggling effort/fast/model re-renders this panel in place without forcing
@@ -70,7 +63,6 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
const currentModel = useStore($currentModel)
const currentProvider = useStore($currentProvider)
const currentReasoningEffort = useStore($currentReasoningEffort)
const modelPresets = useStore($modelPresets)
const visibleModels = useStore($visibleModels)
const modelOptions = useQuery({
@@ -95,41 +87,13 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
: null
const providers = modelOptions.data?.providers
const effectiveVisibleModels = useMemo(
() => effectiveVisibleKeys(visibleModels, providers ?? []),
[visibleModels, providers]
)
// The composer picker never persists the profile default. With a session it
// scopes the switch to that session; with none it's UI state shipped on the
// next session.create (see selectModel). The default lives in Settings → Model.
const switchTo = (model: string, provider: string) => onSelectModel({ model, provider })
// Selecting a model row restores that model's remembered preset onto the
// session (effort/fast), gated by capability. Unset → Hermes defaults.
const selectFamily = async (family: ModelFamily, provider: ModelOptionProvider) => {
const caps = provider.capabilities?.[family.id]
const preset = modelPresets[modelPresetKey(provider.slug, family.id)] ?? {}
// Variant-fast models (no speed param) express "fast" as a separate `-fast`
// id, so honor the saved preset by selecting that sibling. Param-fast is
// applied via applyModelPreset below instead.
const variantFast = !(caps?.fast ?? false) && !!family.fastId
const targetId = variantFast && preset.fast === true ? family.fastId! : family.id
if ((await switchTo(targetId, provider.slug)) === false) {
return
}
await applyModelPreset(
{
effort: (caps?.reasoning ?? true) ? (preset.effort ?? 'medium') : undefined,
fast: (caps?.fast ?? false) ? (preset.fast ?? false) : undefined
},
{ failMessage: t.shell.modelOptions.updateFailed, request: requestGateway, sessionId: activeSessionId }
)
}
const switchTo = (model: string, provider: string) =>
onSelectModel({ model, persistGlobal: !activeSessionId, provider })
const groups = useMemo(
() => groupModels(providers ?? [], search, { model: optionsModel, provider: optionsProvider }, effectiveVisibleModels),
@@ -188,42 +152,37 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
// -fast variant carries the same param support as its base.
const caps = group.provider.capabilities?.[family.id]
// Effective settings for this row: live session state when it's
// the active model, otherwise its remembered preset (Hermes
// defaults when unset). Row label AND submenu read from these so
// they never disagree.
const preset = modelPresets[modelPresetKey(group.provider.slug, family.id)] ?? {}
const effEffort = isCurrent ? currentReasoningEffort : preset.effort ?? ''
const effFast = isCurrent ? currentFastMode : preset.fast ?? false
// Single source of truth for the active row's fast state — keeps
// the row label in lock-step with the submenu's Fast toggle and
// handles the standalone `-fast` id case.
const fastControl = resolveFastControl(
activeId ?? family.id,
group.provider.models ?? [],
caps?.fast ?? false,
effFast
currentFastMode
)
const meta = [
fastControl.kind !== 'none' && fastControl.on ? copy.fast : null,
(caps?.reasoning ?? true) ? reasoningEffortLabel(effEffort) || copy.medium : null
]
.filter(Boolean)
.join(' ')
// 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,
reasoningEffortLabel(currentReasoningEffort) || copy.medium
]
.filter(Boolean)
.join(' ')
: ''
// Every row is a hover-Edit submenu trigger. Activating it
// (pointer or keyboard) switches to the family's base model and
// restores its preset; the Fast toggle inside swaps to the -fast
// sibling (or flips the speed param). The sub-trigger has no
// `onSelect`, so wire both click and Enter/Space for keyboard parity.
// Clicking the row commits the model and closes the picker; the
// edit submenu (reasoning/fast) is reached by HOVER, so you can
// still tweak those without the click dismissing everything.
// (pointer or keyboard) switches to the family's base model;
// the Fast toggle inside swaps to the -fast sibling (or flips
// the speed param). The sub-trigger has no `onSelect`, so wire
// both click and Enter/Space for keyboard parity.
const activate = () => {
if (!isCurrent) {
void selectFamily(family, group.provider)
void switchTo(family.id, group.provider.slug)
}
closeMenu()
}
return (
@@ -245,12 +204,10 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
{isCurrent ? <Codicon className="ml-auto text-foreground" name="check" size="0.75rem" /> : null}
</DropdownMenuSubTrigger>
<ModelEditSubmenu
effort={effEffort}
fastControl={fastControl}
isActive={isCurrent}
model={family.id}
onActivate={() => switchTo(family.id, group.provider.slug)}
onSelectModel={nextModel => switchTo(nextModel, group.provider.slug)}
provider={group.provider.slug}
reasoning={caps?.reasoning ?? true}
requestGateway={requestGateway}
/>
-6
View File
@@ -46,12 +46,6 @@ export interface SlashExecResponse {
warning?: string
}
export interface BrowserManageResponse {
connected?: boolean
url?: string
messages?: string[]
}
export interface SessionSteerResponse {
// 'queued' == accepted into the live turn's steer slot (injected at the next
// tool-result boundary); 'rejected' == no live tool window, caller queues.
@@ -1,6 +1,5 @@
import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react'
import {
type CSSProperties,
type ComponentProps,
type FC,
memo,
@@ -22,7 +21,6 @@ import {
resetThreadScroll,
setThreadAtBottom
} from '@/store/thread-scroll'
import { isSecondaryWindow } from '@/store/windows'
import { MessageRenderBoundary } from './message-render-boundary'
@@ -134,20 +132,6 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
const hiddenCount = firstVisible
const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups
const restoreFromBottomRef = useRef<number | null>(null)
// Secondary windows (new-session scratch, subagent watch, cmd-click pop-out)
// hide the titlebar tool cluster + session header, but the OS traffic lights
// still sit in the top-left, so reserve the titlebar gap above the transcript.
const secondaryWindow = isSecondaryWindow()
// NB: CSS calc() requires whitespace around the +/- operator. This string is
// assigned verbatim to the --sticky-human-top inline style below (it does not
// go through Tailwind, which would auto-space it), so the spaces are load-
// bearing — without them the declaration is invalid, gets dropped, and the
// sticky user bubble falls back to its ~4px default and slides under the OS
// traffic lights.
const secondaryTitlebarGap = 'calc(var(--titlebar-height) + 0.75rem)'
const threadContentTopPad = secondaryWindow
? 'pt-[calc(var(--titlebar-height)+0.75rem)]'
: 'pt-[calc(var(--titlebar-height)-0.5rem)]'
useEffect(() => setThreadAtBottom(isAtBottom), [isAtBottom])
useEffect(() => () => resetThreadScroll(), [])
@@ -251,24 +235,8 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
return (
<div
className="relative min-h-0 max-w-full overflow-hidden contain-[layout_paint]"
style={
{
height: clampToComposer ? 'var(--thread-viewport-height)' : '100%',
...(secondaryWindow ? { '--sticky-human-top': secondaryTitlebarGap } : {})
} as CSSProperties
}
style={{ height: clampToComposer ? 'var(--thread-viewport-height)' : '100%' }}
>
{secondaryWindow && (
// Secondary windows hide the titlebar chrome, so the scroller runs to
// the window's top edge and streamed text slides up under the OS
// traffic lights. Content padding alone scrolls away with the text — a
// fixed opaque strip (the titlebar's drag region) masks anything behind
// it and keeps the window draggable, matching the main window's header.
<div
aria-hidden="true"
className="absolute inset-x-0 top-0 z-10 h-(--titlebar-height) bg-background [-webkit-app-region:drag]"
/>
)}
<div
className="size-full overflow-x-hidden overflow-y-auto overscroll-contain"
data-following={isAtBottom ? 'true' : 'false'}
@@ -284,7 +252,9 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
</div>
) : (
<div
className={cn('mx-auto flex w-full max-w-(--composer-width) min-w-0 flex-col px-6', threadContentTopPad)}
className={cn(
'mx-auto flex w-full max-w-(--composer-width) min-w-0 flex-col px-6 pt-[calc(var(--titlebar-height)+1.5rem)]'
)}
data-slot="aui_thread-content"
ref={contentRef as React.RefCallback<HTMLDivElement>}
>
+26 -9
View File
@@ -11,6 +11,7 @@ import { startManualOnboarding } from '../store/onboarding'
import { InlineNotice } from './notifications'
import { Button } from './ui/button'
import { Checkbox } from './ui/checkbox'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from './ui/command'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog'
import { Skeleton } from './ui/skeleton'
@@ -22,7 +23,7 @@ interface ModelPickerDialogProps {
sessionId?: string | null
currentModel: string
currentProvider: string
onSelect: (selection: { provider: string; model: string }) => void
onSelect: (selection: { provider: string; model: string; persistGlobal: boolean }) => void
/**
* Optional class to apply to DialogContent. Use to override z-index when
* stacking the picker on top of another fixed overlay (e.g. the desktop
@@ -44,6 +45,7 @@ export function ModelPickerDialog({
}: ModelPickerDialogProps) {
const { t } = useI18n()
const copy = t.modelPicker
const [persistGlobal, setPersistGlobal] = useState(!sessionId)
// Own the search term so we can filter manually. cmdk's built-in
// shouldFilter reorders items by its fuzzy-match score (≈alphabetical with
// an empty query), which destroys the backend's curated order. We disable
@@ -77,7 +79,11 @@ export function ModelPickerDialog({
: null
const selectModel = (provider: ModelOptionProvider, model: string) => {
onSelect({ provider: provider.slug, model })
onSelect({
provider: provider.slug,
model,
persistGlobal: persistGlobal || !sessionId
})
onOpenChange(false)
}
@@ -122,13 +128,24 @@ export function ModelPickerDialog({
</CommandList>
</Command>
<DialogFooter className="flex-row items-center justify-end gap-2 bg-card p-3">
<Button onClick={addProvider} variant="ghost">
{copy.addProvider}
</Button>
<Button onClick={() => onOpenChange(false)} variant="outline">
{t.common.cancel}
</Button>
<DialogFooter className="flex-row items-center justify-between gap-3 bg-card p-3 sm:justify-between">
<label className="flex cursor-pointer select-none items-center gap-2 text-xs text-muted-foreground">
<Checkbox
checked={persistGlobal || !sessionId}
disabled={!sessionId}
onCheckedChange={checked => setPersistGlobal(checked === true)}
/>
{sessionId ? copy.persistGlobalSession : copy.persistGlobal}
</label>
<div className="flex items-center gap-2">
<Button onClick={addProvider} variant="ghost">
{copy.addProvider}
</Button>
<Button onClick={() => onOpenChange(false)} variant="outline">
{t.common.cancel}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -80,12 +80,9 @@ const HOVER_REVEAL_EASE = 'cubic-bezier(0.32,0.72,0,1)'
// Offset shadow lifting the revealed panel off the content (same both sides;
// the mirror axis is offset-x, which is 0). Same color on light + dark.
const HOVER_REVEAL_SHADOW = '0px -18px 18px -5px #00000012'
// Edge trigger strip, inset past the OS window-resize grab area AND the
// adjacent pane's scrollbar (0.5rem, .scrollbar-dt) — the strip overlays the
// neighboring scroller's edge, so any overlap makes the scrollbar reveal the
// pane on hover and swallow its clicks (#44140).
// Edge trigger strip, inset past the OS window-resize grab area.
const HOVER_REVEAL_TRIGGER_WIDTH = 14
const HOVER_REVEAL_EDGE_GUTTER = 'calc(0.5rem + 2px)'
const HOVER_REVEAL_EDGE_GUTTER = 6
// Fired (window CustomEvent<{ id }>) to toggle a force-collapsed pane's reveal
// from the keyboard, since its store-open toggle is a no-op while collapsed.
@@ -1,291 +0,0 @@
import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { persistString, storedString } from '@/lib/storage'
import { $petInfo, clearPetUnread, type PetInfo, setPetInfo } from '@/store/pet'
import { $petOverlayActive, initPetOverlayBridge, popOutPet, restorePetOverlay } from '@/store/pet-overlay'
import { $gatewayState } from '@/store/session'
import { isSecondaryWindow } from '@/store/windows'
import { useTheme } from '@/themes/context'
import { PetSprite } from './pet-sprite'
// v2: positions are now top/left anchored (v1 stored bottom-anchored values,
// which dragged inverted). Bumping the key discards stale v1 coordinates.
const POSITION_KEY = 'hermes.desktop.pet-position.v2'
interface Point {
x: number
y: number
}
function clampToViewport({ x, y }: Point): Point {
const maxX = Math.max(0, (window.innerWidth || 800) - 80)
const maxY = Math.max(0, (window.innerHeight || 600) - 80)
return { x: Math.min(Math.max(0, x), maxX), y: Math.min(Math.max(0, y), maxY) }
}
// The sprite art faces left by default, so mirror it when the pet's center sits
// on the left half of the window — it always faces inward, toward the content.
function facing(leftX: number, petW: number): string {
return leftX + petW / 2 < (window.innerWidth || 800) / 2 ? 'scaleX(-1)' : 'none'
}
function loadPosition(): Point {
try {
const raw = storedString(POSITION_KEY)
if (raw) {
const parsed = JSON.parse(raw) as Point
if (typeof parsed.x === 'number' && typeof parsed.y === 'number') {
return clampToViewport(parsed)
}
}
} catch {
// fall through to default
}
// Default: lower-left corner (top/left anchored).
return clampToViewport({ x: 24, y: (window.innerHeight || 600) - 220 })
}
/**
* In-window floating petdex mascot. Always-on-top within the app, draggable,
* and reactive to agent activity via `$petState`. Fetches the active pet via
* the shared `pet.info` RPC; renders nothing until a pet is installed +
* enabled.
*
* Adopting a pet is fully in-app: type `/pet boba` in the composer. That
* writes `display.pet.*` from the slash worker, so we keep polling `pet.info`
* while no pet is active and the mascot pops in within a few seconds no
* reload, no CLI. Once a pet is live we stop polling.
*
* Promotion to a separate frameless OS-level window is a follow-up the
* sprite + state logic here is reused as-is, only the host changes.
*/
const PET_POLL_MS = 3000
export function FloatingPet() {
const { requestGateway } = useGatewayRequest()
const { resolvedMode } = useTheme()
const gatewayState = useStore($gatewayState)
const info = useStore($petInfo)
const overlayActive = useStore($petOverlayActive)
const [position, setPosition] = useState<Point>(loadPosition)
const containerRef = useRef<HTMLDivElement | null>(null)
// The facing mirror lives on the sprite wrapper, not the container, so the
// speech bubble (a container child) never renders flipped/backwards.
const spriteWrapRef = useRef<HTMLDivElement | null>(null)
const petW = (info.frameW ?? 192) * (info.scale ?? 0.33)
// Soft contact shadow, sized off the pet so every scale/species grounds the
// same way (cf. lairp's per-actor feet ellipse). Lighter on light backgrounds.
const shadowW = Math.round(petW * 0.55)
const shadowH = Math.max(3, Math.round(shadowW * 0.28))
const shadowAlpha = resolvedMode === 'light' ? 0.2 : 0.55
// Live drag offset (pointer → element top-left). Drag updates the DOM
// directly to avoid a React re-render (and canvas reflow) per pointermove —
// state is only committed on release.
const dragRef = useRef<{ dx: number; dy: number; x: number; y: number } | null>(null)
// Fetch pet.info on connect, then keep polling while no pet is active so an
// in-app `/pet <slug>` shows up live. Stops polling once a pet is enabled.
const active = info.enabled && Boolean(info.spritesheetBase64)
useEffect(() => {
if (gatewayState !== 'open' || active) {
return
}
let cancelled = false
const pull = async () => {
try {
const next = await requestGateway<PetInfo>('pet.info')
if (!cancelled && next) {
setPetInfo(next)
}
} catch {
// cosmetic feature — never surface gateway errors
}
}
void pull()
const timer = window.setInterval(() => void pull(), PET_POLL_MS)
return () => {
cancelled = true
window.clearInterval(timer)
}
}, [gatewayState, active, requestGateway])
// Wire the overlay control channel once, only in the primary window — the
// pop-out overlay belongs to it (main.cjs positions it against the main
// window and routes control messages back to it).
useEffect(() => {
if (isSecondaryWindow()) {
return
}
return initPetOverlayBridge()
}, [])
// Returning to the app (by any route, not just the mail icon) clears the pet's
// "new message" hint — you've seen it now.
useEffect(() => {
if (isSecondaryWindow()) {
return
}
const onFocus = () => clearPetUnread()
window.addEventListener('focus', onFocus)
return () => window.removeEventListener('focus', onFocus)
}, [])
// Restore a popped-out pet on boot, once the pet has loaded (so we never spawn
// an empty overlay window). Primary window only; runs at most once.
const restoredRef = useRef(false)
useEffect(() => {
if (isSecondaryWindow() || restoredRef.current || !active) {
return
}
restoredRef.current = true
restorePetOverlay()
}, [active])
// A window resize must never strand the pet off-screen — re-clamp the
// committed position (and persist it) whenever the viewport shrinks.
useEffect(() => {
const onResize = () =>
setPosition(prev => {
const next = clampToViewport(prev)
if (next.x === prev.x && next.y === prev.y) {
return prev
}
persistString(POSITION_KEY, JSON.stringify(next))
return next
})
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [])
const onPointerDown = useCallback((e: React.PointerEvent) => {
const el = containerRef.current
if (!el) {
return
}
const rect = el.getBoundingClientRect()
// Shift-click pops the pet out into a free-floating desktop overlay (it can
// leave the window and stays visible while Hermes is minimized) instead of
// starting an in-window drag. Primary window only — the overlay is anchored
// to it.
if (e.shiftKey && !isSecondaryWindow()) {
popOutPet({ height: rect.height, width: rect.width, x: rect.left, y: rect.top })
return
}
dragRef.current = { dx: e.clientX - rect.left, dy: e.clientY - rect.top, x: rect.left, y: rect.top }
el.setPointerCapture(e.pointerId)
el.style.cursor = 'grabbing'
}, [])
const onPointerMove = useCallback(
(e: React.PointerEvent) => {
const drag = dragRef.current
const el = containerRef.current
if (!drag || !el) {
return
}
const next = clampToViewport({ x: e.clientX - drag.dx, y: e.clientY - drag.dy })
drag.x = next.x
drag.y = next.y
// Mutate the DOM directly — no setState, so no re-render while dragging. The
// mirror follows the pointer across the midline for the same reason; it
// rides the sprite wrapper so the bubble stays upright.
el.style.left = `${next.x}px`
el.style.top = `${next.y}px`
if (spriteWrapRef.current) {
spriteWrapRef.current.style.transform = facing(next.x, petW)
}
},
[petW]
)
const onPointerUp = useCallback((e: React.PointerEvent) => {
const drag = dragRef.current
if (drag) {
dragRef.current = null
const committed = { x: drag.x, y: drag.y }
setPosition(committed)
persistString(POSITION_KEY, JSON.stringify(committed))
}
const el = containerRef.current
if (el) {
el.style.cursor = 'grab'
el.releasePointerCapture?.(e.pointerId)
}
}, [])
// While popped out, the desktop overlay window owns the mascot — hide the
// in-window one so there aren't two.
if (!info.enabled || !info.spritesheetBase64 || overlayActive) {
return null
}
return (
<div
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
ref={containerRef}
style={{
cursor: 'grab',
left: position.x,
pointerEvents: 'auto',
position: 'fixed',
top: position.y,
touchAction: 'none',
userSelect: 'none',
zIndex: 60
}}
>
<div
aria-hidden
style={{
background: `radial-gradient(ellipse at center, rgba(0,0,0,${shadowAlpha}) 0%, rgba(0,0,0,0) 70%)`,
bottom: -shadowH * 0.4,
height: shadowH,
left: '50%',
pointerEvents: 'none',
position: 'absolute',
transform: 'translateX(-50%)',
width: shadowW,
zIndex: 0
}}
/>
<div ref={spriteWrapRef} style={{ lineHeight: 0, position: 'relative', transform: facing(position.x, petW), zIndex: 1 }}>
<PetSprite info={info} />
</div>
</div>
)
}
@@ -1,142 +0,0 @@
import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { AlertCircle, Clock, type IconComponent } from '@/lib/icons'
import { $petActivity, $petState, type PetState } from '@/store/pet'
/**
* Speech bubble + status glyph for the popped-out pet overlay the
* "notification" half of the mascot. It externalizes what the agent is doing
* (Codex-style) so a glance at the desktop pet replaces switching back to the
* window. The in-window pet doesn't show it (the app itself is the surface);
* only the overlay renders it.
*
* Text is derived purely from the same `$petState` / `$petActivity` the sprite
* already reacts to, so it never drifts from the animation. The bubble is shown
* only when there's something worth saying (working / reviewing / a transient
* done/error beat / waiting on the user) and is hidden at plain idle.
*/
type Tone = 'error' | 'wait'
interface Spec {
lines: string[]
glyph?: IconComponent
tone?: Tone
}
// Phrasings per mood, picked at random (no immediate repeat) for a bit of life.
// Keep them short — the bubble is tiny and never wraps.
const SPECS: Partial<Record<PetState, Spec>> = {
run: {
lines: ['working…', 'on it…', 'crunching…', 'tinkering…', 'cooking…', 'in the weeds…', 'wiring it up…', 'making moves…', 'heads down…', 'hammering away…']
},
review: {
lines: ['thinking…', 'reading…', 'reviewing…', 'pondering…', 'connecting dots…', 'sizing it up…', 'tracing it…', 'mulling…', 'scheming…', 'hmm…']
},
failed: {
glyph: AlertCircle,
lines: ['hit a snag', 'welp', 'that broke', 'oof', 'snagged'],
tone: 'error'
},
waiting: {
glyph: Clock,
lines: ['your turn', 'all yours', 'over to you', 'balls in your court', 'awaiting orders'],
tone: 'wait'
}
}
const TONE_COLOR: Record<Tone, string> = {
error: 'var(--ui-red)',
wait: 'var(--ui-yellow)'
}
// Random pick that avoids repeating the line we're already showing.
function pick(lines: string[], prev: string): string {
if (lines.length <= 1) {
return lines[0] ?? ''
}
let next = prev
while (next === prev) {
next = lines[Math.floor(Math.random() * lines.length)]
}
return next
}
export function PetBubble() {
const state = useStore($petState)
const activity = useStore($petActivity)
const [line, setLine] = useState('')
// Finish beats are carried by the sprite/mail icon; idle only speaks up when
// it's actually the user's turn. Everything else maps to a mood spec.
const specKey: null | PetState =
state in SPECS ? state : state === 'idle' && activity.awaitingInput ? 'waiting' : null
const rotating = specKey === 'run' || specKey === 'review'
// Pick a fresh line on every mood change, then keep rotating (random, no
// repeat) only while the agent is actively working/thinking.
useEffect(() => {
const spec = specKey ? SPECS[specKey] : null
if (!spec) {
setLine('')
return
}
setLine(prev => pick(spec.lines, prev))
if (!rotating || spec.lines.length <= 1) {
return
}
const id = window.setInterval(() => setLine(prev => pick(spec.lines, prev)), 2600)
return () => window.clearInterval(id)
}, [specKey, rotating])
const spec = specKey ? SPECS[specKey] : null
if (!spec) {
return null
}
const Glyph = spec.glyph
const text = line || spec.lines[0]
const hasText = Boolean(text)
return (
<div
style={{
alignItems: 'center',
// Solid, theme-driven surface (the prior --ui-bg-card mixes in
// `transparent`, so the bubble was see-through).
background: 'var(--ui-bg-elevated)',
border: '1px solid var(--ui-stroke-secondary)',
borderRadius: hasText ? 10 : 999,
boxShadow: '0 4px 14px rgba(0,0,0,0.22)',
color: 'var(--foreground)',
display: 'inline-flex',
fontSize: 11,
fontWeight: 500,
gap: hasText ? 5 : 0,
lineHeight: 1,
// Glyph-only bubbles collapse to a tight, symmetric badge.
padding: hasText ? '5px 8px' : 5,
pointerEvents: 'none',
whiteSpace: 'nowrap'
}}
>
{Glyph && (
<span style={{ display: 'inline-flex' }}>
<Glyph style={{ color: spec.tone ? TONE_COLOR[spec.tone] : 'currentColor', height: 13, width: 13 }} />
</span>
)}
{text}
</div>
)
}
@@ -1,178 +0,0 @@
import { memo, useEffect, useMemo, useRef } from 'react'
import { $petState, type PetInfo, type PetState } from '@/store/pet'
const DEFAULT_FRAME_W = 192
const DEFAULT_FRAME_H = 208
const DEFAULT_FRAMES = 6
const DEFAULT_LOOP_MS = 1100
// Mirrors agent.pet.constants.DEFAULT_SCALE — fallback only; the gateway sends
// the configured scale.
const DEFAULT_SCALE = 0.33
// Mirrors agent.pet.constants.CODEX_STATE_ROWS (Petdex current taxonomy).
const DEFAULT_STATE_ROWS = [
'idle',
'running-right',
'running-left',
'waving',
'jumping',
'failed',
'waiting',
'running',
'review'
]
const STATE_ALIASES: Record<PetState, string[]> = {
idle: ['idle'],
wave: ['wave', 'waving'],
jump: ['jump', 'jumping'],
run: ['run', 'running'],
failed: ['failed'],
review: ['review'],
waiting: ['waiting']
}
interface PetSpriteProps {
info: PetInfo
/** On-screen scale multiplier applied on top of the pet's native scale. */
zoom?: number
}
/**
* Canvas renderer for a petdex spritesheet the one piece that must be
* TypeScript (the engine's decode/encode is Python). Draws the row matching the
* live `$petState`, stepping `framesPerState` frames across a `loopMs` loop.
*
* State is read from `$petState` via a ref + subscription rather than a prop,
* so the frequent activity-driven state changes during an agent turn update the
* canvas (inside its RAF loop) WITHOUT triggering a React re-render. Combined
* with `memo`, this component effectively never re-renders after mount until
* the pet itself changes.
*/
function PetSpriteImpl({ info, zoom = 1 }: PetSpriteProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const stateRef = useRef<PetState>($petState.get())
const frameW = info.frameW ?? DEFAULT_FRAME_W
const frameH = info.frameH ?? DEFAULT_FRAME_H
const frames = info.framesPerState ?? DEFAULT_FRAMES
const framesByState = info.framesByState
const loopMs = info.loopMs ?? DEFAULT_LOOP_MS
const scale = (info.scale ?? DEFAULT_SCALE) * zoom
const rows = info.stateRows ?? DEFAULT_STATE_ROWS
const drawW = Math.round(frameW * scale)
const drawH = Math.round(frameH * scale)
const image = useMemo(() => {
if (!info.spritesheetBase64) {
return null
}
const img = new Image()
img.src = `data:${info.mime ?? 'image/webp'};base64,${info.spritesheetBase64}`
return img
}, [info.spritesheetBase64, info.mime])
useEffect(() => {
const canvas = canvasRef.current
if (!canvas || !image) {
return
}
const ctx = canvas.getContext('2d')
if (!ctx) {
return
}
// Track state via subscription, not a prop — no re-render on activity ticks.
stateRef.current = $petState.get()
const unsubState = $petState.listen(next => {
stateRef.current = next
})
let raf = 0
let frame = 0
let lastStep = performance.now()
let drawnFrame = -1
let drawnRow = -1
const rowIndexForState = (s: PetState): number => {
for (const key of STATE_ALIASES[s] ?? [s]) {
const idx = rows.indexOf(key)
if (idx >= 0) {
return idx
}
}
return 0
}
// Resolve a state to the row it draws and its real frame count. A state
// with no real frames (ragged sheet, empty row) falls back to idle rather
// than flashing blank padding.
const resolve = (s: PetState): { row: number; count: number } => {
const real = framesByState?.[s] ?? frames
if (real > 0) {
return { row: rowIndexForState(s), count: real }
}
return { row: rowIndexForState('idle'), count: Math.max(1, framesByState?.idle ?? frames) }
}
const render = (now: number) => {
const { row, count } = resolve(stateRef.current)
// Per-state step keeps every state's loop ~loopMs even when frame counts
// differ; counts vary per row so derive the cadence here, not once.
const stepMs = loopMs / count
if (now - lastStep >= stepMs) {
frame += 1
lastStep = now
}
frame %= count
// Only touch the canvas when the visible cell actually changes. The RAF
// ticks at ~60Hz but the sprite only steps ~5Hz, so this skips ~90% of
// the clear+draw work and keeps the main thread free.
if ((frame !== drawnFrame || row !== drawnRow) && image.complete && image.naturalWidth > 0) {
const sx = frame * frameW
const sy = row * frameH
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.imageSmoothingEnabled = false
ctx.drawImage(image, sx, sy, frameW, frameH, 0, 0, drawW, drawH)
drawnFrame = frame
drawnRow = row
}
raf = requestAnimationFrame(render)
}
raf = requestAnimationFrame(render)
return () => {
cancelAnimationFrame(raf)
unsubState()
}
}, [image, frameW, frameH, frames, framesByState, loopMs, drawW, drawH, rows])
return (
<canvas
aria-label={info.displayName ? `${info.displayName} pet` : 'pet'}
height={drawH}
ref={canvasRef}
style={{ height: drawH, width: drawW }}
width={drawW}
/>
)
}
/**
* Memoized so a parent re-render (e.g. a position commit on drag-end) doesn't
* re-run the canvas setup. Props change only when the pet itself changes.
*/
export const PetSprite = memo(PetSpriteImpl)
@@ -1,79 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import { PawPrint } from '@/lib/icons'
// petdex frames are a fixed 192×208 grid; the box matches that aspect.
const THUMB_W = 40
const THUMB_H = Math.round((THUMB_W * 208) / 192)
export type PetThumbLoader = (slug: string, url?: string) => Promise<string | null>
/**
* Idle-frame preview for one pet. The backend crops + caches the frame and
* returns it as a same-origin data URI (`pet.thumb`), which dodges the renderer
* CSP / R2 hotlink rules that break a direct `<img src=cdn>`.
*/
export function PetThumb({
slug,
url,
alt,
load,
size = THUMB_W
}: {
slug: string
url?: string
alt: string
load: PetThumbLoader
/** Width in px; height follows the petdex frame aspect. */
size?: number
}) {
const [src, setSrc] = useState<string | null>(null)
const boxRef = useRef<HTMLSpanElement | null>(null)
const height = Math.round((size * 208) / 192)
useEffect(() => {
const el = boxRef.current
if (!el || src) {
return
}
const observer = new IntersectionObserver(
entries => {
if (entries.some(entry => entry.isIntersecting)) {
observer.disconnect()
void load(slug, url).then(uri => {
if (uri) {
setSrc(uri)
}
})
}
},
{ rootMargin: '120px' }
)
observer.observe(el)
return () => observer.disconnect()
}, [slug, url, src, load])
return (
<span
className="grid shrink-0 place-items-center overflow-hidden rounded-md bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)"
ref={boxRef}
style={{ height, width: size }}
>
{src ? (
<img
alt={alt}
aria-hidden
className="pointer-events-none size-full object-contain"
src={src}
style={{ imageRendering: 'pixelated' }}
/>
) : (
<PawPrint className="size-4" />
)}
</span>
)
}
+1 -7
View File
@@ -17,12 +17,7 @@ function Command({ className, ...props }: React.ComponentProps<typeof CommandPri
)
}
interface CommandInputProps extends React.ComponentProps<typeof CommandPrimitive.Input> {
/** Inline trailing slot, rendered on the right of the search row. */
right?: React.ReactNode
}
function CommandInput({ className, right, ...props }: CommandInputProps) {
function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div className="flex h-11 items-center gap-2 border-b border-border px-3" data-slot="command-input-wrapper">
<SearchIcon className="size-4 shrink-0 text-muted-foreground" />
@@ -34,7 +29,6 @@ function CommandInput({ className, right, ...props }: CommandInputProps) {
data-slot="command-input"
{...props}
/>
{right}
</div>
)
}
-23
View File
@@ -1,10 +1,3 @@
import type {
PetOverlayBounds,
PetOverlayControl,
PetOverlayOpenRequest,
PetOverlayStatePayload
} from './store/pet-overlay'
export {}
declare global {
@@ -31,22 +24,6 @@ declare global {
// a spectator window (lazy resume — no agent build) for live-streaming
// a running subagent's session.
openSessionWindow: (sessionId: string, opts?: { watch?: boolean }) => Promise<{ ok: boolean; error?: string }>
// Open (or focus) a compact secondary window on the new-session draft.
openNewSessionWindow: () => Promise<{ ok: boolean; error?: string }>
// The pop-out pet overlay: a transparent always-on-top window hosting only
// the mascot. The main renderer drives it (open/close/drag + state push);
// the overlay sends control messages back (pop-in, composer submit).
petOverlay: {
open: (request: PetOverlayOpenRequest) => Promise<{ ok: boolean; bounds?: PetOverlayBounds }>
close: () => Promise<{ ok: boolean }>
setBounds: (bounds: PetOverlayBounds) => void
setIgnoreMouse: (ignore: boolean) => void
setFocusable: (focusable: boolean) => void
pushState: (payload: PetOverlayStatePayload) => void
control: (payload: PetOverlayControl) => void
onState: (callback: (payload: PetOverlayStatePayload) => void) => () => void
onControl: (callback: (payload: PetOverlayControl) => void) => () => void
}
getBootProgress: () => Promise<DesktopBootProgress>
getConnectionConfig: (profile?: null | string) => Promise<DesktopConnectionConfig>
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
+5 -48
View File
@@ -189,7 +189,6 @@ export const en: Translations = {
'nav.cron': 'Open scheduled jobs',
'nav.agents': 'Open agents',
'session.new': 'New session',
'session.newWindow': 'New session in window',
'session.next': 'Next session',
'session.prev': 'Previous session',
'session.slot.1': 'Switch to recent session 1',
@@ -366,32 +365,7 @@ export const en: Translations = {
installError: 'Could not install that theme.',
installed: name => `Installed “${name}”.`,
removeTheme: 'Remove theme',
importedBadge: 'Imported',
pet: {
title: 'Pet',
intro:
'Adopt an animated petdex mascot that floats over the app and reacts to what Hermes is doing — running while tools execute, celebrating on success, sulking on errors.',
restartHint:
'Pets need a quick restart — the running app started before this feature was added. Quit and reopen Hermes, then come back here.',
on: 'On',
off: 'Off',
scaleTitle: 'Size',
scaleDesc: 'Resize the floating mascot. Applies everywhere instantly.',
chooseTitle: 'Choose a pet',
chooseDesc: 'Picking one installs it (if needed) and makes it active.',
searchPlaceholder: 'Search pets…',
unreachable: "Couldn't reach the petdex gallery. Check your connection and reopen this page.",
noMatch: query => `No pets match "${query}".`,
installedTag: 'installed',
countCapped: (cap, total) => `Showing ${cap} of ${total} — type to narrow it down.`,
count: n => `${n} pet${n === 1 ? '' : 's'}.`,
uninstall: name => `Uninstall ${name}`,
adoptFailed: slug => `Could not adopt ${slug}`,
uninstallFailed: slug => `Could not uninstall ${slug}`,
noneAvailable: 'No pets available to turn on right now.',
turnOnFailed: 'Could not turn the pet on.',
turnOffFailed: 'Could not turn the pet off.'
}
importedBadge: 'Imported'
},
fieldLabels: FIELD_LABELS,
fieldDescriptions: FIELD_DESCRIPTIONS,
@@ -590,14 +564,9 @@ export const en: Translations = {
collapse: 'Collapse',
connectAnother: 'Connect another provider',
otherProviders: 'Other providers',
disconnect: 'Disconnect',
disconnectInTerminal: 'Disconnect (runs the removal command in the terminal)',
removeConfirm: provider => `Remove ${provider}?`,
removeExternalGeneric: provider => `${provider} is managed by its own CLI — remove it there.`,
removeExternal: (provider, command) => `${provider} is managed outside Hermes. Remove it with ${command}.`,
removeKeyManaged: provider => `${provider} is configured from an API key. Remove it from API Keys.`,
removeTerminalConfirm: (provider, command) =>
`Disconnect ${provider}? This runs "${command}" in the terminal to clear the credential.`,
removeTerminalRunning: provider => `Running ${provider} disconnect in the terminal…`,
removedTitle: 'Account removed',
removedMessage: provider => `${provider} was removed.`,
failedRemove: provider => `Could not remove ${provider}`,
@@ -735,22 +704,8 @@ export const en: Translations = {
commandCenter: 'Command Center',
appearance: 'Appearance',
settings: 'Settings',
changeTheme: 'Change theme',
changeTheme: 'Change theme...',
changeColorMode: 'Change color mode...',
pets: {
title: 'Pets',
placeholder: 'Search pets…',
loading: 'Loading petdex gallery…',
error: 'Could not reach the petdex gallery.',
staleBackend: 'Restart Hermes to use pets — the backend predates this feature.',
empty: 'No matching pets.',
turnOff: 'Turn off',
turnOn: 'Turn on',
installed: 'Installed',
adoptFailed: 'Could not adopt that pet.',
toggleFailed: 'Could not toggle the pet.',
noneAvailable: 'No pets available — pick one below to install.'
},
installTheme: {
title: 'Install theme...',
placeholder: 'Search the VS Code Marketplace...',
@@ -1542,6 +1497,8 @@ export const en: Translations = {
unknown: '(unknown)',
search: 'Filter providers and models...',
noModels: 'No models found.',
persistGlobalSession: 'Persist globally (otherwise this session only)',
persistGlobal: 'Persist globally',
addProvider: 'Add provider',
loadFailed: 'Could not load models',
noAuthenticatedProviders: 'No authenticated providers.',
+5 -41
View File
@@ -281,32 +281,7 @@ export const ja = defineLocale({
installError: 'そのテーマをインストールできませんでした。',
installed: name => `${name}」をインストールしました。`,
removeTheme: 'テーマを削除',
importedBadge: 'インポート済み',
pet: {
title: 'ペット',
intro:
'アプリ上に浮かぶ petdex のアニメーションマスコットを採用しましょう。ツール実行中は走り、成功すると喜び、エラーでしょんぼりと、Hermes の状態に反応します。',
restartHint:
'ペット機能には再起動が必要です。この機能が追加される前に起動したアプリが動作中です。Hermes を終了して再度開き、このページに戻ってください。',
scaleTitle: 'サイズ',
scaleDesc: '浮遊マスコットの大きさを変更します。すべての画面に即時反映されます。',
on: 'オン',
off: 'オフ',
chooseTitle: 'ペットを選ぶ',
chooseDesc: '選ぶと(必要に応じて)インストールされ、アクティブになります。',
searchPlaceholder: 'ペットを検索…',
unreachable: 'petdex ギャラリーに接続できませんでした。接続を確認してこのページを開き直してください。',
noMatch: query => `${query}」に一致するペットがありません。`,
installedTag: 'インストール済み',
countCapped: (cap, total) => `${total} 件中 ${cap} 件を表示中——入力して絞り込めます。`,
count: n => `${n} 件のペット。`,
uninstall: name => `${name} をアンインストール`,
adoptFailed: slug => `${slug} を採用できませんでした`,
uninstallFailed: slug => `${slug} をアンインストールできませんでした`,
noneAvailable: 'オンにできるペットがありません。',
turnOnFailed: 'ペットをオンにできませんでした。',
turnOffFailed: 'ペットをオフにできませんでした。'
}
importedBadge: 'インポート済み'
},
fieldLabels: defineFieldCopy({
model: 'デフォルトモデル',
@@ -720,6 +695,7 @@ export const ja = defineLocale({
connectAnother: '別のプロバイダーを接続',
otherProviders: 'その他のプロバイダー',
removeConfirm: provider => `${provider} を削除しますか?`,
removeExternal: (provider, command) => `${provider} は Hermes の外部で管理されています。${command} で削除してください。`,
removeKeyManaged: provider => `${provider} は API キーで設定されています。API Keys から削除してください。`,
removedTitle: 'アカウントを削除しました',
removedMessage: provider => `${provider} を削除しました。`,
@@ -859,22 +835,8 @@ export const ja = defineLocale({
commandCenter: 'コマンドセンター',
appearance: '外観',
settings: '設定',
changeTheme: 'テーマを変更',
changeTheme: 'テーマを変更...',
changeColorMode: 'カラーモードを変更...',
pets: {
title: 'ペット',
placeholder: 'ペットを検索…',
loading: 'petdex ギャラリーを読み込み中…',
error: 'petdex ギャラリーに接続できません。',
staleBackend: 'ペット機能を使うには Hermes を再起動してください。',
empty: '一致するペットがありません。',
turnOff: 'オフ',
turnOn: 'オン',
installed: 'インストール済み',
adoptFailed: 'ペットを採用できませんでした。',
toggleFailed: 'ペットを切り替えできませんでした。',
noneAvailable: '利用可能なペットがありません。'
},
installTheme: {
title: 'テーマをインストール...',
placeholder: 'VS Code Marketplace を検索...',
@@ -1676,6 +1638,8 @@ export const ja = defineLocale({
unknown: '(不明)',
search: 'プロバイダーとモデルをフィルター...',
noModels: 'モデルが見つかりません。',
persistGlobalSession: 'グローバルに保持(それ以外はこのセッションのみ)',
persistGlobal: 'グローバルに保持',
addProvider: 'プロバイダーを追加',
loadFailed: 'モデルを読み込めませんでした',
noAuthenticatedProviders: '認証済みプロバイダーがありません。',
+3 -42
View File
@@ -265,29 +265,6 @@ export interface Translations {
installed: (name: string) => string
removeTheme: string
importedBadge: string
pet: {
title: string
intro: string
restartHint: string
on: string
off: string
scaleTitle: string
scaleDesc: string
chooseTitle: string
chooseDesc: string
searchPlaceholder: string
unreachable: string
noMatch: (query: string) => string
installedTag: string
countCapped: (cap: number, total: number) => string
count: (n: number) => string
uninstall: (name: string) => string
adoptFailed: (slug: string) => string
uninstallFailed: (slug: string) => string
noneAvailable: string
turnOnFailed: string
turnOffFailed: string
}
}
fieldLabels: Record<string, string>
fieldDescriptions: Record<string, string>
@@ -470,13 +447,9 @@ export interface Translations {
collapse: string
connectAnother: string
otherProviders: string
disconnect: string
disconnectInTerminal: string
removeConfirm: (provider: string) => string
removeExternalGeneric: (provider: string) => string
removeExternal: (provider: string, command: string) => string
removeKeyManaged: (provider: string) => string
removeTerminalConfirm: (provider: string, command: string) => string
removeTerminalRunning: (provider: string) => string
removedTitle: string
removedMessage: (provider: string) => string
failedRemove: (provider: string) => string
@@ -613,20 +586,6 @@ export interface Translations {
settings: string
changeTheme: string
changeColorMode: string
pets: {
title: string
placeholder: string
loading: string
error: string
staleBackend: string
empty: string
turnOff: string
turnOn: string
installed: string
adoptFailed: string
toggleFailed: string
noneAvailable: string
}
installTheme: {
title: string
placeholder: string
@@ -1182,6 +1141,8 @@ export interface Translations {
unknown: string
search: string
noModels: string
persistGlobalSession: string
persistGlobal: string
addProvider: string
loadFailed: string
noAuthenticatedProviders: string
+5 -39
View File
@@ -271,30 +271,7 @@ export const zhHant = defineLocale({
installError: '無法安裝該主題。',
installed: name => `已安裝「${name}」。`,
removeTheme: '移除主題',
importedBadge: '已匯入',
pet: {
title: '寵物',
intro: '領養一隻懸浮在應用上的 petdex 動畫寵物,它會根據 Hermes 的狀態做出反應——工具執行時奔跑、成功時歡呼、出錯時沮喪。',
restartHint: '寵物功能需要重新啟動——目前執行的應用在此功能加入前啟動。請結束並重新開啟 Hermes,然後回到此處。',
scaleTitle: '大小',
scaleDesc: '調整懸浮寵物的大小,所有介面即時生效。',
on: '開啟',
off: '關閉',
chooseTitle: '選擇寵物',
chooseDesc: '選擇後會自動安裝(如需)並設為目前寵物。',
searchPlaceholder: '搜尋寵物…',
unreachable: '無法連線至 petdex 畫廊。請檢查網路連線並重新開啟此頁面。',
noMatch: query => `沒有符合「${query}」的寵物。`,
installedTag: '已安裝',
countCapped: (cap, total) => `顯示 ${total} 個中的 ${cap} 個——輸入關鍵字以縮小範圍。`,
count: n => `${n} 個寵物。`,
uninstall: name => `解除安裝 ${name}`,
adoptFailed: slug => `無法領養 ${slug}`,
uninstallFailed: slug => `無法解除安裝 ${slug}`,
noneAvailable: '目前沒有可開啟的寵物。',
turnOnFailed: '無法開啟寵物。',
turnOffFailed: '無法關閉寵物。'
}
importedBadge: '已匯入'
},
fieldLabels: defineFieldCopy({
model: '預設模型',
@@ -695,6 +672,7 @@ export const zhHant = defineLocale({
connectAnother: '連結其他提供方',
otherProviders: '其他提供方',
removeConfirm: provider => `移除 ${provider}`,
removeExternal: (provider, command) => `${provider} 由 Hermes 外部管理。請使用 ${command} 移除。`,
removeKeyManaged: provider => `${provider} 由 API 金鑰設定。請從 API Keys 中移除。`,
removedTitle: '帳號已移除',
removedMessage: provider => `${provider} 已移除。`,
@@ -830,22 +808,8 @@ export const zhHant = defineLocale({
commandCenter: '命令中心',
appearance: '外觀',
settings: '設定',
changeTheme: '變更主題',
changeTheme: '變更主題...',
changeColorMode: '變更色彩模式...',
pets: {
title: '寵物',
placeholder: '搜尋寵物…',
loading: '正在載入 petdex 畫廊…',
error: '無法連線至 petdex 畫廊。',
staleBackend: '請重新啟動 Hermes 以使用寵物功能。',
empty: '沒有符合的寵物。',
turnOff: '關閉',
turnOn: '開啟',
installed: '已安裝',
adoptFailed: '無法領養該寵物。',
toggleFailed: '無法切換寵物顯示。',
noneAvailable: '尚無可用寵物——請在下方選擇一個安裝。'
},
installTheme: {
title: '安裝主題...',
placeholder: '搜尋 VS Code Marketplace...',
@@ -1618,6 +1582,8 @@ export const zhHant = defineLocale({
unknown: '(未知)',
search: '篩選提供方和模型...',
noModels: '找不到模型。',
persistGlobalSession: '全域儲存(否則僅限此工作階段)',
persistGlobal: '全域儲存',
addProvider: '新增提供方',
loadFailed: '無法載入模型',
noAuthenticatedProviders: '沒有已驗證的提供方。',
+5 -45
View File
@@ -185,7 +185,6 @@ export const zh: Translations = {
'nav.cron': '打开定时任务',
'nav.agents': '打开智能体',
'session.new': '新建会话',
'session.newWindow': '在新窗口中新建会话',
'session.next': '下一个会话',
'session.prev': '上一个会话',
'session.slot.1': '切换到最近会话 1',
@@ -359,30 +358,7 @@ export const zh: Translations = {
installError: '无法安装该主题。',
installed: name => `已安装「${name}」。`,
removeTheme: '移除主题',
importedBadge: '已导入',
pet: {
title: '宠物',
intro: '领养一只悬浮在应用上的 petdex 动画宠物,它会根据 Hermes 的状态做出反应——工具执行时奔跑、成功时欢呼、出错时沮丧。',
restartHint: '宠物功能需要重启——当前运行的应用在此功能加入前启动。请退出并重新打开 Hermes,然后回到此处。',
scaleTitle: '大小',
scaleDesc: '调整悬浮宠物的大小,所有界面即时生效。',
on: '开启',
off: '关闭',
chooseTitle: '选择宠物',
chooseDesc: '选择后会自动安装(如需)并设为当前宠物。',
searchPlaceholder: '搜索宠物…',
unreachable: '无法连接到 petdex 画廊。请检查网络连接并重新打开此页面。',
noMatch: query => `没有匹配「${query}」的宠物。`,
installedTag: '已安装',
countCapped: (cap, total) => `显示 ${total} 个中的 ${cap} 个——输入关键词以缩小范围。`,
count: n => `${n} 个宠物。`,
uninstall: name => `卸载 ${name}`,
adoptFailed: slug => `无法领养 ${slug}`,
uninstallFailed: slug => `无法卸载 ${slug}`,
noneAvailable: '当前没有可开启的宠物。',
turnOnFailed: '无法开启宠物。',
turnOffFailed: '无法关闭宠物。'
}
importedBadge: '已导入'
},
fieldLabels: defineFieldCopy({
model: '默认模型',
@@ -782,13 +758,9 @@ export const zh: Translations = {
collapse: '收起',
connectAnother: '连接其他提供方',
otherProviders: '其他提供方',
disconnect: '断开连接',
disconnectInTerminal: '断开连接(在终端中运行移除命令)',
removeConfirm: provider => `移除 ${provider}`,
removeExternalGeneric: provider => `${provider}其自身的 CLI 管理 — 请在那里移除。`,
removeExternal: (provider, command) => `${provider} Hermes 外部管理。请使用 ${command} 移除。`,
removeKeyManaged: provider => `${provider} 由 API 密钥配置。请从 API Keys 中移除。`,
removeTerminalConfirm: (provider, command) => `断开 ${provider}?这将在终端中运行 "${command}" 以清除凭据。`,
removeTerminalRunning: provider => `正在终端中断开 ${provider}`,
removedTitle: '账号已移除',
removedMessage: provider => `${provider} 已移除。`,
failedRemove: provider => `无法移除 ${provider}`,
@@ -923,22 +895,8 @@ export const zh: Translations = {
commandCenter: '命令中心',
appearance: '外观',
settings: '设置',
changeTheme: '更改主题',
changeTheme: '更改主题...',
changeColorMode: '更改颜色模式...',
pets: {
title: '宠物',
placeholder: '搜索宠物…',
loading: '正在加载 petdex 画廊…',
error: '无法连接到 petdex 画廊。',
staleBackend: '请重启 Hermes 以使用宠物功能——当前后端版本过旧。',
empty: '没有匹配的宠物。',
turnOff: '关闭',
turnOn: '开启',
installed: '已安装',
adoptFailed: '无法领养该宠物。',
toggleFailed: '无法切换宠物显示。',
noneAvailable: '暂无可用宠物——请在下方选择一个安装。'
},
installTheme: {
title: '安装主题...',
placeholder: '搜索 VS Code Marketplace...',
@@ -1720,6 +1678,8 @@ export const zh: Translations = {
unknown: '(未知)',
search: '筛选提供方和模型...',
noModels: '未找到模型。',
persistGlobalSession: '全局保存 (否则仅当前会话)',
persistGlobal: '全局保存',
addProvider: '添加提供方',
loadFailed: '无法加载模型',
noAuthenticatedProviders: '没有已认证的提供方。',
@@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest'
import type { ChatMessage, ChatMessagePart } from './chat-messages'
import {
appendAssistantTextPart,
appendReasoningPart,
chatMessageText,
preserveLocalAssistantErrors,
renderMediaTags,
@@ -176,52 +175,6 @@ describe('renderMediaTags', () => {
})
})
describe('interleaved reasoning/text coalescing', () => {
it('keeps narration contiguous when reasoning interrupts mid-sentence', () => {
// Models that interleave reasoning_content + content deltas emit
// text → reasoning → text within one tool-bounded segment. The two text
// fragments are really one sentence and must not be split by the
// "Thinking" block between them.
let parts: ChatMessagePart[] = appendAssistantTextPart([], 'Let me ')
parts = appendReasoningPart(parts, 'checking the file...')
parts = appendAssistantTextPart(parts, 'verify the full file is correct:')
expect(parts.map(p => p.type)).toEqual(['text', 'reasoning'])
expect((parts[0] as { text: string }).text).toBe('Let me verify the full file is correct:')
expect((parts[1] as { text: string }).text).toBe('checking the file...')
})
it('merges reasoning bursts that straddle a narration fragment', () => {
let parts: ChatMessagePart[] = appendReasoningPart([], 'first thought ')
parts = appendAssistantTextPart(parts, 'Working on it.')
parts = appendReasoningPart(parts, 'second thought')
expect(parts.map(p => p.type)).toEqual(['reasoning', 'text'])
expect((parts[0] as { text: string }).text).toBe('first thought second thought')
expect((parts[1] as { text: string }).text).toBe('Working on it.')
})
it('starts a fresh text part after a tool call (segment boundary)', () => {
let parts: ChatMessagePart[] = appendAssistantTextPart([], 'Let me check.')
parts = upsertToolPart(parts, { name: 'read_file', tool_id: 'tc-1' }, 'running')
parts = appendAssistantTextPart(parts, 'Now editing.')
expect(parts.map(p => p.type)).toEqual(['text', 'tool-call', 'text'])
expect((parts[0] as { text: string }).text).toBe('Let me check.')
expect((parts[2] as { text: string }).text).toBe('Now editing.')
})
it('does not merge reasoning across a tool call', () => {
let parts: ChatMessagePart[] = appendReasoningPart([], 'before tool')
parts = upsertToolPart(parts, { name: 'read_file', tool_id: 'tc-1' }, 'running')
parts = appendReasoningPart(parts, 'after tool')
expect(parts.map(p => p.type)).toEqual(['reasoning', 'tool-call', 'reasoning'])
expect((parts[0] as { text: string }).text).toBe('before tool')
expect((parts[2] as { text: string }).text).toBe('after tool')
})
})
describe('preserveLocalAssistantErrors', () => {
it('preserves a local user+error pair when hydration omits the failed turn', () => {
const nextMessages: ChatMessage[] = [

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