mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
perf(state): external-content FTS + tool-row-free trigram index (schema v23) (#65798)
* fix(desktop): refresh repo status on session switch with unchanged cwd (#68208) fix(desktop): refresh repo status on session switch with unchanged cwd * fix(checkpoints): honor gateway config and task cwd (#68195) * fix(gateway): wire checkpoint config into agents * fix(checkpoints): resolve gateway file paths by task cwd * ci: live-updating PR review comment with structured job statuses Replace the static comment-pending + comment-results two-job pattern with a live-updating comment system that polls the GitHub Actions API every 15s, re-assembles the review comment from whatever results are available, and upserts it via the <!-- hermes-ci-review-bot --> marker. The comment updates in real time as each job finishes — no waiting for the full pipeline. Every CI job that wants to appear in the review comment emits a review_status output — a JSON array of objects, each with a source and a results array: [ { "source": "review-label-gate", "results": [ {"kind": "action_required", "title": "...", "summary": "...", "how_to_fix": "..."}, {"kind": "info", "title": "...", "summary": "..."} ] }, { "source": "ci timing", "results": [ {"kind": "warning", "title": "CI timings", "summary": "...", "detail": "...", "link": "..."} ] } ] One job can emit multiple results of different kinds. The source field is used to exclude the corresponding job from the synthesized error list (case-insensitive, hyphen-normalized matching against GitHub Actions job display names). | job | source | kind (on failure) | section | |----------------------------|--------------------------|---------------------------|----------------------| | review-labels | review label gate | action_required / info | Action required | | lockfile-diff | lockfile-diff | action_required | Action required | | ci-timings | ci timing | warning / info | Warnings | | supply-chain scan | supply chain | error / (none) | Job failures | | supply-chain dep-bounds | supply chain | action_required / (none) | Action required | | osv-scanner | osv scan | warning / (none) | Warnings | | uv-lockfile-check | uv.lock check | action_required / (none) | Action required | | history-check | unrelated histories | action_required | Action required | | contributor-check | contributor attribution | action_required | Action required | Jobs that find nothing emit [] (empty array) — no noise info items. A single comment-live job polls the GitHub Actions API every 15s, classifies jobs into (completed, pending), assembles the comment, and upserts it. Merges review_status outputs from all needs jobs via toJSON(needs.*.outputs.review_status), and downloads the ci-timings artifact when it becomes available. Shows commit SHA + message below the header. The assembler has ZERO job-specific knowledge. It just: 1. collect_from_statuses() — flattens all nested status objects into ReviewItems 2. collect_failed_jobs() — synthesizes errors for failed jobs with no declared status 3. _attach_job_urls() — fills in per-job log links for ALL items 4. render_comment() — groups by severity, renders with group headers Each item shows links inline next to the title: View report (job-emitted URL) and View job (auto-attached logs link). Each info item is its own collapsible <details> block. # ૮ >ﻌ< ა ci review running on abc1234 — commit message first line ## ❌ Job failures ### {title} · [View job](url) {summary} ## ⚠️ Action required ### {title} · [View job](url) {summary} **How to fix:** {how_to_fix} ## ⚠️ Warnings ### {title} · [View report](url) · [View job](url) {summary} {detail} <details><summary>{title}</summary> {content} </details> Still running 3 jobs: ci-timings, docker - test_assemble_review_comment.py (48 tests): collect_from_statuses, collect_failed_jobs with exclude_sources, _attach_job_urls, render_comment (group headers, inline links, commit info, per-item details, pending footer), assemble integration - test_live_comment.py (16 tests): classify_jobs pure function - test_timings_report.py (10 tests): generate_review_status nested format - test_lockfile_diff.py (6 tests) - test_classify_changes.py (32 tests, pre-existing) * ci: migrate AUTOFIX_BOT_PAT to GitHub App token Replace the long-lived fine-grained PAT (AUTOFIX_BOT_PAT) with short-lived (1-hour) installation access tokens minted via a new get-app-token composite action wrapping actions/create-github-app-token@v3.2.0. The PAT was used in 13 spots across 8 workflow files for gh CLI / GitHub API calls. The per-repo GITHUB_TOKEN (1,000 req/hr) was getting rate-limited when multiple workflows fire concurrently (deploy-site, skills-index, ci-timings, supply-chain-audit, js-autofix). App installation tokens get 5,000 req/hr per installation and are scoped to the App's permissions, not a user account. New composite action: .github/actions/get-app-token/ - Wraps actions/create-github-app-token@bcd2ba49 (v3.2.0, SHA-pinned) - Reads APP_ID + APP_PRIVATE_KEY repo secrets - Outputs a 1hr installation token via steps.app-token.outputs.token Requires two new repo secrets (set after creating the GitHub App): - APP_ID: the App's numeric ID - APP_PRIVATE_KEY: the PEM private key App installation permissions needed: contents: write (js-autofix push, pypi release upload) pull-requests: write (js-autofix PR create/merge, supply-chain comment) issues: write (skills-index-freshness issue creation) actions: write (skills-index workflow trigger) workflows: write (skills-index triggers deploy-site.yml) The AUTOFIX_BOT_PAT secret can be deleted once CI passes on this PR. The comment in js-autofix.yml noting that PAT pushes trigger downstream workflows is updated — App tokens have the same property (they are not GITHUB_TOKEN), so the concurrency-cancel loop logic is unchanged. * style(desktop): satisfy merged eslint/prettier config The SSH modules predate the stricter lint config that landed on main (curly, no-empty, perfectionist sorting, prettier). Mechanical lint:fix + fmt pass, empty catch blocks filled with the codebase's void-0 convention, and inline no-control-regex disables on the three deliberate control-char patterns (same pattern as lib/ansi.ts). * fix(ci): pass App secrets as inputs to composite action Composite actions cannot access the secrets context — the runner's template engine rejects secrets.* references at load time with 'Unrecognized named-value: secrets'. Move APP_ID and APP_PRIVATE_KEY from direct secrets.* references inside the composite action to inputs passed by each calling workflow. The fallback logic (GITHUB_TOKEN when APP_ID is empty, for fork PRs) stays in the composite action's check step. * fix(ci): add detect to all-checks-pass needs so its failure blocks merge If detect fails, all downstream sub-workflows get SKIPPED (they have needs: detect). all-checks-pass used if: always() and only checked the sub-workflows — which all showed as 'skipped' (= success) — so it passed even though the root cause (detect) failed. This made the PR mergeable despite a broken CI pipeline. Add detect to all-checks-pass needs so its failure propagates to the gate job and blocks the merge. * fix(desktop): bump skills test timeout to fix cold-start flake (#68235) Test 1 in skills/index.test.tsx pays the full cold-start cost (jsdom env init + module transform + the @/hermes/@/store/profile import graph), which pushed past vitest's 5000ms default under load — caught at 8871ms on one run, 6.6s pure test time on another. Tests 2-4 are ~30-130ms each because all that setup is already cached, so only test 1 was at risk of timing out. Bump the describe-level timeout to 15s. Verified with 10 consecutive runs, 4 of which took 5.5-6.6s of test time and would have hard-failed under the old 5s default. * feat(desktop): open multiple full app windows (electron) Add createInstanceWindow() — a full-chrome peer of the primary that renders the complete app (sidebar, routing, its own draft) against the shared backend, so several GUI windows can run at once. Mirrors the primary's window options + chatWindowWebPreferences (backgroundThrottling stays off so a streamed answer never stalls when blurred) but never overwrites the mainWindow global and doesn't respawn the backend — the renderer's getConnection() joins the running one. New windows cascade off their source via the pure, tested instanceWindowBounds(). Exposed via the hermes🪟openInstance IPC and a "New Window" File menu item. Per-window fullscreen state now targets the window itself, and titlebar/native-theme repaints reach every open chat window instead of only the primary. Retires the now-orphaned compact new-session pop-out (its only caller was ⌘⇧N, repointed in the follow-up commit): drops createNewSessionWindow, the hermes🪟openNewSession handler, and the newSession/new=1 URL flag. * feat(desktop): wire New Window to ⌘⇧N + command palette Repoint session.newWindow (⌘⇧N) from the compact new-session pop-out to openNewWindow(), which opens a full peer instance via the new openWindow bridge, and add a "New Window" entry to the ⌘K palette (shown with its hotkey hint, gated on canOpenNewWindow()). Relabel the action "New window". Drops the retired openNewSessionWindow bridge and the vestigial isNewSessionWindow()/new=1 flag; renames the shared opener helper. * fix(desktop): de-dupe cross-window cues so peers don't spam With multiple full windows, each renderer independently reacts to the same backend event, so one-shot cues fired N times: OS notifications (the per-renderer throttle can't see other windows), the turn-end sound (playCompletionSound runs on every message.complete, ungated by focus), and auto-spoken replies (double voice when a chat is open in two windows). Add a single race-free owner in the main process (electron/event-dedupe.ts): main handles IPC serially, so the first window to claim a key within a short window wins and peers stay quiet. Notifications collapse at the hermes:notify choke point; the sound and spoken replies claim via a new hermes:ambient:claim IPC (keyed by session / reply id). Off Electron the claim falls back to "emit", preserving single-window behavior. The sound's mute check runs before the claim so a muted window can't win the cue and silence an audible peer. * refactor(desktop): tidy the cross-window deduper Drop the unused DEDUPE_WINDOW_MS export and rename its interval so "window" isn't overloaded against BrowserWindow in a multi-window feature (windowMs → intervalMs). DRY the completion-sound play path. No behavior change. * nix: add cage to devDeps * fix(desktop): avoid false remote gateway reauthentication (#68250) * fix(desktop): avoid false remote gateway reauthentication Co-authored-by: Rod-fernandez <rodrigo@nxtlevelsaas.com> Co-authored-by: David Andrews (LexGenius.ai) <david@lexgenius.ai> * fix(desktop): harden remote revalidation state --------- Co-authored-by: Rod-fernandez <rodrigo@nxtlevelsaas.com> Co-authored-by: David Andrews (LexGenius.ai) <david@lexgenius.ai> * fix(desktop): keep composer draft across compression tip rotation (#68079) * fix(desktop): keep composer draft across compression tip rotation Auto-compression swaps the live stored session id while the user may still be typing. Scope the composer/queue key on the lineage root and migrate any tip-keyed draft/queue entries onto that durable key when the tip rotates so the in-progress prompt does not vanish when the response lands. * test(desktop): cover draft survival across compression tip rotation Add regression coverage for migrateSessionDraft, lineage-scoped composer keys, and the rotation path that previously wiped an in-progress draft. * fmt(js): `npm run fix` on merge (#68305) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * fix(desktop): Stop parks the queue instead of firing the next queued prompt Interrupting a busy turn with the Stop button (or Esc) settles the session to idle, and the edge-independent auto-drain immediately submits the head of the composer queue. The user pressed Stop to halt the agent, but it looks like Stop skipped the current turn and kept going — and the queued text is hard to find, since its only surface is the collapsed 'N queued' pill above the composer. The old userInterruptedRef latch (a23728dcc) fixed this but was removed in #40221 because it also suppressed the drain that send-now-while-busy depends on. This reintroduces the halt with source awareness instead of a blanket latch: - Explicit halts (Stop button, composer Esc, chat-focus Esc, the streaming message's hover Stop, runtime cancel) park the session's queue before interrupting. Parked queues are skipped by both auto-drain paths (mounted ChatBar + background drainer). - Interrupts that exist to advance the queue (send-now-while-busy) unpark first, so the settle drain they rely on still flows. - The park lifts on any renewed intent: resume, a manual drain (Enter on empty composer or the per-row send arrow), queueing a new prompt, or emptying the queue. It migrates with entries on a runtime re-key and is deliberately not persisted (a fresh process starts unparked). - The queue panel expands on park, switches to 'N Queued — paused' with a pause icon, and grows a Resume action, so the held prompts are visible instead of reading as vanished. Store contract, hook wiring, and background-drain coverage included; docs updated. * fix(cli,tui): recall real paste content on up-arrow Large pastes collapse to a placeholder in the composer, but input history stored the placeholder — so up-arrow recall showed a truncated reference (CLI) or lost the content entirely (TUI, where the `[[…]]` label has no backing snip after submit). Store the expanded content in history instead: - CLI: `_inline_pastes()` expands `[Pasted text #N -> file]` into the buffer before `reset(append_to_history=True)`; also reused by the external editor (dedup). History nav suppresses re-collapse of recalled content. - TUI: `dispatchSubmission` pushes `expandSnips(pasteSnips)(full)`; idempotent on label-free text so re-submitting a recalled entry stays stable. * fix(cli): suppress CPR on POSIX local TTYs under load Delayed ESC[6n replies leak as ^[[row;colR into the classic CLI on SSH/slow PTYs (#13870) and on local POSIX TTYs under heavy subagent load. Suppress CPR on non-Windows platforms (layout hint only); keep native Windows on prompt_toolkit's default pending native coverage. Wire selection through _select_classic_cli_pt_output. * test(cli): prove local CPR leak and Application CPR-disabled wiring Add a delayed-CPR PTY harness (no SSH) plus selection/Application assertions for POSIX local and Windows preserve-default. Update the gating unit test to the new contract. * refactor: drop platform kwarg, fix PTY test cleanup - Remove redundant platform= test seam from _terminal_may_leak_cpr(); use monkeypatch.setattr(sys, 'platform', ...) consistently in both test files. - Wrap PTY tests in try/finally for fd cleanup on assertion failure. - Guard select.select() in terminal thread against OSError after fd close (fixes PytestUnhandledThreadExceptionWarning). - Trim PR-number reference from test module docstring. * docs(portal): remove retired Nous Chat references * fix(web/ddgs): isolate DuckDuckGo search in a disposable process ThreadPoolExecutor timeouts cannot fire when primp holds the GIL in native code (#68096). Run each search in a child process the parent can terminate/kill, and honor tools.interrupt between polls. * test(web/ddgs): cover GIL-hold timeout, interrupt, and worker reap Regression tests for #68096: native GIL-hold and sleep hooks must time out or interrupt promptly with no orphaned search workers. * fix: sanitize subprocess env for DDGS worker os.environ.copy() passes all Hermes secrets (gateway tokens, API keys, dashboard session tokens) into the DDGS child process. Use _sanitize_subprocess_env() to strip Hermes-managed secrets before spawning the worker. * fix(agent): pass persisted-prefix boundary when rotation flushes on cold resume (#68196) The legacy rotation branch in agent/conversation_compression.py flushes the current turn to the OLD session before ending it (#47202) via _flush_messages_to_session_db(messages) with no conversation_history boundary. On the first turn after a cold Desktop resume, the restored transcript rows live in the message list as plain dicts that have not yet been stamped with _DB_PERSISTED_MARKER — the normal turn flush that stamps them runs after preflight compression. With no boundary, _flush_messages_to_session_db builds an empty history_ids set and treats every restored row as new, durably re-appending the whole transcript to the parent session. Repeated restart/resume + threshold compression keeps growing the parent transcript. Pass messages[:_persist_user_message_idx] (the already-durable prefix that turn_context anchors before preflight runs, guarded for int/bounds) as conversation_history so the flush skips the persisted rows by identity and writes only the current turn's new messages. Adds a regression test that pre-populates SQLite, cold-loads the transcript, appends one current user row, and forces rotating compression: it fails before this change (parent grows to 5 rows) and passes after (parent holds the two originals plus the single new turn). * fix(desktop): prevent contentEditable composer input from visually collapsing to near-zero height Fix #68095 The composer input box (contentEditable div) randomly shrank to a tiny/pixelated size when typing character-by-character (paste worked fine). Root cause: during per-keystroke input, the normalizeComposerEditorDom cleanup could briefly leave the contentEditable with zero child nodes, and without intrinsic content the browser collapsed it visually despite the CSS min-height. Two-pronged fix: 1. Add min-h-[1.625rem] bracket syntax alongside the CSS variable min-height to ensure the minimum height is enforced even if the CSS variable resolution is delayed or overridden by browser defaults. 2. In normalizeComposerEditorDom, ensure the contentEditable always has at least one <br> child when empty, giving it intrinsic height that the browser cannot collapse. This is a belt-and-suspenders approach with the CSS min-height. Closes #68095 * fix(agent): circuit-break AttributeError from commit-splice and detect code skew Fix #68178 The git-install auto-updater rewrites source while the desktop backend is live. Because agent/conversation_loop.py is imported lazily on the first API call, a process can end up running two different commits spliced together — one commit's AIAgent against another commit's conversation_loop. When the interface differs, every turn fails permanently with an AttributeError, and the loop retries indefinitely, burning provider API calls (576 failures, 149 wasted API calls observed). Three-prong fix: 1. Circuit-break AttributeError on agent objects: the outer-loop error classifier now detects AttributeError targeting agent/run_agent modules and breaks immediately instead of continuing the retry loop. 2. Code skew detection for desktop/serve backend: run_agent.py now snapshots the checkout revision at import time and exposes a cheap per-iteration check that the conversation loop uses to refuse new work with a clear 'restart required' message before the lazy import can crash. 3. Informative error message: when code skew is detected, the user gets a clear explanation of the mismatch (boot revision vs current revision) and actionable guidance to restart the application. * fix(telegram): preserve fatal recovery handoff Release the current polling-recovery task's ownership before invoking the fatal-error handler. The runner bounds adapter cleanup in a child task; disconnect() cancels the tracked polling-recovery task, so retaining the current notifier in _polling_error_task would cancel the fatal callback before the runner can finish its reconnect-queue or shutdown decision. The new _handoff_polling_fatal_error() helper clears _polling_error_task only when it is the current notifier. Other recovery tasks remain tracked and are still cancelled and awaited during teardown. Covers both network retry exhaustion and polling-conflict exhaustion. Replaces the misleading "Restarting gateway" message with "Escalating to gateway recovery". Fixes #68406. * fix(telegram): widen fatal handoff to heartbeat watchdog path The wedged-recovery heartbeat watchdog (line 2526) calls _notify_fatal_error() directly from the heartbeat task. disconnect() cancels _polling_heartbeat_task unconditionally (no current_task guard, unlike _polling_error_task). Same bug class as #68406: the child disconnect cancels the heartbeat parent before the runner can queue reconnect. Widen _handoff_polling_fatal_error() to also clear _polling_heartbeat_task when it is the current task, and route the heartbeat watchdog call site through the handoff helper. Co-authored-by: Imgaojp <6065749+Imgaojp@users.noreply.github.com> * fix(tests): make the live-system-guard canary fail closed tests/test_live_system_guard_self_test.py executes real kill primitives (os.kill(-1, SIGTERM), os.killpg, pkill -f python) and depends entirely on the autouse _live_system_guard fixture in tests/conftest.py to intercept them. That makes the canary fail-OPEN: in any collection context where the file is present but its home conftest is not — a published sdist that ships tests/ but not tests/conftest.py, a tree assembled by copying test*.py (that glob does not match conftest.py), pytest --noconftest, or a foreign rootdir — the primitives fire for real, and os.kill(-1, SIGTERM) SIGTERMs every process the invoking user owns (a full desktop-session kill was reported in the field). Add an autouse fixture that refuses to run any canary test unless the guard is provably active. The one thing the canary can detect about its own safety is that the guard monkeypatches os.kill with a plain Python function, whereas the unguarded primitive is a C builtin — so the probe keys off that. Tests marked @pytest.mark.live_system_guard_bypass still opt out, matching the guard's own bypass contract (e.g. test_bypass_marker_disables_guard). With the guard loaded every canary test behaves exactly as before; without it each test refuses at setup with zero side effects. Fixes #68311 * fix(billing): rename user-facing "terminal billing" copy to Remote Spending (#68355) * fix(billing): rename user-facing "terminal billing" copy to Remote Spending The capability was renamed Remote Spending on the portal (consent CTA: "Allow Remote Spending"; per-terminal states Granted/Stopped), but the terminal, desktop, and docs still said "terminal billing" everywhere. - Feature name: Remote Spending in titles/labels, lowercase mid-sentence. - Step-up action verb is now "allow", matching the portal consent CTA. - Kill-switch-off recovery copy points at the actual control ("a billing admin can turn it on from the portal's Hermes Agent page") instead of the dead-end "manage it on the portal". - Per-terminal revoke copy uses the portal vocabulary ("stopped"). - Wire identifiers (cli_billing_enabled, cli_billing_disabled, ...) are unchanged; copy, comments, docs, and test expectations only. * fix(billing): correct the post-step-up denial diagnosis + finish the desktop rename Adversarial review findings: (1) a repeated insufficient_scope after a successful step-up is a per-terminal authorization failure, but the copy blamed the org kill-switch and pointed at the wrong recovery control — now: "Remote Spending still isn't active for this terminal — the authorization didn't take. Retry, or make this change on the portal." (2) the desktop step-up flow started in Remote Spending vocabulary but finished in "billing management access" — renamed both end states. (3) prettier formatting on the touched files (matches the post-merge fmt bot). * feat(tui): show the plan catalog in /subscription on Free (#68357) * feat(tui): show the plan catalog in /subscription on Free The server returns the tier list even with no subscription, but the overlay hid the picker behind can_change_plan && !isFree, so a Free account got only "Start a subscription" with no idea what the plans cost. Now: - Overview on Free offers "Choose a plan" whenever the catalog has enabled paid tiers. - The picker on Free lists each plan as name · price · monthly credits (no upgrade/downgrade hints — there is nothing to move from), and picking one opens the portal, where starting a subscription actually happens (card capture + checkout live there; the upgrade RPC requires an existing subscription). - Paid-plan behavior (preview → confirm → apply) is unchanged. * refactor(tui): compute the picker row suffix once Review feedback: the isFree fork duplicated the label template and run handler; only the suffix differs. * fix(tui): arm the busy guard before the Free portal handoff Adversarial review: the Free branch returned before setting busyRef, so a double-Enter could open the portal twice; and the picker narrated a handoff that openManageLink already narrates (duplicate on success, contradictory on failure). Guard first, let the helper do the talking. * fix(tui): monthly credits are dollars — label them as such The Free picker showed "1000 credits/mo" for what is $1,000 of monthly credit — render "$1,000 credits/mo" (grouped, dollar-signed). * feat(tui): render the Free-plan catalog inline in the /subscription overview Sid ruling: the upsell belongs where the user already is — no intermediate "Choose a plan" hop. On Free the overview lists each paid plan (name · $/mo · $credits/mo) as a pickable row; picking opens the portal (openManageLink narrates). The generic "Start a subscription" row survives only when the catalog is empty. The picker reverts to its original change-only form (Free never reaches it). * feat(desktop): tier catalog chips on the Subscription row Desktop parity with the TUI inline catalog (Sid ruling): accounts that can act see the plans where they already are — Free gets the upsell list (every chip opens the portal), a subscriber sees all tiers with the current one marked inert. Members and team contexts see no chips. Chips learn an optional url (portal handoff) in the shared row model. * chore(tui): fixture harness mirrors the live tier catalog The dev screenshot fixtures showed invented plans ($50 Super / $99 Ultra, "1,000 credits"); align with the real catalog ($20/$100/$200 with $22/$110/$220 monthly credits) so fixture renders cannot be mistaken for product truth. The overlay itself always reads tiers from the subscription API. * chore: trim narration comments * fmt(js): `npm run fix` on merge (#68462) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * fix(relay): attach metadata.user_id on guild replies for egress fallback (#68320) The relay adapter re-attaches an egress discriminator on outbound replies so the connector can resolve the owning tenant. It captured scope_id for scoped (guild) messages and user_id for DMs, but as MUTUALLY EXCLUSIVE: a scoped inbound hit an early return, so the author's user_id was never recorded, and _with_scope only attached user_id when there was no scope_id. Guild replies therefore went out with scope_id only. That's fine while the guild has a provision-time route row. But a MANAGED Discord agent joins guilds dynamically (the shared bot is added to / removed from servers at runtime), and GATEWAY_RELAY_ROUTE_KEYS — the only thing that writes guild route rows — is a self-hosted, static field never stamped for managed agents. So their guild has no route row, the connector's guild-route lookup misses, and with no user_id on the frame there's nothing to fall back to → every guild reply is declined "discord egress declined: target not routed to an onboarded tenant" even though INBOUND resolved the same guild fine (via the author-first SharedSocketRouter.targets() fallback). Fix: capture the authentic author user_id for EVERY inbound (DM and scoped alike) and re-attach it on the outbound reply alongside scope_id. The connector consults it only on a route/scope miss, so carrying both never overrides routing-table resolution. This is the gateway half of the paired gateway-gateway change (makeDiscordTenantOf guild-route-miss author-binding fallback); together they make guild replies resolve the same observed-author way inbound already does. Tests (tests/gateway/relay/test_relay_adapter.py): a guild reply now carries both scope_id AND user_id; a scoped inbound with no author still yields scope_id only (never invents one). Verified fail-without / pass-with. * build: declare pywin32 as a direct win32 dependency hermes_cli/windows_ssh_runtime.py imports win32security/win32file/etc. directly but pywin32 only arrived transitively via concurrent-log-handler -> portalocker. Declare it with a sys_platform gate so the Windows SSH runtime doesn't depend on the logging dep chain. Review follow-up on PR #68130. * fix(desktop): preserve dragging with empty titlebar slots * Revert "fix(agent): circuit-break AttributeError from commit-splice and detect code skew" This reverts commit3a9b9d65d5. * fix(context): revalidate Codex OAuth context windows * test(context): document Codex cache persistence coverage * fix(context): scope Codex catalogue cache by credential * test(context): cover Codex context rollback * fix(compression): report live-resolved Codex window in the autoraise notice The autoraise banner hardcoded '272K' for the gpt-5.4/5.5/5.6 family, but the Codex /models catalog is authoritative and shifts server-side (gpt-5.6 served 372K during July 9-18, 2026 before OpenAI rolled it back). Pass the compressor's live-resolved context_length through so the notice reports the window the session actually got; the static 272K/128K text remains as the fallback when no resolved value is available. * fix(codex): send ChatGPT-Account-Id on /models probes The Codex backend returns the per-account model catalog only when the ChatGPT-Account-Id header is present. Without it, GET /backend-api/codex/models responds 200 OK with {"models":[]} and the picker silently degrades to the hardcoded fallback list — which is stale or wrong for the active plan (no GPT-5.6 family, wrong context windows). This was the upstream bug behind slow first responses and HTTP 520/120s SSE hangs: Hermes was sending invalid slugs because the probe never saw them in the catalog, and Codex's request builder also depends on the same JWT claim that's now being threaded through both probe paths. Fixes the probe-side paths in hermes_cli/codex_models.py and agent/model_metadata.py by extracting chatgpt_account_id from the OAuth JWT (mirroring the request-side logic already in auxiliary_client.py) and sending it as a header. Verified live: - _fetch_models_from_api now returns the 10-model catalog (gpt-5.6-sol, gpt-5.6-terra, gpt-5.6-luna, gpt-5.5, gpt-5.4, gpt-5.4-mini, gpt-5.3-codex-spark, 3x -pro variants) instead of []. - _fetch_codex_oauth_context_lengths resolves all 8 account models to 272K context (matches direct API probes of the same account). - end-to-end: hermes chat -m gpt-5.6-sol -q 'Reply with one word: pong' returns 'pong' cleanly via the openai-codex route. Same class of bug as PR #64760. * test(codex): cover ChatGPT-Account-Id header on /models probe Add regression tests locking in the new behavior: a JWT carrying a chatgpt_account_id claim causes the probe to send ChatGPT-Account-Id, while a malformed token omits the header instead of crashing. * fix(tools): make the tool-search context gate provider-aware (#68589) _resolve_active_context_length() called get_model_context_length() with the model id alone, so provider-enforced windows (e.g. Codex OAuth's 272K for gpt-5.x vs the direct API's 1.05M) never reached the tool-search activation gate — it sized against generic metadata for the same slug. Resolve the runtime provider for the configured model and pass provider, base_url, and api_key through. If credential resolution fails (offline, no keys), degrade to a provider+base_url-only lookup so the static provider-aware fallbacks still apply; explicit model.context_length keeps short-circuiting as before (#46620). Gap flagged during review of #16735. * feat(skills): bundle docx, xlsx, and pdf office skills; refresh powerpoint (#68595) Non-technical users asking for Word docs, spreadsheets, or PDF work had no bundled skill coverage — docx/xlsx creation required discovering and installing hub skills, and PDF manipulation had no skill at all beyond OCR extraction and nano-pdf edits. - skills/productivity/docx: create (docx-js), edit (unzip -> XML -> zip), tracked changes, comments, validation. Adapted from anthropics/skills. - skills/productivity/xlsx: openpyxl creation/editing, mandatory LibreOffice recalc gate, formula-compatibility rules, financial-model conventions. Points at optional excel-author for finance-grade work. - skills/productivity/pdf: merge/split/rotate/watermark/encrypt, form filling (AcroForm + flat overlay scripts), text/table extraction, reportlab creation, forms.md + reference.md companions. - skills/productivity/powerpoint: synced to current upstream pptx skill — richer pptxgenjs corruption footguns, template workflow, validate.py + validators + thumbnail.py, font-substitution QA guidance; drops the stale pack.py/editing.md/pptxgenjs.md workflow files. - Cross-linked ocr-and-documents, nano-pdf, excel-author via related_skills so each office skill routes to its siblings. - deliverable-mode docs mention the new skills; regenerated per-skill docs pages, catalogs, and sidebar. - tests/skills/test_office_document_skills.py: frontmatter contracts, referenced-script existence, schema-map integrity, cross-link resolution, script compilation. E2E validated: docx create->render->edit->validate, xlsx recalc (SUM + _xlfn.TEXTJOIN evaluate correctly), pdf create->merge->extract, pptx generate->validate->thumbnail. * fix(approval): raise gateway approval timeout to 300s, honest stale-tap UX, offer Always on mixed prompts (#68597) Three related messaging-approval fixes: 1. approvals.timeout default 60 -> 300. PR #63501 collapsed the gateway wait onto the canonical approvals.timeout (previously gateway_timeout=300), silently shrinking messaging approval windows to 60s. Push-notification approvals routinely arrive later than a minute; taps landed after the wait had already failed closed. 2. Stale-tap honesty: adapters resolved the approval AFTER rendering '<checkmark> Approved by <user>' (Telegram/Discord/Slack), or ignored a zero resolve count (WhatsApp Cloud/Feishu). A tap on an expired prompt claimed approval while the command had already been denied. All button paths now resolve first and render 'Approval expired - command was not run' when nothing was waiting. 3. Mixed-warning prompts (dangerous pattern + tirith finding) now offer Always: the persistence layer already permanently allowlists the pattern key and downgrades the tirith key to session scope, but the UI hid Always whenever ANY tirith warning was present. Pure-tirith prompts still withhold Always (content findings are session-max by design), and Smart-DENY overrides remain once-only. * feat(secrets): one-command token rotation + actionable startup errors for all secret sources (#68605) * feat(secrets): one-command token rotation + actionable startup errors for all secret sources When a Bitwarden machine-account token expired, users saw a raw Rust error dump (invalid_client + Location: + backtrace hints) and the only fix was manually editing .env or re-running the whole setup wizard. - New `hermes secrets bitwarden token` / `hermes secrets onepassword token`: paste a new token (masked prompt or flag), the command probes the backend BEFORE persisting — a rejected token changes nothing; a good one is written to .env and the fetch caches are cleared. - New optional SecretSource.remediation(kind, cfg) hook: startup warnings now print a '→ Run `hermes secrets <name> token`…' fix-it line after any fetch error, for bundled AND plugin sources (generic per-ErrorKind defaults in the ABC). - bws stderr is summarized to its cause line (Location:/backtrace noise dropped) and invalid_client/invalid_grant/400 identity rejects are now classified AUTH_FAILED (was INTERNAL) with a plain-English explanation naming the token env var. - op whoami probe accepts a candidate token so rotation validates the NEW credential, not the ambient one. Additive hook with defaults — no SECRET_SOURCE_API_VERSION bump. * docs: fix MDX parse error in secret-source-plugin hook table Escaped backticks around a <name> placeholder made MDX parse it as an unclosed JSX tag, breaking the docs-site build. Use a plain code span instead. * feat(desktop): configure repository discovery (supersedes #67630) (#68642) * feat(desktop): configure repository discovery * fix(config): preserve additive default migration * fix(desktop): stabilize session-actions-menu gateway mock for repo-scan subscribe projects.ts now runs $gateway.subscribe(syncReposScanning) at module load, and nanostores fires the subscriber synchronously. session-actions-menu.test.ts reaches projects.ts transitively via the session store but mocked @/store/gateway without $gateway, crashing the whole desktop vitest suite ("No \ export is defined"). Simply adding $gateway: atom(null) exposed a second issue: the synchronous subscriber calls the mock's activeGateway() during the transitive import, before the module-level const initializes (TDZ). Hoist the mock fns via vi.hoisted() so activeGateway is defined before the hoisted vi.mock factory runs, and add $gateway: atom(null) to the mock. Mirrors the self-contained mock pattern already used in projects.test.ts. Also maps the PR author's commit email for attribution. Supersedes #67630; incorporates review feedback from that PR. Co-authored-by: Rudimar Ronsoni <rudimar@outlook.com> --------- Co-authored-by: Rudimar Ronsoni <rudimar@outlook.com> Co-authored-by: Austin Pickett <austinpickett@users.noreply.github.com> * fix(desktop): ⌘W closes visible file tab when preview selection is stale (#68639) * fix(desktop): make ⌘W close visible file tab on stale preview selection When the live preview target is gone but $rightRailActiveTabId still points at preview, file tabs remain on screen while ⌘W fell through to a workspace no-op. Close the visible file tab instead. * test(desktop): cover ⌘W close for file tabs and ghost preview selection Lock the happy path and the stale-preview regression so ⌘W keeps closing the file tab the rail is actually showing. * fmt(js): `npm run fix` on merge (#68681) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * feat(billing): plan chips and rows deep-link their tier (#68666) * fix(desktop): drop the decorative top-up credits bar (#68649) The bar rendered full-or-empty (value 1|0) because top-ups have no denominator — the wire carries only the current balance and the pool is open-ended, so a fill fraction is fiction. Show the amount alone; subscription credits and the monthly cap keep their bars (real denominators). * fix(ci): route critical supply-chain findings through review gate (#68833) Let the scanner report critical findings without failing. The review-label gate owns the action-required status and blocking result, allowing the ci-reviewed label rerun to clear both CI and the PR comment. * fix: `tool_calls` double-encoding on import (#68856) * nix: add `cage` to devShell * test(desktop): add pre-filled sessions support Exports createSandbox, writeMockProviderConfig, writeEnvFile, buildAppEnv, findElectron, and launchDesktop from fixtures.ts so specs can compose their own seeded-backend fixtures without duplicating the sandbox/config/launch logic. * test(desktop): auto-fail e2e tests on error banner Adds a shared test fixture (e2e/test.ts) that wraps @playwright/test's page with an error-banner guard. When any [role="alert"] element (error notification toast) appears in the DOM during a test, the test fails with the error message text. The guard uses: - A MutationObserver (injected via addInitScript) that watches for [role="alert"] elements appearing at any point during the test - A final DOM scan in afterEach for alerts still visible at teardown - Deduplication so the same error text only fires once All existing e2e specs updated to import { test, expect } from './test' instead of '@playwright/test'. No per-spec setup needed — the guard is auto-installed on every page via the extended fixture. This catches issues like the "resume failed" error banner that can appear during session loading — previously the test would pass while an error toast was silently visible on screen. * fix(state): parse tool_calls JSON string before re-serializing _insert_message_rows and append_message both do json.dumps(tool_calls) to serialize the field for SQLite storage. But when tool_calls arrives as a JSON string (from import_sessions / export_session, which store it as TEXT), json.dumps double-encodes it — wrapping the already-serialized string in quotes and escaping the inner quotes. When _rows_to_conversation later does json.loads(row['tool_calls']), the double-encoded string parses back to a plain string (not a list). _history_to_messages then iterates this string character-by-character, calling tc.get('function', {}) on each char — 'str' object has no attribute 'get'. This was a pre-existing bug (on main), but only triggered by the import_sessions path (the live agent always passes tool_calls as a Python list). The e2e error-banner guard caught it via the 'Resume failed' notification toast. Fix: in both append_message and _insert_message_rows, parse tool_calls with json.loads first if it's a string, then re-serialize. * fix(desktop): exempt boot-failure from error guard - boot-failure: add allowErrorBanners() beforeEach — these tests deliberately trigger boot errors, so error toasts are expected - test.ts: export allowErrorBanners() opt-out + reset flag in afterEach * feat(status-bar): add /battery toggle for a color-coded battery read-out Add an opt-in battery indicator to the CLI and TUI status bars, shown as the first element and colour-coded by charge (green/yellow/orange/red, or green while charging). Off by default and a no-op on machines without a battery. - agent/battery.py: shared psutil-backed reader with a short TTL cache, category bucketing, and a compact 🔋/⚡ label. Fails open to "unavailable" everywhere. - CLI: /battery [on|off|status] toggle persisted to display.battery, rendered first in every status-bar width tier. - TUI: /battery slash command, config sync, a system.battery RPC polled while enabled, and a pinned first segment in StatusRule. * fix(approval): restore session approval for Tirith-flagged commands Adds an allow_session flag to the gateway approval payload so adapters can render the session tier independently of the permanent tier. Matrix gains a session reaction (🌀) and a reaction legend; pure-tirith prompts now offer once/session/deny instead of collapsing to once/deny. Salvaged from PR #67312, adapted to the allow_permanent semantics that landed in #68597 (Always offered when any dangerous-pattern warning is persistable; pure-tirith prompts stay session-max). * fix(approval): honor allow_session across all button adapters Widen the allow_session tier from Matrix to every adapter the gateway notifies: Telegram, Discord, Slack, Feishu, and Teams gate their Session button on it; WhatsApp Cloud and qqbot accept the kwarg (no session tier in their button sets). Also thread allow_session through the plugin- escalation gate, the execute_code guard payload, and the plain-text fallback so every notify path carries the same capability flags. * test(approval): cover allow_session tiers in Matrix reaction seeding and gateway payload Update the Matrix reaction-seeding contract to the four-reaction default (once/session/always/deny), add tirith-tier (session without always) and no-session-tier cases, and assert allow_session=True in the tirith gateway payload. * fix(desktop): wrap missing sidebar icon-button tooltips (#67500) * fix(desktop): wrap sidebar icon buttons in Tip tooltips Several icon-only buttons in the sidebar (header actions, workspace menu, project menu, session actions, load-more) had aria-label but no visual tooltip on hover. Wrap them in the existing <Tip> component, matching the pattern already used elsewhere (e.g. ProfilePill). No behavioral changes -- purely wraps existing buttons. Adds vitest coverage asserting the Tip wrapper (data-slot=tooltip-trigger) for 6 of 7 files; index.tsx is a 1500+ line top-level page component and was verified manually via screenshots instead. * fix(desktop): satisfy consistent-type-imports lint rule in project-dialog test * test(desktop): update session-row mocks for restored sessionColorById * fix(desktop): compose Tip around the real trigger instead of inside it Tip was being placed as SessionActionsMenu's/PlatformAvatar's DIRECT child, which asChild then cloned instead of the actual button/span. Neither Tip nor PlatformAvatar forwarded the injected onClick/ref, so both silently dropped the wiring: - session-actions-menu.tsx: Tip now wraps DropdownMenuTrigger internally (new ooltip prop) instead of the caller wrapping its children in Tip. - platform-icon.tsx: PlatformAvatar now forwards ref and spreads rest props onto its span so a wrapping Tip's trigger actually attaches. - session-row.tsx: updated call site to use the new tooltip prop. - Added session-actions-menu.test.tsx exercising the real DropdownMenu open behavior end-to-end (no Tip/Dropdown mocks). - session-row.test.tsx no longer mocks PlatformAvatar's behavior; it now exercises the real (fixed) component for the handoff-avatar tooltip. * fix(desktop): compose Tip outside PopoverAnchor in ProjectMenu (#67500) * test(desktop): update session-row test for the tooltip-prop composition (cbbbeb2fd) * fix(desktop): satisfy consistent-type-imports in session-row.test.tsx mocks * chore: retrigger CI * test(desktop): stop mocking PlatformAvatar's behavior (#67500, third pass) The mock was re-introduced by a prior edit that fixed an unrelated lint error, silently undoing the earlier fix where this test started exercising the real (forwardRef) PlatformAvatar. Removed the mock; updated the two handoff-avatar tests to query the real component's rendered span instead of text content, since it renders a brand SVG icon for known platforms rather than the platform name as text. * fmt(js): `npm run fix` on merge (#68867) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * fix(gateway): detect stale lock when macOS psutil returns valid start_time for recycled PID On macOS, the lock record's start_time is None (no /proc at creation), but psutil.Process(recycled_pid).create_time() returns a valid float for the unrelated process that now owns the PID. The old condition required both sides to be None before falling back to cmdline checking, so the recycled PID was never detected as stale. Change the fallback condition from AND to OR: when either side's start_time is missing, fall back to cmdline-based gateway detection. Fixes #53763 * fix(gateway): handle PermissionError on stale root-owned lock file When the macOS launchd service runs in a Background session, the gateway process spawns as root and creates a root-owned gateway.lock. On restart as the normal user, open() on that file raises PermissionError, crashing the gateway immediately and entering a launchd crash loop. Catch PermissionError in is_gateway_runtime_lock_active(), remove the stale lock file, and return False so the new process can start cleanly. Fixes #42685 * fix(gateway): guard acquire_gateway_runtime_lock against root-owned lock PermissionError Widen the PermissionError handling from is_gateway_runtime_lock_active (#42689) to the sibling open() in acquire_gateway_runtime_lock: a stale root-owned gateway.lock left by a launchd Background session previously crashed the acquiring process. Unlink the stale file and retry once; if the unlink or retry fails, return False cleanly instead of raising. * fix(gateway): make stale scoped-lock removal atomic via tombstone rename Replace the unlink()+O_EXCL sequence in acquire_scoped_lock with an atomic os.replace() of the stale lock to a <lock>.stale tombstone followed by the existing O_EXCL create. With plain unlink(), two racing starters could both judge the lock stale and the second unlink() would silently delete the first racer's freshly-created lock — both would then 'win'. os.replace() guarantees exactly one racer claims the stale file; the loser gets FileNotFoundError and falls through to O_EXCL, which admits at most one winner. Tombstones are cleaned up immediately; behavior is otherwise identical. * fix(gateway): detect stale gateway_state.json in `gateway status` (TTL + PID liveness) Verified: applies cleanly and the patched module compiles. Tests are described in the PR body (not bundled in this commit). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(gateway): cover stale gateway_state.json detection (TTL + PID liveness) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(gateway): take over live platform-lock token holders once When --replace misses a cross-HERMES_HOME Telegram token holder, platform connect used to retry forever. Terminate a verified gateway holder once (with the takeover marker) and re-acquire the scoped lock (#65176). Co-authored-by: Cursor <cursoragent@cursor.com> * chore(contributors): map jaretbottoms@gmail.com -> jbbottoms (PR #65178 salvage) * fix(gateway): reap the replaced gateway's orphaned children on POSIX Builds on jbbottoms's #65178 takeover fix (cherry-picked as the previous commit). Windows --replace already tree-kills via taskkill /T, but the POSIX paths signalled only the recorded gateway PID — adapter subprocesses that outlived their parent kept holding scoped token locks and blocked the replacement gateway. - gateway/status.py: _snapshot_gateway_children() captures the old gateway's descendants (psutil, recursive) while it is still alive; reap_gateway_children() SIGTERMs verified orphans after the main PID is confirmed dead, waits bounded, SIGKILLs survivors. Identity-aware (psutil is_running is PID+create-time), skips zombies and children whose ppid still equals the old gateway (parent actually alive), and never raises — best-effort with debug/info logging only. - take_over_scoped_lock_holder() snapshots before terminating and reaps only on a confirmed successful handoff. - gateway/run.py: start_gateway --replace snapshots before SIGTERM and reaps after the old PID is confirmed gone, mirroring taskkill /T. - tests/gateway/test_replace_child_reap.py: reap/skip/never-raise unit coverage plus end-to-end --replace ordering (snapshot → terminate → reap) and the no---replace path never touching the old process. * chore(contributors): map emails for PRs #66906, #66420, #63398 salvage * fix(state): probe FTS5 read path in _db_opens_cleanly so partial index corruption is detected (#66724) `hermes sessions repair --check-only` opens cleanly on state.db files with partial FTS5 index corruption — base tables read fine, the rolled-back write probe from #50502 succeeds, and `PRAGMA integrity_check` returns "ok". But every session_search / /resume title resolution / feature backed by MATCH / snippet / rank queries errors out with `database disk image is malformed` because internal shadow-table segments are bad. The official repair tool then gives false confidence. Add a representative FTS5 read probe against both `messages_fts` and `messages_fts_trigram` (the latter backs title resolution). Empty MATCH strings are accepted by every FTS5 index without requiring populated content, so the probe is safe on a freshly-init'd DB; missing-table / missing-column errors fall through to the existing "not yet a populated DB" branch, matching the write-probe's behaviour. Any other OperationalError is surfaced as the check reason, which sends `hermes sessions repair` to its existing FTS 'rebuild' path (repair_state_db_schema, line 616). Single-file change in hermes_state.py::_db_opens_cleanly. No public API change. No new imports. Fixes #66724. * fix(state): also catch sqlite3.DatabaseError in FTS5 read probe (#66724) The FTS5 read probe in _db_opens_cleanly() only caught sqlite3.OperationalError. But the corruption class #66724 actually wants caught — partial shadow-table damage where MATCH / snippet / rank queries raise DatabaseError("database disk image is malformed") — is a DatabaseError, not OperationalError. Without this catch the probe crashes the caller instead of returning a reason, which is exactly the silent-fail mode the issue describes. Move the try/except inside the for-loop so each FTS table is probed independently (one table corrupted should still surface as a reason), add a separate except clause for DatabaseError that surfaces the same reason format, and use continue instead of pass so the loop still walks both tables when only one is missing on a brand-new DB. Tested by hand: with a corrupted messages_fts_trigram shadow table the function now returns 'fts5 read probe failed on messages_fts_trigram: database disk image is malformed' instead of crashing out. Without this fix it would still crash. * fix(state): preserve degraded-runtime read probe + use canonical FTS5 classifier Two follow-ups on top off842733(the FTS5 read probe added in #66906): 1. The original probe query used MATCH '', which FTS5 rejects with 'fts5: syntax error near '. Empty MATCH syntax is not valid FTS5. Switch to MATCH '""' — a quoted empty phrase that parses, scans zero rows, and exercises the same shadow-table read path the search tools use. The probe previously never reached the shadow segments at all on a healthy DB; the read-corruption class was only being detected because the existing write probe happens to fail first on a DatabaseError. 2. The probe's degraded-runtime branch only checked the substrings 'no such table' / 'no such column'. On a SQLite build without the fts5 module, MATCH against a legacy messages_fts table raises 'no such module: fts5' (a different OperationalError class). The substring check would misclassify that as corruption and trigger repair, whose final fallback deletes the messages_fts% schema (#66906 review). Use SessionDB._is_fts5_unavailable_error() — the canonical classifier already used by the degraded-runtime init path — to recognize both 'no such module: fts5' and 'no such tokenizer: trigram' as capability errors. Add tests covering: - Partial shadow-table damage (read-corruption class) - Repair brings reads back online - Healthy degraded DB without fts5 module stays healthy (regression for the misclassification risk) - Healthy degraded DB without trigram tokenizer stays healthy Closes #66906 review feedback Refs #66724 * fix(state): self-heal FTS corruption on the SessionDB search path too Complements #66296 (self-heal on the write path): search_messages()'s main FTS5 MATCH query caught only sqlite3.OperationalError (a query-syntax error → return empty). A corrupt FTS index raises the malformed / "fts5: corrupt structure record" class, which is a sqlite3.DatabaseError — the parent of OperationalError, so it was NOT caught and propagated straight out of search_messages, crashing session/history search. The write path now rebuilds and retries on that class, but a read-only session (cron/CLI history search, or a search issued before any write) never triggers a write, so its search stayed broken until the next process restart ran the offline repair. Catch the DatabaseError corruption class on the search MATCH read too and route it through the existing one-shot _try_runtime_fts_rebuild(), then retry the query. The catch is moved outside `with self._lock` so rebuild_fts() can re-acquire the lock (mirrors _execute_write). The one-shot guard is shared with the write path, so a single instance never loops on a genuinely unrecoverable index. OperationalError syntax handling is unchanged (caught first). Adds a regression test: with a corrupted messages_fts and no post-corruption write, search_messages() rebuilds in place and returns the match; without the fix it raises DatabaseError. * fix(state): extend search-path FTS self-heal to the CJK/trigram branch The trigram MATCH branch in search_messages() had the same OperationalError-only catch that #66420 fixed on the main FTS5 branch: a corrupt messages_fts_trigram shadow table raises the malformed / 'fts5: corrupt structure record' class (sqlite3.DatabaseError, parent of OperationalError), which propagated straight out of search_messages and crashed CJK session/history search for read-only sessions. Route that class through the shared one-shot _try_runtime_fts_rebuild() and retry the trigram query (catch moved outside self._lock so rebuild_fts() can re-acquire it, mirroring the main branch). If the rebuild is refused (guard consumed / FTS disabled / different error) or the retry fails, fall through to the existing LIKE substring fallback — which reads only the canonical messages table — instead of raising, so CJK search degrades gracefully rather than crashing. Adds two regression tests: trigram search self-heals in place after shadow-table corruption (answers from the rebuilt trigram index, not the LIKE fallback), and degrades to LIKE without raising when the one-shot rebuild was already consumed. Follow-up to #66420; refs #66296 #66724 * fix(state): add REINDEX strategy to repair stale B-tree indexes (#63386) When PRAGMA integrity_check reports 'wrong # of entries in index' for B-tree indexes (e.g. idx_sessions_handoff_state), the existing repair strategies (FTS rebuild, sqlite_master dedup, drop-FTS+VACUUM) don't address the mismatch. Add Strategy 0.5: run REINDEX to rewrite the index b-tree from canonical table rows before escalating to more destructive strategies. * test(state): exercise REINDEX repair against a REAL stale B-tree index Replace the mocked test for #63398's REINDEX strategy: the original monkeypatched _db_opens_cleanly to return the corruption string, so the REINDEX pass itself was never exercised against actual index corruption — the test would pass even if REINDEX didn't fix anything. New fixture _corrupt_btree_index() builds genuine on-disk staleness with a writable_schema hack: rewrite the index definition to a partial index (WHERE 0), REINDEX so the b-tree is rebuilt empty, then restore the full definition. integrity_check then reports the real 'wrong # of entries in index idx_messages_session' / 'row N missing from index' class from #63386 — no mocks anywhere. The rewritten test asserts end-to-end with real function calls: - the real _db_opens_cleanly detects the stale index, - repair_state_db_schema repairs it with strategy 'reindex_btree', - post-repair the detector and raw PRAGMA integrity_check both report healthy, and a query forced through the rebuilt index (INDEXED BY) sees every row. Adds a second test asserting the REINDEX strategy is non-destructive (all sessions/messages survive, readable via SessionDB). Follow-up to #63398; refs #63386 * fix(kanban): auto-repair index-only kanban.db corruption via REINDEX _guard_existing_db_is_healthy previously failed closed on ANY integrity_check failure, including the index-scoped class ('wrong # of entries in index <name>' / 'row N missing from index <name>') where the table b-trees are intact and REINDEX rebuilds the damaged indexes losslessly. Boards hit by that class were bricked until manual surgery even though SQLite can fix them in-place. Now, when integrity_check output consists ONLY of index-scoped errors (index name parsed generically from the message — no hardcoded list): 1. quarantine the corrupt bytes FIRST via the existing content- addressed _backup_corrupt_db, 2. under the caller-held cross-process init flock, REINDEX each named index (falling back to bare REINDEX if a parsed name doesn't resolve), 3. re-run integrity_check and proceed only if it comes back clean. Any non-index error class (page corruption, malformed image, freelist damage) — or a REINDEX whose re-check is still dirty — fails closed exactly as before: backup + KanbanDbCorruptError, no silent recreation. Transient OperationalError (locked/busy) still propagates raw with no quarantine. Tests build a real board DB and corrupt a live index via the writable_schema/partial-index REINDEX trick to produce the genuine 'wrong # of entries in index' shape, then assert auto-repair recovers with data intact, page corruption still raises, and a dirty re-check fails closed. * fix(kanban): cap corrupt-backup retention at 10 files per board DB Content-addressed quarantine backups dedupe identical corrupt bytes, but corruption that keeps mutating between failures (partial repairs, further damage across dispatcher retries, multi-profile fleets) mints a new sha-named backup every round — a user accumulated 124 .corrupt.*.bak files with no bound. After each NEW backup is created, prune oldest-by-mtime backups beyond _CORRUPT_BACKUP_RETENTION (module constant, default 10), including the copied -wal/-shm sidecars. The just-created backup is always exempt (copy2 preserves the source mtime, which can be older than existing backups). Pruning is best-effort and never masks the corruption error about to be raised; dedupe of identical corrupt bytes is unchanged. * feat(kanban): periodic WAL checkpoint (TRUNCATE) on the dispatcher tick Kanban connections set wal_autocheckpoint=100, but SQLite's passive autocheckpoint backs off whenever any reader holds an open snapshot — on a busy multi-process board the -wal file can grow without bound between gateway restarts. After each successful dispatch tick, while still holding the board's single-writer dispatch flock, run PRAGMA wal_checkpoint(TRUNCATE) best-effort at a coarse interval (>=5 min since this process last checkpointed that board; module-level per-path monotonic timestamp, so multi-board dispatchers checkpoint each board on its own clock). Success and busy/locked skips are both logged at DEBUG; a failing checkpoint can never fail the tick. * feat(kanban): add `hermes kanban repair` CLI verb Adds kanban_db.repair_db() — a structured, non-raising wrapper around the same narrow repair policy as the connect-time guard: probe with PRAGMA integrity_check under the board's cross-process init flock; quarantine the corrupt bytes FIRST via the content-addressed backup; REINDEX only when every integrity message is index-scoped; re-check; report ok / repaired / corrupt / missing. Locked/busy OperationalError still propagates raw (a locked healthy DB is not corruption and gets no quarantine), and a repair invalidates the per-process healthy-path cache so the next connect() re-probes. The CLI verb reports status human-readably (or --json), exits 0 for ok/repaired/missing and 1 when the DB is still corrupt (non-index corruption stays fail-closed with manual-recovery guidance). It dispatches BEFORE kanban_command's auto-init: init_db() raises KanbanDbCorruptError on a corrupt board, which previously would have made a repair verb unreachable on exactly the boards that need it. CLI tests drive the real argparse surface (build_parser + kanban_command) against real corrupted SQLite fixtures. * fix(packaging): graft web_dist in MANIFEST.in and add sdist regression test Wheels ship hermes_cli/web_dist via pyproject package-data, but the sdist did not: MANIFEST.in had no graft and .gitignore excludes web_dist, so source tarballs installed a dashboard-less package. Graft the directory and add an sdist regression test that builds the tarball and asserts index.html is inside. Salvaged from #29661; the PR's [web]-extra 404-message change was dropped per maintainer review (misleading guidance for source installs). * fix(dashboard): attempt one recovery build when --skip-build finds no dist --skip-build with a missing web_dist/index.html previously hard-failed with sys.exit(1) (issue #59288). The desktop launcher passes --build-mode skip on every boot, so a wiped or never-populated dist bricked the dashboard until the user manually rebuilt. Now the default-dist path logs a clear warning and attempts exactly ONE recovery build through the existing _build_web_ui path. If the recovery build also fails to produce index.html, the original fatal behavior is preserved with a clear message. A custom HERMES_WEB_DIST stays fail-fast: the build writes to the default dist location and cannot populate a caller-managed directory. Closes #59288 * fix(web): guard _serve_index against a missing or unreadable index.html mount_spa degrades to a JSON 404 catch-all when the dist directory is fully missing, but _serve_index read index.html unguarded — so a dist dir that exists while index.html is missing (partial build, wiped dist, permissions) raised FileNotFoundError on EVERY request instead of returning a useful error. Catch OSError around the read and return the same JSON 404 payload the fully-missing-dist path uses, so clients get a consistent signal. The route recovers automatically once a rebuild restores the file. * fix(dashboard): content-hash web UI freshness check Replace mtime-based web UI dist staleness with a content-hash stamp under HERMES_HOME, matching the desktop build freshness model. This avoids false stale/fresh decisions when git operations rewrite source mtimes without changing bytes, while preserving the existing stale-dist fallback. Also treats malformed or missing stamp data as needing a rebuild. * fix(cli): serialize concurrent web-UI builds with an exclusive flock Concurrent dashboard boots (desktop retry loop) each spawned their own npm install + vite build over the same tree; parallel builds starved each other, the dist sentinel never advanced, and every boot re-triggered the build — cascading into orphan backends, port collisions, and CPU storms that also knocked out the Telegram gateway's heartbeat (2026-07-12). One process now builds under flock; the rest serve the existing dist (stale is acceptable) or wait for the first-ever build to finish. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(cli): run the web-UI staleness walk once, under the build lock The flock wrapper checked _web_ui_build_needed twice (pre-lock fast path and post-lock re-check) and _do_build_web_ui checks it again internally, so a boot that actually built walked the whole web/ source tree three times. The callee's own check already runs under the lock on every path through the wrapper, so it alone is sufficient: drop both wrapper checks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(git): ignore the web UI build lock file The cross-process build flock (.web_ui_build.lock at the repo root) is an empty coordination file created on first dashboard boot; it must never be tracked or show up as untracked noise. Also keeps it out of the content-hash staleness digest, which skips gitignored paths. * test(cli): cover the web UI build flock contention paths PR #63455 shipped the flock without tests. Cover the three contention paths: contended+dist serves stale without a second build, uncontended builds under the lock (creating the lock file), and contended+no-dist blocks then skips the rebuild because the staleness re-check runs under the lock and sees the winner's stamp. Also pin the lock filename into .gitignore via a regression assertion. * style(cli): open the build lock file with explicit encoding (ruff PLW1514) * chore(contributors): map eazye19@users.noreply.github.com -> eazye19 (PR #42387 salvage) * fix(mcp): reconnect immediately on transport TaskGroup drop instead of backoff/park Streamable-HTTP / SSE MCP transports run their stream pump inside an anyio TaskGroup. A transient stream drop (idle timeout, brief backend blip, server-side TCP close) surfaces as a BaseExceptionGroup escaping the transport context manager. It reached run()'s error path, which applied exponential backoff (1s..16s) and eventually parked the server for 300s and deregistered its tools — turning a sub-second glitch into a multi-minute tool outage even though the POST path was still healthy. _run_http now wraps all three transport branches (SSE, new + deprecated Streamable-HTTP) and routes a BaseExceptionGroup through a new _reconnect_or_reraise_group() helper that returns 'reconnect' (immediate rebuild, no backoff/park/deregister). It re-raises instead of masking when shutdown is in progress, when the group carries a real CancelledError (cancellation must propagate, cf. #9930), or when no live session was established this attempt (a connect/handshake failure that should fall through to run()'s backoff rather than hot-loop). Fixes #66092 Co-authored-by: 雨哥 <hanyu1212@users.noreply.github.com> * fix(mcp): charge immediate reconnects against a rapid-drop budget (#62212) Harden the immediate-reconnect path from #66271: - _reconnect_or_reraise_group re-raises when the transport TaskGroup carries a KeyboardInterrupt / SystemExit leaf — fatal signals must propagate to the interpreter, never be converted into a reconnect. - Rapid-drop budget: a completed handshake alone no longer clears _reconnect_retries/backoff on clean transport return. A session is UNPROVEN until it demonstrates real health — survived >=1 full keepalive interval (keepalive success path) or served >=1 successful tool call (_mark_session_proven). Unproven clean returns are charged against _MAX_RECONNECT_RETRIES, so a flapping transport that handshakes fine and drops moments later still reaches the park instead of respawning forever (#62212: 6212 spawns in 63h). Proven sessions keep the #57604 behaviour: budget clears, transient blips over a long-lived session never accumulate toward parking. * fix(mcp): unwrap exception groups and park permanent failures immediately (#65673) - Add module-level _unwrap_exception_group (ported from hermes_cli/mcp_config.py, adapted): handles nested groups, prefers non-cancellation leaves over the CancelledErrors anyio sprays across sibling tasks, and re-raises KeyboardInterrupt/SystemExit leaves. - Add _classify_mcp_failure(exc) -> 'permanent'|'transient'. Permanent: auth 401/403, NonMcpEndpointError, InvalidMcpUrlError, FileNotFoundError/ENOENT on the stdio command. Transient: everything else (network/EOF/ClosedResource/TaskGroup drops). Permanent failures park immediately without burning the retry ladder — every retry against a missing binary or a revoked credential hits the same wall. - Every log site in run() and the keepalive failure log now logs the unwrapped root cause as 'TypeName: message', so a dead stdio pipe says 'BrokenPipeError' instead of the opaque 'unhandled errors in a TaskGroup (1 sub-exception)' (and empty str(exc) dead-pipe errors are no longer blank). * fix(mcp): one WARNING per state transition, DEBUG retry chatter, jittered backoff Log-storm hygiene for the reconnect machinery (#65673, #66092): - State transitions carry exactly one WARNING each: connected → degraded (keepalive failure), degraded → parked (budget exhausted / rapid-drop park / permanent error), parked → connected (revival proven healthy, via _mark_session_proven). - Per-attempt retry logs (initial-connect attempts, connection-lost reconnect attempts, per-cycle rebuild notices, park-wake probes) demoted to DEBUG. - Backoff sleeps get +/-20% uniform jitter (_jittered) so a herd of servers that lost the same backend doesn't retry — and log — in lockstep. * fix(mcp): re-register tools during parked revival * fix(mcp): isolate a single failing stdio server from the bridge (#50394) * fix(mcp): clear connect-cooldown state on every shutdown path Builds on trevorgordon981's #50589 (cherry-picked as the previous commit). The #50394 cooldown reset only ran inside the async _shutdown coroutine, which is skipped on the empty-_servers fast path — the most common state when a server failed to connect (failed servers are never recorded in _servers). It was also skipped when the MCP loop wasn't running. Clear _server_connect_retry_after/_server_connect_failures on the fast path and in a final unconditional sweep so a full shutdown/restart always re-attempts every configured server immediately. Adds regression tests for both paths. * chore(contributors): map diffen77 + fazerluga-creator emails For the #66547 and #66981 salvage cherry-picks in this branch. * fix(mcp): reconnect message-less closed transports * test(mcp): isolate resource error breaker state * fix(mcp): harden nested interruption detection * fix(mcp): make transport classification cycle safe * fix(mcp): cycle-guard cause/context traversal in transport classifier Builds on diffen77's #66547 (cherry-picked as the previous commits). Extend _is_session_expired_error's iterative traversal to follow __cause__/__context__ in addition to ExceptionGroup .exceptions — SDK wrappers often raise a generic RuntimeError *from* the message-less ClosedResourceError, leaving the transport signal reachable only via the chain. The identity-visited set guards chain cycles (handlers re-raising previously seen exceptions), and a bounded node budget (_EXC_TRAVERSAL_MAX_NODES) caps pathological acyclic graphs. Adds regression tests: cause/context chain detection, interruption precedence through chains, cyclic cause/context termination, and budget-bounded termination. * fix(mcp): allow background discovery retry after a run that connected nothing start_background_mcp_discovery() sets _mcp_discovery_started once and never resets it. If the first background run exits without connecting any MCP server (startup cancellation, OOM restart, transient network failure), every later call returns immediately and the process is permanently stuck with zero MCP tools until a full restart. Fix: when discovery is marked started but the thread is dead and no server is connected, reset the flag and spawn a fresh discovery thread. Also log a WARNING when a discovery run completes with zero connected servers, so the condition is visible instead of silent. Caught in production on a long-running gateway fleet where a gateway restarted under memory pressure and came back with all MCP tools missing. * fix(tui): centralize stdio TUI MCP discovery on the shared owner Review follow-up: the stdio hermes --tui path spawned its own one-shot discovery thread, so the retry-after-zero-connected semantics added to start_background_mcp_discovery() did not cover it. Spawn TUI discovery through the shared owner and make the entry-side wait_for_mcp_discovery() fall through to the shared owner when no local thread exists (mcp_discovery_in_flight/join_mcp_discovery already consult both owners). Keeps the cheap no-mcp-servers config guard on the TUI path. Adds regression tests for the entry-side wait delegation. * fix(tui): give the stdio TUI discovery a retry-after-zero-connected path Builds on fazerluga-creator's #66981 (cherry-picked as the previous two commits). start_background_mcp_discovery()'s retry allowance only fires when the function is CALLED again, but tui_gateway/entry.py main() calls it exactly once at startup — so a first discovery run that connected nothing still latched the stdio TUI MCP-less for the whole session. Re-invoke the idempotent spawn from wait_for_mcp_discovery() (the per-agent-build wait) when the process is MCP-enabled, gated on a flag set in main() so non-MCP sessions never pay the MCP import on the wait path. Adds regression tests for both the retry re-invocation and the non-MCP skip. * fix(tui): gate the shared-owner MCP discovery wait on the stdio TUI flag The TUI retry-allowance follow-up made tui_gateway.entry.wait_for_mcp_discovery delegate to hermes_cli.mcp_startup unconditionally when no entry-local thread exists. But server._make_agent already calls the startup wait directly for dashboard /api/ws sessions, so every non-stdio agent build paid the bounded wait twice (caught by test_make_agent_waits_for_shared_mcp_discovery). Gate the retry-spawn AND the delegated wait on _mcp_discovery_enabled, which only the stdio TUI arms in main(). * fix(status-bar): address Copilot review on /battery - TUI /battery matches the CLI surface: adds `status` (live reading via system.battery), and the help/usage strings now consistently read [on|off|status]. - batteryLabel() renders `--` for an unknown percent so a null can never surface as "null%" even without the showBattery guard. - Move the system.battery RPC out of the config section into "Methods: tools & system" where system.* RPCs belong. * fix(dashboard): cap gateway health probe timeout * fix(gateway): report runtime source version in /health, not stale dist-info metadata /health and /health/detailed resolve the version via importlib.metadata first, falling back to hermes_cli.__version__. On editable/source checkouts — including the standard git-based install that hermes-setup performs — hermes_agent-*.dist-info can survive a source update unchanged, so the health endpoints keep reporting the previous release even though the running code (CLI, dashboard, release tags) is newer. Stale metadata does not raise, so the source fallback never fires. Flip the preference: use hermes_cli.__version__ (the runtime source of truth shared by the CLI and dashboard) first, and fall back to distribution metadata only when the source import fails. The never-raise contract of the version probe is unchanged. Observed live: CLI, dashboard, and pyproject all reported 0.18.2 while /health returned 0.18.0 from a stale hermes_agent-0.18.0.dist-info left behind by a source update. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api-server): count "stopping" runs as active in readiness work counts _readiness_work_counts()'s active_api_runs set is {"queued", "running", "waiting_for_approval"} — it excludes "stopping", the status _handle_stop_run() sets while a run is being interrupted. Since the stop is fully cooperative (the run stays "stopping" — doing real executor-thread work — until the agent actually notices the interrupt and the task settles to "cancelled", an unbounded window, not a fixed timeout), /health/detailed's background_queues.active_api_runs undercounts real active work for that whole duration. Fix: add "stopping" to the active-status set. background_queues.status itself is hardcoded "ok" (gateway/readiness.py), so this doesn't change overall readiness — it only corrects the count value external monitoring tooling reads from this endpoint. * chore(contributors): map yingwaizhiying@gmail.com -> tianma-if (supersedes stale msh01 entry) * feat(dashboard): component-level health rollup on /api/status (#68662) The dashboard's own liveness surface could report healthy while every authenticated request 500'd (e.g. wedged state DB) — /api/status carried gateway-only fields, no storage/dashboard signal, and no middleware counted unhandled exceptions. - DashboardHealth state holder: rolling 5-min deque of unhandled-error timestamps + last self-test result. last_error_type/last_error_path are internal-only diagnostics — snapshot() exports counts/enums/ timestamps exclusively (PUBLIC_API_PATHS no-secrets contract). - Outermost @app.middleware('http') (registered last) wraps call_next in try/except: records + re-raises unhandled exceptions, and records responses with status >= 500. - /api/status gains 'components' {gateway, storage, dashboard, platforms} + top-level 'overall' ok|degraded. storage reuses the gateway readiness state_db probe (read-only, 1s-bounded) in an executor; platforms derive ok/degraded from existing gateway_platforms states. - Authenticated self-test task started in the lifespan: every 60s an in-process httpx ASGITransport GET of /api/sessions?limit=1 with the real _SESSION_TOKEN, feeding the dashboard component. Skips cleanly when httpx is unavailable and while the OAuth gate is engaged (the legacy token is not honoured there). Tests: middleware increments on raising route and on 5xx, window expiry, components shape + overall, storage degraded when the state_db probe fails, dashboard degraded after an error, no secret-bearing fields in the public payload, self-test pass/fail recording (mocked client) + a real ASGI round trip. * fix(status): make gateway_updated_at a stable RFC3339-or-null contract (#68657) The /api/status gateway_updated_at field and the gateway /health/detailed updated_at field passed through whatever gateway_state.json contained, untyped. All current writers emit RFC3339 via _utc_now_iso(), but legacy gateways wrote unix epoch floats, and a corrupt or hand-edited state file can inject numbers or arbitrary garbage — while the frontend types (web/src/lib/api.ts) declare string | null. Add normalize_updated_at() in gateway/status.py as the single funnel: - str: accepted iff datetime.fromisoformat parses (trailing Z tolerated); naive timestamps coerced to UTC; canonical isoformat returned - int/float: treated as unix epoch seconds -> UTC ISO string, with a plausibility guard (reject < 2000-01-01, > now+1day, non-finite) - bool: rejected explicitly (int subclass, but never a timestamp) - anything else: None Apply it at both emit sites: the dashboard /api/status handler (covers both the local read_runtime_status() branch and the remote /health/detailed cross-container fallback branch) and the gateway API server's /health/detailed response. Contract tests: parametrized /api/status normalization (epoch float/int, garbage string, None, bool, dict, absent key), remote-health numeric and garbage bodies, dashboard shape test round-trip assertion, direct normalize_updated_at units (range guards, Z suffix, naive coercion, non-finite floats), and a write_runtime_status -> read_runtime_status round-trip proving the writer side stays tz-aware parseable. * feat(desktop): type-to-focus the composer from empty chat chrome Printable keys and soft `/`/Enter pull focus back to the composer when nothing else owns the key (dialogs, terminal, buttons on Enter, …). * feat(ui-tui): widget-grid 2-axis layout engine + overlay primitives (#20379 rebase) Rebase of the widget-grid PR onto current main, then grow it into a real 2-axis engine: resolveGridTracks (grid-template tracks — fixed cell counts and weighted fr shares with mins), layoutWidgetGrid (1D auto-packing flow), and layoutGridAreas (2D absolute placement with row/col spans and implicit row growth). Overlay/Dialog primitives give zoned viewport-level modals with an optional scrim. /grid-test (interactive: areas, nesting, zoom, gap/padding) and /grid-test streams (4x3 mission-control GridAreas board with promote-to-main) exercise everything end to end. * refactor(ui-tui): route production surfaces through the widget-grid engine Banner (responsive tiers: full logo -> compact rule -> text -> hidden), SessionPanel (fixed hero track + flexible info track, the desktop pane shell's fixed-vs-flex contract), floating overlays, prompt zone, and the pickers all render through WidgetGrid instead of hand-rolled flex math. Pickers gain maxWidth so grid cells can cap them. * feat(ui-tui): background-aware theme adaptation + paired palettes + /theme pin OSC-11 asks the terminal for its actual background at startup (env heuristics are blind on xterm.js hosts) and the theme re-derives against the answer: desktop-contract adaptation (contrast floors + fill polarity), a shared list-row selection primitive instead of per-picker panel fills, paired light_colors/dark_colors skin blocks with a machine audit, and a /theme auto|light|dark pin (display.tui_theme) for hosts whose probe lies. E2E coverage for the OSC reply chain + /theme-info diagnostics. * feat(ui-tui): theme engine — seeds to derived-tone ladder + flash-free boot The desktop color-mix system, ported: a theme is a handful of identity SEEDS (text, primary, accent, border, status hues); every secondary tone (muted, label, surfaces, chips, selection) is a color-mix derivative against the real terminal background. lib/color.ts consolidates the primitives (parse/mix/luminance/contrast/retone + xterm.js's multiplicative liftForContrast). Knobs are grid-search fitted so the math reproduces the classic hand-tuned literals (contract-tested). The display shim renders authored palettes RAW and only rescues near-invisible colors. Boot reads the last resolved theme from $HERMES_HOME/tui-theme-boot.json so the first frame paints in the right palette (no default-dark flash), and the placeholder cursor follows the bubbles textinput pattern. * perf(tui): skin switches stay hot — MCP gating, pooled reload, swap repaint Four responsiveness/hardening fixes surfaced by rapid /skin switching: config.get mtime now carries an mcp_rev hash so the TUI reloads MCP only when MCP-relevant config changed (cosmetic /skin writes cost seconds of reconnects before); reload.mcp runs on the RPC pool serialized by a lock (inline it froze the stdio reader for the duration of a flapping server's retry loop — config.set/complete.slash sat unread and the TUI appeared dead); theme swaps schedule one full repaint (incremental diffs after a recolor tear — stale cells keep the old palette, read as "shadows"); and OSC-11 pure-black answers are distrusted universally (unset-default fingerprint on xterm.js hosts and tmux). Parent EIO zombie fixed: a dead PTY made every render write throw once a second forever; exit after 5 consecutive dead-stream errors. * fix(ui-tui): light mode renders the vivid palette RAW, not WCAG-darkened Pixel-sampled against the reference screenshots: the beloved classic light-mode look is the vivid authored golds rendered essentially raw (#FFD700 shows as bright #F5C242) — transparent terminal profiles apply no contrast lift of their own, and pre-darkening every foreground to WCAG 4.5 produced the reported mustard mud. Light-mode display floors become near-invisible rescues only (1.18 display / 1.6 semantic; dark keeps 1.45/2.2); the default skin ships a fills-only light_colors OVERLAY (polarity-flip the navy menu/status fills, foregrounds inherit the vivid colors), and themeForSkin merges polarity overlays instead of replacing. Palette audit reworked: base colors audited fully, overlays for valid keys and fill polarity. * fix(ui-tui): eradicate every transparent-terminal black-slab trigger On terminal.background #00000000 xterm paints "drawn blank" and attribute-styled cells against an opaque black RGB the user never sees elsewhere. Verified by PTY byte capture + stateful SGR replay that we emit no black backgrounds — then removed every trigger: the banner's opaque space-fills, the scrollbar's non-scrollable space column and SGR-dim track, the placeholder's SGR inverse cursor and dim fallback, and the bold full-width banner rule. Chrome styling is now explicit truecolor only: scrollbar thumb rides primary (accent on hover/drag) over a blended track, the placeholder cursor is a theme-colored chip (48;2 bg + luminance-picked ink), hints always carry an explicit 38;2 foreground. * fix(ui-tui): session-panel hierarchy + polarity-proof placeholder tone Tool/skill rows rendered labels in muted and member lists in text — which inverts per skin (muted is the strong family tone on gold skins but the weak gray on blue ones; slate/poseidon read backwards). Labels now lead in the theme's label tone, values recede via an explicit fade toward the surface; audited across all 9 built-ins x both poles. The composer placeholder lands on muted — the "(and N more toolsets…)" tone — a mid-luminance family color that reads receded on both poles even when polarity detection is wrong. * feat(ui-tui): OSC-10 foreground polarity tiebreaker for transparent terminals Transparent profiles make OSC-11 useless (xterm reports the unset default, pure black, regardless of the composited surface) so polarity detection lagged editor theme flips. OSC-10 reports the theme's REAL foreground on those hosts — its luminance reveals the pole. hermes-ink grows a foreground slot (shared reportedColorSlot factory), App.tsx queries both in the startup batch (background first so a trusted answer wins without churn), and the app commits an inferred pole only when the background was distrusted AND the foreground is decisive (bright=dark theme, dark=light; mid-grays and #000/#fff defaults commit nothing). User pins still outrank. * fix(ui-tui): session-panel fade anchors on muted, not the surface — readable on every pole mix(text, surface) was invisible whenever text was already pale (the light-rendered default: cream blended toward white = nothing) and inherited wrong-polarity detection through the surface. mix(muted, text, .5) is pole-proof: muted is mid-luminance by construction, so the midpoint stays readable everywhere. Audited 9 skins x both poles: 2.0-2.9:1 on white, 5.6-9.3:1 on dark (worst case 1.92 for a light-authored skin on dark). * style(skins): default light overlay = goldenrod ladder, not neon or mustard On white, the vivid #FFD700/#FFBF00 read as glare and the WCAG-darkened mustard reads as mud. The sweet spot is the statusbar's goldenrod family (hue kept, saturation tamed, mid luminance): title #C8961E, headers #D89B04, labels #A97E10, muted #B8860B unchanged, warm bronze ink body #5C4718, deepened semantics + shell blue. Hierarchy on white: ink 8.9:1 > fade 5.2 > label 3.7 > muted 3.3 > title 2.7 > headers 2.4. Dark mode renders the vivid block untouched (explicitly approved as-is). * refactor(ui-tui): DRY the chrome primitives — shared scrollbarColors + hintRgb scrollbarColors(t, hover, grabbed) in overlayPrimitives is now THE scheme for both scrollbars (was duplicated formulas); textInput's hex parsing collapses into one hintRgb helper feeding colorizeHint and hintCursorCell. Comment bloat trimmed. No behavior change — 1243 tests byte-green. * fix(ui-tui): completions popover — aligned name track + neutral descriptions Two-column grid: the name track auto-sizes to the widest visible command so descriptions align in their own column instead of running under the names. Descriptions render in the neutral statusFg gray — label and muted are near-twins on the gold skins, which made command and description read as one unparseable run. * refactor(ui-tui): every selectable list rides the shared selection chip — zero inverse left /agents, /journey, model picker, skills hub, plugins hub, pet picker, and the approval/confirm prompts all used SGR inverse for the active row — terminal-interpreted against unknowable defaults (black slab on transparent profiles) and visually divergent from completions/session-switcher. New spreadable chipRowProps(t, active) in overlayPrimitives (chip bg + lifted ink + bold; spread after `color` so chip ink wins) converts each site to the one selection treatment. rg confirms zero inverse={} remaining in components/. * fix(ui-tui): overlay width caps are absolute — clampOverlayWidth (Copilot review) Every picker/hub forced width >= 24 AFTER applying the caller's maxWidth, so a grid cell narrower than 24 (or a FloatBox cell under 28 with its 4 cols of chrome) overflowed and clipped at the terminal edge. One shared clampOverlayWidth(preferred, maxWidth, min=24): the caller's cap is ABSOLUTE (a cell knows its budget), the usability floor applies only when the cap allows it, uncapped keeps the old floor semantics. Five call sites (model/pet pickers, skills/plugins hubs, session switcher) route through it; the grid-test FloatBox drops its own 24 floor. Contract-tested including the sub-floor cap case from the review. * fix(tui): reload.mcp lock scope + coalesced refresh + macOS polarity flip (Bugbot) Three real findings from the review of the reload.mcp pooling + OSC-10 work: - Lock released too early: the leader now holds _mcp_reload_lock across shutdown+discover AND its own agent refresh — releasing after discover let a second reload tear the registry down while the first was still reading it to rebuild the session's tool snapshot. - Coalesced reload skipped the agent refresh: a follower returned "reloaded" without rebuilding ITS OWN session's snapshot, so a coalesced session kept stale tools. Followers now wait, then refresh their agent against the freshly-built registry (under the lock, skipping the redundant shutdown/discover). Shared _finish_reload tail for the `always` opt-out. - macOS AppleInterfaceStyle fallback never set `resolved`, so a late OSC-10 foreground reply could re-flip the committed inference (visible churn). It now marks resolved; a real OSC-11 background measurement still corrects it (that listener intentionally doesn't gate on resolved — measurement beats inference). Bugbot's other four findings were diff-truncation false positives (color.ts, themeBoot.ts, and the mcp_rev/light_colors/dark_colors types all exist; typecheck + build green). 384 tui_gateway + TS suites pass. * fix(tui): mcp_rev includes mcp_servers; reload survives leader failure; /theme persists first (Bugbot r2) - mcp_rev hash now covers `mcp_servers` (the server DEFINITIONS the classic CLI watches) alongside `mcp`/`tools` — editing a server previously bumped mtime but not mcp_rev, so the TUI skipped reload.mcp and new servers never connected until a manual /reload-mcp. - reload.mcp coalescing survives a failed leader: a completed-generation counter (bumped only after a successful shutdown+discover) gates the follower. If the leader threw (flapping server) the follower re-runs the full reload itself instead of returning a bogus success over an empty registry. - /theme applies AFTER config.set confirms (mirrors /indicator) with a guardedErr catch — a failed persist no longer leaves the session showing a theme that reverts on restart. 384 tui_gateway + 1246 TS tests, lint, build green. * fix(ui-tui): first-swap repaint + config-auto respects shell theme pin (Bugbot r3) - commitTheme compares the first commit against the SEED theme uiStore mounted with (boot cache/default), not null — a cold start that resolves to a skin differing from the default previously skipped the anti-tearing forceRedraw on that first swap. - applyConfiguredTuiTheme('auto') now clears only a pin CONFIG set (tracked via configPinnedTheme), never a HERMES_TUI_THEME the user exported in their shell — that env override outranks auto-detection per detectLightMode's documented priority, and a config hydrate was wiping it. (Bugbot's recurring "GatewaySkin missing paired palette fields" is a diff-truncation false positive — gatewayTypes.ts declares light_colors/ dark_colors, typecheck green.) 81 handler + build/lint green. * fix: restore package-lock.json @esbuild platform entries (Bugbot r4) The branch had stripped every node_modules/@esbuild/* optional-platform entry from the lockfile (442 deletions, 0 additions) — collateral from an earlier local @esbuild/darwin-arm64 reinstall that rewrote the lockfile to this machine's platform only. esbuild still declares them as optionalDependencies, so `npm ci` on Linux CI would skip the platform binary and break Vitest / ui-tui builds. No dependency was added on this branch (the only package.json delta is a dev `visual` script), so the lockfile is restored to match main exactly. * fix(ui-tui): seed mcp_rev baseline at startup so first cosmetic write doesn't reload MCP (Bugbot r5) The startup config.get seeded mtimeRef but not mcpRevRef; after a normal boot mtime is already non-zero so the poller's baseline branch never runs, leaving mcpRevRef empty. The first /skin (or other cosmetic) write bumped mtime with an unchanged mcp_rev, and empty !== hash tripped a full reload.mcp — exactly the reconnect this optimization removes. Seed the revision alongside mtime at boot. * fix(ui-tui): record config theme pin even when env already matches (Bugbot r6) Regression from the r3 shell-pin fix: applyConfiguredTuiTheme('light'|'dark') returned early when HERMES_TUI_THEME already matched, leaving configPinnedTheme false — so a later '/theme auto' refused to clear the pin and the session stayed forced to the old mode while config said auto. Set configPinnedTheme before the match short-circuit. Bugbot's other three r6 findings (GatewaySkin paired-palette fields, ConfigMtimeResponse.mcp_rev, picker maxWidth props) are diff-truncation false positives — all declared in gatewayTypes.ts / the picker prop interfaces; typecheck is green. * feat(ui-tui): shimmer skeleton for the lazy tools section The lazy-loaded Available Tools section rendered a BLANK gap that popped when data landed. It now shows animated shimmer rows shaped like the real content (label block + value run, diagonal band sweep), and the summary line prints "… tools · … skills" instead of "0 tools · 0 skills" while counts load. components/loaders.tsx ships the primitives (shimmerSegments band math, Shimmer, useShimmerPhase, ShimmerRows) — colors are caller-owned theme tones, one interval per composition. Cherry-picked from the SDK-shape branch so the skeleton lands with this PR; the widget-SDK consumers stay in the follow-up. * fmt(js): `npm run fix` on merge (#68938) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * fmt(js): `npm run fix` on merge (#68976) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * fmt(js): `npm run fix` on merge (#68977) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * test(mcp): stamp breaker-open time on the monotonic clock, not a literal (#69003) test_session_expired_retry_waits_for_new_session hardcoded _server_breaker_opened_at["hindsight"] = 123.0 to simulate a circuit breaker whose cooldown has already elapsed. But the breaker in tools/mcp_tool.py compares that stamp against time.monotonic() (age = monotonic() - opened_at, elapsed when age >= _CIRCUIT_BREAKER_COOLDOWN_SEC). time.monotonic()'s origin is arbitrary and small on a freshly-booted CI container, so age worked out to only a few seconds there (< the 60s cooldown) — the breaker stayed open, the half-open probe never fired, and the retry returned the "unreachable" error instead of "bank ok". It passed on long-uptime dev boxes (large monotonic) and failed under CI, with the reported "Auto-retry available in ~Ns" drifting run to run as the container's monotonic clock varied. Stamp opened_at relative to the same clock the code reads (time.monotonic() - _CIRCUIT_BREAKER_COOLDOWN_SEC - 1.0) so the cooldown is provably elapsed regardless of the monotonic origin, exercising the intended half-open transition deterministically. * fix(windows): share one bounded, tree-killing git probe across both call sites (#68997) subprocess.run(["git", ...], timeout=...) deadlocks on Windows: run()'s post-timeout cleanup calls an unbounded communicate() after killing git. Killing the PATH-resolved launcher can leave a suspended descendant git.exe holding duplicates of the captured stdout/stderr handles, so the pipes never reach EOF and the reader-thread join blocks forever — leaking a process + two reader threads per fired timeout (the accumulating git.exe load behind Windows Defender CPU spikes). Two fail-open probe call sites had this identical flaw: - tui_gateway/git_probe.py::run_git — on the Desktop agent-build path (_start_agent_build -> _session_info -> branch() -> run_git), where the hang turned an optional branch label into "agent initialization timed out" (#68609). - agent/coding_context.py::_git — hangs the agent turn inside build_coding_workspace_block under an ACP host (#66037). Consolidate both onto one shared bounded_git_probe() in hermes_cli/_subprocess_compat.py (both files already import from there, so no new import surface): - explicit communicate(timeout), then on ANY failure a tree-kill — proc.kill() AND, on Windows, best-effort taskkill /T /F so the suspended descendant that holds the pipe writers dies too — plus a bounded 1s post-kill drain; if the pipes are still held they're abandoned (the orphaned reader threads are daemonic and cost nothing). - fail open to "" on every path: spawn error, timeout, kill() raising (access denied / already reaped — a raise inside the except handler previously escaped the contract), and non-timeout communicate() failures now also terminate the child instead of leaving it running. - the taskkill spawn can't re-enter the deadlock class: it captures no pipes (DEVNULL), so its own timeout cleanup has no reader threads to join. Normal-path spawn contract is preserved byte-for-byte: PIPE/PIPE/DEVNULL, text + utf-8 errors="replace", hidden-window creationflags on Windows only, nonzero returncode -> "". Each call site keeps its own timeout (1.5s / 2.5s). Supersedes #68622 (Sora-bluesky — git_probe fix + tree-kill) and #66038 (iamwongeeeee — coding_context fix), folding both into one shared helper so the two sites can't drift and every timeout tree-kills the descendant. Tests consolidated onto the helper, incl. the previously-missing assertion that a Windows timeout escalates to taskkill /T /F. Co-authored-by: Sora-bluesky <sora.bluesky.dev@gmail.com> Co-authored-by: iamwongeeeee <wykim777@naver.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> * fix(tui): revision-aware reload.mcp — an ack now means the revision was LOADED Review on #20379, finding 1 (High). Two ways an MCP config revision could be silently acknowledged without ever being applied: Client: the poll advanced its accepted mcp_rev BEFORE calling reload.mcp, and quietRpc collapses failures to null — a reload that failed against a temporarily broken server left the revision recorded as applied, and no subsequent poll retried until an unrelated MCP edit. The handshake is now syncMcpReload(): send the observed rev with the request, advance `accepted` only when the server answers status=reloaded (to the server's loaded_rev, falling back to the requested rev on older gateways), and re-compare on EVERY poll tick — decoupled from mtime — so a transient failure heals on the next tick. An in-flight guard stops the 5s poll from stacking requests behind a slow reload. Server: generation-only coalescing let a follower triggered by revision B ack against revision A's registry when the config changed under a slow leader. The leader now re-hashes the MCP-relevant config after discovery and repeats until stable (bounded), records _mcp_reload_loaded_rev, and a follower coalesces only when the revision it was asked to load matches — otherwise it re-runs the full reload itself. Responses carry loaded_rev. Deterministic tests for the exact failure sequences: failed reload → no ack, no generation advance; A-then-B overlap → follower re-runs; matching rev → coalesces; failed leader → follower re-runs; legacy no-rev callers keep generation-only coalescing (thread ordering via an instrumented lock, no sleeps). Client: 6 vitest cases on the ack/retry/in-flight contract. * fix(ui-tui): boot theme cache gets provenance — a stale hint can't pin the terminal Review on #20379, finding 2 (High). The boot cache seeded the previous session's background into HERMES_TUI_BACKGROUND, the same slot a CURRENT OSC-11 answer occupies — so a cache written on a light terminal pinned a now-pure-black terminal to light forever: the new OSC-11 #000000 answer is distrusted by design, the pure-white OSC-10 foreground is distrusted too, and the macOS appearance fallback refuses to run while the slot is set. Seeding now records provenance (themeBoot.seedBootEnvironment, extracted and testable against a passed env). When the current terminal answers the background probe with the untrusted fingerprint, the gateway handler calls invalidateBootBackground(): the slot clears ONLY while it still holds the seeded value (a trusted answer that overwrote it is authoritative), OSC-10 gets first claim in the same startup batch, and a short settle pass re- derives from the live fallback chain if nothing answered. The cache is also pin-coherent now: commitTheme persists the config mode pin (display.tui_theme) alongside the resolved theme + physical background. Previously "/theme light" on a dark terminal cached a light theme next to a dark background — the next launch painted light, flipped dark when the skin resolved against the seeded background, then flipped light again on config hydration: the exact multi-stage flash the cache exists to eliminate. The seeded pin counts as config-owned (bootSeededPin), so a later 'auto' can still clear it instead of mistaking it for a user shell export. Tests cover the review's sequences: stale-light cache vs current dark terminal (invalidate → fallback chain), stale-dark vs ambiguous light, pinned light on a dark physical background across restart (and the inverse), trusted-overwrite protection, and the explicit-signal guards. * fix(ui-tui): stacked dialog gets input before the grid under it Review on #20379, finding 3 (Medium). /grid-test's `d` opens a dialog on top without clearing the grid, but the grid's input branch ran FIRST — so Esc/q/Enter mutated the hidden grid (close/unzoom/promote) instead of closing the visible dialog, contradicting its "Esc/q/Enter close" hint. Input routing now follows visual stacking: the dialog/grid dispatch is extracted into handleStackedModalInput() with the dialog branch first, the hook consumes through it, and tests drive the real dispatch against the overlay store — each advertised close key closes only the dialog (grid byte-identical), grid keys don't leak through while the dialog is up, and the same keys route to the grid again after it closes. * fix(ui-tui): make `npm run visual` portable off POSIX — zero new deps Review on #20379, finding 4 (Medium). Three portability defects in the visual verification harness: - `FORCE_COLOR=3 COLORTERM=truecolor tsx ...` POSIX env assignment does not work under the Windows npm command shell. The script is now a plain Node launcher (scripts/visual/run.mjs) that sets the env itself and spawns tsx via require.resolve('tsx/cli') — no cross-env, no shell syntax. - Hardcoded /tmp/tui-visual.{html,png} resolve to a drive-root path like C:\tmp on native Windows (and fail when that directory doesn't exist). Both scripts now derive the output directory from a shared paths.mjs helper: os.tmpdir()/hermes-tui-visual (created recursively; HERMES_TUI_VISUAL_DIR overrides for CI or side-by-side runs). - electron was undeclared by ui-tui and only worked via hoisting luck. The launcher now resolves it EXPLICITLY from the install tree the desktop workspace already provides (require('electron') in plain Node returns the binary path), with an ELECTRON_BIN override and a clear error pointing at the repo-root install when it's absent — instead of declaring a second ~100MB dependency on a TUI workspace for a dev-only harness. Also fixes the trailing-whitespace line in render.tsx that `git diff --check` flags. Verified end-to-end: render writes the HTML scene sheet and the electron shot step produces the screenshot from the tmpdir path; the missing-electron error path prints the guidance message. * perf(ui-tui): shimmer loaders share one clock and stop after a bounded period Review on #20379, finding 5 (Perf). Every ShimmerRows mounted its own 90 ms setInterval — the session panel can show lazy skills AND lazy tools at once, and a lazy watch session stays lazy indefinitely, so an otherwise- idle TUI ran ~22 React state updates per second forever. All shimmer compositions now subscribe to a single module-level clock: one interval regardless of how many skeletons are on screen, updates delivered in one timer callback so React batches them into a single render pass, and the interval is torn down with the last subscriber. Each mount's animation is also bounded (SHIMMER_ANIMATE_MS, 30 s): after the budget the skeleton freezes in place — it still reads as "loading" — and stops costing renders entirely. Tests: fake-timer coverage that N subscribers share one timer in lockstep, the interval stops with the last unsubscribe, and a late subscriber restarts the clock cleanly. * fmt(js): prettier pass on the new review tests * chore: gitignore node_modules symlinks, not just directories Worktrees symlink node_modules to the main checkout; the dir-only node_modules/ pattern doesn't match symlinks, so one slipped into a commit and broke npm ci on CI (ENOTDIR). Dropping the trailing slash matches both. * fix(desktop): stop long-session transcript from drifting to old turns (#69019) content-visibility:auto on turn groups (perf: off-screen turns skip style/layout/paint) pairs with contain-intrinsic-size:auto, which only remembers a turn's size after it renders. A turn that finished streaming near the bottom had its smaller mid-stream size remembered; once it scrolled off the top edge and got skipped, it collapsed to that stale height. With overflow-anchor:none the viewport can't self-correct, so the stick-to-bottom lock drifts and the view creeps up over older turns — the 'long session eventually shows old responses' visual glitch. Exempt the newest turns (live tail) from virtualization so a turn is only ever skipped after its layout has settled at its final size (remembered == real -> skipping changes no height). Off-screen older turns still skip, so the dialog/popover whole-document recalc win on long transcripts is kept (it scales with the hundreds of old turns, not the small tail). * ci: retrigger (transient setup-uv manifest fetch flake) * feat(ui-tui): widget-app SDK — registry, host, dispatch; demos become apps The SDK the desktop app already has, ported to the TUI: a WidgetApp contract (id/help/mode/init/reduce/render/usage), a registry, and a host that owns the active widget, routes input to its reducer, and renders it. The grid-test and dialog-test debug surfaces are reimplemented as widget apps instead of bespoke overlay state, and slash commands are generated from the registry. Input for an open widget is owned by the active app (supersedes the demo-only stacked-modal routing) — the single active widget enforces topmost-owns-input structurally. * feat(ui-tui): weather reference app — the async-data contract, themed ASCII art /weather [location]: wttr.in current conditions behind a Dialog, art bucket table-driven off WWO weather codes, every tint a theme family tone (sun = primary, rain = shell blue, thunder = warn). Proves the async story the demos don't: init returns a loading phase and fires the fetch; results land through the new host.updateWidget, which patches state ONLY while the app is still active — a late resolution can never resurrect a closed app or clobber a different one. `r` refetches; Esc/q/Enter close. Four async-contract tests (loading→ready via updateWidget, late-resolution guard, error phase, keymap). 1253 TS tests green. * feat(ui-tui): ambient widget mode — registry-driven slash catalog + in-flow dock Widgets can render as ambient (glanceable, non-blocking) instead of modal, docked in the normal layout flow above/below the status bar rather than taking over the screen. The slash catalog is generated from the widget registry so new apps surface automatically, and /ticker lands as the first live-animation ambient demo. * feat(ui-tui): self-authored widgets — user-widget loader, hot-load, skill Hermes can write its own widgets: a loader discovers $HERMES_HOME/tui-widgets/*.mjs, fs.watch hot-loads them the moment they land (no restart), and a tui-widgets skill teaches the agent the contract and the openWidget-at-register auto-open recipe. Load/error/remove events announce themselves in the transcript; a lazy intro skeleton covers the first paint. * feat(ui-tui): widget primitives — charts, accordion, shimmer, stable streams Reusable render primitives the SDK exposes to widget authors: sparkline/gauge/ hbars chart helpers (dimension-stable so live updates never resize the card), an Accordion for expand/collapse sections, animated shimmer loaders, and a streams demo that no longer reserves a phantom icon column on unfocused titles. * docs(skill): tui-widgets — auto-open recipe (openWidget at end of register) * feat(ui-tui): ambient zone system + widget crash boundary A full placement grid so the agent can put a widget where it asks — dock-top/ bottom and corner zones, with corners as reserved rails that take real space instead of floating over content. A per-widget error boundary plus lenient ShimmerRows means generated widget code can't crash the TUI. * refactor(ui-tui): host placement router + grid-test width-floor fix host.tsx collapses to one placement router over a shared render context, and the grid-test app drops its width floor too (carrying the #20379 review rule). Final formatting pass folded in. * feat(themes): cross-surface theme SDK — one skin themes CLI, TUI, and desktop Make the Python skin engine the single source of truth for a canonical theme shape consumed by every surface, so a skin authored in $HERMES_HOME/skins/*.yaml (by a user or by Hermes from a prompt) themes the CLI, TUI, and desktop GUI at once — the theme analogue of the plugin SDK. - @hermes/shared: canonical `HermesSkin` token shape + `SKIN_COLOR_TOKENS` enum, consumed by both TS surfaces (TUI `GatewaySkin` and desktop dedup onto it). - Desktop: `skinToDesktopTheme` resolver (skin → CSS-var palette, VS Code-style derive-from-seed) + `backend-sync` that registers backend skins into the theme registry (Appearance/Cmd-K/`/skin`) and applies on a real change. Seeds on gateway.ready (never stomps a persisted pick), applies on skin.changed and the post-turn `config.get skin` poll (catch-all for agent-edited config.yaml). - TUI: `fromSkin` now maps the status bar + `background` keys it was dropping. - Gateway: `config.get skin` also returns the full resolved palette (additive). - Skill: `hermes-themes` teaches the agent to author + activate a skin. Each surface keeps its own normalizing resolver (ansi for the TUI, CSS vars for the desktop, prompt_toolkit/Rich for the CLI). * fix(themes): activate skins via `hermes config set`, never a config.yaml hand-edit The skill told the agent to `patch` display.skin into config.yaml; a stray indent corrupts the file and breaks the live gateway (the reported "/ menu broke"), and a raw file edit never live-applies in a running CLI/TUI ("nothing happened"). Route activation through the safe writer (`hermes config set display.skin`), and state plainly that a tool call can't hot-switch a running CLI/TUI — the user runs `/skin <name>` (desktop still auto-repaints on the next turn). * feat(themes): agent-authored skins switch live via a gateway skin watcher A skin Hermes activates (`hermes config set display.skin X`) or recolors in place now goes live on every surface (CLI, TUI, desktop) within ~half a second, on its own — no `/skin`, no tool-hook timing, no user action. A gateway daemon polls the resolved skin signature `(name, active-file mtime)` every 0.5s and broadcasts `skin.changed` on any real move — a name switch OR a live color edit to the active skin. It routes through the SAME path `/skin` uses, so all surfaces repaint identically. The watcher seeds its baseline at gateway.ready (stdio + ws) so it only fires on a real change; the `/skin` RPC seeds the baseline too so it never double-broadcasts. Subsumes the desktop's post-turn `config.get skin` poll (its skin.changed handler already applies). * feat(themes): TUI paints its own background from the skin (OSC 11) The TUI inherited the terminal's background; now a skin's `background` paints the whole surface via OSC 11 when a skin is applied, and clears back to the terminal default (OSC 111) on revert and on exit (ridden in through resetTerminalModes). Opt-in: a skin with no `background` leaves the terminal untouched, and the restore only fires if we actually painted. Desktop already themed its own bg; this closes the loop so Hermes owns its background on every surface. * feat(themes): element tokens (ui_tool, ui_thinking) + skinnable diffs Theming was semantic-only: the gold tool `●` was `accent`, shared with headings/links/chevrons, so "recolor tool calls" was impossible and the agent had no key to point at. Add `ui_tool` (● + tool spinner) and `ui_thinking` (reasoning body) tokens that fall back to accent/muted — defaults unchanged, but now independently settable. Make diffs skinnable too (`diff_*`), which fromSkin previously hardcoded. Document the full element→key map in the skill so Hermes knows which knob turns what. * fix(themes): tweak the ACTIVE skin in place, never fork default Changing one color ("make the tool ● cyan") forked `default` — which has no `background` — so applying it reset the terminal to its own (black) default and dropped the active skin's palette. Teach the skill to edit the active skin's file in place for a tweak (watcher repaints on the mtime bump), and to fork a built-in only by carrying its full palette. Hard pitfall: never fork `default` for a tweak. * feat(themes): `hermes skin set` — deterministic one-color tweak, bg untouched Changing a single color kept wrecking the rest because the agent hand-authored a new skin (often from `default`, which has no `background`, resetting the terminal to black). Add `hermes skin set <key> <hex>`: edits the ACTIVE skin's one key in place (a built-in is forked into an editable copy carrying its full palette), so everything else — background included — is preserved. Plus `skin use` / `skin list`. The skill now points tweaks at this command instead of hand-authoring. * feat(themes): dedicated code-syntax palette keys Code highlighting reused brand tokens (accent/text/border/muted), so it couldn't be themed independently. Add syntax_string/number/keyword/comment skin keys → syntax* theme tokens (defaulting to those brand tokens, so defaults are unchanged) and point the highlighter at them. Documented in the element→key map. * test(themes): E2E live skin switch — config write → skin.changed broadcast * fix(themes): reconcile element/syntax tokens with main's derive+adapt pipeline Element tokens (ui_tool/ui_thinking), skinnable diffs, and code-syntax keys flow through buildPalette → adaptColorsToBackground instead of a hand-mapped color block, so they inherit #20379's contrast/polarity machinery. thinking and syntaxComment track the EFFECTIVE muted (banner_dim override included); the skin's `background` feeds the surface (it also paints the terminal via OSC 11); statusFg falls back through ui_text/banner_text. Tests assert the routing/independence contracts rather than pre-adaptation hexes. * fix(themes): apply a runtime switch back to default on the desktop ingestBackendSkin returned early for name === 'default' even when apply=true, so a real runtime switch to the default skin (/skin default on CLI/TUI, or config.set display.skin=default) emitted skin.changed but never repainted the desktop. 'default' is no-opinion on the PALETTE (the desktop keeps its own nous default, so we still never register a converted theme under it), but it IS a valid apply TARGET: setTheme normalizes 'default' -> nous, so switching back repaints to the desktop default. Skip only the registry step for 'default' and let it flow through the apply guard. Addresses Copilot review. * fix(tui_gateway): serve candidate-inclusive display on warm/live resume #65919 persists verification candidates (finish_reason=verification_required / verify_hook_continue) to state.db but collapses them out of the in-memory model history via repair_message_sequence. The eager session.resume + REST paths read the verbatim display lineage (candidate present), but the warm/live-reuse payload (_live_session_payload) built its user-visible messages from the collapsed in-memory model history — so switching to a still-live session dropped the substantive verification answer that a cold resume of the SAME session showed. That divergence is the cross-session "substantive text vanishes on switch" class, and the direct sibling of the resume-duplication regression fixed in #68149. Reconcile the persisted display lineage (candidate-inclusive, the same get_messages_as_conversation(..., include_ancestors=True) read the eager resume + REST paths use) with the fresh in-memory tail in _live_visible_history, so all three surfaces agree by construction while a not-yet-flushed live turn is still shown. Extracted _reconcile_display_with_live as a pure, DI-testable function (anchors on the last persisted row's (role, text); appends only the uncovered in-memory tail; trusts the DB display when the tail can't be anchored). Tests: unit coverage for candidate-inclusion, freshness, empty/raising-DB fallback, and the combined candidate+fresh-tail case. The existing freshness guard (test_session_resume_live_payload_uses_current_history_with_ancestors) stays green. * fix(tui_gateway): candidate-inclusive display on child-watch resume + E2E Complete the #65919 warm/live-payload fix across its sibling path and add real-SessionDB cross-builder coverage. - Child-watch (lazy) resume: the delegated-subagent watch window served _history_to_messages(repaired_history) for its user-visible messages, which collapses out persisted verification candidates just like the warm-payload path did. Build the visible messages from the verbatim child-only display projection (repair_alternation=False) while the repaired history still feeds live replay; fall back to the repaired history if the display read fails. - E2E cross-builder consistency (real SessionDB, not mocks): a persisted verification candidate is collapsed out of the model projection but kept in the display projection, and _live_visible_history now equals the eager session.resume display projection (candidate present). Adds the combined candidate + fully-flushed-second-turn case and a lazy child-watch handler test that asserts the candidate survives in resp["result"]["messages"]. * fix(cli): add skin to _BUILTIN_SUBCOMMANDS for plugin gating The new hermes skin subcommand must be declared so startup plugin discovery can skip when the user targets it. * fmt(js): `npm run fix` on merge (#69048) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * feat(desktop): Billing page revamp — current-plan card, in-app plans view, tier art (#68722) * feat(desktop): revamp Billing page — plan card, in-app plans view, tier art Reshape the desktop Billing settings per wayfinder ticket 09. New page order: Plan → Payment → One-time top-up → Automatic refill → Usage, with the at-a-glance summary strip unchanged at the top. - CurrentPlanCard replaces the old Subscription row: tier name + price + renewal and at most one button — "View plans" (free/no-sub + can_change_plan), "Change plan" (subscriber + can_change_plan), or none for teams / non-changers. Teams keep the portal "Adjust plan ↗" link so they are not stranded. The button navigates in-app to the plans sub-view. - bview=plans sub-view mirrors the settings pview/kview pattern (useRouteEnumParam, default overview). BillingPlansView renders a grid of PlanCard from live tiers[] (is_enabled, sorted by tier_order, free tier included). - PlanCard: tier art + name + $/mo + monthly credits as dollars ("$110 credits/mo"). Current tier is highlighted + inert; higher/no-current tiers get "Choose ↗" (opens portal with plan=<tierId>); lower tiers are a DISABLED "Downgrade" with a caption — downgrades move in-app in ticket 11 (gateway pending-change flow), so this PR intentionally links them out/disabled rather than wiring the money path. - buildManageSubscriptionUrl gains an optional third arg (tierId) → appends plan=<tierId>. Signature kept identical to draft PR #68666 for a trivial rebase; NAS #748 validates the param server-side. - Tier art: four NAS hero webps rendered as ~40px thumbnails over a Nous-blue well with per-tier blend modes (the only place Nous blue appears). Keyed by lowercase tier NAME (free/starter→connect, plus→memory, super→automation, ultra→sandbox); unknown name → text-only card. Imported via vite static imports for packaged file:// + webSecurity. - Top-up vs auto-refill disambiguated by section label + first sentence: "One-time top-up" / "Buy credits now" vs "Automatic refill" / "Refill when low" (configured copy reads "Charges $X automatically when your balance falls below $Y."). - Variant-A auto-refill editing: Manage swaps the row's left side (caption → the two $ fields with a pre-allocated error line) and the action column (Manage → Save/ Cancel) in place, with the row height reserved for the tallest state so the Usage section never shifts. Fixes the spurious on-open validation error (errors now show only after an edit or a save attempt). Save/disable API calls + confirm-disable flow unchanged. - Remove subscriptionTierChips and the subscription-row chips; reshape (not delete) deriveBillingView to expose plan + tiers. Buy-credits row keeps the chips seam. - Dev fixtures: add free-personal and subscriber-personal (personal orgs, full 4-tier Free/Plus/Super/Ultra catalog) so the plans view is exercisable. Tests: update/extend index.test.tsx + use-billing-state.test.ts, add tier-art.test.ts; delete the old chips tests. Desktop billing suite 70/70 green, typecheck clean. * fix(desktop): mark the free/lowest tier current (not an upgrade) when there is no subscription Visual verification caught a spec-fidelity bug: in the plans grid, an account with no active subscription rendered the Free tier ($0/mo, tier_order 0) as a "Choose ↗" upgrade — clicking would deep-link the portal to "subscribe to Free". Ruling: current-card = tier.is_current OR (subscription.current == null AND the tier is the lowest-order / $0 tier). derivePlanTiers now falls back to the lowest-order tier as the stand-in current plan when there is no subscription, so the free card renders exactly like is_current (inert, "Current plan") and — being the lowest order — no tier can be a downgrade; every paid tier is a "Choose ↗" upgrade. CurrentPlanCard is unaffected (still "Free" + "View plans"); subscriber-personal is unchanged (Free stays a disabled Downgrade below the current Plus tier). Tests: free-personal grid now asserts Free = current/inert, no downgrade state, three Choose buttons; text-only unknown-tier test gains a free tier so the unknown paid tier is unambiguously an upgrade. Billing suite 70/70 green, typecheck + lint clean. * chore(desktop): shrink bundled tier art to 128px thumbnails The plan-card wells render the art at ~40px; shipping the full landing images added 2.7 MB to the repo for no visible difference. 128px covers 2x displays; total is now 26 KB. * fix(desktop): address 6 adversarial-review findings on the Billing revamp 1. Grandfathered current tier (BLOCKER). NAS marks a grandfathered current tier is_enabled:false; the enabled-only filter dropped it, leaving currentOrder undefined so every lower tier rendered as an actionable "Choose ↗". derivePlanTiers now resolves current identity/ordering against the UNFILTERED tiers and keeps the grandfathered current tier in the grid as the inert "Current plan" card; downgrades classify against its tier_order. (Non-current disabled tiers are still dropped.) 2. Dead plan-card button. derivePlanCard offered "View plans"/"Change plan" purely on can_change_plan, but the grid could be empty / current-only and showPlans refused, so the button no-oped. It now offers the in-app action ONLY when the grid has ≥1 actionable (non-current) tier; otherwise it falls back to the portal link. 3. Deep-link bypass. showPlans now gates on the same capability that renders the button (view.plan?.action), so a team / non-changer deep-linking bview=plans always falls back to overview instead of a grid of live Choose buttons. 4. Lost portal escape hatch. Whenever the card has no in-app action (teams, non-changers, refused subscription, empty catalog) it now ALWAYS carries the "Adjust plan ↗" portal link built from subscription?.portal_url ?? billing.portal_url — the refusal caption no longer promises a portal the UI didn't render. 5. Choose URLs dropping org_id/plan. (a) derivePlanTiers now threads billing.portal_url as the fallback base for the Choose URL. (b) buildManageSubscriptionUrl treats the hard-coded FALLBACK_PORTAL_BILLING_URL as a last-resort ORIGIN (applying org_id/plan) instead of a bare return, so a null portal_url never strips the routing params. 6. Zero-shift on narrow panes. Replaced the magic min-h-28 (under-reserved once the two inputs stack below @2xl) with exact reservation: the edit form is always rendered and both states share one grid cell ([grid-template-areas:'stack']), invisible+aria-hidden when not editing — the row equals the tallest state at every width, no breakpoint math. The refusal stays inside the reserved layer. Tests: +12 (grandfathered current, no-dead-button + empty-catalog portal link, team & personal deep-link fallback to overview, billing.portal_url-backed Choose URL, fallback org_id/plan, reserved-form-mounted); updated the two portal-link expectations for §4. Billing suite 78/78 green; typecheck (app/electron/e2e) + lint clean. * refactor(desktop): reuse the shared openExternalLink helper in the plans view * fix(desktop): honor the auto_reload wire contract — null card + disable amounts A full-stack contract sweep (desktop ↔ shared types ↔ gateway ↔ NAS) surfaced two real desktop bugs in the auto-refill row: A. auto_reload.card can be null. The gateway's _parse_auto_reload_card returns None for a missing/unknown-kind card and _serialize_billing_state emits `card: null`, but the shared BillingAutoReload.card union had no null arm and use-billing-state dereferenced `autoReload.card.kind` bare — a crash on the enabled path. Add `| null` to the shared union (contract honesty) and guard the read (`card?.kind`); null now falls through to the default enabled path, same as a canonical card. B. Disable was rejected by the gateway. billing.auto_reload unconditionally requires threshold + top_up_amount, so `updateAutoReload({ enabled: false })` came back invalid_request. (The TUI always sends both; desktop fixture mode stubbed it.) disable() now sends the current threshold_usd/reload_to_usd from the autoReload prop alongside enabled: false, matching the TUI. Tests: enabled auto_reload with card:null renders the normal enabled row (derivation + render, no crash); disable call carries both current amounts. Billing suite 80/80 green; typecheck (app/electron/e2e) + lint clean. * fix(tui): guard the nullable auto_reload card in the auto-reload screen The shared BillingAutoReload.card union gained its honest null arm (the gateway emits card: null for a missing/unknown card); the TUI's only bare dereference follows the same default path as a canonical card. * fix(desktop): align billing inputs to the sm control height The three billing inputs used an ad-hoc h-8 (32px) next to size=sm buttons (24px). They now use the control system's size=sm with a py-[3px] compensation for the input's real 1px border — buttons draw theirs as an inset shadow, so sm alone still sits 2px taller. All five controls in the buy row now measure 24px. * fix(desktop): plan-card actionability + billing view-model hardening Code-quality review of the Billing revamp (PR #68722). BLOCKING — a top-tier subscriber (only downgrades/current below them) opened a plans grid with zero enabled actions AND no portal link. The plan card gated its in-app button on `tiers.some(state !== 'current')`, which counts the (disabled) downgrade tiles. It now gates on an actual UPGRADE being present (`capable && tiers.some(state === 'upgrade')`); with no upgrade the card falls back to its "Adjust plan ↗" portal link, and the bview=plans deep link (gated on the same plan.action) falls back to overview. Reviewer structural items: - One "plans capability" verdict (personal + can_change_plan + subscription ok) is derived once in deriveBillingView and threaded to BOTH derivePlanCard and derivePlanTiers; the grid only mints upgrade actions when capable, so the invariant lives in one place. - BillingPlanTierView is now a discriminated union (`current` | `downgrade` w/ disabledCaption | `upgrade` w/ required action), and BillingPlanCardView is an action-XOR-link union — deleting the `tier.action?.url ?? ''` and `plan.link?.url` defensive branches in the consumers. - `findCurrentTier(subscription)` replaces the repeated is_current||id predicate at its three sites (plan card price, grid ordering, summary plan line). - BillingView exposes named `paymentRow` / `topupRow` / `refillRow` instead of an `accountRows[]` + three `.find(id)` lookups. - The auto-refill row that edits in place carries an explicit `manageInApp: true`; AutoReloadRow keys off it instead of sniffing the action label/url. - tier-art header comment no longer cites an internal repo path; dead `?.` removed from RowValue (via a destructured const) and the plan-card link handler. Behavior is identical except the blocking fix. Billing suite 82/82 green; typecheck (app/electron/e2e) + lint clean. * refactor(desktop): adopt inline-review nits on the billing plan card Resolves the inline suggestion threads: - plan-card gate reads a named `hasActionableTier` = "a tile carries an action" (union-safe `'action' in tier`, equivalent to the old upgrade-only check). - re-narrow link/action inside the click callbacks (`plan.link && …`, `tier.action && …`) rather than relying on outer narrowing. Behavior unchanged; billing suite 82/82 green, typecheck + lint clean. * fmt(js): `npm run fix` on merge (#69050) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * feat(cli): plan catalog on Free + plan= deep link + top-up/auto-refill copy split (#68689) * feat(cli): plan catalog on Free + plan= deep link + top-up/auto-refill copy split Bring the plain (non-TUI) CLI billing surface to parity with the desktop/TUI billing changes: - /subscription on Free (admin/owner, interactive) prints the plan catalog (name · $/mo · $credits/mo, from the same tiers[] data the TUI uses; monthly credits render as dollars). A numbered pick opens the manage-subscription deep-link directly with plan=<tier_id> appended. - subscription_manage_url(state, tier_id=...) appends plan=<tier_id> (the stable tiers[] id) when a tier was picked, org_id first — mirrors the TUI's ?plan=. The paid change flow's blocked/unknown-preview portal fallback carries plan= for upgrades only; downgrades stay generic/native. - /topup overview splits one-time top-up from automatic refill, the distinction stated in each first sentence ("Add funds now — a single charge…" vs "Refill when low — charges … automatically …"), keeping "credits" out of the dollars-only surface. - Downgrades remain native (chargeless scheduled change), unchanged. Updates the CLI-parity section of docs/billing-lifecycle.md and tests under tests/hermes_cli + tests/agent. * refactor(billing): share plan-catalog helpers + harden manage-url builder - subscription_manage_url now preserves unrelated portal query params (parse_qsl, popping only the contract-owned org_id/plan) and restricts to http/https schemes, matching the desktop URL builder — the function owns the contract. - Lift the plan-catalog derivation into agent/subscription_view.py so the CLI Free catalog and the paid picker/blocked-preview branch share one implementation: selectable_tiers (enabled paid, not current, sorted), format_tier_row (name · $/mo · $credits/mo — thousands-grouped like the TUI's toLocaleString, credits suffix hidden when absent/zero), and is_upgrade(state, tier_id). * fix(cli): numbered pick, canonical guarded browser opener, partial auto-refill copy - Free catalog: accept a bare digit as a pick (the shared normalizer only knows the confirm-dialog digit aliases, so `1` used to resolve to None → "Cancelled"). The Nth digit maps to the Nth printed row. - Extract one _open_url_in_browser used by every "open the portal" path, applying the device-code flows' console-browser / remote-session guard (webbrowser.open returns True even for lynx/w3m over SSH) and returning whether a real browser opened. - Consume the shared selectable_tiers / format_tier_row / is_upgrade helpers from the Free catalog, the paid picker, and the blocked-preview branch. - /topup auto-refill copy: the concrete "charges $X … below $Y." sentence only when both amounts are present and finite; otherwise the generic sentence. * docs(billing): correct CLI-parity rows (drop cross-repo ref, downgrade invariant) Remove the other-repo PR reference from the manage-URL row, and state the real downgrade invariant: a blocked downgrade may print the generic manage URL but never carries plan=<tier_id> — selected-tier deep-links are reserved for new subscriptions and upgrades. * feat(desktop): native in-app downgrade — chargeless preview → schedule → undo (#68761) * feat(desktop): native in-app downgrade (chargeless preview → schedule → undo) Ticket 11, stacked on the Billing revamp (ticket 09). Downgrades no longer bounce to the portal — picking a lower tier runs the gateway pending-change flow in-app; the scheduled state renders on the plan card with an undo. Upgrades keep the portal deep link. - api.ts: add previewSubscriptionChange / scheduleSubscriptionChange / resumeSubscription wrappers over subscription.preview|change|resume ({subscription_type_id} / {}), typed via SubscriptionPreviewResponse + BillingMutationResponse (now re-exported from types.ts). - use-subscription-change.ts (new): useDowngradeFlow (preview → confirm → schedule, refetch + onScheduled on success; typed refusals surface via the shared BillingRefusalInline, so insufficient_scope drives the existing step-up exactly like the auto-reload save, retried in place) and useResumeFlow (confirm-less undo). Both accept a `simulate` switch so DEV fixtures click through with canned success. - plans-view.tsx: downgrade tiles are now an actionable "Downgrade" that opens an in-card preview → confirm panel (mirrors the TUI confirm copy: "…takes effect <date>. No charge now; you keep your current plan until then."). The scheduled downgrade target renders an inert "Scheduled" marker; other lower tiers stay actionable (picking one reschedules). - CurrentPlanCard: when a downgrade is pending, the caption reads "Changes to <tier> on <when>." with an inline Undo → resume → refetch. One line, no jumps. - use-billing-state.ts: BillingPlanTierView gains a `scheduled` state (and drops the ticket-09 disabled-downgrade caption); derivePlanTiers matches the pending target by name (NAS sends no id for it) before the downgrade branch; BillingPlanCardView gains `pending`, derived from current.pending_downgrade_* . - inline-feedback.tsx (new): extracted openExternal / BillingRefusalInline / StepUpInlineAction / InlineMessage so the plans view reuses the step-up-aware refusal renderer without a circular import; openExternal now delegates to the canonical @/lib/external-link opener. - dev-fixtures.ts: add `pending-downgrade` (subscriber-personal on Plus with a Free downgrade scheduled for Aug 15) for the plan-card pending state + grid marker. Tests (+16 → 94 green in the billing suite): api wrappers (preview/change/resume + insufficient_scope refusal); view derivation (pending plan-card state, scheduled grid marker); confirm flow (preview shown, change called with the right tier_id, refetch on success, schedule refusal → step-up affordance); undo flow; the use-subscription-change hooks (preview-refusal retry, cancel, simulate path). Updated the ticket-09 downgrade tests for the now-actionable tile. typecheck (app/electron/e2e) + lint clean. PR (later): base sid/desktop-billing-revamp; retarget to main after #68722 (09) merges. * fix(desktop): format downgrade credits delta as signed dollars The downgrade preview rendered the raw wire string ("Monthly credits change: -88."), violating the "monthly credits are DOLLARS" ruling. NAS sends monthly_credits_delta as a bare decimal; format it as signed dollars through the same money formatter ("−$88/mo", sign preserved, abs value formatted). Zero / absent still hides the line. Adds formatMonthlyCreditsDelta (exported) + unit tests (negative/positive/zero/ absent) and asserts the rendered "Monthly credits change: −$88/mo." in the confirm flow. Billing suite 99/99 green; typecheck + lint clean. * fix(desktop): downgrade flow hardening — concurrency guard, a11y, DEV-gated sim Addresses the adversarial review of the native-downgrade diff. - Concurrency: useDowngradeFlow exposes `mutating` (true only while the schedule RPC is in flight). While a change commits, every other Downgrade tile and the Back button are disabled; the active panel's Confirm/Cancel already lock. The plan-card Undo blocks on its own resume via `busy`. (The server also 409s overlapping per-org mutations — this is UI honesty, not the only defense.) - Accessibility: the confirm panel is role="status" aria-live="polite" and takes focus on open (tabIndex=-1 container); closing it returns focus to the tile card, so keyboard focus is never stranded and the async preview text is announced. - DEV-gated simulation: the canned preview/change/resume seam is ignored unless import.meta.env.DEV, so a production build never takes the simulated branch even if a `simulate` prop leaks through. - Comments: documented the deliberate manual-retry-after-step-up (no auto-replay, matching auto-reload) and that name-matching the scheduled target is safe because SubscriptionTypes.name is @unique in NAS. Tests (+5 → 104 green in the billing suite): mutating exposed only during schedule; simulate ignored outside DEV; other downgrade tiles + Back disabled mid-schedule; Undo disabled mid-resume; confirm panel role + focus on open. typecheck (app/electron/e2e) + lint clean. * fix(desktop): scheduled cancellations, downgrade-flow concurrency, inline nits Addresses the native-downgrade review threads. Scheduled cancellations were invisible (NEW review item). subscription.current carries cancel_at_period_end + cancellation_effective_* and subscription.resume clears cancellations exactly like downgrades, but the pending-transition helper only read pending_downgrade_*, so a portal/TUI-scheduled cancellation rendered as a plain renewal with no Undo. The pending state is now a union — { kind:'downgrade', tierName, when } | { kind:'cancellation', when } — computed once in deriveBillingView and threaded to BOTH the plan card and the grid. The card reads "Cancels on <date>." with the same Undo (resume); the grid shows a Scheduled marker only for downgrades (a cancellation has no target tier). Precedence: a downgrade wins if both fields are set (it names a concrete target — the stronger signal), commented at the helper. Adds a `pending-cancellation` fixture + tests (card copy, undo wiring, no grid marker, downgrade-wins precedence). Concurrency: confirm() takes a synchronous scheduling ref (mirroring useResumeFlow) so two same-tick clicks — before React commits busy='schedule' — cannot fire two schedule RPCs; the ref clears on every exit (simulated/stale/refusal/success). useResumeFlow reorders its unlock: a refusal releases immediately, a success holds runningRef/busy THROUGH the refetch so Undo never re-enables against the still-pending card. Test: a synchronous double-activation fires one schedule RPC. Inline nits: re-narrow link/action inside the click callbacks (`plan.link && …`, `tier.action && …`) instead of relying on outer narrowing / `?? ''`. Billing suite green (109); typecheck (app/electron/e2e) + lint clean. * refactor(desktop): move DEV billing simulation behind the api seam The fixture simulation lived as `simulate` / `simulateResume` prop drills and `if (simulated)` branches inside the flow hooks, and it could not actually produce the state it advertised (a simulated schedule never showed the pending card). Replaced with `createSimulatedBillingApi(fixture)` — a fully in-memory BillingApi built once, DEV-gated, in BillingSettingsWithDevFixtures where the fixture is known, and supplied to the whole subtree via a new `BillingApiProvider` (context override on `useBillingApi`; `null` = the real gateway api). It serves fetches from a mutable copy of the fixture and its subscription-change mutations WRITE that copy's pending state: schedule sets a pending downgrade, resume clears a pending downgrade OR cancellation. Fixture mode now flows through the SAME react-query path (fetch short-circuit deleted; queries always enabled; an effect refetches on fixture switch), so the click-through genuinely progresses — schedule → pending card + Undo + Scheduled marker, undo → cleared. Deleted `SubscriptionSimulation`, `simulationEnabled`, both prop drills, and every `if (simulated)` branch — the hooks are now production-pure. Added a test driving the full simulated loop (schedule → pending appears → resume → cleared), plus cancellation undo and no-shared-mutation coverage. Removed the now-obsolete simulate hook tests. Billing suite green (110); typecheck (app/electron/e2e) + lint clean. * refactor(desktop): extract billing row/card components out of index.tsx Purely mechanical, no behavior change: split the settings billing route file (1065 → 593 lines) into focused siblings now that the downgrade feature has settled their final shape. - billing-amounts.ts — the dollar parse/format/validate/clamp helpers. - account-row-value.tsx — RowValue (shared by AccountRow + AutoReloadRow). - current-plan-card.tsx — CurrentPlanCard. - auto-reload-row.tsx — AutoReloadRow (the in-place auto-refill editor). index.tsx keeps the page shell, AccountRow dispatch, BuyCredits flow, and the fixture wiring. Billing suite green (110); typecheck (app/electron/e2e) + lint clean. * refactor(desktop): tighten the downgrade flow — phase union, previewMessage, tidy shared modules Polish that composes with the api-seam rework: - ActiveDowngrade's four nullables become a `DowngradePhase` discriminated union (previewing | previewFailed | ready | scheduling | scheduleFailed). Impossible combinations (a preview AND a refusal, "ready" with no quote) can no longer be represented; the hook and panel branch on one `kind`, and `mutating` is simply `phase.kind === 'scheduling'`. - The five-way ternary in DowngradeConfirm is replaced by a pure `previewMessage(phase, fallbackTierName)` helper; the misnamed `caption` className local is renamed `captionCn`. - inline-feedback.tsx now holds ONLY the shared refusal/step-up pieces: `openExternal` moves to its own `open-external.ts` (a thin wrapper over `@/lib/external-link`'s `openExternalLink`), and `InlineMessage` moves back into its sole consumer (auto-reload-row.tsx). No behavior change. Billing suite green (110); typecheck (app/electron/e2e) + lint clean. * fmt(js): `npm run fix` on merge (#69067) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * fix(gateway): hard-exit CLI runner after graceful teardown * test: update gateway run stub for hard-exit helper * fix(gateway): hard-exit on KeyboardInterrupt path too The KeyboardInterrupt handler in run_gateway() was the only exit path that still used bare 'return' instead of _hard_exit_after_gateway_teardown(). While less common than service-managed restarts, a console Ctrl+C still leaves the process vulnerable to the same Python finalization hang on non-daemon worker threads (cron ThreadPoolExecutor jobs). Route it through the same backstop, with a 'return' guard for test stubs that don't raise on code 0 (production os._exit never returns). * fix(cli): pass conversation_history on /new /resume /branch flush Closes #68454 Root cause: cold-resumed transcript rows lack _DB_PERSISTED_MARKER until a normal turn flush stamps them. Immediate /new,/resume,/branch flushed with no history boundary, so every restored row was re-appended to the old session. Fix: pass conversation_history=self.conversation_history at all three sites (mirrors #68205). Add offline regression coverage for noop + tail-only write. Verification: pytest tests/agent/test_session_rotation_flush_cold_resume_68454.py (4 passed) * test: drop source-grep change-detector from #68480 The three behavior tests (control proves dup, boundary is noop, tail-only write) fully cover the flush semantics. The source-grep test reading cli.py + cli_commands_mixin.py as text and asserting a string appears is a change-detector that breaks on benign refactors without adding coverage. * test: update mock assertions for conversation_history kwarg The /branch and /resume flush tests asserted the old positional-only call signature. Update to match the fix from #68480. * fix(gateway): make adapter fatal-error handoff cancellation-proof; exit if a platform is stranded The fatal-error notification runs on the failing adapter's own polling task, and adapter.disconnect() inside the handler can cancel that task (its current-task guard misses because _safe_adapter_disconnect runs the close in a wrapper task). The CancelledError killed the handler between the fatal log and the reconnect queue, leaving the platform permanently dead inside a live gateway process. #68447 fixed this for telegram at the adapter layer; this hardens the shared gateway dispatch so every platform gets the same protection (qqbot #25505/#29005, photon #68693). - _handle_adapter_fatal_error now runs the real handler in a detached task, awaited through asyncio.shield() so caller cancellation cannot tunnel into it (Task.cancel() also cancels the task's _fut_waiter). - If a retryable platform still ends up neither reconnected nor queued, the gateway exits with failure so launchd/systemd KeepAlive restarts it instead of running indefinitely with a dead platform (#68693). Fixes #68693 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: map anoop.mehendale@gmail.com -> anoopmehendale-cue For PR #69007 salvage (#69112). * fix(update): isolate systemctl timeouts per gateway unit during fleet restart A TimeoutExpired from one hermes-gateway*.service used to abort the whole per-scope restart loop, leaving later profile gateways on pre-update in-memory code after hermes update. Catch timeouts per unit, continue the fleet, warn with the exact stale units, and exit non-zero when any remain unrestarted (#68523). * test(update): cover fleet restart timeout isolation (#68523) * fix: refresh vulnerable npm lockfile entries * chore: AUTHOR_MAP for tinetwork * fix(compression): prevent stale-budget retry loops * fix(compression): harden startup route scoping * fix(providers): align custom route scoping * test(compression): cover overflow after blocked preflight * test(providers): cover route URL identity boundaries * test(providers): cover query path slash identity * test(providers): complete hermetic route coverage * test(providers): cover URL whitespace route identity * fix(providers): fail closed on missing active route * fix(providers): scope route-owned runtime settings * fix: restore base_url rstrip, extract should_clear_context_pin helper Salvage follow-up for PR #68899: - Restore .rstrip('/') on base_url in _swap_credential (both anthropic and OpenAI paths) to match every other assignment site. The route identity comparison still uses normalize_route_base_url which handles trailing slash correctly. - Extract should_clear_context_pin() into hermes_cli/route_identity.py, consolidating 7 copy-pasted call sites across cli.py, gateway/run.py, gateway/slash_commands.py, and hermes_cli/model_switch.py into a single fail-closed helper. C1 (anthropic path TLS re-application): pre-existing gap — the Anthropic adapter (build_anthropic_client) has no TLS customization support at all, so this is out of scope for this salvage. * fix(compression): ignore assistant handoff summaries in tail anchor Assistant-role compaction summaries were treated as the last visible assistant reply after head protection decayed. That pulled the tail boundary back to the summary itself and left zero new turns to summarize. Exclude internal context summaries from both the visible-reply search and the assistant fallback, mirroring the existing user-role summary exclusion. * chore: AUTHOR_MAP for McHermes * fix(telegram): group authz fallback + command sender identity - authz_mixin: add config.extra fallback for group_allowed_chats when observe-unmentioned mode strips user_id from env-var check - authz_mixin: check adapter allow_from/group_allow_from for user authorization from config.yaml without env vars - telegram/adapter: separate group_allow_from for group chats vs allow_from for DMs - telegram/adapter: preserve sender source for command messages so admin-only slash commands work in groups - telegram/adapter: add _telegram_extra fallback for group_allow_from config reading * fix(telegram): address review findings from PR #67816 - Update test_observed_group_context_preserves_slash_command_text_for_dispatch to assert user_id is preserved for COMMAND messages (new correct behavior) - Add _coerce_allow_set helper to handle both list and comma-separated string allowlist inputs (prevents character-by-character iteration bug) - Include 'channel' in chat_type checks for group-scoped authorization - Add _telegram_extra fallback for group_allowed_chats (consistent with group_allow_from fallback) - Add AUTHOR_MAP entry for nyaruko@hermes -> tsuk1nose * fix(telegram): update auth check tests for group_allow_from split Update test_telegram_auth_check.py to use group_allow_from for group messages (matching the PR's intentional behavior split: allow_from for DMs, group_allow_from for groups). Add test_is_user_authorized_from_message_group_allow_from to cover the new group path. * fix(openviking): recover pending session commits * docs: clarify OpenViking local setup (cherry picked from commita6807170f1) (cherry picked from commit6fb4e9aa8a) * fix(openviking): serialize orphan session recovery * fix(openviking): chunk structured session sync Preserve ordered structured turns across OpenViking's 100-message batch limit and resume retries from the first unconfirmed message. Based on the OpenViking batching work from commit1a567f7067in #58981. * refactor: cleanup follow-up for salvaged PR #58871 - Remove dead current_sid parameter from _recover_pending_sessions - Remove dead cleanup parameter from _release_owner_run_claim (always True) - Set _run_lock_path after flock succeeds, not before - Collapse redundant BlockingIOError branch (covered by OSError+errno check) - Track _pending_marked_sids to skip re-writing marker file on every sync_turn * fix(openviking): inject session-start memory context (cherry picked from commit18b474d0bd) * fix(openviking): align session context with shared profile contract * fix: discard both session IDs on compression for profile re-injection The _profile_prefetched_sessions set stores whichever session_id was passed to prefetch(), which may differ from self._session_id. On compression, only old_session_id (self._session_id) was discarded, missing the case where the stored key was the prefetch session_id parameter. Discard both old and new IDs to cover all cases. * chore: add kshitij@kshitij.dev to AUTHOR_MAP * fix(secrets): fall back to stale disk cache when bws live fetch fails Without this, a single DNS hiccup or BWS outage at gateway startup leaves the whole fleet running with an empty credential pool — every model call fails until someone restarts after the network recovers. When a previous successful fetch already populated the disk cache, return those secrets with an explicit warning instead of raising RuntimeError. `use_cache=False` (explicit opt-out) still raises so manual flows like the setup wizard surface the original error. The disk cache is not re-written on the fallback path so a process restart still triggers a proper TTL re-check. Fixes #41925 * fix(secrets): port stale-cache fallback to current DiskCache API + gate by error kind The stale-fallback branch called _read_disk_cache(), a helper removed indb495b0fbawhen disk-cache logic moved to the shared DiskCache class — every fallback attempt raised NameError instead of serving cached secrets, silently defeating the PR's whole purpose. Port to _DISK_CACHE.read(). Also tighten the fallback per DiskCache's TTL contract and the secret-source error taxonomy: - Gate on cache_ttl_seconds > 0 so a caller that opted out of caching entirely (ttl=0) never gets a secret value that didn't come from a live fetch, even on the failure path. - Gate on _classify_bws_error(str(exc)) being NETWORK or TIMEOUT, reusing the existing classifier — an AUTH_FAILED or malformed-output failure must still raise, since serving stale secrets there would mask a real credential/config problem instead of a transient outage. Ported the test helpers off the removed _write_disk_cache to a direct JSON write (matching this file's existing disk-cache test convention) and added tests for the auth-failure, malformed-output, and zero-TTL gates. Reverting the fix and re-running confirms 7 of 8 stale-fallback tests fail with the original NameError. * fix(secrets): fold OP_CONNECT_HOST/OP_CONNECT_TOKEN into 1Password auth cache-key _auth_fingerprint() built the 1Password secret cache-key from the service-account token, OP_ACCOUNT, and OP_SESSION_* vars but omitted OP_CONNECT_HOST/OP_CONNECT_TOKEN, which are in _OP_ENV_ALLOWLIST and are forwarded to the op child (the Connect-server auth path). Rotating OP_CONNECT_TOKEN or re-pointing OP_CONNECT_HOST at a different Connect identity left the fingerprint unchanged, so both the in-process and disk caches kept serving secrets resolved under the old Connect credentials for the full TTL (default 300s, disk-persisted across invocations). This contradicts the function's own docstring invariant that a value cached under a previous identity is never served under a new one; it closes the gap for the Connect path, matching the OP_SESSION_*/service-account paths that are already protected. * fix(secrets): pass OP_LOAD_DESKTOP_APP_SETTINGS through to the op child env The 1Password secret source builds a minimal allowlisted environment for the `op read` child process. The allowlist omits OP_LOAD_DESKTOP_APP_SETTINGS, so a user who exports it (shell, .env, or service unit) sees it silently stripped before it reaches `op`. That var is `op`'s documented switch to skip the desktop-app integration probe. When the 1Password desktop app is installed, `op` probes its settings/socket at startup *before* evaluating service-account auth. If the desktop app's group container is wedged (e.g. macOS 'Interrupted system call' on the 1Password group container), that probe blocks with no timeout, so `op read` hangs indefinitely even with a valid OP_SERVICE_ACCOUNT_TOKEN present. Setting OP_LOAD_DESKTOP_APP_SETTINGS=false is the intended escape hatch — but stripping it means it has no effect on exactly the headless boxes that need it. Fix: add OP_LOAD_DESKTOP_APP_SETTINGS to _OP_ENV_ALLOWLIST so the documented var reaches the child. No behavior change when it's unset. Adds a focused test alongside the existing allowlist test. Repro: on a machine with a wedged 1Password desktop container + a valid SA token, `op read` hangs 600s+ without the var and returns in ~4s with it — but only if it actually reaches the op process, which this allowlist entry ensures. Co-authored-by: Minh Nguyen <menhguin@users.noreply.github.com> * fix(mcp): pass secret-source-injected env vars to stdio servers Surgical reapply of PR #37523 onto current main (the original branch predates the SecretSource registry refactor). _build_safe_env() now forwards env vars tagged in env_loader._SECRET_SOURCES — widened from Bitwarden-only to any registered secret source (Bitwarden, 1Password, plugin backends), since the provenance map is source-agnostic. Explicit server env: config still wins; untagged secrets stay filtered. Fixes #37499. * fix(env): stop printing Bitwarden secret names * fix(secrets): validate bitwarden status token Keep the env-presence row, but add a real Bitwarden probe so revoked or malformed tokens no longer look healthy in hermes secrets bitwarden status. Also document the new status behavior and lock it in with a dedicated regression test. Refs: NousResearch/hermes-agent#40275 Tested: ./scripts/run_tests.sh tests/hermes_cli/test_bitwarden_status.py tests/test_bitwarden_secrets.py Tested: .venv/bin/python -m ruff check hermes_cli/secrets_cli.py tests/hermes_cli/test_bitwarden_status.py * fix(secrets): mark _APPLIED_HOMES only after a real fetch attempt (#40597) (#69056) _apply_external_secret_sources() added the home to _APPLIED_HOMES before loading config, so a malformed config.yaml, a missing secrets section, or all-sources-disabled permanently disabled secret loading for the process — even after the user fixed the config. Long-lived processes (gateway) never recovered without a restart. Now the home is marked only after apply_all() actually ran with at least one enabled source. Fetch errors still mark the home (so import-time load_hermes_dotenv() calls don't re-fetch and re-print the same failure 3-5x per startup); the cheap early-exit paths stay retryable. Fixes #40597. * fix(secrets): fall back to os.environ on scope miss when multiplexing is offfdab380a1wraps every cron job in a <home>/.env secret scope regardless of deployment mode. get_secret() treats any installed scope as authoritative, so in single-profile deployments where provider keys live only in the process environment (systemd Environment=, pass-cli/op run wrappers, shell exports) every cron credential read returns empty, the OpenAI client is built with the no-key-required placeholder, and each scheduled job 401s — while interactive turns keep working. Scope-miss reads now fall through to os.environ when multiplexing is off; multiplexed scopes stay authoritative. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(gateway): activate multiplex flag in cross-profile env isolation test The test installs a secret scope and asserts a scope miss does NOT fall back to the default profile's env — that isolation guarantee only holds under multiplexing, which the real gateway activates at startup via set_multiplex_active(). With the #67827 overlay fallthrough (scope miss → os.environ when multiplex is OFF), the test needs to model the multiplexed runtime it is actually testing. * feat(secrets): orchestrator-level preserve_existing + profile aliasing (#69058) Fixes the profile-clobber bug cluster at the apply_all() chokepoint so every secret source — bundled and plugin — gets both behaviors for free: - secrets.preserve_existing (#58073): env var names whose existing .env / shell value always wins, even against a source with override_existing: true. Escape hatch for per-profile platform secrets while everything else rotates centrally. - Profile aliasing (#51447): under a named profile, an applied FOO_<PROFILE> var (credential-shaped suffixes only) also hydrates the canonical FOO, so adapters/plugins that read fixed env names see the profile's value. Direct supply beats alias; protected/claimed/ override guards all apply; secrets.profile_alias: false disables. Reimplements the intent of PR #58085 (tianma-if, preserve_existing on the legacy Bitwarden apply shim) and PR #51616 (LeonSGP43, profile aliasing inside the Bitwarden backend) on the SecretSource orchestrator that superseded those code paths. Fixes #58073. Fixes #51447. Co-authored-by: tianma-if <5895871+tianma-if@users.noreply.github.com> Co-authored-by: LeonSGP43 <154585401+LeonSGP43@users.noreply.github.com> * test(state): accept FTS5-specific corruption message in read-probe test With the v23 external-content FTS layout, corrupt shadow segments surface as 'fts5: corrupt structure record for table ...' instead of the generic 'database disk image is malformed'. Same corruption class, same variance already documented and handled by _is_fts_write_corruption_error — widen the test assertion to match. --------- Co-authored-by: ethernet <arilotter@gmail.com> Co-authored-by: brooklyn! <brooklyn.bb.nicholson@gmail.com> Co-authored-by: Gille <4317663+helix4u@users.noreply.github.com> Co-authored-by: yoniebans <jonny@nousresearch.com> Co-authored-by: Rod-fernandez <rodrigo@nxtlevelsaas.com> Co-authored-by: David Andrews (LexGenius.ai) <david@lexgenius.ai> Co-authored-by: xxxigm <54813621+xxxigm@users.noreply.github.com> Co-authored-by: hermes-seaeye[bot] <307254004+hermes-seaeye[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: SHL0MS <SHL0MS@users.noreply.github.com> Co-authored-by: HexLab98 <liruixinch@outlook.com> Co-authored-by: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Co-authored-by: PRATHAMESH75 <prathamesh290504@gmail.com> Co-authored-by: x7peeps <xtpeeps@qq.com> Co-authored-by: Imgaojp <6065749+Imgaojp@users.noreply.github.com> Co-authored-by: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Co-authored-by: Ben Barclay <ben@nousresearch.com> Co-authored-by: sbe27 <283218367+sbe27@users.noreply.github.com> Co-authored-by: TARS <tars@users.noreply.github.com> Co-authored-by: Austin Pickett <pickett.austin@gmail.com> Co-authored-by: Rudimar Ronsoni <rudimar@outlook.com> Co-authored-by: Austin Pickett <austinpickett@users.noreply.github.com> Co-authored-by: Joshua <joshua@amokk.net> Co-authored-by: alelpoan <155192176+alelpoan@users.noreply.github.com> Co-authored-by: liuhao1024 <sunsky.lau@gmail.com> Co-authored-by: Cossackx <121278003+Cossackx@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Jaret Bottoms <jaretbottoms@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Enough1122 <chenjin@hermes.local> Co-authored-by: Frowtek <frowte3k@gmail.com> Co-authored-by: Aniruddha Adak <aniruddhaadak80@users.noreply.github.com> Co-authored-by: eazye19 <eazye19@users.noreply.github.com> Co-authored-by: frohsinnllc <231045016+frohsinnllc@users.noreply.github.com> Co-authored-by: hanyu1212 <28571259+hanyu1212@users.noreply.github.com> Co-authored-by: 雨哥 <hanyu1212@users.noreply.github.com> Co-authored-by: yungchentang <46495124+yungchentang@users.noreply.github.com> Co-authored-by: trevorgordon981 <trevorbgordon@gmail.com> Co-authored-by: Börje <borje@dqsverige.se> Co-authored-by: fazerluga-creator <fazerluga@gmail.com> Co-authored-by: yoma <yingwaizhiying@gmail.com> Co-authored-by: Kennedy Umege <kenmege@yahoo.com> Co-authored-by: pierrenode <298902573+pierrenode@users.noreply.github.com> Co-authored-by: Sora-bluesky <sora.bluesky.dev@gmail.com> Co-authored-by: iamwongeeeee <wykim777@naver.com> Co-authored-by: web3blind <264741654+web3blind@users.noreply.github.com> Co-authored-by: Bartok9 <danielrpike9@gmail.com> Co-authored-by: anoopmehendale-cue <anoop.mehendale@gmail.com> Co-authored-by: tinetwork <martin@tinetwork.com> Co-authored-by: cucurigoo <241698038+cucurigoo@users.noreply.github.com> Co-authored-by: McHermes <mchermes@edu.dreamcatcher.ai> Co-authored-by: Nyaruko <nyaruko@hermes> Co-authored-by: Hao Zhe <haozhe4547@gmail.com> Co-authored-by: Naveen Fernando <90832919+NPFernando@users.noreply.github.com> Co-authored-by: Chris Korhonen <ckorhonen@gmail.com> Co-authored-by: Flownium <157689911+itsflownium@users.noreply.github.com> Co-authored-by: kshitij <kshitij@kshitij.dev> Co-authored-by: JackJin <1037461232@qq.com> Co-authored-by: briandevans <252620095+briandevans@users.noreply.github.com> Co-authored-by: menhguin <17287724+menhguin@users.noreply.github.com> Co-authored-by: Minh Nguyen <menhguin@users.noreply.github.com> Co-authored-by: SeoYeonKim <28585885+westkite1201@users.noreply.github.com> Co-authored-by: andrexibiza <84248988+andrexibiza@users.noreply.github.com> Co-authored-by: izumi0uu <izumi0uu@gmail.com> Co-authored-by: Soju06 <qlskssk@gmail.com> Co-authored-by: tianma-if <5895871+tianma-if@users.noreply.github.com> Co-authored-by: LeonSGP43 <154585401+LeonSGP43@users.noreply.github.com>
This commit is contained in:
parent
0faf4c838c
commit
9acc4b47f5
7 changed files with 1643 additions and 118 deletions
|
|
@ -3216,6 +3216,23 @@ DEFAULT_CONFIG = {
|
|||
# GBs of disk on heavy users. Opt in only if you have an external
|
||||
# tool that consumes the JSON files directly.
|
||||
"write_json_snapshots": False,
|
||||
# Search-index (FTS) storage optimization — the compact v23 layout
|
||||
# that drops duplicate content copies and stops trigram-indexing tool
|
||||
# output (typically reclaims ~60%+ of state.db on heavy users). It is
|
||||
# OPT-IN: existing databases keep their working legacy index until the
|
||||
# user runs `hermes sessions optimize-storage`, because the rebuild is
|
||||
# disk-heavy and long on large DBs (see that command's disk preflight).
|
||||
#
|
||||
# "advise" (default): `hermes update` prints a one-line notice with
|
||||
# the reclaimable size and the command, when a legacy index is
|
||||
# detected. Nothing is changed automatically.
|
||||
# "require": the notice is shown as a REQUIRED upgrade (firmer copy),
|
||||
# and future tooling may gate on it. Flip this default in a future
|
||||
# release when we're ready to make the v23 layout mandatory — the
|
||||
# command, progress bar, and resumability are already in place, so
|
||||
# enforcement is a copy/gating change, not new migration code.
|
||||
# "off": suppress the notice entirely.
|
||||
"fts_optimize_notice": "advise",
|
||||
},
|
||||
|
||||
# Contextual first-touch onboarding hints (see agent/onboarding.py).
|
||||
|
|
|
|||
|
|
@ -6347,6 +6347,116 @@ def _print_curator_first_run_notice() -> None:
|
|||
)
|
||||
|
||||
|
||||
def _print_fts_optimize_available_notice() -> None:
|
||||
"""Advertise the opt-in v23 search-index optimization after `hermes update`.
|
||||
|
||||
Only fires when the current profile's state.db is still on the legacy
|
||||
(pre-v23) inline FTS layout. Leads with the reclaimable-space figure and
|
||||
points at the exact command. Honors ``sessions.fts_optimize_notice``:
|
||||
``advise`` (default) prints an advisory notice, ``require`` prints a
|
||||
firmer required-upgrade notice, ``off`` suppresses it. Silent for
|
||||
fresh/already-optimized installs.
|
||||
"""
|
||||
mode = "advise"
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
mode = str(
|
||||
((load_config() or {}).get("sessions") or {}).get(
|
||||
"fts_optimize_notice", "advise"
|
||||
)
|
||||
).strip().lower()
|
||||
except Exception:
|
||||
mode = "advise"
|
||||
if mode == "off":
|
||||
return
|
||||
|
||||
try:
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_state import SessionDB
|
||||
except Exception:
|
||||
return
|
||||
db_path = get_hermes_home() / "state.db"
|
||||
if not db_path.exists():
|
||||
return
|
||||
try:
|
||||
size_gb = db_path.stat().st_size / (1024 ** 3)
|
||||
except OSError:
|
||||
return
|
||||
# Skip the notice for trivially small DBs — the win isn't worth the nag.
|
||||
if size_gb < 0.5:
|
||||
return
|
||||
db = None
|
||||
interrupted = False
|
||||
try:
|
||||
db = SessionDB(db_path=db_path, read_only=True)
|
||||
# read_only opens skip schema init, so probe the layout directly.
|
||||
row = db._conn.execute(
|
||||
"SELECT sql FROM sqlite_master "
|
||||
"WHERE type = 'table' AND name = 'messages_fts'"
|
||||
).fetchone()
|
||||
# An interrupted `optimize-storage` run: the table is already the
|
||||
# v23 shape, but backfill markers / demoted trash tables remain.
|
||||
# Offer the command again — re-running resumes and finishes it.
|
||||
interrupted = bool(
|
||||
db._conn.execute(
|
||||
"SELECT 1 FROM state_meta "
|
||||
"WHERE key = 'fts_rebuild_high_water' LIMIT 1"
|
||||
).fetchone()
|
||||
or db._conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type = 'table' "
|
||||
"AND name LIKE 'fts\\_v22\\_trash\\_%' ESCAPE '\\' LIMIT 1"
|
||||
).fetchone()
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
finally:
|
||||
if db is not None:
|
||||
try:
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
sql = (row[0] if row else "") or ""
|
||||
if not sql or ("tool_name" in sql and not interrupted):
|
||||
# v23 layout already present (fresh/optimized) — nothing to offer.
|
||||
return
|
||||
|
||||
if interrupted:
|
||||
print()
|
||||
print("◆ Session database optimization incomplete")
|
||||
print(
|
||||
" A previous `hermes sessions optimize-storage` run was "
|
||||
"interrupted. Search still works; re-run the command to resume "
|
||||
"and finish reclaiming disk:"
|
||||
)
|
||||
print(" hermes sessions optimize-storage")
|
||||
return
|
||||
|
||||
# Concrete size framing — lead with the savings the user cares about.
|
||||
est_reclaim = size_gb * 0.6
|
||||
print()
|
||||
if mode == "require":
|
||||
print("◆ Session database upgrade required")
|
||||
print(
|
||||
f" Your search index uses the OLD storage layout and should be "
|
||||
f"upgraded. The new layout typically frees ~60% of state.db "
|
||||
f"(≈{est_reclaim:.1f} GB of your current {size_gb:.1f} GB) and is "
|
||||
f"required for continued optimal operation."
|
||||
)
|
||||
else:
|
||||
print("◆ Reclaim ~60% of your session database disk")
|
||||
print(
|
||||
f" Your search index uses the old storage layout. Upgrading it "
|
||||
f"typically frees ~60% of state.db — about {est_reclaim:.1f} GB "
|
||||
f"of your current {size_gb:.1f} GB."
|
||||
)
|
||||
print(" Run when convenient: hermes sessions optimize-storage")
|
||||
print(
|
||||
" It runs in the foreground with a progress bar, is safe to "
|
||||
"interrupt/re-run, and never changes your conversations."
|
||||
)
|
||||
|
||||
|
||||
def _print_curator_recent_run_notice() -> None:
|
||||
"""Print the most recent curator run summary, exactly once.
|
||||
|
||||
|
|
@ -10951,6 +11061,17 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
|||
else:
|
||||
print("✓ Update complete!")
|
||||
|
||||
# Search-index optimization notice (v23). Existing installs keep their
|
||||
# working search index untouched on update; the compact v23 layout —
|
||||
# which reclaims a large fraction of state.db on heavy users — is
|
||||
# opt-in. Surface it here (the moment the user is already thinking
|
||||
# about their install) with the exact command and the concrete size
|
||||
# win. Show-once-ish: only when a legacy index is actually present.
|
||||
try:
|
||||
_print_fts_optimize_available_notice()
|
||||
except Exception as e:
|
||||
logger.debug("FTS optimize notice failed: %s", e)
|
||||
|
||||
# Curator first-run heads-up. Only prints when curator is enabled AND
|
||||
# has never run — i.e. the window where the ticker would otherwise
|
||||
# have fired against a fresh skill library. Kept silent on steady
|
||||
|
|
@ -14630,6 +14751,33 @@ def main():
|
|||
help="Reclaim disk space: merge FTS5 segments + VACUUM (no data change)",
|
||||
)
|
||||
|
||||
sessions_optimize_storage = sessions_subparsers.add_parser(
|
||||
"optimize-storage",
|
||||
help="Migrate the search index to the compact v23 layout (reclaims disk on large DBs)",
|
||||
description=(
|
||||
"Rebuild the full-text search index in the compact v23 "
|
||||
"external-content layout. On large databases this reclaims a "
|
||||
"large fraction of state.db (the old layout stored duplicate "
|
||||
"copies of every message and indexed tool output). Runs "
|
||||
"foreground with a progress bar, throttles so a running gateway "
|
||||
"stays responsive, and VACUUMs at the end. Safe to interrupt and "
|
||||
"re-run — it resumes where it left off. No conversation data is "
|
||||
"changed; only the search index is rebuilt."
|
||||
),
|
||||
)
|
||||
sessions_optimize_storage.add_argument(
|
||||
"--no-vacuum",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Skip the final VACUUM (index is rebuilt but freed pages aren't returned to the OS until a later VACUUM)",
|
||||
)
|
||||
sessions_optimize_storage.add_argument(
|
||||
"--yes", "-y",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Skip the disk-space confirmation prompt",
|
||||
)
|
||||
|
||||
sessions_repair = sessions_subparsers.add_parser(
|
||||
"repair",
|
||||
help="Repair a malformed state.db schema so hidden sessions reappear",
|
||||
|
|
@ -15412,6 +15560,96 @@ def main():
|
|||
f"(reclaimed {saved:.1f} MB)"
|
||||
)
|
||||
|
||||
elif action == "optimize-storage":
|
||||
db_path = db.db_path
|
||||
if not db.fts_optimize_available():
|
||||
print("Search index is already on the compact layout — nothing to do.")
|
||||
db.close()
|
||||
return
|
||||
|
||||
before_bytes = os.path.getsize(db_path) if db_path.exists() else 0
|
||||
before_mb = before_bytes / (1024 * 1024)
|
||||
|
||||
# Disk preflight: the rebuild adds the new index before the old is
|
||||
# torn down, and the final VACUUM needs a full second copy of the
|
||||
# file. Require headroom ≈ current file size to finish cleanly.
|
||||
do_vacuum = not getattr(args, "no_vacuum", False)
|
||||
try:
|
||||
import shutil as _shutil
|
||||
free_bytes = _shutil.disk_usage(db_path.parent).free
|
||||
except Exception:
|
||||
free_bytes = None
|
||||
need_bytes = before_bytes if do_vacuum else int(before_bytes * 0.3)
|
||||
print(f"Search-index optimization for {db_path}")
|
||||
print(f" Current database size: {before_mb:.1f} MB")
|
||||
if free_bytes is not None:
|
||||
print(f" Free disk: {free_bytes / (1024*1024):.0f} MB "
|
||||
f"(need ~{need_bytes / (1024*1024):.0f} MB to complete"
|
||||
f"{' incl. VACUUM' if do_vacuum else ''})")
|
||||
if free_bytes < need_bytes:
|
||||
print()
|
||||
print("⚠ Not enough free disk to complete safely. Free up "
|
||||
"space, or run with --no-vacuum (rebuilds the index "
|
||||
"but doesn't reclaim space until a later VACUUM).")
|
||||
db.close()
|
||||
return
|
||||
if before_mb > 500:
|
||||
print(" This may take a while on a large database. It runs in "
|
||||
"the foreground with progress below; safe to Ctrl-C and "
|
||||
"re-run (it resumes).")
|
||||
if not getattr(args, "yes", False):
|
||||
try:
|
||||
resp = input("Proceed? [y/N] ").strip().lower()
|
||||
except EOFError:
|
||||
resp = ""
|
||||
if resp not in ("y", "yes"):
|
||||
print("Cancelled.")
|
||||
db.close()
|
||||
return
|
||||
|
||||
_last = {"phase": None}
|
||||
|
||||
def _progress(info):
|
||||
phase = info.get("phase")
|
||||
pct = info.get("percent", 0)
|
||||
if phase == "backfill":
|
||||
print(f"\r Rebuilding index: {pct:3d}% "
|
||||
f"({info.get('indexed',0):,}/{info.get('total',0):,})",
|
||||
end="", flush=True)
|
||||
elif phase != _last["phase"]:
|
||||
label = {"teardown": "Reclaiming old index",
|
||||
"vacuum": "Compacting database (VACUUM)",
|
||||
"done": "Done"}.get(phase, phase)
|
||||
print(f"\n {label}…", flush=True)
|
||||
_last["phase"] = phase
|
||||
|
||||
print("Optimizing search-index storage…")
|
||||
try:
|
||||
result = db.optimize_fts_storage(
|
||||
progress_cb=_progress, vacuum=do_vacuum
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"\nError: optimization failed: {e}")
|
||||
print("No data was lost. Re-run to resume.")
|
||||
db.close()
|
||||
return
|
||||
if not result.get("ok"):
|
||||
print(f"\nCould not optimize: {result.get('reason', 'unknown')}")
|
||||
db.close()
|
||||
return
|
||||
after_mb = (
|
||||
os.path.getsize(db_path) / (1024 * 1024) if db_path.exists() else 0.0
|
||||
)
|
||||
saved = before_mb - after_mb
|
||||
print(f"\n✓ Search index optimized.")
|
||||
print(
|
||||
f" Database size: {before_mb:.1f} MB -> {after_mb:.1f} MB "
|
||||
f"(reclaimed {saved:.1f} MB)"
|
||||
)
|
||||
if result.get("vacuumed") is False:
|
||||
print(" (VACUUM was skipped or failed — run "
|
||||
"`hermes sessions optimize` later to reclaim freed space.)")
|
||||
|
||||
elif action == "stats":
|
||||
total = db.session_count()
|
||||
msgs = db.message_count()
|
||||
|
|
|
|||
|
|
@ -3181,6 +3181,27 @@ async def get_status(profile: Optional[str] = None):
|
|||
else "degraded"
|
||||
)
|
||||
|
||||
# Deferred FTS rebuild progress (schema v23): lets the desktop /
|
||||
# dashboard render a "search index rebuilding: N%" indicator instead
|
||||
# of users wondering why old-message search is slower after an
|
||||
# update. None/absent when no rebuild is pending (the common case).
|
||||
# Read-only probe, never blocks startup, never raises.
|
||||
try:
|
||||
from hermes_state import SessionDB as _SDB
|
||||
from hermes_constants import get_hermes_home as _ghh
|
||||
|
||||
_db_path = _ghh() / "state.db"
|
||||
if _db_path.exists():
|
||||
_sdb = _SDB(db_path=_db_path, read_only=True)
|
||||
try:
|
||||
_rebuild = _sdb.fts_rebuild_status()
|
||||
finally:
|
||||
_sdb.close()
|
||||
if _rebuild is not None:
|
||||
status["fts_rebuild"] = _rebuild
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Profile + gateway topology: which profiles exist, whether one
|
||||
# multiplexed gateway or several per-profile gateways serve them, and
|
||||
# (gated) which host ports the live gateways' port-binding platforms
|
||||
|
|
|
|||
985
hermes_state.py
985
hermes_state.py
File diff suppressed because it is too large
Load diff
|
|
@ -3,6 +3,8 @@
|
|||
import sqlite3
|
||||
import time
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_state
|
||||
|
|
@ -792,24 +794,50 @@ class TestSessionLifecycle:
|
|||
def test_v11_migration_backfills_base_fts_when_trigram_unavailable(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""Regression: v11 migration must backfill base FTS even when trigram is unavailable."""
|
||||
"""A legacy inline-FTS DB opened under a no-trigram runtime keeps its
|
||||
base FTS searchable (and is flagged optimizable) without crashing.
|
||||
|
||||
Opt-in model: opening never auto-migrates. The legacy single-column
|
||||
index keeps working for content search; the trigram tokenizer being
|
||||
unavailable must not break base FTS or the open itself.
|
||||
"""
|
||||
real_connect = sqlite3.connect
|
||||
db_path = tmp_path / "state.db"
|
||||
|
||||
# Phase 1: create a DB at schema v10 with messages.
|
||||
db = SessionDB(db_path=db_path)
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
db.append_message("s1", role="user", content="legacy message alpha")
|
||||
db.append_message("s1", role="assistant", content="legacy reply beta")
|
||||
# Force schema version to v10 so migration runs on next open.
|
||||
db._conn.execute(
|
||||
"UPDATE schema_version SET version = 10"
|
||||
# Phase 1: build a genuine legacy inline DB by hand (single-column
|
||||
# messages_fts, no trigram table), at an old schema version.
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.executescript(SCHEMA_SQL)
|
||||
conn.executescript("""
|
||||
DROP TABLE IF EXISTS messages_fts;
|
||||
DROP TABLE IF EXISTS messages_fts_trigram;
|
||||
DROP VIEW IF EXISTS messages_fts_trigram_src;
|
||||
CREATE VIRTUAL TABLE messages_fts USING fts5(content);
|
||||
CREATE TRIGGER messages_fts_insert AFTER INSERT ON messages BEGIN
|
||||
INSERT INTO messages_fts(rowid, content) VALUES (new.id, COALESCE(new.content,''));
|
||||
END;
|
||||
""")
|
||||
conn.execute("DELETE FROM schema_version")
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (10)")
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (id, source, started_at) VALUES ('s1', 'cli', ?)",
|
||||
(time.time(),),
|
||||
)
|
||||
db._conn.commit()
|
||||
db.close()
|
||||
for role, content in (
|
||||
("user", "legacy message alpha"),
|
||||
("assistant", "legacy reply beta"),
|
||||
):
|
||||
conn.execute(
|
||||
"INSERT INTO messages (session_id, timestamp, role, content) "
|
||||
"VALUES ('s1', ?, ?, ?)",
|
||||
(time.time(), role, content),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Phase 2: reopen with trigram disabled — migration should still
|
||||
# backfill base FTS and make existing messages searchable.
|
||||
# Phase 2: reopen with trigram disabled — must NOT crash, base FTS
|
||||
# keeps working, and the DB is flagged optimizable (opt-in, so no
|
||||
# auto-migration and the version stays put).
|
||||
def connect_without_trigram(*args, **kwargs):
|
||||
kwargs["factory"] = _NoTrigramConnection
|
||||
return real_connect(*args, **kwargs)
|
||||
|
|
@ -821,6 +849,7 @@ class TestSessionLifecycle:
|
|||
assert migrated_db._trigram_available is False
|
||||
assert migrated_db._fts_table_exists("messages_fts") is True
|
||||
assert migrated_db._fts_table_exists("messages_fts_trigram") is False
|
||||
assert migrated_db.fts_optimize_available() is True
|
||||
|
||||
# Existing messages must be searchable via base FTS.
|
||||
results = migrated_db.search_messages("legacy message")
|
||||
|
|
@ -3749,11 +3778,15 @@ class TestSchemaInit:
|
|||
migrated_db.close()
|
||||
|
||||
def test_v9_migration_skips_v10_trigram_backfill_before_v11_rebuild(self, tmp_path, monkeypatch):
|
||||
"""Direct v9→current migration should do only the v11 FTS rebuild.
|
||||
"""Direct v9→current migration should do only the v23 FTS rebuild.
|
||||
|
||||
v10 backfilled ``messages_fts_trigram`` with content-only rows. Current
|
||||
v11+ migration immediately drops and rebuilds both FTS tables with
|
||||
content + tool metadata, so running the v10 insert first is wasted work.
|
||||
v10 backfilled ``messages_fts_trigram`` with content-only rows. The
|
||||
current migration immediately drops and rebuilds both FTS tables in
|
||||
external-content form, so running the v10 insert first is wasted work.
|
||||
|
||||
v23 contract: tool rows are excluded from the trigram index (they
|
||||
remain fully searchable via the standard index); non-tool rows are
|
||||
indexed in both.
|
||||
"""
|
||||
db_path = tmp_path / "v9_fts.db"
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
|
|
@ -3769,6 +3802,11 @@ class TestSchemaInit:
|
|||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
("s1", "tool", "plain content", "browser_snapshot", '{"name":"browser_snapshot"}', 1001.0),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO messages (session_id, role, content, timestamp) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
("s1", "assistant", "assistant summary of the snapshot", 1002.0),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
|
@ -3794,16 +3832,37 @@ class TestSchemaInit:
|
|||
try:
|
||||
assert trigram_content_only_inserts == []
|
||||
version = migrated_db._conn.execute("SELECT version FROM schema_version").fetchone()[0]
|
||||
# This DB was built via SCHEMA_SQL, so its FTS is already the v23
|
||||
# external-content shape — not a legacy inline install. Opening it
|
||||
# therefore advances the version to current (no opt-in gate) and
|
||||
# runs no backfill (rows were indexed live by the v23 triggers).
|
||||
assert version == SCHEMA_VERSION
|
||||
normal_count = migrated_db._conn.execute("SELECT COUNT(*) FROM messages_fts").fetchone()[0]
|
||||
trigram_count = migrated_db._conn.execute("SELECT COUNT(*) FROM messages_fts_trigram").fetchone()[0]
|
||||
assert normal_count == 1
|
||||
assert migrated_db.fts_optimize_available() is False
|
||||
assert migrated_db.fts_rebuild_status() is None
|
||||
# Standard FTS indexes every row, including tool output (MATCH
|
||||
# probes the index; COUNT(*) on external-content tables doesn't).
|
||||
normal_count = migrated_db._conn.execute(
|
||||
"SELECT COUNT(*) FROM messages_fts WHERE messages_fts MATCH 'snapshot'"
|
||||
).fetchone()[0]
|
||||
assert normal_count == 2
|
||||
# Trigram excludes role='tool' rows (v23) but keeps non-tool rows.
|
||||
trigram_count = migrated_db._conn.execute(
|
||||
"SELECT COUNT(*) FROM messages_fts_trigram "
|
||||
"WHERE messages_fts_trigram MATCH 'snapshot'"
|
||||
).fetchone()[0]
|
||||
assert trigram_count == 1
|
||||
# Tool metadata stays searchable via the standard index (#16751).
|
||||
tool_hit = migrated_db._conn.execute(
|
||||
"SELECT COUNT(*) FROM messages_fts "
|
||||
"WHERE messages_fts MATCH 'browser_snapshot'"
|
||||
).fetchone()[0]
|
||||
assert tool_hit == 1
|
||||
# And is intentionally absent from the trigram index.
|
||||
tri_tool_hit = migrated_db._conn.execute(
|
||||
"SELECT COUNT(*) FROM messages_fts_trigram "
|
||||
"WHERE messages_fts_trigram MATCH 'browser_snapshot'"
|
||||
).fetchone()[0]
|
||||
assert tool_hit == 1
|
||||
assert tri_tool_hit == 0
|
||||
finally:
|
||||
migrated_db.close()
|
||||
|
||||
|
|
@ -5200,14 +5259,22 @@ class TestFTS5ToolCallMigration:
|
|||
assert legacy_hits == [], "sanity: legacy FTS must NOT contain tool_name"
|
||||
conn.close()
|
||||
|
||||
# Now open via SessionDB — migration runs.
|
||||
# Open via SessionDB — the legacy DB is detected as optimizable but
|
||||
# NOT auto-migrated (opt-in). Its old content-only index still works
|
||||
# for content, but doesn't yet cover tool_name/tool_calls (#16751).
|
||||
session_db = SessionDB(db_path=db_path)
|
||||
try:
|
||||
assert session_db.fts_optimize_available() is True
|
||||
|
||||
# `hermes db optimize` performs the v23 transition; afterwards the
|
||||
# tool fields are searchable.
|
||||
result = session_db.optimize_fts_storage(vacuum=False)
|
||||
assert result["ok"] is True
|
||||
assert len(session_db.search_messages("LEGACYTOOL")) == 1, \
|
||||
"v11 migration must backfill tool_name into FTS"
|
||||
"v23 optimize must index tool_name into FTS"
|
||||
assert len(session_db.search_messages("LEGACYARG")) == 1, \
|
||||
"v11 migration must backfill tool_calls JSON into FTS"
|
||||
# schema_version bumped
|
||||
"v23 optimize must index tool_calls JSON into FTS"
|
||||
# schema_version bumped once the FTS layer is v23
|
||||
from hermes_state import SCHEMA_VERSION
|
||||
row = session_db._conn.execute(
|
||||
"SELECT version FROM schema_version LIMIT 1"
|
||||
|
|
@ -5218,6 +5285,343 @@ class TestFTS5ToolCallMigration:
|
|||
session_db.close()
|
||||
|
||||
|
||||
class TestFTSExternalContentMigration:
|
||||
"""v23 migration: inline-mode FTS tables (v11-v22) are rebuilt as
|
||||
external-content tables, and role='tool' rows are excluded from the
|
||||
trigram index while remaining searchable via the standard index."""
|
||||
|
||||
@staticmethod
|
||||
def _build_v22_db(db_path):
|
||||
"""Build a v22-shaped DB by hand: inline FTS tables + concat triggers."""
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.executescript(SCHEMA_SQL)
|
||||
# Replace the current (v23) FTS objects with the v22 inline shape.
|
||||
conn.executescript("""
|
||||
DROP TABLE IF EXISTS messages_fts;
|
||||
DROP TABLE IF EXISTS messages_fts_trigram;
|
||||
DROP VIEW IF EXISTS messages_fts_trigram_src;
|
||||
|
||||
CREATE VIRTUAL TABLE messages_fts USING fts5(content);
|
||||
CREATE TRIGGER messages_fts_insert AFTER INSERT ON messages BEGIN
|
||||
INSERT INTO messages_fts(rowid, content) VALUES (
|
||||
new.id,
|
||||
COALESCE(new.content, '') || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '')
|
||||
);
|
||||
END;
|
||||
|
||||
CREATE VIRTUAL TABLE messages_fts_trigram USING fts5(content, tokenize='trigram');
|
||||
CREATE TRIGGER messages_fts_trigram_insert AFTER INSERT ON messages BEGIN
|
||||
INSERT INTO messages_fts_trigram(rowid, content) VALUES (
|
||||
new.id,
|
||||
COALESCE(new.content, '') || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '')
|
||||
);
|
||||
END;
|
||||
""")
|
||||
conn.execute("DELETE FROM schema_version")
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (22)")
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (id, source, started_at) VALUES ('s1', 'cli', ?)",
|
||||
(time.time(),),
|
||||
)
|
||||
rows = [
|
||||
("user", "find the 大别山项目 deployment notes", None, None),
|
||||
("assistant", "关于大别山项目的总结在这里", None,
|
||||
'{"function":{"name":"send_message","arguments":"{}"}}'),
|
||||
("tool", "TOOLBLOB " + "x" * 5000 + " 项目文件内容测试", "read_file", None),
|
||||
]
|
||||
for role, content, tool_name, tool_calls in rows:
|
||||
conn.execute(
|
||||
"INSERT INTO messages (session_id, timestamp, role, content, tool_name, tool_calls) "
|
||||
"VALUES ('s1', ?, ?, ?, ?, ?)",
|
||||
(time.time(), role, content, tool_name, tool_calls),
|
||||
)
|
||||
conn.commit()
|
||||
# Sanity: v22 inline tables have their own content shadow tables.
|
||||
shadow = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE name = 'messages_fts_content'"
|
||||
).fetchall()
|
||||
assert shadow, "sanity: v22 inline FTS must have a content shadow table"
|
||||
conn.close()
|
||||
|
||||
def test_v22_open_leaves_legacy_untouched_and_advertises(self, tmp_path):
|
||||
"""Opening a legacy v22 DB must NOT auto-migrate the FTS layout, but
|
||||
the main schema_version DOES advance (decoupled) so future non-FTS
|
||||
migrations aren't blocked. The inline index keeps working and the
|
||||
opt-in flag is set."""
|
||||
db_path = tmp_path / "v22.db"
|
||||
self._build_v22_db(db_path)
|
||||
|
||||
db = SessionDB(db_path=db_path)
|
||||
try:
|
||||
# DECOUPLED: the main schema_version advances to current even though
|
||||
# the FTS layout stays legacy — future migrations must not be gated
|
||||
# behind the FTS opt-in.
|
||||
version = db._conn.execute(
|
||||
"SELECT version FROM schema_version"
|
||||
).fetchone()[0]
|
||||
assert version == SCHEMA_VERSION, "main schema version must advance"
|
||||
# But the FTS storage layout is NOT stamped current — it's legacy.
|
||||
assert db.get_meta("fts_storage_version") is None
|
||||
assert db.fts_optimize_available() is True
|
||||
assert db.get_meta("fts_optimize_available") == "1"
|
||||
|
||||
# Legacy inline shape is intact (content shadow table still there).
|
||||
assert db._conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE name = 'messages_fts_content'"
|
||||
).fetchone() is not None
|
||||
|
||||
# Search still works on the legacy index (no deferred rebuild).
|
||||
assert db.fts_rebuild_status() is None
|
||||
assert len(db.search_messages("deployment")) == 1
|
||||
assert len(db.search_messages("send_message")) == 1 # #16751 held
|
||||
|
||||
# A new write is indexed live by the legacy triggers.
|
||||
db.append_message("s1", role="user", content="AFTEROPEN token")
|
||||
assert len(db.search_messages("AFTEROPEN")) == 1
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def test_optimize_fts_storage_transitions_to_v23(self, tmp_path):
|
||||
"""`optimize_fts_storage()` migrates a legacy DB to v23 external-content
|
||||
to completion: no shadow copies, tool rows excluded from trigram,
|
||||
version bumped, everything searchable exactly once."""
|
||||
db_path = tmp_path / "v22.db"
|
||||
self._build_v22_db(db_path)
|
||||
|
||||
db = SessionDB(db_path=db_path)
|
||||
try:
|
||||
assert db.fts_optimize_available() is True
|
||||
result = db.optimize_fts_storage(vacuum=False)
|
||||
assert result["ok"] is True
|
||||
|
||||
# Layout stamped current; flag cleared; no longer "available".
|
||||
assert db.get_meta("fts_storage_version") == str(
|
||||
hermes_state.FTS_STORAGE_VERSION
|
||||
)
|
||||
assert db._conn.execute(
|
||||
"SELECT version FROM schema_version"
|
||||
).fetchone()[0] == SCHEMA_VERSION
|
||||
assert db.fts_optimize_available() is False
|
||||
assert db.fts_rebuild_status() is None
|
||||
|
||||
# External-content: no *_content shadow tables, no trash left.
|
||||
for shadow in ("messages_fts_content", "messages_fts_trigram_content"):
|
||||
assert db._conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE name = ?", (shadow,)
|
||||
).fetchone() is None
|
||||
assert db._conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE name LIKE '%_v22_trash%'"
|
||||
).fetchall() == []
|
||||
|
||||
# Standard FTS: all rows incl tool metadata (#16751).
|
||||
assert len(db.search_messages("TOOLBLOB")) == 1
|
||||
assert len(db.search_messages("send_message")) == 1
|
||||
# Trigram excludes tool rows; CJK conversation search works.
|
||||
assert len(db.search_messages("大别山项目")) == 2
|
||||
assert db._conn.execute(
|
||||
"SELECT COUNT(*) FROM messages_fts_trigram "
|
||||
"WHERE messages_fts_trigram MATCH '\"项目文件内容\"'"
|
||||
).fetchone()[0] == 0
|
||||
assert db.search_messages("项目文件内容", role_filter=["tool"]) != []
|
||||
# No duplicate index entries; integrity clean.
|
||||
for term in ("TOOLBLOB", "deployment"):
|
||||
assert db._conn.execute(
|
||||
"SELECT COUNT(*) FROM messages_fts WHERE messages_fts MATCH ?",
|
||||
(term,),
|
||||
).fetchone()[0] == 1
|
||||
db._conn.execute(
|
||||
"INSERT INTO messages_fts(messages_fts, rank) VALUES('integrity-check', 1)"
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def test_optimize_fts_storage_resumable_after_interrupt(self, tmp_path):
|
||||
"""A partially-completed optimize resumes on re-run: after demote +
|
||||
one chunk, re-invoking finishes without duplicating rows."""
|
||||
db_path = tmp_path / "v22.db"
|
||||
self._build_v22_db(db_path)
|
||||
|
||||
db = SessionDB(db_path=db_path)
|
||||
try:
|
||||
# Simulate an interrupted run: demote + a single backfill chunk,
|
||||
# then stop (as if the process died mid-optimize).
|
||||
db._demote_legacy_fts_to_trash()
|
||||
assert db.fts_rebuild_status() is not None
|
||||
db.fts_rebuild_step() # one chunk only
|
||||
|
||||
# Old rows not yet backfilled are still findable via gap supplement.
|
||||
assert len(db.search_messages("TOOLBLOB")) == 1
|
||||
|
||||
# Re-run the full command — must resume, not restart or duplicate.
|
||||
result = db.optimize_fts_storage(vacuum=False)
|
||||
assert result["ok"] is True
|
||||
assert db.fts_rebuild_status() is None
|
||||
assert db._conn.execute(
|
||||
"SELECT version FROM schema_version"
|
||||
).fetchone()[0] == SCHEMA_VERSION
|
||||
for term in ("TOOLBLOB", "deployment"):
|
||||
assert db._conn.execute(
|
||||
"SELECT COUNT(*) FROM messages_fts WHERE messages_fts MATCH ?",
|
||||
(term,),
|
||||
).fetchone()[0] == 1
|
||||
db._conn.execute(
|
||||
"INSERT INTO messages_fts(messages_fts, rank) VALUES('integrity-check', 1)"
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def test_interrupted_optimize_reopen_still_reports_available(self, tmp_path):
|
||||
"""An interrupted optimize followed by a process restart must keep
|
||||
offering the resume: the legacy vtables are gone (demoted), so the
|
||||
legacy-shape check alone would say "already compact" — the gate has
|
||||
to accept pending rebuild markers / trash tables too. And the reopen
|
||||
must NOT stamp fts_storage_version (the transition isn't done)."""
|
||||
db_path = tmp_path / "v22.db"
|
||||
self._build_v22_db(db_path)
|
||||
|
||||
db = SessionDB(db_path=db_path)
|
||||
try:
|
||||
db._demote_legacy_fts_to_trash()
|
||||
db.fts_rebuild_step() # one chunk, then "the process dies"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Fresh open, as the CLI would after the interrupt.
|
||||
db = SessionDB(db_path=db_path)
|
||||
try:
|
||||
# The CLI gate must still offer optimize-storage (resume).
|
||||
assert db.fts_optimize_available() is True
|
||||
# The layout must NOT be stamped current mid-transition.
|
||||
assert db.get_meta("fts_storage_version") is None
|
||||
# Search stays complete through the gap supplement meanwhile.
|
||||
assert len(db.search_messages("TOOLBLOB")) == 1
|
||||
|
||||
# Re-running the command resumes and completes the transition.
|
||||
result = db.optimize_fts_storage(vacuum=False)
|
||||
assert result["ok"] is True
|
||||
assert db.fts_optimize_available() is False
|
||||
assert db.get_meta("fts_storage_version") == str(
|
||||
hermes_state.FTS_STORAGE_VERSION
|
||||
)
|
||||
assert db._conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE name LIKE '%_v22_trash%'"
|
||||
).fetchall() == []
|
||||
for term in ("TOOLBLOB", "deployment"):
|
||||
assert db._conn.execute(
|
||||
"SELECT COUNT(*) FROM messages_fts WHERE messages_fts MATCH ?",
|
||||
(term,),
|
||||
).fetchone()[0] == 1
|
||||
db._conn.execute(
|
||||
"INSERT INTO messages_fts(messages_fts, rank) VALUES('integrity-check', 1)"
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def test_v23_fresh_db_born_optimized(self, tmp_path):
|
||||
"""A brand-new DB is born on v23 — no legacy layout, no opt-in flag,
|
||||
no pending rebuild."""
|
||||
db = SessionDB(db_path=tmp_path / "fresh.db")
|
||||
try:
|
||||
assert db.fts_optimize_available() is False
|
||||
assert db.fts_rebuild_status() is None
|
||||
assert db.get_meta("fts_optimize_available") is None
|
||||
# Already external-content: no shadow copy tables.
|
||||
assert db._conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE name = 'messages_fts_content'"
|
||||
).fetchone() is None
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
db.append_message("s1", role="user", content="hello fresh world")
|
||||
assert len(db.search_messages("fresh")) == 1
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def test_v23_trigram_stays_in_sync_on_write_paths(self, tmp_path):
|
||||
"""INSERT/UPDATE/DELETE through SessionDB keep both indexes coherent
|
||||
under the new trigger shape (integrity-check verifies external
|
||||
content agreement)."""
|
||||
db = SessionDB(db_path=tmp_path / "fresh.db")
|
||||
try:
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
db.append_message("s1", role="user", content="搜索大别山项目相关资料")
|
||||
db.append_message("s1", role="tool", content="工具输出的大段内容在这里",
|
||||
tool_name="web_search")
|
||||
db.append_message("s1", role="assistant", content="assistant reply")
|
||||
|
||||
# Trigram: user+assistant only; standard: everything.
|
||||
assert db._conn.execute("SELECT COUNT(*) FROM messages_fts_trigram").fetchone()[0] == 2
|
||||
assert db._conn.execute("SELECT COUNT(*) FROM messages_fts").fetchone()[0] == 3
|
||||
|
||||
# Rewind-style UPDATE (active=0) must not desync the index — the
|
||||
# triggers only fire on content/tool column changes.
|
||||
def _deactivate(conn):
|
||||
conn.execute("UPDATE messages SET active = 0 WHERE role = 'assistant'")
|
||||
db._execute_write(_deactivate)
|
||||
|
||||
# FTS5 integrity-check raises SQLITE_CORRUPT_VTAB on any
|
||||
# index/content disagreement; passing = indexes are coherent.
|
||||
db._conn.execute(
|
||||
"INSERT INTO messages_fts(messages_fts, rank) VALUES('integrity-check', 1)"
|
||||
)
|
||||
db._conn.execute(
|
||||
"INSERT INTO messages_fts_trigram(messages_fts_trigram, rank) "
|
||||
"VALUES('integrity-check', 1)"
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def test_v23_cjk_tool_role_filter_uses_like_fallback(self, tmp_path):
|
||||
"""A CJK query with role_filter=['tool'] must bypass the trigram index
|
||||
(tool rows aren't in it) and still find matches via LIKE."""
|
||||
db = SessionDB(db_path=tmp_path / "fresh.db")
|
||||
try:
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
db.append_message("s1", role="tool", content="错误日志:数据库连接超时",
|
||||
tool_name="terminal")
|
||||
hits = db.search_messages("数据库连接", role_filter=["tool"])
|
||||
assert len(hits) == 1
|
||||
assert hits[0]["role"] == "tool"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def test_cjk_like_fallback_hides_rewound_messages(self, tmp_path):
|
||||
"""The CJK LIKE fallback must honor the same visibility rule as the
|
||||
FTS5 paths: rewound rows (active=0, compacted=0) are hidden unless
|
||||
include_inactive=True; compaction-archived rows (active=0,
|
||||
compacted=1) stay discoverable (#38763)."""
|
||||
db = SessionDB(db_path=tmp_path / "fresh.db")
|
||||
try:
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
db.append_message("s1", role="user", content="被撤销的搜索目标内容")
|
||||
db.append_message("s1", role="user", content="被压缩归档的搜索目标内容")
|
||||
|
||||
def _flags(conn):
|
||||
# First row: rewound (active=0, compacted=0) — hidden.
|
||||
conn.execute(
|
||||
"UPDATE messages SET active = 0 WHERE content LIKE '%撤销%'"
|
||||
)
|
||||
# Second row: compaction-archived (active=0, compacted=1) — visible.
|
||||
conn.execute(
|
||||
"UPDATE messages SET active = 0, compacted = 1 "
|
||||
"WHERE content LIKE '%归档%'"
|
||||
)
|
||||
db._execute_write(_flags)
|
||||
|
||||
# Short-CJK query (2 chars — below the 3-char trigram minimum)
|
||||
# forces the LIKE fallback; both rows contain the token 内容.
|
||||
# search_messages strips full content from results — assert on
|
||||
# the snippet column instead.
|
||||
hits = db.search_messages("内容")
|
||||
snippets = [h["snippet"] or "" for h in hits]
|
||||
assert any("归档" in s for s in snippets), "archived row must stay visible"
|
||||
assert not any("撤销" in s for s in snippets), "rewound row must be hidden"
|
||||
|
||||
# include_inactive=True surfaces everything.
|
||||
all_hits = db.search_messages("内容", include_inactive=True)
|
||||
assert len(all_hits) == 2
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# apply_wal_with_fallback — read-only probe tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -298,7 +298,17 @@ def test_fts_read_corruption_detected_by_read_probe(tmp_path):
|
|||
reason = _db_opens_cleanly(db_path)
|
||||
assert reason is not None
|
||||
assert "messages_fts" in reason
|
||||
assert "malformed" in reason.lower() or "database disk image" in reason.lower()
|
||||
# Message varies by SQLite build (same variance documented in
|
||||
# SessionDB._is_fts_write_corruption_error): older builds raise the
|
||||
# generic "database disk image is malformed"; newer builds raise the
|
||||
# FTS5-specific 'fts5: corrupt structure record for table "..."'.
|
||||
# Both are the same corruption class.
|
||||
reason_l = reason.lower()
|
||||
assert (
|
||||
"malformed" in reason_l
|
||||
or "database disk image" in reason_l
|
||||
or ("fts5" in reason_l and "corrupt" in reason_l)
|
||||
)
|
||||
|
||||
|
||||
def test_fts_read_corruption_repaired_in_place(tmp_path):
|
||||
|
|
|
|||
|
|
@ -103,6 +103,28 @@ def _resolve_to_parent(db, session_id: str) -> str:
|
|||
return cur
|
||||
|
||||
|
||||
def _annotate_rebuild_status(db, payload: Dict[str, Any]) -> None:
|
||||
"""Add a rebuild-progress note when the deferred FTS backfill (schema
|
||||
v23) is still running, so the agent can tell the user why older results
|
||||
may be incomplete/slower instead of treating a thin result set as
|
||||
ground truth. No-op (and never raises) when no rebuild is pending."""
|
||||
try:
|
||||
status = db.fts_rebuild_status()
|
||||
except Exception:
|
||||
return
|
||||
if status is None:
|
||||
return
|
||||
payload["index_rebuild"] = {
|
||||
"percent": status["percent"],
|
||||
"note": (
|
||||
f"The search index is rebuilding in the background "
|
||||
f"({status['percent']}% done, {status['indexed']:,} of "
|
||||
f"{status['total']:,} messages). Results from older messages "
|
||||
f"may be incomplete until it finishes."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _order_for_recall(raw_results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Stable-sort FTS rows so interactive sessions rank above automation.
|
||||
|
||||
|
|
@ -531,14 +553,16 @@ def _discover(
|
|||
raw_results = _order_for_recall(raw_results)
|
||||
|
||||
if not raw_results and not title_result:
|
||||
return json.dumps({
|
||||
_empty_payload = {
|
||||
"success": True,
|
||||
"mode": "discover",
|
||||
"query": query,
|
||||
"results": [],
|
||||
"count": 0,
|
||||
"message": "No matching sessions found.",
|
||||
}, ensure_ascii=False)
|
||||
}
|
||||
_annotate_rebuild_status(db, _empty_payload)
|
||||
return json.dumps(_empty_payload, ensure_ascii=False)
|
||||
|
||||
# Dedupe by lineage. Keep the raw owning session_id on the surviving
|
||||
# row — only that pairs validly with the FTS5 match id for the anchored
|
||||
|
|
@ -606,14 +630,16 @@ def _discover(
|
|||
entry["parent_session_id"] = lineage_root
|
||||
results.append(entry)
|
||||
|
||||
return json.dumps({
|
||||
_final_payload = {
|
||||
"success": True,
|
||||
"mode": "discover",
|
||||
"query": query,
|
||||
"results": results,
|
||||
"count": len(results),
|
||||
"sessions_searched": len(seen_sessions),
|
||||
}, ensure_ascii=False)
|
||||
}
|
||||
_annotate_rebuild_status(db, _final_payload)
|
||||
return json.dumps(_final_payload, ensure_ascii=False)
|
||||
|
||||
|
||||
def session_search(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue