Reconcile Magi overlay onto upstream main (CP007D) - #2
Merged
Conversation
Three foot-guns in the canonical test runner, each of which cost real debugging time by making an unverified run look verified. 1. Zero collection across the whole run reported success-shaped output. Per-file rc=5 is rewritten to rc=0 so a platform-gated file (every test skipped on this OS) doesn't fail the suite — correct, but it also meant a run where NOTHING was collected anywhere printed "0 tests passed, 0 failed (100% complete)" and, with no failures recorded, could exit 0. Now the run-level guard counts every collected outcome (passed/failed/skipped/errors/xfailed/xpassed): an all-skipped file still passes, but zero-collected-anywhere prints an explicit "✗ NO TESTS RAN — this is NOT a pass" block naming the likely causes and returns 1. 2. A venv without pytest was selected merely for existing. The probe accepted any directory with bin/activate, so in a checkout/worktree without a local .venv it picked the RELEASE venv (~/.hermes/hermes-agent/venv, no pytest). Every file then died with "No module named pytest" and the run reported 0 tests. Candidates are now import-checked for pytest — the same guard the HERMES_PYTHON fallback already applied — and a skipped candidate is named on stderr. 3. Pytest node ids were silently discarded. This runner is file-granular, so `tests/foo.py::TestBar::test_baz` isn't an existing path: discovery dropped it and the run ended "No test files to run" while the selector looked accepted. Node ids are now translated to the FILE plus an inferred `-k` on the leaf name (parametrized ids reduced to the function name), with a note explaining the translation. An explicit caller `-k` wins over the inferred one. Tests: 4 behavior contracts in tests/test_run_tests_parallel.py. Verified by sabotage — reverting the runner fails 3 of the 4 (the fourth pins the pre-existing all-skipped tolerance so fix 1 can't regress it).
…bility
Two tests fail deterministically on main depending only on which SQLite the
test interpreter links — nothing about the code under test. Both are green in
isolation and red in the suite / on an older library, the worst diagnostic
shape.
Root cause A — WAL is not always WAL. Hermes refuses journal_mode=WAL on
SQLite builds carrying the upstream WAL-reset corruption bug (3.7.0–3.51.2,
excluding backports 3.50.7 / 3.44.6) and falls back to DELETE. On such a
build NO -wal sidecar is ever created, so
test_wal_checkpoint_truncates_wal_file asserts on a file that cannot exist.
Invisible locally when the repo .venv and the Hermes managed runtime link
different versions (observed: .venv 3.50.4 → DELETE, runtime 3.53.1 → WAL),
so the same test passes for one interpreter and fails for the other.
- tests/conftest.py: add a `requires_wal` marker plus a
pytest_collection_modifyitems hook that skips such tests when the linked
library will fall back to DELETE. The skip reason names the actual
version so it is diagnosable rather than mysterious.
- pyproject.toml: register the marker.
- test_kanban_db_repair.py: mark the -wal-sidecar test.
Root cause B — process-global warn-once dedup. The WAL-fallback warning is
emitted at most once per (process, db_label). Any earlier test in
test_kanban_db.py that opens a kanban.db consumes that one-shot, so
test_connect_falls_back_to_delete_on_locking_protocol sees zero warnings and
fails — but only as part of the file, never alone.
- test_kanban_db.py: clear both dedup sets in the test that asserts on the
warning, with a comment explaining the isolation trap.
The gate deliberately does NOT import hermes_state. That module computes
DEFAULT_DB_PATH from get_hermes_home() at import time, so importing it during
collection — before the per-test _isolate_hermes_home fixture redirects
HERMES_HOME — permanently caches the developer's REAL ~/.hermes/state.db for
the whole session. The first version of this change did exactly that and made
tests read a live 31,881-session production database (test_console_engine
asserted "Total sessions: 2" and got 31881). The version predicate is
duplicated instead, and tests/test_conftest_wal_gate.py pins the two
implementations in agreement across every documented upstream boundary plus
guards against the import coming back.
Verified: on SQLite 3.50.4 the sidecar test SKIPS naming the version; on
3.53.1 it RUNS and passes, so coverage is not lost where WAL works. Clean
main fails exactly these 2 tests under
`scripts/run_tests.sh tests/hermes_cli/ tests/test_hermes_state.py`
(9926 passed, 2 failed); with this change the same scope is green.
Tests: 726 passed across the two kanban files, test_hermes_state.py, and the
new gate tests.
An agent-written ref rendered as a chip that went nowhere on click. It now renders as an ordinary inline link — the agent wrote it mid-sentence, so it should read like one — with the funnel icon leading the resolved title. Clicking either surface (that link, or the chip in the user's own message) opens the session as a tab, the way its sidebar row does. The tile store loads on click rather than at import: the composer's rich editor pulls this module in, so a static import would boot the profile store and its REST routing along with every transcript render.
The old wording ("no need to also spell out the title") left the model free
to write the link on its own line and then repeat the title in the sentence,
showing the user the same session twice. Say plainly that the link IS the
title and belongs mid-sentence as a noun.
…nk-titles feat(desktop): resolve @session links to titles you can click
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…ary models The gateway's pre-agent session-hygiene compression killed the summary call at a fixed 30s wall-clock deadline (compression.hygiene_timeout_seconds), regardless of whether the summary model was hung or merely slow. A reasoning model happily streaming a large summary was cut off mid-generation, the user got '⚠️ Context compression timed out after 30.0s', and a 300s failure cooldown left the session oversized — a doom loop for slow-but-healthy auxiliary models. Timeouts are now liveness-based instead of wall-clock-based: - agent/auxiliary_client.py: new thread-local aux_progress_hook. When installed (only by context compression today), the primary call_llm attempt streams (stream=True) and aggregates chunks back into a complete response, ticking the hook per chunk. The configured timeout then acts per stream read (idle) instead of as a total budget. Providers that reject streaming fall back to the plain non-streaming call; auth/payment/ rate-limit/transport errors propagate unchanged into the existing recovery chains. Codex Responses (per SSE event) and Anthropic Messages (per stream event, via the new create_anthropic_message on_stream_event callback) tick the same hook from inside their wire adapters. - agent/conversation_compression.py: CompressionCommitFence gains touch_progress()/seconds_since_progress(); compress_context() installs fence.touch_progress as the progress hook around the compress call. - gateway/run.py: the hygiene wait loop treats hygiene_timeout_seconds as an INACTIVITY budget — while the fence reports fresh progress the wait extends, bounded by the new compression.hygiene_total_ceiling_seconds (default 600s, clamped >= the idle budget) so a degenerate trickle stream still dies. The timeout warning now says the summary model produced no output, which is the only case that still triggers it. - config/docs: hygiene_total_ceiling_seconds added to DEFAULT_CONFIG and configuration.md; hygiene_timeout_seconds documented as inactivity-based. Tests: tests/agent/test_aux_progress_streaming.py (hook plumbing, stream aggregation incl. tool-call deltas and reasoning deltas, rejection fallback, ceiling kill, fence progress surface); two new gateway tests prove a slow-but-streaming worker survives past the fixed timeout (sabotage-verified: fails with the old fixed deadline) and a forever-trickling worker is still cut off at the ceiling.
…ion, not a stale closure Redirect/steer, regenerate, restore-checkpoint, edit-message, and change-cwd read `activeSessionId || activeSessionIdRef.current`, which prefers the closure-captured prop whenever it is non-null and only falls back to the ref once the prop is null. That precedence is backwards. The actions bag is a stable ref that wiring.tsx mutates in place (Object.assign), and the pane surfaces are memoized on that stable ref, so a surface does not re-render when the active session changes and keeps whichever closure was current when it last rendered. `activeSessionIdRef` is the authority: it is mirrored during render in use-session-state-cache, and submit.ts / use-session-actions pin it imperatively mid-flight without touching the source prop. The prop is stale by design. `cancelRun` in the same file already reads the ref exclusively and documents exactly this hazard. User-visible effect after switching chats: a typed correction was delivered into the previously focused conversation's live turn (the "session suddenly working on another chat's task" report), and rewinds truncated the wrong session's transcript — real data loss, since a truncating resubmit deletes history after the target ordinal. Nothing crosses over in stored state, which is why a DB/transcript audit of the affected session comes back clean. Also fixes the same defect in `changeSessionCwd`, where a stale target re-anchored another conversation's workspace, pointing that agent's terminal/file tools at the wrong project. The now-unused `activeSessionId` option is dropped from useCwdActions rather than left as a footgun for the next caller. Not changed, verified not affected: model-edit-submenu reads the runtime id via `useStore` (live subscription, re-renders on change), and use-composer-actions guards on `attachedSessionId === activeSessionId`, so a stale value there only skips a detach instead of writing cross-session. Tests: 4 regression cases pin each action to the current session when the prop and the ref disagree. Verified to fail against the pre-fix code (all four reported the stale `rt-abc123` instead of the current session), including a scripted revert of all four sites. Co-authored-by: Drew Donaldson <49219012+Automata-intelligentsia@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…st-actions adapters
The adapters wrapped every field in an arrow function, including the
optional ones. That makes an absent handler unconditionally truthy, and
several children gate on a handler's PRESENCE rather than just calling
it:
- onDismissError -> assistant-message.tsx renders the dismiss button
only when defined
- onRestoreToMessage -> thread/index.tsx gates the restore-confirm flow
- onTranscribeAudio -> use-voice-recorder / use-voice-conversation gate
recording on it
- onLoadMoreMessaging / onLoadMoreProfileSessions -> sidebar paging
So the adapter would paint a dead dismiss button and let voice recording
proceed into a no-op transcription path even when the controller had
deliberately left those handlers off.
Wrap an optional field only when it is currently present, and re-read the
latest value inside the wrapper so the stale-closure fix still applies.
Presence is stable for a given actions object (the controller mutates
fields in place rather than toggling a handler between defined and
undefined), while the closure is what churns — which is exactly what the
indirection re-reads.
Adds two regression tests: absent optional handlers stay undefined, and a
present optional handler still late-binds to the latest closure. The
first was verified to fail against the unconditional-wrapper form.
…ms/clarify) + react ack lifecycle (NousResearch#71404) - RelayAdapter.send_exec_approval / send_slash_confirm / send_clarify: override the base text fallbacks with ONE platform-abstract `prompt` op (connector renders Discord components / Telegram inline keyboards / Slack Block Kit / WhatsApp buttons+lists). Option sets mirror the native adapters exactly (once/session/always/deny with the same allow_session/allow_permanent/smart_denied gating; once/always/cancel; choices + Other). Clarify option ids are positional (c0..cN/other) — choice text is arbitrary UTF-8, callback budgets are 64 bytes. - Pending-prompt registry: gateway-minted 8-hex prompt ids → {kind, session_key, extras}; one answer wins, lazy expiry, unanswered prompts swept opportunistically. Wire timeout_s stays advisory. - _consume_prompt_response (wired into _on_inbound AND the Discord passthrough lane): routes answers to the SAME primitives the native button handlers call — tools.approval.resolve_gateway_approval, tools.slash_confirm.resolve, tools.clarify_gateway resolve/mark_awaiting_text — then acks in-channel. Unknown/expired ids fall through as command-shaped text (typed-reply degradation, the relay's analog of the native 'approval expired' edit). - Discord type-3 stub replaced: an hp1:<prompt>:<option> custom_id decodes to a structured prompt_response (codec mirrored from the connector's promptCodec); foreign custom_ids keep the legacy best-effort text shape. - MessageEvent.prompt_response field + ws_transport wire parsing (additive). - react ack lifecycle: on_processing_start/complete → `react` ops (👀 → ✅/❌, remove-then-add), op-gated on supported_ops, best-effort by contract (a react failure never touches the turn). - Op gating throughout: a connector not advertising `prompt` gets success=False from send_exec_approval/send_slash_confirm (run.py's text fallback takes over — same contract as a failed native button send) and the base numbered-text clarify; `react` silently no-ops. - docs/relay-connector-contract.md §4: prompt / prompt_response / react semantics (callback token, budgets, authorization-parity, foreign-id behavior, per-platform react mappings). - tests: tests/gateway/relay/test_relay_interactive.py (19) — option-set rendering + gating matrices, registry consume-once/expiry, resolver routing for all three kinds (monkeypatched primitives), fall-through cases, Discord hp1 decode + foreign-id shape, react lifecycle (success/failure/cancelled), op-gated/best-effort react. Cross-repo pair: gateway-gateway 'Phase 3 interactive' PR (prompt/react senders on all four lanes + interaction ingest).
Some OpenAI-compatible endpoints — notably Tencent Copilot (copilot.tencent.com) — only accept streaming chat requests; any non-streaming call returns HTTP 400 (code 11101, 'Non-stream chat request is currently not supported'). The main conversation loop already streams, so interactive chat works, but every auxiliary task (title generation, compression, web extraction) used the non-streaming path and failed on each call. _provider_requires_stream() detects stream-only endpoints (copilot.tencent.com built in, plus user-configurable auxiliary.stream_only_base_urls substring markers in config.yaml). Matching sync auxiliary calls route through _create_with_progress (force_stream=True) and async calls through the new _acreate_with_stream, aggregating the chunk stream — including tool-call deltas and reasoning deltas — into a complete response via the shared _ChatStreamAccumulator. Salvaged from PR NousResearch#60686 by @kudi88 onto the progress-aware streaming machinery from NousResearch#71508, addressing both sweeper-review gaps: the async path now consumes the stream with 'async for' (awaiting create() and iterating synchronously raised on AsyncOpenAI streams), and tool-call deltas are reassembled instead of dropped (MCP passes tools= through call_llm). Under force_stream there is no silent non-streaming retry — a stream-only provider rejects those by definition, so the original error surfaces to the normal recovery chains.
The progress-hook streaming from NousResearch#71508 only activated when a CompressionCommitFence was present (gateway session hygiene). CLI /compress and in-loop auto-compression still used the plain non-streaming summary call, where the SDK timeout is inactivity-based — a byte-trickling provider that keeps the connection alive could outlive auxiliary.compression.timeout indefinitely (the gap NousResearch#69192/NousResearch#41397 were built to close). Fenceless compression callers now install a no-op progress hook, which routes their summary call onto the same streamed path: the configured timeout acts on inactivity (slow models finish instead of being cut off mid-generation), and a degenerate trickle stream is bounded by the streamed total ceiling (max(600s, 4× the task timeout)) instead of running forever. No config knob needed — the ceiling machinery ships with the streaming layer and applies uniformly. Supersedes the opt-in wall-clock deadline approaches in PR NousResearch#69192 (@JabberELF) and PR NousResearch#41397: same guarantee (bounded total compression wall time even while bytes move) without a daemonized watchdog thread or a new config surface, and without punishing slow-but-healthy models.
`sync_skills()` keys the bundled manifest by frontmatter name but computes the destination from the bundled path. When upstream renames or recategorizes a skill, the manifest key still matches while the new dest does not exist yet, so the loop fell into its "in manifest but not on disk" branch and misread the skill as user-deleted: the user's copy was stranded at the old path forever and never received another update. Three skills hit this in the July 2026 reorg (computer-use, evaluating-llms-harness, serving-llms-vllm) — silently frozen at their pre-rename content on every machine that ran `hermes update`. Recovery only moves a stale copy when it is byte-identical to the origin hash recorded the last time sync wrote it, which proves the directory is ours rather than the user's work. User-modified copies are kept in place with a warning, hub-installed paths are never touched, and a genuine deletion (no copy anywhere on disk) is still respected. - tools/skills_sync.py: add _recover_renamed_skill() plus the _index_active_skills() / _read_hub_install_paths() indexes; call it before classification and report moves via a new `relocated` key. - hermes_cli/main.py: surface relocations in both `hermes update` skill sync reporting sites. - tests: 4 cases covering relocate, user-modified preservation, hub-installed exemption, and genuine-deletion respect.
`/goal status` reported "No active goal" for a goal that was live: the desktop's slash pipeline resolved its target session differently than the submit pipeline, so the command ran against a different session than the chat on screen. `/goal` state is persisted per-session in SessionDB (`state_meta` key `goal:<session_id>`). slash.ts resolved with a bare `hint || activeRef || createBackendSessionForSend()`, so whenever the runtime binding was momentarily absent — profile swap, reconnect, orphan-reap, request timeout — it silently MINTED A NEW SESSION and ran there. submit.ts already handles this case by resuming the routed stored session on its owning profile (NousResearch#55578, NousResearch#67603). Extract that ladder into one shared resolver both pipelines use, per the "one resolver owns each policy" rule in apps/desktop/AGENTS.md. This fixes the whole class, not just `/goal`: every exec/rpc slash command (`/usage`, `/status`, `/tools`, …) had the same hole. A targeted durable conversation whose runtime cannot be rebound now returns null instead of forking the chat — reporting that a command could not run beats running it against the wrong session.
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…cess2 HANDLE truncation (NousResearch#71381) * fix(update): bind IsWow64Process2 HANDLE and fall back to GetNativeSystemInfo The NousResearch#71218 OS-native probe still rejected correct ARM64 Desktop rebuilds on Windows-on-ARM when ctypes truncated GetCurrentProcess()'s pseudo-handle and IsWow64Process2 failed with ERROR_INVALID_HANDLE, falling through to the lying PROCESSOR_ARCHITECTURE=AMD64 value. Type the HANDLE correctly and use GetNativeSystemInfo before the env-var fallback. * test(update): cover WoA IsWow64Process2 handle failure and system-info fallback Pin the residual NousResearch#71218 shape where IsWow64Process2 returns FALSE, the env arch lies as AMD64, and GetNativeSystemInfo must still report ARM64 so the integrity gate accepts a correctly-built ARM64 Hermes.exe.
…sh header stops echoing long args
Four symptoms from the same /goal flow on desktop:
- Typing '/goal <text>' sealed the command into a directive chip on
Space because /goal was registered without args:true, so the goal
prose rendered awkwardly after a pill. The registry row now matches
/personality and /tools: the arg stays editable text.
- The slash status header echoed the ENTIRE invocation ('slash:/goal
<whole goal prose>') in mono, immediately above the backend notice
that repeats the goal text again, and the kickoff user bubble that
repeats it a third time. The header now carries just the command
token (slash:/goal).
- When the session was busy, handleDispatch rendered 'session busy'
and dropped the dispatch message. For /goal that message is the
kickoff prompt, and the backend has ALREADY set the goal by then —
the goal existed but the agent never heard about it, and later turns
looked goal-unaware (NousResearch#63352). The busy path now queues the kickoff
on the composer queue: it sends on settle and is visible/editable in
the queue panel meanwhile. Falls back to the old message if the
queue rejects the entry.
- A slash command issued on a fresh draft created the backend session
with no preview, so the sidebar row sat as 'Untitled session' —
and when the kickoff was dropped, auto-title never fired either
(it needs a completed user->assistant exchange). ensureSessionId now
seeds the preview with the typed command.
Tests: registry row contract, busy-path queueing (kickoff neither
sends mid-turn nor vanishes), and the header-token assertion.
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…g + caching the worktree prune `_prune_stale_worktrees` runs synchronously before the banner on every `hermes -w` launch and shells out to git several times per candidate worktree. On a repo with dozens of accumulated worktrees this dominated startup: measured 18.5s of a 20.6s cold start, 11.6s on warm repeats. The `git cherry` patch-equivalence probe was the single largest cost (9.4s of 11.5s across 24 trees). It is also pure waste on repeat runs: a tree preserved because it holds unpushed work is re-diff-hashed on every launch, forever, always reaching the same verdict. On this repo 19 of 24 aged trees were unreapable, so ~11s per launch bought zero reaps. Two changes, both verdict-preserving: - Split the loop into a stat-only age filter, a parallel read-only classification phase (thread pool, bounded to min(8, cpu_count)), and a serial mutation phase. Only reads are concurrent; unlock/remove/ branch -D stay ordered, so log output and removal order are unchanged. A pool failure falls back to serial rather than blocking startup. - Memoize `git cherry` verdicts to $HERMES_HOME/cache/worktree_merge_verdicts.json, keyed on the exact `(base_sha, head_sha, max_ahead)` range the verdict was computed from. Because that key is the complete input to the git call, a cache hit is identical to recomputation by construction: if either ref moves, the key changes and real git runs again. Bounded to 1000 entries, written atomically, and a corrupt/hand-edited cache degrades to recomputation. Measured on a 44-worktree repo (24 aged candidates): _prune_stale_worktrees before 11.5s after 2.05s cold / 0.42s warm hermes -w to banner before 13.9s after 1.79s Work-preservation is unchanged: all 44 worktrees survived, and every dirty/unpushed/live-locked guard still fires. Verified the 24 real-tree verdicts are byte-identical across serial, cold-cache, and warm-cache runs. Tests: 75/75 in tests/cli/test_worktree.py (67 existing + 8 new). The new cache tests were sabotage-verified — swapping the exact-sha key for a naive path-only key makes two of them fail by deleting a worktree that had gained unmerged work, which is the data-loss case the key prevents.
…load (NousResearch#19505) The chatgpt.com/backend-api/codex backend 400s on every tool_choice shape for the hosted image_generation tool — it looks up tool_choice as a function name and never recognizes hosted-tool entries. Removing the field from _build_responses_payload() lets the host model decide; the instructions field nudges it toward the tool. Salvaged from PR NousResearch#19979 (originally targeted the old client.responses.stream call, which no longer exists on upstream/main; the live request now flows through _build_responses_payload + httpx in _collect_image_b64).
…t limit The Codex image backend rejected our own request shape for every account, and we then translated that rejection into "Image generation is not enabled for the current Codex account. Switch the image provider to OpenAI API key, FAL, or xAI." — telling every affected user to abandon a provider that had never actually been tried. That message is why this reads as a setup failure rather than a bug: the wire error was replaced with a confident, wrong diagnosis. Removes the classifier and its exception, so any HTTP failure surfaces verbatim. The paired request-shape fix (previous commit) is what makes the 400 stop happening; this commit makes the next one diagnosable. Also fixes error-body truncation: bodies were head-truncated at 500 chars, and Codex error payloads can carry hundreds of bytes of leading metadata, so the user got a wall of padding and no message. _summarize_error_body() prefers the parsed error.message and falls back to a truncated raw body. Docs: drop the unqualified image-to-image claim for the Codex backend and note that the hosted tool call cannot be forced, so it is best-effort. Verified E2E against a local fake Codex backend: success path writes a real PNG with no tool_choice on the wire; the 400 path now returns api_error carrying "Tool choice 'image_generation' not found in 'tools' parameter" (148 chars) instead of the entitlement message. Sabotage run confirms all 4 regression tests fail when the old behavior is restored. Refs NousResearch#19505, NousResearch#49008, NousResearch#31335.
A failed `hermes sessions repair` printed "keep state.db and the backup" and stopped, so a user whose sessions had vanished had no way to discover that a non-destructive recovery path exists — the reported dead end. The failure branch now names the next command, read-only step first, seeded with the backup path it just preserved. Covered by a CLI-surface regression test that drives the real subprocess against an unrepairable database.
…air still resolves vulnerable Fixes NousResearch#71250. `hermes update` could not repair the embedded Python runtime's vulnerable SQLite (WAL-reset bug range 3.7.0-3.51.2, except backports 3.50.7/3.44.6) on installs where `uv python install <minor>` (bare request, e.g. "3.11") resolves to an older cached/indexed patch that still links a vulnerable SQLite build, even though a newer non-vulnerable patch on the same minor line is available and known to uv. The smoke test correctly rejected the vulnerable candidate every time, but the provisioner gave up immediately after one attempt, leaving the repair permanently stuck in the same failing loop on every `hermes update` run. Implements option D from the issue (query the index, retry with an explicit newer patch), which the reporter identified as cleanest: - New `_list_available_patches()`: runs `uv python list <minor> --all-versions --only-downloads --output-format json --no-config`, filters to cpython/default-variant entries (excluding pypy/graalpy), and returns known patch versions newest-first. Fails safe (returns []) on any network/parse error. - Refactored the single install+find+probe cycle out of `_install_safe_python_generation()` into `_attempt_install_generation()`, reusable per attempt with its own generation directory (so a rejected candidate's files are fully cleaned up before the next attempt, matching the existing --reinstall semantics). - `_install_safe_python_generation()` still tries the bare minor-line request first (preserves the original comment's rationale: for a given exact patch, python-build-standalone may have no artifact with fixed SQLite at all). If that resolves vulnerable, it now queries `_list_available_patches()` and retries with explicit newer patches, newest-first, bounded to `_MAX_PATCH_RETRIES` (5) attempts -- each attempt is a real download+install+probe cycle, so the cap keeps worst-case repair time bounded. Sanity-checked `_list_available_patches()` against the real `uv` binary (0.11.7) in this environment: correctly parses live `uv python list --all-versions --output-format json 3.11` output, returns 14 patches sorted newest-first starting at 3.11.15. 9/9 new tests pass (retry succeeds with a newer patch, exhausts gracefully when every known patch is vulnerable, empty patch list degrades to None without crashing, retry count is bounded, plus direct JSON-parsing unit tests for realistic/malformed/empty uv output); 47/47 in the full tests/hermes_cli/test_managed_uv.py file (including the 3 pre-existing tests for the original bare-minor success path, confirming no regression there).
…ousResearch#56580) Follow-up to the main fix in this PR. rodriguez46p-ui's review on the equivalent NousResearch#56632 (closed stale) flagged that only the auto-subscribe path in tools/kanban_tools.py was covered; the same gap existed in two more call sites: - gateway/slash_commands.py: the `/kanban create` slash command auto- subscribes the calling session but didn't pass chat_type. Read it from source.chat_type (already available on SessionSource). - hermes_cli/kanban.py: the `kanban notify-subscribe` CLI command now accepts --chat-type and threads it through. The dashboard plugin API (plugins/kanban/dashboard/plugin_api.py) still has the gap because the home_channel config schema doesn't carry chat_type — that's a follow-up that needs a config schema change. Verified: 258 tests pass on the kanban + session_context suites.
Strengthen the salvaged regression test to prove the end-to-end claim in NousResearch#56580/NousResearch#68874: a DM-created task's terminal wake must build the creator's ':dm:<chat_id>' session key via build_session_key(), not a group-scoped key that forks a fresh session. Sabotage-verified: reverting the watcher to the hardcoded chat_type='group' fails this test.
…update Capture each manually-started dashboard/serve process's argv before the stale-process kill (/proc/<pid>/cmdline on Linux, ps -o command= on macOS), then respawn it detached after the update — headless (--no-open) with output to logs/dashboard-restart.log under the active profile's HERMES_HOME. Supervised PIDs keep their systemd-unit restart; --stop stays a plain stop. Salvaged from PR NousResearch#41508 with scope fixes: serve matching preserved, profile- aware log path, restart only on the update path (restart_managed=True).
11 new tests: owning-unit restart + dedupe + failure hint (NousResearch#68934), argv capture/respawn + --no-open + failure fallback (NousResearch#40449), /proc and ps cmdline capture, --stop never restarts. All fail without the fix; 26 pre-existing tests unchanged.
Windows-footguns lint: subprocess text=True without encoding= decodes via locale.getpreferredencoding(). Match the file's house style.
'Refreshing cua-driver (Computer Use)...' could hang for minutes on Windows: when the driver's native check-update verb returned an indeterminate result (old driver without the verb, offline, GitHub rate-limited, or the probe timing out), install_cua_driver(upgrade=True) fell through to the full upstream installer — a silent, output-captured run with a 660s ceiling, plus install.ps1's 600s concurrency-lock wait on Windows on top. Every 'hermes update' paid that cost. Two changes: - install_cua_driver() grows require_confirmed_update: with it set, an indeterminate check keeps the installed version and returns fast, printing the force path (hermes computer-use install --upgrade). 'hermes update' passes it; the explicit --upgrade CLI keeps the old fall-through so a force refresh still works when the check can't answer. - cua_driver_update_check() default timeout is now 25s on Windows (8s unchanged on POSIX): first-spawn of the exe under Defender / SmartScreen routinely exceeds 8s, and a false timeout is exactly the indeterminate result that used to trigger the multi-minute reinstall.
Two problems, both found by distrusting the harness's own numbers. 1. The scenario slept a fixed 1s after mounting tabs, then recorded. Boot and session hydration are not reliably done by then, so a variable amount of unrelated work landed inside the measurement window. Three back-to-back runs on identical code spread 2.2x on total_renders and 3.8x on wasted_renders — wide enough that a single-run before/after delta could be mostly noise. Replaced with a quiesce gate that waits for commits to hold still before recording, and reports 'quiet:N' or 'timeout:...' so a contaminated run is visible instead of silent. 2. The counter attributed a context-driven re-render as 'wasted', which pointed at memo() as the fix when memo cannot block context at all. Adds contextChanged via the fiber's context dependency list, and excludes it from wasted. The gate also turned up a finding worth more than the fix: with five busy tiles and NO driver running, the renderer still commits ~18x/sec. The report now names the cascade roots (own state changed, props did not) rather than leaving them to be guessed at — Streamdown re-renders itself 105 times while idle, which is what drives Block/Ct.
…anscript-renders fix(desktop): make render-churn measure streaming, not boot churn
_tool_ctx switched to build_tool_label in NousResearch#55166, so every tool.start carried an already-phrased string ("Running sleep 70 + 2 commands"). Both clients then apply their own verb on top: the TUI renders Terminal("Running sleep 70 + 2 commands") and the desktop row reads "Ran Running sleep 70 + 2 commands". The friendly labels stay where they belong — the CLI spinner and the gateway progress line, which compose verb + preview at their own call sites.
The expanded terminal row printed the same string as the title, as the `$` transcript, and again as detail. shellCommand preferred the backend's display preview over the real `command` arg, so the transcript showed a summary of what ran rather than what ran; and a terminal call with no output fell through to the generic fallback, which echoes args.context under a transcript already showing it.
… paths - hermes dashboard --status now verifies each matched PID is alive AND bound to a listening socket before reporting it, so stale PIDs and the desktop app's IPC-only 'serve --port 0' backends no longer masquerade as running dashboards (NousResearch#58578). - The git and Windows ZIP update paths share one _finish_dashboard_update_cleanup(), so the ZIP fallback gets the same stop/restart reporting. - _kill_stale_dashboard_processes returns a structured {matched, killed, failed, unrecovered} result; the explicit was-stopped notice fires only for processes that could NOT be auto-restarted, meshing with the auto-respawn from NousResearch#72192.
…y owner; restore stdio startup + WSTransport tests Follow-up to @LionGateOS's NousResearch#72135 salvage: - Route ensure_mcp_discovery_started through hermes_cli.mcp_startup's shared owner instead of a hand-rolled bare thread, keeping the start lock, retry-after-zero-connected allowance, and interactive-OAuth suppression. The shared owner now captures the caller's context-local HERMES_HOME override and re-installs it inside the discovery thread, so discovery reads the selected profile's mcp_servers (NousResearch#67605). - Restore stdio TUI startup discovery in main() and the _mcp_discovery_enabled retry gate in wait_for_mcp_discovery, both dropped by the original branch. - Restore the 3 WSTransport regression tests (send serialization, cross-batch ordering, drained-token ordering) deleted by the PR. - Harden the profile-scoped discovery test against sibling-state leaks.
…edupe fix(tools): stop the inline tool row stuttering its verb and repeating the command
Copy gateway notification subscriptions from parent tasks to child tasks created by create_task(..., parents=...), link_tasks(), and decompose_triage_task(). Inherited subscriptions start at the child's current event cursor, so linking an existing child does not replay pre-link task events.
…action queue Re-express the Magi compaction overlay on top of upstream's reworked compression routing (0054c7e). Subscription-only route guard (agent/anthropic_adapter.py, auxiliary_client.py): classify Anthropic credentials as OAuth-subscription vs metered and, on the compaction route, scan past metered-shaped candidates so context never routes through a metered openai-api / Gemini key — only ChatGPT Pro (openai-codex) and Claude Max (anthropic OAuth). Preserves the hard privacy constraint through upstream's new route-scoping model. Cross-session compaction queue (agent/compaction_coordinator.py, compaction_metrics.py, conversation_compression.py): a root-scoped SQLite leased-semaphore that bounds concurrent compaction summarisation calls across all sessions/processes. Ships disabled by default (inert). Reconciled onto upstream's new automatic_compaction_status_message formatter API — the status is computed via upstream's helper but the emit is deferred until AFTER queue admission, so a merely-denied session never narrates a compaction it won't run. config/kanban diagnostics (hermes_cli/config.py, kanban_diagnostics.py) expose queue config + read-only slot load. Spec in docs/plans. Test fix: test_compaction_queue_wiring.py's real-agent acquired-path test built a bare MagicMock context_compressor; null get_automatic_compaction_status_message so it falls back to the default COMPACTION_STATUS the way a real ContextEngine does, instead of returning a MagicMock and defeating the truthful-status assert. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… timeout Reconcile the Magi local-STT overlay onto upstream's config-defined STT provider work. - pyproject.toml + uv.lock: exact-pin ctranslate2==4.7.2 (faster-whisper only floors it at >=4.0; an unpinned float can be ABI-incompatible with the host CUDA/cuDNN and silently drop STT to CPU/int8). Kept in lockstep with LAZY_DEPS['stt.faster_whisper'] — parity enforced by tests/test_project_metadata.py. - tools/lazy_deps.py: same ct2 pin for the lazy-install path; exclude the durable STT target from the core-only dependency constraint. - tools/transcription_tools.py: warm the faster-whisper model in-process and raise the transcribe request timeout so first-token latency doesn't abort. - hermes_cli/web_server.py: pre-warm the local STT model in a worker thread on startup (no-op when local STT isn't the active provider; never blocks/fails). - i18n: add STT provider/config keys (en/ja/zh/zh-hant/types) alongside upstream's config-defined provider list. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reconcile the Magi session-window overlay onto upstream's window-state / transcript-virtualization rework — layer Magi behavior on top, don't clobber upstream fixes. KEEP (no upstream equivalent): - Gateway-gated reopen QUEUE: renderer emits hermes:window:gateway-ready from reportPrimaryGatewayState; main gates the next window-open on it (correlated by webContents id) so restored sessions connect one at a time. - Durable window-set across quit/bulk-close: appIsQuitting + schedule-not-flush + empty-guard so the window set survives a quit instead of being wiped. - Empty/silent voice-capture guard (use-voice-recorder.ts): drop empty/silent captures before they hit transcription. RECONCILE: - thread/list.tsx: replace isSecondaryWindow with the session-aware isHeaderlessSecondaryWindow(hasSession) predicate for the titlebar-gap / opaque drag strip, leaving upstream's renderBudget/visibleGroups transcript virtualization untouched. - window-state.ts + main.ts: fold onto upstream's persist-zoom / save-on-first- show (NousResearch#56726) rather than double-persisting. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ud search guard - hermes_cli/relaypipe.py + main.py: `hermes handoff` subcommand (triad relay pipe) — capture writes one assistant message verbatim to a file and prints its path so Hermes relays a handle rather than re-emitting the body; carries the lean-artifact lint and proposed/acknowledged/locked decision gate. Artifacts land in .triad/ (gitignored — relay scratch, never repo content). - main.py exit-code contract: main() now RETURNS its exit code (bool excluded from int propagation so True/False isn't reinterpreted as a code). Both the __main__ block and the installed console_script wrap it in sys.exit(main()), so the process still exits non-zero while in-process callers (the CP017 dispatch regression test) can observe the code — fixes `handoff lint --strict` exiting 0 despite a violation. - tools/file_operations.py: guard Windows cloud roots (OneDrive/iCloud/Creative Cloud) in file search so a cloud-synced tree can't stall or over-walk. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…hold The "queues an Enter-submitted draft while compaction is active" E2E timed out in CI at waitForHeldCompletion(): the mock never saw a summarization prompt because automatic pre-API compaction never fired. Root cause is fixture threshold drift, not an app regression. The trigger payload 'E2E_TRIGGER_AUTOMATIC_COMPACTION '.repeat(1500) is ~12,375 tokens (estimate_tokens_rough: 33 chars/unit, ~4 chars/token). The effective pre-API threshold is compression.threshold_tokens=22,000 (the small-context 0.75 floor raises the ratio to ~48k, but the absolute cap clamps it back to 22k). At 12.4k the payload only crossed 22k by borrowing ~10k from the ambient system prompt (skills index + tool schemas); on current upstream that prompt shrank below the needed size, so request pressure stayed under 22k and no summary was issued. Grow the trigger to repeat(4000) (~33,000 tokens) so it clears the 22k threshold on its own weight (+11k margin) while staying well under the 64k window — system-prompt size can no longer un-trigger it. Setup messages remain ~34 tokens each, so they still get normal replies. No skip/timeout/guard change; the queue-while-compacting invariant and assertions are untouched. Verified with the real estimator: NEW payload 33,000 > 22,000 threshold and < 64,000 window; setup 34 < 22,000.
CP007D's subscription-only compaction guard fails closed for a localhost/mock
provider, which blocks the Desktop E2E (session-compression-and-queue-stop) and,
more broadly, forbids a local inference server (ollama/llama.cpp/vLLM) from
running compaction even though localhost is not metered third-party cloud egress
— the exact leak the guard exists to prevent.
Add a deliberate, DEFAULT-OFF product opt-in
(auxiliary.compression.allow_local_loopback_route). With it unset, behavior is
byte-identical to today (loopback/custom rejected exactly like a metered route).
With it set, compaction may ALSO run on a client whose resolved base_url host is
a LITERAL loopback address.
Guard (agent/auxiliary_client.py):
- _is_loopback_host: strict — localhost / 127.0.0.0/8 / ::1 only. Rejects
public hosts, private-LAN (10/8, 172.16/12, 192.168/16), 0.0.0.0/wildcard,
and hostname spoofing (base_url_hostname parses host, so path/userinfo tricks
like 127.0.0.1.evil.com do not match).
- _loopback_egress_is_proxy_safe: Codex's proxy concern — a proxy env var could
forward a localhost URL off-box. Fails closed when a proxy is set unless
NO_PROXY (or *) bypasses the loopback host.
- _loopback_compression_route_admitted: opt-in AND loopback AND proxy-safe.
Wired into the single runtime screen (_compression_client_allowed) only on a
None classification, so cloud/metered/aliased routes are untouched; logs a
warning on admission. _client_compression_route stays a pure client-shape
classifier (no config reads).
- validate_configured_compression_routes (startup) mirrors the runtime gate via
_configured_provider_base_url so a loopback provider is valid only when opted
in and proxy-safe; error text points at the opt-in.
Config (hermes_cli/config.py): allow_local_loopback_route: False documented in
the auxiliary.compression block with the on-box/threat-model rationale.
Desktop E2E: opt into the exception, switch the compaction route from the
undefined `custom` to the defined loopback `mock` provider so a real client is
built and screened, and set NO_PROXY so the loopback route is provably
proxy-safe. All held-summarization / Queue message / 1 Queued / no-steer
assertions unchanged; CP007H repeat(4000) threshold fix retained.
Tests (tests/agent/test_compression_route_loopback.py, 60 cases): default-closed;
opt-in admits only proxy-safe literal loopback; public/cloud/private-LAN/
wildcard/spoofed hosts rejected even with opt-in; provider-name aliasing to
non-loopback still rejected; proxy env safety; opt-in flag coercion +
fail-closed on unreadable config; startup validation agrees with the runtime gate.
…ision
Codex CP007L: _loopback_egress_is_proxy_safe intersected NO_PROXY against a
generic loopback token set {localhost,127.0.0.1,::1,host}, so it admitted a
route whose ACTUAL host the client would still proxy — e.g. base_url host
127.9.9.9 with NO_PROXY=localhost,127.0.0.1,::1 was called "safe" while
process_bootstrap._get_proxy_for_base_url("http://127.9.9.9:5000/v1") returns
the proxy. That could route compaction content off-box despite the on-box log.
Delegate proxy-safety to the SAME function the auxiliary HTTP client uses:
_loopback_egress_is_proxy_safe(base_url) now returns
`_get_proxy_for_base_url(base_url) is None`. That resolves the proxy for the
exact request URL via urllib.proxy_bypass_environment over NO_PROXY, so the
guard and the client agree by construction — no NO_PROXY-token false positive is
possible. Fails closed on any error / un-importable bootstrap. The signature
takes base_url (not a bare host) so the exact host+scheme are matched; both
callers (runtime _loopback_compression_route_admitted and startup
validate_configured_compression_routes) pass the full effective base_url.
Non-loopback/cloud/metered rejection and the default-off posture are unchanged.
Tests (+8, 68 total): Codex's mismatch cases now fail closed — 127.0.0.1 with
NO_PROXY=localhost, 127.9.9.9 with NO_PROXY=127.0.0.1, ::1 with an IPv4-only
bypass — plus the safe positive (127.0.0.1 with NO_PROXY=localhost,127.0.0.1,::1
admits), a structural test asserting guard == _get_proxy_for_base_url decision,
and runtime-gate + startup-validation integration variants of the mismatch.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Reconciles the Magi Hermes overlay onto the verified upstream base used for CP007D:
NousResearch/hermes-agent@0054c7edcb332829ed02d105fa0d6e39b66e227a08cd9d32aae2889d2d9408fefcc717d5736b0cfdmagi/reconcile-2026-07-26This PR intentionally syncs the Magi fork forward from its older
origin/mainand applies the Magi overlay commits.Local commits
Source-level review / gates
approve_with_notes, no blocking issues.Verification evidence
Hermes independent verification after Claude CP007D exited:
External/independent packed-app B+ repro CP007F:
B+ verdict
The create-timeout-then-retry orphan path was not exercised because it would require a hermetic backend hook/source delay; this is a follow-up test, not a push blocker.
Caveats / follow-ups
NousResearch/mainadvanced after CP007D's frozen base; this PR preserves the reviewed CP007D head instead of silently rebasing.tests/agentand desktop installer/e2e matrices were not run.session.createto test create-timeout-then-retry orphan behavior.Safety
HERMES_HOME, throwaway Electron userData,HERMES_DESKTOP_IGNORE_EXISTING=1, and isolated backend Python.