chore: sync upstream (5168 Commits) #11
No reviewers
Labels
No labels
herkunft/alpha-feedback
status
blockiert
status
zu-testen
typ
bug
typ
docs
typ
enhancement
typ
feature
typ
idea
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
iris/hermes-agent!11
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "chore/sync-upstream-20261105"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Upstream-Sync von
upstream/main(5168 Commits). Merge-Commit, 43 eigene Commits bleiben erhalten. Konflikte von Hand gelöst.79658be93bdocs(memory): point the public-module comment at plugin_api.json0502fe0597feat(plugins): one published list of what plugins may import7e8c8a5856fix(plugins): keep a reloaded hosted plugin when an abandoned load fails latef23fb505e7fix(plugins): keep a failed hosted load's error when its cleanup fails994e901730fix(plugins): run a failed hosted load's on_unload cleanup0004efe89btest(plugins): fail when the session identity example loses its importbce3109469docs(plugins): get_session_env falls back to the environment when unboundd982caf654fix(plugins): show the get_session_env import that validate accepts91e7b43badfix(plugins): plugin packs refuse a bare name whose catalog entry is recalledf77f25a350fix(plugins): judge enable of a nested plugin by its own manifest and directory347833a129fix(plugins): updates of --allow-removed subdirectory installs keep workingca17481680fix(plugins): plugin packs and dashboard/TUI enable respect the plugin kill list8987ae565ffix(gateway): owner names a managed approvals.mode when it refuses the write2dc7afd82efix(gateway): owner refuses a global reasoning level when agent.reasoning_effort is managed7a8c7bd693fix(tui_gateway): queue a global model pick that only keeps a pinned context lengthThe default and messaging packages carried their groups in extraDependencyGroups, so the documented overlay path `pkgs.hermes-agent.override { extraDependencyGroups = [ ... ]; }` replaced them and rebuilt the venv as minimal. Give the package its own defaultDependencyGroups argument, set it from packages.nix, and keep extraDependencyGroups purely additive (deduplicated, devPython too). Replace module-extras-extend-package with extras-extend-package, which covers the overlay and module paths and asserts positive effects as well as preserved ones, so an implementation that ignores the extras fails.Desktop chats run on the session authority, which installed the sudo and skill-secret prompts for a local viewer's turn but never the three vault callbacks. browser_vault therefore answered prompt_unavailable ("this session cannot ask the user") in every Desktop chat: an emailed or texted sign-in code could not be entered, a login could not be saved from the chat, and a password manager could not be unlocked. The owner now sends the sidecar's vault.unlock_prompt, vault.save_login and vault.code requests to the viewers attached to the session's event stream, as it already does for sudo and secret. The Desktop and Ink cards already handle these frames. Values travel only in the viewer's response frame and are never published, journaled or logged. - gateway/session_vault_prompt.py: vault_prompt_scope installs the three callbacks and the unlock owner for a local viewer's turn and restores them after; release_unlocks locks the managers a session unlocked. - gateway/run_turn_runner.py: enter the scope beside sudo and secret. - gateway/session_viewer_close.py: release the session's unlocks when its last viewer closes it, as the sidecar's session end does. - hermes_cli/gateway_agent.py: the classic CLI viewer answers vault.* requests through its own panels instead of leaving them to time out.load_hermes_dotenv expands ${VAR} / ${VAR:-default} before TERMINAL_ENV picks the backend, so `HPC_BACKEND=singularity` + `TERMINAL_ENV=${HPC_BACKEND}` runs Singularity. The predicate compared the raw text, said no, and the non-empty reference also blocked the startup process-env fallback: update and startup both skipped the plugin. env_loader.resolve_home_dotenv returns what load_hermes_dotenv would publish from a home's dotenv layers (home .env, then managed .env, both override), read-only, through the same parser and resolution step the loader now shares (_parse_dotenv_file, _dotenv_lookup_env, _resolve_dotenv_assignments). Review: https://github.com/NousResearch/hermes-agent/pull/142561#discussion_r4343427764A local session freezes its working directory; the owner's readiness gate (policy_scope) refuses any turn whose frozen cwd no longer exists, by design. But TurnRunner.run_sync let that RuntimeStoreError('workspace_unavailable') escape as a raw exception into the generic agent-error handler, so every surface showed an unexplained failure: the classic CLI printed "The model request failed. Run /model ... / Details: Something went wrong", the transcript gained a false "Some actions may already have run" row, and the gateway logged a traceback per message. A classic `hermes -w` session whose clean tree quit removed (or any folder deleted by hand) became a chat where every message failed with no hint why. run_sync now catches only that refusal on scope entry and settles the turn as a failed, never-run result (api_calls 0) with its own copy naming the missing folder and the way out (recreate it, `/worktree new` in the CLI, Move to project in Desktop), stamped failure_reason workspace_unavailable / failure_retryable false: the same shape the model-resolution failure path uses. The refusal carries field=cwd, detail=<path> so other paths that surface it name the folder too. agent/error_surface maps the code to the gateway layer (no "Switch provider"), and Desktop gets its own error-card copy with Retry + Start new chat. Copy lands in all 17 gateway catalogs and the 9 Desktop catalogs carrying error codes. The over-cap Desktop catalogs offset the growth by moving their error-card tables into topical siblings (<lang>_error_layers.ts as en already does, en/ru_error_codes.ts).Safe-mode and config-only sessions run turns in a managed worker. A removed folder crashed that worker inside its policy scope; the worker reported only managed_worker_failed, which the owner treats as a LOST execution: the admission settled unknown and every later message stayed queued behind it, even after the folder came back. The managed ingress route (admit_managed) now checks workspace readiness before any worker exists and settles the same stamped, never-run failure the in-process runner does. workspace_ready / workspace_unavailable_result move to gateway/session_policy.py so policy_scope, TurnRunner and the managed route share one predicate and one result; execute_managed (the spawn phase tests drive directly) is unchanged. Real-gateway test: safe-mode session, folder removed -> terminal/failed naming the folder with zero model calls; folder restored -> the next message completes. Red on the previous head with ('unknown', None). Found by an independent review of #143075.hermes auth import-agentno longer suggests a nonexistent login command c20d96fdaaElectron 42 dropped the postinstall that unpacked its binary into node_modules/electron/dist. The binary now appears on the first require('electron'), so parallel vitest workers race to unpack it ("Text file busy" in #128267's CI), and `hermes desktop --source` finds no path.txt to launch. electron-runtime.mjs runs Electron's own install.js once: after npm ci for the desktop workspace, and in CI before the parallel checks. It first checks the installed package against the desktop pin and the lockfile, and does nothing when the runtime is already in place. On the current 40.10.2 pin, whose postinstall still unpacks the binary, it is a no-op. Salvaged from #137648 (the runtime-preparation subset only).Electron 44 removed clipboard.readImage() and made readText/writeText async. hermes:saveClipboardImage now reads PNG bytes through clipboard.read() + ClipboardItem.getType('image/png'), extracted into clipboard-image.ts with handler-level tests; hermes:writeClipboard awaits the now-async writeText. Declares ws@^8.21.1 as an apps/desktop dependency (same pin as #128311) so source-backend.test.ts typechecks against a declared module instead of a transitive peer. (cherry picked from commit 36d97a778f1915da4387d08f0cdbd407dce8da97)A routed task's result carried only the backend's label. It now carries route = {label, provider, model, reasoning_effort, service_tier}, read off the built child, so the parent sees what actually ran. Failed and abandoned entries carry it too. Default-strategy results keep their shape. Co-authored-by: unsupportedpastels <274530371+unsupportedpastels@users.noreply.github.com>PluginContext.ask_choice(question, choices) -> str | None lets a sync slash-command handler ask the user who ran it to pick one option and get the picked value back. Choices are strings or {value, label, description}. Each surface binds an asker around the handler call (hermes_cli/plugin_choices.py), and every asker is that surface's existing clarify prompt. No surface gets a new card type or wire shape: - messaging chats (gateway/plugin_command_choice.py): adapter.send_clarify plus the clarify tool's own delivery (_clarify_send_then_wait, text fallback, card retire). Telegram/Discord/Slack show buttons, other platforms a numbered list. The typed reply reaches the clarify text-intercept while the command still holds the chat. - local sessions on the gateway (classic CLI, Ink TUI, Desktop): the `clarify` server request to the session's viewers. Two fixes make the answer arrive while slash.exec is still waiting for the handler: * tui_gateway/ws.py: a viewer's answer to an open server request (a response frame, `clarify.lock`) is settled at once instead of queuing behind that socket's running slash.exec. * hermes_cli/gateway_plugin_commands.py: the classic CLI answers the session's `clarify` requests with its clarify panel while it waits for slash.exec. No turn pump reads the session's frames between turns. - in-process classic CLI and the legacy `hermes serve` backend: their clarify panel / `clarify` server request. Returns None when nothing can ask (hooks, cron, async handlers on the event loop), when the user cancels or answers with something that is not a choice, or when the clarify timeout passes. The handler then prints its text usage.Settings › Model › Subagents gains a Routing section for delegation.backend: Default / Model routes / Profile routes, plus any other plugin's backend by its own label. Plugin modes stay visible with an Install / Turn on hint while the Advanced Delegation plugin is absent or off. Core fails closed, so the page shows the SAVED mode as selected even when it cannot run, with a "Subagents will not start until …" banner naming the fix (Install plugin / Turn on / restart) and a Switch to Default button. Model routes get a list with create/edit/delete, a default route and inline validation (reusing the provider catalog + ModelSelect picker); Profile routes are a checkbox list of existing profiles with an optional description and a default. In any mode but Default the subagent model/provider rows are shown inert with one line saying why (with no default route, unrouted tasks use the main agent's model) and a Switch to Default button. Backend: GET /api/delegation/routing (configured + blocked_reason from core's backend_status), PUT /api/delegation/backend (core set_global_backend, under the profile scope + config lock), PUT /api/delegation/backends/{model_map, profile_routes} — profile-scoped (?profile=), validated (unique route names, known reasoning effort / service tier, existing profiles); route api_keys are redacted on read and kept on write.* feat(desktop): sign-in dialog shows who signed in and what moved The free-tier sign-in dialog now reads "Finish in your browser" with the device code shown small ("Do not share it.") and an "Open page again" button. Once the browser approves, the result screen sets the account email large under "Signed in as", then says what the replaced free tier's apps did, in the same lines every other sign-in surface prints. A Reconnect button opens the app that has to be reconnected; a move still retrying is a small note that updates itself. - The sign-in poll is a long-poll (`?wait=`): the backend answers once the session leaves pending, capped at 25 s. The desktop never asks again sooner than 2 s, so an older backend that answers at once is not hammered. The onboarding picker uses the same loop, and a Nous sign-in there that moved apps opens the same result screen. - The poll reports `handoff_status`: the handoff record as fields plus its copy (`guest_handoff.wire_status`). - New `GET /api/providers/oauth/nous/handoff?since=&wait=` (token-protected, profile-scoped) long-polls the handoff record so a retrying move is followed until it settles or the dialog closes. - The copy and failure screens for the retired transfer outcomes (declined, superseded, retired) are gone. * test(auth): sign-in copy check builds Completed with its handoff_status The every-state copy check builds each SignInState from a placeholder per dataclass field; Completed gained handoff_status in the parent commit, so give it a real wire_status value. * fix(desktop): Greptile round 1 on the sign-in result screen - Onboarding paints above every dialog, so a result from the onboarding provider list now waits until onboarding leaves the screen. - The result keeps the profile the sign-in ran for, and Reconnect names that profile (and connection) in the Connectors link, like a chat's connector links do. - A sign-in whose own run attempted no move (another process held the claim, or the budget ran out) reports the replaced free tier's saved record instead of no handoff (`guest_handoff.record_status`). - A failed handoff read retries with backoff (4, 8, 16, 32 s) before the result stops following the move. - de, es, fr and zh get the new dialog strings.reconnect-turn-state.spec.ts:177 goes red intermittently on CI only ("a turn indicator (Stop) is still showing after the turn settled"; rc.1 v0.22.5 run 39867334090 and the Oct 19 dispatch 39330962543). The CI trace shows the indicator at 0 (toHaveCount passed at +10795 ms), back at 1 by +11054 ms, and idle again by the failure screenshot: a transient re-arm after the settle, not an early assertion. Eleven local runs (eight pinned to two cores) never re-armed, and the CI artifact carried no frame or DOM timeline to name which path re-armed it. On a settle failure the spec now attaches stop-timeline.txt to the test's output dir (uploaded by CI): every non-delta gateway frame on the primary socket (event type, seq, running; RPC results with running and the replay window), merged with every Stop-button transition from an in-page MutationObserver (no polling load), on one clock. Assertions and waits are unchanged.aiohttp-socks: 0.11.0 -> 0.12.0 (pins edited: 1; also: {})alibabacloud-dingtalk: 2.2.42 -> 2.2.65 (pins edited: 1; also: {})anthropic: 0.87.0 -> 0.125.0 (pins edited: 2; also: {})asyncpg: 0.31.0 -> 0.32.0 (pins edited: 1; also: {})azure-identity: 1.25.3 -> 1.26.0 (pins edited: 1; also: {})boto3: 1.42.89 -> 1.43.115 (pins edited: 1; also: {botocore 1.42.97->1.43.115, s3transfer 0.16.1->0.19.3})concurrent-log-handler: 0.9.29 -> 0.9.30 (pins edited: 1; also: {})croniter: 6.0.0 -> 6.2.4 (pins edited: 1; also: {pytz 2026.5->None})cryptography: 50.0.1 -> 50.0.2 (pins edited: 2; also: {})daytona: 0.155.0 -> 0.210.0 (pins edited: 1; also: {marshmallow 4.3.1->None, daytona-api-client-async 0.155.0->0.210.0, daytona-api-client 0.155.0->0.210.0, daytona-toolbox-api-client-async 0.155.0->0.210.0, daytona-toolbox-api-client 0.155.0->0.210.0, environs 14.6.0->None})debugpy: 1.8.20 -> 1.8.22 (pins edited: 1; also: {})distlib: 0.4.3 -> 0.4.4 (pins edited: 1; also: {})exa-py: 2.10.2 -> 2.26.0 (pins edited: 1; also: {})fal-client: 0.13.1 -> 0.14.1 (pins edited: 1; also: {})firecrawl-py: 4.17.0 -> 4.53.2 (pins edited: 1; also: {})google-api-python-client: 2.194.0 -> 2.201.0 (pins edited: 1; also: {})google-auth: 2.55.1 -> 2.62.0 (pins edited: 2; also: {})google-auth-httplib2: 0.3.1 -> 0.4.4 (pins edited: 1; also: {})google-auth-oauthlib: 1.3.1 -> 1.5.0 (pins edited: 1; also: {})google-cloud-pubsub: 2.39.2 -> 2.43.0 (pins edited: 1; also: {})httpx2: 2.12.0 -> 2.15.1 (pins edited: 3; also: {httpcore2 2.12.0->2.15.1})huggingface-hub: 1.24.0 -> 1.33.0 (pins edited: 1; also: {})lark-oapi: 1.6.8 -> 1.7.5 (pins edited: 1; also: {})markdown: 3.10.2 -> 3.11.2 (pins edited: 1; also: {})microsoft-teams-apps: 2.0.13.4 -> 2.1.1 (pins edited: 1; also: {microsoft-teams-api 2.0.16->2.1.1})modal: 1.5.5 -> 1.6.2 (pins edited: 1; also: {})nemo-relay: 0.10.0 -> 0.10.3 (pins edited: 0; also: {})numpy: 2.4.3 -> 2.5.4 (pins edited: 3; also: {})packaging: 26.0 -> 26.3 (pins edited: 2; also: {})parallel-web: 0.4.2 -> 0.6.0 (pins edited: 1; also: {})pillow-heif: 1.6.0 -> 1.8.1 (pins edited: 0; also: {})playwright: 1.62.0 -> 1.64.0 (pins edited: 1; also: {})prompt-toolkit: 3.0.52 -> 3.0.53 (pins edited: 1; also: {})pydantic: 2.13.4 -> 2.14.1 (pins edited: 1; also: {pydantic-core 2.46.4->2.50.1})pytest-asyncio: 1.3.0 -> 1.4.0 (pins edited: 1; also: {})python-dotenv: 1.2.2 -> 1.2.4 (pins edited: 1; also: {})pywinpty: 3.0.5 -> 3.1.0 (pins edited: 0; also: {})requests: 2.33.0 -> 2.34.2 (pins edited: 1; also: {})resvg-py: 0.4.0 -> 0.5.0 (pins edited: 1; also: {})rich: 14.3.3 -> 14.3.4 (pins edited: 1; also: {})slack-sdk: 3.44.1 -> 3.45.0 (pins edited: 2; also: {})sounddevice: 0.5.5 -> 0.5.6 (pins edited: 3; also: {})starlette: 1.3.1 -> 1.9.0 (pins edited: 4; also: {})tenacity: 9.1.4 -> 9.2.1 (pins edited: 1; also: {})truststore: 0.10.4 -> 0.10.5 (pins edited: 2; also: {})uvicorn: 0.41.0 -> 0.54.0 (pins edited: 1; also: {})uvloop: 0.22.1 -> 0.23.0 (pins edited: 0; also: {})vercel: 0.7.2 -> 0.12.0 (pins edited: 1; also: {vercel-workers 0.0.25->None, vercel-internal-telemetry 0.7.2->0.8.1, vercel-cache 0.7.2->0.7.6})watchfiles: 1.2.0 -> 1.3.0 (pins edited: 0; also: {})ty: 0.0.82 -> 0.0.89 (pins edited: 1; also: {})ruff: 0.15.10 -> 0.15.22 (pins edited: 1; also: {})daytona: 0.210.0 -> 0.225.0 (pins edited: 1; also: {daytona-analytics-api-client 0.210.0->0.225.0, daytona-api-client-async 0.210.0->0.225.0, daytona-api-client 0.210.0->0.225.0, daytona-analytics-api-client-async 0.210.0->0.225.0, daytona-toolbox-api-client 0.210.0->0.225.0, daytona-toolbox-api-client-async 0.210.0->0.225.0})openai: 2.24.0 -> 2.54.0 (pins edited: 1; also: {})The worker store validated a tools[] pin as exactly {version, tools}, so a pin carrying delegation_backend raised invalid_params and left the worker's store failed for the rest of the turn (every managed-worker test timed out).- run_agent / gateway shutdown / cli + hermes -q atexit / /browser and the browser.manage RPC import cleanup_*, emergency_cleanup_all_sessions, get_cloud_provider, get_browser_engine, the lightpanda status pair and the CDP override + supervisor helpers from tools.browser_session_{lifecycle, cloud,cdp} and tools.browser_lightpanda; - per-turn cleanup reads browser.headed through is_headed_mode() only (the AGENT_BROWSER_HEADED fallback is gone with agent-browser); - tests repointed; tests that only covered browser_vision / the built-in tool wrapper are dropped, the browser child env is credential-free per the session layer, and the codex MCP list must name core tools only.hermes tools: the Camofox and separate Browser Use rows and the agent_browser, browser_use_cli and camofox post-setup hooks are gone; Local Browser's hook is chromium (pm.ensure("chromium"); Termux and the Docker image get a hint). Provider picker logic keyed on browser.backend is removed. Doctor's agent-browser rows become one Chromium row via chromium_executable(); doctor --live, Nous subscription browser checks and setup summary rows follow.The guard matched only page targets, so a SharedWorker target_id of a private page (or a bogus id) passed and fetch('/') read the page. Judge the acted target's own URL; non-page/unknown/target-less calls are refused while any page is private, and an unreadable target list refuses too.Target.createTarget{url: "about:blank"} was refused as a private address: url_blocked rejects every non-http scheme, so a model could not open the blank tab Hermes's own helpers start from before installing scripts or navigating. about:blank (and an empty or omitted url, Chrome's same default) is allowed; any other url still goes through Page.navigate's check, so network URLs and other schemes (file:, data:) stay refused.browser.user_data_dir, chromium_args, cloud_provider and every other browser.* knob were read with read_raw_config, which skips the managed overlay and ${VAR} expansion: a managed profile pin or flag set was ignored, and ${HOME}/chrome-profile became a literal dir under HERMES_HOME. The session layer's one reader (browser_session_cloud.browser_section) now uses load_user_config_effective, and the other raw browser.* reads go through it or the same loader: browser_exec's cloud routing (browser_use_cli._read_browser_cfg), the real-profile pin (browser_connect._browser_setting) and /browser status's cdp_url (methods_browser, now browser_session_cdp.get_cdp_override_raw). Tests that stubbed read_raw_config for browser.* now stub the effective loader.A local browser_exec call (hybrid ::local sidecar, cloud fallback, real profile) was labelled by re-reading browser.engine after the call. A session created before the engine changed (a routed profile re-reads config every call), or the real-profile Chromium, could therefore be counted as Lightpanda, and a Lightpanda session as Chromium. The route now carries the engine of the session it actually ran on ("local" or "lightpanda"; the real profile is always Chromium) into the metrics label instead of reconstructing it from configuration.Plugins can put short text labels on Kanban card tiles through a new KANBAN_CARD_AREAS.badges data contribution instead of inserting DOM into the board. decorate(card) receives a frozen {id, status, tenant, assignee} view and returns {label, tone?, tooltip?} (or a few, or null); the host validates (text only, control/bidi chars stripped, length caps, palette tones), orders by plugin id then contribution id (contribution order is ignored), caps at two badges per plugin per card, attributes each badge to its plugin in the tooltip, and paints them in a fixed row above the title. Disposers and plugin unload remove them. Refs #140026decorate(card, board) now also gets a frozen { tenants } view (the list the board's filter menu already shows), so a tenant-label plugin can assign collision-free hues across the board the way kanban-tenant-labels does today, instead of reading the Kanban plugin's query cache for it.@assistant-ui/react: 0.14.24 -> 0.14.29 (pins edited: 1; also: {'safe-content-frame': "['0.0.21']->['0.0.25']"})@assistant-ui/react-streamdown: 0.3.5 -> 0.3.7 (pins edited: 1; also: {})@babel/core: 8.0.1 -> 8.0.7 (pins edited: 2; also: {'@babel/code-frame': "['8.0.0', '8.0.0']->['8.0.6', '8.0.6']", '@babel/compat-data': "['8.0.0', '8.0.0']->['8.0.5', '8.0.5']", '@babel/generator': "['8.0.0', '8.0.0']->['8.0.6', '8.0.6']", '@babel/helper-compilation-targets': "['8.0.0', '8.0.0']->['8.0.7', '8.0.7']", '@babel/helper-globals': "['8.0.0', '8.0.0']->['8.0.6', '8.0.6']", '@babel/helper-string-parser': "['8.0.0', '8.0.0']->['8.0.6', '8.0.6']", '@babel/helper-validator-identifier': "['8.0.4', '8.0.4']->['8.0.6', '8.0.6']", '@babel/helpers': "['8.0.0', '8.0.0']->['8.0.7', '8.0.7']", '@babel/parser': "['8.0.4', '8.0.4']->['8.0.7', '8.0.7']", '@babel/traverse': "['8.0.4', '8.0.4']->['8.0.7', '8.0.7']", '@babel/types': "['8.0.4', '8.0.4']->['8.0.6', '8.0.6']", '@jridgewell/sourcemap-codec': "['1.5.5']->['1.6.0']"})@chenglou/pretext: 0.0.6 -> 0.0.9 (pins edited: 1; also: {})@codemirror/commands: 6.10.4 -> 6.11.1 (pins edited: 1; also: {})@codemirror/language: 6.12.4 -> 6.13.1 (pins edited: 1; also: {})@codemirror/state: 6.7.1 -> 6.7.6 (pins edited: 1; also: {})@codemirror/view: 6.43.6 -> 6.43.14 (pins edited: 1; also: {})@electron/rebuild: 4.2.0 -> 4.2.1 (pins edited: 1; also: {})@icons-pack/react-simple-icons: 13.11.1 -> 13.15.1 (pins edited: 1; also: {})@lezer/highlight: 1.2.3 -> 1.2.5 (pins edited: 1; also: {})@nous-research/ui: 0.18.2 -> 0.19.1 (pins edited: 3; also: {})@react-three/fiber: 9.6.1 -> 9.8.1 (pins edited: 1; also: {'its-fine': "['2.0.0']->['2.1.1']"})@rolldown/plugin-babel: 0.2.3 -> 0.2.4 (pins edited: 2; also: {'picomatch': "['4.0.5']->['4.0.7']"})@streamdown/math: 1.0.2 -> 1.0.3 (pins edited: 1; also: {})@tanstack/react-query: 5.101.2 -> 5.104.1 (pins edited: 1; also: {'@tanstack/query-core': "['5.101.2']->['5.104.1']"})@tanstack/react-virtual: 3.14.6 -> 3.14.13 (pins edited: 1; also: {'@tanstack/virtual-core': "['3.17.4']->['3.17.11']"})@tauri-apps/api: 2.11.1 -> 2.12.2 (pins edited: 1; also: {})@tauri-apps/cli: 2.11.4 -> 2.12.1 (pins edited: 1; also: {'@tauri-apps/cli-darwin-arm64': "['2.11.4']->['2.12.1']", '@tauri-apps/cli-darwin-x64': "['2.11.4']->['2.12.1']", '@tauri-apps/cli-linux-arm-gnueabihf': "['2.11.4']->['2.12.1']", '@tauri-apps/cli-linux-arm64-gnu': "['2.11.4']->['2.12.1']", '@tauri-apps/cli-linux-arm64-musl': "['2.11.4']->['2.12.1']", '@tauri-apps/cli-linux-riscv64-gnu': "['2.11.4']->['2.12.1']", '@tauri-apps/cli-linux-x64-gnu': "['2.11.4']->['2.12.1']", '@tauri-apps/cli-linux-x64-musl': "['2.11.4']->['2.12.1']", '@tauri-apps/cli-win32-arm64-msvc': "['2.11.4']->['2.12.1']", '@tauri-apps/cli-win32-ia32-msvc': "['2.11.4']->['2.12.1']", '@tauri-apps/cli-win32-x64-msvc': "['2.11.4']->['2.12.1']"})@tauri-apps/plugin-dialog: 2.7.1 -> 2.8.1 (pins edited: 1; also: {})@tauri-apps/plugin-opener: 2.5.4 -> 2.7.0 (pins edited: 1; also: {})@tauri-apps/plugin-process: 2.3.1 -> 2.4.0 (pins edited: 1; also: {})@tauri-apps/plugin-shell: 2.3.5 -> 2.4.1 (pins edited: 1; also: {})@testing-library/dom: 10.4.1 -> 10.4.2 (pins edited: 1; also: {})@testing-library/react: 16.3.2 -> 16.3.3 (pins edited: 1; also: {})@types/node: 22.20.1 -> 22.20.5 (pins edited: 3; also: {})@types/react: 19.2.17 -> 19.3.0 (pins edited: 4; also: {})@types/react-dom: 19.2.3 -> 19.3.0 (pins edited: 3; also: {})bidi-js: 1.0.3 -> 1.1.0 (pins edited: 1; also: {})bippy: 0.5.43 -> 0.7.3 (pins edited: 1; also: {})blobatar: 2.0.0 -> 2.7.0 (pins edited: 1; also: {})dbus-native: 0.15.2 -> 0.16.1 (pins edited: 1; also: {})driver.js: 1.8.0 -> 1.9.0 (pins edited: 1; also: {})electron-updater: 6.8.9 -> 6.8.10 (pins edited: 1; also: {})esbuild: 0.28.1 -> 0.28.2 (pins edited: 3; also: {})eslint-plugin-perfectionist: 5.10.0 -> 5.12.1 (pins edited: 1; also: {})eslint-plugin-react-refresh: 0.5.3 -> 0.5.7 (pins edited: 1; also: {})frimousse: 0.3.0 -> 0.4.0 (pins edited: 1; also: {})get-east-asian-width: 1.6.0 -> 1.7.0 (pins edited: 1; also: {})@playwright/test: 1.62.1 -> 1.64.0 (pins edited: 2; also: {'playwright': "['1.62.1']->['1.64.0']", 'playwright-core': "['1.62.1']->['1.64.0']"})@tabler/icons-react: 3.44.0 -> 3.49.0 (pins edited: 1; also: {'@tabler/icons': "['3.44.0']->['3.49.0']"})@tanstack/react-virtual: 3.14.13 -> 3.14.14 (pins edited: 1; also: {'@tanstack/virtual-core': "['3.17.11']->['3.18.0']"})globals: 17.7.0 -> 17.13.0 (pins edited: 1; also: {})ignore: 7.0.6 -> 7.0.12 (pins edited: 1; also: {})katex: 0.18.4 -> 0.19.0 (pins edited: 3; also: {'commander': "['8.3.0']->['15.0.0']"})lefthook: 2.1.14 -> 2.2.1 (pins edited: 1; also: {'lefthook-darwin-arm64': "['2.1.14']->['2.2.1']", 'lefthook-darwin-x64': "['2.1.14']->['2.2.1']", 'lefthook-freebsd-arm64': "['2.1.14']->['2.2.1']", 'lefthook-freebsd-x64': "['2.1.14']->['2.2.1']", 'lefthook-linux-arm64': "['2.1.14']->['2.2.1']", 'lefthook-linux-x64': "['2.1.14']->['2.2.1']", 'lefthook-openbsd-arm64': "['2.1.14']->['2.2.1']", 'lefthook-openbsd-x64': "['2.1.14']->['2.2.1']", 'lefthook-windows-arm64': "['2.1.14']->['2.2.1']", 'lefthook-windows-x64': "['2.1.14']->['2.2.1']"})mermaid: 11.16.1 -> 11.17.2 (pins edited: 2; also: {})motion: 12.42.2 -> 12.43.0 (pins edited: 2; also: {'framer-motion': "['12.42.2']->['12.43.0']"})nanoid: 6.0.0 -> 6.0.2 (pins edited: 1; also: {})prettier: 3.9.5 -> 3.9.9 (pins edited: 2; also: {})radix-ui: 1.6.7 -> 1.7.0 (pins edited: 2; also: {'@radix-ui/react-accessible-icon': "['1.1.15']->['1.1.16']", '@radix-ui/react-accordion': "['1.2.20']->['1.2.21']", '@radix-ui/react-alert-dialog': "['1.1.23']->['1.1.24']", '@radix-ui/react-arrow': "['1.1.15']->['1.1.16']", '@radix-ui/react-aspect-ratio': "['1.1.15']->['1.1.16']", '@radix-ui/react-avatar': "['1.2.6']->['1.2.7']", '@radix-ui/react-checkbox': "['1.3.11']->['1.3.12']", '@radix-ui/react-collapsible': "['1.1.20']->['1.1.21']", '@radix-ui/react-collection': "['1.1.15']->['1.1.16']", '@radix-ui/react-context-menu': "['2.3.7']->['2.3.8']", '@radix-ui/react-dialog': "['1.1.23']->['1.2.0']", '@radix-ui/react-direction': "['1.1.4']->['1.1.5']", '@radix-ui/react-dismissable-layer': "['1.1.19']->['1.1.20']", '@radix-ui/react-dropdown-menu': "['2.1.24']->['2.1.25']", '@radix-ui/react-focus-scope': "['1.1.16']->['1.2.0']", '@radix-ui/react-form': "['0.1.16']->['0.2.0']", '@radix-ui/react-hover-card': "['1.1.23']->['1.1.24']", '@radix-ui/react-label': "['2.1.15']->['2.1.16']", '@radix-ui/react-menu': "['2.1.24']->['2.1.25']", '@radix-ui/react-menubar': "['1.1.24']->['1.1.25']", '@radix-ui/react-navigation-menu': "['1.2.22']->['1.3.0']", '@radix-ui/react-one-time-password-field': "['0.1.16']->['0.1.17']", '@radix-ui/react-password-toggle-field': "['0.1.11']->['0.1.12']", '@radix-ui/react-popover': "['1.1.23']->['1.2.0']", '@radix-ui/react-popper': "['1.3.7']->['1.3.8']", '@radix-ui/react-portal': "['1.1.17']->['1.1.18']", '@radix-ui/react-presence': "['1.1.10']->['1.1.11']", '@radix-ui/react-primitive': "['2.1.10']->['2.1.11']", '@radix-ui/react-progress': "['1.1.16']->['1.1.17']", '@radix-ui/react-radio-group': "['1.4.7']->['1.4.8']", '@radix-ui/react-roving-focus': "['1.1.19']->['1.1.20']", '@radix-ui/react-scroll-area': "['1.2.18']->['1.3.0']", '@radix-ui/react-select': "['2.3.7']->['2.3.8']", '@radix-ui/react-separator': "['1.1.15']->['1.1.16']", '@radix-ui/react-slider': "['1.4.7']->['1.5.0']", '@radix-ui/react-slot': "['1.3.3']->['1.4.0']", '@radix-ui/react-switch': "['1.3.7']->['1.3.8']", '@radix-ui/react-tabs': "['1.1.21']->['1.1.22']", '@radix-ui/react-toast': "['1.2.23']->['1.2.24']", '@radix-ui/react-toggle': "['1.1.18']->['1.1.19']", '@radix-ui/react-toggle-group': "['1.1.19']->['1.1.20']", '@radix-ui/react-toolbar': "['1.1.19']->['1.1.20']", '@radix-ui/react-tooltip': "['1.2.16']->['1.3.0']", '@radix-ui/react-use-size': "['1.1.4']->['1.1.5']", '@radix-ui/react-visually-hidden': "['1.2.11']->['1.2.12']"})react-arborist: 3.13.2 -> 3.16.0 (pins edited: 1; also: {})react-router: 8.3.0 -> 8.4.0 (pins edited: 2; also: {})react-shiki: 0.9.3 -> 0.11.2 (pins edited: 1; also: {})remend: 1.3.0 -> 1.4.0 (pins edited: 1; also: {})shiki: 4.3.1 -> 4.4.3 (pins edited: 1; also: {'@shikijs/core': "['4.3.1']->['4.4.3']", '@shikijs/engine-javascript': "['4.3.1']->['4.4.3']", '@shikijs/engine-oniguruma': "['4.3.1']->['4.4.3']", '@shikijs/langs': "['4.3.1']->['4.4.3']", '@shikijs/primitive': "['4.3.1']->['4.4.3']", '@shikijs/themes': "['4.3.1']->['4.4.3']", '@shikijs/types': "['4.3.1']->['4.4.3']"})tailwind-merge: 3.6.0 -> 3.7.0 (pins edited: 3; also: {})three: 0.180.0 -> 0.186.1 (pins edited: 1; also: {})tsx: 4.23.1 -> 4.23.15 (pins edited: 2; also: {})tw-shimmer: 0.4.12 -> 0.4.13 (pins edited: 2; also: {})typescript-eslint: 8.64.0 -> 8.71.1 (pins edited: 1; also: {'@typescript-eslint/eslint-plugin': "['8.64.0']->['8.71.1']", '@typescript-eslint/parser': "['8.64.0']->['8.71.1']", '@typescript-eslint/project-service': "['8.64.0']->['8.71.1']", '@typescript-eslint/scope-manager': "['8.64.0']->['8.71.1']", '@typescript-eslint/tsconfig-utils': "['8.64.0']->['8.71.1']", '@typescript-eslint/type-utils': "['8.64.0']->['8.71.1']", '@typescript-eslint/types': "['8.64.0']->['8.71.1']", '@typescript-eslint/typescript-estree': "['8.64.0']->['8.71.1']", '@typescript-eslint/utils': "['8.64.0']->['8.71.1']", '@typescript-eslint/visitor-keys': "['8.64.0']->['8.71.1']"})wait-on: 9.0.10 -> 9.5.1 (pins edited: 1; also: {})ws: 8.21.1 -> 8.22.0 (pins edited: 2; also: {})zod: 4.4.3 -> 4.6.5 (pins edited: 1; also: {})katex: 0.18.4 -> 0.19.0 (pins edited: 1; also: {'commander': "['8.3.0']->['15.0.0']"})mermaid: 11.16.1 -> 11.17.2 (pins edited: 1; also: {})prism-react-renderer: 2.3.0 -> 2.4.1 (pins edited: 1; also: {})serialize-javascript: 7.0.7 -> 7.1.2 (pins edited: 1; also: {})uuid: 14.0.1 -> 14.0.2 (pins edited: 1; also: {})@opentelemetry/core: 2.10.0 -> 2.12.0 (pins edited: 1; also: {})@opentelemetry/exporter-logs-otlp-http: 0.218.0 -> 0.223.0 (pins edited: 1; also: {})@opentelemetry/exporter-trace-otlp-http: 0.218.0 -> 0.223.0 (pins edited: 1; also: {'@opentelemetry/resources': "['2.7.1']->['2.12.0']"})@opentelemetry/otlp-exporter-base: 0.218.0 -> 0.223.0 (pins edited: 1; also: {})@opentelemetry/otlp-transformer: 0.218.0 -> 0.223.0 (pins edited: 1; also: {'@opentelemetry/resources': "['2.7.1']->['2.12.0']"})protobufjs: 8.7.1 -> 8.8.0 (pins edited: 1; also: {})spectrum-ts: 12.7.0 -> 12.10.1 (pins edited: 1; also: {'@opentelemetry/resources': "['2.10.0']->['2.12.0']", '@opentelemetry/sdk-trace': "['2.10.0']->['2.12.0']", '@opentelemetry/sdk-trace-base': "['2.10.0']->['2.12.0']", '@spectrum-ts/core': "['12.7.0']->['12.10.1']", '@spectrum-ts/imessage': "['12.7.0']->['12.10.1']", '@spectrum-ts/slack': "['12.7.0']->['12.10.1']", '@spectrum-ts/telegram': "['12.7.0']->['12.10.1']", '@spectrum-ts/terminal': "['12.7.0']->['12.10.1']", '@spectrum-ts/whatsapp-business': "['12.7.0']->['12.10.1']", 'es-module-lexer': "['2.3.2']->['3.0.3']", 'import-in-the-middle': "['3.3.3']->['3.5.2']"})react+react-dom: 19.2.7 -> 19.3.0 (pins edited: 9; also: {})react+react-dom: 19.2.7 -> 19.3.0 (pins edited: 2; also: {'scheduler': "['0.27.0']->['0.28.0']"})vite: 8.2.4 -> 8.3.4 (pins edited: 3; also: {})shiki: 4.4.3 -> 4.5.0 (pins edited: 1; also: {'@shikijs/core': "['4.4.3']->['4.5.0']", '@shikijs/engine-javascript': "['4.4.3']->['4.5.0']", '@shikijs/engine-oniguruma': "['4.4.3']->['4.5.0']", '@shikijs/langs': "['4.4.3']->['4.5.0']", '@shikijs/primitive': "['4.4.3']->['4.5.0']", '@shikijs/themes': "['4.4.3']->['4.5.0']", '@shikijs/types': "['4.4.3']->['4.5.0']"})streamdown: 2.5.0 -> 2.7.0 (pins edited: 1; also: {'remend': "['1.3.0']->['1.4.0']", 'marked': "['17.0.6']->['18.1.0']"})A newline ended the directive even when the next line continued the expression ('use strict'\n.toUpperCase()), so the inserted guard broke a valid script. A newline now counts only when the next line cannot continue the expression; comments after the directive still keep it strict.- Feishu routed .ogg/.opus to an "audio" (voice) message by extension even for send_document / is_voice=False; Opus is now an audio message only when the caller requested one. - Discord _ogg_opus_duration walks page headers by segment table instead of rfind("OggS"), which could match packet data and misread the granule. - Tests: successful transcode path (bytes, duration, temp removed), OggS run inside packet data, Feishu plain .ogg/.opus and the voice-intent control.A Desktop plugin can now register how Desktop shows a MEDIA: attachment produced by one of ITS OWN tools (TOOL_MEDIA_RENDERER_AREA, 'transcript.toolMedia'). Ownership is provenance, never file type: the transcript finds the tool call in that turn whose result produced the path (earliest call wins, so a later `ls` only mentions it), and the plugin's component replaces the built-in player only when that plugin's package registered the tool. Why: the elevenlabs-sound-effects and meshy plugins each needed a custom card for their files and had to make the model emit a ::directive (plus a backend handshake) to get one, which leaves non-Desktop users a raw line. With this seam the tool keeps returning a plain MEDIA: tag every surface understands and Desktop alone upgrades it. - Backend: `plugins.manage list` rows carry `tools` (native tools the loaded plugin registered, minus built-in names and names two plugins list) and `tool_prefixes` (`mcp__<server>__` for each MCP server the loader attributed to a portable package). Contract + generated TS. - Desktop: lib/tool-media-renderers.ts (contract, producer lookup, ownership resolution), store/tool-media-owners.ts (lazy per-route ownership list), components/assistant-ui/tool-media-slot.tsx wired into MarkdownLink's MEDIA and bare-path branches. Claims on core or other plugins' tools, or from a desktop plugin with no agent half, are refused with a console warning. A throwing renderer, a disabled plugin or an older backend fall back to the built-in player. - Props: file {path, name, kind, mime, resolveUrl()} (stream route for audio/video, works on remote backends), toolName, toolCallId, args, result, sessionId, fallback. - Docs: desktop-plugin-sdk.md section with a worked example; skill ref.load_config() expands ${VAR} in a native mcp_servers entry while the package declaration keeps its placeholders until connect, so a user who re-declares the package server (to widen its trust) with an env-backed argument compared as a different server and the package lost its cards. Both sides now compare after the same connect-time expansion.restart --allowes the same "Gateway online" notice bc8e9107b6The spawn mock in checkout-source.test.ts stood in for posix.sh taking the update marker, but wrote line 2 from its own clock (Math.floor(Date.now() / 1000)). The protocol-1 claim check (update-marker.ts handoffTakenBy) requires line 2 to equal the updateStartedAt the Desktop stamped at checkout.ts applyPosixHandoff and passed as HERMES_UPDATE_STARTED_AT, which the real script echoes (posix.sh STARTED_AT="${HERMES_UPDATE_STARTED_AT:-$NOW}"). When a loaded runner crossed a wall-clock second between that stamp and the spawn, the claim never matched, the 2 s handoffClaimTimeoutMs ran out and apply() returned { ok: false, error: 'updater-spawn-failed' } (main run 40137931246). Echo options.env.HERMES_UPDATE_STARTED_AT, as checkout-handoff.test.ts already does. Product code is unchanged: the real script echoes the env. Repro: a scratch delay to the next second boundary before spawnUpdaterProcess in applyPosixHandoff fails the old test 6/6 with CI's exact result object and passes the fixed one 6/6.Module and .override extras now append to the selected package's defaultDependencyGroups. Say so in nix-setup.md (en + zh-Hans) and the option description, point smaller builds at .#minimal or defaultDependencyGroups = [ ], and replace examples that named groups the default already ships ("messaging", "voice") or that no longer exist ("honcho").A file whose tests pass but whose fixtures error at setup ("41 passed, 7 errors") exits 1, yet the Summary read "41 tests passed, 0 failed" and the file was filed under "all tests passed but pytest exited non-zero". - accumulate summary["errors"] into tests_errored and print ", N errors" on the Summary line when non-zero (same pattern as crashed_note) - dedicated failure bucket "with pytest ERRORs" listing passed/error counts per file; such files no longer land in the passed-but-nonzero or no-tests-ran buckets - tests: parser returns errors for "41 passed, 7 errors"; end-to-end probe with a failing fixture asserts the Summary shows "3 errors"; clean run has no errors note- A category holding one skill renders as a single top-level row (" - computer-use: ...") instead of a header that repeats the name. - Descriptions over SKILL_PROMPT_DESC_LIMIT (60) are clipped at a word boundary, never mid-word; plugin skill rows are clipped too (they were not at all). Snapshot v5 so cached mid-word clips are rebuilt. - skill_manage rejects a description over 60 chars whenever the call writes it (create, or an edit/patch that changes it); an unchanged legacy description no longer blocks a body edit. The schema text says the limit and that a description must not repeat the category name; skills/AGENTS.md, hermes-agent-skill-authoring and the curator prompt match. - test_authoring_standards reads the limit from SKILL_PROMPT_DESC_LIMIT so the CI hardline and the index clip cannot drift apart. - prompt_size and the skills-index turn delta read both row depths.Greptile "Explicit paths skip the limit": patch(file_path="SKILL.md") and write_file("SKILL.md") reach the skill's root SKILL.md but skipped the 60-char description check that create/edit/plain patch run. The check now lives in _guarded_write whenever its target is the root SKILL.md, using the original it already reads to keep unchanged legacy descriptions editable; the per-action checks and edit's extra read are gone.Greptile "Half words still appear": clip_skill_description only treated " " as a word boundary, so a tab- or NBSP-separated legacy description fell into the hard cut and showed a half word ("bra..."). Any whitespace now marks a boundary and is trimmed before the ellipsis.Maintainer-approved prompt-footprint trims (measured on the Nous endpoint, anthropic/claude-opus-5.5): All sessions - The [SKILL_PRUNED] "Skill Safety Rule" (126 tok) left the system prompt: markers only exist after a compaction, so the compaction handoff now carries the rule whenever a marker survives in the compacted transcript (context_compressor_tail.with_skill_safety_rule, applied in _assemble_compressed for LLM and fallback summaries alike). The "## Pruned Skills" block drops its own parenthetical, which the rule now covers. `hermes sessions repair-prompts` keyed its "full build" detector on the rule's heading; it now keys on SKILLS_GUIDANCE (present in prompts built before and after this change). - The CLI/TUI cron "LOCAL-ONLY" caveat (154 tok) moved from the platform hints into cronjob_manage's deliver description (a deferred tool: paid only when loaded). - The Hermes help pointer no longer restates identity ("You run on Hermes Agent (by Nous Research)") after SOUL.md's "You are Hermes Agent, built by Nous Research."; it now opens "For help with Hermes Agent itself — ...", which still tells a custom persona what it runs on (22 tok). One-shot sessions only (-q/--oneshot/-z, and gateway-owned `oneshot` policy sessions) - No "## Mid-turn user steering" note (253 tok): nobody can steer a one-shot. - No clarify tool (590 tok): ONESHOT_DISABLED_TOOLSETS is folded into session_disabled_toolsets (oneshot=...), so the agent tool build, MCP refresh, the tool_search bridge, delegate children and the gateway's session inventory / tool listings all drop it together. skill_manage stays. - delegate_task without live control (425 tok): action/subagent_id/message and the background-delivery paragraph are removed (children are joined inside the call), applied via toolsets.agent_tool_surface at load and MCP refresh. The one-shot fact is frozen on the agent at its first tool build so refreshes stay byte-stable. Interactive tool schemas are byte-identical to main.Moving the shipped groups to defaultDependencyGroups dropped extraDependencyGroups from the base callPackage arguments, so an override (prev: { extraDependencyGroups = prev.extraDependencyGroups ++ [...]; }) that evaluated on main threw 'attribute extraDependencyGroups missing'. The base package now passes an explicit empty list, and the extras-extend-package check appends without 'or [ ]' so the shape stays covered.test_picker_reads_cached_usage_and_refreshes_without_waiting bounded the first build_model_options_payload() call at 5 s to prove the picker never waits on the usage API. That call's own cold work is not the usage API: on a fresh home list_authenticated_providers() seeds the Copilot pool through a real `gh auth token` probe and loads catalogs, which took 6.2 s and 18.5 s on loaded CI runners (runs 40147906961, 40139971387) and 5.2 s of a 5.9 s call locally at load ~216 (cProfile). The fetcher had already been called in those runs. Assert the contract directly: the fake usage API answers only after `release`, so a picker that waited on it cannot return before `answered` is set. The background-refresh and cache waits become 30 s deadlines (wait_until), which a passing run never approaches. Repro: a 6 s sleep injected in build_model_options_payload before _apply_usage fails the old test 3/3 and passes the new one 3/3. Sabotage: calling fetch_account_usage() synchronously in _apply_usage still fails the new test ("the picker waited on the usage API").The browser_exec description picks its text-first header from _should_use_native_vision_fast_path(), which measures whether screenshots can ride back inside TOOL RESULTS for the active provider/model. The header then asserted a model-level fact ("Your model cannot view images") that was never tested, and a vision-capable model on such a provider (deepseek-flash in the report; nous/claude on this host) quoted it against itself and skipped an image the user had attached to the conversation. Reword the header to state only what was determined: screenshots will not reach the model through this tool's results, work text-first, and this says nothing about user-attached images. Same guidance, no capability claim. Reported and diagnosed by @dcdc4747 in #131568.Greptile finding "Browser recommends a missing tool": the non-native browser_exec header picked its vision_analyze pointer from check_vision_requirements(), which reports whether a vision route exists, not whether this session exposes the tool, so a session with `vision` disabled was told to call a tool it does not have (tools/AGENTS.md: cross-toolset references are added in get_tool_definitions()). The header now states only the transport fact ("Screenshots are not attached to this tool's results. Work text-first: ..."), still never calling the model blind, and model_tools' browser_exec rewriter appends the vision_analyze pointer when "vision_analyze" is in the session's available set. The native header (screenshots ride back in the result) never gets it. Prompt-surface fixtures re-rendered: the stock render has vision_analyze, so browser_exec gains the pointer (+8 tokens per surface).With no cached manifest, get_catalog() retried the primary and fallback URLs (8 s timeout each) on every call after a failure, so an offline user paid ~16 s on every model-picker build (ACP session/new, session/load and fork each build one). A failed fetch with nothing cached now suppresses retries of that URL for 5 minutes per process (keyed by profile cache file + URL, same window as the models.dev registry backoff); later calls return {} at once and callers use the in-repo lists. A per-provider override URL gets the same backoff and falls back to the main manifest. force_refresh (hermes model --refresh, refresh_catalogs) and reset_cache() bypass/clear it; the success and stale-disk-cache paths are unchanged.Greptile "Working fallback gets skipped": with (home, url) keys, an override pointing at the main catalog URL recorded its single failed request under the same key the manifest checks, so get_catalog() returned {} without trying the raw-GitHub fallback. Backoff keys now carry the kind ("manifest"/"override").* fix(memory): an entry written before a limit was lowered stays removable The drift guard reads any entry longer than the whole-file char limit as free text an external writer appended, on the premise that no tool write can produce one. Lowering memory_char_limit or user_char_limit breaks that premise: a well-formed file whose entry was written under the old limit then refuses every replace and remove (tool, approval replay and the Journey/dashboard delete alike) and writes a .bak each time, while adds are blocked by the total. Pruning, the documented way back under the limit, is refused, so the store stays locked until hand-edited. A file that round-trips but carries an oversized entry now refuses only a write that adds or rewrites text. A write that only deletes entries goes through; when it drops an oversized entry the .bak snapshot is still taken first and returned as drift_backup, so text glued onto an entry without a delimiter is never deleted without a copy. Round-trip mismatches are refused as before. * fix(memory): a refused write beside an oversized entry still names that entry With deletions now allowed past an oversized entry, a replace or edit that blows the budget because of that entry came back as a plain budget refusal ("shorten the new content") with no snapshot, although the oversized entry is the real obstacle. An over-budget refusal next to an oversized entry returns the drift error and its .bak again; other refusals (no match, would empty the store) pass through unchanged. The Journey edit/delete closure reports its budget refusal through _error so the class is visible. The Journey drift test (43d3d4e851) pinned "delete is refused beside free text"; it now pins the edit refusal and that a delete leaving the oversized entry in place goes through, which is the ruling for #133028 (removes must work while the store is over its limit). * fix(memory): delete-only batches prune past a lowered cap; snapshots never overwrite or go missing Review round (Greptile on e0ca0c027a8): - A remove-only batch skips the final budget check like a single remove (the empty-store guard still applies), so pruning two small entries beside an oversized one is one call, not two. - Snapshots are created exclusively (.bak.<ts>, then .bak.<ts>.<n>): two deletes of oversized entries in the same second no longer overwrite the first snapshot with content that lacks what the first delete removed. - A delete that drops an oversized entry is refused when its snapshot could not be written, instead of deleting possibly glued free text with no copy. * test(memory): freeze the clock so the same-second snapshot test cannot pass by luck Greptile on 2eba9c5e78a: with the real clock, two deletes straddling a second boundary get distinct backup names even without exclusive creation. Pinning time.time makes the collision certain; reverting to overwrite-mode writes now fails the test. * chore: retrigger CI (lost synchronize event) --------- Co-authored-by: John Paul Soliva <soliva.johnpaul@icloud.com>Two Webapp tests spied on the `localStorage` instance to make storage refuse reads and writes. On Node 22, which the desktop package supports, jsdom keeps that spy as a stored item rather than a method, so the page's calls still succeeded: the settings test failed there ("promise resolved instead of rejecting"), and the zoom test passed without ever blocking storage. Spying on `Storage.prototype` instead, as suggested, fails the other way on Node 26, where the test setup replaces jsdom's storage with a plain object whose methods are its own. A small test helper now returns whichever object holds the page storage's methods, and both tests spy there and assert that their spies were called. They pass on Node 22.23.1, 24.15.0 and 26.7.0.`hermes webapp` prints a link that signs a browser in as the user: on gateways before the previous change the browser credential itself after `#hermes-session=`, and now a one-use launch ticket after `/webapp/launch#ticket=`, which on macOS spends without a check on who opens it. Web URLs pass through the redactor by design, so when Hermes ran the command through its terminal tool the link went into the transcript intact. The redactor now masks the value after `hermes-session=` and the ticket in a `/webapp/launch` fragment, wherever they appear, and leaves the rest of the link readable. A floor of 20 characters keeps placeholders and code that builds such a link (`hermes-session=${token}`) unchanged. The `security.redact_secrets` switch still turns it off with every other pattern.Greptile: "Source assertions miss broken clients." The PR added an inspect.getsource check that _session_ticket and webapp_launcher._control contain "private_control_query(". Replace it with behaviour: the existing foreign-uid listener test now also drives gateway_client._session_ticket() and webapp_launcher._control() and asserts each refuses with unsafe_control_peer before any request reaches the listener. Mutation check: either client rewired to a raw AF_UNIX dial fails the test.Greptile: "Native tests miss CI." Every OS-gated test this PR adds now runs on the host it is about, where the OS lanes select it: - test_webapp_listener_fallback: skipif(sys.platform ...) -> platforms("linux") / platforms("windows"); the Windows SO_EXCLUSIVEADDRUSE test was imported by no lane before. - test_webapp_listener: the posix=True/False parametrize patched run_api._REUSE_FIXED_PORT (a faked OS flag); split into a platforms("posix") and a platforms("windows") test on the real listener socket, and drop the test that only mirrored the flag's expression. - test_webapp_file_urls: the UNC row was pytest.skip()ped off Windows inside a parametrized fixture; it is now its own platforms("windows") tests, and the local-path tests carry platforms("linux", "windows") so the Windows lane runs the drive-letter case too. - test_webapp_launch: stop patching sys.platform. _peer_is_server_user and _one_use_links_work take an optional platform (default sys.platform) and the policy tests pass it explicitly. list_os_marked_tests.py windows: none of these files before, three after.The launch tests made the interpreter believe it was on another OS: they set sys.platform to freebsd14, openbsd7, sunos5 or win32, and replaced the launch-link check and the browser peer check with lambdas marked "macOS". The full-gateway fixture did the same for its macOS run. So the Windows path of the real functions was never run on Windows (no file was on the Windows lane), and the macOS detection only once, in one handoff test. The two decisions take the platform as data now (a keyword defaulting to sys.platform), and register_control_verbs takes whether launch links can be checked (None asks the host, as before). Unmarked tests pass each platform in as data; platforms("windows") and platforms("macos") tests run the real launch verb and handoff route on those hosts, and a platforms("linux") test runs the real peer check on a real loopback connection, reading /proc/net/tcp. The fixture's macOS run tells the gateway its links cannot be checked through that parameter, so the printed link is still exercised on every host. The handoff test for printed links now asserts the peer check is never consulted. Behaviour is unchanged.test_cron_timeout_closes_the_descendant_snapshot_fork_window read each descendant's status the instant _run_job_script returned. SIGKILL is asynchronous: the killed process stays in state R until the kernel finishes tearing it down, and freeing its memory waits for a CPU on a loaded runner. CI caught the pre-existing setsid child there ({gone, running, zombie}). The kill was delivered. A scratch dump at the check showed the "running" child in state R with SIGKILL pending (ShdPnd 0x100) and PF_EXITING set, and it exited 86-233 ms later. kill_process_tree is correct. The probe now waits up to 10 s for every recorded descendant to be gone, a zombie or dead (the transient X state while its parent reaps it). The children now sleep 60 s instead of 15, so a descendant the kill missed is still alive when the wait ends and the probe still fails. Repro: give the first child 1.5 GiB of touched memory, so its exit takes ~100-200 ms. Base fails 12/12 with CI's shape; the fixed probe fails 0/12. The fixed probe still fails when the fork-window stop+rescan is reverted, and when snapshotted descendants are never signalled.The panel connect, the account connect and the account rename all create the guest identity first (ensure_guest_identity). In an empty test home that is a real HTTP mint against portal.nousresearch.com with a 5s client timeout, so every run of these files minted live guest identities, and a slow or refusing Portal failed them: - slow mint: the connect reply outlives the test's 2s / 5s wait ("no reply"), and the timed-out mint is memoised with a cooldown, so the next test in the same process (rename) is refused with CONNECTOR_REQUEST_FAILED instead of ALIAS_TAKEN; the exact CI trio. - Portal unreachable: 5 of 13 tests fail, including the failed-reissue cases that only passed because an earlier test's live mint left an identity behind in the shared home. Patch both ensure_guest_identity seams production reads (the tools.connectors facade and tools.connectors.managed), as test_connector_account_surface.py already does. Repro (scratch pytest plugin wrapping anon_auth._reconcile_and_provision): slow (5.2s then ConnectTimeout): base 5 failed, fixed 13 passed, 0 mint calls down (ConnectError): base 5 failed, fixed 13 passed, 0 mint calls no network (unshare -rn): base 5 failed, fixed 13 passedUnder plugins.isolation: host every value crosses as JSON, so a dataclass arrived as a generic Record, a Path as str, an enum as its value and a plugin object as an Opaque placeholder. Core consumers that type-check broke: - secret sources: SecretSource.fetch() came back as Record, the orchestrator rejected it ("fetch returned Record instead of FetchResult") and applied no secrets; fetch() also received home_path as str, so `home_path / "x"` raised in the plugin. - context references: ContextCompletionItem (a __slots__ class) became Opaque, so @prefix: autocomplete silently returned nothing. - context engines: clone_for_agent() returned Opaque, so no agent could get its own copy of a hosted engine. The provider ABC's annotations are the contract, so the wire now restores against them: the parent rebuilds results from the declared return type (dataclasses, enums, Paths, typed containers; a declared provider ABC becomes an object proxy) and the host rebuilds method arguments from the declared parameter types. Type names never come from the host. ContextCompletionItem becomes a slots dataclass so it crosses as data.The rest of the suite uses an in-memory keyring. These two tests run only on the macOS and Windows OS lanes (platforms("macos", "windows")): store/read/delete a uniquely named dummy entry through the real keyring backend, and a keychain-mode vault create -> migrate to file -> migrate back round trip. They skip where the host has no OS keychain and clean their entries up in teardown; Linux hosts never run them, so a developer's Secret Service is never touched. tests-os.yml installs the keychain extra so keyring is importable on those lanes.A gateway unit whose stop outlives the updater's systemctl timeout (a cgroup child stuck in D state makes systemd wait TimeoutStopSec for SIGTERM and again for the final SIGKILL: ~181 s on a 90 s unit) broke both ends of `hermes update`: - pause: `_disarm` -> `_stop_unit` timed out at 120 s although the drained gateway was already gone; the pause rolled back with `systemctl start`, which queued behind the still-running stop job and booted the gateway on the old tree seconds before the git swap. - restart: the post-drain `systemctl start` timed out the same way and the unit was recorded failed ("Update incomplete"), while systemd finished the job and brought the gateway up on the new code. The client timeout cancels nothing in systemd. `run_unit_job` (one helper in update_cmd_fleet_unit_budget, used by the pause stop/start, the post-drain start and the blunt/catch-up restart) now waits for the unit's own job past a timeout, bounded by 2 x TimeoutStopUSec (+ TimeoutStartUSec for a start) + slack from the call, under an independent 900 s cap, and reports the settled state. The pause fails only when the gateway is provably still running; a stop job systemd is still finishing holds it. Calls that return in time are unchanged.Review F1 (P2): a queued messaging head the drain refuses is cancelled, but admit_message still turned the waiter's admission_conflict into the pause notice ("paused ... your message is saved"), and pause_notified then left the next refused follow-up with no reply at all. Read the row: a terminal one gets its own "not answered, send it again" reply; queued rows behind a real pause keep pause_notice. Refs #143547Teknium: the ElevenLabs variant of text_to_speech is where voice search belongs, not a separate tts_voices tool. The ElevenLabs schema now carries voice_id plus a find_voices object (text optional there); other providers' schema is byte-identical to main. Live search findings folded in: the library is the default source and sorts by a year of usage (most-used first); use_case (e.g. characters_animation) and sort filters; library voices with a custom credit rate show credit_rate; the description tells the model the library matches words literally, so a role word that finds nothing ('dwarf') falls back to describing the sound ('gravelly old' + filters).An empty find_voices object is the model's way to browse the library with the defaults, but a truthiness check sent it down the speech path ('Text is required', or audio instead of a search). Check for presence, in the handler and the display formatter (Greptile P1 on #144633).Greptile: "LazyHeaders lost across the plugin host wire". A user model-provider override that copies a bundled profile (dataclasses.replace(get_provider_profile('gmi'), ...)) inherits its LazyHeaders; the credential-free profile extraction encoded that Mapping as an Opaque placeholder, so dict() on it raised inside the client builders' suppress() and the User-Agent was silently dropped. describe_profile now materializes non-dict Mapping fields to a plain dict in the extraction child; the wire codec stays dict-only so no other Mapping (an environ) starts crossing the boundary as data.- tui_gateway _load_cfg_raw: only FileNotFoundError means "no config". config_version_or_none folded every stat error into None, so after a cached absent config a transient EACCES matched the cached {} and the next config.set could save it over the now-existing file. Any other stat error is a FailedConfigRead again, which _save_cfg refuses. - ConfigBackend.read_user_doc replaces read_user_layer/UserLayer: no caller used the version it stat'ed on every uncached read; cached loaders already ask config_version first. - Reader lint scopes follow Python: a self.<attr> bound in one method is seen by every method of the class, and a comprehension's target no longer leaks into the enclosing function. The class rule found pm/publication.py's compare-and-swap re-read of self.path, now marked like its twin in __init__.Native local profiles share the primary socket too (multiplex-only routing, case 6). A primary reconnect republishes the primary's own unflagged local descriptor while the picked profile stays selected, and the local-mode shortcut then sent an unnamed poll answered for the launch profile. Drop the shortcut: any descriptor that isn't the selected profile's own asks main for the selected profile's shared route. The selected profile's own descriptor still sends {} with no extra call.In the browser Webapp, a folder dropped on the composer (or the chat area, or a message being edited) showed a copy cursor, attached nothing and said nothing. The browser bridge has no host path for a dropped File, and the drop extractor discarded a directory without one before any composer saw it. The extractor now keeps a path-less folder with its File, so it takes the same attach pipeline as other unsupported drops. Staging refuses it with a reason ("Folders can't be attached by dropping them. Use Attach > Folder… instead."), shown as the usual "Could not attach <name>" drop warning in the composer and as an error toast in the edit composer. A folder never takes the image path. Folders with a path (Electron) still become @folder: refs. Refs #93508 (review P3) (cherry picked from commit 52f0b3fadceda6f11cca9644a8af5351051eb358)The Save guard took the session's own sandbox to be the first one whose directory still existed. When the session's whole sandbox directory was deleted, the shared profile sandbox became that first one, so a Save the read had resolved into the shared copy overwrote it. The session's own sandbox is now the first name in the translator's order whether or not its directory exists; a Save that resolves into any later sandbox answers the same 409 as a missing file ("File is no longer in this session's workspace"). Reads keep the fallback._own_port hand-rolled a raw read plus the managed overlay. It now reads load_user_config_effective(home / "config.yaml"), the loader hermes_cli/AGENTS.md names for presence-sensitive readers: the user file, ${VAR} expansion and the managed overlay, with no defaults merged, so a port is still only one the user or the administrator set.The pronounless-action detector's "relaunching it on <provider> [via acpx]" transition cue matched every registered provider name and alias, so a final answer like "Launching it on Nous." kept the turn going. Restrict it to external_process (ACP agent) profiles: their name, aliases and binary (process_command), which is how agents refer to an ACP CLI ("on Copilot via acpx" names the `copilot` binary). Main's answers for the ACP announcements are unchanged.With cwd set to the Hermes home, a relative HERMES_HOME (or plugin dir) resolved a second time from inside it, so extraction under plugins.isolation: host failed ("No __init__.py in hh/plugins/..."). Pass absolute paths for cwd, HERMES_HOME and the plugin dir. Reviewer probe with HERMES_HOME=hh: provider registered again.R4 H2 (creds CR-2). _provider_alias_table merges the bound home's plugin aliases but was memoized once per process: the first home's aliases served every profile ('github' normalized differently by call order) and a plugin installed while the process ran never reached it. Memoize per home key, rebuilt when the provider registry generation moves, through providers.bound_registry_key() (home key + generation after the periodic plugin-dir check), the same invalidation profile_for_host uses. auxiliary_client.py net -2 lines. Test: tests/agent/test_aux_alias_home_scope.py (both orders; install while running); red on the base.R4 H3. Profile provider_overlay lookups sent every non-alias name through list_providers() plus a linear scan: normalize_provider('gemini') went from a dict hit (~0.07us) to ~8us, and _lossy_alias_registry_pdef paid it once per registry row (~250us per call). - Built-in canonical ids (auth BUILTIN_PROVIDER_IDS, the static alias groups and HERMES_OVERLAYS) are answered by a frozenset hit before the overlay path; overlays never renamed them on the base either. Mid-auth-import (plugin discovery) only the local tables answer and nothing is cached. - normalize_provider / _overlay_profile take an optional already-fetched overlay list so a registry-wide loop reads the profile list once. p_cost: normalize_provider('gemini') 8.1 -> 0.09us; _lossy_alias_registry_pdef('kimi-coding') ~230 -> ~50us. Unknown names (no table hit) still consult the bound home's overlay profiles. Test: tests/hermes_cli/test_provider_normalize_cost.py (same answers; built-in ids never read the profile list; a registry-wide loop reads it once); red on the base.R4 review P1-2 (ACP-1): the alias only served CopilotACPClient, so three more listed catalog ACP plugins broke at their pinned SHAs: kiro-acp@02c4dd0 imports ACP_MARKER_BASE_URL at module top grok-acp@2851d66 imports _build_subprocess_env, _acp_supported, _model_selection_request and spawns/patches through agent.copilot_acp_client.subprocess.Popen cursor-acp@d5b6146 imports _effective_timeout on every completion (swept every plugin-catalog entry in the models category at its pinned SHA: only these three plus kimchi-acp-provider import the module.) The module now serves every top-level name it had before the deprecation through the same warn-once __getattr__: Copilot-flavoured helpers keep their former defaults (the copilot-acp placeholder, copilotEnablement filter, HERMES_COPILOT_ACP_* launch fallbacks); generic ones are the very objects agent.acp_client uses (shared probe cache). `subprocess` is a real module attribute so patches through it reach ACPClient's spawn and --acp probe. The docstring and developer guide no longer claim kimchi is the only user.LC-5 (r4 review). providers_selected intersected raw provider names with the row's aliases, so `model.provider: github` with `custom_providers: [{name: github, base_url: ...}]` counted as Copilot use: `hermes update` and agent start installed the plugin, and every process's first resolution tried to, although the resolver serves that name as the user's own endpoint. Names the resolver's named-custom rung (named_custom_provider_config_path) maps to a providers:/custom_providers: entry are dropped from the choice set.hermes updatereads the launch home's process env, as agent start does 4a4d62b6a4Independent review round: - EOF at the prompt meant "nobody could answer" even on a real TTY, so Ctrl-D printed the non-interactive --yes hint and exited 1. At a terminal it is a decline again ("Cancelled.", exit 0, as on main); /dev/null and empty pipes still exit 1. - sessions export refusing a session over sessions.max_export_messages printed an error and exited 0. It exits 1 on all three selection paths. The backup round-trip test never asserted the exit code (its helper serves the success legs too); its refusal leg now expects SystemExit(1) alongside the existing assertions._admission_outcome read the admission row, then its result blob, in two statements. The drain settles from a worker thread (commit_and_publish), so the commit could land between them: the result was present while the stale started row had no outcome, and a completed turn projected as a terminal `failed` receipt carrying the reply as its error. Terminal receipts leave _bot_receipt_index, so the settle wake re-read nothing and no reply watcher existed; the sender kept the false failure for good. Read the result first; when one exists but the row read was not terminal, re-read the row (the result and terminal outcome commit together, and terminal is final). This is the CI flake test_paused_receipt_rearms_on_resume_without_polling_rpc ("the paused delivery never settled"). Repro: an in-process loop of that scenario with a 0-20 ms stall between the two reads strands 251/2400 receipts on main (all `failed`/error=reply, row terminal/completed), 0/2400 with this fix. The new test commits the settlement between the two reads through refresh_receipt and fails on main with ('failed', 'pong').test_a_killed_updates_lock_stays_while_any_git_works_in_the_checkout [a reader git in the checkout] failed on 5 main runs Oct 16-19 with {'killed_update_owns': False, 'held_open': False}: the git was gone and nothing held the lock, yet the reclaim kept it. _killed_update_owns only counts a run that started before the lock was written (started > lock mtime is skipped: a later run cannot have left it). The fixture's receipt took started_at from datetime.now() a few ms before the test wrote index.lock, while the lock's mtime comes from the kernel's coarse file clock, which on a loaded CI VM can trail the precise clock by more than that gap. The lock then looked older than the run and the run was dropped. A killed update really starts seconds before its git takes the lock, so the fixture now backdates started_at by 5 s. Repro: shifting index.lock's mtime back 60 ms reproduces CI's exact assertion on main and passes with this change; 5x green unpatched.On top of the receipt backdate: test_a_failed_retry_never_erases_the_killed_updates_claim slept 50 ms on each side of the lock write to keep the killed run before the lock and the retry after it. The backdated fixture covers the first; the second never needed a wait, since a file's coarse-clock mtime is never ahead of the precise clock the retry's started_at reads. Both sleeps go (HX013). The reader case's failure message now also names whether the lock still exists. On CI it read {'killed_update_owns': False, 'held_open': False}, which a lock deleted by something else would print too; only the file timing (the failing attempts skip the 5 s holder poll of the first reclaim, so the ownership proof was false from setup) ruled that out. Repro (scratch copies, flake_hammer-style loop under CPU load, -k killed, file retries off): with the lock's mtime floored to a coarser file clock (10 ms tick + instant fsync, or 1 s granularity) main fails with CI's exact assertion; this head passes.Two writers changed the shared draft stash without the window's record of what it last saved there, so later saves skipped the editor's text as unchanged: - A send's late accept cleared the session's whole stash entry, wiping the next message the user had already typed and the debounce had saved. It now clears only while the stash still holds the sent message, through the composer's own save so the editor's text counts as unsaved again. - Each window wrote its whole draft list from its own copy of localStorage, which reaches the other windows milliseconds late. A write from that stale copy dropped or reverted the other window's newest draft, and the victim took it as "sent elsewhere". Drafts now carry their save time, a cleared draft leaves a timestamped marker for a while, and every write and reload merges per key, newest wins. A window that sees storage holding an older entry than the one it saved writes its own back. The 50-draft cap keeps the most recently saved drafts by those times, so all windows agree. The authoritative stash moves to one key (hermes:composer-drafts:v4) so a read never sees half a write; the v3 { key: text } list is still written after it and is read once on first load.Over plain HTTP from another machine the browser exposes no Clipboard API, so every Copy button showed "Copy failed": writeClipboardText had no fallback on the browser host. Copy path and Copy link went through the browser bridge's writeClipboard, which returned false; the SVG copy and Kanban's copy items threw a TypeError; and the goal criterion, onboarding code and install command copies failed silently because they called navigator.clipboard directly. When neither the Clipboard API nor Electron's IPC is there, copy with a hidden textarea and execCommand('copy') inside the same click, then restore the selection and focus. The textarea goes inside a focused dialog or menu, which may be the root of its focus trap (Safari does not focus a clicked button), else beside the focused control, so the trap keeps it in scope. The bridge's writeClipboard and the direct callers use it; Kanban reaches writeClipboardText through the plugin SDK. Electron keeps its IPC-first order, and secure contexts take the same path as before. Refs #141864, #93508 (cherry picked from commit 46f28cdf5f8f6753d615198f9f92ce1b375e588d)From a live phone-lane run against the curated broker set (consented subject): - add verified reverse-phone url_patterns for 8 brokers (fastpeoplesearch, usphonebook, spokeo, intelius, searchpeoplefree, peoplefinders, advancedbackgroundchecks, cyberbackgroundchecks); fastpeoplesearch's stale 'LIKELY /{digits} UNCONFIRMED' quirk replaced with the confirmed shape (/{areacode}-{prefix}-{line} at the site root), and its June full-block note updated: the reverse-phone path renders via plain web_extract, only /removal is still assumed gated - mark radaris, rehold, and peekyou defunct: their domains were transferred to Atlas Data Privacy Corp (NJ Daniel's Law cases; final judgment 2026-08-27 for Radaris + Rehold, settlement for PeekYou) - scan/opt-out moot, record not_found - nuwber + clustrmaps: quirk that their domains resolve to a range the web_extract SSRF guard refuses; take those vectors to the browser rung (methods.md too) All findings verified live 2026-10-26. 34/34 unbroker + 1471 authoring tests green.The opt-out form now carries a reCAPTCHA v2 checkbox ('Please complete the captcha to submit your opt out request') - verified live 2026-10-26, the record previously said captcha-free. Set optout.requires.captcha=true (tier becomes T2 without a captcha-clearing browser) and recorded the fallback: if the checkbox will not clear, use the privacy@spokeo.com deletion email naming the listing URL(s).Every finding from the review, addressed at both layers: Blocking: - A nameless subject can no longer produce or send a legal email that says 'None'. _email_request appends the name-as-relative identifier only when a name exists (generic drafts get an explicit [your full name] placeholder); _require_email_identity gates render-email/send-email (legal kinds need the name, sends always do, contact email required); next routes the email lane and indirect_email_send to the digest via the shared _email_identity_ready predicate instead of queueing an autonomous send. - dnc_register now surfaces only for phone-led runs (focus_phone), always with confirm_first: true (intake consent covers broker removal, not a new registry disclosure), and dnc --declined records dnc_declined_at so the action can be declined without recording a filing that never happened. registry.dnc_eligible is the one eligibility source (NANP-checked, deduped). Should fix: - Gate findings reach the agent: the live-verified ENTRY GATE quirks moved to position 0 in their records, spokeo's captcha quirk also lands in its playbook, and synthesize_steps keeps quirks[:4] and discloses any overflow. - DEFUNCT is structured: one 'defunct': {since, evidence} field per dead broker plus a single prose line. plan/batch_plan/runnable_for/fanout exclude them, next skips their re-checks and polls, open cases get a one-time close-out digest entry, and the ledger allows not_found from the in-flight states for exactly this (dead-broker close-out, comment says so). - --focus-phone counts as an identifier, dedupes on normalized digits, and the --json path applies it too. Also: _mark_one_shot shared by drop/dnc, _indirect_case extracted, vcount dead defaults dropped, captcha-gated opt-outs record human_task_queued instead of blocked (breaks the re-scan/re-queue loop), docs counts fixed (14 of the live records; three phone-specific notes; unsearchable in the batch row), tests tightened (SystemExit asserts on the message, exact skipped_unsearchable) and 8 new tests added covering the reviewer's probes. 42/42 unbroker tests; 1568 across the nine contract files; health ratchet 0 blocking 0 advisory; ruff clean. intelius + the defunct records rebuilt on main formatting - broker diff is content-only.dnc_register always carries confirm_first: true, because intake consent covers broker removal, not a new registry signup. But the full-autonomy instructions told the agent the opposite in three places: SKILL.md's "Never pause before individual submissions" rule and its `next` loop step ("honor confirm_first in assisted mode"), and the `note` that `next` returns in full mode ("do not pause"). The consent fix only held if the agent ignored its own instructions. All three (plus the matching methods.md line and the autopilot policy docstring) now say: in full autonomy, do not pause, except on an action with confirm_first=true, which waits for the operator in every mode. Website skill page regenerated with website/scripts/generate-skill-docs.py. Test: a phone-led full-autonomy subject gets dnc_register with confirm_first=True and a `note` carrying the exception. Red with autopilot.py at the previous commit.Three small correctness fixes on the review-round commit: - `pdd.py dnc` printed the literal text "dnc {args.subject} --declined": the second half of the split string had no f prefix, so an agent copying the step ran a command with a placeholder in it. - A defunct broker parked as human_task_queued got two digest entries: the new close-out (record not_found) and step 7's "parked manual step", which sent the operator to a dead domain. Step 7 now skips defunct ids, as steps 2 and 3 already do. - SKILL.md still said an unclearable captcha is recorded `blocked`; the optout_web_form note in autopilot.py says human_task_queued (a blocked case re-scans, re-finds the listing and re-queues the opt-out). SKILL.md now matches. Website page regenerated. Tests: the dnc steps carry `dnc <sid> --declined`; a defunct parked case yields exactly one digest entry. Both red with pdd.py/autopilot.py at the previous commit.The captcha wording added in the previous commit (SKILL.md gotcha, methods.md) said a human_task_queued case reaches the operator "once in the digest", copying the optout_web_form note's "exactly once". next_actions step 7 re-emits every human_task_queued case on each `next` until it moves, so the true claim is: it parks as one standing digest task instead of looping through re-scan and re-queue. All three now say that. Also: the _DEFUNCT_OPEN_STATES comment names all three exclusions, and the two new close-out assertions match the close-out entry itself ("broker is defunct"), not just the broker id. Website page regenerated.Twenty loops across 17 files hand-wrote the same thing: deadline = time.monotonic() + timeout while time.monotonic() < deadline: if condition(): return ... time.sleep(interval) with small drifts between copies: some re-checked once after the loop, some did not, one slept before its first check, and update_cmd_windows kept a private _poll_until. A copy without the final check can miss a condition that becomes true during the last sleep. agent/deadline.py (already the home of clamp_timeout / run_bounded_*) gains poll_until(predicate, timeout, interval): checks at once, cuts the last sleep to the time left, checks again at the deadline, and returns the predicate's last value so callers that wait for a pid / row / identity get it back directly. timeout <= 0 is one check. Converted: outbound_webhooks.flush, cron delivery wait and jobs flock, host_attach channel wait, browser profile unlock, data-cleanup drain, gateway dbus/bus waits, launchd fresh-pid and supervision waits, supervised restart, gateway_migrate live-gateway wait, Windows drain/absent waits, kanban init lock, local-runtime boot wait, update_cmd_windows (private helper deleted), bot desktop start/stop waits, TUI browser connect. Left as is: loops whose body does more than check (progress callbacks, interrupt handling, two distinct exits) and one site whose blind except the health gate would reject once moved. Behaviour changes, all small: - loops that stopped at the deadline without re-checking now make one more check there; - loops that already re-checked after a full last sleep now cut that sleep to the time left, so the check lands at the deadline (up to one interval sooner); - a timeout <= 0 now means one check (some loops made none); - the local-runtime boot wait checks before its first sleep instead of after it; - _wait_for_user_dbus_socket also exports the user systemd env when the socket appears on that final check (before, that path returned True without it). update_cmd_windows is imported by update_cmd on every OS, so the fresh-source-install acceptance fixture (which keeps agent/ out of the update path) now copies the stdlib-only agent/deadline.py too. Tests: 4 pins for poll_until (two red against the classic no-final-check variant), plus the existing suites for every touched module.test_fallback_chain_hot_reload drives server._prepare_turn_input directly on the pytest main thread. Admission binds tools.approval_context._approval_session_key to the session's key ("session-95066") and the test stops it with _StopAfterSync, so the production unwind (_release_turn_scopes in run_body's finally) never ran and the contextvar stayed bound for the rest of the process. human_input_hooks._identity reads that contextvar first, so test_tui_sudo_request_fires_human_input_hooks, run later in the same process, got session_key "session-95066" instead of its own conversation key. Repro (single process, deterministic): pytest tests/tui_gateway/test_fallback_chain_hot_reload.py tests/tui_gateway/test_human_input_hooks_tui.py -> 1 failed on main; each file alone passes. Fix: run the real _release_turn_scopes in a finally and assert the key is unbound afterwards (a sabotaged release fails the polluter itself). Production is not affected: a TUI/Desktop turn runs on a fresh spawn_context_thread with a copied context and run_body's finally always calls _release_turn_scopes, so the binding cannot outlive the turn or reach another session or profile in a multiplexed backend.Same cold-load mechanism as the previous commit, found by a full loaded `--project ui` run on origin/main (12 CPU spinners on top of load ~120): - use-gateway-request: the first secondary reopen runs production's lazy `await import('@/store/session-states')` (store/gateway.ts), so the first reconnect case paid the 3k-line store's cold transform and timed out; the zombie body then nulled `result.current` for every later case. - branch-owner-routing.integration: both cases import `@/store/gateway` cold after resetModules, and the second one also reaches the lazy session-states import. - floating-mode-memory: imports registry, interface-mode and floating-panes cold in the first case; its late render leaked a second textbox into the next case. - layout-flipped-sides and browser-popout-handoff: same cold imports, previously papered over with `{ timeout: 30_000 }` and `vi.setConfig({ testTimeout: 90000 })`. Under load base still timed out through both (first test median 18 s / 42 s, max 30 s / 90 s). Both overrides are gone; the first tests now take 1-5 s under the same load. The warm loads are static side-effect imports, except in branch-owner-routing, where the `@/hermes` mock reads a class declared in the file: a hoisted import would reach it in the TDZ, so that file warms with top-level await after the mock. Loaded A/B, 20 runs each, base and head side by side under the same spinners: base red 1/20, 4/20, 14/20, 20/20, 3/20; head 0/20 for each file.The hub index resolves a ClawHub hit to its bare slug, and the lock records @owner/slug. HostInstaller.install_skill looked the lock up by the id it was given, so a finished install raised its last printed line ("Files: skill-card.md, SKILL.md, _meta.json") and a second one raised "Use --force to reinstall." instead of reporting already installed. Its search rows marked the same skill installed: false. Both now go through installed_entry, which matches a hit's exact id and, for a ClawHub hit, its clawhub/<slug> card id. The runner passes the source that resolved the row, so another source's bare id still matches exactly. Co-authored-by: yoniebans <jonny@nousresearch.com>Review H4 F1: text containment could not tell "the compressor added text" from "the row lost text after the copy". An in-place edit that SHORTENED a tail row ("fix bug in parser" -> "fix bug", or "yes, but first delete the prod database" -> "yes") left the new text inside the older held copy, so the row was weakly claimed and the model stayed on the pre-edit text; the edit survived only in display. The second claim pass now requires a copy of the row's current version before containment counts: - a held dict carrying the stored-row digest (DB_ROW_SNAPSHOT) must match transcript_row_snapshot of the row (tail rows are read with every column for this); a mismatch means a rewrite after the copy; - without a digest only a _row_id carry qualifies (row-addressed loaders, e.g. a Desktop resume, omit the digest by design); a bare uid copy may predate any rewrite, so it is cloned; - the row's text must sit in the copy as whole lines (blanks beside it allowed): the compressor only adds whole blocks (header, markers, summary) around a carry. Image-placeholder swaps stay claimed without a digest: that check already compares everything else. Anything else is cloned: a duplicate, never a stale model view.A window reload (or an app restart) mid-reply resumes the chat cold. The resume restores the turn's live row, from the in-flight journal or from the backend's in-flight projection (assistant-stream-<runtime id>), and points the stream at it. A resume snapshot that names no pending clarify then settles clarify cards, and that spread set the stream target to `clearedClarifyProjection.streamId ?? state.streamId`: with no card open it fell back to the pre-resume state's target, which a fresh window does not have. The next delta therefore opened a second bubble beside the restored row, the reply split in two ("A4" | "answer across a reload") on every reload, and message.complete wrote the whole text into the new bubble while the restored one still showed the first chunk, so the reply drew twice until the post-turn refresh replaced both. Fires on origin/main and the PR head: core e2e reload step (reattach-running-turn.spec.ts) splits in every run and fails its transient-duplicate sampler in 1-3 of 10. The target is now chosen by row identity (resume-reconcile.ts resumedStreamTarget): the first of the settled clarify row, the pending connection row, the pre-resume target and the recovered row that the resumed transcript holds, else the backend projection's live row. livePromptStreamId moves into the same sibling so use-session-actions/index.ts does not grow.A turn stopped during the answer after a tool round is stored as the narration row with its tool call, the tool result, then the interrupted partial (display_metadata {"interrupted": true}). Reading the transcript folds that partial into the tool bubble, and the fold branch kept its parts but not its interrupted flag. So once a refresh replaced the live stopped turn with the stored copy (the post-turn / window-return refresh, ~13 s after Stop in the l1h stopped spec), the "Response stopped" tag vanished, although the stored row says the reply was stopped. A stopped reply without a tool round was not affected (live probe: stored row carries the flag and the tag survives the replacement). The fold now carries the absorbed row's interrupted flag onto the bubble.A turn stopped before any reply text ends on stored rows no page paints: the tool result folds into its call and the empty closing assistant row is skipped (toChatMessages of rows 133-136 paints only 133 and 134). The receipt drop requires the page to reach the turn's last row, so it never fired: the window's live copy ("Interrupted", "Response stopped") and the page's stored copy ("Ran sleep 12") both drew. When the page holds a stopped turn's first row but not its last, the window's live copy, which carries the receipt the interrupted completion bound, is the whole turn; the page's rows inside that receipt are the partial copy and go. Identity only (receipt row ids, the turn's prompt), and the live copy keeps its stopped mark.In a chat longer than the newest page, the refresh that settles a steered turn drops the window's copy of the turn (the page holds it whole) but keeps its local notices (/btw, /bg echoes and answers, fallback-switch). With no stored row of their turn left in the window, the notices travelled with the next window row that has a stored id. When the background process the turn started had already run its own completion turn, that row was the auto turn's reply, whose prompt ("Background Process Finished") only the page holds, so the notices drew inside the next turn. The receipt drop now records, for each notice inside a dropped turn, the stored id of the next dropped row of that turn (the steer prompt, or the final answer). The merge puts the notice in front of the page's copy of that row, where it was drawn live. A notice whose anchor the page does not carry falls back to the end of the window instead of being lost. Found by independent review J2 (N1) on this PR.A re-attach retry ran the full resumeSession entry: clearNotifications() dismissed every non-persistent toast in the app, and each failure re-showed "Resume failed" and counted another error_toast friction event (up to five per incident). Retries now pass { reattach: true }: they leave the app's toasts alone, the first failure's toast carries a stable id and is the only one shown and counted, and use-resume-reattach dismisses it once a runtime binds. Tests: the arming is driven through the real resumeSession (RPC fails, REST paints -> re-attach latch set, failure latch clear, retry quiet), and the hook test now fires the timer after the selection moved / a runtime bound. Each kills its mutation (arming no-op, guard deleted, toast wipe, re-toast).`hermes desktop --install-apparmor-profile` escaped only `\` and `"` in the attachment path. apparmor.d(5) "Globbing (AARE)" keeps `? * [ ] { } ^` as pattern syntax inside a quoted path: `{x}` fails the parse, and `*`, `?` or `[` widen the grant to other binaries. Because `sudo tee` ran before `apparmor_parser -r`, a broken profile stayed in /etc/apparmor.d and failed at every boot. - Refuse install paths containing those characters (plus `"`, `\` and non-printables) with a message naming them, instead of trying to escape them. - Compile the generated text as the user (`apparmor_parser --skip-kernel-load --skip-cache`, verified on apparmor 4.0.1 / Ubuntu 24.04 and 5.0.2) from a temp file under the Hermes scratch dir before any sudo write. - If loading fails after the write, `sudo rm -f` the file and say so. - Before sudo, say in one sentence that the profile grants user namespaces to a path the user can replace, which the setuid helper does not. - The launcher's remedies now list the setuid chrome-sandbox helper first, quoted for copy-paste; desktop.md states the trade-off.Review finding "Messages reach the wrong bot": _base_url rewrote every <peer>/hermes target to /p/default, so a peer that still serves a real pre-reservation 'hermes' profile (get_profile_dir keeps those resolving, and the server keeps such a profile ahead of the alias) had its DMs, runs, status and stop requests delivered to its default profile instead. The client now addresses /p/hermes first and uses /p/default only when the peer itself answers that it has no such profile: the 404 its /p/<profile>/ prefix middleware returns before any handler runs ({"error": "Unknown or unconfigured profile"}). A handler 404, a 5xx or a transport failure is no verdict and never reroutes. Peers without the server-side alias (older hosts) still reach their default profile.Chromium forks its zygote into the sandbox before main.ts runs, so the Linux boot-abort ladder's sticky fallback and a config.yaml `desktop.electron_flags: [--no-sandbox]` (app opened without `hermes desktop`) only reached renderer command lines. Those renderers are still forked from the sandboxed zygote, believe they are unsandboxed, open /dev/shm directly from the zygote's empty chroot and die with ESRCH ("Try 'sudo chmod 1777 /dev/shm'"): a renderer crash loop and a blank window, or an ESRCH loop in $TMPDIR under --disable-dev-shm-usage. The lock-owning instance now restarts once with the flag on argv when --no-sandbox was only requested at runtime (AppImage relaunches the image itself). Launches that start unsandboxed (argv or ELECTRON_DISABLE_SANDBOX at launch) and sandboxed launches are unchanged. The config.yaml switch loop moves into renderer-heap-flags.ts to keep main.ts from growing.`_submit_api_worker` ran every API-server agent turn (`/v1/chat/completions`, `/v1/responses` without an Idempotency-Key, `/v1/runs`, `/api/sessions/{id}/chat`) with `loop.run_in_executor(None, ...)`, and the cron fire endpoint ran whole cron turns with `asyncio.to_thread`. Both hold a thread of the loop's default executor for the whole turn, and that pool (min(32, cpu_count + 4) threads) is also the one every `asyncio.to_thread` SessionDB read in the session handlers uses. Once live turns reached the pool size, every /api/sessions read queued behind a running turn, and the real turn cap became the pool size instead of `gateway.api_server.max_concurrent_runs`. They now run on their own `_UnboundedThreadExecutor`, the same executor class the messaging and Desktop turns use (`GatewayRunner._get_executor`). Concurrency stays bounded where it is refused: `max_concurrent_runs` (429 past the cap). The cron fire keeps `to_thread`'s contextvars copy. The worker-lifetime count and its shutdown close gate are unchanged. Fixes #144362`ResponseStore.put` serialises the whole chained `conversation_history` to JSON, then INSERTs, counts, runs the LRU eviction select/delete, settles identities against state.db and commits, all on the gateway's event loop (`persist_snapshot` and the non-streaming `/v1/responses` path called it directly). With `previous_response_id` chaining the history is the whole thread, so on long sessions each save blocked every other request on that loop, the Desktop WebSocket included, and one box was killed by the liveness watchdog mid-eviction. The reads on the same paths (`get` of a chained response, replay lookups, output-index allocation, GET/DELETE /v1/responses/{id}) did the same with smaller rows. The store now owns an RLock that every public method holds, so its one sqlite3 connection is safe from worker threads, and loop handlers reach it only through `store.off_loop(fn, ...)`, which runs `fn` on a worker thread with the lock held. Sequences that used to run without an await between them (settled check + body + conversation pointer; terminal snapshot + replay record + read-back; non-streaming body + pointer + replay record) stay one locked unit, so no other observer interleaves where it could not before. Every write is awaited before the handler answers, and `response.created` is now sent after the in-progress snapshot is stored, so a response id a client receives is readable by its very next request. The `/v1/runs` chain lookup moved to a worker as well. Fixes #142641 (item 1; the compressor lockout in item 2 is a different mechanism)The streaming terminal commit (_commit_terminal) called _persist_snapshot unconditionally; only persist_snapshot checked self.store. A streamed {"store": false, "conversation": ...} request was readable by GET and advanced the named conversation. The keyed replay record still lands: it is idempotency state, not response retention. Review: unsupportedpastels deep review on #144911, finding 2.1.Electron's Linux relauncher starts the new process with PR_SET_NO_NEW_PRIVS. Every descendant inherits it, so after any self-relaunch (GPU fallback, sandbox fallback, bundle-swap or Settings restart) the backend and the agent's terminal run with NoNewPrivs=1: sudo refuses ("no new privileges"), and pkexec and other setuid helpers stop working. The same flag stops a genuine FUSE AppImage from mounting itself again, so it never comes back. main.ts now hands every relaunch site and the final exit a relauncher from createAppRelauncher(app). On Linux, relaunch() only records the args, and exit() starts the target as an ordinary detached child right before app.exit(). By then the backend is down and the single-instance lock has been released, so the child takes the lock instead of quitting as a second instance. The target is the AppImage file for a mounted image (relaunchStrategy), otherwise process.execPath. The child gets the launcher's short socket TMPDIR back, the same way the --no-sandbox relaunch does. macOS and Windows keep app.relaunch.Native Desktop reads <root>/desktop-plugins from its own disk. A Webapp tab's only disk is the server, and /api/fs is the wrong reader for it: it follows the profile's SSH workspace and truncates at the 512 KiB preview cap. /api/desktop-plugins/{root,list,read} answer exactly the loader's reads: the app-level root, its plugin folders, and each folder's plugin.js and .hermes-package.json, resolved through symlinks to inside the root, whole up to Electron's 16 MiB plugin-source cap. Webapp surface only.The preload getVersion bridge dropped the {connectionId, profile} scope the renderer passes (lost in mergeb3bfc3afe5), so the version label always reported the primary gateway. Forward the scope, re-fetch on any connection/profile scope change with a stale-reply guard, and scope the native About panel to the focused window. Fixes #139333After {...process.env} the proxy lookups were case-sensitive, so a Windows `Https_Proxy` was not seen as explicit (the system proxy was injected over it) and a `No_Proxy` lost its entries. Explicit-proxy detection and NO_PROXY entries now match any letter case (urllib lowercases env names on every OS), and on Windows a case-variant NO_PROXY is saved for the relaunch restore instead of being marked Desktop-added and deleted. (Deep review on #144894, minor.)Nine stores write their value the moment their module evaluates (keybinds '{}', the sound and view switches, profile colours: 11 keys). That happens before main.tsx starts the sync, so the provisional mark never saw them; on a fresh browser's reload they read as its own data, its first-contact import put them in the store, and native Desktop's seed (adds only keys the store lacks) then lost the user's custom keybinds and switches. The provisional mark now lives at the storage choke point, so it covers writes made before the sync listens. persistStore() writes a store's load-time value provisionally and every later change as the user's. A provisional write that changes nothing is skipped, and one that rewrites the user's own value (a heal, a migration from a real value) keeps it theirs.Every main-built Webapp tab holds about a dozen defaults that its stores wrote at load with no provisional mark (keybinds "{}", hapticsMuted false, the translucency book...). After the upgrade such a tab often reaches the machine first: its first-contact seed sent those defaults as data, and native Desktop's custom keybinds and haptics were then rejected by the add-only seed and overwritten locally. persistStore now takes the store's default: a load value equal to it is provisional even when it is already stored, so it never enters the shared store by seed or write. Every allowlisted store that writes at load passes its default (profileOrder now goes through persistStore too), and the translucency book marks a stored default the same way. Re-review N1 on #145536.1. Grouped edits: new write_config_keys(path, {key: value}) sends related keys as ONE backend write (one CAS PATCH in remote mode), so a lock on any key refuses all of them. persist_model_selection, the TUI /focus setter and the thinking_mode setter use it; the TUI gets _write_config_keys (one raw round-trip save with the file backend). File-mode writes are unchanged (key by key, comment-preserving). 2. Migration reads: _postprocess has no single flight any more. A reader or writer that finds the published doc unmigrated runs its own private pass; the first to finish publishes (generation-fenced, st.postprocessed set only on publish). read_user_layer / read_user_doc_readonly snapshot only completed output (_snapshot re-completes when a poll lands in between), and write_changes prepares only against completed output. Waiting on the other pass instead could deadlock on _CONFIG_LOCK; private recursion is unchanged. 3. Docs: developer-guide/config-backend.md says the remote backend ignores the managed config.yaml while the managed .env still applies.andrexibiza P1-B. ConfigBackendUnavailable subclasses SystemExit on purpose: a fail-open reader must never serve defaults instead of the selected backend's config, and boot fails closed. That contract stays. But long-lived consumers caught only Exception, so a plane blip after boot killed the thread (or, inside an asyncio task, stopped the gateway's event loop). The six named sites, each with its own outcome: - gateway/readiness._probe_config: the existence check moved inside the guard; the outage reports {"status": "degraded", "detail": "config backend unavailable"}. - cron/scheduler._load_cron_job_config: still refuses defaults (its except Exception lets the outage through); run_job, the fire's owner, now catches it and returns that fire's failure result; _run_one_job_body records it like an Exception (mark_job_run(False), finish_execution, crash notice) instead of re-raising into the ticker. - cron/jobs._main_model_pin (moved to cron/jobs_model_pin.py to keep jobs.py under its cap): raises ValueError, so pinned=True create/update is refused as invalid input instead of silently dropping the lock or exiting. - tui_gateway/server._profile_configured_cwd: None (logged); the cwd is a hint. - tui_gateway/server._load_cfg_raw: the designed FailedConfigRead; _refuse_failed_read now refuses to save it with "could not be read", not "formatting error". - tui_gateway/prompt_attachments._profile_attachments_storage: "" (logged): attachments stage in the profile home the sandbox mounts and the vision allowlist cover. The owners above every other config read on a long-lived path (sweep in the PR body): - TUI request owner already answers 4064 (_handle_admitted_request); deferred agent-build and resume-hydration threads now report agent_error / the resume failure and release their waiters; compute_host turn and frame workers answer turn.error / their error frame. - gateway platform turn (_process_message_background) notifies the user and ends the turn instead of re-raising this SystemExit into the loop; api_server gets an outermost config_unavailable_middleware (503) since entering a profile scope reads config; the served- profile reconcile watcher and _serve_profile_runtime park the profile for the next rescan. Tests: tests/plugins/config_backends/test_remote_unavailable_consumers.py (13), each raising the real ConfigBackendUnavailable from the seam the consumer calls; every production file's fix was reverted alone and its test(s) went red.1. A document save is diffed against the base the saving context READ (per-context _READ_BASE recorded by read_user_layer / read_user_doc_readonly / version and after the context's own acknowledged write), not against whatever a poll installed between the read and the save. save_config's fail-closed guard read keeps the caller's read base. A poll before save no longer turns its changes into our unsets/sets with the current expectedVersion. 2. persist_model_selection (remote): a key the switch clears that the effective model block still holds is stored as null ("no value", contract 5.4), not unset at the profile level, so an upper-level old-endpoint api_key/key_env/api_key_env/context_length stops applying at the new endpoint. New config_backend.write_changes carries explicit nulls. 3. profiles.configure: skills / toolsets / MCP sections report applied only when the section reads back as written; a bulk save that dropped a locked key is no longer acknowledged. 4. _dotenv_assignments decodes UTF-16 (BOM) and strips NULs as the runtime sanitizer does, so stage2 / first-read selection agrees with the backend the runtime boots. Regressions (each fails with the production change reverted): test_poll_between_read_and_save_sends_only_the_callers_edit, test_model_switch_masks_inherited_endpoint_credentials, test_profile_editor_reports_a_locked_skills_edit_as_not_applied, test_stage2_decides_like_the_runtime_for_utf16_dotenv[utf16-bom|utf16-no-bom].1. _resolve_assignment_credentials (POST /api/model/set) and the custom-endpoint make_default / activate paths replaced an inherited model.api_key with the target provider's key_env by popping it. Under Remote Config the block is the effective one, so the pop became a profile unset that cannot reach the upper level, and the inherited inline key (which outranks key_env at runtime) stayed active at the new endpoint. drop_model_key stores null for a key the effective block holds (file backend: plain removal), the same mask apply_model_selection uses. 2. remote_model_selection_guard is a preflight; a lock published after it was still silently omitted from the document save, landing a half route with ok=true. New config_backend.refusing_locked_omissions("model") scope: the remote backend's document intent raises ConfigLockedError (HTTP 403, nothing sent) when a lock it sees at save time covers a changed key under the scoped prefix. Wrapped around every whole-document model assignment save (dashboard main slot, profile builder, custom endpoint make_default / activate). The CAS retry already refuses a locked key through _patch_body's write_check. Regressions: test_dashboard_model_switch_keeps_mask_when_target_provider_carries_key_env (fails with the pop restored), test_dashboard_model_switch_refuses_when_a_lock_lands_after_the_guard[poll] (fails with the scoped refusal disabled), [cas-retry] (coverage of the existing retry refusal).Layered on the salvaged budget commit: the limits are read with load_user_config_effective (the gateway-runtime loader: user file + managed overlay + ${VAR} expansion) inside the room owner's scope, and a malformed value is logged and falls back to today's 3 rounds / 10 replies instead of failing the service construction, which would take every room on that store offline. Docs say so. Refs #144648 (WA-21) Refs #145184The card registered its settle observer only after `channel.send` returned. If the request ended during the send (wait timed out, /stop, session closed, withdrawn), the registration found no entry and the card retired with the default reason "resolved". The card then said "This approval has already been resolved" for a command that never ran. Build the view first and register the observer before sending. A reason that arrives while the card is still in flight is held on the view (`pending_retire`) and applied as soon as the card exists. Only a request that was already gone before the card was built still has an unknown outcome. That card now says "⌛ This approval is no longer pending (it timed out or was answered elsewhere)", a new outcome-neutral string, translated in every locale. It no longer claims either "resolved" or "not run". Building the view outside `_build` also removes the `view_holder` list that only existed to get the view back out. The view never depended on the channel. The test now covers the three orderings (after, during, before the send) and asserts the footer. Two of them fail on the previous commit.Item 1 of the review is only partly fixed by the stale-click commit before this one. Clearing `_backend_settled` lets the settle observer retire the card, but the click still edited the same card with "Approval expired — command was not run" through its own interaction response. Nothing serializes that edit with the observer's. If the click's edit lands last, it hides the real outcome and reattaches the disabled controls. Discord serializes the interaction payload before awaiting the response, so a later embed change can't repair it. When a click on a card with a request id resolves nothing, that request has already settled through another path. Since the earlier commits, its observer is always registered (it is registered before the send, and a request gone before that already retires the card). So the click now only answers the clicker privately ("no longer pending") and leaves the card edit to the observer. A click whose lookup raises recorded nothing, and its request may still be pending. It used to stamp the card "expired — not run" and disable it for good, and that edit could race the observer too. Now it re-opens the card for a retry, tells only the clicker ("Couldn't record your choice. Click again, or answer with /approve or /deny." — a new string in every locale), and leaves the final edit to the observer. A card without a request id has no observer, so its no-op click keeps the old "expired" edit. Tests: - The stale-click regression now asserts that the click never edits the card. It fails on the previous commit. - A new test drives a failed lookup on a really pending request with its observer attached: the card stays live, and a later text answer retires it with the real outcome. - A third test pins the expired edit on a card with no request id.Follow-up to the two salvaged redial changes. Review against a real owner (gateway.run with a faked model) showed the premise holds: after a dropped socket, a new connection's prompt.receipt returns the live receipt, and a repeated kanban.run for the same attempt (one request id per card run) returns the same session and admission with one turn. Three gaps remained: - Dial failures during recovery still ended the worker, and the owner then killed the turn: a refused connection ("Gateway connection failed"), a bootstrap identity change or timeout during a restart, and a raw OSError from the ticket fetch. Now every transport error (GatewayClientError that is not an owner refusal, ConnectionClosed, OSError, TimeoutError) retries. - A socket lost while waiting for kanban.run's answer still exited 1, although the owner may have admitted the claim. Without an admission id the worker now asks kanban.run again. - A runtime_draining refusal on the first connection after kanban.run answered re-called kanban.run through a stale local; the admission now lives in one dict the next connection reads, and runtime_retired (a stale connection to a retired authority) joins runtime_draining as a retryable owner answer. The four overlapping pieces of state (admission, deadline, redial_deadline, progress) collapse into that dict plus the monotonic time of the owner's last answer: the worker gives up once the owner has been silent for OWNER_WAIT_S, and says so ("Kanban owner unreachable for 180s") instead of claiming the owner retains the attempt (the owner ends it once this worker exits). Retries use jittered exponential backoff (agent.retry_utils.jittered_backoff, 1 s to 8 s plus jitter) instead of a fixed 1 s, so a gateway restart does not get every worker's redial in the same second. _submit_claim splits into _await_receipt and _exit_code so an "unknown" verdict is raised after the connection loop and is never retried as a transport loss. Tests are rewritten against a clock that moves only when the client sleeps or a scripted step advances it (the salvaged ones depended on the exact number of monotonic() calls). 9 of 10 cases fail on main; 4 fail on the salvaged head (the dial failures, the lost kanban.run answer, and the honest give-up message). The owner-recovery probe (tests/gateway/fixtures/kanban_recovery_probe.py) now waits for the worker to exit before stopping its owner. A worker still polling when the owner stops now redials instead of exiting, and that redial starts a replacement owner, as a client's first dial does. Under the probe's strace -f, that replacement kept the traced worker tree alive past the probe's deadline. Not changed: a graceful gateway restart still interrupts the turn (#144397); the worker now reads that interrupted verdict instead of failing the redial.Final-head review folds: - _protocol_violation_streak's legacy text match now reads only the error head, before WORKER_OUTPUT_SEP (" Worker's last output: ", now one named constant shared by the writer, the streak and apply_attempt_note). Every crash quotes the worker's output, and a retry worker is shown the previous attempt's protocol-violation error, so an ordinary crash that echoed the phrase lengthened the streak. That drops the worker_lost-only exception added earlier. - website/docs kanban.md said resolving an unknown admission "does not authorize another Kanban attempt". On this branch an unbound run whose owner settled the admission is released once its recorded pids are dead (or, with none recorded, once the claim expires): booked crashed, or reclaimed if the claim had lapsed, so the card can be retried. The paragraph and owner_reclaim_paused's comment now say so. - owner_attempt_provably_over's docstring drops the incident count and its outdated premise (only killed owners), and the lapsed-claim comment matches the code. - record_worker_loss reads policy.kanban_json directly: its policy is always a LocalSessionPolicy. Test: a crash whose output quotes "protocol violation" leaves the streak at 0 (fails with the whole-error match).Mechanism: the probe's daemon runs in a fresh HERMES_HOME with no models.dev disk cache. Its first session.create builds the session inventory, whose tool-availability pass runs check_vision_requirements -> routed_model_rejects_vision_tool_messages -> is_routing_aggregator('custom') -> get_provider -> fetch_models_dev, a foreground requests.get to https://models.dev/api.json with timeout (5, 10) (per read, so a trickling multi-MB body has no total bound). On a runner with a slow network that one fetch outlasts the probe's 20 s rpc() deadline: CI job 122625883308 failed test_admitted_images_reach_managed_worker [image-fixture-normal] and [image-fixture-safe] with TimeoutError inside rpc(ws, 'session.create'). Repro: a sitecustomize.py in a scratch tree that makes getaddrinfo('models.dev') stall 21 s fails all 6 cases on base with the CI shape (session.create TimeoutError at 20.0 s); a 6 s stall makes session.create take 6.5 s instead of 0.7 s. Fix: the test's config pins models_dev.url to a refused loopback port (http://127.0.0.1:9/api.json, the offline pattern tests/e2e, the ACP conftest and test_session_busy_controls already use), so the fetch fails at once and the probe stays on loopback. Under the same 21 s injection all 6 cases pass with session.create at ~0.6 s and zero non-loopback connects. The pin is per test, not in the shared daemon() fixture: rewriting configs there broke test_session_authoring_commands, which asserts that config.yaml is byte-for-byte unchanged.test_gui_releases_lock_after_build_failure (8th in file order, the one CI's SIGKILL'd runs stopped at) stubbed only build_prepared_desktop, so cmd_gui ran the real source_build_env(explicit=True) -> pm.ensure("npm"): it provisioned the PM runtime (uv sync from PyPI) and Node into the test home, then ran real node under the update custody launcher. ~13 s with a good network; a stalled runner network hangs the file to the 600 s cap. Repro: a cold tool store plus a proxy that accepts and never answers (https_proxy=127.0.0.1:<blackhole>): base is killed at 90 s, fixed passes in 0.3 s. The test now stubs both build steps and asserts the failure lands after dependency preparation; an autouse fixture makes any other test in the file that reaches either step fail at once instead of hanging. The test's own child waits (subprocess.run, the holder's readline) are bounded at 60 s.fetch_models_dev downloads models.dev/api.json with timeout=(5, 10). That bounds each socket operation, so a body that trickles in slower than 10 s per read (or a DNS lookup that hangs) has no total bound. With no disk cache, the fetch runs in the foreground of whatever asked for the registry; in a fresh HERMES_HOME that is the first session.create (tool availability -> vision check -> provider lookup), which blocked for as long as the download took. Run the exchange under agent.deadline.run_bounded_sync with a 15 s cap. On expiry the fetch raises DeadlineExpired, which the existing failure path already handles: arm the 5-minute backoff and serve the stale disk cache, or {} when there is none.test_inventory.py::test_include_unconfigured_appends_canonical_skeletons passes alone and fails when it runs in one process after test_models_dev_provider_capabilities.py (with any earlier file that already ran provider discovery, e.g. test_model_metadata.py): AssertionError: assert 'solstice' not in {...} _isolated_registry() swaps providers._REGISTRY for {} but leaves providers._PROVIDER_LIST_CACHE alone. register_provider() clears the cache, the next list_providers() refills it from the fixture registry, and the monkeypatch undo restores _REGISTRY but not the cache. Every later list_providers() then returns only the fixture profile, so unlisted_provider_names() never sees the hidden pre-release "solstice" profile and the picker lists it. The same fixture also left its fixture-provider/fixture-relay rows in hermes_cli.models.CANONICAL_PROVIDERS: a post-discovery registration mirrors into the picker catalog, and a string setattr on "hermes_cli.models..." imports that module inside the patch window, so the undo "restores" the patched copy. Fix at the polluter: patch _PROVIDER_LIST_CACHE and _SOURCES alongside _REGISTRY, and scope the catalog tables (both modules imported before any patch). test_account_usage_fetch.py's _register_profile had the same missing list-cache patch. Repro (test venv python, -p no:randomly, one process; run_tests.sh runs each file in its own process and cannot hold the order): test_model_metadata.py test_models_dev_provider_capabilities.py <victim> main: 1 failed, 120 passed fix: 121 passed 48 models_dev/model_metadata_http files + victim main: 1 failed, 774 passed fix: 775 passed`detectPluginComponents` (Electron) and `detect_components` (gateway) required `plugin.yaml` AND `__init__.py` before calling a folder an agent half. Catalog plugins that ship a `dashboard/plugin_api.py` backend and no `register(ctx)` — hermes-cron-bulletin, iowap-hermes-integration and other unified packages — were therefore reported as desktop-only: - the install dialog planned the desktop half alone, so `installAgentHalf` never ran: nothing landed on the backend (local or a remote gateway) and every `ctx.rest` call answered 404 while the pane sat in the sidebar; - a manifest-only package with no `desktop/plugin.js` was refused outright with "No agent or desktop plugin artifacts found", although `hermes plugins validate` admits it ("manifest-only plugin") and `hermes plugins install` installs it. A manifest is enough to mean "this folder is the agent-side package". Update both inspectors and the fixture they share, and cover the Electron case.The alias owner scan took the first profile in list order, so a bound home plugin declaring an alias (my-meta aliases=("meta",)) lost to the bundled meta-ai even though get_provider_profile picks the home plugin. Ask the registry for the owner instead; built-in and exact-name guards stay.backend.sessionTokenFile used to be rendered into a launcher that read the file, exported the token and execed hermes -- because hermes took the token only as an env var. That launcher is gone: hermes reads HERMES_DASHBOARD_SESSION_TOKEN_FILE itself, so the module now sets the path in the unit environment (systemd Environment=, launchd EnvironmentVariables) and builds the plain hermes argv whenever no wait is configured. Nothing renders the path through a shell any more, which also retires the escapeShellArg-versus-${XDG_RUNTIME_DIR} bug class for the backend -- and the same expansion fix lands in the desktop wrapper, which still reads the token itself. The option insists on an absolute path; a secret manager's placeholder would pass through unexpanded. Depends on the HERMES_DASHBOARD_SESSION_TOKEN_FILE backend support; against an older backend the service would mint a random token and the desktop would fail to attach with no nix-side error.Scraping vendors answer 200 and report the target page's own status inside the payload. Firecrawl (direct key, Nous Tool Gateway, keyless REST) put it in metadata.status_code / statusCode and Hermes never read it, so web_extract("https://httpbin.org/status/403") returned the stub error page with error: null: the agent could not tell blocked from blank, the keyless rescue never ran, and the error page was cached as content. One shared helper in plugins/web/_common.py (origin_http_error / with_origin_status / vendor_error_entry) now turns an origin status >= 400 into "HTTP <code> from origin[: reason]", keeping any error-page body as content. Applied to: - Firecrawl SDK/gateway scrape and the keyless REST scrape (both key spellings) - Parallel keyed + keyless (error was the bare "http_error"; the payload's http_status_code was dropped) - Exa keyed (URLs Exa could not fetch were silently missing from results; their reason lives in response.statuses) Tavily, Perplexity and Keenable already surface per-URL failures (Tavily's failed_results carry no status code). Salvages the Firecrawl statusCode check from #99549 (and #99556), which read only the camelCase key and only flagged empty content, so it missed the Gateway/SDK case where the 403 body is a non-empty stub page. Found on Hermes Cloud staging. Co-authored-by: liuhao1024 <sunsky.lau@gmail.com> Co-authored-by: Ahmett101 <ahmet.tunc@gmail.com>A newer native Desktop on an older SSH/URL/Cloud backend had every custom-source install refused at admission ("invalid params for plugins.manage: inspected_sha: ... out of sync"). Resend once without the field on exactly that refusal (the handler never ran), the same drop-and-resend session.create uses for fields older backends lack. Current backends still get inspected_sha and refuse a moved branch; Electron's own desktop-half install keeps its exact-commit check.rg --files exit 2 (EACCES on any walked path) was treated as an invocation failure in the file-discovery lane: stderr was discarded (2>/dev/null) and exit 2 was outside the accepted {0,1,124} set, so one unreadable directory anywhere under the search root produced the bare error "File search failed while running ripgrep." with no detail and no results. - _search_files_rg merges stderr, separates diagnostics from paths by the "rg: "/"grep: " prefix, and degrades a permission-only exit 2 to partial results plus a "Skipped N unreadable path(s)" warning, in both orders. The directory supplement (#54347) still runs on the degraded path. - Content search keeps the same partial-success contract for rg AND grep: the unreadable-path matcher accepts grep's "grep: <path>: Permission denied" as well as ripgrep's "(os error N)" spellings, so the grep fallback no longer hard-fails on a tree with an unreadable directory. - Bad regex/glob and other IO errors still fail closed, now with the diagnostic text; the modified-order failure no longer blames the ripgrep version the capability gate already accepted. - The bounded-find fallback treats find exit 1 with a payload as partial results plus a warning (empty payload still fails closed).- The native rg runner keeps draining stderr but retains at most 64 KiB (first line always kept); dropped lines are tallied and still counted in "Skipped N ... (+N more)". Unreadable-path dedupe uses a set (first-seen order kept). - find exit 1 with results now names the first non-permission diagnostic ("Some paths could not be read: ...") instead of reporting a permission problem; the rg/grep content lane does the same when a non-permission error sits beside permission skips. - The unreadable-bound test checks the exact skip count only on a run whose limit lets traversal finish; the bounded run asserts the budget and the warning's presence.A browser tab's /api/ws reaches a gateway through the dashboard mount (chat_ws.gateway_ws), not the native ticket path (gateway/run_api.py) that wraps the socket in owner_scope(authority). Legacy handlers the socket falls back to (billing.state, subscription.state, usage.bars, and the account reads they spawn on worker threads) bind no scope of their own, so on a multiplexed gateway every Webapp billing/usage read resolved the Nous Portal override with no profile secret scope: errors.log logged "HERMES_PORTAL_BASE_URL/NOUS_PORTAL_BASE_URL unreadable — no profile secret scope on a multiplexed call", the override was dropped, the stored non-production portal_base_url was then refused by the allowlist ("ignoring invalid portal_base_url ... using default") and the token resolution fell back to the production Portal. gateway_ws now binds owner_scope() of the authority it serves (the ticket's profile when the tab named one), exactly as the native ticket path does, so every handler on the socket (and every thread it copies its context into) reads the right profile's secrets. Found on Hermes Cloud staging.With the private-URL guard off (local backend, or browser.allow_private_urls on a cloud/CDP backend) the floor only checked Page.navigate/Target.createTarget, so Runtime.evaluate("fetch('http://169.254.169.254/...')"), Network.loadNetworkResource and Fetch.continueRequest reached the metadata endpoint that browser_exec refuses on every path. The guard-off branch now runs every _METHOD_PARAM_GUARDS probe (and the url param of any other method) with an is_always_blocked_url-only predicate.A Bedrock session that switches from a reasoning GPT (Converse) to Claude with /model, or falls back from one to the other, failed every Claude turn with `HTTP 400: messages.N.content.M: Invalid `data` in `redacted_thinking` block` and then failed over to GPT ("Fallback activated: <claude> -> <gpt> (bedrock)"). The only workaround was /new. #142139 filters reasoning by issuing model on the Converse path only: a turn stamped by a non-Claude model (CONVERSE_ISSUER_TYPE) replays no reasoning to Claude there. Claude on Bedrock runs on the AnthropicBedrock (anthropic_messages) path, whose converter copied every thinking/redacted_thinking block out of reasoning_details, GPT's sealed blob included. The classifier did not help either: the rejection names none of the signature words, so it landed as format_error (non-retryable, fallback) and never reached the existing thinking_signature strip-and-retry. Two layers: - Root: assistant_replay_carrier's reasoning_details carrier (_extract_preserved_thinking_blocks) yields nothing for a turn a non-Claude Converse model stamped, on every route and inside the in-flight tool loop. The predicate is #142139's, shared as bedrock_adapter.reasoning_minted_outside_claude (Converse's _reasoning_filter now calls the same helper). Claude's own turns (unstamped on AnthropicBedrock, or stamped by a Claude model on Converse) replay as before. Unstamped turns keep replaying, as on Converse: Claude mints redacted_thinking itself and its AnthropicBedrock turns carry no stamp, so "unstamped" cannot mean "foreign". - Safety net: a 400 naming an invalid `redacted_thinking` block classifies as thinking_signature, so turn_recovery's one-shot strip (remember_rejected_thinking on signed-replay routes) removes the blob, persists the suppression for the session and retries on Claude. This covers unstamped GPT turns from sessions older than the stamp. It runs after the OpenAI Responses thinking_signature_invalid rule, so that class and invalid_encrypted_content keep winning. Refs #142139- docx/xlsx/powerpoint/pdf and finance excel-author/pptx-author: when `hermes` is not on PATH (terminal in the default Docker sandbox image, which has no Hermes runtime) the shared setup block builds a venv under the sandbox home with the same pinned versions as the documents/documents-pdf extras, instead of calling `hermes pm environment` and leaving PY empty. Identical block in all six skills; the pdf helpers' missing-dependency hints point at it. - pdf ocr-extraction: same sandbox fallback for the pymupdf and marker side environments; marker CLIs run from the interpreter's bin dir ("$(dirname "$PY")/marker_single"), which is not on PATH; helper examples use "$PY". - hermes-agent skill: the no-tmux PTY recipe polls the log for the ❯ prompt before the first write (startup's terminal query flushes earlier input); tmux recipes poll capture-pane for the prompt instead of a fixed sleep 8. - Regenerate the skill pages.The catch-up window (how late a missed cron occurrence may run instead of being skipped) was half the job's period, clamped to 120s-2h, for every job. A job can now override it outright: 0-1440 minutes, `0` meaning never catch up. `grace_minutes` is a real 0, not "unset" — and the obvious write pattern would eat it: `v in {None, "", False}` (the `workdir` update lambda) and `if value:` both treat `0` as False in Python. Both doors therefore share `jobs_time.normalize_grace_minutes`, reads test `is None`, and the renderer formats before testing truthiness. The override moves the dispatch stamp too: `_classify_dispatch_lateness` and the dispatch health metric read the same window, so a job the scheduler calls "late" is never reported overdue by the dashboard. Refused on `once`/`interval` schedules: the window describes a missed wall clock, and a duration has none. Split from the timezone commit so it can be reverted alone.Relay inbound attachments are localized to random relay_media_<x> temp files, and that random basename was the only name left. The wire media[].filename was dropped, Content-Disposition only contributed its extension, and the document note showed the random basename as the user's filename. - MessageEvent.media_filenames: per-attachment original name, parallel to media_urls with the same invariant as media_types ("" = unknown). - _event_from_wire fills it from media[].filename by URL lookup. Older connector payloads without the field still parse. - RelayMediaClient.download_with_name returns (path, name). It prefers suggested_name, then Content-Disposition parsed with the stdlib MIME parser (quoted values, RFC 5987 filename* preferred over filename). download() keeps its path-only signature. Storage stays a random temp name, so duplicate names never collide. - _localize_inbound_media carries (url, mime, name) triples, so a failed download never shifts a neighbour's type or name. - Document, audio and video notes use the original name when known. A relay temp file with no known name is labelled "(original filename unknown)" instead of its random basename. Native cache paths (doc_<id>_<name>) render exactly as before. - Names are untrusted: basename only, control/format characters dropped, 255-char cap, and the existing note regex replaces quotes and brackets. - The names survive pending-message merges, text batching, /queue and the native admission envelope. The envelope omits the field when no name is known, so existing admissions keep their fingerprint.A combined package's desktop half is published by the reconcile pass, and a half that pass refuses (a symlink in its tree, over 64 MB) was only logged. The reconcile IPC returned the published paths alone, so the dialog toasted "Desktop plugin <name> installed" for a half that was never copied. The reconcile IPC now answers { published, refused: [{ name, reason }] }. The preload keeps reconcileDesktopPlugins() returning the published paths for its existing callers and adds reconcileDesktopPluginsReport(); the install run reads it and puts the refusal reason in the dialog for the package it just installed. The agent half's success toast is unchanged.local9562f87b56In the browser-hosted Desktop a URL preview renders in a sandboxed iframe that no page reader can ever register for, so desktop_preview read answered "The page has not finished loading — retry in a moment." forever (after an 8 s wait on a pop-out that cannot exist there). It now answers at once with surface: "iframe" and a note pointing at web_extract / browser_exec. read_window_below in a browser tab got an empty answer, which the tool reported as "the desktop app did not answer". The renderer now answers the documented {error, platform} shape saying a browser tab cannot see OS windows. Electron behaviour is unchanged (gated on hostHas capabilities).Spawns re-derive HOME (a host keeps the real one), so on CI the login shell read /home/runner's startup files and the test's ~/.profile never ran. TERMINAL_HOME_MODE=profile makes the child's HOME {HERMES_HOME}/home on every runner; the .profile lives there.Every docs deploy since 2026-10-28 20:08 UTC failed at Deploy to GitHub Pages ('Artifact could not be deployed ... total size'): the retained-assets pool reached 11 GB and the site artifact 2.95 GB compressed. The pool exists so open tabs and edge caches can still load the previous build's hashed chunks; that horizon is hours, so a 2-day window keeps the protection and drops the bulk.A model that answers and then closes the same message with a marker line ("Here is the answer.\n\nNO_REPLY") had the marker delivered as text: the silence filters only match a response that is exactly a marker. Strip a marker on the reply's last line at every delivery door (final send, stream previews and final edit, crash-left replay, Bot Mode relays). A bare marker still goes through the silence verdict, a marker inside a line is prose, and stored history keeps the model's text.Install scanner (tools/plugin_guard*, PLUGIN_SCANNER_VERSION bumped once): - hardcoded_secret: a PostHog project key (phc_, write-only ingest key) joins the Google installed-app secret as a public client key -> high (caution), only when every matched literal on the line has a public prefix (telesec telemetry.py:27 was dangerous). phx_ personal keys and any other token stay critical. - proc_access: reads of /proc/self|<pid>/{cgroup,mountinfo,stat,status,...} (container placement, pid start time) land at low instead of high (hermes-lcm-x cpu_capacity.py was caution). environ, cmdline, mem, maps, fd, root/cwd, ns, bare /proc/self, any unlisted leaf and any write keep high. Generalises #144304's cgroup/mountinfo allowance and keeps the finding visible instead of dropping it. - bytecode_file: a committed .pyc/.pyo (git ls-files; plus any bytecode outside __pycache__) is a high structural finding, the binary_file tier (rule 17; hermes-emotion shipped tests/__pycache__/*.pyc and scanned safe). Untracked caches the validate run writes are not flagged. Validator (hermes plugins validate, warnings only): - private imports: gateway.session_context.get_session_env is the documented route (plugins/index.md) and a plugin's fallback import of its own install path (plugins.memory.openltm._db) is not a core import. - new warning for private-method calls on core handles (SessionDB, AIAgent, GatewayRunner, PluginContext) the plugin does not define itself (opencode-sync db._execute_write, runner._evict_cached_agent). - desktop surface review warning for raw window.hermesDesktop calls and app markup selectors ([data-slot], [data-tree-*], [data-pane-host]) in CSS text or closest() (squad-hub, novel-reader, search-sources).When the keepalive times out, the socket closes and GatewayClient's reader ends before the dock's next poll. websockets refuses any later send on that socket without writing it, so the frame never leaves. GatewayClient.rpc now raises NOT_SENT once the reader has ended, instead of DISCONNECTED ("turn outcome is unknown; do not resend"). A prompt.submit that hits this path tells the user nothing was sent, so they know to send it again. A send that fails while the reader is still alive stays DISCONNECTED, because the frame may already have left. NOT_SENT moves next to DISCONNECTED in gateway_client; gateway_connection imports it, so gateway_queue's `str(exc) == NOT_SENT` check is unchanged.Names the owned-child API in process_identity layer 4: - spawn_owned(argv, purpose=..., **popen_kwargs): starts the child as a session/group leader, hands the group to the owner-death supervisor and records it in the spawn ledger. - own_process_groups(pgids) / release_process_groups(pgids): for groups something else spawned (MCP SDK servers, agent-browser's Chromium, harness daemons) and for children the owner stopped itself. The stringly update_supervised_pgids("register"/"unregister", ...) becomes the private protocol writer. llama-server's spawn_server(owned=True) now goes through spawn_owned instead of re-spelling it. Adds a contract test for spawn_owned (group leader, supervisor registration, ledger entry, release); it goes red when the registration is removed.Symptom: LocalEnvironment._run_bash starts every terminal command in its own session (start_new_session=True, so the TERM->KILL group teardown reaches its children). Nothing reaps that group when the Hermes process dies without running its cleanup (SIGKILL, OOM killer, crash): the command and everything it started reparent to init and keep running. Measured on Linux (tbg_probe: an owner runs `sleep 305` in env.execute() on a thread, owner gets kill -9, check at +10 s): bash and sleep 2/2 alive, 2/2 runs. Change: _run_bash hands the command's group to the owner-death supervisor (process_identity.own_process_groups; pipe EOF -> TERM, 3 s, KILL). LocalEnvironment._wait_for_process releases it once the wait returns, by any path (exit, timeout, interrupt), so what a command deliberately leaves in the background after it returns (`server &`) keeps its current behaviour, the same as a graceful exit. Not spawn_owned: an awaited command needs no spawn-ledger entry. Windows is unchanged (the layer-3 job object). The existing TERM->KILL teardown, the setsid-escapee sweep and the start-time PID-reuse guards stay: they cover a live owner. Result: same probe, 3/3 runs: 0/2 alive. Per-command cost with the supervisor resident: own+release is 0.02 ms (3.4 ms when it has to start the supervisor); env.execute("true") 20.8-24.2 ms on the base vs 22.4-23.4 ms here, inside load noise. New tests/tools/test_terminal_owned_jobs.py (kill -9 owner vs live control owner; a returned command releases its group): green x3, both red on the pre-change code and red on their own mutation (no own / no release). test_local_env_cwd_recovery fakes subprocess.Popen, so its class now stubs the supervisor calls.A child started in its own session or group (start_new_session, os.setsid / setpgid / setpgrp, windows_detach_popen_kwargs) does not die with a SIGKILLed Hermes: it reparents to init and runs forever. That is how every family this PR migrates leaked. With the families on spawn_owned, this keeps the class closed. scripts/check_detached_spawns.py (AST) flags those shapes in the production trees unless the line, or the line above it, says `detached-spawn: ok — <why>`. It runs in scripts/check ("detached-spawns") and as a blocking lint.yml step. The baseline is empty: every remaining site is on spawn_owned / own_process_groups, or carries its reason. The reasons this commit adds are the census verdicts: - must outlive the starter on purpose: restart watchers, /update and the updater hand-offs, dashboard/desktop/service launches, cron and kanban workers (restart-safe), the update-custody watchdog itself; - awaited with a group kill on timeout: cron scripts, rg search, the Browser Use CLI, update completion (holds the checkout lock fd); - covered by its own parent guard: the compute host (compute_host._parent_guard_loop flushes sessions; a supervisor KILL could cut that short); - not a spawn: the wake-word `start_new_session` config key and event field. tests/scripts/test_check_detached_spawns.py: each shape flagged, non-detached and commented/string shapes not, the marker opts out on its line or the line above and nowhere else, tests and the helper are out of scope, main() fails on a new unmarked spawn, and the repository scan is empty. Teeth: appending an unmarked start_new_session Popen to tools/file_operations_search.py turns `scripts/check --only detached-spawns` red (rc 1); restoring it turns it green.The browser-daemon and supervisor tests had their own deadline/sleep polling loops; they now use the shared wait_until helper, as tests/AGENTS.md requires (the router test moved with its fix). The only sleeps left are inside child-process scripts and blocking holders. test_windows_never_releases faked Windows by patching os.name on a POSIX interpreter inside a POSIX-marked file. The file-wide mark is now one mark per test, and the Windows case is platforms("windows") with no host fake, so it runs on the Windows lane against the real branch. Reported by Greptile on #137057 (two threads).Review (nit 9, nit 4): when git could not list the tracked files the ratchet scanned nothing and reported the tree clean; it now exits 2. A bare `detached-spawn: ok` with no reason passed; the marker now counts only with a reason after the dash (every existing marker has one). The docstring now lists the shapes it does not catch (PTY spawns, aliased imports, os.posix_spawn(setsid=True), kw.setdefault("start_new_session", True)), and says that a group-killing timeout runs in the owner, so an owner killed mid-wait leaves such a child to finish; the process_identity comment no longer claims the ratchet keeps every detached spawn out. Tests: a reasonless marker does not opt out; a tree git cannot list fails with exit 2. Both fail on the prior ratchet.Rebase onto main: main moved the repack into git_commit_graph.run_bounded_git, a niced git in a process group of its own (process_group=0) that the timeout and atexit kill. A SIGKILLed or OOM-killed owner ran neither, so git repack and its pack-objects kept packing for as long as they took; the detached-spawn ratchet flagged the new spawn. It now starts through spawn_owned (same own group, plus the owner-death supervisor) and releases the group once it is empty; the timeout, atexit and SIGTERM-first kill are unchanged. spawn_commit_graph_refresh is meant to be detached ("must neither delay the update nor die with it"), so its Popen gets a marker with that reason. Test: a git-maintenance case in the kill -9 owner matrix (a `git` that execs the child, the way repack forks pack-objects): the SIGKILLed owner's tree dies, the live owner's survives. Fails with the plain process_group spawn.Review nit: Thread.start() and Timer.start() raise RuntimeError ("can't start new thread") when no thread can be started; catching Exception there could hide a real bug in the waiter or the idle release. The thread-start test already raises RuntimeError and still passes.Review (Greptile P2): tests/AGENTS.md requires @pytest.mark.platforms("posix") instead of a bare skipif, so two tests this branch added (the stalled ledger-lock holder, the orphaned slash worker's metrics flush) now carry the marker. The Raft stop test asserted the stop returned within 0.8 s, below the suite's 2 s timing floor; it now records the release call and asserts it probes once (timeout=0), which is the behaviour it guards. That assertion fails when _stop_bridge drops timeout=0. The removed timing check left `import time` unused, and the WhatsApp disconnect test imported the adapter module it never used; both imports go.`moa.presets.<name>.aggregator.{provider,model,reasoning_effort}` are read by _clean_slot, and reasoning_effort is the documented per-slot effort knob (config_defaults.py, auxiliary.moa_* comment). They still printed the unknown-key notice: always for reasoning_effort, and for provider/model on any preset other than `default` (the only one the schema walk knows), so the same field got a different verdict depending on the preset name. Accept those three sub-fields in is_preset_field_path. As with `default` today, a slot left without provider or model is dropped by the normalizer and the default aggregator applies. The `fanout` mapping keys keep the notice on purpose: `n` only takes effect beside `mode: every_n` (either one alone normalizes to user_turn), and `fanout every_n:N` sets the cadence in one write. A test in test_moa_config.py pins the accepted slot fields to the keys _clean_slot keeps.A job whose newest ledger attempt is claimed/running shows Stop in its header. A click calls POST /api/cron/jobs/{id}/cancel and holds "Stopping…" while the view polls the job list until that attempt is terminal, then toasts "Run cancelled" (or that the run finished first / nothing was in flight). A cron.changed refresh makes a run started elsewhere show its Stop too, and while this window's own Run-now is waiting the run counts as live until an attempt claimed since the click has finished, so the button never flickers back. The editor gains "Missed runs" (use the default / run once when back / skip them), sent only when the user changed it; the dashboard create endpoint now accepts catch_up as update already did. Strings live in types_cron_control.ts plus one <lang>_cron_control.ts per locale.The opt-in step let setup end on "✓ Photon setup complete" even when the wait timed out, was interrupted with Ctrl-C, or was skipped on a non-TTY run, so the user was told to start the gateway for a number Photon still refuses ("Target not allowed for this project"). - setup: when the number is not opted in, the closing line says the setup was saved but routing is NOT live, names the line to text, and points at `hermes photon status`; exit code stays 0 (config was written). - wait_for_opted_in_user: add a wall-clock deadline next to the attempt cap, so slow list calls (30 s HTTP timeout each) cannot stretch the advertised 5-minute wait to half an hour.photon statusrouting row gives up after 5 s on an unreachable API ce02e4686dClicking a button on an approval card replaced the whole message with only the decision ("✅ Approved once by Ana"), so a shared chat lost the record of WHAT was approved. Discord and Slack already kept the body. - Telegram: exec-approval and slash-confirm resolutions keep the prompt's own rendering (Message.text_html rebuilds it from the entities, code block included) and append the decision; a prompt with no readable body, or one the appended line pushes past the length cap, still gets the decision alone so the buttons never stay live. New sibling module telegram_resolved_prompt.py; the adapter facade does not grow. - Feishu: the card body is kept in the per-card state at send time and rendered above the decision on the replacement card. - Teams: a click after the approval ended replaced the card with only "expired"; it now keeps the command and reason like the resolved card.A stream cut by agent.stream_max_lifetime was counted as a stale kill, so two capped long replies latched HERMES_STREAM_STALE_GIVEUP and the next healthy request was refused with nothing sent. The cut also surfaced as a transport error ("stream attempt N was superseded", "the endpoint looks unreachable"). - cap kills are marked accounted in the stale breaker but never counted; - the killed attempt's error becomes StreamLifetimeExceeded (a TimeoutError, so still retried) naming the setting, and the exhausted notice uses it; - docs say which transports the cap covers (Chat Completions and Anthropic Messages; not Codex/Responses or Bedrock) and that it skips the breaker.A pre_llm_call callback (Python plugin or shell hook) may now return {"action": "block", "message": "..."} to veto the turn: no model call is made, the message is the reply the user sees and the transcript keeps a normal user -> assistant pair, and the result carries turn_exit_reason=blocked_by_plugin_pre_llm_call. This is the supported seam for DLP / privacy / policy plugins that previously had to patch core (#57364). The vetoed prompt also stays on the host for that turn: no session titling call, no external-memory prefetch or sync, no background memory review. Its user row gets an api_content placeholder for the main-loop replay. That alone does not keep the prompt out of later turns (compaction, session_search and memory providers read the row's content); the next commit stores the placeholder as the content. First valid block wins; a Python block without a message is ignored (same as pre_tool_call). Shell hooks accept both the Hermes {"action": "block"} and the {"decision": "block", "reason": ...} shapes, mirroring pre_tool_call.Clarify questions went through the command escaper, so ordinary prose showed VS16, ZWJ and ZWNJ as escapes ("⚠\ufe0f Which branch?", Persian words split by \u200c). sanitize_command_for_display/redact_approval_display gain prose=True: it still surfaces controls, bidi embeddings/overrides/ isolates, line/paragraph separators and padding, and leaves emoji and script joiners alone. Choices now get the same display copy; a picked choice maps back to its raw text by position, so the model still receives the raw question and answer. Commands keep the strict escaper.The Desktop asks `i18n.languages` and `i18n.catalog {surface: desktop}` once, right after it connects. In tui_gateway nothing has run plugin discovery by then (it happens on agent creation, plugin RPCs, or a model_tools import), so a pack plugin's `provides_locales` was not registered yet: the reply listed the language as `bundled` with an empty Desktop catalog, and the Desktop does not ask again until the language or profile changes. With hermes-lang-ko installed and enabled, the UI stayed in English. Both handlers now run the idempotent `discover_plugins()` first. A failed discovery still answers with the bundled and overlay layers. A cold discovery loads every enabled plugin, so both methods run on the RPC pool (`_LONG_HANDLERS`) and never hold the request reader. Repro on a cold `tui_gateway.entry` with hermes-lang-ko 0.2.4 enabled: before, ko `source: bundled` and 0 Desktop keys; after, ko `source: plugin:hermes-lang-ko` and 5,373 keys. Introduced with the handlers in9bcbe7b5df(#126296). Fixes #143523The Desktop asks the backend for language packs (`i18n.languages`, `i18n.catalog {surface: desktop}`) from an effect in the i18n profile provider. That effect read `$gateway.get()` once and only re-ran on the connection state, the requested language and the profile scope. On a normal boot the connection state turns `open` before the gateway client is published in `$gateway`, so the effect saw no client, returned, and never ran again: the Desktop never asked for packs at all. A language only a pack or a user overlay renders (Korean via hermes-lang-ko, or `~/.hermes/locales/ko.desktop.yaml`) stayed in English even with the backend serving all 5,373 strings. The provider now subscribes to `$gateway` and lists it as a dependency, so the sync runs as soon as the client is published (and again if it is replaced). A second ordering problem sat behind it. When the saved language is not renderable at config load, the locale context asks the OS for its language and applies that guess when the answer returns. Once the sync works, the pack can land first and promote Korean, and a later OS answer then replaced it with English. The context now re-resolves the saved language when the OS answer arrives, so a pack that landed in the meantime wins. Seen in a real Desktop run on a scratch home with hermes-lang-ko enabled and `display.language: ko`: before, no `i18n.*` frame went out at boot and the UI stayed English; after, the UI boots in Korean. With no pack installed, it boots in English and does not offer 한국어. Both came with9bcbe7b5df(#126296): it added the sync effect, and the parked-language promotion that lets a saved language become renderable after config load, without re-checking it after the OS probe.The video-note branch passes the label "video note" to _surface_media_cache_failure, which translates the chat reply through _MEDIA_KIND_KEYS. The label had no entry there, so a German or Japanese user whose circle failed to download got a translated sentence with the English words "video note" in it. Map it to the existing kind_video key ("video file" in every catalog). The agent-visible note keeps the English "video note". Scope: no new catalog key; a circle is reported as a video file in the reply, which every locale already translates. Test: test_video_note_failure_reply_is_localized (added with the test fold later in this stack) fails with this entry removed.Adds the user-facing half of the per-skill lock (hermes curator lock): - PUT /api/skills/lock {name, locked, profile?}: resolves the skill and keys the record by its directory (what the write guards resolve), in the selected profile; 404 for an unknown skill, 500 when the sidecar write fails. - GET /api/skills rows carry locked (best-effort; a corrupt sidecar lists everything unlocked instead of failing the list). - Desktop: Lock/Locked text toggle with a state-specific hint in the skill detail pane for every provenance (optimistic, rolls back on error), plus a lock glyph on locked rows (list rows, cards, detail header). Strings in all bundled locales.The pointer click at the composer's hit-tested centre passed the topmost check but did not focus the v0.19.1 editor on 7 of 43 legs (proof run 41418896446: "The click at the composer must focus it"). Focus it with focus({ preventScroll: true }) after the same topmost check: no scrolling, and the hit test still proves the composer is the element a user would reach.The kept-route set was a plain Set: keepRoute added the route key and its release deleted it, so two holders of one route would release each other: the first release drops the keep while the second still relies on it, leaving that socket to the idle pruner. No current caller does this (keepGatewaysConnected starts the only keeper once, and it takes one keep per connection); this is hardening for a future second holder. Count holders per route (Map of key to count) and make each release idempotent, so a holder releasing twice cannot drop another holder's pin. Single-holder behaviour is unchanged. The route key itself is left as is. It is not interchangeable with the registry scope key: the scope key trims the connection id and defaults an empty or blank profile to "default", while the kept key compares the raw (connectionId, profile) pair, and secondaries opened through openGatewayForAgent / ensureGatewayForAgent keep the caller's raw profile. Switching keys would change which entries count as kept. Test: gateway-kept-routes.test.ts "a route two holders keep stays kept until both release it, however often one releases". Red on the previous module: AssertionError: expected false to be true at expect(isKeptRoute('workshop', 'default')).toBe(true). Green here: gateway-kept-routes 4 pass (the three older kept-route tests pass unchanged) and gateway-persistence 2 pass.Review findings: the two new Linux-only tests now carry @pytest.mark.platforms("linux") instead of a bare skipif, and the pack-transfer poll uses tests.waiting.wait_until (still failing fast if the fetch exits early) instead of a sleep loop.A sidebar gateway pick now publishes the new gateway while the previous gateway's session rows are still loaded, and kept gateways pool their rows into the same lists. setConnection rescopes the pin set to the new gateway, and pin sync then adopted every pinned row it could see into that set without looking at the row's owner. The other gateway's pinned chat showed up in the new gateway's pin list and was saved under its pin key, and an unpin of it was written through the new gateway, which never had the row, so the next page re-adopted it. Pin reconciliation, both the pull (adopting server pins) and the push-side row lookup (which row and profile a pin write targets), and the optimistic repaint now consider only rows owned by the gateway the pin list belongs to. A row's owner is its connection_id. An untagged row belongs to the primary pool, because the row stamp never tags local rows or the primary's own: local when the primary is local, otherwise the primary's registry id. A primary with no registry id keeps the old behaviour (untagged rows count as the active backend's). The pin list's gateway is the published connection's id, or the ambient owner id when the descriptor names none. Single-gateway local, remote and webapp windows see no change: their rows are untagged or tagged with the active id and still pass. Pin writes are not rerouted. With another gateway active, a server-pinned chat from a different kept gateway can no longer be unpinned from there (the toggle is a no-op instead of a write to the wrong gateway), and pinning one keeps it in the active gateway's local list without writing. Unpinning such a pin later still writes the unpin to the active gateway, now under its active profile instead of the row's. Owner-routed pin writes are left to a separate change. The sidebar comment that said pin sync adopts every server-only pin into the local set now says it does so only for the pin list's own gateway. Tests: session-pin-connection-scope.test.ts, each red without its rule. - Gateway A's pinned row still loaded when B is published. With the base pin sync: expected [ 'only-on-a' ] to deeply equal []. - The same id listed by A and B, pinned while B is active: only B's row is repainted. With the owner check in the repaint removed: expected { a: true, b: true } to deeply equal { a: false, b: true }. - This device is the primary with an untagged pinned row loaded, then registry gateway B is picked. With untagged rows given no owner: expected [ 'only-local' ] to deeply equal []. B's own pinned row is still adopted.A redial makes a fresh entry for a scope that has opened before, so openSecondary takes its `reopening` branch and awaits import('@/store/session-states'). This file never mocked that module: each run transformed its whole real graph cold (~0.7-1 s unloaded), then the import threw on the partial '@/store/session' mock and the catch moved on. That load ran inside vi.waitFor's 1 s budget, so under CPU load the redial landed late, the first test timed out on the old URL, and the late dial spilled into the next two tests. Mock the three functions the reopening branch calls, as gateway-connection-lifecycle.test.ts already does. The redial now settles in microtasks (tests 1.2 s -> ~0.14 s per file run). Loaded (20 CPU burners, 6-10 parallel vitest runs): origin/main 30/30 runs failed; this branch 0/60./api/status gateway_shared_with is [] on a single-profile gateway: it serves only the profile it was launched as, which the list does not name. Treating that as {default} switched a window on a gateway launched as 'work' onto 'default', which that gateway refuses (profile_mismatch). A non-empty list already names the launch profile, so it is used as-is; [] is unknown, like null./api/status gateway_shared_with is [] on a single-profile gateway: it serves only the profile it was launched as, which the list does not name. Treating that as {default} switched a window on a gateway launched as 'work' onto 'default', which that gateway refuses (profile_mismatch). A non-empty list already names the launch profile, so it is used as-is; [] is unknown, like null.The MCP stdio spawner built its child env from an allowlist (_SAFE_ENV_KEYS) that omits HERMES_HOME, so every stdio MCP child was launched with no HERMES_HOME at all. Under a multiplexed host / dashboard-desktop backend serving `--open-profile` (e.g. hermes-serve-dev.service, host home = default, opened profile = dev) those children resolved to the platform-default home instead of the served profile: - any Python child importing hermes_constants tripped _warn_profile_fallback_once() -> "[HERMES_HOME fallback] HERMES_HOME is unset but active profile is 'dev' ... Any data this process writes will land in the wrong profile" (issue #18594); - children that DO write (caches/state) landed under the default home. Pin the ACTIVE profile's home at spawn time via get_hermes_home() (honours the served-profile contextvar, which does not cross the process boundary; os.environ only holds the launch profile's home). An explicit server `env.HERMES_HOME` still wins (applied after). Verified: tests/tools/test_mcp_stability.py, tests/tools/test_mcp_connect_secret_scope.py -> 20 passed. Probe: served scope (override=dev) now yields child HERMES_HOME=.../profiles/dev.Red on base: `_build_safe_env(None)` raises KeyError('HERMES_HOME') under a served profile scope. Green with the pin. Also asserts an explicit server env.HERMES_HOME wins.Reported in review by ghgoodreau: KaTeX defaults to inline mode, which rejects display-only TeX. `A["$$x^2\tag{1}$$"]` threw "\tag works only in display equations", so the resolver returned null and Pele drew the literal source. mermaid.js passes `displayMode: true`, so this does too. Display mode also restores mermaid's display sizing: \frac and \sum render with stacked, full-size limits. The in-page measurement still matches what the SVG draws: `x^2\tag{1}` measures 86x26 and draws 86x26, `\frac{a+b}{c+d}` 35x30 and 35x30.40b56650) 3e3718381dReview follow-ups on the transient-failure recovery. - A hop that echoes the request's bearer into its error body bare ("token <x> rejected") got it logged: the generic redactor has no pattern for a prefix-less base64url token. describe_http_error now takes the exact credentials the caller sent (secrets=) and replaces them literally before the excerpt is cut and redacted. The provision path passes the identity token, which also covers the connector's own refusal reason; the media upload passes the per-attempt bearer and the per-gateway secret. - Credentials obtained while a reconnect-watcher pass was still dialing without them were pushed back to the 300s cap when that dial failed. _on_relay_provisioned now also sets retry_now on the queue entry. A pass clears the flag when it starts; if it is still set when that pass records a failure, the failure keeps the immediate retry. - A JSON {"error"} 403 from a proxy (Cloudflare/Envoy style) was taken for a connector refusal and paced at 600s. It is not a refusal when Via, Proxy-Status or X-Squid-Error is present, or Server names a known proxy. The connector sets none of these, and a hosting edge's own Server name (railway-edge) is not on the list.On a relay Slack DM, a top-level turn's typing metadata names no thread, so the relay adapter anchored the status ("<bot> is working") on the chat's latest inbound message, read from a per-chat cache at every frame. A second top-level message or a /stop sent while the turn runs overwrote that cache: the running turn's heartbeats and its final clear moved to the newer message, and a turn that ended without posting a reply (an interrupted one) left its own message showing "is working". Resolve the anchor once, when the turn starts, from the turn's own message id, and reuse it for every refresh and clear of that turn: - gateway/platforms/helpers.py: typing_metadata_for_turn(adapter, chat_id, anchor, metadata) calls an adapter's status_metadata_for_turn hook (class lookup, so test doubles never stand in for it); metadata is unchanged for adapters without the hook. - RelayAdapter.status_metadata_for_turn: _with_status_thread_anchor takes an explicit anchor; the per-chat cache stays as the fallback for event-less callers. - Callers that have the turn: BasePlatformAdapter._process_message_background (typing refresh + its three stops; the reply metadata is unchanged), the producer-turn bracket (session_producer_lifecycle), the runner's end-of-turn clear (_hmwa_stop_typing_for_turn), and the progress-bubble status restore (_progress_restore_typing). Threads named in metadata (channel threads, dm_top_level_threads_as_sessions true, thread replies) are unchanged: the hook only fills a missing thread.Claude Mythos 5.1 documents thinking as "Adaptive (always on)" (platform.claude.com/docs/en/models/mythos-5-1/overview) and answers thinking: {type: disabled} with HTTP 400, so a reasoning-off request (titles, /reasoning none) failed once before the retry dropped the disable. Add the family to _MANDATORY_THINKING_CLAUDE_SUBSTRINGS, whose comment already prefers a spurious entry over a missing one.monkeypatch.setattr on a registry singleton (get_package("venv")) records the bound method as the old value and restores it into the instance __dict__, where it shadows every later class-level patch in the same process. A single-process tests/pm run then skipped Venv.apply's counting patch in test_update_sync_retries_a_fetch_failure_once_before_disabling (trials == []) whenever test_features.py or test_generation_durability.py ran first.The `command '...' failed` pattern also matched a compiler that ran and failed on a source error ("command 'gcc' failed with exit code 1"), so the hint told users to install a compiler they already have. Match distutils' missing-executable form ("failed: No such file or directory") only.cmake 4.x dropped support for cmake_minimum_required(VERSION < 3.5). python-olm 3.2.16 bundles libolm with a CMakeLists.txt that declares VERSION 3.4, so installing it from an sdist fails at the cmake *configure* step, before any compiler is reached: CMake Error at CMakeLists.txt:1 (cmake_minimum_required): Compatibility with CMake < 3.5 has been removed from CMake. CMAKE_POLICY_VERSION_MINIMUM=3.5 tells cmake 4.x to treat the project as though it declared 3.5, which unblocks the configure step. One env key, no source patching, no platform gating. Relocated from tools/lazy_deps.py: the lazy-install path moved to PM. That module is now a 16-line old-updater stub (scripts/ci/check_lazy_deps_imports.py fails CI on any production import of it), and the extra install runs `hermes pm install --extra X` -> pm.packages.Venv.apply -> uv sync. The variable therefore goes where uv is actually spawned: PythonEnvironment._run in pm/environment.py assembles the env for every uv invocation, so the floor reaches both installer tiers — `uv sync` and the `uv pip install --requirements` tier behind _install_requirements_file. There is no `python -m pip` fallback in the install path any more; that concern applied to the deleted ladder in the old _venv_pip_install. Sanitization is unchanged: the variable is setdefaulted onto the env _base_environment() already produced, so ambient UV_*/PYTHON* stripping and the pip index bridge still hold, and a caller-supplied value wins. Still live on current main: uv.lock supports python_full_version >= '3.14' while python-olm 3.2.16 ships cp311/cp312 manylinux wheels only, so the linux-gated matrix extra has no matching wheel and uv builds the sdist there exactly as it did on macOS before the gate. Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>pm install --withoutdrop a recorded extra and keep it dropped fe95d04facpm installsync from the main-era venv scan 3fad3293b2--withoutbfa652fae8A plugin whose selected extra names the core package (honcho-style `hermes-agent[x] @ git+...`) made uv refuse the generated plugin workspace: "`hermes-agent` is included as a workspace member, but is missing an entry in `tool.uv.sources`". The lock failed and the update evicted the plugin. The generated root now carries `<core name> = { workspace = true }` in its `tool.uv.sources`. uv applies root sources to every member (renamed virtual members and any spelling of the name included), and a source beats an inline URL, so the requested core extra installs from this checkout. Salvaged from #147311: the dependency-group stripping and unselected-extra pruning from that PR are already on main (#148199, #148215), and runtime `[project].dependencies` naming hermes-agent are already filtered by `applicable_requirements`, so only the selected-extra case remained. The per-member binding is replaced by one root-level source.Independent review (P2): binding the core name to `{ workspace = true }` makes uv ignore the specifier, so a selected extra `hermes-agent[other]>=2` installed against Hermes 1. Main's runtime path had the same hole: `applicable_requirements` drops a runtime `hermes-agent>=2` without checking it. Every applicable requirement naming hermes-agent (runtime dependencies, manifest pip_dependencies, and the extras this generation installs, markers evaluated on the target interpreter) is now checked against the version identity requires_hermes reads (the committed pyproject version is a 0.0.0 placeholder). A mismatch raises ResolutionConflict naming the plugin, the requirement and the running version, which update-time eviction treats as a verdict on the plugin and parks it.The ownership check added in this branch only counted an upload flag when a literal curl/wget word preceded it, so $CURL -d "k=$API_KEY", ${CURL} -F, c=curl; $c -F, "$(which curl)" -T and `which curl` -F stopped being flagged (they were flagged on main). A word that starts with a parameter expansion, command substitution or backtick may run curl, so it now counts as an owner. Literal delimiter commands (awk -F, cut -d, sort -t) stay unflagged. Reported in review by @Enough1122.The previous commit treated any word containing "=" as an assignment, so `c${suffix:=url} -d @.env …` (which runs curl when suffix is unset) was no longer flagged. Use the existing NAME=value assignment pattern instead, as the earlier version of this check did.connect() called ctypes.util.find_library("opus") on the gateway loop whenever opus was not loaded. On Linux find_library runs ldconfig, gcc and ld subprocesses, and on a host without libopus (the Hermes Cloud image) it never succeeds, so every Discord connect and reconnect repeated that blocking work on the loop. On a memory-starved 2 GB instance both loop-liveness watchdog dumps that restarted the gateway had the loop thread inside this probe. Move the opus helpers into adapter_opus.py and probe at most once per process under a lock; connect() runs it with asyncio.to_thread. A libopus installed later needs a restart, and the "Opus codec not found" warning now logs once instead of on every reconnect. The per-connect probe came in with0ff1b4ade2(#1299).- Desktop: key the link panel by platform so a pending code never follows the user to another platform's page. - A code that goes inactive before it expires was redeemed: a re-link from an already-linked account no longer reads as "expired". - Polling stops after 5 consecutive errors or at expiry and shows the error. - Connector 401/403/409 map to named, plain-language errors; 5xx/408/429 are retryable (503); unlink answers {unlinked: true}; a silent gateway is worded for stopped, too-old and timed-out alike.A refused link (e.g. 409 platform_not_fronted, 503 gateway_not_running) reached the card as "Error invoking remote method 'hermes:api': Error: 409: {"detail":{...}}". Unwrap the IPC prefix and the router's {detail:{message}} body before showing it.A relaunch restores the Bots pane before the gateway socket opens. The gateway Group Chat list fetched groups.capabilities at mount, the request was refused ("Hermes gateway is not connected"), and nothing re-listed when the socket opened, so the refusal stayed as a role=alert line under "Refresh gateway groups" until the user clicked it. Seen with one or two connections. Re-list on every gateway state change and only surface a list failure from an open socket.Greptile: "Membership edits hide the room". Who drives a non-canonical room was re-derived from its live members on every render, so removing the last remote bot from a 2-local + 1-remote room sent it to GroupExecutionGate ("Start gateway group", history hidden; starting forked a new room). The room record now carries `engine: 'desktop'`: stamped at creation when the selection spans connections, and by either membership door when the room spans machines before or after the edit (so unstamped older rooms are pinned at their first edit). Storage hydration and the ui_meta projection keep the mark (sticky union), so a reload or a second Desktop does not drop it. The membership check remains only for records without the field.A cross-machine room's transcript named each line's speaker from the seated members only. Once the Homelab bot was removed from the room (or before its source painted after a relaunch), its lines fell back to a bare { name: 'default' } row and read as this device's "Hermes", while the Homelab backend reports no title (its roster row reads "Homelab"). groupTranscriptSpeaker resolves the speaker owner-qualified: seated member, else the live roster row from the line's own source, else a row carrying that source, so a remote `default` never borrows the local identity.Delta re-review P2: unchecking a remote member whose machine is unreachable saved without error, landed only locally, and that machine's unchanged ui_meta re-seated the member on reconnect. - Manage members: a seated member on another machine that is offline (or a ghost row) cannot be unchecked; the row says "<machine> is offline - reconnect to remove <bot>" (every Bot Mode locale). - setGroupChatMembers refuses before writing when a removed member is shown offline, writes other machines' rows first, and when one of them did not take the change ('failed' / 'unreachable') puts back every row it already wrote and fails before the room record changes, so membership stays the same on every machine. - saveBotMeta still never throws: a routed row whose machine never answered the read now reports 'unreachable' instead of the older-gateway 'unsupported' fallback; other callers keep treating it as non-fatal.Same class as 4fb8d21 (Manage members), on the per-Bot "Manage groups" door: toggling a room for a bot on another machine that is offline (or does not answer) reported success, landed only in this Desktop's copy, and that machine's unchanged ui_meta re-seated or dropped the bot again on reconnect. - setGroupMembership refuses before writing when the bot's machine is shown offline, and writes through writeMembershipChanges, so a remote write that does not land ('failed' / 'unreachable') puts the local groups[] back and fails before the room's stored seats change. - Manage groups: for such a bot every room toggle, Create & join and Remove from all groups are disabled, with "<machine> is offline - reconnect to change <bot>'s groups" (every Bot Mode locale). The success toast now waits for the save and a failure shows its error.All four tests failed on main: New Group Chat now builds a gateway-owned room (composer "Group message"), so the Desktop-driven room view and its localStorage log never appeared. Every assertion here is about that engine (thread replies, watermarked delta prompts, stranded-turn harvest on the five-second observation timer, renderer prompt.submit refusals, Stop clearing the renderer queue), so the tests keep driving it; no assertion changed. - Answer only the groups.capabilities probe as a gateway that does not own rooms ({driver:false, persistent_process:false}); that is the single route by which the current app opens a Desktop-driven room. Turns, member sessions and inference still run through the real backend; createRoom asserts the probe was answered and the legacy composer appeared. - Give the sandbox .env the provider key so the bots' member turns are not refused ("No API key for custom provider 'Mock'"). - Wait for both bots' birth kickoffs before building the room: a late birth foregrounds its Bot Chat over the room tab. Needs the preceding commit (cherry-picked from 2b2b1f5c69f): without it the local gateway answers a missing member session 4001 not_found and refuses room_plumbing, so the first member turn never starts and the spec hangs.The room read as loose parts: header, chips, messages and composer each started on a different left edge, the round line floated mid-air between the transcript and the composer, a stray chevron trailed the title, chips were low-contrast with mixed grammar ("scout ·· 7s" next to "critic idle"), and the Desktop-driven room still had a text "Back" button and its own small grey message rows. - One column: header, chips, transcript and composer share the transcript's max-w-3xl column, so every left edge lines up; a short room anchors on the composer like a chat thread instead of floating at the top. - Messages: avatar + name + time over the body on one avatar column; member bodies render as bare markdown (the main thread's answer), the user's own body sits in the main thread's user bubble (fill + transcript hairline). Notices line up on the same edge. - Header: bottom hairline, no trailing chevron (the title button toggles details on hover/click). - Chips: soft control fill, 12px text, one grammar for every member: name · tool-or-status word · time ("scout · terminal · 7s", "critic · idle"). The typing row reuses it ("scout · terminal · 7s"). - The round line is a slim status strip docked on the composer. - Shorter placeholder: "Message the group — @mention to assign" (all locales). - The Desktop-driven room view uses the same RoomHeader (icon back, ghost icon actions), RoomMessage rows, typing row and column.groups.send stamps every message from this Desktop as {kind: 'user', id: 'desktop'}, which the new chat layout printed as the author. That principal with no display name reads 'You' (all 9 plugin locales); every other user keeps its name or id. The room transcript and composer used --conversation-text-font-size outside the slots styles.css scales, so they get the same scoped variable. The typing row's native title= becomes the app Tip.hermes cron create --deliverhelp names the room:<id>[@profile] target 32f95b873fA room's settings gain `routing: everyone | lead` (+ `lead_member_id`), stored on the same `room.context_set` event as purpose/notes and read as of the message that opened the discussion. `everyone` is the default and plans exactly as before. In a lead room, a human message that opens a new thread and names nobody first runs one routing turn on the lead's member session. The lead answers `{"assign": [handles]}`; the gateway validates the handles against the roster and appends a gateway-authored `room.assignment` event, and only those members take the normal round (with a line in their prompt that the lead gave them the message). An answer naming no member, @all, or a failed routing turn wakes the lead alone; a routing answer can never broadcast. @mentions, @all/@everyone, scheduled posts and thread replies keep today's resolution. The routing turn is an ordinary driver task whose seen_through_seq is the message's seq minus one (a member turn always sees through its opening message), so it is identifiable from durable state, its receipt never advances the lead's watermark past the message, and it is excluded from the round-0 terminal set so a lead that picks itself still answers. Its answer is published as the assignment, never as a visible reply. Desktop: canonical-group-lead.tsx holds the Room settings control (Everyone answers / Lead: <member>), the "Ada gave this to Linus, Grace" row, and markRoomLead() so the member chips can show a Lead badge; the history mounts the row with one line.serverOwnsComposerQueue still decided canonical ownership from the owner's `mode === 'local'` or the URL's native_dial marker. The wire a public connection negotiated never left HermesGateway, so on a public canonical chat: - an attached image was cleared from the composer but the canonical prompt.submit carried no attachments; - /undo and /retry went through slash.exec -> command.dispatch, which the owner refuses as unsupported_command; - busy Enter used the global busy_input_mode, never the session's config.get busy; - busy and /queue input stayed in the renderer-local queue instead of prompt.submit {queued:true}. Each socket now publishes its client's latched wire into $connectionWires from gateway.ready, keyed by its owner connection: the primary under its route's connection, a secondary under its own. serverOwnsComposerQueue reads the wire of the owner route's connection (or the window's connection when the owner is unknown) and keeps the native-dial marker only as the fallback before that socket is ready. The `mode: 'remote'` early return is gone, which also covers the Bot Chat owner hints the webapp stamps `mode: 'remote'` on its own native dial. useBusyInputMode subscribes to the wires, so a chat opened before the ready frame switches to the session's busy policy when it lands. isStalledDialError moves unchanged from store/gateway.ts to store/gateway-dial-failures.ts beside the other dial-failure classifiers, keeping gateway.ts under its code-health line cap after the new hook.A primary reached through HERMES_DESKTOP_REMOTE_URL has a route of { connectionId: null, mode: 'remote' }. primaryWireConnectionId returned its null id, and the wire store keyed every null id as the local source. With a real local source connected as a secondary, the two sockets' gateway.ready frames overwrote each other's wire. After a legacy remote reconnect, native local chats read `legacy`: /undo went through slash.exec instead of the owner's rewind, and images took the legacy attachment path. The wire store now keys a source without a registry id by its mode: local stays LOCAL_CONNECTION_ID, and a remote gets UNNAMED_REMOTE_WIRE_KEY, which no registry id can take. primaryWireSource hands the established route (id and mode) to the publisher, and serverOwnsComposerQueue looks up the owner route, or the window's connection, by the same key.message_agent's cancel picked an admission from the target's receipt file and cancelled it with prompt.cancel. When the first attempt had just failed transiently, the owner's one automatic retry could be scheduled but not yet recorded: the cancel hit the finished first attempt, and the retry then ran anyway. The cancel is now an owner RPC, bot_relay.cancel {id, profile, author_id} (gateway/session_bot_cancel.py). It runs under the mailbox lock that admits the retry, follows the retry when it exists, and otherwise withdraws it for good: the receipt's retry is marked refused "cancelled", so no wake, re-delivery or owner restart admits it, and the receipt reads cancelled. Only the delivery's own principal and author may cancel it.- message_agent takes optional `constraints` and `expecting`; the teammate reads them as labelled "Constraints:" / "Reply needed:" lines under the message. - A teammate that ends a request turn silent or empty after writing its answer elsewhere (beside a tool call, or in a message_agent back to the sender) has those last words relayed once, marked "[no final reply; their last message this turn:]". Notify-only and non-bot deliveries keep silence. - A turn its own gateway's stop/restart interrupted is a failed receipt ("its gateway stopped or restarted mid-turn ... not run again"), not `cancelled`: nobody cancelled it. - Every live-lane completion and the fan-out join lead with `outcome`: "@beta answered." or "@gamma never answered: <why>" plus whether resending is safe; a wait that lapses on an unreachable owner says so (target_state "unreachable"); a busy teammate's ack says it takes the request next.- The configuring-models table lists the slots the dashboard exposes, not every auxiliary task (goal_judge, memory_query_rewrite and others are config-only), so say that and link the canonical Auxiliary Models reference; "All auxiliary tasks" assigns the slots on the page. - /review does not take a fallback_chain (it resolves credentials like a delegation pin), so the agent-loop and integrations fallback sentences name vision, compression and title generation instead of "every task". - Remove "web extraction summarization" / "web summarization" from the aux-task lists in adding-providers, provider-runtime and integrations/providers, and the zh-Hans copies of those pages plus agent-loop and integrations/index. - zh-Hans: drop "offloaded to smaller models" from the intro, which contradicted the Review row, and pin the {#setting-auxiliary-models} and {#auxiliary-models} heading ids the new links point at. Refs #147807- locales/_keys.desktop.json: drop settings.model.tasks.skills_hub.{label,hint}. The committed key list mirrors the English catalog (apps/desktop/scripts/i18n-keys.test.mjs runs `--check`), and the rows were removed from en_aux_tasks.ts. - zh-Hans configuring-models still had the Skills Hub row claiming that `hermes skills search` uses the slot; the developer-guide provider-runtime lists (EN + zh) still named "skills hub operations" as an aux task. - Pin the removal: no built-in surface (DEFAULT_CONFIG, the `hermes model` picker, the dashboard REST allowlist, the web and desktop rows) offers the slot, and a config that still carries an auxiliary.skills_hub block loads. Refs #40703, #142403render_inline_image ran exif_transpose(...).convert("RGBA") on the full image before downscaling, and Image.draft() is a no-op for PNG, so a 169 MP PNG (under Pillow's bomb error, only a warning) decoded in full: 2.3 s and 1.4 GB RSS synchronously on the CLI agent thread for a 64-column preview. The preview now reads the grid from the header size (EXIF rotation included), drafts JPEGs to the real target box, refuses a frame past a 40 MP preview cap before any pixel decodes, treats DecompressionBombWarning as a refusal, and thumbnails before the transpose/convert. A 24 MP JPEG preview takes ~0.24 s.image_lines put the whole kitty APC transmit (70 KB-1 MB of base64) inside the first placeholder row, and the iTerm2 OSC 1337 overlay (ESC7 ESC[{rows-1}A <image> ESC8) inside the last reserved row, and those rows went into _OUTPUT_HISTORY. On a resize replay that keeps a row only in part, _ansi_drop_cells walked the transmit char by char and cut it into visible base64; a cropped iTerm2 region kept the cursor-up and drew the image over the replayed text above it. prompt_toolkit's ANSI parser also walked the full transmit on every _cprint, _line_rows and replay (35-800 ms on the UI loop), and every resize re-sent it. _cprint takes a `raw` escape now, written verbatim as one zero-width fragment after the row and never recorded; image_rows hands the transmit there and history holds only the placeholder or blank rows. kitty keeps the image by id, so replayed placeholders still show it. An iTerm2 image is pixels over its reserved rows, so a replay repaints those rows blank: the picture is lost from the redrawn viewport (scrollback keeps it) instead of overpainting text. The overlay now also steps past the row indent, as the placeholders do. _ANSI_SEQUENCE_RE matches prompt_toolkit's \001...\002 brackets whole, so a cropped placeholder row keeps the exact truecolor that names its image instead of a split bracket the parser quantizes.Gateway-hosted Group Chats gain two things: - routing "auto" (opt-in room setting; "everyone" stays the default). The deterministic rules run first and never reach the router: an @mention, @all/@everyone, a scheduled post, a message inside an existing thread. An unaddressed message otherwise gets ONE tool-less auxiliary.group_router call over the room purpose/notes, the roster (handle, name, profile description) and the last 5 messages, answering strict {"members": [...]} JSON. The answer is written as room.routed BEFORE any turn is planned, so plan_next_task stays a pure function of the log; a failed or invalid answer records a fallback to everyone. The call runs on its own thread; its answer is recorded only if the re-run planner still wants exactly that route, so a Stop or a newer message during the call discards it. - The cap (max_rounds / max_messages) still writes room.activity bounded, and groups.continue (both the standalone and the gateway route) appends room.continued: one more budget of rounds and messages for the same discussion, at most 5 times. The policy checkpoint reopens the discussion's projection from the room log, so Continue works after a gateway restart. Desktop: "Assigned to X" rows (names, never ids), "Paused after N messages" with Continue and Stop, a "Continued" row, and the auto choice in Room settings when the gateway lists it (driver_status.routing_modes). All 9 locales.f3e5bd5 made the row a DropdownMenuItem so the menu closed before the confirm dialog. That ties ContextUsagePanel to a Radix Menu ancestor, and use-statusbar-items.lifecycle.test.tsx renders the panel content on its own ("MenuItem must be used within Menu"). The row is a plain Button again. The statusbar dropdown now provides StatusbarMenuCloseContext around element menuContent (a render-fn already gets close()), and the row calls it before requesting the confirmation, so the menu still closes first and the meter reopens after the dialog.Community feedback comparing against other agent TUIs asked for /new to open a fresh session without pausing the one that is busy. On main /new refused mid-turn ("interrupt the current turn before trying to switch sessions"). - /new while a turn runs starts a new LIVE session (the switcher's `+ new` path): nothing is closed or interrupted, so no destructive confirm. The old session keeps its turn and stays in the session switcher (Ctrl+X). /new on an idle session and /clear are unchanged (the idle one is closed and stays resumable, so live agents do not pile up one per /new). - Approval/clarify/sudo/secret/vault requests from a session that is not on screen no longer open over the focused session's view: they stay open on the backend and come back as open_requests when that session is activated. - The live-session poll announces a background session this view showed when its turn finishes or it starts waiting for an answer.A large paste collapses to a [[ … [N lines] … ]] chip, and until now the user could neither see nor change what was behind it, and the sent message showed only the chip. Composer: Ctrl+P expands the chip under the cursor (or the last one) back into ordinary editable text; the composer shows a window of rows that follows the cursor so a 60-line paste never pushes the caret off screen. Ctrl+P again, or Enter, folds the span back into a chip whose payload is the text as edited, so what the model receives is the edited full text, through the same expandTokens path as before. Transcript: a sent user row keeps showing the chip and gets one accordion per paste ("Pasted text #1 · 60 lines"). Click toggles one; Ctrl+P with no chip in the composer toggles them all. Payloads ride from submit to the row through a small FIFO keyed by chip label, so a busy-time row painted later still gets its own payload.Byte-limited gateways reject an oversize request body with HTTP 400 (or a status-less re-wrap) in wordings the exact 413 phrase list misses: "RequestSize(bytes): N, Limit(bytes): M", Novita's "request body too large, must be less than 20MB" (tagged INVALID_REQUEST_BODY, so it fell to format_error) and Z.AI's "Request size exceeds the limit of {0}" (caught by the token-overflow "exceeds the limit" rule). An image-heavy session, large in bytes but small in tokens, then never recovered. _is_byte_size_limit_message() needs a body/payload/request-size subject plus an overflow signal, lowercases its own input, and never matches a response-scoped subject. It runs on the 400 path ahead of the invalid-body and overflow rules, and on the message path after the head rules, routing to the existing payload_too_large compression recovery.Review finding (BearHuddleston, 4473380964): the bare 'content length' guard always passed for OpenAI/Anthropic-shaped 400s, because str(error) embeds the parsed body and splitting on '_' turned 'invalid_request_error' / 'request_id' into the word 'request'. Keep '_' inside words so only a genuinely named request/bytes subject qualifies; the field-length control ('tools[0].description content length exceeds maximum allowed') fails fast again in every envelope.A flagged command that a prior user decision covers (a command_allowlist entry, an 'always' approval, an 'approve for session' answer) ran with no trace of why. The guard result now carries `pre_approved`: one entry per finding with its own scope ('allowlist' + the matching rule, 'permanent', or 'session'), and the terminal tool turns it into the existing `approval` note. Decisions are unchanged: provenance is built only after the same session/permanent check passed, using the same alias lookups. Clean commands and yolo / approvals.mode=off / unattended passes keep the bare {"approved": True, "message": None} result.Review finding (MAJOR, webhook transport): _handle_webhook_request turned the whole payload into SimpleNamespaces, so action.value failed the dict route check and the settled card was dropped by the fixed {code:0} reply. card.action.trigger now keeps the button value a dict and returns the callback card as the HTTP body; token/signature checks still run first.Finding: the 250 MiB host budget also reaches cached videos, which are always refused later. Videos are never downscaled and video_analyze refuses over 50 MB of base64, so a permitted=('video',) host read now keeps _MAX_INGEST_BYTES and a 50-250 MiB cached video fails at the stat instead of being read, copied and base64-encoded before the same refusal._load_mcp_config interpolated only config.yaml entries and merged portable (plugin mcp.json) servers raw. The first connect re-renders, so it worked, but every transport rebuild calls _refresh_remote_config, which compares the running (rendered) headers with the raw ${VAR} from the load, sees a changed definition, adopts the literal placeholder and parks the server with "${VAR} in url/headers is not set". A plugin remote server with header auth therefore never survived a retry or reconnect.Adds Alibaba's Wan 2.7 to FAL_FAMILIES: fal-ai/wan/v2.7/{text,image}-to-video. 1080p/720p, integer duration 2-15s, negative prompts, seed, native auto audio (no generate_audio key), i2v drops aspect_ratio (schema omits it). Schema verified against fal.ai llms.txt + OpenAPI. $0.10/s (720p), $0.15/s (1080p).Summaries shed qualifier words first, so "I suspect X" either hardens into "X" (a fact the resumed agent acts on) or drops out entirely. The summarizer prompt now asks for each uncertain claim as an explicit labelled line ("UNVERIFIED: <claim> — <basis>") under a new "## Unverified / Working Hypotheses" section, on both the first-compaction and iterative-update paths. The text lives in the new agent/context_compressor_prompt_rules.py sibling (which also takes _today_for_prompt off the facade); the facade only interpolates it. Summarizer prompt only: system prompt, cached prefix and the persisted handoff prefix are unchanged. Cost: +288 chars per summary call (~+79 prompt tokens measured live), down from +756 in the first version of this change.Freeform requests mix timing with work ("every morning at 9, check X"). When the model copies that cadence into the stored prompt, the run-time agent re-reads it as an instruction (waits, re-schedules, checks the clock). One clause in the prompt field description now says the prompt holds the work only and timing goes in 'schedule'. Schema growth: +10 o200k tokens on cronjob_manage (1814 -> 1824).The manage_connections MCP card asked the user to Install / Enable / Authorize a server by name only. Each MCP target now carries a backend-built `launch` line ({kind: stdio|remote, target}) in connection.request: the catalog manifest's transport for install, the configured mcp_servers entry for enable/authorize. The card renders it under the row as plain text ("Runs: npx -y ...", "Connects to: https://...") until the row resolves. The line is scrubbed in the backend (tools/connectors/mcp_launch.py): values of secret-named flags (--api-key=..., --token ...), credential query params and URL userinfo, long opaque URL path segments, then the standard egress redaction; control characters are flattened and the text is capped at 400 chars after scrubbing. The env map and headers are never part of it. The model's tool result drops the field. i18n: launchCommand / launchRemote in all nine locales, _keys.desktop.json regenerated. Gateway contract regenerated.Three defects an adversarial review found in the previous masking round: 1. Interpreter latch: any later arg whose basename matched the (over-broad) interpreter pattern (ssh, publish, a --mode r value) made every following -c/-e/-p count as inline code, so the named secret after it was shown. Interpreter status is now argv[0] only (or the effective command resolved past wrappers), with a precise interpreter name list; a secret behind docker run --name ssh -e GITHUB_TOKEN=x, node server.js -e API_KEY=x, etc. masks again. 2. The "plain value only" rule showed real passwords containing shell metacharacters (--password=Pa$$w0rd!, -e DB_PASSWORD=x$y, ?token=a(b), Authorization: Bearer ab{c}). Named-secret values (flags, -e/--env, headers, URL userinfo/query) are masked whatever they hold; the plain-value check stays only on a bare KEY=VALUE, where it tells an interpreter's inline code from an env var. 3. Readback: a control/invisible arg rendered unquoted, so shlex read it wrong and a real newline collided with the literal text \n. Such args are now bash $'...' words (escapes doubled), so a real newline is distinct from \n and no hidden code point is shown raw. Pre-existing, left per the review: a userinfo password holding a raw / # ? (an invalid URL) is shown, because the authority splits on the first /.The piped rule from the first commit still required -r/-fo after the verb, so `powershell -NoProfile "gci C:\data -File -Recurse | Remove-Item"` (deletes every file, no switch) and `"cd C:\x; rd build -r"` / `"gci | % { ri $_ -r }"` ran with no prompt. The command-position rule for the same carrier already gates a bare delete verb with no switch; the later-in-script rule now matches the verb after `|`, `;`, `{` or `(` inside the quoted script with the same no-switch bar. The separator must be inside the quotes, so an outer-shell compound is left to the rm rules. PowerShell resolves a unique parameter prefix: on Stop-Process and Stop-Service -Force is the only F parameter, so `-f` is -Force (checked on pwsh 7.4 with Get-Command parameter metadata). On Remove-Item / Remove-ItemProperty `-f` is ambiguous with -Filter and refuses to bind, so those rules keep `-fo`.Delta review of a6664b8: - The pipe-alias rule's quote mask hid deletes whose quoted text is executed (`ssh host "gci | ri -r -fo"`, `cmd /c "..."`, `iex "..."`, `echo "..." | pwsh -`). The mask now applies only when no unquoted launcher or shell carrier runs the quoted text, so `Write-Host "run gci | ri -r to clean"` stays quiet. - The command-position anchor missed xargs, doas, time, timeout N, nice, command, busybox, wsl, `sudo -u bob`, `env FOO=1`, `ssh -o X=1 host`, `ssh -p 22 host`, and a quoted ssh remote command (`ssh host "pwsh -c 'gci | ri'"`). - The optional path prefix excludes `(){}` and backticks, so a run of them is no longer rescanned from every position (`'{'*100000`: 36 s -> 6 ms for the rule). - Greptile: the script span runs to the OUTER quote, so a nested `'C:\work dir'` no longer ends it before `; rd build -r`.The space-separated flag rule masked whatever word followed `--token` / `--password`, so ordinary output lost text: `--help` usage lines (`[--password PASSWORD]`, `--token TEXT API token`), prose ("use --token to pass a token"), sub-option specs (`docker build --secret id=npmrc`) and the next flag. `docker login ghcr.io; docker run -p 8080:80` also masked the second command's port because the login context crossed `;`. A space-separated value is now skipped when it is an upper-case metavar, a short lower-case word, another flag/bracket, or a `name=` sub-option; a non-password flag naming a file path (`--credentials ~/.config/x.json`) is skipped the way the ENV passes skip path values. The login `-p` context stops at `;`, `|` and `&`. `--account-key` (az CLI) is added to the flag names. Equals-form values and quoted values are always masked.Review findings on the salvage head: - Azure connstr keys only match as a connection-string segment (``;AccountKey=``); Primary/SecondaryKey dropped, so ``this.primaryKey = x`` / ``dt.PrimaryKey = new ...`` pass through read_file and terminal. - Flag values: no whitespace after ``=`` (``--password= or -p``); snake_case and camelCase metavars (``db_name``, cobra's ``stringArray``) and ``...xxx`` doc samples are placeholders. - Password-only DSN placeholders (``redis://:<password>@``, ``:${REDIS_PASSWORD}@``) stay readable. - PuTTY Private-Lines accept CRLF and JSON-escaped line ends. - ``rediss://`` and ``amqps://`` are DSN schemes. - YAML ``signing_key:`` / ``private_key:`` / ``signingKey:`` names join _YAML_CFG_NAMES. - _DB_CONNSTR_RE userinfo and password runs are possessive: 100 KB of ``redis://:`` or ``redis://u:`` goes from 4.7 s / 4.3 s to ~20 ms. PGP scan gated on its END marker (1 s -> 7 ms).Round-2 review: the metavar skip let all-letter passphrases through (--password correct_horse_battery_staple, --token correctHorseBatteryStaple); it now only covers two short snake words or one-hump camelCase (db_name, stringArray). The password-only URL placeholder exemption now requires an upper-case env var (${REDIS_PASSWORD}) or a lower/ALL-CAPS name in angle brackets (<password>), so redis://:$ecretPassw0rd@ masks. The Azure key gate also opens a segment after , ? and &.Two leaks that 6715bfd had opened relative to the previous head: - A double- or single-quoted flag value with no closing quote within 512 characters fell back to `\S+`, so only its first word was masked and the rest of the secret stayed visible. An unterminated or over-long quote now masks to the end of its line. The match is possessive, so it still never rescans the text. - Under a qualified key (signing_key, private_key, ...), any value in angle brackets was treated as a docs placeholder, so `{"signing_key": "<hunter2>"}` passed through. Only word-shaped samples (`<your key>`, `<api_key>`, `<token>`) are exempt now.The resume refresh read config.yaml with the gateway process environment, so a ${VAR} that the profile's .env sets differently produced a changed value and a needless new generation on every attach. It now reads config.yaml the way session creation does.A messaging turn reloads .env before it runs (inside _current_max_iterations); a local session's turn took its saved turn budget instead and skipped that reload with it, so a key rotated in .env reached it only after a gateway restart. Side tasks now read config.yaml, whose ${VAR} references expand against the environment, so a local turn runs the same reload. Its turn budget stays the session's own.The quoted-scalar YAML pass masked `password: ""`, `api_key: "<your key>"` and `api_key: "${API_KEY}"`, which main kept readable (the JSON pass skips an empty value and _redact_db skips the same placeholder shapes). Reuse _DSN_PW_PLACEHOLDER_RE so docs samples and env refs stay visible.The force-only NAME=user:pass pass destroyed stored values that merely look colon-y: an ISO timestamp with a fraction (TS=...T12:00:00.123456Z lost its tail), a letter-bearing image tag (IMAGE=ubuntu:24.04-noble) and a registry reference (TARGET=registry.example.com:5000/repo lost host, port and path) — data loss at the persistence boundary, not display. The password group is now a single token (no '/', ':' or '@'), so a timestamp tail truncates to digits and a registry path stays whole, and digit-led dotted version tails (24.04-noble, v1.2.3-alpine) are rejected outright. '@' no longer ending up in the group also lets a user:{pw}@host value keep its pure brace expression, to which the promised #33801 f-string template exemption is now actually applied (code_file), matching _redact_userinfo_passwords.Fixes three review-bot findings on 500d5e91cfb: - A braced ODBC password read from a file stayed readable: redact_terminal_output("Server=db;UID=app;PWD={Zq7x;LmP2};...", "cat appsettings.json") runs with code_file=True, and the template exemption took {Zq7x;LmP2} for a format slot. On a ;-delimited line only a format slot ({0}, {pw}, {db.password}) or ${VAR} counts as a template now, so ODBC's literal braces are masked. - Affixed JSON keys missed escaped JSON: a tool result wrapping a config dump ({"output": "{\"DB_PASSWORD\": \"...\"}"}) and a value carrying an escape ("s3cret\\value"). An escaped twin of the affixed-key matcher shares the same key policy, and the value grammar now consumes \x escape pairs. - The force-only NAME=user:pass rule matched a prefix of the value, so PROXY_DEST=buildhost:Release2026/artifact.tar lost its path. The pair must now be the whole value. The new tests fail on 2e34be9dc4a and pass here.Delta review of 10680b3d15a found: - The whole-value lookahead added for PROXY_DEST rejected a Basic pair whose quote opens before the NAME, so "MY_PROXY_BASIC=user:pw" in a YAML list, a JSON/Python array or a single-quoted string leaked again in force mode. Closing quotes, ] and } and a backtick now end the value too; a / still does not, so PROXY_DEST keeps its path. - The ODBC template test accepted only bare identifiers, so code reads (cat app.py) masked f-string and C# interpolations: {os.environ['DB_PASS']}, {config["Db:Password"]}, {get_pw()}, {pw!r}, { pw }, {1:s}. A brace opening with a digit or identifier and carrying no ; or nested brace is a template now. {Zq7x;LmP2}, {abc}}def} and {#...} still count as ODBC literals and are masked. The new cases fail on 10680b3d15a and pass here.Delta review of 73c68b21330 (P3): widening the code-template test to cover interpolations also exempted brace literals that open with a letter or digit, so a code-mode read of appsettings.json left PWD={P@ssw0rd!}, {Tr0ub4dor&3} and {9f$kL2@pq} readable (main leaks them too). A template slot never contains @ $ & #, or a ! other than a !r/!s/!a conversion, so those characters now mark an ODBC literal. Every interpolation from the previous round ({os.environ['DB_PASS']}, {get_pw()}, {pw!r}, {pw!s:>10}, {1:s}) stays as written. Still unmasked in code reads, as on main: a brace literal made only of identifier characters ({hunter2-Secret.99}), which can't be told apart from {cfg.secret_name}.Two review findings on 22639e3df32, both P3: - The network-path rule only started a token after whitespace, a quote, = or (, so `//user:pw@host` in Markdown, or one in a [a, b] list, stayed readable under force. A backtick, comma and [ now open a token too. - //user:%SMB_PASSWORD%@host/share was masked, though %VAR% is the Windows-environment placeholder the way ${VAR} is on POSIX. It now counts as a placeholder wherever the DSN placeholder set is used. Plain DB_PASSWORD=%DB_PASS% assignments keep masking as before. The new cases fail on 22639e3df32 and pass here.Review bot (P2, 4480379637): redact_url_credentials=True (what sanitize_memory_context uses) runs _STRICT_URL_USERINFO_RE after the patterns this PR fixed. It still let userinfo cross a ", so memory context turned {"@context":"https://schema.org","@type":"Thing"} into https://***:***@type. It now excludes " as well; real userinfo inside a JSON string ({"u": "https://user:pw@host"}) is still masked. Covered in TestUserinfoStopsAtQuote, which fails on c3207154990.`platforms: []` is not neutral here. The site renders `plugin.platforms?.length ? … : "Linux, macOS, Windows"`, so the empty list advertises this entry as working on Windows, which it never has: `pass` is a `#!/usr/bin/env bash` script (bash + gpg + git + tree) and the source calls it through `subprocess.run(["pass", ...])` / `shutil.which("pass")`, neither of which can execute a script on Windows outside WSL/Cygwin/MSYS2. There is no `fcntl`/`getuid`/`termios` elsewhere in the path — the bash dependency is the whole constraint. `linux` and `macos` are both exercised: the GPG store, `pass init`, and the `.gpg` subdir walk are the same on either. Entry-only change: `sha` and `version` are untouched, still `485f105` / 1.0.0. The platform claim is about the package's capability, not about this pin, and 1.0.0 had the same bash dependency. Related: #146751 carries the same line as part of the 1.1.0 bump. That PR is blocked on the `/s` sender-gate rework, which is unrelated to this field, so landing the one-line correction separately keeps a false OS claim off the listing in the meantime. Whichever merges first, the other needs a one-line rebase.The transcript-integrity core spec found the fresh p2 tab with getByRole('tab', { name: /Draft/ }). That only worked because the tab had no aria-label, so its accessible name was computed from its content, which included the status dot's "Draft — nothing sent yet" label. #146606 (keyboard-accessible session tabs) gave every pane tab aria-label={title}. That is correct for screen readers, but it means the tab's name is now just "P2" and the old locator never matches. Desktop core E2E runs only on release attempts and dispatches, so PR CI never saw it; rc.1-v0.23.0 failed here deterministically. The locator now picks the selected "P2" tab by name and asserts that the draft status dot sits inside it, so the step still proves the click opened a fresh draft on the p2 profile.Publishing v0.23.0 committed the Microsoft Store submission, and Partner Center answered POST .../commit with 202 Accepted and status CommitStarted. The submission API helper only accepted 200, so the publication pass logged "commit failed: 202 {'status': 'CommitStarted'}" and told the release owner to check Partner Center by hand, even though the commit had started. Any 2xx is now success. The fake Partner Center in the tests answers a commit with 202, as the live API did, so the six tests that commit a submission fail on the old check.The managed-key guard added for `config.set model` only covered the immediate switch path inside `_apply_model_switch`. When a turn is in flight, `_config_set` takes an earlier exit: it parses the request, stashes it in `session["pending_model_switch"]`, and returns `{"deferred": true}` without ever consulting the managed policy. With `model.default` pinned, `/model X --global` during a running turn was acknowledged as a success. The refusal did eventually fire, but in the wrong place and to the wrong audience: at the next turn start `_apply_pending_model_switch` calls `_apply_model_switch`, whose `ManagedKeyError` is caught by that function's blanket `except Exception` and downgraded to a generic "Could not switch model" error event. The original RPC never received 4030, so a client could not distinguish an administrator refusal from a transient switch failure. Refuse in the deferred branch before the stash. The check resolves the full persistence policy via `resolve_persist_behavior` rather than testing `--global` alone: resolution rule 5 defers to `model.persist_switch_by_default`, so a bare `/model X` persists when that key is set, and a `--global`-only test would let exactly that case through. Session- and once-scoped picks never persist and still queue. Also make `ManagedKeyError`'s message descriptive instead of the bare dotted key. Two call sites interpolate the exception into user-facing copy (`_apply_pending_model_switch` and the live-session slash mirror), which rendered as "live session sync failed: model.default". `.key` remains the source of truth for the structured 4030 payload, which reads the attribute and never `str(exc)`. [Ported onto the current layout: the deferred branch is _stash_pending_model_switch in tui_gateway/methods_config_set.py, which also queues every compute-host pick, so the check sits at its top (a `/model reset` never persists and is skipped). The descriptive message is already the ManagedKeyError text from the previous commit.] (cherry picked from commit 191c7545241c386a1563e7d4a336e164d0596e6d)79658be93b(Nachzug-Merge), PORTING-NOTES nachgezogenStand — Upstream-Sync 2026-11-05
Befund: Dritter Re-Sync des Forks. 43 eigene Commits gegen 5.168 Upstream-Commits gemergt (2026-10-22 → 2026-11-05; 5.778 Dateien, +345k/−119k Zeilen), in zwei Merges: der erste (5.097) mit Konflikten, die 71 Commits, die Upstream während der Arbeit schob, sauber hinterher.
.fork-upstream-base:944c3a434071→79658be93ba3.Geändert:
README.md(Fork-Doku bleibt — Upstream hatte nur sein Marketing-README umformuliert, Original lebt inUPSTREAM-README.md) und ein struktureller Hunk intools/terminal_tool.py(Upstream wickelt den Call-Body neu inwith _call_in_flight(...); die server/client-session_key-Trennung von 2026-08-19 mit neuer Einrückung innerhalb des Blocks wieder eingesetzt, Semantik unverändert).&→auto_backgrounded; Foreground-Timeout über Cap → Höherstufung mitnotify_on_complete). Der Early-Exit-Report (2026-10-07) kam diesen von Upstream-Tests festgenagelten Resultat-Formen zuvor und überdeckt jetzt nur noch die stillebackground=true-Form — der Originalvorfall war genau ein stiller Lauf; ein konvertierter/höhergestufter Spawn meldet sein Ende über das Completion-Event. Beide Absichten überleben (tools/terminal_tool_background.py).[tool.uv] override-dependencies; Kern-Deps sind per Marker an ≥3.14 gebunden), Hinweis auf Gegenprüfung gegen einen sauberenupstream/main-Worktree bei Testfehlern. PORTING-NOTES-Abschnitt „2026-11-05“ ergänzt, README „Offene Punkte“ auf diesen Stand gezogen.Geprüft:
compileallüber alle 10.757 Python-Dateien des Merge-Ergebnisses: 0 Fehler.uv,uv pip install -e .): alle gepatchten Module importieren,hermes --helpok — nach beiden Merges.upstream/main-Worktree im selben venv-Setup (umgebungsbedingt, kein Fork-Thema); die anderen 3 waren die Auto-Background-Kollision oben. Nach dem Re-fit: Failure-Menge identisch zur Upstream-Basis (17, gleiche Test-IDs).-k run: 11 umgebungsbedingt (identisch zu Upstream) + 2 Sammel-Fehler wegen fehlendem optionalemlark_oapi. Weitere 5 (test_api_authority_*,test_native_ingress_trust,test_session_policy) sind eine vorbestehende Fork/Upstream-Interaktion: schlagen auforigin/main(vor diesem Merge) in demellem venv bereits fehl, bestehen auf reinem Upstream — verursacht durch den Stall-Judge (2026-10-07), der bei jedem self-hosted Reply einen zusätzlichen Klassifizierungs-Request an die eigene Endpoint setzt, den diese Wire-Shape-Tests als unerwarteten System-Turn sehen. Keine Regression dieses Syncs.Nicht geprüft:
iris-hermes/iris-hermes-scheduled) noch nicht nachgezogen.tests/agent-Suite: lief nicht zu Ende (ein einzeln hängender Upstream-Test, Logging-Watchdown-Teardown; dieselbe Form schon vor dem Merge) — bewusst abgebrochen und der betroffene Kernbereich gezielt gefahren.Grenzen / offen:
.fork-upstream-basesteht auf79658be93ba3(Stand beim Einreichen).shipist hier der falsche Weg.