feat(skills): bundle docx, xlsx, and pdf office skills; refresh powerpoint - #68595
Merged
Conversation
…point 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.
teknium1
force-pushed
the
feat/office-document-skills
branch
from
July 21, 2026 12:28
e836b01 to
3b84c87
Compare
Contributor
૮ >ﻌ< ა ci reviewrunning on 3b84c87 CI timingsCI timings · View jobWall time 8m34s vs 8m53s (-3.6%). 9 job(s) slower, 10 faster, 2 unchanged.
|
teknium1
added a commit
that referenced
this pull request
Jul 22, 2026
…ma 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:window: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:window: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 commit 3a9b9d65d505646212c4c875bab19b96ae14b2e6. * 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 of f842733 (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 p…
zapabob
added a commit
to zapabob/hermes-agent-windows
that referenced
this pull request
Jul 23, 2026
Fork deleted nano-pdf/ocr/powerpoint during consolidate; upstream NousResearch#68595 restored them. Classify those trees as upstream so sync_all can proceed. Co-authored-by: Cursor <cursoragent@cursor.com>
ildunari
pushed a commit
to ildunari/hermes-agent
that referenced
this pull request
Jul 23, 2026
…ma v23) (NousResearch#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:window: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:window: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 commit 3a9b9d65d505646212c4c875bab19b96ae14b2e6. * 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 of f842733 (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 p…
1 task
teknium1
pushed a commit
that referenced
this pull request
Jul 25, 2026
…ce skill scripts The bundled office skills (#68595) read user documents and agent-authored payloads with the locale-default codec: - docx/powerpoint validators/base.py opened OOXML part XML in text mode before handing it to lxml. On Windows (cp1251/GBK) the bytes decode to mojibake that lxml then parses, so validation runs against silently corrupted document text; on locales where the UTF-8 bytes don't decode the validator crashes with UnicodeDecodeError instead of validating. Opening as bytes lets lxml honor the encoding declared in the XML prolog. - The pdf form scripts (fill_fillable_fields, fill_pdf_form_with_annotations, create_validation_image, check_bounding_boxes) read the fields JSON the agent authors — UTF-8 by construction — with the locale codec, so non-ASCII form values (any Cyrillic/CJK/accented input) either crash or get written into the user's PDF as mojibake. The json.dump writers use ensure_ascii=True and were already safe; only the readers needed pinning. Adds a contract test asserting every document/payload reader is locale-independent, plus a live regression test that runs check_bounding_boxes.py on a non-ASCII fields.json under a forced non-UTF-8 locale — it fails without the fix on both POSIX (C locale) and Windows (cp1251 chokes on the 0x98 byte of U+2018).
Th0rgal
added a commit
to Th0rgal/hermes-agent
that referenced
this pull request
Jul 26, 2026
#25) * fix(checkpoints): bind an empty orphan preview to an empty deletion allowlist Follow-up for salvaged PR #69141, addressing the last open review point: cmd_prune() only set orphan_allowlist inside 'if orphans or pre_v2_orphans', so a zero-orphan preview passed the unrestricted None sentinel down to prune_checkpoints(), authorizing deletion of any project that became orphaned between the preview and the rescan — with zero confirmation calls. The allowlist is now bound unconditionally for every non-force run (empty preview => empty allowlist); --force keeps None. Adds the zero-orphan-preview timing regression plus allowlist-identity tests. * fix(agent): cache static system prompt prefixes * fix(prompt-caching): inject cache breakpoints after message normalization The conversation loop normalizes message text right before the API call so the request prefix is byte-identical across turns -- the stated reason is KV cache reuse on local inference servers and better cache hit rates on cloud providers. Cache breakpoints were injected *before* that pass, which defeats it. `_apply_cache_marker` rewrites a plain-string `content` into a `[{"type": "text", ...}]` block. The normalization pass is guarded on `isinstance(content, str)`, so every message that just got marked is silently skipped by it and keeps its raw leading/trailing whitespace. A message is only marked while it sits in the last-3 window, so: turn N in the window -> marked, content "file1\nfile2\n" turn N+1 rolled out -> plain, content "file1\nfile2" The same logical message is sent with different bytes on consecutive turns. The prefix stops matching at that position -- which is inside the span the breakpoints were placed to protect -- so the reusable prefix collapses back toward the system breakpoint on every turn. Tool results carry a trailing newline almost by default (any shell command output), so this is the common case, not an edge case. Move the injection below every message mutation. Besides fixing the whitespace divergence this stops breakpoints from being spent on messages that the orphan sweep or the thinking-only drop is about to remove or merge away -- a marker on a dropped message is a wasted breakpoint out of the four available. Nothing between the old and new call sites reads `cache_control`, and the mutators now see the plain-string shapes they were written against. * fix(caching): reconstruct static system prefix on session restore and post-compression reuse Follow-up to the cherry-picked #68258 base: the cross-session-stable prefix (_cached_system_prompt_static) was only recorded on fresh builds, so two paths silently degraded to the legacy single-breakpoint layout (flagged in review of #68258/#69341/#69704): - Session restore: gateway surfaces build a fresh AIAgent per turn and restore the persisted prompt verbatim from the session DB; the static prefix stayed None from turn 2 onward, flip-flopping the wire layout. - Post-compression cached-prompt reuse: _invalidate_system_prompt() clears the static prefix, and the keep-cached-prompt branch never restored it. Both sites now reconstruct the stable tier and adopt it ONLY when the authoritative prompt string literally startswith() it — stable-tier drift (skills edited, identity changed) falls back to the legacy layout with the stored bytes untouched. Fail-open on any builder error. The restore-path rebuild is gated on _use_prompt_caching so non-Anthropic routes skip it entirely. Refs #68191 Co-authored-by: JonthanaHanh <92574114+JonthanaHanh@users.noreply.github.com> Co-authored-by: joaomarcos <joaomarcosdias444@gmail.com> Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> * fix(config): preserve opaque .env values The .env sanitizer inferred missing newlines from known KEY= substrings inside existing values. Plain secrets containing those bytes could therefore be split into synthetic assignments and rewritten to disk. Treat each physical line as the only assignment boundary and keep bytes after the first equals sign opaque for boundary discovery. Preserve safe formatting, null-byte removal, BOM handling, and normal one-assignment-per-line parsing. Cover direct loading, dotenv loading, sanitization, writers, and migration with behavioral regressions. Fixes #29155 * fix(web): resolve per-profile gateway state for ?profile= in /api/status When ?profile=<name> was passed to /api/status, the handler used _config_profile_scope to set the HERMES_HOME contextvar override, but the gateway liveness check (get_running_pid_cached) and runtime status read (read_runtime_status) both resolve _get_process_hermes_home(), which deliberately ignores contextvar overrides (issue #56986) — it always reads os.environ['HERMES_HOME'] or the platform default. A named profile's gateway identity files (~/.hermes/profiles/<name>/gateway.pid, gateway_state.json) were therefore never found and the endpoint always reported the profile's gateway as stopped. Fix: when ?profile=<name> is requested, resolve the profile directory and pass explicit profile-scoped paths: - get_running_pid_cached(pid_path=profile_dir / 'gateway.pid') - read_runtime_status(path=profile_dir / 'gateway_state.json') - get_runtime_status_running_pid(..., expected_home=profile_dir) This is the same explicit-path pattern _collect_profile_gateway_topology already uses for per-profile gateway state, and it works within the #56986 constraint (no HERMES_HOME env mutation; read-only cross-profile access). Plain /api/status without ?profile= keeps the exact zero-arg calls, so its behavior — including the pid-cache signature and runtime-status fallback — is byte-for-byte unchanged. Fixes #69143 * test(web): pin per-profile gateway state scoping on /api/status Follow-up for the salvaged #70498 fix: replace the original PR's mock-signature churn (28 lambda **kw edits, needed only because it changed the no-profile call shape) with two targeted regression tests: - ?profile=<name> must pass the profile's gateway.pid / gateway_state.json paths and expected_home to the gateway status readers (HOME-anchored per-profile state under ~/.hermes/profiles/<name>/) - ?profile=<unknown> must 404 via _resolve_profile_dir The production change keeps plain /api/status on the exact zero-arg calls, so every pre-existing test passes unmodified. * test: accept the new profile-scoped kwargs in status fakes /api/status?profile= now passes pid_path=/path=/expected_home= to the PID and runtime-status readers; the profile-unification fakes had zero-arg signatures and raised TypeError. Plain /api/status call shapes are unchanged (pinned by the existing zero-arg tests in test_web_server.py). * fix(gateway): prevent reconnect watcher wedge after network-loss fatal error (#70344) Three-part fix for the gateway going silently deaf after a retryable fatal adapter error (e.g. httpx.ConnectError on Telegram): 1. **Detach-on-timeout in _connect_adapter_with_timeout** — Replaced plain asyncio.wait_for with the task-detach pattern used by _await_adapter_cleanup_with_timeout. asyncio.wait_for cancels the overdue task but then waits for it to exit, so a connect() that catches CancelledError can block recovery forever. The detach pattern releases the runner at the deadline via consume_detached_task_result. 2. **Ensure reconnect watcher always runs after escalation** — Added _ensure_reconnect_watcher_running(), called after queueing a retryable fatal error. If the reconnect watcher task has died (exhausted restart budget, terminal exception), it is respawned so queued platforms are never permanently stranded. 3. **Faulthandler at gateway startup** — Enabled faulthandler + SIGUSR2 dump to a rotating file under HERMES_HOME/logs/ for post-mortem diagnosis of future event-loop freezes. Tests added for _ensure_reconnect_watcher_running (alive, dead, not-started, not-running), fatal-error integration (retryable calls ensure, non-retryable does not), and _connect_adapter_with_timeout (timeout raises, success returns). * fix: explicit encoding for faulthandler file open (ruff PLW1514) * fix(gateway): stay alive on mixed retryable + non-retryable startup failures When connected_count == 0 and at least one platform failed with a non-retryable error, the runner exited with GATEWAY_FATAL_CONFIG_EXIT_CODE (78) even if OTHER platforms failed for merely transient reasons. Real-world shape (NS-609, hosted instance): WhatsApp enabled but never paired (non-retryable whatsapp_not_paired) + Telegram TimedOut during polling startup (retryable) => exit 78 => the gateway either goes permanently down (supervisors honoring the exit-78 contract via RestartPreventExitStatus / the s6 finish->125 translation from #51228) or crash-loops (anything else). Either way Telegram never gets its retry and the dashboard drops with every exit, so a single unpaired platform plus one network blip disconnected every channel on the instance. Now exit 78 is reserved for the case where ALL startup failures are non-retryable (true config error, nothing to wait for). With mixed failures the gateway stays alive in degraded state: the reconnect watcher recovers the retryable platforms and the misconfigured ones stay fatal-parked and visible in runtime status. * fix: gate SIGUSR2 faulthandler registration behind POSIX check signal.SIGUSR2 and faulthandler.register() don't exist on Windows; the bare reference raised AttributeError at import time per the windows-footgun checker. faulthandler.enable() still covers fatal-error dumps on all platforms. * fix(gateway): detect and escape silent event-loop freezes - A self-rescheduling 5s call_later floor timer, armed before any adapter connects, guarantees the selector always has a finite timeout, so the existing async defenses (polling heartbeat, timeout guards) regain a chance to run after a zero-pending-timer stall. - A resident daemon-thread liveness watchdog probes the loop via call_soon_threadsafe every 30s; after 3 consecutive 10s-timeout misses (~120s of total unresponsiveness) it dumps all thread tracebacks and exits with the established GATEWAY_SERVICE_RESTART_EXIT_CODE (75) so a supervisor restarts the gateway - async-level recovery cannot run on a frozen loop. - stop() disarms both guards before any teardown await so a busy shutdown is never misjudged as a freeze. HERMES_GATEWAY_LOOP_WATCHDOG=0 disables; _INTERVAL/_TIMEOUT/_STRIKES tune the thresholds. Fixes #69089 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gateway): close watchdog shutdown race against final-strike exit - Re-check stop_event after a missed probe (before the strike increment) and again on entering the final-strike branch (before the critical log, dump, and hard exit), so a normal stop() landing between the last timeout check and the exit path can no longer be misclassified as a freeze and trigger a supervisor restart. - Deterministic boundary tests pin both re-checks independently (mutation-verified: removing either check turns its own test red); frozen-loop semantics are unchanged. Addresses the shutdown-race review on #69164. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gateway): recheck stop immediately before watchdog hard exit - A stop() landing while the final diagnostics (critical log, traceback dump) are executing could still reach os._exit(75) after the pre-diagnostic check. Add a third stop_event recheck immediately before the hard exit: diagnostics may complete, but a disarmed watchdog never exits. - Deterministic regressions for both windows (stop triggered from inside logger.critical and from inside faulthandler.dump_traceback); mutation-verified (removing the check turns both red). Frozen-loop semantics unchanged. Addresses the second round of the shutdown-race review on #69164. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(gateway): gate loop-liveness watchdog via config.yaml, drop HERMES_* env knobs Follow-up to the salvaged #69164 commits: policy forbids introducing new HERMES_* environment variables, so the four watchdog env knobs (HERMES_GATEWAY_LOOP_WATCHDOG / _INTERVAL / _TIMEOUT / _STRIKES) are replaced with a single config.yaml boolean: gateway: loop_watchdog: true # default; false disables both guards - gateway/config.py: new GatewayConfig.loop_watchdog field (default True), parsed from top-level or nested gateway: form, round-trips via to_dict/from_dict. - gateway/run.py: _start_loop_liveness_guards() checks config.loop_watchdog before arming the floor timer + watchdog (getattr-guarded for bare object.__new__ runners). - gateway/shutdown_watchdog.py: start_loop_liveness_watchdog() no longer reads the environment; probe interval/timeout/strikes are module constants (30s/10s/3 — ~90s to restart, matching the systemd watchdog layer's posture). - hermes_cli/config.py: documented gateway.loop_watchdog default so 'hermes config set gateway.loop_watchdog false' validates. - tests: env-knob tests replaced with config-gate + round-trip tests; the final-strike boundary test injects its probe via max_strikes directly instead of patching the removed env helper. * fix: getattr-guard _stop_loop_liveness_guards in GatewayRunner.stop Teardown-path tests build bare runners via object.__new__ without the liveness-guard machinery; the unguarded call raised AttributeError in 8 tests. Same guard pattern as the start path. * fix(desktop): close cross-session leak windows in composer + session refs (#59305) Two React passive-effect timing bugs let a session switch land in the wrong chat: activeSessionIdRef/selectedStoredSessionIdRef (use-session-state-cache) and the composer's attachment-scope swap (use-composer-draft) both mirrored their source props via useEffect, which fires one commit AFTER the new session's view has already painted — a synchronous read/submit in that window observed the outgoing session's ids/attachments. - use-session-state-cache.ts: mirror the session refs synchronously during render instead of a useEffect, guarded to fire only when the prop itself changed (not unconditionally) so an imperative pin from submit.ts / use-session-actions (e.g. a freshly resumed runtime id, intentionally not synced to the source atom) survives an unrelated re-render. - use-composer-draft.ts: the per-thread attachment-scope-swap effect is now a useLayoutEffect, closing the window before paint. - submit.ts / session-context-drift.ts: add a 3rd drift prong comparing the composer's loaded scope (SubmitTextOptions.composerScope) against the submit target, resolved into the same lineage-root domain (resolveComposerSessionKey) the composer itself uses — comparing against the raw tip id would false-positive-abort every submit into any session that has ever auto-compressed. - routes.ts / chat/index.tsx: the primary composer's durable scope key now prefers the route over a possibly-stale store selection (primaryRouteSelectedSessionId). - use-composer-draft.ts: redacted [composer-rehydrate] diagnostic log (counts/kinds/scope only, never raw refs) for future reports in this class. - chat-runtime.ts: normalize attachment id values (url/path) before hashing so a re-attach with a trailing slash or backslash path dedupes correctly. 16 files, 286 tests across the touched/dependent suites (17 files) green, including new regression coverage for each fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(desktop): satisfy eslint import-order rules in use-composer-draft.test.tsx CI's check:lint failed on two perfectionist rule violations introduced by the new test file: type import ordering and missing blank line between the parent-relative and same-directory import groups. No behavior change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(agent): prevent shared OpenAI client FD-recycle corruption from stale stream watchdog The streaming stale watchdog was calling _replace_primary_openai_client() from its polling thread, which closes the shared client's connection pool. Worker threads from previous stale-killed attempts may still be unwinding their SSL BIOs, causing TLS application-data to overwrite SQLite file headers via FD reuse. This is the same corruption vector documented in #67142 for Anthropic, where the fix was to never close the shared client from a non-owner thread. Apply the same pattern to the OpenAI-wire path: - Stale stream watchdog: skip shared client replacement - Mid-tool-retry cleanup: skip shared client replacement - Stream retry cleanup: skip shared client replacement The request-local client is already closed via _close_request_client_once. The shared client is replaced lazily by _ensure_primary_openai_client on the next request, which runs on the owning thread. Closes #70773. * fix(agent): retire replaced shared OpenAI clients instead of cross-thread pool close Widen the #70773 fix beyond the three in-request cleanup sites removed in the cherry-picked commit: every remaining path that swaps out the shared OpenAI client could still hard-close its pool from a thread that doesn't own the in-flight sockets (credential rotation/refresh on the turn thread, dead-connection cleanup, gateway cache eviction, transport recovery) — the same FD-recycle corruption vector, just rarer. Add AIAgent._retire_shared_openai_client(): shutdown(SHUT_RDWR) all pooled sockets (FD-safe from any thread, unblocks in-flight readers) but never call client.close() — FD release is deferred to GC, which cannot run until every borrowing thread has unwound its SSL BIO. Refcounting is the ownership handshake; with no borrowers the FDs are released immediately. Wired into: - _replace_primary_openai_client (rotation/refresh/dead-conn cleanup) - try_recover_primary_transport (primary_recovery) - release_clients (gateway cache_evict) agent.close() keeps the hard close: full teardown is a real session boundary where no request may be in flight. Tests: new tests/run_agent/test_70773_shared_client_fd_corruption.py covers the three watchdog/retry sites plus retire semantics; existing close-assertions updated to pin retire-not-close. * test: update credential-refresh tests for retire-not-close contract The three refresh tests asserted the replaced shared client gets close()d — the exact cross-thread close #70773 removes. They now pin the new contract: close() is NOT called from the refresh path; the old client is retired (sockets shutdown, FD release deferred to GC). * fix(doctor): UTF-8/latin-1 fallback when scanning .env Prefer UTF-8 for ~/.hermes/.env provider scans, then latin-1 for cp1252/Notepad files. Add regression test for invalid UTF-8 bytes. * fix: handle non-UTF-8 files in OpenClaw migration script * fix: decode config and state files as UTF-8 on non-UTF-8 locales Several file-I/O call sites still use open() / Path.read_text() / Path.write_text() without an explicit encoding, so they fall back to the platform default. On Windows CN/JP/KR locales (GBK/CP932/CP949) any non-ASCII byte in a config/state/user-content file raises UnicodeDecodeError or UnicodeEncodeError and crashes the caller. to the remaining hot paths: - agent/copilot_acp_client.py: fs/read_text_file and fs/write_text_file (Copilot's read_file / write_file tools, directly reported in #18637 bug 2) - agent/model_metadata.py: context-length YAML cache load + two save sites (context probing is on the call path of every model invocation) - agent/nous_rate_guard.py: cross-session rate-limit JSON state (read + atomic write via os.fdopen) - cron/scheduler.py: user config.yaml read in run_job - gateway/delivery.py: cron output writes for AI-generated content, very likely non-ASCII yaml.dump call sites also gain allow_unicode=True so the emitted YAML preserves non-ASCII chars as-is instead of emitting \u escape sequences. Adds regression tests that monkeypatch builtins.open / Path.read_text / Path.write_text to simulate a GBK locale: each test raises UnicodeDecodeError / UnicodeEncodeError unless the caller explicitly passes encoding='utf-8'. Verified that the tests fail on main and pass with this change, on Linux as well as on Windows. Refs #18637 * fix(cli): add explicit encoding to read_text/write_text calls Path.read_text() and Path.write_text() without explicit encoding default to the system locale encoding. On Windows this is typically cp1252, which causes UnicodeDecodeError for UTF-8 content (JSON configs, user data, service scripts). Add encoding="utf-8" to all read_text() and write_text() calls across 8 CLI files, matching the pattern established in PR #50534 (security_audit_startup.py) and ruff rule PLW1514. Fixed files: - main.py: 4 read_text calls - auth.py: 3 read_text calls - banner.py: 1 read_text + 1 write_text - service_manager.py: 1 read_text + 4 write_text - container_boot.py: 1 read_text + 4 write_text - doctor.py: 3 read_text calls - uninstall.py: 2 read_text calls - gateway.py: 1 write_text call * fix(core,cli,gateway,plugins): add encoding='utf-8' to read_text() calls Path.read_text() without an explicit encoding uses the platform's default encoding. On Windows this is typically cp1252 or mbcs, which causes UnicodeDecodeError or silent data corruption when reading UTF-8 content (JSON files, user text, config with non-ASCII chars). This is the read-side companion to the write_text() encoding fix. Fixed the most critical locations that read JSON data, user content, and config files across 14 files with 31 call sites. Pattern: .read_text() → .read_text(encoding='utf-8') json.loads(path.read_text()) → json.loads(path.read_text(encoding='utf-8')) * fix(install): emit UTF-8 from skills_sync on non-UTF-8 Windows locales On Windows with a non-UTF-8 system locale (e.g. CP936/GBK on zh-CN), Python defaults stdout/stderr to the active codepage. tools/skills_sync.py prints glyphs such as checkmark (U+2713) and up-arrow (U+2191) that GBK cannot encode, raising UnicodeEncodeError mid-run. The installer (scripts/install.ps1) captures this script's stdout and the Rust bootstrap parses it as UTF-8 expecting a JSON result frame. A GBK byte stream (or the traceback it triggers) surfaces as: WARN stdout read error: stream did not contain valid UTF-8 stage=config-templates state=Failed error=install.ps1 -Stage config-templates produced no JSON result frame (exit=Some(0)) i.e. the stage fails even though the script exits 0. install.ps1 already sets [Console]::OutputEncoding = UTF8, but that does not propagate to the python.exe child (Python reads PYTHONIOENCODING / locale, not the console encoding). Fix in two places for defense in depth: - tools/skills_sync.py: reconfigure sys.stdout/stderr to UTF-8 at import so output is valid UTF-8 regardless of caller or active codepage. - scripts/install.ps1: set PYTHONIOENCODING=utf-8 and PYTHONUTF8=1 (scoped to the call, restored afterwards) around the skills_sync.py invocation. * test(install): add UTF-8 regression guard for skills_sync child path Addresses hermes-sweeper review on PR #54866: the installer runs tools/skills_sync.py as a child python.exe whose PYTHONIOENCODING / PYTHONUTF8 the scoped install.ps1 block sets, but there was no regression test for this child-Python UTF-8 path. The existing test_child_process_inherits_utf8_mode covers a different (bootstrap entry-point) flow. Add TestSkillsSyncUtf8Guard: three subprocess tests that import skills_sync (triggering its import-time stdout/stderr reconfigure) and assert the checkmark/up-arrow glyphs the script prints at tools/skills_sync.py:596,675 emit valid UTF-8 and exit 0 even when the child env is left unset or explicitly hostile (gbk). A third test proves the guard is load-bearing by reproducing the crash without it. Also keep the new install.ps1 comment ASCII-only (the checkmark spelled out as U+2713) per the file's PS 5.1 parser-compatibility contract at scripts/install.ps1:79-80; the literal glyph in the comment violated that contract. * fix: add encoding="utf-8" to Path.write_text() calls (P1) Path.write_text() without encoding defaults to system locale encoding. On Windows (cp1252), this silently corrupts non-ASCII content written to JSON files, config files, and cache files. This is the write-side counterpart to the read_text() encoding fix (PR #56115). PLW1514 only covers open() calls — Path methods are unguarded by ruff. 39 instances across 16 files, all passing py_compile. Files changed: - agent/copilot_acp_client.py (1) - tools/web_tools.py (1) - tools/xai_http.py (1) - tools/skills_hub.py (8) - gateway/slash_commands.py (1) - gateway/run.py (5) - gateway/dead_targets.py (1) - gateway/delivery.py (2) - gateway/platforms/qqbot/adapter.py (1) - hermes_cli/gateway.py (1) - hermes_cli/banner.py (1) - hermes_cli/service_manager.py (5) - hermes_cli/container_boot.py (5) - hermes_cli/uninstall.py (1) - hermes_cli/main.py (2) - hermes_cli/profiles.py (3) * fix(hindsight): specify UTF-8 encoding for file I/O on Windows On Windows with CJK locales (e.g. Chinese/GBK), pathlib.Path.read_text() defaults to the system encoding instead of UTF-8, causing UnicodeDecodeError when reading .env or .json config files that contain non-ASCII characters. Explicitly pass encoding='utf-8' to all read_text() and write_text() calls in the hindsight memory provider plugin. * fix(memory): read/write .env as UTF-8 in mem0 and hindsight setup The mem0 and hindsight memory-provider setup routines round-trip the user's ~/.hermes/.env: they read existing lines, update the keys they manage, and rewrite the whole file preserving every other line verbatim. Both used env_path.read_text() / write_text() with no encoding. read_text()/write_text() with no encoding fall back to the system locale (cp1252/GBK on Windows), so on a non-UTF-8 host the preserved lines get mangled or the call crashes on any non-ASCII value, and — because the reader never strips a BOM — a Notepad-edited .env makes the first key fail the in-place match and get duplicated instead of updated. Match the canonical .env readers in hermes_cli/config.py: read with encoding='utf-8-sig' (BOM-tolerant) and write with encoding='utf-8'. mem0/_setup.py already pins utf-8 for mem0.json, so this just aligns the .env path in the same file. Fixes both memory plugins in one class fix. Adds regression tests: a BOM'd .env updates the first key in place (locale-independent, fails without the fix) and non-ASCII existing lines survive the round-trip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(memory): cover the remaining setup-time .env reads with utf-8-sig Follow-up to review feedback: - mem0 _prompt_api_key read .env with the locale default, so a Notepad BOM hid the first key from the masked current-value lookup; read it with utf-8-sig + errors=replace like the canonical readers in hermes_cli/config.py. - hindsight _load_simple_env used plain utf-8; it also parses the Hermes .env during post_setup, where a BOM stuck to the first key. Switch to utf-8-sig + errors=replace. - Add hindsight regressions: BOM key matching in _load_simple_env and in the cloud post_setup writer, plus non-ASCII round-trip preservation, and a mem0 regression for the BOM'd masked-key lookup. The BOM tests fail without the fix on any platform. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(profile): read .env as utf-8-sig in the distribution-install preview `_render_distribution_plan` reads the target profile's `.env` to decide whether a required env var is already set (so it doesn't nag the user), using `Path.read_text()` with no encoding. Two bugs: 1. `Path.read_text()` defaults to the system locale (cp1251/GBK on Windows), which raises `UnicodeDecodeError` on any non-ASCII byte. The surrounding `except OSError` does NOT catch that — `UnicodeDecodeError` is a `ValueError` — so a mis-encoded `.env` aborts the entire install preview. 2. Even on a UTF-8 locale, a Notepad-added BOM prefixes the first key (`KEY`), so the very first required env var is mis-reported as "needs setting" when it is actually present. `.env` is written as UTF-8 everywhere in the codebase. Read it as `utf-8-sig` (tolerates the BOM) and also catch `UnicodeDecodeError` so a genuinely un-decodable file skips the pre-check instead of crashing. Regression tests: a BOM-prefixed `.env` whose first key must still read as "set", and an invalid-UTF-8 `.env` that must not abort the preview. * fix: add UTF-8 encoding to read_text/write_text in tools/ and agent/ Path.read_text() and Path.write_text() without encoding= default to the system locale (cp1252 on Windows), which corrupts non-ASCII JSON content. Coverage-gap fix for files not addressed by prior encoding PRs: - tools/skills_hub.py: 6 read_text + 8 write_text (cache, index, lock files) - tools/skills_sync.py: 1 read_text (lock file) - tools/xai_http.py: 1 read_text + 1 write_text (auth store, marker) - agent/shell_hooks.py: 1 read_text (allowlist) - gateway/status.py: 1 read_text (PID file) - hermes_cli/banner.py: 1 read_text + 1 write_text (update cache) All sites read/write JSON or short text. No behavioral change on Linux (already UTF-8); fixes silent data corruption on Windows. * fix(skills): tolerate non-UTF-8 bytes in hub lock.json _read_hub_installed_names() reads ~/.hermes/skills/.hub/lock.json with a strict utf-8 decode. Hub skill descriptions can carry Windows-1252 typographic bytes (em-dash 0x97, smart quotes, bullets) as single high bytes; read_text(encoding="utf-8") then raises UnicodeDecodeError, which is a ValueError sibling not caught by the function's except (OSError, json.JSONDecodeError). It escapes and 500s the whole /api/skills endpoint, blanking the desktop Skills panel. Decode with errors="replace" so the offending byte degrades to U+FFFD and the structurally valid JSON — and every other skill — stays readable. Fixes #68053 * fix(cli): read .worktreeinclude and .gitignore as UTF-8 in worktree setup _setup_worktree read both files with the locale default encoding. On a cp1251/GBK Windows machine a UTF-8 include list either decodes to mojibake paths (non-ASCII entries silently not copied) or raises UnicodeDecodeError, which the enclosing handler logs at DEBUG and swallows — no include is copied at all, so the worktree starts without .env/keys and the agent breaks invisibly. A Notepad BOM likewise glues to the first include entry on every platform, and to the first .gitignore line, defeating the '.worktrees/' membership check and appending a duplicate entry on each run. Read both files with utf-8-sig + errors=replace, matching the canonical .env readers in hermes_cli/config.py (utf-8-sig because Notepad adds a BOM) and the UTF-8 append this same block already performs on .gitignore. Regression tests exercise the real cli._setup_worktree: the two BOM tests fail without the fix on any platform, the non-ASCII include test additionally reproduces the Windows locale failure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skills): read OOXML parts as bytes and form JSON as UTF-8 in office skill scripts The bundled office skills (#68595) read user documents and agent-authored payloads with the locale-default codec: - docx/powerpoint validators/base.py opened OOXML part XML in text mode before handing it to lxml. On Windows (cp1251/GBK) the bytes decode to mojibake that lxml then parses, so validation runs against silently corrupted document text; on locales where the UTF-8 bytes don't decode the validator crashes with UnicodeDecodeError instead of validating. Opening as bytes lets lxml honor the encoding declared in the XML prolog. - The pdf form scripts (fill_fillable_fields, fill_pdf_form_with_annotations, create_validation_image, check_bounding_boxes) read the fields JSON the agent authors — UTF-8 by construction — with the locale codec, so non-ASCII form values (any Cyrillic/CJK/accented input) either crash or get written into the user's PDF as mojibake. The json.dump writers use ensure_ascii=True and were already safe; only the readers needed pinning. Adds a contract test asserting every document/payload reader is locale-independent, plus a live regression test that runs check_bounding_boxes.py on a non-ASCII fields.json under a forced non-UTF-8 locale — it fails without the fix on both POSIX (C locale) and Windows (cp1251 chokes on the 0x98 byte of U+2018). * fix(windows): sweep remaining bare read_text/write_text sites + linter rule AST-driven pass over every Path.read_text()/write_text() without an explicit encoding= across non-test code: 71 sites in 34 files (skills_hub, hermes_cli/main+profiles+service_manager+container_boot, mem0/hindsight/honcho plugins, achievements dashboard, release/CI scripts, productivity+comfyui skill helpers, agent/*). Verified zero positional-encoding collisions before insertion; per-file compile() check after. Adds a check-windows-footguns rule flagging bare single-line read_text/write_text (multi-line forms stay covered by the AST guard test from #38985). Together with the salvaged contributor commits this retires the ~169-site bare file-I/O class (#37423's long tail). * fix: restore utf-8-sig BOM tolerance at .env readers the sweep normalized The cherry-pick auto-resolution + AST sweep applied plain utf-8 at three .env reader sites where the salvaged PRs (#62617, #62123) deliberately use utf-8-sig — a Notepad BOM must not hide/duplicate the first key. Restore the contract (tests pin it). * chore: contributor email mappings for the file-I/O salvage * refactor(desktop): add shared Field form-dialog primitive Dialog forms each hand-rolled their own label+control+hint stack (or borrowed the settings-surface ListRow), so gaps and hint styling drifted between the profile, cron, and webhook dialogs. Add a single Field / FieldHint primitive for label-over-control dialog fields and adopt it in the create/rename profile dialogs as the first consumers. * fix(desktop): unify overlay-pane padding and add primary PanelAction Overlay panes each set their own top padding, so the Settings sidebar and Panel headers sat at different heights than System/Agents and the close X (the #67759 regression). Hoist the shared beside-the-X clearance into OVERLAY_TOP_CLEARANCE, keep the taller pad only on OverlayMain (which sits under the X), tighten OverlayMain's gutters, and drop the one-off Settings override. Also give PanelAction a `primary` variant so a detail header can promote its main action to a filled button. * refactor(desktop): fold cron Blueprints into the New Job dialog Blueprints lived behind a separate Jobs/Blueprints tab with its own card gallery — a bespoke surface no other overlay uses. Remove the tab and make blueprints a "Start from" dropdown at the top of the New Job dialog (default "Custom" = the manual editor); picking one swaps the form for that blueprint's typed slots. Also promote the detail-view "Trigger now" button to a primary action and adopt the shared Field primitive. * refactor(desktop): webhooks create form uses shared Field; drop status pill The create dialog used the settings-surface ListRow/ToggleRow inside a modal, which read differently from every other form dialog, and the detail header carried an enabled/disabled pill that rendered as a stray dash. Switch the form to the shared Field primitive (+ Switch) and remove the pill. * fmt(js): `npm run fix` on merge (#71099) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * fix(dashboard): add lightweight /api/health liveness endpoint /api/status is the only public liveness route, and its handler loads the gateway config, probes gateway health, and counts sessions before it can answer. That work is wrong for a readiness probe: a caller that only needs to know the process is up pays for a cold plugin import tree. Add /api/health, which returns process liveness, version, and the auth-gate shape and touches nothing else. * fix(desktop): probe /api/health for boot readiness, and survive a stalled loop Desktop boot polls /api/status, so readiness waits on gateway config and a cold plugin import tree. On Windows that regularly outlives the probe and Desktop kills a backend that is already listening, respawns it, and re-pays the same import cost — the reported crash loop. Probe /api/health instead, falling back to /api/status only for the missing-route shapes the fetch helpers emit (404, or HTML from the SPA), so an older remote backend still connects. Timeouts and server errors keep polling health rather than dropping to the heavyweight route. A cheap route is not enough on its own. Warming the gateway import holds the GIL, so the event loop can stall for tens of seconds and starve /api/health too. At the default 15s socket timeout only three attempts fit in the 45s budget; give each probe 5s so the loop keeps retrying across the stall. Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com> Co-authored-by: DESXIE <78300229+DESXIE@users.noreply.github.com> Co-authored-by: frohsinnllc <231045016+frohsinnllc@users.noreply.github.com> * fix(state): decode display_metadata at every message read path get_messages(), get_messages_around() and get_anchored_view() returned the raw display_metadata column instead of the dict every caller expects. The desktop paints a resumed transcript from the REST prefetch, which reads through get_messages(), so any session holding an async_delegation_complete event failed resume with "Cannot use 'in' operator to search for 'task_count'" — on every such session, not just corrupted ones. Route all four read paths through one shared codec that also unwraps rows carrying a second JSON layer, so sessions already broken on disk recover on read rather than needing a migration. Co-authored-by: Studio729 <Studio729@users.noreply.github.com> Co-authored-by: aml1973 <aml1973@users.noreply.github.com> Co-authored-by: xxxigm <xxxigm@users.noreply.github.com> * fix(state): stop double-encoding display_metadata on write export_session() reads through get_messages(), so before the read fix an already-serialized string went straight back into _insert_message_rows() and got re-dumped — an export/import round trip permanently corrupted the row. Guard the three write paths the same way tool_calls already is: parse a string argument before storing it, and drop metadata that isn't an object rather than persisting something no reader can use. Co-authored-by: xxxigm <xxxigm@users.noreply.github.com> Co-authored-by: aml1973 <aml1973@users.noreply.github.com> * fix(desktop): tolerate unparsed display_metadata from an older backend The desktop and the Hermes backend it talks to version independently — a remote VM running an older build still serves display_metadata as JSON text. Indexing into that string with `in` threw and failed the whole resume, so narrow the type to admit a string and parse it before reading task_count. Falling back to the generic label keeps a delegation event renderable even when the metadata is unusable. Co-authored-by: xxxigm <xxxigm@users.noreply.github.com> Co-authored-by: Studio729 <Studio729@users.noreply.github.com> * fix(checkpoints): don't prune a project whose volume is merely unmounted Orphan pruning decides a project is gone from a single probe: if delete_orphans and (not workdir or not Path(workdir).exists()): reason = "orphan" then deletes its ref, index, and metadata — the project's entire checkpoint history. `Path.exists()` is False for a deleted directory, but it is equally False for one whose storage is not attached right now: an unplugged external drive, a share behind a downed VPN, a bind-mount absent from this container, an offline Windows mapped drive. The project is fine; only our view of it is. This is not an opt-in maintenance command. `maybe_auto_prune_checkpoints` runs unattended at startup from both `cli.py` and `gateway/run.py`, with `delete_orphans=True` by default. So starting Hermes once while the drive is unplugged silently destroys the restore points for every project on it — the one thing checkpoints exist to provide, and there is nothing to restore from afterwards. Reproduced against the real store: a project registered under an unmounted path and one on local disk, then a startup prune — prune: {'scanned': 2, 'deleted_orphan': 1} unreachable project index still on disk: False The legacy pre-v2 branch has the same flaw plus a second one: a `HERMES_WORKDIR` marker that exists but cannot be read leaves `workdir = None`, which the same condition treats as an orphan. Failing to read a file is not evidence that a project was deleted. Require corroboration before deleting: the workdir's parent must be present, so its absence is something we actually observed. A missing parent means the volume is not there and we know nothing, so the entry is left alone — and an unreadable marker never deletes at all. Genuinely abandoned projects are still reclaimed, both by the unchanged orphan path (parent present, project gone) and by the retention/stale rule, which runs off `last_touch` rather than a filesystem probe. tests/tools/test_checkpoint_manager.py: a project whose whole mount disappears keeps its history; controls prove a genuinely deleted project is still pruned and a live project is untouched. The data-loss test fails on main; both controls pass there. 81 passed across the checkpoint suites (2 failures in test_checkpoint_manager.py are pre-existing and fail identically on clean main). * fix(checkpoints): an empty surviving mount point is not evidence of deletion Addresses @egilewski's review: the parent-directory check still deleted checkpoint history for the most common unmount layout. Detaching storage removes the parent outright in some layouts (`/Volumes/Ext/proj` on macOS, `/media/<user>/<label>/proj`), which the first commit handles. But in the classic static layout — `/mnt/volume/proj`, an fstab entry, a container bind-mount — unmounting removes the contents and leaves the mount point behind as an empty directory. `parent.is_dir()` is then true, the project is absent, and the startup sweep deletes its ref, index and metadata: exactly the case this PR set out to protect. Reproduced against the real predicate before this commit: mount root vanished (macOS) -> False ok empty surviving mount point -> True <-- history deleted really deleted (siblings) -> True ok An empty parent carries no information: it looks identical whether the volume was detached or the project was deleted. So require the parent to actually say something — it holds some other entry (we observed a populated directory that does not contain the project), or it is itself a live mount point (the volume is attached right now and demonstrably does not hold the project). The cost is that a project deleted out of an otherwise-empty parent is no longer reclaimed by the orphan rule. It is not leaked: the retention rule reads `last_touch` rather than probing the filesystem and still collects it, so reclamation is deferred, not lost. That is the right direction for a predicate whose false positive destroys a user's restore points unattended. `_dir_has_any_entry` stops at the first entry via `os.scandir` instead of materializing a listing, since a project root can hold a large tree. tests/tools/test_checkpoint_manager.py: `test_surviving_empty_mountpoint_ keeps_its_checkpoints` pins the reviewed case, and `test_empty_parent_project_ is_still_reclaimed_by_retention` pins the deferral above so the safety valve cannot silently regress into a leak. Both fail on the previous commit. The real-orphan control now seeds a sibling so it exercises a populated parent rather than the ambiguous empty one. 80 passed in the checkpoint suite; the 2 remaining failures (`TestGitEnvIsolation`, `TestClearFunctions`) fail identically on clean main. * fix(checkpoints): require positive volume-attachment evidence before orphan classification Follow-up to the cherry-picked #69063: egilewski's review found that the _dir_has_any_entry(parent) guard treats ANY entry in the mount point's parent as proof the volume is attached — but unmounting exposes the UNDERLAY directory's own files (e.g. a .keep placeholder), so a populated underlying mount-point dir still classified the project as an orphan and deleted its ref/index/metadata. Reproduced on both main and the PR head. Attachment evidence is now positive instead of circumstantial: * _volume_evidence() records the parent directory's (st_dev, st_ino) identity in the project's metadata while the workdir is observably live (at _register_project/_touch_project time). A mount point resolves to the mounted filesystem's root while attached and to the underlay directory after detach — same path, different directory, different identity. * _workdir_is_observably_gone() now requires the parent visible at prune time to match that recorded identity before the populated-parent check can classify an orphan. A mismatch means a different directory (the underlay) is showing through — a detached volume, not an observed deletion. * Metadata without a recorded identity (written by older versions) is never orphan-classified — unsure never deletes; the retention/stale rule still reclaims genuinely abandoned projects off last_touch. * The frozen pre-v2 layout has no metadata channel for the identity, so it keeps the structural checks only (require_parent_identity=False). * A failed evidence probe on re-registration preserves the previously recorded identity — stale evidence can only make pruning MORE conservative. Windows: st_dev/st_ino of 0 (filesystems without file IDs, some network shares) is treated as "no evidence recorded", which falls into the conservative never-orphan path. os.path.ismount and Path.stat are cross-platform; no POSIX-only calls added. tests/tools/test_checkpoint_manager.py: adds egilewski's exact regression (checkpoint history for mnt/volume/project, detach exposes mnt/volume/.keep, prune with orphan deletion enabled → NOT deleted; fails on the bare cherry-pick, passes with this fix), plus no-recorded-identity conservatism and probe-failure identity preservation. His absent-parent/empty-parent/retention/genuine-deletion/ live-project controls all still pass. Reported-by: egilewski (review on #69063) * fix(telegram): require initial polling readiness Use wall deadlines for deleteWebhook and start_polling, then fail cold startup unless getUpdates proves progress. This lets the gateway discard partial PTB state and retry with a fresh adapter.\n\nRefs #67498 * fix(gateway): allow Telegram readiness budget Give Telegram a 180s default outer connect budget so cold polling can prove getUpdates readiness. Preserve the 30s default for other platforms and all explicit config/env overrides.\n\nRefs #67498 * fix(telegram): bind strict cold-start readiness to its own polling generation Follow-up hardening for the salvaged #69240 readiness gate (#67498): - _start_polling_once now returns its (generation, progress_event) pair so the strict cold-start gate binds to exactly the generation it started, instead of re-reading self._polling_progress_event which a concurrent recovery task may have replaced with a newer generation's event (the G1/G2 race flagged in the #69240 review). - Strict cold start no longer schedules background polling recovery: a polling error during the readiness wait is captured by a strict callback and fails the connect attempt immediately with a loud OSError, so GatewayRunner disposes the partial adapter and retries with a fresh one — no more waiting out the full readiness deadline on a generation that already errored, and no G2-on-partial-app healing. - After readiness is proven the strict callback delegates every later polling error to the real background-recovery callback, preserving the existing degraded/reconnect semantics for the polling lifetime. - The readiness-timeout error message now states the deadline and that the gateway will retry with a fresh adapter (loud failure, not a silent wait). - Regression tests: current-generation progress connects; a polling error during strict cold start fails fast without scheduling background recovery (the #67498 idle-threads shape); stale-generation progress is rejected. Progresses #67498 * test: record getUpdates progress in mocked cold-connect polling flows The strict cold-start readiness gate (#67498) means adapter.connect() no longer returns True until the mocked start_polling records a successful getUpdates round trip for its generation. Update the conflict-suite Application mocks accordingly: - fake_start_polling side effects call adapter._record_polling_progress(adapter._polling_generation) on the initial connect (retry generations intentionally do NOT auto-progress where a test asserts the conflict count survives an unproven retry). - _build_polling_app takes the adapter so its start_polling mock can record progress. Without this, the cold connects in these tests wait out the full 60s readiness deadline and fail — which is exactly the fail-closed behavior the gate is supposed to provide when polling shows no progress. * fix(config): add a collision-safe env var name for custom endpoint keys Both the Desktop panel and the CLI setup flow need somewhere in .env to put a custom endpoint's API key. Deriving the name from the endpoint's hostname collapses two servers on one machine onto a single slot, and every IP-based local endpoint slugs to a digit-leading name that save_env_value rejects outright. Key off the endpoint's own identity and keep a fixed prefix. Co-authored-by: asorry75 <33794789+asorry75@users.noreply.github.com> Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com> * fix(windows): verify rebuilt Hermes.exe integrity before shipping it as an update (#69179) The desktop self-update chain (Desktop -> hermes-setup --update -> hermes update -> hermes desktop --build-only -> relaunch) rebuilds Hermes.exe on the user's machine and declared success on bare file EXISTENCE. A truncated PE (corrupt cached Electron zip / interrupted extraction or rcedit rewrite / full disk) or a wrong-architecture unpacked tree therefore shipped as the 'updated' app, which Windows refuses to load with 'This app can't run on your computer' (此应用无法在你的电脑上运行) — and the previous working build had already been wiped by before-pack.mjs, leaving nothing to fall back to. Fix, in three parts: - hermes_cli/main.py: post-build integrity gate on Windows (_ensure_desktop_exe_launchable). Parses the PE header of the freshly built Hermes.exe — MZ/PE magic, section-table completeness vs file size (catches truncation), and COFF machine vs the host arch (catches arm64/x64 mixups). On failure it purges the (likely corrupt) cached Electron zip, invalidates the content-hash build stamp so the updater's retry-once genuinely re-downloads and rebuilds, restores the previous build from the .bak tree when one exists (keeping the corrupt tree as .corrupt for diagnostics), tells the user the update was aborted and their old version kept, and exits nonzero. _desktop_packaged_executable also now prefers a host-loadable PE over pure newest-mtime when multiple win-*-unpacked trees coexist. - apps/desktop/scripts/before-pack.mjs: on win32, the previous unpacked tree is preserved as <appOutDir>.bak (only when it holds the product exe — partial/corrupt trees still get the plain wipe) instead of being destroyed, providing the rollback material for the gate above. Non-Windows behavior is unchanged. - Behavior-contract tests: tests/hermes_cli/test_desktop_exe_integrity.py (23 tests — synthetic PE fixtures for truncation/non-PE/arch-mismatch, rollback semantics, and the build-only exit contract) and 6 new vitest cases in before-pack.test.mjs for the .bak preservation rules. Progresses #69179 * fix(desktop): persist the whole discovered model list when saving an endpoint Test enumerates a custom provider's catalogue and the panel holds the result in discoveredModels, but the save payload never carried it, so only the one model the user hand-typed reached providers.<id>.models. Every downstream picker reads that map straight from config.yaml with no live probe, which is why a proxy serving 18 models offered exactly one. Send the discovered list and merge it onto the entry, so models already known keep their context lengths. Fixes #69988 Co-authored-by: asorry75 <33794789+asorry75@users.noreply.github.com> * fix(web_server): keep Desktop custom endpoint API keys out of config.yaml The Custom Endpoints panel wrote the raw key to providers.<id>.api_key, so the credential sat in plaintext in a file users routinely share and commit. The input is masked, so nothing warned them. Write the key to .env and reference it via key_env, the same indirection built-in providers use and that runtime_provider already resolves. The read side has to move with it: reporting has_api_key from api_key alone would show "no API key" for every migrated endpoint, and activate copying only api_key would drop the credential entirely. Delete now clears the .env slot too, and an entry still carrying a pre-fix plaintext key is migrated on its next save so existing users get cleaned up without re-entering anything — unless the key is a hand-written ${VAR} template, which is already safe and must not be duplicated into a second env var. Fixes #69449 Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com> Co-authored-by: asorry75 <33794789+asorry75@users.noreply.github.com> * fix(cli): store custom endpoint API key in .env instead of config.yaml hermes model's custom-endpoint flow is the other write path that produced a plaintext key, on both the model block and the custom_providers entry. Route it through the same .env indirection as the Desktop panel, and swap an existing entry's inline key for the reference when the URL is re-saved. Co-authored-by: liuhao1024 <sunsky.lau@gmail.com> * test: cover custom endpoint key storage and model-list persistence Bug-class coverage for both fixes: the full catalogue survives Save, context lengths are preserved, the key never lands in config.yaml on either write path, blank clears it, a pre-fix plaintext key migrates while a ${VAR} template is left alone, two endpoints on one host keep separate credentials, and an IP-derived name is still a valid POSIX env var. The two delete tests asserted on the plaintext mirror; they now assert the same invariants against the credential reference. * fix(desktop): persist @image: refs instead of the vision-enrichment text The desktop gateway passed the vision-enriched, model-only message text (carrying an `image_url:<path>` hint) straight into run_conversation as the persisted user turn. The renderer only parses `@image:<path>`, so it could not rebuild the attachment from history: after a restart the image was gone and only the caption survived, and on a live session switch the warm cache disagreed with the authoritative text and the frontend "rescued" the image by appending it after the caption. run_conversation already supports persist_user_message for exactly this "what the model sees" vs "what gets stored" split; it was simply never wired up for the attachment path. * fix(desktop): keep cached attachment refs on session resume Persisted history carries no attachment metadata for non-image refs, so resume reconciliation dropped `@file:` chips off a user turn whose text matched. Carry the warm cache's refs forward when the resumed message has none of its own, never replacing refs that are already present. (cherry picked from commit eac5b0a8ac39eab242a5d571531e386ec70e2435) * fix(desktop): quote persisted @image: paths so spaced paths render The unquoted alternative in the directive pattern is `\S+`, so a ref built by string interpolation truncates at the first space and strands the tail as loose text next to a broken thumbnail. Composer images live in the app's userData dir, which on macOS is `~/Library/Application Support/<App>/` — so every pasted or dropped image hit this. Adds format_reference_value next to REFERENCE_PATTERN, mirroring formatRefValue in the desktop's directive-text.tsx, and covers the round-trip through the parser. * fix(desktop): persist the image ref for natively-vision-capable models too A turn routed to a model that takes pixels directly sends `content` as a parts list, and the session store deliberately ignores a plain-string persist override for a list payload — a text override must not erase a turn's image summary. So the override was dropped for every user on a vision-capable main model, and the durable row kept only the caption plus a literal `[Image attached at: ...]` / `[screenshot]`, which the renderer cannot turn back into an image. Only vision-preprocessed (text-mode) turns were actually fixed. Mirror the shape instead: swap the text part for the `@image:` ref form and keep the image parts, so the model still has the pixels for the rest of the session, and drop the `[screenshot]` stand-in on the way into the bubble when a ref was lifted from the same message. * refactor(desktop): memoize the directive image-segment filter Matches the two derived values above it and fixes the indentation. * fix(desktop): lead persisted image turns with the caption Session previews are the first 60 characters of the first user message, so persisting the @image: directives ahead of the caption labelled the session with a truncated file path in the sidebar, session switcher, and command palette. Clients lift the refs out of the body line by line, so moving them after the caption changes nothing about how the turn renders. * test(desktop): cover attached-image resume end to end The unit tests cover each layer in isolation, but nothing exercised the whole chain the bug lived in: the real gateway persisting an attachment, SessionDB holding it after the process exits, and the renderer rebuilding a thumbnail from the stored turn. Seeds a session through the real gateway with an image attached, then launches desktop against it — so the first render is already the relaunch case. Pins native image routing (the majority path, and the one where a text-only persist override is dropped) and stages the file behind directory and file names with spaces, mirroring the macOS composer's Application Support path. * fix(models): resolve custom provider model ids Map picker-prefixed custom provider selections back to their configured model IDs before validation, persistence, and API requests. Fixes #68347 * chore(contributors): map jevin@jevin.org to ijevin Attribution check needs a mapping for the cherry-picked commit's author so release notes credit them correctly. * fix(relay): normalize forwarded Discord interactions to leading-slash commands (#71048) A real APPLICATION_COMMAND interaction forwarded over the relay arrived slash-less: _discord_interaction_to_event set text = data['name'] ("new", not "/new"), MessageType.TEXT, and dropped options entirely — so a registered /new dispatched as plain chat instead of a command (MessageEvent.is_command() is text.startswith("/")). Port the connector's Slack slash-command precedent (normalizeSlackCommand builds `${command} ${args}`.trim() with a leading slash and explicit command type): for type-2 interactions build "/" + name, append rendered options space-separated (scalar options contribute their value, matching the native adapter's f"/model {name}" shape; SUB_COMMAND/ SUB_COMMAND_GROUP contribute their name then recurse into nested options), and set MessageType.COMMAND. Type-3 (custom_id) and other interaction types are unchanged. This implements the interaction->command sub-design previously flagged as deferred in the _on_passthrough docstring. Companion connector fix in gateway-gateway: fix(relay): strip own-mention prefix so addressed slash commands dispatch. * feat(desktop): add session link title resolver Resolve @session:<profile>/<id> reference values to the session's title: the in-memory sidebar list answers most lookups, and an unknown id falls back to GET /api/sessions/{id}. Cache, in-flight dedupe, and subscriber fan-out mirror the external-link title resolver. An untitled row resolves to empty rather than "Untitled session" so the caller's short-id fallback stays the chip label. * feat(desktop): show resolved titles on @session chips Route session refs in the transcript through the title resolver so a dropped session reads as its title instead of a truncated id, and use Tabler's funnel for the session chip icon. * feat(desktop): render agent-written @session links as chips Assistant text goes through the markdown renderer, not DirectiveContent, so a session reference an agent wrote came out as literal text. Rewrite bare refs into `#session/<value>` links during markdown preprocessing and dispatch that href to the shared chip in MarkdownLink, alongside the existing media and preview hrefs. Preprocessing already skips code fences and inline code, so a ref being discussed in code stays literal. The pure parsing/href helpers move to session-refs.ts to keep the resolver's React and API imports out of the per-flush preprocess path. * fix(sessions): export delegate cascade before deletion * refactor: extract lineage_is_logical local + document TOCTOU re-query Follow-up cleanup for PR #71123: - Extract getattr(args, 'lineage', 'single') == 'logical' to a local (appeared 3x in the export block) - Document that the double _collect_delegate_child_ids traversal in delete_session is an intentional TOCTOU guard inside the write txn * feat(session-search): give the agent a link to hand back Asked to link to a session, the agent had no way to know the @session reference syntax exists — every mention in the tool schema described consuming a link the user dropped, never writing one — so it answered with the title and timestamp as prose and the desktop had nothing to render. Every result now carries a ready-to-copy `link`, and the schema says to write it inline instead of restating the title around it. The profile segment is omitted when the active profile can't be named confidently; a bare id still resolves. Also skip linkifying a ref a model already wrapped in a markdown link, which would otherwise rewrite into a nested link. * fix(tui_gateway): retain failed turns as replayable inflight snapshots A turn that ended in error cleared inflight_turn and emitted its terminal frame in the same breath. If the client was disconnected during that window (the exact case for a failure like a network drop), the frame went to the detached drop-transport and the in-memory state was already gone — the desktop reconnected to a session with no trace of the failure. Failed turns now retain a compact error snapshot (user prompt, partial assistant text, error, recoverable) that session.resume's inflight payload carries to a reconnecting client. Covers all three loss sites: the returned-error result path, the turn exception path (which now closes with the same status:"error" message.complete frame shape instead of a bare error event), and agent-init failure. The snapshot lives until the next turn starts or the session closes; _run_prompt_submit replaces a retained error leftover instead of appending onto it. Co-authored-by: Reza Sayar <rsayar@uvic.ca> * feat(desktop): crash-survivable in-flight turn journal The renderer's session-state cache is memory-only and the backend's inflight snapshot dies with the backend process, so nothing survived a full app or machine death mid-turn: reopening the session showed the transcript up to the last committed turn and silently dropped everything the crashed turn had streamed. While a turn runs, the visible tail (user prompt + streamed assistant rows, tool calls included) is now journaled to localStorage — throttled off the delta-flush hot path, bounded (24 entries / 7 days), cleared the moment the turn settles. Session resume folds the journaled tail back onto the restored transcript. When the backend also has a live text-only inflight projection for the same turn, the journal overlays its richer structure onto that row (longer text wins, base row id kept so live deltas keep landing) instead of treating it as caught up — the ordering defect that dropped locally recorded tool progress in the original PR. Co-authored-by: Omar Baradei <omar@kostudios.io> * fix(desktop): surface terminal error frames as failed bubbles message.complete frames with status "error" were detected only by a text regex heuristic, which misses the gateway's "Error: <detail>" texts and partial-text failures — a failed turn rendered as a healthy reply. The structured e…
randlee
pushed a commit
to randlee/hermes-agent
that referenced
this pull request
Aug 11, 2026
…point (NousResearch#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.
randlee
pushed a commit
to randlee/hermes-agent
that referenced
this pull request
Aug 11, 2026
…ma v23) (NousResearch#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:window: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:window: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 commit 14e34bde5952a6d75f5d17e9622e469c93b3b577. * 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 of f842733 (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 p…
randlee
pushed a commit
to randlee/hermes-agent
that referenced
this pull request
Aug 11, 2026
…ce skill scripts The bundled office skills (NousResearch#68595) read user documents and agent-authored payloads with the locale-default codec: - docx/powerpoint validators/base.py opened OOXML part XML in text mode before handing it to lxml. On Windows (cp1251/GBK) the bytes decode to mojibake that lxml then parses, so validation runs against silently corrupted document text; on locales where the UTF-8 bytes don't decode the validator crashes with UnicodeDecodeError instead of validating. Opening as bytes lets lxml honor the encoding declared in the XML prolog. - The pdf form scripts (fill_fillable_fields, fill_pdf_form_with_annotations, create_validation_image, check_bounding_boxes) read the fields JSON the agent authors — UTF-8 by construction — with the locale codec, so non-ASCII form values (any Cyrillic/CJK/accented input) either crash or get written into the user's PDF as mojibake. The json.dump writers use ensure_ascii=True and were already safe; only the readers needed pinning. Adds a contract test asserting every document/payload reader is locale-independent, plus a live regression test that runs check_bounding_boxes.py on a non-ASCII fields.json under a forced non-UTF-8 locale — it fails without the fix on both POSIX (C locale) and Windows (cp1251 chokes on the 0x98 byte of U+2018).
prmartinow
pushed a commit
to prmartinow/hermes-agent
that referenced
this pull request
Aug 26, 2026
…point (NousResearch#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.
prmartinow
pushed a commit
to prmartinow/hermes-agent
that referenced
this pull request
Aug 26, 2026
…ma v23) (NousResearch#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:window: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:window: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 commit c65e7d2ef4accbd30a477a2310d3651858f8dff7. * 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 of f842733 (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 p…
prmartinow
pushed a commit
to prmartinow/hermes-agent
that referenced
this pull request
Aug 26, 2026
…ce skill scripts The bundled office skills (NousResearch#68595) read user documents and agent-authored payloads with the locale-default codec: - docx/powerpoint validators/base.py opened OOXML part XML in text mode before handing it to lxml. On Windows (cp1251/GBK) the bytes decode to mojibake that lxml then parses, so validation runs against silently corrupted document text; on locales where the UTF-8 bytes don't decode the validator crashes with UnicodeDecodeError instead of validating. Opening as bytes lets lxml honor the encoding declared in the XML prolog. - The pdf form scripts (fill_fillable_fields, fill_pdf_form_with_annotations, create_validation_image, check_bounding_boxes) read the fields JSON the agent authors — UTF-8 by construction — with the locale codec, so non-ASCII form values (any Cyrillic/CJK/accented input) either crash or get written into the user's PDF as mojibake. The json.dump writers use ensure_ascii=True and were already safe; only the readers needed pinning. Adds a contract test asserting every document/payload reader is locale-independent, plus a live regression test that runs check_bounding_boxes.py on a non-ASCII fields.json under a forced non-UTF-8 locale — it fails without the fix on both POSIX (C locale) and Windows (cp1251 chokes on the 0x98 byte of U+2018).
melon-xf
added a commit
to melon-xf/hermes-agent
that referenced
this pull request
Sep 3, 2026
…point (NousResearch#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.
melon-xf
added a commit
to melon-xf/hermes-agent
that referenced
this pull request
Sep 3, 2026
…ma v23) (NousResearch#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:window: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:window: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 commit 3a9b9d65d505646212c4c875bab19b96ae14b2e6. * 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 of f842733 (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 p…
melon-xf
added a commit
to melon-xf/hermes-agent
that referenced
this pull request
Sep 3, 2026
…ce skill scripts The bundled office skills (NousResearch#68595) read user documents and agent-authored payloads with the locale-default codec: - docx/powerpoint validators/base.py opened OOXML part XML in text mode before handing it to lxml. On Windows (cp1251/GBK) the bytes decode to mojibake that lxml then parses, so validation runs against silently corrupted document text; on locales where the UTF-8 bytes don't decode the validator crashes with UnicodeDecodeError instead of validating. Opening as bytes lets lxml honor the encoding declared in the XML prolog. - The pdf form scripts (fill_fillable_fields, fill_pdf_form_with_annotations, create_validation_image, check_bounding_boxes) read the fields JSON the agent authors — UTF-8 by construction — with the locale codec, so non-ASCII form values (any Cyrillic/CJK/accented input) either crash or get written into the user's PDF as mojibake. The json.dump writers use ensure_ascii=True and were already safe; only the readers needed pinning. Adds a contract test asserting every document/payload reader is locale-independent, plus a live regression test that runs check_bounding_boxes.py on a non-ASCII fields.json under a forced non-UTF-8 locale — it fails without the fix on both POSIX (C locale) and Windows (cp1251 chokes on the 0x98 byte of U+2018).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Non-technical users asking Hermes for Word docs, spreadsheets, or PDF work now get bundled, first-class skill coverage —
docx,xlsx, andpdfjoinpowerpointinskills/productivity/, and the whole office suite is cross-linked so each skill routes to its siblings.Previously: .docx/.xlsx creation required knowing the Skills Hub existed and installing Anthropic's skills by hand; PDF manipulation (merge/split/forms/watermarks) had no skill at all;
powerpointhad drifted well behind upstream.Changes
skills/productivity/docx/— new bundled skill: create via docx-js (with the full corruption-footgun list), edit via unzip →word/document.xml→ zip, tracked changes/redlining validation, comments helper, XSD validation scripts. Adapted from anthropics/skills.skills/productivity/xlsx/— new bundled skill: openpyxl create/edit, mandatory LibreOffice recalc gate (scripts/recalc.py), LibreOffice-safe formula rules (_xlfn.prefixes, no XLOOKUP/spill functions), financial-model color/number conventions. Routes finance-grade work to the optionalexcel-authorskill.skills/productivity/pdf/— new bundled skill: merge/split/rotate/watermark/encrypt (pypdf, qpdf), text/table extraction (pdfplumber), creation (reportlab), form filling with 8 helper scripts +forms.md/reference.mdcompanions.skills/productivity/powerpoint/— synced to current upstream pptx skill: richer pptxgenjs corruption footguns (chart axis corruption, stacked-bar label corruption,<p:presentation>ordering), template workflow withvalidate.py+ validators +thumbnail.py, font-substitution-aware QA. Drops the stalepack.py/editing.md/pptxgenjs.mdworkflow.ocr-and-documents,nano-pdf,excel-authornow reference the new skills viarelated_skillsand prose pointers;deliverable-mode.mdnames the new skills.tests/skills/test_office_document_skills.py— frontmatter contracts (description ≤60 chars, platform gating), every script referenced in SKILL.md exists, validator schema map integrity, related_skills resolve, all helper scripts compile, docs pages generated.Validation
validate.py --original=SUM+_xlfn.TEXTJOIN→recalc.pystatus: success, total_errors: 0; B5=555, TEXTJOIN correctvalidate.py→thumbnail.pyscripts/run_tests.sh tests/skills/test_office_document_skills.pynpx docusaurus buildLicense note: the four adapted skills carry Anthropic's source-available LICENSE.txt, same as the existing bundled
powerpointskill has since it landed.Infographic