From 68bd8befd243254bff4947831666449e6b59cbf8 Mon Sep 17 00:00:00 2001 From: lepetitprince716-prog Date: Thu, 6 Aug 2026 12:23:14 -0400 Subject: [PATCH 1/8] feat(desktop-sdk): expose focused-session state atoms to plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Disk plugins read app state exclusively through host.state, which only exposed the primary workspace tab ($activeSessionId). In the multi-tile layout, clicking a tile never touches that atom — and tile focus is a pure renderer concern, invisible to both gateway RPC and the event stream — so a plugin cannot follow the session the user is actually looking at. The core statusbar solves this same problem by reading the focused- session atoms (use-statusbar-items.tsx). Widen the generic plugin surface with the same signals, per the contribution rubric: - host.state.focusedSessionId — runtime id of the focused session (interacted tile, else the primary), the key for session.* RPC - host.state.focusedStoredSessionId — durable id for navigation and session-list matching - host.state.focusedUsage — live streamed UsageStats projection (context_used/max/percent, tokens, cost_usd), no RPC needed Additive only; no existing behavior changes. tsc --noEmit clean. Verified end-to-end with a disk plugin that now tracks the focused session across tiles. --- apps/desktop/src/sdk/index.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index 654d5f9660..9a86fc3134 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -45,8 +45,14 @@ import { $gatewayState, $selectedStoredSessionId } from '@/store/session' -import { $focusedSessionState, $focusedStoredSessionId, $sessionStates } from '@/store/session-states' +import { + $focusedRuntimeId, + $focusedSessionState, + $focusedStoredSessionId, + $sessionStates +} from '@/store/session-states' import { runGatewayRestart } from '@/store/system-actions' +import type { UsageStats } from '@/types/hermes' // -- state: readonly views over the app's live atoms ------------------------- @@ -109,6 +115,10 @@ if (typeof window !== 'undefined') { $narrowViewport.listen(refresh) } +/** Live usage of the FOCUSED session, projected out of the streamed session + * state — the same readout the core statusbar's context chip paints. */ +const $focusedUsage = computed($focusedSessionState, state => state?.usage ?? null) + export const host = { state: { /** Runtime id of the active chat session (null on a fresh draft). */ @@ -126,6 +136,17 @@ export const host = { busyBySession: readonlyAtom>($busyBySession), /** Active workspace cwd ('' when detached). */ cwd: readonlyAtom($currentCwd), + /** Runtime id of the FOCUSED chat session — the interacted tile, else the + * primary. Prefer this over `activeSessionId` for any readout that + * should follow the user between tiles (context, tokens, cost). */ + focusedSessionId: readonlyAtom($focusedRuntimeId), + /** Stored (durable) id of the focused session — for navigation and + * session-list matching, where runtime ids don't survive reloads. */ + focusedStoredSessionId: readonlyAtom($focusedStoredSessionId), + /** Live usage snapshot of the focused session (`context_used` / + * `context_max` / `context_percent`, token counts, `cost_usd`) — + * streamed by the backend, no RPC needed. Null while unresolved. */ + focusedUsage: readonlyAtom($focusedUsage), /** Gateway socket state: 'idle' | 'connecting' | 'open' | …. Not turn-busy. */ gateway: readonlyAtom($gatewayState), /** Current main model slug. */ From 74b7d5da2d2ca58212730f1af583b9e491f198a5 Mon Sep 17 00:00:00 2001 From: lepetitprince716-prog Date: Thu, 6 Aug 2026 13:18:17 -0400 Subject: [PATCH 2/8] test(desktop-sdk): contract-test the focused-session host.state atoms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Locks the plugin-facing contract: the focused atoms exist as readonly nanostores, mirror the primary session while no tile is focused, project the focused session's usage, and — the behavior this PR exists for — follow the interacted tile while the primary-only $activeSessionId stays put. --- apps/desktop/src/sdk/host-state.test.ts | 92 ++++++++++++++++++++++++- 1 file changed, 91 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/sdk/host-state.test.ts b/apps/desktop/src/sdk/host-state.test.ts index 45c4a4c5e5..ddbc314df2 100644 --- a/apps/desktop/src/sdk/host-state.test.ts +++ b/apps/desktop/src/sdk/host-state.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createClientSessionState } from '@/lib/chat-runtime' import { $gatewayState } from '@/store/session' @@ -6,6 +6,96 @@ import { $sessionStates, dropSessionState, publishSessionState } from '@/store/s import { host } from './index' +// Plugins read app state exclusively through host.state — and before this +// contract existed, only the PRIMARY workspace tab was reachable +// ($activeSessionId). Clicking a tile never moved any plugin-visible atom, +// and tile focus is pure renderer state that gateway RPC can never see, so +// no plugin-side workaround was possible. These atoms are the plugin door to +// the same focused-session signals the core statusbar reads +// (use-statusbar-items.tsx). + +describe('host.state focused-session atoms', () => { + beforeEach(() => { + window.localStorage.clear() + vi.resetModules() + }) + + afterEach(() => { + vi.resetModules() + }) + + async function setup() { + const { host } = await import('@/sdk/index') + const states = await import('@/store/session-states') + const session = await import('@/store/session') + + return { host, states, session } + } + + it('exposes readonly atoms for the focused session (runtime id, stored id, usage)', async () => { + const { host } = await setup() + + for (const key of ['focusedSessionId', 'focusedStoredSessionId', 'focusedUsage'] as const) { + const store = host.state[key] + expect(store, key).toBeDefined() + expect(typeof store.get, 'function', key) + expect(typeof store.listen, 'function', key) + expect(typeof store.subscribe, 'function', key) + } + }) + + it('mirrors the primary session while no tile is focused', async () => { + const { host, states } = await setup() + + expect(host.state.focusedSessionId.get()).toBe(states.$focusedRuntimeId.get()) + expect(host.state.focusedStoredSessionId.get()).toBe(states.$focusedStoredSessionId.get()) + }) + + it('focusedUsage projects the focused session usage, null while unresolved', async () => { + const { host, states } = await setup() + + const focused = states.$focusedSessionState.get() + expect(host.state.focusedUsage.get()).toBe(focused?.usage ?? null) + }) + + it('follows the interacted tile while the primary-only atom stays put', async () => { + const { host, session } = await setup() + const tree = await import('@/components/pane-shell/tree/store') + const model = await import('@/components/pane-shell/tree/model') + const { registry } = await import('@/contrib/registry') + + // A second chat zone holding a session tile, next to the main workspace. + for (const id of ['workspace', 'session-tile:tile-a']) { + registry.register({ + area: 'panes', + data: id === 'workspace' ? { placement: 'main', uncloseable: true } : { placement: 'main' }, + id, + render: () => null, + title: id + }) + } + tree.declareDefaultTree( + model.split('row', [ + model.group(['workspace'], { active: 'workspace', id: 'grp-main' }), + model.group(['session-tile:tile-a'], { active: 'session-tile:tile-a', id: 'grp-side' }) + ]) + ) + + const primaryBefore = session.$activeSessionId.get() + const primarySelection = session.$selectedStoredSessionId.get() + + // Focusing the tile zone moves the focused atoms onto the tile's session… + tree.noteActiveTreeGroup('grp-side') + expect(host.state.focusedStoredSessionId.get()).toBe('tile-a') + // …while the primary-only atom a plugin used to rely on does not move. + expect(host.state.activeSessionId.get()).toBe(primaryBefore) + + // Focusing back homes to the primary's selection, whatever it is. + tree.noteActiveTreeGroup('grp-main') + expect(host.state.focusedStoredSessionId.get()).toBe(primarySelection) + }) +}) + describe('host.state busy vs gateway', () => { afterEach(() => { $sessionStates.set({}) From a445a812792801eff3a3ab514c96973bbf8431c5 Mon Sep 17 00:00:00 2001 From: lepetitprince716-prog Date: Thu, 6 Aug 2026 15:18:04 -0400 Subject: [PATCH 3/8] fix(desktop-sdk): type focusedUsage as Partial, fix expect arity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClientSessionState.usage is Partial (app/types.ts) — the backend streams whichever fields changed — so the computed produces ReadableAtom | null>. Annotate the entry honestly instead of claiming full UsageStats, and document the fallback rule for plugin authors. Also collapse the three-argument expect() calls in the contract test (vitest takes one message arg). Addresses triage review on PR #80461. --- apps/desktop/src/sdk/host-state.test.ts | 7 ++++--- apps/desktop/src/sdk/index.ts | 6 ++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/sdk/host-state.test.ts b/apps/desktop/src/sdk/host-state.test.ts index ddbc314df2..1dd2590092 100644 --- a/apps/desktop/src/sdk/host-state.test.ts +++ b/apps/desktop/src/sdk/host-state.test.ts @@ -38,9 +38,9 @@ describe('host.state focused-session atoms', () => { for (const key of ['focusedSessionId', 'focusedStoredSessionId', 'focusedUsage'] as const) { const store = host.state[key] expect(store, key).toBeDefined() - expect(typeof store.get, 'function', key) - expect(typeof store.listen, 'function', key) - expect(typeof store.subscribe, 'function', key) + expect(typeof store.get, key).toBe('function') + expect(typeof store.listen, key).toBe('function') + expect(typeof store.subscribe, key).toBe('function') } }) @@ -74,6 +74,7 @@ describe('host.state focused-session atoms', () => { title: id }) } + tree.declareDefaultTree( model.split('row', [ model.group(['workspace'], { active: 'workspace', id: 'grp-main' }), diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index 9a86fc3134..1d71e32287 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -145,8 +145,10 @@ export const host = { focusedStoredSessionId: readonlyAtom($focusedStoredSessionId), /** Live usage snapshot of the focused session (`context_used` / * `context_max` / `context_percent`, token counts, `cost_usd`) — - * streamed by the backend, no RPC needed. Null while unresolved. */ - focusedUsage: readonlyAtom($focusedUsage), + * streamed by the backend, no RPC needed. Null while unresolved. + * Partial: the backend streams whichever fields changed, so every + * field read needs a fallback. */ + focusedUsage: readonlyAtom>($focusedUsage), /** Gateway socket state: 'idle' | 'connecting' | 'open' | …. Not turn-busy. */ gateway: readonlyAtom($gatewayState), /** Current main model slug. */ From 5fa092b4819c43bec6fcdeb7aaccf113e85101e7 Mon Sep 17 00:00:00 2001 From: lepetitprince716-prog Date: Thu, 6 Aug 2026 15:49:30 -0400 Subject: [PATCH 4/8] =?UTF-8?q?fix(desktop-sdk):=20address=20adversarial?= =?UTF-8?q?=20review=20=E2=80=94=20type=20honesty,=20real=20tile=20coverag?= =?UTF-8?q?e,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent second-pass review found three gaps: - focusedUsage is null | UsageStats, not Partial — ClientSessionState.usage is the full type (app/types.ts) and its only write site seeds the four required fields before merging (gateway-event.ts). The earlier Partial annotation traced the wrong type (SessionRuntimeInfo, an RPC payload). Comment now names the genuinely optional fields instead. - The tile-focus contract test never seeded $sessionTiles/$sessionStates, so it proved focusedStoredSessionId follows a tile but could not distinguish focusedSessionId/focusedUsage working from broken. Seed a bound runtime with distinct usage and assert both readout atoms move. - The two public host.state references (website docs + bundled skill reference) enumerated the old six atoms; plugin authors would never discover the new ones. Both lists updated. tsc/eslint/vitest green (4/4). --- apps/desktop/src/sdk/host-state.test.ts | 23 ++++++++++++++++++- apps/desktop/src/sdk/index.ts | 6 ++--- .../references/desktop-plugins.md | 15 ++++++++---- .../developer-guide/desktop-plugin-sdk.md | 3 +++ 4 files changed, 38 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/sdk/host-state.test.ts b/apps/desktop/src/sdk/host-state.test.ts index 1dd2590092..00a94931aa 100644 --- a/apps/desktop/src/sdk/host-state.test.ts +++ b/apps/desktop/src/sdk/host-state.test.ts @@ -59,7 +59,7 @@ describe('host.state focused-session atoms', () => { }) it('follows the interacted tile while the primary-only atom stays put', async () => { - const { host, session } = await setup() + const { host, session, states } = await setup() const tree = await import('@/components/pane-shell/tree/store') const model = await import('@/components/pane-shell/tree/model') const { registry } = await import('@/contrib/registry') @@ -85,9 +85,30 @@ describe('host.state focused-session atoms', () => { const primaryBefore = session.$activeSessionId.get() const primarySelection = session.$selectedStoredSessionId.get() + // Bind the tile to a live runtime with its own usage, so the readout + // atoms (focusedSessionId / focusedUsage) — not just the navigation id — + // are proven to follow the tile. + const tileUsage = { + calls: 3, + input: 1200, + output: 300, + total: 1500, + context_used: 42000, + context_max: 200000, + context_percent: 21, + cost_usd: 0.0123 + } + + states.$sessionTiles.set([{ storedSessionId: 'tile-a', runtimeId: 'runtime-tile-a' }]) + states.$sessionStates.set({ + 'runtime-tile-a': { storedSessionId: 'tile-a', usage: tileUsage } as never + }) + // Focusing the tile zone moves the focused atoms onto the tile's session… tree.noteActiveTreeGroup('grp-side') expect(host.state.focusedStoredSessionId.get()).toBe('tile-a') + expect(host.state.focusedSessionId.get()).toBe('runtime-tile-a') + expect(host.state.focusedUsage.get()).toBe(tileUsage) // …while the primary-only atom a plugin used to rely on does not move. expect(host.state.activeSessionId.get()).toBe(primaryBefore) diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index 1d71e32287..05ad5208c9 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -146,9 +146,9 @@ export const host = { /** Live usage snapshot of the focused session (`context_used` / * `context_max` / `context_percent`, token counts, `cost_usd`) — * streamed by the backend, no RPC needed. Null while unresolved. - * Partial: the backend streams whichever fields changed, so every - * field read needs a fallback. */ - focusedUsage: readonlyAtom>($focusedUsage), + * The UsageStats-optional fields (context_*, cost_usd) arrive as the + * backend reports them, so read them with a fallback. */ + focusedUsage: readonlyAtom($focusedUsage), /** Gateway socket state: 'idle' | 'connecting' | 'open' | …. Not turn-busy. */ gateway: readonlyAtom($gatewayState), /** Current main model slug. */ diff --git a/skills/autonomous-ai-agents/hermes-agent/references/desktop-plugins.md b/skills/autonomous-ai-agents/hermes-agent/references/desktop-plugins.md index 4e34d70909..04cfbd3a53 100644 --- a/skills/autonomous-ai-agents/hermes-agent/references/desktop-plugins.md +++ b/skills/autonomous-ai-agents/hermes-agent/references/desktop-plugins.md @@ -56,11 +56,16 @@ The ONLY import surface is `@hermes/plugin-sdk` (plus `react` / - `host.state.*` — readonly reactive atoms: `activeSessionId`, `busy`, `awaitingResponse`, `busyBySession`, `cwd`, `gateway` (socket state, not - turn-busy), `model`, `profile`, `viewport`. - `busy` is true while the focused chat is working after a send (thinking - and streaming). `awaitingResponse` is true until the first assistant - payload. `busyBySession` maps runtime session id → mid-turn, for rosters - that watch every session. Read with `.get()` in handlers, + turn-busy), `model`, `profile`, `viewport`, plus the tile-aware focused + session atoms: `focusedSessionId` (runtime id — key for `session.*` RPC), + `focusedStoredSessionId` (durable id — navigation / list matching), and + `focusedUsage` (live streamed `UsageStats` of the focused session, no RPC + needed). `busy` is true while the focused chat is working after a send + (thinking and streaming). `awaitingResponse` is true until the first + assistant payload. `busyBySession` maps runtime session id → mid-turn, + for rosters that watch every session. Prefer the focused atoms for any + readout that should follow the user between tiles. Read with `.get()` + in handlers, `useValue(atom)` in components. - `host.request(method, params)` — gateway JSON-RPC (sessions, config, skills, cron — everything the app uses). diff --git a/website/docs/developer-guide/desktop-plugin-sdk.md b/website/docs/developer-guide/desktop-plugin-sdk.md index 73369e66ec..d0c4892277 100644 --- a/website/docs/developer-guide/desktop-plugin-sdk.md +++ b/website/docs/developer-guide/desktop-plugin-sdk.md @@ -378,6 +378,9 @@ host.state.activeSessionId // ReadableAtom host.state.awaitingResponse // ReadableAtom true until the first assistant payload host.state.busy // ReadableAtom focused chat is working after a send host.state.busyBySession // ReadableAtom> runtime id → mid-turn +host.state.focusedSessionId // ReadableAtom (runtime id of the FOCUSED session — tile-aware; prefer for session.* RPC) +host.state.focusedStoredSessionId // ReadableAtom (durable id — navigation / session-list matching) +host.state.focusedUsage // ReadableAtom (live streamed usage of the focused session, no RPC needed) host.state.cwd // ReadableAtom host.state.gateway // ReadableAtom socket state ('idle' | 'connecting' | 'open' | …) host.state.model // ReadableAtom From 6ce7922af11ecfc2b668293b94c24401414c0a83 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 16 Aug 2026 03:06:01 -0700 Subject: [PATCH 5/8] chore: map contributor email for focused-session atoms salvage --- .../emails/lepetitprince716-prog@users.noreply.github.com | 1 + 1 file changed, 1 insertion(+) create mode 100644 contributors/emails/lepetitprince716-prog@users.noreply.github.com diff --git a/contributors/emails/lepetitprince716-prog@users.noreply.github.com b/contributors/emails/lepetitprince716-prog@users.noreply.github.com new file mode 100644 index 0000000000..568b68869a --- /dev/null +++ b/contributors/emails/lepetitprince716-prog@users.noreply.github.com @@ -0,0 +1 @@ +lepetitprince716-prog From 9e062912b9c309993c970aa978cde339a7559627 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 16 Aug 2026 01:49:04 -0700 Subject: [PATCH 6/8] fix(desktop): exclude process-less descriptors from backend pool LRU cap Remote/cloud registry descriptors (entry.process === null) shared the POOL_MAX_BACKENDS cap with real spawned local backends, so a roster refresh across N registered remote connections could LRU-evict a live local backend idle past the keepalive window. Cap accounting and cap-driven eviction now count only entries with a live child process; descriptors remain subject to the idle reaper. --- apps/desktop/electron/main.ts | 34 ++++----- apps/desktop/electron/pool-eviction.test.ts | 85 +++++++++++++++++++++ apps/desktop/electron/pool-eviction.ts | 58 ++++++++++++++ 3 files changed, 156 insertions(+), 21 deletions(-) create mode 100644 apps/desktop/electron/pool-eviction.test.ts create mode 100644 apps/desktop/electron/pool-eviction.ts diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 2776d84510..0b2017a42c 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -108,6 +108,7 @@ import { shouldRemoveAppBundle, uninstallArgsForMode } from './desktop-uninstall' +import { selectPoolEvictions } from './pool-eviction' import { describeDevCdpDecision, resolveDevCdpPort } from './dev-cdp' import { installEmbedReferer } from './embed-referer' import { createEventDeduper } from './event-dedupe' @@ -9420,31 +9421,22 @@ function touchPoolBackend(profile) { } } -// Evict least-recently-used pool backends until at most `keep` remain — but only -// ever evict backends without a live renderer socket (stale beyond the keepalive -// window). When every backend is actively kept alive we let the pool exceed the -// soft cap rather than kill a running session. +// Evict least-recently-used SPAWNED pool backends until at most `keep` remain — +// but only ever evict backends without a live renderer socket (stale beyond the +// keepalive window). When every backend is actively kept alive we let the pool +// exceed the soft cap rather than kill a running session. Process-less +// descriptor entries (remote/cloud registry sources, per-profile remote +// overrides — `entry.process === null`) are excluded from the cap entirely: +// they hold no local process, so counting them used to let a roster refresh +// across N registered remote connections LRU-evict a REAL local backend that +// was merely idle past the keepalive window. Descriptors are still reclaimed +// by the idle reaper. function evictLruPoolBackends(keep) { - if (backendPool.size <= keep) { - return - } - - const now = Date.now() - - const evictable = [...backendPool.entries()] - .filter(([, entry]) => now - (entry.lastActiveAt || 0) > POOL_KEEPALIVE_FRESH_MS) - .sort((a, b) => (a[1].lastActiveAt || 0) - (b[1].lastActiveAt || 0)) - - let removable = backendPool.size - Math.max(0, keep) - - for (const [profile] of evictable) { - if (removable <= 0) { - break - } + const evictions = selectPoolEvictions(backendPool.entries(), Math.max(0, keep), Date.now(), POOL_KEEPALIVE_FRESH_MS) + for (const profile of evictions) { rememberLog(`Evicting idle profile backend "${profile}" (LRU cap ${POOL_MAX_BACKENDS})`) stopPoolBackend(profile) - removable -= 1 } } diff --git a/apps/desktop/electron/pool-eviction.test.ts b/apps/desktop/electron/pool-eviction.test.ts new file mode 100644 index 0000000000..cb44e8d285 --- /dev/null +++ b/apps/desktop/electron/pool-eviction.test.ts @@ -0,0 +1,85 @@ +/** + * Tests for electron/pool-eviction.ts — LRU cap accounting for the desktop + * backend pool. The cap exists to bound SPAWNED local backends (real child + * processes); process-less descriptor entries (remote/cloud registry sources, + * per-profile remote overrides) must not count against it, or a roster + * refresh across N registered remote connections evicts a real local backend + * that was merely idle past the keepalive window. + */ + +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { selectPoolEvictions } from './pool-eviction' + +const NOW = 1_000_000 +const FRESH_MS = 90_000 + +/** A spawned local backend entry (has a child process). */ +const spawned = (idleMs: number) => ({ process: { pid: 123 }, lastActiveAt: NOW - idleMs }) + +/** A process-less remote/cloud descriptor entry. */ +const descriptor = (idleMs: number) => ({ process: null, lastActiveAt: NOW - idleMs }) + +test('process-less descriptors do not count toward the cap', () => { + // 1 real spawned backend idle beyond the keepalive window + 3 remote + // descriptors: total size (4) exceeds keep (2), but only ONE entry holds a + // process, so nothing may be evicted. This is the roster-refresh regression: + // the old size-based accounting evicted the real local backend here. + const entries: [string, ReturnType][] = [ + ['default', spawned(120_000)], + ['conn:homelab::default', descriptor(0)], + ['conn:office::default', descriptor(0)], + ['conn:cloud-a::default', descriptor(0)] + ] + + assert.deepEqual(selectPoolEvictions(entries, 2, NOW, FRESH_MS), []) +}) + +test('spawned backends over the cap are still LRU-evicted', () => { + const entries: [string, ReturnType][] = [ + ['a', spawned(500_000)], + ['b', spawned(300_000)], + ['c', spawned(100_000)], + // Descriptors interleaved: must neither inflate the count nor be evicted. + ['conn:x::a', descriptor(999_000)] + ] + + // keep=2 → one spawned backend over; evict the least-recently-used ('a'). + assert.deepEqual(selectPoolEvictions(entries, 2, NOW, FRESH_MS), ['a']) +}) + +test('fresh spawned backends are spared even over the cap', () => { + const entries: [string, ReturnType][] = [ + ['a', spawned(1_000)], + ['b', spawned(2_000)], + ['c', spawned(3_000)] + ] + + // All within the keepalive window → the pool may exceed the soft cap. + assert.deepEqual(selectPoolEvictions(entries, 1, NOW, FRESH_MS), []) +}) + +test('evicts only enough stale spawned backends to reach the cap', () => { + const entries: [string, ReturnType][] = [ + ['a', spawned(500_000)], + ['b', spawned(400_000)], + ['c', spawned(300_000)], + ['d', spawned(1_000)] + ] + + // 4 spawned, keep 2 → remove 2, oldest first. + assert.deepEqual(selectPoolEvictions(entries, 2, NOW, FRESH_MS), ['a', 'b']) +}) + +test('descriptor-only pools never evict', () => { + const entries: [string, ReturnType][] = [ + ['conn:a::p', descriptor(999_000)], + ['conn:b::p', descriptor(999_000)], + ['conn:c::p', descriptor(999_000)], + ['conn:d::p', descriptor(999_000)] + ] + + assert.deepEqual(selectPoolEvictions(entries, 2, NOW, FRESH_MS), []) +}) diff --git a/apps/desktop/electron/pool-eviction.ts b/apps/desktop/electron/pool-eviction.ts new file mode 100644 index 0000000000..628a331199 --- /dev/null +++ b/apps/desktop/electron/pool-eviction.ts @@ -0,0 +1,58 @@ +// LRU cap accounting for the desktop backend pool. +// +// The pool holds two very different kinds of entries under one Map: +// 1. SPAWNED local profile backends — a real child process each (the thing +// the POOL_MAX_BACKENDS cap exists to bound). +// 2. Process-less connection DESCRIPTORS — remote/cloud registry sources and +// per-profile remote overrides (`entry.process === null`). These hold no +// local process; their only cost is a cached descriptor. +// +// Counting both kinds against the cap meant a roster refresh across N +// registered remote connections could push the Map size over the cap and +// LRU-evict a REAL spawned backend that had merely been idle past the +// keepalive window. Cap accounting (and cap-driven eviction) therefore only +// considers entries with a live child process; descriptor entries remain +// subject to the idle reaper, just not to the process cap. + +export interface PoolEvictionEntry { + lastActiveAt?: null | number + process?: unknown +} + +/** + * Pick which pool keys the LRU cap should evict so that at most `keep` + * SPAWNED backends remain. Only entries with a live child process count + * toward the cap or are eligible for cap eviction, and — as before — only + * entries idle beyond `freshMs` may be evicted (an actively kept-alive pool + * may exceed the soft cap rather than kill a running session). + */ +export function selectPoolEvictions( + entries: Iterable<[K, PoolEvictionEntry]>, + keep: number, + now: number, + freshMs: number +): K[] { + const spawned = [...entries].filter(([, entry]) => Boolean(entry.process)) + + if (spawned.length <= keep) { + return [] + } + + const evictable = spawned + .filter(([, entry]) => now - (entry.lastActiveAt || 0) > freshMs) + .sort((a, b) => (a[1].lastActiveAt || 0) - (b[1].lastActiveAt || 0)) + + let removable = spawned.length - Math.max(0, keep) + const evictions: K[] = [] + + for (const [key] of evictable) { + if (removable <= 0) { + break + } + + evictions.push(key) + removable -= 1 + } + + return evictions +} From 28c729e68f6f841a88f17678c369792a8ea22c8c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 16 Aug 2026 01:51:23 -0700 Subject: [PATCH 7/8] fix(desktop): tear down renderer secondaries when a registry connection is removed Removing a connection stopped its pooled backends and ssh tunnels but never told the renderer: for remote/cloud sources there is no local process to die, so the removed connection's WebSocket stayed open and kept streaming ghost events into the UI until page reload. If the socket did drop, openSecondary -> getConnectionFor threw 'No connection with id' and scheduleReconnect retried forever (backoff caps at 15s, entry never evicted). - main now broadcasts 'hermes:connections:changed' on removal (and on material edits); preload exposes connections.onChanged. - use-gateway-boot subscribes and calls the new disposeSecondariesForConnection(), which disposes + evicts every secondary scoped to the connection id (redialing on edits). - reconnectSecondary fail-stops: when the Electron main reports the connection no longer exists, the entry is disposed and evicted instead of retrying forever; ordinary transport errors keep the existing backoff behavior. --- apps/desktop/electron/main.ts | 19 ++ apps/desktop/electron/preload.ts | 11 +- .../src/app/gateway/hooks/use-gateway-boot.ts | 14 ++ apps/desktop/src/global.d.ts | 7 + .../gateway-connection-lifecycle.test.ts | 195 ++++++++++++++++++ apps/desktop/src/store/gateway.ts | 56 ++++- 6 files changed, 299 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/store/gateway-connection-lifecycle.test.ts diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 0b2017a42c..e1e22a0730 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -9144,6 +9144,21 @@ function sendConnectionApplied() { webContents.send('hermes:connection:applied') } +// Registry lifecycle push: a connection was removed or materially edited, so +// every window must tear down (and, for edits, re-dial) its secondary sockets +// scoped to that connection. Without this, a removed remote/cloud source keeps +// its renderer WebSocket open and streaming as a ghost, and an edited one +// keeps talking to the OLD endpoint until idle-reap. +function broadcastConnectionsChanged(payload: { connectionId: string; reason: 'removed' | 'updated' }) { + for (const win of BrowserWindow.getAllWindows()) { + const { webContents } = win + + if (webContents && !webContents.isDestroyed()) { + webContents.send('hermes:connections:changed', payload) + } + } +} + async function waitForBackendExit(child, timeoutMs = 5000) { if (!child || child.exitCode !== null || child.signalCode !== null) { return @@ -11907,6 +11922,10 @@ ipcMain.handle('hermes:connections:remove', async (_event, id) => { // Tear down anything the removed connection still had running: pooled // backends under its composite keys and any ssh tunnel scopes it owned. stopRegistryConnectionBackends(key) + // And the renderer side: without this push, secondaries scoped to the + // removed connection keep their WebSocket open (remote/cloud have no local + // process to kill) and stream ghost events until page reload. + broadcastConnectionsChanged({ connectionId: key, reason: 'removed' }) return { ok: true, registry: sanitizeConnectionsRegistry(registry) } }) diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 3c9f2ab1eb..96c76d4822 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -138,7 +138,16 @@ contextBridge.exposeInMainWorld('hermesDesktop', { setPrimary: id => ipcRenderer.invoke('hermes:connections:set-primary', id), test: id => ipcRenderer.invoke('hermes:connections:test', id), // Fan out `hermes update` to every eligible registered connection. - updateAll: () => ipcRenderer.invoke('hermes:connections:update-all') + updateAll: () => ipcRenderer.invoke('hermes:connections:update-all'), + // Registry lifecycle push (main → renderer): a connection was removed or + // materially edited, so secondaries scoped to it must be disposed (and, + // for edits, re-dialed at the new target). + onChanged: callback => { + const listener = (_event, payload) => callback(payload) + ipcRenderer.on('hermes:connections:changed', listener) + + return () => ipcRenderer.removeListener('hermes:connections:changed', listener) + } }, sshConfigHosts: () => ipcRenderer.invoke('hermes:ssh-config:hosts'), sshResolveHost: host => ipcRenderer.invoke('hermes:ssh-config:resolve', host), diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index 334ea4e604..dc988eb089 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -17,6 +17,7 @@ import { $gateway, closeSecondaryGateways, configureGatewayRegistry, + disposeSecondariesForConnection, ensureGatewayForProfile, pruneSecondaryGateways, reconnectSecondaryGateways, @@ -434,6 +435,18 @@ export function useGatewayBoot({ const offPowerResume = desktop.onPowerResume?.(() => reconnectNow()) const offConnectionApplied = desktop.onConnectionApplied?.(() => void softSwitch()) + // Registry lifecycle: a removed connection's secondaries must close NOW + // (remote/cloud have no local process whose death would drop the socket — + // they'd keep streaming ghost events); a materially edited one is + // disposed AND re-dialed so its sockets target the new endpoint. + const offConnectionsChanged = desktop.connections?.onChanged?.(payload => { + if (!payload || typeof payload.connectionId !== 'string') { + return + } + + disposeSecondariesForConnection(payload.connectionId, { redial: payload.reason === 'updated' }) + }) + const onOnline = () => reconnectNow() const onVisible = () => { @@ -636,6 +649,7 @@ export function useGatewayBoot({ document.removeEventListener('visibilitychange', onVisible) offPowerResume?.() offConnectionApplied?.() + offConnectionsChanged?.() offState() offEvent() offExit() diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 14f73a061e..b7486a5c59 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -143,6 +143,13 @@ declare global { // Fan out `hermes update` to every eligible registered connection; // cloud entries are skipped (platform-managed), each row independent. updateAll?: () => Promise<{ ok: boolean; results: DesktopConnectionUpdateResult[] }> + // Registry lifecycle push: fired when a connection is removed or + // materially edited so the renderer can dispose (and re-dial) the + // secondary gateways scoped to it. Optional: older Electron mains + // don't emit it. + onChanged?: ( + callback: (payload: { connectionId: string; reason: 'removed' | 'updated' }) => void + ) => () => void } sshConfigHosts: () => Promise sshResolveHost: (host: string) => Promise diff --git a/apps/desktop/src/store/gateway-connection-lifecycle.test.ts b/apps/desktop/src/store/gateway-connection-lifecycle.test.ts new file mode 100644 index 0000000000..846ee3501e --- /dev/null +++ b/apps/desktop/src/store/gateway-connection-lifecycle.test.ts @@ -0,0 +1,195 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Connection lifecycle for registry-scoped secondary gateways: +// +// 1. Removing a connection must dispose its secondaries — remote/cloud +// sources have no local process whose death would drop the socket, so +// without an explicit dispose the WebSocket stays open and streams ghost +// events until page reload. +// 2. A materially edited connection re-dials so fresh sockets target the +// NEW endpoint. +// 3. When the Electron main reports the connection no longer exists +// (`No connection with id`), the reconnect loop fail-stops and evicts +// the entry instead of retrying forever. + +const gatewayMocks = vi.hoisted(() => { + const instances: { close: ReturnType; connectionState: string }[] = [] + + return { + connect: vi.fn(async (_wsUrl: string): Promise => undefined), + instances + } +}) + +vi.mock('@/hermes', () => ({ + HermesGateway: class { + connectionState = 'closed' + close = vi.fn(() => { + this.connectionState = 'closed' + }) + connect = async (wsUrl: string): Promise => { + await gatewayMocks.connect(wsUrl) + this.connectionState = 'open' + } + onEvent = vi.fn(() => () => {}) + onState = vi.fn(() => () => {}) + constructor() { + gatewayMocks.instances.push(this as never) + } + } +})) +vi.mock('@/store/session', () => ({ + setConnection: vi.fn(), + setGatewayState: vi.fn() +})) +vi.mock('@/store/notify-baseline', () => ({ markNativeNotifyBaseline: vi.fn() })) + +const { + closeSecondaryGateways, + configureGatewayRegistry, + disposeSecondariesForConnection, + ensureActiveGatewayOpen, + ensureGatewayForAgent, + setPrimaryGateway +} = await import('./gateway') + +function installDesktop(stub: Record): void { + ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = stub +} + +function descriptorFor(connectionId: string, profile: string) { + return { + authMode: 'token', + baseUrl: `https://${connectionId}.invalid`, + mode: 'remote', + profile, + token: 'fake-test-token', + wsUrl: `wss://${connectionId}.invalid/api/ws?profile=${profile}` + } +} + +beforeEach(() => { + configureGatewayRegistry({ onEvent: vi.fn() } as never) + setPrimaryGateway({ connectionState: 'open' } as never, 'default') +}) + +afterEach(() => { + closeSecondaryGateways() + gatewayMocks.instances.length = 0 + vi.clearAllMocks() + vi.useRealTimers() + delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop +}) + +describe('disposeSecondariesForConnection', () => { + it('closes and evicts every secondary scoped to the removed connection', async () => { + const getConnectionFor = vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) => + descriptorFor(connectionId, profile) + ) + + installDesktop({ getConnectionFor }) + + await ensureGatewayForAgent('homelab', 'default') + await ensureGatewayForAgent('homelab', 'work') + await ensureGatewayForAgent('office', 'default') + + expect(gatewayMocks.instances).toHaveLength(3) + + disposeSecondariesForConnection('homelab') + + // Both homelab sockets closed; the office socket untouched. + expect(gatewayMocks.instances[0].close).toHaveBeenCalledOnce() + expect(gatewayMocks.instances[1].close).toHaveBeenCalledOnce() + expect(gatewayMocks.instances[2].close).not.toHaveBeenCalled() + + // No redial for a removal. + expect(getConnectionFor).toHaveBeenCalledTimes(3) + }) + + it('re-dials disposed secondaries when redial is requested (material edit)', async () => { + const getConnectionFor = vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) => + descriptorFor(connectionId, profile) + ) + + installDesktop({ getConnectionFor }) + + await ensureGatewayForAgent('homelab', 'default') + expect(gatewayMocks.connect).toHaveBeenCalledTimes(1) + + disposeSecondariesForConnection('homelab', { redial: true }) + + // The redial runs async through the normal open path — flush it. + await vi.waitFor(() => { + expect(gatewayMocks.connect).toHaveBeenCalledTimes(2) + }) + + // Old socket closed, fresh descriptor fetched (would carry the new URL). + expect(gatewayMocks.instances[0].close).toHaveBeenCalledOnce() + expect(getConnectionFor).toHaveBeenCalledTimes(2) + }) + + it('is a no-op for blank or unknown connection ids', async () => { + installDesktop({ + getConnectionFor: vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) => + descriptorFor(connectionId, profile) + ) + }) + + await ensureGatewayForAgent('homelab', 'default') + + disposeSecondariesForConnection('') + disposeSecondariesForConnection('ghost') + + expect(gatewayMocks.instances[0].close).not.toHaveBeenCalled() + }) +}) + +describe('reconnect fail-stop on a removed connection', () => { + it('evicts the entry instead of retrying when the registry no longer knows the id', async () => { + const getConnectionFor = vi + .fn() + .mockResolvedValueOnce(descriptorFor('homelab', 'default')) + .mockRejectedValue(new Error('No connection with id "homelab".')) + + installDesktop({ getConnectionFor }) + + await ensureGatewayForAgent('homelab', 'default') + expect(gatewayMocks.instances).toHaveLength(1) + + // Simulate the socket dropping after the connection was removed. + const socket = gatewayMocks.instances[0] as unknown as { connectionState: string } + socket.connectionState = 'closed' + + // ensureActiveGatewayOpen drives reconnectSecondary for the active scope. + const result = await ensureActiveGatewayOpen() + + expect(result).toBeNull() + // Fail-stop: the entry was disposed + evicted, so a second drive finds + // nothing to retry (no further getConnectionFor calls). + const callsAfterFailStop = getConnectionFor.mock.calls.length + await ensureActiveGatewayOpen() + expect(getConnectionFor.mock.calls.length).toBe(callsAfterFailStop) + }) + + it('keeps retrying on ordinary transport failures', async () => { + const getConnectionFor = vi + .fn() + .mockResolvedValueOnce(descriptorFor('homelab', 'default')) + .mockRejectedValueOnce(new Error('ECONNREFUSED')) + .mockResolvedValue(descriptorFor('homelab', 'default')) + + installDesktop({ getConnectionFor }) + + await ensureGatewayForAgent('homelab', 'default') + + const socket = gatewayMocks.instances[0] as unknown as { connectionState: string } + socket.connectionState = 'closed' + + // First drive fails with a transport error → entry survives. + await ensureActiveGatewayOpen() + // Second drive succeeds against the surviving entry. + const reopened = await ensureActiveGatewayOpen() + + expect(reopened).not.toBeNull() + }) +}) diff --git a/apps/desktop/src/store/gateway.ts b/apps/desktop/src/store/gateway.ts index 52b34a1133..1c2173b56c 100644 --- a/apps/desktop/src/store/gateway.ts +++ b/apps/desktop/src/store/gateway.ts @@ -235,8 +235,18 @@ async function reconnectSecondary(entry: Secondary): Promise { try { await openSecondary(entry) entry.reconnectAttempt = 0 - } catch { - // Transport failure → fall through to the backoff below. + } catch (error) { + // The registry no longer knows this connection (removed while we were + // backing off). Retrying forever can never succeed — fail-stop: dispose + // the entry and evict it instead of an infinite 15s-cap retry loop. + if (entry.connectionId && isMissingConnectionError(error)) { + entry.reconnecting = false + disposeSecondary(entry) + g.secondaries.delete(entry.scope) + + return + } + // Other transport failure → fall through to the backoff below. } finally { entry.reconnecting = false @@ -246,6 +256,15 @@ async function reconnectSecondary(entry: Secondary): Promise { } } +// Electron's getConnectionFor rejects with `No connection with id "…"` when +// the registry entry is gone. That is a permanent condition for the scoped +// socket, unlike transient transport errors. +function isMissingConnectionError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error ?? '') + + return message.includes('No connection with id') +} + function createSecondary(profile: string, connectionId: null | string = null): Secondary { const gateway = new HermesGateway() const scope = backendScopeKey(connectionId, profile) @@ -533,6 +552,39 @@ export function closeSecondaryGateways(): void { restoreActiveToPrimaryIfEvicted() } +// Registry lifecycle: a connection was removed or materially edited. Dispose +// every secondary scoped to it (a removed remote/cloud source has no local +// process to die, so without this its WebSocket stays open streaming ghost +// events). With `redial` (the edit case) each disposed profile is re-dialed +// through the normal open path so the fresh socket targets the NEW endpoint; +// the active scope re-activates so the foreground keeps painting. +export function disposeSecondariesForConnection(connectionId: string, opts: { redial?: boolean } = {}): void { + const id = String(connectionId || '').trim() + + if (!id) { + return + } + + for (const [key, entry] of [...g.secondaries]) { + if (entry.connectionId !== id) { + continue + } + + const wasActive = key === g.activeKey + + disposeSecondary(entry) + g.secondaries.delete(key) + + if (opts.redial) { + const reopen = wasActive + ? ensureGatewayForAgent(entry.connectionId, entry.profile) + : openGatewayForAgent(entry.connectionId, entry.profile) + + void reopen.catch(() => undefined) + } + } +} + // Self-accept so editing this module (or a fan-out that lands here) is an // in-place hot update instead of a full page reload — the live sockets in `g` // survive the swap. Dev-only: production strips import.meta.hot. From f4c80e4243d4616e7210e53cbeaf1e1492bb668c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 16 Aug 2026 01:51:35 -0700 Subject: [PATCH 8/8] fix(desktop): recycle live backends and sockets when a connection edit changes its target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit saveRegistryConnection only rewrote the registry file: editing a connection's URL/token/host left pooled backend descriptors under 'conn:::*' and open renderer sockets pointing at the OLD endpoint — the UI showed the new target while traffic kept flowing to the old one until idle-reap. When a save MATERIALLY changes an existing connection (endpoint / auth / ssh routing fields, via the new connectionDialFieldsChanged helper), main now stops that connection's pooled backends and tunnels (stopRegistryConnectionBackends, same teardown as removal) and broadcasts 'hermes:connections:changed' with reason 'updated' so renderers dispose and re-dial their secondaries at the new target. Label-only renames do not recycle. --- .../electron/connection-registry.test.ts | 29 +++++++++++++++ apps/desktop/electron/connection-registry.ts | 37 +++++++++++++++++++ apps/desktop/electron/main.ts | 13 ++++++- 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/apps/desktop/electron/connection-registry.test.ts b/apps/desktop/electron/connection-registry.test.ts index 67ff99c92a..6567616184 100644 --- a/apps/desktop/electron/connection-registry.test.ts +++ b/apps/desktop/electron/connection-registry.test.ts @@ -15,6 +15,7 @@ import { backendScopeKey, backendScopePrefix, buildAgentRoster, + connectionDialFieldsChanged, connectionIdForLabel, labelKey, labelSlug, @@ -523,3 +524,31 @@ test('upsertConnection replaces by id and appends new ids', () => { assert.equal(registry.connections.filter(c => c.id === a.id).length, 1) assert.equal(registry.connections.find(c => c.id === a.id)?.url, 'http://a:2') }) + +// --- connectionDialFieldsChanged (edit → recycle decision) --- + +test('connectionDialFieldsChanged: label-only edits do not recycle', () => { + const before = { id: 'homelab', kind: 'remote', label: 'Homelab', url: 'http://10.0.0.5:9119', authMode: 'token', token: { encoding: 'safeStorage', value: 'abc' } } as const + + assert.equal(connectionDialFieldsChanged(before, { ...before, label: 'Home lab (renamed)' }), false) + // Identity edit is also a no-op. + assert.equal(connectionDialFieldsChanged(before, { ...before }), false) +}) + +test('connectionDialFieldsChanged: url / auth / token changes recycle', () => { + const before = { id: 'homelab', kind: 'remote', label: 'Homelab', url: 'http://10.0.0.5:9119', authMode: 'token', token: { encoding: 'safeStorage', value: 'abc' } } as const + + assert.equal(connectionDialFieldsChanged(before, { ...before, url: 'http://10.0.0.9:9119' }), true) + assert.equal(connectionDialFieldsChanged(before, { ...before, authMode: 'oauth', token: undefined }), true) + assert.equal(connectionDialFieldsChanged(before, { ...before, token: { encoding: 'safeStorage', value: 'NEW' } }), true) +}) + +test('connectionDialFieldsChanged: ssh routing fields recycle, kind change recycles', () => { + const before = { id: 'box', kind: 'ssh', label: 'Box', host: 'box.lan', user: 'me', port: 22 } as const + + assert.equal(connectionDialFieldsChanged(before, { ...before, label: 'Box 2' }), false) + assert.equal(connectionDialFieldsChanged(before, { ...before, host: 'other.lan' }), true) + assert.equal(connectionDialFieldsChanged(before, { ...before, port: 2222 }), true) + assert.equal(connectionDialFieldsChanged(before, { ...before, remoteProfile: 'work' }), true) + assert.equal(connectionDialFieldsChanged(before, { id: 'box', kind: 'remote', label: 'Box', url: 'http://x:1' }), true) +}) diff --git a/apps/desktop/electron/connection-registry.ts b/apps/desktop/electron/connection-registry.ts index ca3c789d63..08296d2a71 100644 --- a/apps/desktop/electron/connection-registry.ts +++ b/apps/desktop/electron/connection-registry.ts @@ -414,6 +414,43 @@ export function mergeConnectionInput(input: ConnectionInput, existing?: null | R return merged } +/** + * True when an edit changes how a connection is DIALED — endpoint, auth, or + * ssh routing fields — as opposed to a cosmetic label rename. Callers use + * this to decide whether live pooled backends / renderer sockets for the + * connection must be recycled after a save: a label-only edit keeps traffic + * flowing, while a url/token/host change means everything currently open + * points at the OLD target and must be torn down and re-dialed. + */ +export function connectionDialFieldsChanged(before: RegistryConnection, after: RegistryConnection): boolean { + if (before.kind !== after.kind) { + return true + } + + const fields: (keyof RegistryConnection)[] = [ + 'url', + 'authMode', + 'org', + 'host', + 'user', + 'port', + 'keyPath', + 'remoteHermesPath', + 'remoteProfile' + ] + + for (const field of fields) { + if ((before[field] ?? null) !== (after[field] ?? null)) { + return true + } + } + + // Token envelopes are opaque here (main.ts encrypts). An edit that carries + // no new token inherits the stored envelope verbatim, so structural + // equality is exact for the label-only case. + return JSON.stringify(before.token ?? null) !== JSON.stringify(after.token ?? null) +} + // ── Registry-level operations (all pure: return a new registry) ──────────── function localEntry(label = 'This device'): RegistryConnection { diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index e1e22a0730..529c2d8ea3 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -86,6 +86,7 @@ import { backendScopeKey, backendScopePrefix, buildAgentRoster, + connectionDialFieldsChanged, mergeConnectionInput, migrateV1ToRegistry, normalizeConnectionInput, @@ -108,7 +109,6 @@ import { shouldRemoveAppBundle, uninstallArgsForMode } from './desktop-uninstall' -import { selectPoolEvictions } from './pool-eviction' import { describeDevCdpDecision, resolveDevCdpPort } from './dev-cdp' import { installEmbedReferer } from './embed-referer' import { createEventDeduper } from './event-dedupe' @@ -213,6 +213,7 @@ import { electronProcessStartMarker, parentWatchdogEnv } from './parent-process-identity' +import { selectPoolEvictions } from './pool-eviction' import { createKeepAwake } from './power-save' import { FirstRunSetupResetError, runPrimaryBackendStartup } from './primary-backend-startup' import { rehomePrimaryConnection } from './primary-connection-rehome' @@ -8062,6 +8063,16 @@ function saveRegistryConnection(input: any = {}) { writeDesktopConnectionsRegistry(upsertConnection(registry, entry)) + // A dial-material edit (endpoint/auth/ssh routing — NOT a label rename) + // leaves pooled backends under `conn:::*` and renderer sockets pointing + // at the OLD target while the UI shows the new one. Recycle them: stop this + // connection's pooled backends/tunnels and tell renderers to dispose+redial + // their secondaries for this connection id. + if (existing && connectionDialFieldsChanged(existing, entry)) { + stopRegistryConnectionBackends(entry.id) + broadcastConnectionsChanged({ connectionId: entry.id, reason: 'updated' }) + } + return sanitizeRegistryConnection(entry) }