Skip to content

Reconcile Magi overlay onto upstream main (CP007D) - #2

Merged
Nemquae merged 2905 commits into
mainfrom
magi/reconcile-2026-07-26
Jul 27, 2026
Merged

Reconcile Magi overlay onto upstream main (CP007D)#2
Nemquae merged 2905 commits into
mainfrom
magi/reconcile-2026-07-26

Conversation

@Nemquae

@Nemquae Nemquae commented Jul 27, 2026

Copy link
Copy Markdown

Summary

Reconciles the Magi Hermes overlay onto the verified upstream base used for CP007D:

  • Base used for reconciliation: NousResearch/hermes-agent@0054c7edcb332829ed02d105fa0d6e39b66e227a
  • Head: 08cd9d32aae2889d2d9408fefcc717d5736b0cfd
  • Branch: magi/reconcile-2026-07-26
  • Local commits on top of the CP007D upstream base: 4

This PR intentionally syncs the Magi fork forward from its older origin/main and applies the Magi overlay commits.

Local commits

08cd9d32a Magi overlay: handoff relay pipe, CLI exit-code contract, Windows cloud search guard
40cb60e99 Magi overlay: desktop session-windows + voice-capture guard
ac4019b35 Magi overlay: local faster-whisper STT — ct2 pin, warm model, request timeout
c6d489416 Magi overlay: subscription-only compaction guard + cross-session compaction queue

Source-level review / gates

  • Claude CP007D implementation checkpoint: ready for review.
  • Codex persistent reviewer: approve_with_notes, no blocking issues.
  • Gemini/agy: no verdict due quota exhaustion; Lake explicitly waived Gemini for CP007D. This is recorded as a user waiver, not reviewer approval.
  • Claude CP007E answered Codex notes/questions and closed source-level review with caveats.

Verification evidence

Hermes independent verification after Claude CP007D exited:

git diff --check => pass
git diff --check upstream/main..HEAD => pass
uv lock --check => pass
focused compaction/route pytest => 122 passed
focused handoff/search/project pytest => 49 passed
Electron session-windows/window-state vitest => 43 passed

External/independent packed-app B+ repro CP007F:

verdict: passed
pack_result: npm run pack exit 0; fresh win-unpacked/Hermes.exe rebuilt on this HEAD
startup_first_submit: passed
new_session_first_submit: passed
rapid_create_switch_orphan: passed
large_session_render_scroll: passed
create_timeout_then_retry_orphan: not_exercised
DB: 7 sessions, 35 messages, no empty orphans, all expected prompts persisted, message_count rows match

B+ verdict

first_submit_dropped: resolved
session_divergence_orphan_create: likely_resolved
idempotent_create_handoff: SUPERSEDED

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

  • Upstream NousResearch/main advanced after CP007D's frozen base; this PR preserves the reviewed CP007D head instead of silently rebasing.
  • Full tests/agent and desktop installer/e2e matrices were not run.
  • Add the Electron gateway-ready reopen test recommended by Codex.
  • Optional future hermetic backend hook: delay JSON-RPC session.create to test create-timeout-then-retry orphan behavior.
  • Windows/CUDA faster-whisper device-path and long-audio timeout were not verified in packed app; mock provider was used for B+.

Safety

  • No live install was touched during reconciliation, review, push, or B+ repro.
  • CP007F packed repro used throwaway HERMES_HOME, throwaway Electron userData, HERMES_DESKTOP_IGNORE_EXISTING=1, and isolated backend Python.

teknium1 and others added 30 commits July 25, 2026 07:09
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).
张满良 and others added 28 commits July 26, 2026 13:42
…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.
@Nemquae Nemquae added the ci-reviewed CI-sensitive changes reviewed; rerun label gate label Jul 27, 2026
@Nemquae
Nemquae merged commit c74b19d into main Jul 27, 2026
94 of 96 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-reviewed CI-sensitive changes reviewed; rerun label gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.