Twenty-odd desktop files import `@assistant-ui/core` directly, but it was
never in `dependencies` — it resolved only because react 0.15 pulled core
0.3.2 and npm hoisted it to the workspace root. react 0.14.24 wants core
^0.2.19, which nests under `react/node_modules`, so the undeclared imports
stop resolving and the build fails on `@assistant-ui/core/internal`.
Declare it so resolution doesn't depend on hoisting luck either way.
The 0.15 migration swapped the `useMessageRuntime()` hook for a plain
`useAui().message` accessor read. The hook subscribes and re-renders on
change; the accessor does not, so message components paint once and then
go stale until the window is reloaded. `useComposerRuntime().subscribe`
was widened to `aui.subscribe` the same way.
Restores the 0.14 call form, including the test mock whose added
`getState` stub let the API break through CI unnoticed.
The 0.15 bump rode along with the npm audit pass in #75037, but it was
not required by any advisory — `npm audit` reports 0 vulnerabilities with
0.14.24 pinned. It is a breaking major that costs us a working transcript,
so take the API stability instead.
Follows PR #75592: the notifier is no longer gated on
kanban.dispatch_in_gateway. Every gateway delivers events for
subscriptions owned by the profiles whose adapters it hosts; legacy
unstamped subscriptions go only through the confirmed dispatcher
lock owner. Adds a 'Multi-profile setups' subsection to the kanban
Gateway notifications docs (en + zh-Hans).
Review follow-up on #75102. The shadow substituted the sidecar whenever
the ``api_content`` key was merely PRESENT, but the wire only substitutes
a non-empty string sidecar on a user/assistant row (see
``turn_context.substitute_api_content``). For any other shape the sidecar
is popped and discarded while the clean ``content`` is sent -- so the
shadow dropped real content from the estimate and UNDERcounted, the
dangerous direction: compaction fires too late and the turn dies on a
hard context-length error instead of merely compressing early.
Gate the substitution on the same predicate, and cover the divergent
shapes (None, empty string, int, list, non-user/assistant role) with a
test that fails against the unconditional version.
Also rename the image test: it never carried a sidecar, so it was not
testing what its name claimed. It is a non-regression pin on the flat
per-image accounting that moved into ``_wire_message_shadow()``, and is
now named for that.
`api_content` is a SUBSTITUTE for `content`, not an addition to it.
`turn_context.substitute_api_content()` pops the sidecar and overwrites
`content` at every API-bound message-build site (the `api_messages` build
in `conversation_loop`, the max-iterations summary in
`chat_completion_helpers`, the chat-completions transport), so exactly one
of the two is ever sent to the provider.
The preflight estimator counted both, because both `_estimate_message_chars`
and `_estimate_message_tokens_without_images` walked every key of the
persisted dict with a single-entry denylist (`_anthropic_content_blocks`).
Any message whose sidecar differs from its clean stored content was counted
twice — exactly 2.00x on a 40KB sidecar.
The sidecar exists to keep the provider prompt-cache prefix byte-stable, so
it is written on precisely the long, cache-pinned messages where the
doubling hurts most. Because `estimate_messages_tokens_rough()` also feeds
the compaction threshold via `context_compressor` and `conversation_loop`,
the inflated estimate makes compression fire on phantom bytes.
Fix: substitute rather than sum, mirroring the wire. The two estimator
helpers had drifted into near-identical copies of the same shadow-building
loop, so this factors the shared logic into `_wire_message_shadow()` and
fixes the class once instead of patching one site and leaving the other.
Image accounting is unchanged: base64 payloads are still replaced with a
placeholder and charged at the flat `_count_image_tokens` rate, and the
`_multimodal` text_summary path is preserved.
Tests: three cases in `TestEstimateMessagesTokensRough` — sidecar equal to
content is counted once, a sidecar that DIFFERS is still counted (a lower
bound, so it fails if the field were dropped rather than substituted, which
would undercount the real request), and a sidecar cannot smuggle raw base64
past the flat image rate.
Verified on Linux (Python 3.11): 53 passed in
tests/agent/test_model_metadata.py, 57 passed with
tests/agent/test_context_breakdown.py, 656 passed / 3 skipped across the
compression/context/token/estimate/prune surface of tests/agent.
Mutation-tested: reverting the substitution fails the new equality test.
`scripts/check-windows-footguns.py` is not applicable — no file I/O,
process management, terminal handling, subprocesses, or signals.
The embedded Hindsight daemon's profile env file carries the plaintext
HINDSIGHT_API_LLM_API_KEY but was written via bare write_text(), leaving
it with umask-derived (typically world-readable) permissions.
- Create/truncate the file via os.open(..., 0o600); chmod a pre-existing
file to 0600 BEFORE writing new secret bytes.
- Post-write validation on POSIX: verify 0600, retry chmod, and raise if
the file still isn't owner-only.
- If validation fails, unlink the secret file so a plaintext key is never
left behind with unverified permissions.
- Regression tests under tests/plugins/ for fresh-write mode, tightening a
pre-existing 0644 file, and cleanup on validation failure.
Narrowed reimplementation of #74236 confined to plugins/memory/hindsight/;
the core utils.py atomic-replace opt-out from the PR was dropped.
Co-authored-by: carrion256 <carrion256@proton.me>
Follow-ups to the previous commit (#74414 by @webtecnica, re #74373):
- When distribution_owned is OMITTED, restore the legacy contract: every
staged entry outside USER_OWNED_EXCLUDE is copied. The cherry-picked
filter consulted owned_paths(), which silently narrowed omitted-list
distributions to DEFAULT_DIST_OWNED and dropped undeclared payload
(extra top-level files/dirs existing distributions legitimately ship).
- Make explicit allowlists path-aware so documented nested entries like
skills/research/ and cron/digest.json select exactly that subtree/file
instead of being dropped by the top-level name comparison. Traversal
segments (.., absolute) and USER_OWNED_EXCLUDE roots are still rejected.
- Regression tests: omitted-list legacy behavior + nested-path allowlist.
_copy_dist_payload() in profile_distribution.py iterated all staged
entries without consulting the manifest's distribution_owned allowlist,
so manifests that restricted distribution_owned only had cosmetic effect.
Fix: compute manifest.owned_paths() at the top of _copy_dist_payload()
and skip entries not in that set, after the USER_OWNED_EXCLUDE check.
The owned_paths() method already existed on DistributionManifest and
correctly falls back to DEFAULT_DIST_OWNED when no explicit
distribution_owned is set, so the new filter preserves backward
compatibility for existing manifests.
Closes#74373
Follow-ups to the previous commit (#74155 by @Drexuxux):
- enrich_model_switch_warnings_for_gateway() -> merge_preflight_compression_warning()
still called the sync resolve_display_context_length() provider probe ladder
inline in both async /model call sites; dispatch it via asyncio.to_thread.
- Replace the inspect.getsource() test (source-reading tests are banned by
AGENTS.md) with behavioral tests that drive the real _handle_model_command:
assert the resolver runs off the loop thread and that the warning enrichment
is dispatched through asyncio.to_thread.
resolve_display_context_length() runs two blocking chains: the route
comparison in should_clear_context_pin() and the provider probe ladder in
get_model_context_length() (blocking requests calls to Anthropic /v1/models,
Copilot, Nous, Codex, GMI, Ollama, models.dev and OpenRouter).
The gateway message path already offloads both via
get_model_context_length_async() and should_clear_context_pin_async(), but
the /model slash-command handlers (_handle_model_command, _finish_switch)
called the sync helper directly, freezing the whole event loop for the
duration of the probe ladder - no messages processed on any platform, and
the Discord heartbeat timeouts that get_model_context_length_async() was
introduced to prevent.
Add resolve_display_context_length_async(), a thin asyncio.to_thread wrapper
mirroring the two existing *_async helpers (no logic duplication), and await
it at both handlers.
setup_path() only wrote a 'hermes' launcher into the command-link dir,
even though pyproject.toml declares three [project.scripts]:
hermes, hermes-agent (run_agent:main), hermes-acp (acp_adapter.entry:main).
Fresh venv installs (the common case on macOS/Linux) leave
~/.local/bin/{hermes-agent,hermes-acp} empty, so external tools
expecting 'hermes-acp' as a standalone command (documented as
first-tier supported in website/docs/user-guide/features/acp.md)
fail to find it after a fully successful install.
Loop over the three console-script names, writing a shim per entry
that exec's the venv interpreter with the right checked-in
entrypoint. --no-venv keeps the old single-shim behaviour since
it does not manage the venv and only 'hermes' is guaranteed on PATH.
Fixes#74819
_is_env_config_key() already routes _API_KEY and _TOKEN suffixed
keys to .env for safe credential storage. Add _SECRET to the suffix
list so keys like CLIENT_SECRET and ENCRYPTION_SECRET are stored
in .env (excluded from git by default) rather than config.yaml.
7b5a18817 migrated the sibling slug sites to custom_provider_slug, which
keeps a keyed providers: entry's config key as its durable identity. It
covered find_custom_provider_identity_by_model; canonical_custom_identity's
third recovery source - the configured-provider fallback - still built
f"custom:{normalized}" out of whatever string the caller happened to hold.
_get_named_custom_provider matches on either spelling, so a display name
that differs from its config key matches the entry and then heals to
custom:<display-name>. That is a second identity for one endpoint: the
endpoint- and model-based sources of the same function return
custom:<config-key>, and so does everything that persists or restores a
session's provider override. canonical_custom_identity exists precisely to
make a bare "custom" routable again, and tui_gateway calls it on the
session-persist, resume and recovery paths - so the divergence lands in
stored session identity.
Re-resolve through the endpoint the matched entry owns, reusing the
function's own URL-based canonicaliser rather than duplicating the match
logic. Legacy unkeyed custom_providers: entries keep their name identity,
and an unconfigured candidate still returns None.
524ab5399 widened the media read timeout from send_video to "all upload send
paths" - send_voice/send_audio/send_photo/send_document/send_media_group/
send_animation. Both send_photo calls inside send_image() were missed, so
they still ran on the short timeout the rest of the Bot API is tuned for
while the sibling media paths already pass it.
The missed pair is the worst one to miss: send_image tries a URL send first,
then falls back to downloading the image and uploading the bytes - the path
documented as "supports up to 10MB", i.e. the slowest send in the file and
the one whose server-side processing wait most often outlasts the short
budget. When it times out the handler's last resort posts the bare URL as
text, so the picture silently never arrives as a picture.
Pass _MEDIA_SEND_READ_TIMEOUT on both, covered by two behavioral tests that
drive send_image for real - the URL send and the forced byte-upload fallback
- and assert the read_timeout that actually reaches the Bot API.
Address review feedback from teknium1:
1. Re-validate stale 'test' category entries in quick() — existing
tracked.json entries under now-protected directories (patches/,
projects/, etc.) are re-classified via guess_category() and
dropped instead of deleted, mirroring the cron-output pattern.
2. Add patches, projects, skins, themes, contributors to
_EMPTY_DIR_PROTECTED_TOP_LEVEL so the empty-directory sweep
never traverses into these user-authored project trees.
guess_category() classified any file whose name starts with 'test_' or
'tmp_' as disposable, even when the file lived under user-authored
directories like patches/, projects/, skins/, or themes/. Files in
these trees were silently deleted on session end.
Added the missing user-project directories to the exclusion list so
that basename-based classification only applies to files in temporary
or scratch locations, not durable project trees.
Two call sites in _busy_steer_command were assigning directly to
adapter._pending_messages[quick_key], which overwrites the FIFO head
when a message is already enqueued. Changed both to self._enqueue_fifo()
which preserves the pending slot and appends to the overflow tail.
Regression test verifies both the pending-sentinel and no-steer() paths.
Ports the negative limit/offset fix onto the current router modules
(hermes_cli/web_routers/sessions.py, profiles.py) since the handlers
moved out of web_server.py in 011ec4513e after this PR was opened.
Per review feedback: only add Query(..., ge=0) — no le=500. The
messages route already clamps oversized requests with min(limit, 500)
and must keep that behavior (succeed + cap) rather than reject them;
the two session-list routes never had a public 500 cap and shouldn't
gain a new rejecting one as a side effect of this fix.
_resolve_explicit_runtime's generic-provider branch accepted model_cfg's
persisted api_mode unconditionally, letting a stale mode from a previous
provider (e.g. anthropic_messages) leak into a newly-switched provider
(e.g. gemini) and break the transport. Reuse the existing
_provider_supports_explicit_api_mode guard, already used by the copilot
and named-custom-provider resolution paths for exactly this case, so the
persisted mode is only honored when model_cfg's provider matches the one
being resolved.
Closes#74318
agent_init.py's init-time fallback and chat_completion_helpers.py's
try_activate_fallback() still read key_env via raw os.getenv(), missing the
per-profile secret scope installed by the multiplexed gateway (same bug
fixed for fallback_config.py/auxiliary_client.py in this PR). Both now
delegate to hermes_cli.fallback_config.resolve_entry_api_key(), and the
Ollama Cloud OLLAMA_API_KEY read now goes through
agent.secret_scope.get_secret() too.
agent_init.py's fallback loop had no try/except around key resolution
(unlike the other three call sites), so a fail-closed UnscopedSecretError
under multiplexing would have crashed init instead of skipping to the next
fallback entry — added the same skip-and-continue handling.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
resolve_entry_api_key() and the duplicated _fallback_entry_api_key()
read key_env via a raw os.getenv(), bypassing per-profile secret
scoping in the multiplexed gateway. Under multiplexing this can hand
a fallback request another profile's credential. Both now resolve
through agent.secret_scope.get_secret(), which reads the active
profile scope when multiplexing is on and falls back to os.environ
unchanged when it's off, so single-profile behavior is preserved.
Closes#74311
ForwardedRecordsParseMiddleware.handle() called the coroutine function
_send_loading_heartbeat() without awaiting it, so the coroutine was built
and dropped. The RUNNING heartbeat never reached the client and Python
raised "coroutine was never awaited".
Forwarded-record parsing is the slow inbound path, which is where the
loading bubble matters most: the user sees nothing while the deep parse
runs. Awaiting is safe, since the helper already swallows every exception
and the call sits inside the middleware's own try block.
Python keeps only the last definition of a name in a scope, so a duplicate
silently deletes the first. Four had accumulated, and two cost real coverage.
tests/agent/test_auxiliary_client.py grew a second _clean_env autouse fixture
alongside an NVIDIA feature. Being module-level with the same name, it
replaced the original for all 158 tests in the file: the ANTHROPIC_API_KEY,
ANTHROPIC_TOKEN, CLAUDE_CODE_OAUTH_TOKEN, OPENAI_MODEL, LLM_MODEL and
NOUS_INFERENCE_BASE_URL stripping went away, and so did the _aux_unhealthy_*
cache reset between tests. Two tests had already started clearing that cache
by hand to work around the leak, one of them with a comment describing the
pollution. The NVIDIA keys are folded into the original fixture and the
duplicate removed.
tests/gateway/test_mattermost.py had two copies of
test_progress_send_with_invalid_thread_root_never_falls_back_flat. The
surviving copy omitted the recorded 400 "invalid root_id" state, so the case
the name describes never ran. Both now run under distinct names.
The other two were harmless but hid the pattern: an exact duplicate in
test_tts_media_routing.py, and a dead _codex_auth_store in
test_credential_pool.py that nothing calls.
tests/test_no_shadowed_test_definitions.py walks every test module and fails
on any repeated definition in one scope, exempting the property/setter/
register family and throwaway _ callbacks. It fails on main listing exactly
these four.
/background (/bg, /btw) exists to start independent work while the current
turn keeps running. Typed while the agent was busy it went into
_pending_input like ordinary input, and process_loop is blocked inside
self.chat() for the whole run, so the background task only started once the
foreground turn had finished. That is the one moment it was not needed.
/steer had the identical problem and was fixed the same way, by dispatching
inline on the UI thread. The command's own CommandDef already declares
busy_policy="dispatch"; the gateway honours that, the classic CLI never
consulted it.
The foreground turn is untouched: no interrupt, no steer, and ordinary
non-slash input keeps following the configured busy-input behaviour.
append_message refused immediately when another writer held the session's
compression lock. The conversation loop turns that into
session_persistence_failed and tells the operator to check disk space and
permissions, when the store is healthy and merely busy. The two append
attempts in the reported incident were 3ms apart, so there was no wait at
all before the turn was destroyed (#75083).
The wait is deliberately short (_COMPRESSION_BUSY_WAIT_S, 5s) rather than
the 60s transcript write patience. The lease is a correctness boundary, not
just a busy signal: test_compression_lease_blocks_non_owner_but_allows_owner_flush
pins that a late stale turn must not land in a session being compressed.
Reusing the full write patience made that append succeed once the lease
aged out, which is exactly what the guard exists to prevent. A short budget
saves the common case, where compression publishes in a couple of seconds,
and still refuses a writer locked out by a long-running or wedged
compression.
CompressionSessionBusyError could not simply be retried either: it covers
two conditions with opposite handling. A compressor discovering its own
lease is gone is permanent, and retrying that would spend the whole budget
before failing anyway. Split the transient case into a
SessionCompressionInProgressError subclass, raised only by append_message,
and wait on just that. Existing except CompressionSessionBusyError handlers
catch both unchanged.
The retry jitter is extracted into _sleep_before_write_retry so the lock
path and the compression path share one implementation.
Answering "Always Approve" on the /clear, /new, /reset and /undo
confirmation calls save_config_value("approvals.destructive_slash_confirm",
False) and then appended "Future /clear, /new, /reset, and /undo will run
without confirmation" unconditionally.
save_config_value catches its own exceptions and reports the outcome in the
return value, so the caller's try/except could never observe a failed write,
and the return value was ignored. On any install whose config.yaml is not
writable the user was told the preference stuck when it had not, and the
prompt returned on the next restart with no explanation.
Check the return value. The approved action still runs either way, but when
the write did not land, say so and point at the config key instead of
promising an opt-out that was never written.
shutil.move() moves into an existing destination directory instead of
replacing it. When the snapshot extract failed after creating some of its
output, the recovery path moved each staged entry onto a path the extract
had already created, burying the user's own skill one level deeper
(skills/alpha/alpha/) and leaving the snapshot's partial content in its
place. rollback() then returned "snapshot extract failed (state restored)".
Clear the failed extract's output before moving the staged copies back:
entries the original tree never had are dropped, and each staged entry's
destination is removed before the move. When an entry still cannot be
restored, keep the staging dir and name the entries in the message rather
than reporting a restore that did not happen.
_load_auth_store() treated every exception from reading auth.json as
corruption and returned an empty store. EMFILE under fd exhaustion,
EACCES, EIO and a stalled network mount all reached that branch. This
module does read-modify-write in roughly fifteen places, so the empty
store was one _save_auth_store() away from erasing every stored
credential.
Separate OSError from parse failure: a file that exists but cannot be
read now raises, naming the real cause and leaving the file on disk
untouched. Only a genuine parse failure takes the preserve-and-start-
empty branch, which is unchanged.
The backup was also unreliable in exactly the conditions that triggered
it: shutil.copy2 opens a file, so under EMFILE it failed too, its bare
except swallowed that, and the log still said "Corrupt file preserved
at ..." when nothing had been written. Track whether the copy landed
and say so accurately.
_venv_launcher_ancestors() ran after
_wait_for_windows_update_gateway_exit(), but the drain stops tracking a
PID exactly when it dies - for the common graceful-drain case the worker
is gone by the time the wait returns, and a dead pid's parent cannot be
recovered, so the launcher stop never fired on that path. Resolve
launcher ancestors before draining and stop the snapshot afterwards
alongside the survivors; a launcher that already exited with its worker
raises ProcessLookupError at the kill and is skipped.
The set-cover invariant test now marks drained workers uninspectable
(construction raises, like psutil.NoSuchProcess), so a post-drain
launcher lookup can never reappear unnoticed.
_is_pausable_gateway() hand-rolled a second gateway parser and regressed
a valid form: in `--profile gateway gateway run` the profile VALUE
shadowed the subcommand token, so the scan reported that gateway as a
fatal preflight holder. Delegate to
gateway.status.looks_like_gateway_command_line() - profile-selector
aware, shlex-tokenizing, run-only - so the preflight exemption, the
pause discovery, and the updater's guard fallback share one parser.
Non-run gateway subcommands, serve backends, and REPLs still block; the
bare-`gateway` form now classifies as a running gateway, mirroring the
canonical matcher's contract.
The pause stops every gateway its discovery maps, but the venv-holder
guard sees the process table as it is now: a gateway respawned by its
supervisor (Scheduled Task, login watchdog) inside the pause-to-guard
window, or one started through a spawn path discovery does not map,
still holds venv .pyds - and the guard dead-ended the update on exactly
the kind of process the pause machinery exists to stop.
When every remaining holder classifies as a pausable gateway - using the
same _is_pausable_gateway matcher the Desktop preflight uses, so the two
views cannot drift - stop them and re-scan once. Any non-gateway holder
(REPL, stray script, Desktop backend) keeps the hard refusal exactly as
before, and a survivor after the stop still aborts.
The Desktop update preflight (`scanVenvBlockers` -> `python -m
hermes_cli._scan_venv_blockers`) reports every venv-side python as a
blocker and aborts the handoff:
main.ts: scanVenvBlockers(...) <- aborts HERE
return { ok:false, error:'venv-blocked' }
spawnUpdaterProcess(hermes-setup ...) <- never reached
But a *gateway* is not a dead-end holder. `hermes-setup` invokes
`hermes update --yes --gateway`, and the CLI updater's
`_pause_windows_gateways_for_update()` gracefully drains and stops
running gateways before touching the venv — machinery added for exactly
these processes (#50090 and follow-ups). The preflight replicated the
CLI's *guard* without its *pause*, so a Windows service-mode gateway
(e.g. a Scheduled Task running `gateway run`) made every Desktop update
abort forever with
[updates] venv-blocked: N process(es) hold the install
PID ... python.exe ... -m hermes_cli.main gateway run --replace
while the component one layer down was never allowed to run and handle
it. The abort points at a process the updater knows how to stop.
Fix: `_is_pausable_gateway()` exempts `hermes_cli.main ... gateway run`
invocations (both halves of the venv-shim launcher/worker chain match,
since the uv-side worker re-runs the same argv). Everything else keeps
blocking — the Desktop `serve` backend, other `gateway` subcommands,
operator REPLs and stray scripts have no pause machinery downstream.
The CLI updater's own post-pause venv guard is untouched, so a pause
that genuinely fails still aborts before any .pyd mutation.
The JSON gains a diagnostic `pausable_gateways` count. The TS consumer
validates only `ok`/`blocked`/`processes` and ignores unknown keys, so
old and new Desktop builds both accept the new document; no Electron
rebuild is required for the fix to take effect (the scan runs from the
repo's Python).
`import pty` at module scope pulls in `termios`, which does not exist on
Windows. That raised ModuleNotFoundError during *collection*, so pytest
aborted the whole module with
Interrupted: 1 error during collection
before any skip marker could take effect. The single PTY-dependent test
was already correctly marked `skipif(sys.platform == "win32")` — the
import crashed ahead of it and took the module's 13 other, entirely
platform-agnostic tests down as collateral. Windows contributors got zero
gateway coverage and, worse, a collection error that masks real failures
in any batch that includes this file.
Two changes:
- Move `import pty` into the `stdin_is_tty` branch that actually uses it
(the sole `pty.openpty()` call). Nothing else in the module needs it.
- Skip `test_systemd_install_checks_linger_status` on Windows. It drives
`_systemd_linger_enabled()` -> `os.getuid()`, which does not exist on
Windows; the production helper is annotated
"windows-footgun: ok — POSIX systemd helper, never invoked on Windows",
so the test is Linux-only by nature. It was previously hidden behind
the collection crash.
POSIX behaviour is unchanged: both guards are `skipif(win32)`, inactive
off Windows, and the local import resolves exactly where the module-level
one did.
before (Windows): 0 collected, 1 collection error
after (Windows): 14 collected, 9 passed, 5 skipped
On Windows a gateway started through the venv shim is a two-process chain:
venv\Scripts\python.exe (launcher — keeps venv .pyd files mapped)
└─ uv\python\...\python.exe (worker — writes the gateway PID file)
`_pause_windows_gateways_for_update()` builds its pause set from
`find_gateway_pids()`, which reads the PID file and therefore only ever
sees the *worker*. The venv-holder guard immediately downstream
(`_detect_venv_python_processes()`) matches on the venv path prefix, so it
only ever sees the *launcher*.
The two sets are disjoint. A gateway the updater had just gracefully
drained still left its launcher alive, the guard reported that launcher as
a venv holder, and the update aborted — every time. On the Desktop path
this surfaces as the dead-end dialog:
[updates] venv-blocked: 2 process(es) hold the install
PID ... python.exe ...\venv\Scripts\python.exe -m hermes_cli.main gateway run --replace
Note the reported holder is a gateway the updater believes it stopped.
The Desktop path is affected because `hermes-setup.exe` runs
`hermes update --yes --gateway --force`, and `--force` deliberately does
NOT bypass the venv guard (that needs `--force-venv`), so the abort is
correct behaviour reacting to an incomplete pause.
Fix: after the graceful drain, walk one hop up from each mapped gateway
PID and force-kill parents that live under the project venv.
Deliberately additive, not a substitution:
- The planned-stop marker and the graceful drain still target the worker
(the PID that wrote the PID file), so clean shutdown is unchanged and
updates don't get pushed onto the hard-kill path.
- `terminate_pid(force=True)` is `taskkill /T` (tree kill), so killing a
launcher that outlived its worker also reaps stragglers.
- `_resume_windows_gateways_after_update()` needs no change: the mapped
respawn argv is rebuilt from the profile name
(`_gateway_run_args_for_profile`), never from the killed PID, and the
restart watcher's `_pid_exists()` wait still terminates because the
tree kill takes the whole chain down.
- Only the venv-side parent is returned. Unrelated ancestors (a Scheduled
Task's `cmd.exe`, an operator's shell) are ignored, and the caller's own
process chain is excluded so a CLI `hermes update` never nominates
itself.
Tests assert the invariant the two PID-resolution paths must satisfy —
the pause's kill set must cover the guard's abort set — rather than
snapshotting PIDs. Verified to fail without the fix:
AssertionError: pause stopped [] but the venv guard aborts on [400]
— disjoint sets abort the update
Issue #74874. The renderer's 'Repair' button treated every transient
backend GIL stall (event loop stalled ... ws ready frame send failed)
as a fatal backend fault, asking the bootstrap to force-reinstall +
restart, which then stalled again for the same reason — looping the
user through 30+ minutes of reinstall cycles.
Distinguish 'venv is genuinely broken' from 'backend is just transiently
stalled' before honouring a repair request. Probe the live backend
process (exitCode === null && signalCode === null) and an in-flight
repair-attempt counter:
attempt <= 3 AND primary alive → soft restart (skip installer)
attempt <= 3 AND primary dead → soft restart (verify before reinstall)
attempt > 3 → hard reinstall (escalate)
Counter resets on a clean backend.ready so a later, unrelated failure
episode starts at attempt 1. The guard is a pure helper (decideBootstrap
Repair in electron/bootstrap-repair-guard.ts) so the decision logic is
unit-tested in isolation; main.ts only wires the existing flag and
counters to it.
Refs #74874
resolveUpdaterBinary() picked up a staged hermes-setup on every platform, so a
macOS binary predating the update hand-off protocol took over the update, held
the marker, and had its `hermes update` child refuse its own parent. The in-app
Update button then failed for good, with no route -- update, re-download or
reinstall -- back to a capable binary (#74836).
Move the decision into a pure resolveStagedUpdaterBinary() helper in
updater-process.ts and return null off Windows. The installer self-copies into
HERMES_HOME on every platform (paths::installer_dest,
bootstrap::copy_self_to_hermes_home), so finding that binary on macOS or Linux
is expected rather than leftover junk: declining to hand it an update is a
policy decision, and the comments now say so instead of describing the binary
as Windows-specific.
Cover the resolver in updater-process.test.ts: Windows accepts a staged
hermes-setup.exe, macOS/Linux return null even when hermes-setup exists, and
Windows returns null when nothing is staged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
resolveUpdaterBinary() returned a path on macOS if hermes-setup existed
in HERMES_HOME, routing macOS into the Windows-style quit→hand-off→rebuild
update dance. A stale hermes-setup (e.g. from 2026-06-08, predating the
applyUpdatesPosixInApp path) permanently breaks the in-app Update button.
The Tauri hermes-setup binary is a Windows-specific mechanism; macOS and
Linux use applyUpdatesPosixInApp instead. Always return null on non-Windows
so the native drag-and-drop updater is used on those platforms.
Fixes#74836
The salvaged commit renamed server.py's private _INDICATOR_STYLES/_INDICATOR_DEFAULT
to the shared hermes_constants imports, but methods_config.py (extracted after the
original PR was authored) still referenced the old private names through the
server-globals rebinding. Import the shared constants directly.
The /indicator command was registered in COMMAND_REGISTRY, listed in
/help, offered by tab-completion, recommended by the tips system, and
even documented in config.py — but it had no actual handler. Running
/indicator in the CLI produced "Unknown command: indicator".
Add _handle_indicator_command to CLICommandsMixin that:
- Shows the current indicator style when called with no args or "status"
- Validates the requested style against the shared INDICATOR_STYLES
allowlist (ascii | emoji | kaomoji | unicode)
- Persists the choice to display.tui_status_indicator in config.yaml
via the existing save_config_value helper
- Falls back to session-only when config save fails
The indicator-style allowlist is defined once in hermes_constants as
INDICATOR_STYLES + DEFAULT_INDICATOR_STYLE and imported by all three
consumers (CLI handler, command registry, TUI gateway config handler),
preventing drift between the TUI and CLI validation.
Also adds tests/cli/test_indicator_command.py covering dispatch,
validation, persistence, and registry integration.
Signed-off-by: dongjiang <dongjiang1989@126.com>
Two fixes for the Skills Hub "View source" links on ClawHub skills:
1. Source URL generation was missing the required {owner} segment —
https://clawhub.ai/skills/{slug} → 404. Correct format is
https://clawhub.ai/{owner}/skills/{slug}. When the owner handle is
unavailable, source_url is now "" (card omits the button) instead of
emitting a broken link.
2. _fetch_owner_handle() previously delegated to _get_json() which
returned None on any non-200 response with no retry. Under HTTP 429
rate-limiting the "50 consecutive failures" safety rail in
enrich_owners() fired immediately — the documented claim "Respects
HTTP 429 rate-limit responses with exponential backoff" was not
actually implemented. Now has its own retry loop: 3 attempts, honours
Retry-After on 429, exponential backoff on 5xx/transport errors, no
retry on 4xx.
Changes:
- tools/skills_hub.py: _coerce_skill_payload carries owner from top-level
response; inspect() captures owner from detail API; _fetch_owner_handle()
added with bounded retry/backoff; enrich_owners() batch method with
safety rails (30 workers, early termination at 50 consecutive failures).
- website/scripts/extract-skills.py: _source_url() reads extra["owner"]
for ClawHub.
- scripts/build_skills_index.py: batch enrichment step after crawling.
- tests: 35 URL/enrichment tests + 7 retry tests (42 total).
Signed-off-by: dongjiang <dongjiang1989@126.com>