Commit Graph

5439 Commits

Author SHA1 Message Date
xxxigm
4ea57fa61b fix(auth): count profile .env keys in the explicit provider gate
os.getenv saw only the launch profile, so a DeepSeek key pasted in
another profile never appeared in Settings → Model until Refresh
models ran against that Bot's own backend.
2026-09-20 22:46:49 -04:00
xxxigm
050ea53aba fix(desktop): honor Applies-to on Custom Endpoints
The page only showed a read-only active-profile note, so endpoint
saves followed the left-rail Bot instead of the Settings chips
Accounts and API keys already share.
2026-09-20 22:46:49 -04:00
teknium1
60dd9778c6 fix(desktop): large text pastes attach from HERMES_HOME/composer-pastes when the chat cwd is elsewhere
Desktop persisted a large paste under Electron's userData dir and attached
it as `@file:<abs path>`; the tui_gateway prompt path expands that reference
with `allowed_root=cwd`, so `_resolve_path` refused it with "path is outside
the allowed workspace" whenever the chat's cwd was not an ancestor of the
paste dir (always on Linux/macOS, and on Windows for any project cwd).

Electron now writes pastes under `<HERMES_HOME>/composer-pastes`, and
`agent/context_references.py::_resolve_path` admits exactly that anchored
directory (active profile home and global root, via file_safety._hermes_dirs)
as the one root besides `allowed_root`. A sibling directory that merely
contains the substring stays refused; the credential deny-list still runs
on the admitted path.

Supersedes the substring whitelist proposed in #117150.

Fixes #117149
2026-09-20 19:45:30 -07:00
teknium1
3d0bdce9f9 fix(desktop): prove the sign-in wall from curl's arrival URL and title too
Port the reporter's (@tianyu-liu) url_effective half: curl reports where it
landed via --write-out on a tail buffer that survives the body budget, and
isAuthWall decides on arrival URL -> sign-in title -> markup, so the Apps
Script ServiceLogin shape (no markup id) is a wall too and a wall's own title
is never used as the link label. Tests trimmed to two invariants.

Co-authored-by: tianyu-liu <tianyu-liu@users.noreply.github.com>
2026-09-20 19:37:57 -07:00
Finn763
1c81ad3485 fix(desktop): never load a sign-in wall in the hidden link-title renderer
The cookieless title partition answers a Google Workspace link with Googles

(cherry picked from commit 31e7dd19fafb13b8350bf3271d91036f243079b3)
2026-09-20 19:37:57 -07:00
liuhao1024
04845f5f3e fix(desktop): skip the local gateway restart on update when the Desktop is remote-served
A Desktop whose active connection is remote (SSH/remote/cloud, including the
registry primary) owns no local messaging gateway, yet the update hand-off
always ran `hermes update --gateway`. On hosts where launchd/service recovery
fails, the updater falls back to a detached local `gateway run --replace`;
with the same Telegram bot token as the remote VPS gateway, the two processes
compete for getUpdates and Telegram rejects one consumer, taking the
production bot offline (#117529).

Pass the ownership down the hand-off: globalRemoteActive() now adds
--no-gateway (posix) / -NoGateway (windows) when the Desktop is remote-served,
and both orchestrators drop --gateway from every update invocation (initial +
retry). The local-ownership default keeps --gateway exactly as before.
2026-09-20 19:30:16 -07:00
Chukuwebuka-2003
338b31226d fix(desktop): surface an externally-terminated renderer with a recovery page (#116472)
The OS/Chromium can SIGKILL a renderer while the window is live (memory reclaim, an external kill). Hermes never does this itself and never reloads it (a killed-after-close window must not pop back up), so the window sat silent with only a desktop.log line.

- window-renderer-lifecycle.ts: optional onRendererTerminated(details) callback, invoked for an unrecoverable render-process-gone on a live (not destroyed) window; recoverable crashes still reload; expected teardown still does nothing.
- renderer-load-error-page.ts: optional escaped title for the recovery page.
- main.ts: wire the primary window's callback to a visible recovery page (reason + Reload), guarded against intentional quit/handoff (isQuittingForHandoff || backendShutdown.hasStarted()).
- tests: three-arm lifecycle invariant + custom-title test.

Electron-only slice harvested from #116592 (commit 9bb16bd6ae) per the split advised on #117084; the agent half of #116472 lives in #117084.

(cherry picked from commit 5bdfe7ed00c4b3f764d4044fdfa527f978140cf3)
2026-09-20 19:21:46 -07:00
teknium1
c4f5dd0dad test(desktop): widen the roster-enumeration scan window past the ssh install-id branch
enumerateRegistryAgentSources grew when the ssh branch started carrying the
install id learned by the inventory probe, so the 3_700-char slice in
backend-dial-claim.test.ts stopped before getJsonForBackend('/api/profiles')
and CI went red. Same fix the sibling update-all case already applied.
2026-09-20 19:13:13 -07:00
John Paul Soliva
82e147ffb8 fix(desktop): an ssh connection learns its backend install id, so two addresses for one machine collapse
#88828 gave every connection a stable backend identity so one physical install is one roster row
per profile — but wired the probe into the non-ssh branch only. `probeConnectionInstallId` has a
single caller, in the enumerator's `else`; the other writer, `rememberConnectionInstallId`, sits
in `hermes:connections:test` after its ssh branch has already returned. So an ssh connection's
`installId` was always undefined, `buildAgentRoster` always fell back to the per-connection key,
and a Mac registered twice over ssh (alias + address, LAN + WAN) showed every bot twice with
`@name-label` handles, pushed two rows per bot into every gateway's relay roster
(`write_remote_roster` dedups by connection_id + profile), and made a bare-handle DM to it
`ambiguous` — asking the sender to disambiguate between two ids naming one machine.

The id is a plain file at the install root, and the ssh inventory probe already reads the remote
home over its own session without spawning a dashboard, so `readRemoteInstallId` reads it there:
from the INSTALL root (a connection pinned at `<root>/profiles/<name>` must report the same
backend as one pointed at the root), read-only — a missing file stays missing, since minting
identity is the install's job and not a visiting client's — and only a well-formed id counts, so
an older backend keeps today's no-id behaviour.

Fixes #117226

(cherry picked from commit 3d596f9b8fc898c74f96a378588a0b4601bfe39d)
2026-09-20 19:13:13 -07:00
John Paul Soliva
07888c880f fix(desktop): removing or re-pointing a connection forgets what was cached about it
Three main-process caches are keyed by connection id — the ssh profile list, its retry stamp and
the backend install id — and neither lifecycle event that invalidates them evicted anything.
`hermes:connections:remove` stopped pooled backends and told renderers to dispose their sockets;
`saveRegistryConnection`'s dial-material branch recycled sockets for exactly this reason, its own
comment noting they "point at the OLD target". The caches were not in scope for either.

Ids are recycled label slugs (`connectionIdForLabel` suffixes only against CURRENTLY taken ids),
and `shouldRetrySshInventory` never retries a cached success — "Cached successes never retry" —
so a connection re-added under the same label, or simply pointed at another host, kept serving the
previous machine's profiles for the rest of the app session. Clicking one of those bots dialled
the new box and failed, the relay pushed the phantom names to every peer gateway, and a peer bot
DMing one got 4092 `no profile '<name>' on this gateway` while the new machine's real bots stayed
invisible. Only a restart or a manual Test recovered.

The three maps move into `connection-caches.ts`, where the invariant they share can be stated and
tested — each is valid only while its id names the same machine — and `evictConnectionCaches()`
is called from both handlers. Every existing reference in main.ts is unchanged: the module
exports the same Map objects.

Fixes #117227

(cherry picked from commit 03847f66e8eae50f1972511ec89421b0298dbd22)
2026-09-20 19:04:11 -07:00
teknium1
16b749f61f fix(desktop): keep the chat composer mounted through transient session loaders
The chat bar was gated on `!loadingSession`, so any one-render flip of the
routed session into its loading state (periodic sidebar / live-status
refresh dropping the routed row, a hydrate swapping the transcript through
an empty frame) unmounted the composer: draft, attachments, caret and focus
vanished and came back every ~30s. Once a route has rendered with its
composer, a later transient loader for the same route now keeps it mounted;
a route change, the exhausted state and watch windows still hide it.

The unmount also fired `placeCaretEnd` / `placeCaretAtOffset` on the
detached editor from the draft repaint, throwing
`addRange(): The given range isn't in document` inside React's commit and
looping the reconciler (the visible flicker). Both caret helpers now bail
when the editor or the computed range is no longer connected.

Fixes #117375
Fixes #117285
2026-09-20 18:53:37 -07:00
xielevi
5d9af50aa0 fix(desktop): gate the plain-text token warning on secure storage being unavailable
The Settings -> Gateway "Token stored in plain text" banner predates the
opt-in keychain policy: it was written when a plain token could only exist
on keyring-less Linux, and it fires off the token encoding alone. Since
keychain encryption became opt-in (default off), every saved token is
plain, so the banner shows for every default user, while
probeSecureTokenStorage deliberately reports availability in that mode
"so no plain-text warning banners fire: storing plaintext is the user's
chosen (default) mode, not a degraded state."

- Compute the renderer-facing signal in a new
  hardening.resolveRemoteTokenPlainText helper: true only when the token
  is plain AND secure storage is genuinely unavailable (the keyring-less
  opt-in path the banner was written for), and never under the
  HERMES_DESKTOP_REMOTE_URL env override.
- Behavioral tests for the truth table plus a source assertion pinning
  the call site; both proven red against the ungated logic.
- Scope the Linux-only keyring names in the plain-text copy (banner and
  save-time confirm, en/zh/zh-hant/ja/ru) to Linux, since both can render
  on any platform.

Fixes #117269
2026-09-20 18:47:20 -07:00
Finn763
f532b8adf5 fix(desktop): keep the edit composer available after a timeline rail jump
A rail jump selects a bounded around-page, which drove `onEdit: undefined`
on the runtime. assistant-ui's `useActionBarEdit` only checks
`composer.isEditing`, so the edit button stayed enabled while `beginEdit`
threw "Runtime does not support editing" — the inline composer was dead
for every message after any far jump, infectious downward, and healed
only by the floating jump button's returnToLatest.

`isDisabled` still keeps the page static (no submit/reload/branch) and
`editMessage` resolves its target against the live session store, so the
edit rewinds the real transcript and drops the page.

(cherry picked from commit 326b245ad7cc19a1d4db2a06da7a01cd07043117)
2026-09-20 18:31:49 -07:00
teknium1
b6d96ccc74 test(desktop): give the profiles-page profile mock the $showAllProfiles atom layout.ts reads
Scoping the right rail per profile made session-states import preview.ts,
which imports layout.ts, which derives its grouping from $showAllProfiles.
The Manage Profiles page test mocks @/store/profile without that export, so
vitest rejected the module graph and the whole suite failed to load.
2026-09-20 18:30:20 -07:00
teknium1
765cb40bb5 fix(desktop): trim right-rail scope salvage to the renderer store
Drop the per-profile Electron session partition (a second mechanism that
would reset every secondary-profile window's persisted layout once) and keep
the rail-per-owner store change only; trim the scope tests to two
invariants and sort imports for the repo lint.
2026-09-20 18:30:20 -07:00
Cody
c996d1c088 fix(desktop): scope the right rail to the chat on screen, not the gateway socket
The right rail's tabs were persisted under ONE global localStorage key, so a
preview opened in one agent's chat appeared in every other agent's chat —
Tess's 3D model showed up in VEXA's rail and vice versa.

apps/desktop/AGENTS.md states the rule this violates: "Persisted state must
declare its scope in its own key: is this global, or does it belong to a
connection, a profile, a stored session, a project, or a window? Getting the
scope wrong is how one profile's setting bleeds into another."

The scope is the CHAT ON SCREEN, deliberately not the window's gateway socket:
a focused tab does not swap the socket, and every Bot Mode chat is served by one
pooled backend, so a socket-keyed rail shows one agent's previews in every
agent's chat. bot-row.tsx documents the same trap for the roster highlight
("Highlight follows the chat on screen (focused session's owner), not the
gateway socket's home — a focused tab doesn't swap the socket").

The rail therefore resolves its scope from the focused session's owner via the
existing knownOwnerForSession ladder, the same value the roster highlight
trusts, so the two cannot disagree about whose chat is on screen.
session-states.ts owns that resolver and already imports preview.ts, so it
pushes the scope in (setPreviewScope) rather than the rail reaching back, which
would be an import cycle.

The bucket map mirrors tilesByProfile, including a "view key" that follows a
rename (without it the persist subscriber re-creates the bucket the rename just
deleted) and lazy adoption of a legacy global array into the first scope that
arrives, so tabs the user can see survive the migration. Registered in both
dropTilesForProfile and migrateTilesForProfile, as the guide requires of any
profile-keyed localStorage family.

Also gives each non-primary profile its own Electron session partition on the
session pop-out and instance windows, so a per-agent window keeps its own rail
even across partitions.

Tests: preview-tabs-scope.test.ts holds the contract (no cross-agent bleed, per
agent buckets, rename moves rather than strands, and a socket change does NOT
re-home the rail — the regression that made the first cut look correct).

(cherry picked from commit dd40bd75fe7e27b9164af67b352884fc28242fef)
2026-09-20 18:30:20 -07:00
fangliquan
46e1e49d30 fix(cron): preserve custom provider model choices
(cherry picked from commit c784dabdc3adb3573c8870d10b2a6db70e789e0f)
2026-09-20 18:27:54 -07:00
fangliquan
4de06d1dbf fix(desktop): honour updates.pre_update_backup=off in the Desktop updater preflight
The Electron updater copied state.db to state.db.pre-update-emergency-*.bak on every
update hand-off regardless of updates.pre_update_backup, pinning ~3x state.db on disk.
preflightStateDb now asks the backend (`hermes config get updates.pre_update_backup --json`)
and skips the emergency copy when the mode is off/false/none/disabled; any probe failure
fails open to taking the backup.

Squashed from PR #116755 (4 commits) for a clean apply onto origin/main.
2026-09-20 16:20:08 -07:00
teknium1
9c12b9808c fix(desktop): keep agent tips out of the persisted seen ledger
Agent tips are unbounded in number; only the ✕ ($retiredTips) needs to persist, so showTip no
longer writes agent: ids into $tipShownAt. Folds the duplicate store test into the first one.
2026-09-20 15:59:52 -07:00
teknium1
767ce1a5df test(desktop): tip.show bridge refuses a retired agent tip
Drives handleDesktopBridgeEvent (the production entry point) so a dropped
agentTipId wire at the call site goes red, not only the store helper.
2026-09-20 15:59:52 -07:00
wangtao
7a15dd1943 fix(desktop): hash persisted agent tip identities 2026-09-20 15:59:52 -07:00
wangtao
c1f96e6c65 fix(desktop): remember dismissed agent tips 2026-09-20 15:59:52 -07:00
teknium1
4f0ced3e5f fix(desktop): mask typographic double quotes too and document quoted stop words
macOS smart-quote substitution turns the straight quotes a user types into “ ” in the composer,
so the quoted-span mask covers both; the user-visible rule (stop words inside code, quotes or
blockquotes never hold) now has its sentence in bot-mode.md alongside the behaviour.
2026-09-20 15:58:58 -07:00
kokhlo
c5b27c4c8c fix(desktop): quoted or pasted stop words no longer hold a group-chat member (#117040)
stopWordPlacement() tokenized the raw message string, so a stop/halt/pause
token inside a fenced code block, inline code span, straight-quoted span or
blockquote line still landed within the two-token window of an @token and
held the resolved member. Reporting or pasting a log that contains
"stop @impl" held the bot again, repeatedly — all three repro cases from
the report hold on main.

maskQuotedAndCodeSpans() collapses each such span to the @tokens it
contains (mentions resolve from raw text and must keep their place in the
proximity window, so quoting AT a bot still releases a held member) or to
one neutral filler word, so masking never manufactures adjacency:
"stop `service` @impl" still holds. An unclosed fence — what a cut-short
paste produces — swallows the rest of the message; blockquote lines are
masked line-wise.

Mention resolution, bare-mention release and the @all wake semantics stay
untouched: the plain directive forms, the German-prose and distant-stop
suites are green, with new invariant tests for the quoted/pasted cases.

Fixes #117040
2026-09-20 15:58:58 -07:00
teknium1
c7f2e76c50 fix(desktop): drop the unwired owner parameter from fetchVoiceLiveStatus and trim the routing tests
refreshVoiceLiveStatus is the only caller and probes the active profile (engine selection is a
window-level capability), so the owner overload had no production reach; its direct-call test
pinned a surface that never shipped. Ownerless routing is now asserted inside the owner test.
2026-09-20 15:49:38 -07:00
Konstantin Khlopkov
ae1530745a fix(desktop): route GPT-Live sessions through the Bot owner's profile 2026-09-20 15:49:38 -07:00
tobenwarrior
5171ea18dc fix(desktop): scope plugin specifier scanning to code, not strings/comments
The runtime plugin loader's specifier regex is unanchored (from\s*['\"]), so a
plugin whose own source contains a string ending in "from" — e.g. a 'Copy
keys from' UI label — was rejected as an "unsupported import", and a mapped
specifier quoted inside a string (documenting the import form) was rewritten
into a shim blob URL, corrupting the string at load.

Scan the source once for code ranges (string, template-literal and comment
text excluded; template ${…} interpolations stay code) and honour specifier
matches only inside them. Both failure modes are real: a plugin's own label
made the entire plugin fail to load in the desktop app.

Tests: 3 new invariants proven red on base (label/comment must not
load-block, in-string specifiers must stay verbatim), 2 guards stay green
(real unmapped import still rejected, real mapped import still rewritten).

(cherry picked from commit 3ac1d440cb1e21f8931a75bc07074363fccaa0a7)
2026-09-20 15:46:26 -07:00
teknium1
522e121e90 fix(desktop): pin the update-check proxy deps exactly and document the proxy variables
The salvaged commit added https-proxy-agent, proxy-from-env and
@types/proxy-from-env as caret ranges; the repo pins every dependency to an
exact version so `npm ci` resolves the same tree everywhere. Pins are the
versions the lockfile already resolved (7.0.6 / 2.1.0 / 1.0.4), regenerated
with `npm install --package-lock-only`.

Documents that the Desktop update check now follows HTTPS_PROXY / HTTP_PROXY /
NO_PROXY in the environment-variables reference.
2026-09-20 15:08:40 -07:00
Jony
7473088b4d fix(desktop): honor proxy env for update API checks 2026-09-20 15:08:40 -07:00
teknium1
13fe9c7171 feat(providers): external-process provider support for standalone model-provider plugins (from #105863)
The provider-agnostic half of PR #105863, so a CLI-driven subscription provider can ship as a
standalone `kind: model-provider` plugin instead of a bundled one:

- ProviderProfile: `native_reasoning_details_type`, `model_aliases`, `get_model_context_length`,
  `get_usage_cost`, `setup_status`, `discover_models` hooks (all default None / no-op).
- Chat Completions transport: provider-native `reasoning_details` carriers follow only their
  declaring profile; standard records still replay on OpenRouter-style routes, strict routes
  drop the field wholesale (#70233). Relay/stream accumulate `delta.reasoning_details` verbatim.
- `hermes model`: the generic plugin flow gates an external-process row on the CLI's own login
  status (inline `login_command` on a TTY), offers `discover_models()` rows with per-row notes,
  and never writes config when the executable is missing.
- `/model` and the pickers: process providers list their live catalog merged with the pinned
  one, declared aliases/ids resolve inside the provider, and validation accepts a listed id
  without probing `process://`.
- Delegation keeps the selected external-process provider and protocol for the child.
- Model metadata / usage pricing consult the profile's bound and cost hooks first.
- Desktop: `[1m]` renders as a "1M" tag and hyphenated Anthropic versions read "Haiku 4.5".

The bespoke `_model_flow_external_process` and hard-coded `hermes_cli/main.py` paths from the
PR were dropped in favour of main's `_model_flow_plugin_provider`.

Co-authored-by: unsupportedpastels <unsupportedpastels@users.noreply.github.com>
2026-09-20 14:29:39 -07:00
teknium1
00c0ea6cbe fix(desktop): "Open containing folder" is offered only for a session on this computer, and says so when a path is not here
The statusbar workspace menu built its reveal item unconditionally while
the sidebar menus (file-actions.tsx, review/file-tree.tsx) already hid it
on a remote backend, and `hermes:fs:reveal` returned true after
`shell.showItemInFolder`, which silently no-ops on a missing item — so a
remote bot's workspace path gave a click that did nothing and reported
success.

- electron/fs-ipc.ts: `hermes:fs:reveal` answers false when nothing at the
  (tilde-expanded) path exists on this computer.
- lib/desktop-fs.ts: revealDesktopPath surfaces that false as an error the
  existing revealFile toast shows (new i18n key fileMenu.revealUnavailable;
  locales fall back to English through defineLocale).
- store/file-actions.ts: shouldOfferLocalReveal() — the focused row's
  Connections tag decides (a row tagged with another gateway is never
  local, even under a local primary); an untagged row follows the window's
  primary mode, the rule the sidebar already applies.
- use-statusbar-items.tsx: the reveal item is gated on it.

Slimmer redo of #115168 by @jonpol01 (19 files): same mechanism, without
the project-menu/workspace-header rewiring and the per-locale translations.

Fixes #115167
Co-authored-by: John Paul Soliva <soliva.johnpaul@icloud.com>
2026-09-20 14:29:15 -07:00
teknium1
ead1a80a33 test(desktop): trim the in-flight marker suite to two invariants
Keep the two tests that pin the fix: the marker is written at submit and
cleared with the reply (production entry runGroupChatMemberTurn), and a
marker left by a previous Desktop process is harvested at the next
boundary. The live-poll-owns-marker and 4007-drop cases are implied by
the first (seen.live === true while the poll runs) and by the harvest
code path; salvage bar is two invariant tests per fix.
2026-09-20 14:22:28 -07:00
teknium1
aae3c737ad feat(desktop): hermes://plugin/install?catalog=<name> opens the reviewed catalog install dialog
A `catalog=<name>` deep link now resolves the name against the live plugin
catalog feed (the same `/docs/api/plugins.json` the Capabilities → Plugins
picker renders) and opens the Install Plugin dialog in its reviewed/pinned
catalog mode — identical to an in-app catalog pick, via one shared
`openCatalogPluginInstall` helper the Plugins tab now uses too.

The `catalog` param claims the link outright: an unknown, invalid, or
unresolvable name is a clear error toast and nothing else. It is never
reinterpreted as a git identifier, so a link cannot smuggle an unreviewed
repo behind a familiar-looking name (a `repo=` riding along is ignored).
2026-09-20 13:56:30 -07:00
teknium1
2c78b9b39e feat(process_registry): stamp exited_at and expose it on process.list
The live-work docks retire a finished background process ~60 s after it
ends; the registry only knew started_at, so the age of an exit was not
observable. _move_to_finished is the single choke point every exit path
(reader loop, reconcile, kill) passes through, so the stamp lives there.
completion_reason rides along so a killed process can read "killed"
instead of "exit -15".
2026-09-20 13:55:03 -07:00
teknium1
099d19a512 fix(desktop/bots): outside writes into member sessions surface on room open (#93813)
Review follow-ups on the external-write mirror:

- Idle trigger: `openGroupChat` now runs `sweepExternalGroupWrites`, one
  `session.resume` per stored member session whose thread the room still
  shows, then the existing mirror. A Bot posting into its own room session
  between rounds (the reporter's scenario) is posted the moment the user
  opens the room, not only once the room next drives that member and it
  happens to be a responder. No polling; a room mid-round is left to the
  round, which sweeps its responders itself.
- Classifier: the single `[System:` skip becomes the canonical synthetic
  user-row set (mirrors `agent/context_compressor.py::
  _SYNTHETIC_USER_ROW_PREFIXES`, comment links both) plus the gateway's
  `display_kind` on typed scaffolding rows. A compaction handoff, cron
  delivery, delegation result or steer marker is never mirrored as member
  speech, and the assistant row reacting to it closes the exchange instead
  of inheriting the previous writer's origin.
- Stranded harvest picks the FIRST substantive assistant row after the
  header-prefixed prompt and stops at the next outside user row, so a CLI
  answer written after the late reply is no longer posted as the turn reply
  and then mirrored again.
- Cursor edges (documented in the module header): first sight of a session
  seeds the cursor at the transcript's current length — a room hydrated from
  the gateway mirror (which carries no cursors) on a second Desktop does not
  re-post its history; "late, never lost" holds from that moment on, at the
  cost of no history replay. A cursor past the end after compaction is
  reset to the end. A missing snapshot leaves the cursor alone.

Tests stay at 2 in group-external-writes.test.ts: the negative control now
also opens the room without a drive and asserts the peer exchange arrives
while the compaction/cron/auto-continue rows and their answers do not. Red
with the `openGroupChat` call removed and with the prefix set reverted.
2026-09-20 13:44:30 -07:00
teknium1
e00aa13d7d fix(desktop/bots): writes into a member's Group session reach the room log (#93813)
A member's hidden per-group session is an ordinary Hermes session, so the
CLI (`hermes -p <bot> chat --resume "Group: <room> · <thread>"`), cron and
the agent's own tools append to it too. Those rows reached the transcript
but never the room log, so the room silently diverged from what the member
actually said.

What: new sibling group-external-writes.ts sweeps each member session's
unseen tail on the two paths that already read it — the pre-resume
snapshot in runGroupChatMemberTurnLeased and the stranded-marker harvest —
and appends the rows the room engine did not write itself, authored by
that member, in the session key's thread. Cursor keyed by the SESSION key
(`thread:<t>::<memberKey>`) in room.externalCursors, persisted through all
three room projections (updateGroupChat, durableGroupChatRooms, plugin.tsx
hydrate) so a window restart never re-mirrors a row.

Why the classifier is header-based: every room-fed prompt opens with the
header buildGroupChatTurnPrompt writes (now the exported
GROUP_PROMPT_HEADER_PREFIX), agent-injected `[System:` rows continue the
open exchange, and an assistant row answers whichever user row preceded it.

Why the round watermark walk: mirrored rows land after the round's submit
anchor, so `anchorIdx + 1` re-fed the member its own CLI conversation as
room news and drove an extra turn. A member's own entries are never news
to their author; the walk generalises the existing tail bump for replies.

Why the `at` stamps: the gateway mirror merge orders same-millisecond
entries by id, so a burst of mirrored rows appended in one tick came back
shuffled.

Ports the types.ts/group-chat.ts externalCursors persistence hunks of
PR #94340; its memberKey cursor and 'legacy' thread predate per-thread
member sessions and are replaced by the session-key cursor.

Co-authored-by: YusukeOshima-5564 <yusuke_oshima@capsor.co.jp>
2026-09-20 13:44:30 -07:00
teknium1
08c4063ff4 test(desktop): voice boundary test partial-mocks @/hermes and @/api/client
main went red after ae7bf98971 (voice playback threads the Bot owner scope
through api/client::ownerScoped) met 96cb6636d3 (the new boundary test):
the wholesale vi.mock of @/hermes drops setApiRequestProfile, which the
profile-scope store reads at import, and the @/api/client mock lacks
ownerScoped. Spread importOriginal() on both mocks so only the intended
seams are faked.
2026-09-20 13:35:26 -07:00
John Paul Soliva
8cc4ff5d66 fix(desktop): publish the recovered binding before the retried attach, not after it returns
Review (ehz0ah): withSessionNotFoundResume retries stageForSession(recoveredId)
before it returns, so recording the stored→runtime binding in the caller's
onSessionRecovered came after the retry. The window's session-RPC dispatcher
routes a session-scoped call by translating its runtime id back to the stored
session and that session's owner; with no binding yet, an off-screen remote or
multi-profile retry was rejected as ownerless before the attach reached the
owning backend.

uploadComposerAttachment now hands an onRecovered callback to the resolver,
which fires before call(recoveredId); syncAttachmentsForSubmit publishes there
— the stored→runtime map and updateSessionState(recovered, …, stored), the same
publish the submit path does — and only then retargets the foreground when the
target is the foreground.

Tests: the dispatcher case pins that a freshly recovered runtime id fails closed
until its binding is published and routes to the session's owner after; the
queued-image case records what the dispatcher could see at the moment the
retried image.attach is issued and requires both bindings to be there already
(red on the previous head: central=false).
2026-09-20 13:00:31 -07:00
John Paul Soliva
af9d98673f fix(desktop): a queued send's attachments stage on, recover and submit to ITS session, not the chat on screen
#74581 bound a queued prompt's text to its origin session, but the primary
chat's attachment sync still took its recovery id and transport rule from the
selected chat. A queued image send to chat B, drained after the user moved to
chat A with B's cached runtime dead (sleep/wake), resumed A, staged the image
there, submitted B's text into A, retargeted the foreground, and rebound stored
B to A's runtime for every later send.

syncAttachmentsForSubmit now takes the submit's own target session: the
recovery resumes that session, the transport rule follows its connection, the
recovered runtime is recorded for that session, and only a foreground send may
retarget the foreground. submit.ts passes targetStoredSessionId, so foreground
sends behave exactly as before.

Fixes #116285
2026-09-20 13:00:31 -07:00
teknium1
8a0c91f392 test(desktop): vault owner suite partial-mocks notifications instead of stubbing two exports
`vault-settings.owner.test.tsx` mocked `@/store/notifications` with only
`notify`/`notifyError`. Wiring `preserveLocalPendingTurnMessages` into
`use-background-sync` (imported by `store/gateway-switch`) pulls
`use-session-actions/utils` → `store/onboarding` → `store/cron-model-impact`
into the settings import graph, and that module dismisses its notification
from a module-level profile-scope subscriber. The suite's profile switch now
reaches `dismissNotification`, which the two-export stub did not define.

Spread `importOriginal()` and override only the two calls the suite asserts
on, so the double stays truthful as the store graph grows.
2026-09-20 13:00:00 -07:00
denis.dresvyanskiy
5f97bb0fa7 fix(desktop): keep un-acked optimistic messages across a resync
An optimistic user row is keyed `user-${Date.now()}-${rand}`
(use-prompt-actions/submit.ts:362) — an id the server never returns — and
nothing else holds it: the durable transcript-tail cache stores server truth
only, and the composer draft is cleared at send. Until the server acks, the
renderer's in-memory state is the only copy of what the user typed.

Three merge paths replaced that state wholesale.
`graftRefreshedTailOntoBackfill` drops the local tail in both of its branches
(with no anchor it returns the refreshed tail outright; otherwise it splices
`previous.slice(0, anchor)` in front of it), so a background refresh or a
post-turn rehydrate landing in the un-acked window silently deleted the
message and the user had to retype it. On a flaky link — mobile signal in a
tunnel — the reconnect is itself what triggers the rehydrate, so the drop and
the resync coincide exactly.

Wrap all three in `preserveLocalPendingTurnMessages`, the helper
`reconcileAuthoritativeChatMessages` already composes for the same reason
(use-session-actions/index.ts:270-272) — same order, pending-turn inner and
assistant-errors outer. It is idempotent and already handles contiguous user
runs (#73793) and the #70209/#75825 dedup, which is why the sibling path
use-session-tile-delegate.ts:59 needed no change.
2026-09-20 13:00:00 -07:00
teknium1
e848e612d0 test: trim the env-pinned sign-in coverage to two cases
Keep the lapsed-session sign-in (the report) and the saved-remote control;
the sign-out-then-in variant exercises the same unconditional Authentication
row. Salvage bar: at most two invariant tests per fix.
2026-09-20 12:52:42 -07:00
finn763
c28a0f74bb fix(desktop): an env-pinned remote can sign in again from Gateway settings
HERMES_DESKTOP_REMOTE_URL pins the connection's URL/mode, not the browser
session. Gateway settings nevertheless hid the whole remote block behind
`!state.envOverride` — URL, auth probe, and the Authentication row — so a
lapsed session had no sign-in anywhere in Settings, and the two session
buttons that did render were disabled by the same gate.

That made "Use local gateway" the only way back into a remote-only setup:
the boot-recovery card routes every remote failure into this panel (embedded
copy), and with no control to refresh the session the user had to start the
bundled local backend, fix the remote connection from there, and switch back
— exactly the dead end in #114856.

Render the remote block whenever the mode is remote: its URL input, Save, and
Test stay env-gated, while the session actions (Sign in / Sign out) no longer
are. They authenticate into the OAuth partition and persist no connection
routing, which is what the docs already promise for this setup
("you still sign in from the Gateway settings panel").
2026-09-20 12:52:42 -07:00
teknium1
40c5986d3e fix(desktop): install the macOS application menu after the first window exists
The updater's relaunch of the macOS shell died twice (#115332) with
EXC_BAD_ACCESS inside AppKit's key-equivalent routing: a keystroke landed on
the freshly launched app, AppKit asked the application menu's delegate to
populate, and Electron's delegate crashed because no window existed yet.

main.ts installed our menu inside app.whenReady() before createWindow(), and
Electron installs its own default menu even earlier — at
`will-finish-launching` — whenever the app has not called
Menu.setApplicationMenu before that event. On macOS a later
Menu.setApplicationMenu(null) never removes an installed NSMenu, so the
suppression has to run at module scope.

Now: Menu.setApplicationMenu(null) at module scope suppresses Electron's
default menu, and installApplicationMenuAfterFirstWindow() (new pure helper,
DI-tested) creates the first BrowserWindow and only then installs the real
menu on macOS. Windows/Linux keep shipping without an application menu.

Not live-run on macOS: no macOS runner is available here; the ordering is
proven by the helper's vitest cases and by code-path reading of Electron
40.10.2's lib/browser/init.ts + default-menu.ts.
2026-09-20 12:24:46 -07:00
Konstantin Khlopkov
1cf8a9fb41 fix(tui): count streamed frames as heartbeat liveness
The TUI's JsonRpcRequestChannel took the 'response' liveness default, so
only a ping-ack or a response to a pending call refreshed the deadline:
a socket streaming a delta every second through a 134s turn was declared
dead at the 45s deadline and force-closed, splitting sessions that went
on to complete server-side (#115251).

Pass heartbeatLiveness: 'any-inbound' on the TUI channel — the same
contract the desktop/web client already uses — so any inbound frame
counts as life. A silent drop still trips the deadline, so true
dead-transport detection is unchanged; only the false-kill class goes.

The channel's 'response' mode keeps its semantics for callers that
explicitly choose it; the shared wiring test pins the TUI construction
site so the option cannot silently regress.

Fixes #115251
2026-09-20 12:18:44 -07:00
teknium1
59496ffc6d fix(desktop): scale drive_preview input by the guest zoom factor
The act engine measures a target inside the guest in CSS pixels, but the
webview input space is css x zoom (the context-menu handler in
preview-pane.tsx measured the same relation live). Nothing on the input
path read a zoom factor, so at the shipped 90% default every pointer
landed 1/0.9 too far from the origin and missed silently while the tool
reported success (#116281). `toWebviewInputSpace` scales pointer events
at the pane send seam, reading the zoom from the webview (the guest keeps
its own per-host zoom) so every caller - click, glide, wheel - is covered.

Slimmer redo of #116363 by @StanleyStetson (zoom scaling only; the
three-level zoom/DPR fallback ladder and the witness-mismatch reporting
are not taken).

Co-authored-by: StanleyStetson <24758295+StanleyStetson@users.noreply.github.com>
2026-09-20 12:14:24 -07:00
teknium1
5a41d1bc7c fix(desktop): room-level group failures carry their reason too
The drive loop's outer catch recorded a bare `failed` row, so a room-level
failure rendered as "A bot hit an error" with nothing to act on. Classify it
through the same `groupFailureReason` the member catch and the stranded
harvest use, so a pool slot-wait at that level also reads as could-not-start.

Adds the invariant test that drives the production round loop with the
coordinator's slot-wait error: the activity row reads "couldn't start — too
many bots running" and no roster badge is set. Red with the member catch on
origin/main, green with the classifier call site.
2026-09-20 12:06:19 -07:00
joaomarcos
9ae34de7e9 fix(desktop): surface pool slot-wait cause in group activity labels 2026-09-20 12:06:19 -07:00
teknium1
15ce7a5533 fix(desktop): a group member that is still working keeps its turn for up to three hours
The 20-minute hard cap on a visibly busy member turn truncated real work:
in a five-bot local-model room 44% of turns (mean 33 min, longest 154 min)
crossed it, the member was recorded timed-out and stranded, the next
members were handed a turn on work that did not exist yet, and the room
settled while the worker was mid-deploy (#100274).

The cap is a runaway guard, not a budget: a member that stops reporting
work already expires on the 3-minute idle timeout, so the cap only ever
cut off a member that was demonstrably producing. Raise it to 180 minutes
(no measured turn exceeded it) instead of removing it, so a session stuck
reporting `running` still cannot hold a room forever. The quiet-room
harvest window follows the constant.

Slim redo of #100288 (@astraltrekkin), which dropped the clamp entirely
behind a per-room `turnHardCapMs` knob no editor sets.

Co-authored-by: astraltrekkin <astraltrekkin@users.noreply.github.com>
2026-09-20 12:06:19 -07:00
John Paul Soliva
4c9ad8549a fix(desktop): the in-flight marker token is a UUID (review nit)
A clock + 6 random base36 chars is not collision-proof across restarts; a
persisted marker equal to a token this process mints would read as live and
skip its harvest. crypto.randomUUID() removes the argument.
2026-09-20 12:06:19 -07:00