Commit Graph

5044 Commits

Author SHA1 Message Date
liuhao1024
b08ec00a70 fix(desktop): hand preview guest links to the audited opener via a guest preload
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
2026-09-17 09:05:24 -07:00
teknium1
6c057dbaa5 test(desktop): the no-heartbeat check expects the capability advertisement
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.
2026-09-17 09:04:38 -07:00
teknium1
f9d178f78e fix(tui_gateway): old app builds no longer stall the agent on clarify/approval; late approval choices count
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.
2026-09-17 09:04:38 -07:00
teknium1
ec14e8dfa2 test(desktop): session reads no longer expect the foreground dial tag
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.
2026-09-17 09:01:59 -07:00
teknium1
6ae79db3eb fix(desktop): explicitly scoped session reads keep the ambient dial priority
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
2026-09-17 09:01:59 -07:00
teknium1
53517082c8 fix(desktop): every explicitly scoped api/ read dials its profile backend foreground
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
2026-09-17 09:01:59 -07:00
teknium1
7b085a734b fix(desktop): a logged-out gh is asked again on the next update check
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
2026-09-17 08:54:13 -07:00
teknium1
c61a11474b fix(desktop): update check falls back to the gh CLI login before going anonymous
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.
2026-09-17 08:54:13 -07:00
kshitijk4poor
ab6e665807 refactor(desktop): a missing server-request registry lets the channel answer -32601
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.
2026-09-17 21:18:37 +05:30
kshitijk4poor
4c214157d8 test(desktop): registry-missing server request answers -32601
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.
2026-09-17 21:18:37 +05:30
kshitijk4poor
dc8fe4def8 refactor(shared): a crashed server-request handler reports through an owner hook, not console.error
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.
2026-09-17 21:18:37 +05:30
kshitijk4poor
e35743a6fe refactor(desktop): drop the socket-listener try/catch and share the registry-missing guard
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.
2026-09-17 21:18:37 +05:30
Pond
ae43ded1bc fix(desktop): answer server→client requests when a handler crashes instead of stalling the backend
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.
2026-09-17 21:18:37 +05:30
kshitijk4poor
43f94bc1c4 test(desktop): reasoning tests follow the sibling naming
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.
2026-09-17 21:18:30 +05:30
kshitijk4poor
74b4d0e41c fix(desktop): a half-arrived reasoning tag at a block boundary is held back too
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.
2026-09-17 21:18:30 +05:30
kshitijk4poor
bc6bd6998a perf(desktop): reasoning-block seam check reads two chars, not two string copies
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.
2026-09-17 21:18:30 +05:30
kshitijk4poor
a12ab2280a refactor(desktop): align reasoning tags with think_scrubber, trim #113302 tests
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.
2026-09-17 21:18:30 +05:30
finn763
956abb9d25 fix(desktop): reasoning blocks stop eating streamed prose
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).
2026-09-17 21:18:30 +05:30
kshitijk4poor
1450c7fcfb refactor(desktop): derive SidebarGrouping from the grouping order; invariant cycle test
`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.
2026-09-17 20:36:03 +05:30
kshitijk4poor
206e054063 refactor(desktop): one ordering for sidebar groupings shared by the menu and the keybind
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.
2026-09-17 20:36:03 +05:30
Ayush Nangia
81de5afa44 feat(desktop): add rebindable sidebar grouping cycle 2026-09-17 20:36:03 +05:30
Ayush Nangia
a9bba684b2 fix(desktop): hide no-op pop-out for file previews 2026-09-17 20:33:49 +05:30
kshitijk4poor
82e87e9ff5 refactor(desktop): read the primary view's turn-busy directly, no $petBusy alias
`$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`.
2026-09-17 20:33:19 +05:30
kshitijk4poor
8edc4085cf refactor(desktop): pet reads the active runtime's busy slice, drop the 30s recency TTL
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`.
2026-09-17 20:33:19 +05:30
Ayush Nangia
91f679a140 fix(desktop): keep pet activity bound to the live runtime 2026-09-17 20:33:19 +05:30
Ayush Nangia
e252aeac00 style(desktop): lint-fix pet import order (perfectionist/sort-named-imports) 2026-09-17 20:33:19 +05:30
Ayush Nangia
1878e5f969 fix(desktop): pet animates for reclaimed/draft sessions via recency-guarded activity
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.
2026-09-17 20:33:19 +05:30
Ömer Faruk Okumuş
bbaf7af5c8 fix(desktop): await page-owned promises before Electron IPC
Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com>
Co-authored-by: Ömer Faruk Okumuş <23411283+omerlefaruk@users.noreply.github.com>
2026-09-17 02:35:52 -05:00
teknium1
4146fcbd7a test(desktop): drop the source-text cpSync/rmSync guard
Root AGENTS.md bans tests that read source files and regex-match call sites;
the behavioral accented-tree restage test stays and is the invariant.
2026-09-17 00:26:32 -07:00
teknium1
afb9243f9c fix(desktop): widen libuv-only staging to get-windows and the rollback .bak wipe
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>
2026-09-17 00:26:32 -07:00
Kósa
8317fc8f4e fix(desktop): harden before-pack cleanup against the same non-ASCII rmSync no-op
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>
2026-09-17 00:26:32 -07:00
Kósa
2d88b9b4df fix(desktop): stage node-pty without fs.cpSync/fs.rmSync (non-ASCII Windows paths)
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>
2026-09-17 00:26:32 -07:00
teknium1
3dcf0d49ad fix(install): let Rolldown name the missing binding; repair on every OS
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).
2026-09-17 00:25:57 -07:00
Gille
f67cad172e test(desktop): register Rolldown repair tests with Vitest 2026-09-17 00:25:57 -07:00
Gille
ae9f42accf fix(install): repair missing Rolldown bindings 2026-09-17 00:25:57 -07:00
teknium1
9f077100cc fix(desktop): enable New Group Chat with one local bot plus remote-connection bots
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>
2026-09-17 00:22:51 -07:00
teknium1
d62036acb8 test(desktop): e2e — pre-split hosted rooms stay reachable from the Desktop backend
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>
2026-09-17 00:15:35 -07:00
brooklyn!
36842e639a test(desktop): exercise native retirement against live serve children
Verify cap preservation, foreground promotion, real cron survival and renderer parking across authenticated retirement and actual child exit.
2026-09-17 00:34:50 -05:00
brooklyn!
768e5c2af3 fix(desktop): serialize idle reclamation and release slots on real exit
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>
2026-09-17 00:34:50 -05:00
Austin Pickett
0fa67196b4 fix(desktop): a retired scope parks instead of redialing
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>
2026-09-17 00:34:50 -05:00
Austin Pickett
5fef73015a fix(desktop): cooperative pool retirement for foreground dials behind an admission fence
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>
2026-09-17 00:34:50 -05:00
teknium1
2b267f9467 fix(desktop): introduce a renamed primary bot by its own @tag in group-turn prompts
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.
2026-09-16 22:25:33 -07:00
teknium1
f879fead28 test(desktop): live Bot Chat spec for message_agent friendly-name targets
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.
2026-09-16 22:25:33 -07:00
brooklyn!
d6ee487993 test(desktop): cover titlebar replacement and navigation lifecycle
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>
2026-09-17 00:16:00 -05:00
brooklyn!
444c75c10a fix(desktop): keep sidebar navigation stable beside page titles
Keep standing sidebar tabs below the native band on every page and render page-owned titles in the workspace panel header. Preserve existing hide preferences and fullscreen measurements while rebinding replaced titlebar clusters.

Co-authored-by: Muhammed Emin Boydak <me@eminboydak.com>
Co-authored-by: KoNit-K <124019182+KoNit-K@users.noreply.github.com>
Co-authored-by: wukangcheng1994 <160389295+wukangcheng1994@users.noreply.github.com>
Co-authored-by: bl05b3e <benoit.lavenier@e-is.pro>
2026-09-17 00:16:00 -05:00
teknium1
597310857b fix(desktop): clear the composer's blur-close timer on unmount
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.
2026-09-16 22:15:33 -07:00
teknium1
1abc58adc4 test(desktop): import the SDK statically in the workspace-reveal spec
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.
2026-09-16 22:15:33 -07:00
brooklyn!
140d12545a fix(desktop): settle approval and tool-row layout together 2026-09-17 00:03:58 -05:00
brooklyn!
4bcde77e53 fix(desktop): retire card-stack height without a completion snap 2026-09-17 00:03:58 -05:00
teknium1
d4625b593d test(desktop): re-select the room before pressing Stop in the @all re-engage spec
Same background intro-turn tab yank as the other room specs: the room's
Stop button is hidden behind a fronted bot chat tab and the click times out.
2026-09-16 21:55:50 -07:00