Preview-pane guest pages (Streamlit's traceback "Ask Google" / "Ask …"
buttons, plain `<a target="_blank">` anchors) could not open anything: the
`<webview>` has no `allowpopups`, so Chromium drops the popup before any
handler runs. Opening from `setWindowOpenHandler` is banned
(GHSA-9f4c-93c8-jc8g, window-open-policy.ts), so this adds an explicit
click bridge instead:
- main.ts installs a guest preload through `will-attach-webview`, keyed on
the `persist:hermes-preview` partition only.
- The preload forwards a clicked `_blank` anchor's resolved href to the
host renderer via `ipcRenderer.sendToHost`; it opens nothing itself.
- PreviewPane admits the URL and routes it through the existing audited
`hermes:openExternal` IPC.
Salvaged from PR #112959 (squash of its two commits).
Fixes#112941
Same stale invariant the ui-tui test had: since the shared channel sends one
client.capabilities frame on every gateway.ready, "no frames" is no longer
what this case guards. What it guards is that an older backend without
heartbeat gets no gateway.ping and stays open, so assert the wire holds
exactly the advertisement.
Item 2 of #112548: a Desktop/dashboard build that predates server→client
requests has no response path, so every clarify/approval/sudo/secret/vault/
connection/bridge request sat for the full deadline (clarify: 300s). Only the
tour probed. Clients now advertise once per connection
(`client.capabilities {server_requests: true}`, sent by the shared TypeScript
channel on `gateway.ready`); `send()` / `send_async()` return the
error-response shape (None) at once when every WebSocket peer of the session
is a build that never advertised. Sessions with no client attached still wait
so the reconnect replay (`open_requests`) keeps working; the stdio TUI ships
with the backend and is not gated. The advertisement is dropped on disconnect.
Reviewer minors from #113227:
- tools/approval_gateway_wait.py: the verdict is the choice committed under
the approval lock while leaving the queue, so an /approve that lands after
the deadline check but before the entry is dropped is an answer, not a
timeout (the client was already acked "ok").
- tests/tui_gateway/test_protocol.py: the error-fails-fast test that only
restated pre-existing behaviour is replaced by the two capability
invariants (never advertised → fails fast; advertised → frame written,
waits, forgotten on disconnect).
- server_requests.send try/finally around event.wait already landed on main
(4371ed34a9); nothing to change.
Docs: programmatic-integration.md (advertise once per connection; method
list), tui_gateway/AGENTS.md; contracts regenerated.
sessionScoped() strips the tag since the review fix; the assertions added by the
first commit of this branch still expected it on session reads and failed CI.
Profile writes keep the tag.
Moving the foreground tag into capabilityScoped() made every helper that
spreads it dial foreground, including sessionScoped() and therefore
getSession/getSessionMessages. resolveStoredSession probes every OTHER
profile sequentially with getSession(id, profile) while resolving a
remembered or 404'd session id — a boot-time path, not a scope-selector
click — so it would have cold-started each profile's backend on the pool's
reserved foreground slot. sessionScoped() now strips the tag: the tag is
for the user-facing scope selectors (Settings, Capabilities, Messaging),
session lookups stay on main's background default.
Part of #111651
Fold the scoped-dial priority rule into profileScoped()/capabilityScoped()
themselves: an explicit scope (string or object) now carries
priority 'foreground' from the scope helper, so every api/*.ts request
that targets a selected profile — skills hub/official lists, learning
nodes, toolset config/models, MCP, messaging/pairing, memory providers,
sessions, profiles — inherits it. The per-call `...scopedDialPriority()`
spreads (config/models/mcp/skills/toolsets) and the helper are removed;
the ambient path (`undefined`) and the deliberate `null` → primary path
stay untagged so hydration cannot take the reserved foreground slot.
WHY: the first pass tagged the six Settings pages, then models.ts, then
Capabilities' three page-open reads, and each round left siblings behind
(getOfficialSkills / getSkillHubSources / getLearningNode /
getToolsetConfig / getToolsetModels / the Messaging page) still queueing
behind roster hydration when the selected profile's backend was cold —
an infinite spinner once the pool's background slots were saturated. A
rule that must be re-spread at every call site is the failure mode; one
that rides on the scope helper cannot be forgotten by the next helper.
messaging.ts spread profileScoped() into request BODIES for the
body-scoped pairing/onboarding endpoints; those now bind the scope once
and forward only `profile`, so the dial tag never reaches the backend.
Tests: hermes-capability-scope.test.ts gains two invariants (red on
base): every scoped helper outside models.ts carries the tag and `null`
does not; the tag never leaks into a body. Exact-shape request
assertions in hermes.test.ts, session-transcript-paging.test.ts and
use-desktop-integrations.test.tsx gain the tag for explicit scopes.
Fixes#111651
githubTokenFromGhCli cached the outcome of `gh auth token` for the whole
process, including "none": a user who ran `gh auth login` after launching
the Desktop stayed anonymous (and rate-limited) until an app restart, since
only a 401 on a real token cleared the cache. Only a token is cached now; a
null answer (gh missing, logged out, hung) is re-asked by the next hourly
check, one bounded spawn per check at most.
Part of #112615
Closes the last atom of #112615. A Desktop app started from the Dock, Finder or a
desktop launcher inherits a minimal environment, so the GITHUB_TOKEN / GH_TOKEN rung
landed in #113190 never fires for exactly the users the issue is about (shared exit
IP, 60/hour anonymous budget exhausted by neighbours). The update check now walks the
same ladder as the Python GitHub client (tools/skills_hub_github.py::GitHubAuth):
1. GITHUB_TOKEN, then GH_TOKEN, from the launch env (unchanged).
2. `gh auth token` — execFile with an argv array (no shell), stdin closed, 3 s
timeout, windowsHide. `gh` is resolved on PATH plus the GUI-safe install
locations backend-env already appends for the backend (Homebrew, /usr/local,
~/.local/bin, nix profile, the Windows GitHub CLI installer dirs). The outcome
(token or none) is cached for the process lifetime so gh runs at most once.
3. Anonymous.
A rejected credential (401) still retries anonymously; the log line names the
source (env vars vs gh login) and never the token, once per source per process.
A rejected gh token also drops the cache so a re-login is picked up by the next
check. envTokenRejected is renamed githubTokenRejected since both rungs use it.
Live: with PATH=/usr/bin:/bin:/usr/sbin:/sbin and no env token, the module found
gh, resolved a credential in 135 ms and api.github.com reported a 5000/hour core
budget (anonymous: 60). A logged-out gh and a hanging gh both resolved to null
(anonymous) in 5 ms / 3001 ms.
Docs: the credential ladder is documented on the Desktop "Updating" page and in
the environment-variables reference.
dispatchServerRequest hand-rolled request.fail(JSON_RPC_METHOD_NOT_FOUND,
'Hermes Desktop has no server-request registry yet'), but the channel already
owns that reply: JsonRpcRequestChannel.deliverRequest answers -32601 and fires
onUnhandledRequest when a ServerRequestHandler returns false, and
GatewayBootOptions.handleServerRequest already documents "false = no handler
(the channel answers -32601)".
Make dispatchServerRequest return false when the registry has no
onServerRequest and true after forwarding; dispatchPrimaryServerRequest and
both gateway.onRequest registrations (store/gateway.ts secondary sockets,
use-gateway-boot.ts primary) now propagate that value to the channel. Keep
the desktop-specific wording by wiring onUnhandledRequest on HermesGateway
beside the onRequestHandlerError sink (console.warn), which needs the same
GatewayClientOptions passthrough onRequestHandlerError got. Drop the now
unused JSON_RPC_METHOD_NOT_FOUND import from the store and export
JSON_RPC_INTERNAL_ERROR from the shared barrel beside it.
Test: the store test asserts the false return with no fail() call when the
registry is missing, and true + forwarded profile when it is present.
Follow-ups (same class, outside this stack): use-gateway-boot.ts ~L889-891
still hand-rolls -32601 when the registry is present but has no handler;
ui-tui has its own copy.
Mutation check: reverting store/gateway.ts to the merge-base left the desktop
suite green — nothing exercised dispatchServerRequest. Cover both arms through
dispatchPrimaryServerRequest: a registry configured without onServerRequest
answers request.fail(-32601, /registry/) exactly once; with onServerRequest
the request is forwarded carrying the profile and fail is never called.
The deliverRequest catch branch wrote to a module-level console.error sink
(the only direct console.* in apps/shared/src) and fired onUnhandledRequest,
whose contract is "nobody handled it, already answered -32601" — so the TUI
logged a -32603 crash as "unhandled server request".
Add onRequestHandlerError(error, request) to JsonRpcRequestChannelOptions
beside onHeartbeatFailure, call it from the catch after answering -32603,
and drop the console sink. Wire both owners: HermesGateway (desktop, via a
GatewayClientOptions passthrough) logs to console.error like its dial-failure
sink; ui-tui gatewayClient pushes a [protocol] log line. Collapse the two
normalisation arms into the existing `error instanceof Error ? … : new
Error(String(error))` idiom and restore the early `return true` instead of
the handled flag + break — nothing runs after the loop but the -32601
fallthrough.
Test: the crash case now asserts onRequestHandlerError fires once for the
-32603 request and onUnhandledRequest only for the -32601 one.
Follow-up to the salvage of #112791.
- json-rpc-gateway.ts: revert the try/catch around `channel.handleFrame`
to main. deliverRequest is the single chokepoint that dispatches into
feature handlers and now answers -32603 itself; a second catch in the
socket listener is defense-in-depth that would also hide bugs in event
and response handling that should surface as uncaught errors.
- store/gateway.ts: the primary and secondary dispatch sites carried the
same copy-pasted "no registry -> fail -32601" block. Fold both into one
file-local `dispatchServerRequest(request, profile, connectionId)`.
The secondary keeps main's tagging (its own connectionId, no fallback
to the active connection), which the contributor's version changed.
A clarify request renders as an eternal spinner when anything in the
renderer's handler chain throws: deliverRequest had no error handling,
the WS message listener let the exception escape as an uncaught error,
and both dispatch sites silently no-oped via optional chaining when the
registry was absent. In every case the backend (clarify_tool blocks up
to 3600s) never receives any frame — no result, no error — and waits
out its whole deadline.
- json-rpc-channel: wrap each handler invocation; a crash now answers
-32603 ("server request handler crashed: <method>"), fires the
onUnhandledRequest hook, logs the stack, and stops. The unhandled
path still answers -32601.
- json-rpc-gateway: guard the socket message listener so no frame can
escape as an uncaught error.
- store/gateway (primary + secondary wiring): missing registry now
fails the request immediately with -32601 instead of dropping it.
Backend already handles {"error"} response frames
(tui_gateway/server_requests.resolve_response), so fail-fast answers
settle the tool at once; no backend change needed.
Regression test drives boom→-32603, unknown→-32601, and a working
request after the crash.
The lib test sits beside markdown-preprocess.directives.test.ts as
markdown-preprocess.reasoning.test.ts, and the frame-fidelity test joins
its markdown-text.*.test.tsx siblings as markdown-text.reasoning.test.tsx,
so a glance at the directory shows what each file exercises. Also trim the
REASONING_TAGS comment: how the two tag lists converged is git history, not
something a reader of the constant needs.
OPEN_REASONING_BLOCK_RE needs the full `<tag>`, so a frame ending in `<thin`
at a block boundary painted `<thin` as prose for one frame and then erased it
— the paint/un-paint class of #62774, one frame long. The fidelity test carved
an escape hatch around exactly those frames.
Add a third pass that drops a trailing partial open tag at a block boundary,
restricted to prefixes of the known reasoning tag names (built from
REASONING_TAGS) so `<div` at a line start still renders. This is the desktop
counterpart of agent/think_scrubber.py `_hold_partial`/`_max_partial_suffix`,
cited rather than ported. The fidelity test's escape hatch is deleted so the
monotonic-prefix invariant holds on every frame; the unit test gains the
`<thin` positive and `<div` negative cases.
stripReasoningBlocks runs on the accumulated message text every streaming
flush (markdown-text.tsx). Its replacer sliced the whole text twice per
closed block to test whether a space is needed at the seam — O(n) copies plus
a forward scan per block per flush. Read the single char on each side of the
match instead.
The seam test also had a second defect: with two adjacent closed blocks the
second block's `before` ended in the first block's `>`, so a second space was
emitted (`no Hermes`). Matching a run of adjacent blocks as one match makes
the two-edge check see the real prose on both sides. Covered by an extra
expect in the existing seam test.
WHAT: one REASONING_TAGS alternation feeds both regexes and now also covers
`thought` and `reasoning_scratchpad` (agent/think_scrubber.py THINK_TAG_NAMES);
the desktop-only `scratchpad`/`analysis` stay. Behaviour change beyond the
contributor's: closed or unterminated `<thought>`/`<reasoning_scratchpad>`
blocks are now stripped on the desktop too.
Doc comments trimmed to the WHY (block-boundary rule, why an unterminated block
is held back). Tests cut to the ≤2-invariant shape: two unit cases (seam,
unterminated-vs-mention) and one DOM frame-by-frame case; the accents/plain-prose
DOM cases were protection for unrelated code and are dropped.
preprocessMarkdown runs on the accumulated text on every streaming flush, so
the plain REASONING_BLOCK_RE replace damaged the visible answer in two ways a
settled message never shows:
- a model that inlines its chain of thought in the answer channel streams the
open tag long before the close one, and the regex needs both, so the reasoning
rendered as chat prose until the close tag arrived — and when it did the whole
span vanished in one frame, taking text the reader had already read with it
(#62774: the mid-reply "truncation" on long answers).
- the regex ate the whitespace on its seam, so a block sitting between two words
fused them: `no` + `Hermes` rendered as `noHermes`.
An unterminated block now strips at a block boundary — the same rule
agent/think_scrubber.py draws, so a real reasoning preamble (always its own
block) disappears while prose that merely mentions `<thinking>` mid-sentence
survives — and a removal between two prose fragments leaves one space instead of
deleting the seam.
Tests: markdown-reasoning-stream.test.ts (module level: seam, unterminated
blocks, per-delta leak + visibility monotonicity) and
streaming-text-fidelity.test.tsx (real surface: accents/emoji and plain prose
streamed in 1- and 3-character deltas, plus the chain of thought never painting
a frame).
`SidebarGrouping` was a literal union kept in step with `SIDEBAR_GROUPING_ORDER`
by hand — a fifth grouping added to the type would compile while the cycle
silently skipped it. The array is now the single source (`as const`) and the
type is derived from it. The cycle test no longer pins the exact order (a
change-detector on the constant); it asserts one lap visits every grouping
once and returns to the start, and still fails when the cycle skips a step.
Follow-up to Ayush Nangia's feature: the filter menu kept its own literal
list of groupings while the new cycle keybind walked a second one, with only
a test comment saying they should agree. The menu now builds its options
from `SIDEBAR_GROUPING_ORDER`, so reordering or adding a grouping is one
edit and the keybind cannot drift from what the menu shows.
`$petBusy` collided with the gallery download-busy atom of the same name in
store/pet-gallery.ts (imported side by side in five files) and was a bare
alias of `PRIMARY_SESSION_VIEW.$busy`. pet.ts and pet-overlay.ts now read
that atom directly; the overlay push takes `awaiting` from the same view too,
so neither overlay field comes from the global mirror. Stale comments that
still named `$busy` as the fallback are fixed, and the two pet tests fold into
one invariant (runs while the active slice is busy, idles the moment it
settles) that fails when the deps are pointed back at `$busy`.
Follow-up to Ayush Nangia's fix: keep one mechanism. `deriveLivePetState`
now takes `PRIMARY_SESSION_VIEW.$busy` (session-view.tsx already derives the
active slice's turn-busy and returns false for a slice-less stored session)
instead of a private `$activeSessionBusy` re-implementation plus a
`Date.now()`-based recency guard.
Why the TTL had to go: `Date.now()` inside a nanostores computed never
re-evaluates on time alone, so "decays after 30s" only ran on the next
unrelated store change; and a user Stop sends no flag-clearing event, so the
guard kept the pet on `run` for 30s+ after an interrupt — the very case the
original `live &&` gate handled synchronously.
The pop-out overlay push (`store/pet-overlay.ts`) switches to the same
`$petBusy` signal; it was still shipping the stale `$busy` mirror, so the
first fix never reached the overlay window.
Tests: two invariants — the pet animates while the active slice is busy with
`$busy` false, and drops to idle the moment the slice settles with no
clearing event. Both fail when `$petBusy` is pointed back at `$busy`.
The pet's steady flags (toolRunning/reasoning) were gated on the global
$busy mirror, which can read false for a session that is working: a
reclaimed session reopened after idle timeout (#84434) or a desktop-minted
draft (#84438) doesn't always flip the busy mirror for its turn, so the
pet froze on idle while tools ran.
Honor a steady flag while it is recent (stamped on every tool.start /
message.start, 30s TTL) even when busy is stuck false, and let it decay
to idle when the stream goes silent — preserving the original guard
against stale flags from interrupted turns.
Both reports by @camilliaK — shared root cause, one fix.
The salvaged commits drop the cpSync/rmSync imports, but stageGetWindowsInto
landed on main after the PR was opened and still called both, so the module
threw ReferenceError on every platform (7 vitest failures on the cherry-picked
head). Route its six sites through copyFileSync/removeDirSync so get-windows
staging survives the same non-ASCII profile paths as node-pty.
preserveRollbackBackup had the same rmSync no-op as cleanStaleAppOutDir: a
surviving .bak makes renameSync fail, so the previous working build gets wiped
instead of kept as rollback material. Same existsSync -> removeDirSync fallback.
Earlier fixes for the same crash: #60480 (@liuhao1024), #61832 (@danilofalcao),
#76211, #103458, #109273, #111590.
Co-authored-by: liuhao1024 <liuhao1024@users.noreply.github.com>
Co-authored-by: danilofalcao <danilofalcao@users.noreply.github.com>
Review follow-ups to the previous commit:
- before-pack.mjs: cleanStaleAppOutDir kept the retrying rmSync (its
EBUSY resilience is worth keeping) but now verifies the tree is gone
and falls back to the libuv-backed removeDirSync when the native
rmSync silently no-ops on a non-ASCII path — previously it logged
'removed stale unpacked dir' while the stale tree survived.
- removeDirSync: export it for reuse; handle a plain file or symlink at
the path (rm -rf semantics) instead of throwing ENOTDIR, and tolerate
broken symlinks via lstat.
- Add a source-level guard test: repo CI runs on Linux where the native
implementations happen to work, so the accented staging test cannot
catch a cpSync/rmSync reintroduction there.
- Link the upstream Node issues in the banner (nodejs/node#61878, fixed
in v24.15.0; nodejs/node#56049, fixed in v24.13.1) and stop
attributing the native rewrite to Node 24 — only the observation was
on v24.11.1.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Node 24's native rewrite of fs.cpSync/fs.rmSync mishandles non-ASCII
Windows paths (observed on v24.11.1 with an accented Windows user name,
i.e. the default %LOCALAPPDATA%\hermes install home):
- recursive cpSync fails with EIO "Access is denied" (errno 5) or
hard-crashes the process (0xC0000409),
- cpSync over an existing file fails with a bogus errno-0
'unlink' / "The operation completed successfully" error,
- rmSync({recursive, force}) silently deletes nothing, so the
half-staged dist/node_modules/node-pty tree poisons every retry.
In practice this bricked the desktop build (and thus install/update)
for every Windows user whose profile path contains accented characters:
stage-native-deps.mjs died copying the conpty prebuild, and each rerun
failed earlier on the stale tree the silent rmSync left behind.
Replace all cpSync/rmSync uses with libuv-backed primitives that handle
these paths correctly on the same node.exe: copyFileSync for single
files, a manual mkdirSync+readdirSync+copyFileSync walk for directory
copies, and a manual unlinkSync/rmdirSync walk (verified with existsSync
afterwards, failing loudly instead of silently) for the dest cleanup.
Add a regression test that stages a fake node-pty tree - including a
nested conpty/ payload - into an accented src/dest path twice, so the
restage exercises the delete-then-recopy path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The first cut derived the package from `binding-${platform}-${arch}` with an
exact-suffix match, which never matches Windows (`-msvc`) or Linux
(`-gnu`/`-musl`) names, so the repair only ever worked on macOS and
install.sh had to gate it there. Rolldown's own loader already resolves
platform, arch and libc and prints the exact `@rolldown/binding-*` it wanted
in its error chain; parse that instead and drop the gate. Also spawn npm
through a shell on Windows (Node refuses to spawn npm.cmd directly) and trim
the tests to the two invariants (no-op when it loads; installs exactly what
the loader asked for, then re-probes).
The roster menu gated "New Group Chat" on `activeSourceRoster.length < 2`
(local-source rows only) while the dialog it opens seats the FULL
multi-source roster (`selectableRoster = roster.filter(bot => !bot.ghost)`,
`canCreate = selected.length >= 2`). With one local bot and any number of
remote-connection bots the door stayed shut on a room the dialog would
happily create; users added a throwaway local bot just to click through.
The gate now counts the same selectable set as the dialog.
Live repro (real Electron + a real second `hermes serve` registered as a
remote connection): e2e/group-create-gate-remote-roster — base: menu entry
`aria-disabled="true"` with 1 local + 2 remote rows in the roster; head:
enabled, dialog seats Hermes + the remote Inbox, "Create Group (2)" enabled.
Co-authored-by: FalconOrtiz <falcon.ortiz11@gmail.com>
Co-authored-by: neil771 <neil@lannscapital.com>
Seeds the pre-isolation layout (hosted_room* rows in the root state.db,
written by the store's own writers), launches the REAL Electron app and asks
its spawned `hermes serve` backend over JSON-RPC: groups.list / groups.state /
groups.log for the old room. On origin/main the store is created empty and
every call answers 4114 "hosted room not found"; with the one-shot import the
room, its cursor and its event come back, shared-state.db exists and the
legacy state.db is left intact.
Docs: one sentence on where room state lives and that pre-existing rooms are
copied across once on update. Contributor mapping for L4XB, whose #109917
proposed the same import a little earlier (ATTACH + INSERT OR IGNORE into an
empty store); the marker-row variant from #109979 is the one landed here
because it also reaches installs that already created new rooms after the
upgrade.
Refs #109775
Co-authored-by: L4XB <lukas.buck@e-mail.de>
Serve foreground queue promotions and concurrent opens without interrupting backend work. Park retired scopes and keep failed-stop generations fenced until exit.
Co-authored-by: Nikolas de Hor <nikolasdehor79@gmail.com>
Co-authored-by: Mark Sheppard <mark@orchardstreetpress.com>
Co-authored-by: chelsealong <chelsealong@126.com>
When main retires a pooled backend for a foreground open, the renderer entry
riding it is still wantOpen: the socket's 'closed' state ran
scheduleReconnect -> reconnectSecondary -> openSecondary('background') and
immediately re-queued a spawn for the slot the retirement had just freed. The
wake/focus nudge (reconnectSecondaryGateways) re-arms parked entries too, so
even a stall-parked scope would have redialed on the next window focus.
Listen for `hermes:pool:retiring` in use-gateway-boot and route it through a
new `parkSecondariesForRetiredBackend(poolKey)`: every scope riding that
child (bare profile or `conn:local::<profile>`; both ride one local child)
gets wantOpen=false, its reconnect timer cleared, and a sticky `retiredByPool`
flag that the nudge skips. The entry stays, so bot tiles keep their card
without a live socket. Only an explicit open of the scope (rearmSecondary,
reached from ensureGatewayForAgent / openGatewayForAgent /
ensureGatewayForProfile / ensureActiveGatewayOpen) clears the flag and dials
again.
Turn-lease reporting (shape from #104871): retainGatewayForSessionTurn and
touchSecondaryGateways publish `activeTurn` per scope through the widened
touchBackend IPC. On release the SCOPE's lease state is reported, not the
single lease's, so a second session on the same backend keeps it marked.
Test (gateway-connection-lifecycle.test.ts): a retired local scope does not
redial from the nudge across ~400s of fake time while an unrelated remote
scope is untouched; an explicit open re-arms it and the nudge treats it
normally afterwards. RED on base: the nudge redialed the retired scope.
Co-authored-by: bounce12340 <128559392+bounce12340@users.noreply.github.com>
A foreground bot open against a full 3-slot local pool waited 30s for a slot
and timed out (production: 7,122 slot waits, 6,305 timeouts, ~790 cancelled
starts in 10 days on a 17-profile host). Nothing could free a slot: a child's
hard lease lives until the child exits (main.ts child 'exit' handler,
teardownFailedLocalBackend, stopPoolBackend after the bounded SIGTERM->SIGKILL
exit), while LRU eviction and the idle reaper key off lastActiveAt, which the
renderer refreshes every 60s for every open socket. A bot-tile-pinned resident
is keepalive-fresh forever. Occupied is not busy.
New `electron/pool-retire.ts` (pure, DI-testable like pool-spawn-coordinator):
a foreground dial that finds `activeCount >= poolMaxBackends()` may retire ONE
resident, under these rules:
* Proof is the backend's. Each LRU candidate is probed over
`/api/health/idle` (running sessions + running cron jobs + prompts waiting on
a human). Only `true` is idle; `false` and `null` (older runtime 404, probe
error, unreadable ledger) are busy. The renderer-published `activeTurn`
lease is an early skip, never the proof; entries no longer initialise it to
false, so a fresh backend is not evictable by default.
* Admission fence: concurrent foreground dials share one retirement and one
probe; the coordinator hands the freed slot to the ticket that queued first.
* Identity recheck (`pool.get(key) === entry`, no new turn lease) after every
await, and a re-probe immediately before the stop.
* The waiter's `coordinator.request()` is issued BEFORE the stop so it sits at
the queue head; the slot is released only by the retired child's real exit
through the existing stopPoolBackend -> releaseLocalBackendSlot path.
* `hermes:pool:retiring` is broadcast to every window before the SIGTERM so
the renderer parks the scope instead of redialing into the vacated slot.
main.ts grows only the trigger point, the HTTP probe, the broadcast and the
retirer construction. Kept from #104871 with credit: the trigger point before
`coordinator.request`, the `touchBackend(profile, options)` IPC widening
(preload.ts / global.d.ts), and the LRU-among-eligible selector shape.
Tests (pool-retire.test.ts): fence with two concurrent tickets over a real
LocalBackendSpawnCoordinator (one probe, one SIGTERM, slot granted only after
the simulated exit, exactly one waiter served); identity recheck aborts on a
swapped entry or a lease published after the probe; idle null / cron-running
ineligible with fall-through to the queue; renderer activeTurn:false loses to
a backend re-probe.
Co-authored-by: bounce12340 <128559392+bounce12340@users.noreply.github.com>
The primary profile renamed to a Bot Mode title ("Bobby") is @bobby to the
roster, autocomplete and the mention resolver, but buildGroupChatTurnPrompt
introduced it — to itself and to its peers — as @hermes. A member told
"You are @hermes" read "@bobby …" as someone else's message and replied
"(pass)", so the room settled with the addressed bot silent.
Use botMentionTag() (title-derived tag, falls back to the @handle) for the
viewer and the peer list; an unrenamed primary still reads @hermes.
Live repro: e2e/group-prompt-renamed-primary-handle.spec.ts fails on
origin/main (room log carries only the user line) and passes here (entry
authored by `default` saying "B"). Prompt hunk proposed by @chilledtonic
in #89720.
Mock inference gains a one-shot `E2E_CALL(<tool>)[<json>]` script so a spec can make
the REAL tool run from a real Bot Chat; the new spec creates a sender bot, seeds a
`writer` profile titled "Scribe", sends a DM to "Scribe" and asserts the rendered
tool result says "Message dispatched to @writer" (base: "No teammate named
'Scribe'"), plus the sender's state.db tool row.
Exercise real route contributions, StrictMode node replacement, size drift and caption translations without losing the current fullscreen regressions.
Co-authored-by: Muhammed Emin Boydak <me@eminboydak.com>
onBlur scheduled `window.setTimeout(closeTrigger, 80)` and never cleared it, so an
editor unmounted inside that window still ran closeTrigger() → setState after
teardown. In CI vitest surfaced it as an unhandled "ReferenceError: window is not
defined" originating in paste-url-is-text.test.tsx, failing check:test:ui with all
8201 tests green. The timer now lives in a ref, is replaced on re-blur and cleared
on unmount.
The two host.navigate cases did `await import('@/sdk')` inside the test body, so the
first transform of the whole SDK module graph counted against vitest's 15 s per-test
timeout. On loaded CI runners that import alone exceeded it, failing both cases on
~half of unrelated PRs (six Bot Mode PRs hit it today). A static import moves the
cost to module load, where it is not timed; the assertions are unchanged.