diff --git a/apps/desktop/electron/dashboard-token.cjs b/apps/desktop/electron/dashboard-token.cjs
new file mode 100644
index 00000000000..1a9ca50ad9c
--- /dev/null
+++ b/apps/desktop/electron/dashboard-token.cjs
@@ -0,0 +1,99 @@
+/**
+ * Helpers for local dashboard session-token discovery.
+ *
+ * The desktop main process can pass HERMES_DASHBOARD_SESSION_TOKEN when it
+ * spawns the local dashboard, but the dashboard is the source of truth for the
+ * token it actually serves to the renderer. If those drift, HTTP readiness
+ * probes still pass while /api/ws rejects the renderer's token.
+ */
+
+const DEFAULT_TOKEN_FETCH_TIMEOUT_MS = 3_000
+
+async function fetchPublicText(url, options = {}) {
+ const { protocol } = new URL(url)
+ if (protocol !== 'http:' && protocol !== 'https:') {
+ throw new Error(`Unsupported Hermes backend URL protocol: ${protocol}`)
+ }
+
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
+ const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }).catch(error => {
+ if (error.name === 'TimeoutError') {
+ throw new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)
+ }
+ throw error
+ })
+ const text = await res.text()
+
+ if (!res.ok) throw new Error(`${res.status}: ${text || res.statusText}`)
+
+ return text
+}
+
+function extractInjectedDashboardToken(html) {
+ const match = /window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/.exec(String(html || ''))
+ if (!match) return null
+ try {
+ return JSON.parse(match[1])
+ } catch {
+ return null
+ }
+}
+
+function dashboardIndexUrl(baseUrl) {
+ return `${String(baseUrl || '').replace(/\/+$/, '')}/`
+}
+
+async function resolveServedDashboardToken(baseUrl, fallbackToken, options = {}) {
+ const fetchText = options.fetchText || fetchPublicText
+ const html = await fetchText(dashboardIndexUrl(baseUrl), {
+ timeoutMs: options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
+ })
+ const servedToken = extractInjectedDashboardToken(html)
+
+ if (servedToken && servedToken !== fallbackToken && typeof options.rememberLog === 'function') {
+ options.rememberLog('[boot] dashboard served a different session token; using served token for WebSocket auth')
+ }
+
+ return servedToken || fallbackToken
+}
+
+/**
+ * A served token that differs from our spawn token while our child is DEAD
+ * came from a process we did not spawn (orphan/port squatter that satisfied
+ * the public /api/status readiness probe). With a live child the mismatch is
+ * benign: our own backend regenerated the token because the env pin did not
+ * survive the spawn.
+ */
+function isForeignBackendToken({ servedToken, spawnToken, childAlive }) {
+ return Boolean(servedToken) && servedToken !== spawnToken && !childAlive
+}
+
+/**
+ * Resolve the token the backend actually serves, adopting benign drift and
+ * failing loudly on a foreign backend. `childAlive` is a thunk so liveness is
+ * sampled after the fetch, not before.
+ */
+async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, label = 'Hermes backend', ...options }) {
+ const servedToken = await resolveServedDashboardToken(baseUrl, spawnToken, options).catch(error => {
+ options.rememberLog?.(`[boot] could not read served dashboard token (${label}): ${error.message}`)
+ return spawnToken
+ })
+
+ if (isForeignBackendToken({ servedToken, spawnToken, childAlive: childAlive() })) {
+ throw new Error(
+ `${label} exited and ${dashboardIndexUrl(baseUrl)} is served by a process we did not spawn; refusing its session token.`
+ )
+ }
+
+ return servedToken
+}
+
+module.exports = {
+ DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
+ adoptServedDashboardToken,
+ dashboardIndexUrl,
+ extractInjectedDashboardToken,
+ fetchPublicText,
+ isForeignBackendToken,
+ resolveServedDashboardToken
+}
diff --git a/apps/desktop/electron/dashboard-token.test.cjs b/apps/desktop/electron/dashboard-token.test.cjs
new file mode 100644
index 00000000000..d598ffc2bc1
--- /dev/null
+++ b/apps/desktop/electron/dashboard-token.test.cjs
@@ -0,0 +1,142 @@
+/**
+ * Tests for electron/dashboard-token.cjs.
+ *
+ * Run with: node --test electron/dashboard-token.test.cjs
+ * (Wired into npm test:desktop:platforms in package.json.)
+ */
+
+const test = require('node:test')
+const assert = require('node:assert/strict')
+
+const {
+ adoptServedDashboardToken,
+ dashboardIndexUrl,
+ extractInjectedDashboardToken,
+ fetchPublicText,
+ isForeignBackendToken,
+ resolveServedDashboardToken
+} = require('./dashboard-token.cjs')
+
+test('extractInjectedDashboardToken reads the JSON-encoded dashboard token', () => {
+ const html = ''
+ assert.equal(extractInjectedDashboardToken(html), 'served-token')
+})
+
+test('extractInjectedDashboardToken handles escaped token strings', () => {
+ const html = ''
+ assert.equal(extractInjectedDashboardToken(html), 'served\\token"quoted')
+})
+
+test('extractInjectedDashboardToken returns null for missing or malformed values', () => {
+ assert.equal(extractInjectedDashboardToken(''), null)
+ assert.equal(extractInjectedDashboardToken(''), null)
+})
+
+test('dashboardIndexUrl preserves dashboard path prefixes', () => {
+ assert.equal(dashboardIndexUrl('http://127.0.0.1:9120'), 'http://127.0.0.1:9120/')
+ assert.equal(dashboardIndexUrl('https://host.example/hermes/'), 'https://host.example/hermes/')
+})
+
+test('resolveServedDashboardToken uses the served token and logs when it differs', async () => {
+ const logs = []
+ const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
+ fetchText: async url => {
+ assert.equal(url, 'http://127.0.0.1:9120/')
+ return ''
+ },
+ rememberLog: line => logs.push(line)
+ })
+
+ assert.equal(token, 'served-token')
+ assert.equal(logs.length, 1)
+ assert.match(logs[0], /served a different session token/)
+})
+
+test('resolveServedDashboardToken falls back when the served HTML has no token', async () => {
+ const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
+ fetchText: async () => '',
+ rememberLog: () => {
+ throw new Error('should not log when no served token is present')
+ }
+ })
+
+ assert.equal(token, 'spawn-token')
+})
+
+test('resolveServedDashboardToken does not log when served token matches fallback', async () => {
+ const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'same-token', {
+ fetchText: async () => '',
+ rememberLog: () => {
+ throw new Error('should not log when token already matches')
+ }
+ })
+
+ assert.equal(token, 'same-token')
+})
+
+test('resolveServedDashboardToken propagates fetch errors so callers can fall back explicitly', async () => {
+ await assert.rejects(
+ () =>
+ resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
+ fetchText: async () => {
+ throw new Error('boom')
+ }
+ }),
+ /boom/
+ )
+})
+
+test('fetchPublicText rejects unsupported protocols', async () => {
+ await assert.rejects(() => fetchPublicText('file:///tmp/index.html'), /Unsupported Hermes backend URL protocol/)
+})
+
+test('isForeignBackendToken only flags a mismatched token from a dead child', () => {
+ const cases = [
+ [{ servedToken: 'other', spawnToken: 'mine', childAlive: false }, true],
+ // Live child + drift = our backend regenerated the token (env pin lost).
+ [{ servedToken: 'other', spawnToken: 'mine', childAlive: true }, false],
+ [{ servedToken: 'mine', spawnToken: 'mine', childAlive: false }, false],
+ [{ servedToken: 'mine', spawnToken: 'mine', childAlive: true }, false],
+ [{ servedToken: null, spawnToken: 'mine', childAlive: false }, false],
+ [{ servedToken: '', spawnToken: 'mine', childAlive: false }, false]
+ ]
+ for (const [input, expected] of cases) {
+ assert.equal(isForeignBackendToken(input), expected, JSON.stringify(input))
+ }
+})
+
+test('adoptServedDashboardToken adopts drift from a live child', async () => {
+ const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
+ childAlive: () => true,
+ fetchText: async () => ''
+ })
+
+ assert.equal(token, 'served-token')
+})
+
+test('adoptServedDashboardToken refuses a foreign token when our child is dead', async () => {
+ await assert.rejects(
+ () =>
+ adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
+ childAlive: () => false,
+ fetchText: async () => '',
+ label: 'Hermes backend for profile "work"'
+ }),
+ /profile "work".*process we did not spawn/
+ )
+})
+
+test('adoptServedDashboardToken falls back to the spawn token when the fetch fails', async () => {
+ const logs = []
+ const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
+ childAlive: () => true,
+ fetchText: async () => {
+ throw new Error('boom')
+ },
+ rememberLog: line => logs.push(line)
+ })
+
+ assert.equal(token, 'spawn-token')
+ assert.equal(logs.length, 1)
+ assert.match(logs[0], /could not read served dashboard token \(Hermes backend\): boom/)
+})
diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs
index bfa5e178d2f..911e26e1106 100644
--- a/apps/desktop/electron/main.cjs
+++ b/apps/desktop/electron/main.cjs
@@ -29,6 +29,8 @@ const { runBootstrap } = require('./bootstrap-runner.cjs')
const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./session-windows.cjs')
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
+const { adoptServedDashboardToken } = require('./dashboard-token.cjs')
+const { PortPool } = require('./port-pool.cjs')
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
const { readDirForIpc } = require('./fs-read-dir.cjs')
@@ -107,6 +109,10 @@ if (USER_DATA_OVERRIDE) {
const PORT_FLOOR = 9120
const PORT_CEILING = 9199
+// In-process port reservations that close the pickPort() TOCTOU window where
+// two concurrent backend spawns could be handed the same port. See
+// port-pool.cjs for the full rationale.
+const portPool = new PortPool(PORT_FLOOR, PORT_CEILING)
const DEV_SERVER = process.env.HERMES_DESKTOP_DEV_SERVER
const IS_PACKAGED = app.isPackaged
const IS_MAC = process.platform === 'darwin'
@@ -2452,10 +2458,11 @@ function isPortAvailable(port) {
}
async function pickPort() {
- for (let port = PORT_FLOOR; port <= PORT_CEILING; port += 1) {
- if (await isPortAvailable(port)) return port
+ const port = await portPool.reserve(isPortAvailable)
+ if (port === null) {
+ throw new Error(`No free localhost port in ${PORT_FLOOR}-${PORT_CEILING}`)
}
- throw new Error(`No free localhost port in ${PORT_FLOOR}-${PORT_CEILING}`)
+ return port
}
function fetchJson(url, token, options = {}) {
@@ -4539,9 +4546,20 @@ async function spawnPoolBackend(profile, entry) {
// --profile wins over the inherited HERMES_HOME env (see _apply_profile_override
// step 3 in hermes_cli/main.py), so the child re-homes to this profile.
const dashboardArgs = ['--profile', profile, 'dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)]
- const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
- const hermesCwd = resolveHermesCwd()
- const webDist = resolveWebDist()
+ let backend
+ let hermesCwd
+ let webDist
+ try {
+ backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
+ hermesCwd = resolveHermesCwd()
+ webDist = resolveWebDist()
+ } catch (error) {
+ // These run before the child exists / its exit handler is attached, so a
+ // throw here would otherwise leak the reservation and slowly exhaust the
+ // 9120-9199 range across switch cycles in one app session.
+ portPool.release(port)
+ throw error
+ }
rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`)
@@ -4579,11 +4597,13 @@ async function spawnPoolBackend(profile, entry) {
child.once('error', error => {
rememberLog(`Hermes backend for profile "${profile}" failed to start: ${error.message}`)
backendPool.delete(profile)
+ portPool.release(port)
rejectStart?.(error)
})
child.once('exit', (code, signal) => {
rememberLog(`Hermes backend for profile "${profile}" exited (${signal || code})`)
backendPool.delete(profile)
+ portPool.release(port)
if (!ready) {
rejectStart?.(
new Error(`Hermes backend for profile "${profile}" exited before it became ready (${signal || code}).`)
@@ -4594,15 +4614,21 @@ async function spawnPoolBackend(profile, entry) {
const baseUrl = `http://127.0.0.1:${port}`
await Promise.race([waitForHermes(baseUrl, token), startFailed])
ready = true
+ const authToken = await adoptServedDashboardToken(baseUrl, token, {
+ childAlive: () => child.exitCode === null && !child.killed,
+ label: `Hermes backend for profile "${profile}"`,
+ rememberLog
+ })
+ entry.token = authToken
return {
baseUrl,
mode: 'local',
source: 'local',
authMode: 'token',
- token,
+ token: authToken,
profile,
- wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`,
+ wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
logs: hermesLog.slice(-80),
...getWindowState()
}
@@ -4612,6 +4638,7 @@ function stopPoolBackend(profile) {
const entry = backendPool.get(profile)
if (!entry) return
backendPool.delete(profile)
+ if (entry.port) portPool.release(entry.port)
if (entry.process && !entry.process.killed) {
try {
entry.process.kill('SIGTERM')
@@ -4697,6 +4724,11 @@ async function startHermes() {
}
if (connectionPromise) return connectionPromise
+ // Hoisted so the outer .catch can release a port reserved by pickPort() when
+ // a throw (e.g. ensureRuntime failing) happens before the child's exit
+ // handler is attached. Stays null on the remote path (no port picked).
+ let reservedPort = null
+
connectionPromise = (async () => {
await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8)
// Resolve for the desktop's primary profile so a per-profile remote
@@ -4726,6 +4758,7 @@ async function startHermes() {
await advanceBootProgress('backend.port', 'Finding an open local port', 16)
const port = await pickPort()
+ reservedPort = port
const token = crypto.randomBytes(32).toString('base64url')
const dashboardArgs = ['dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)]
// Pin the desktop's chosen profile via the global --profile flag. This is
@@ -4790,6 +4823,7 @@ async function startHermes() {
)
hermesProcess = null
connectionPromise = null
+ portPool.release(port)
sendBackendExit({ code: null, signal: null, error: error.message })
rejectBackendStart?.(error)
})
@@ -4797,6 +4831,7 @@ async function startHermes() {
rememberLog(`Hermes backend exited (${signal || code})`)
hermesProcess = null
connectionPromise = null
+ portPool.release(port)
sendBackendExit({ code, signal })
if (!backendReady) {
const message = `Hermes backend exited before it became ready (${signal || code}).`
@@ -4821,6 +4856,11 @@ async function startHermes() {
await advanceBootProgress('backend.wait', 'Waiting for Hermes backend to become ready', 90)
await Promise.race([waitForHermes(baseUrl, token), backendStartFailed])
backendReady = true
+ const authToken = await adoptServedDashboardToken(baseUrl, token, {
+ // The exit/error handlers null hermesProcess when the child dies.
+ childAlive: () => hermesProcess !== null && hermesProcess.exitCode === null && !hermesProcess.killed,
+ rememberLog
+ })
updateBootProgress({
phase: 'backend.ready',
message: 'Hermes backend is ready. Finalizing desktop startup',
@@ -4834,8 +4874,8 @@ async function startHermes() {
mode: 'local',
source: 'local',
authMode: 'token',
- token,
- wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`,
+ token: authToken,
+ wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
logs: hermesLog.slice(-80),
...getWindowState()
}
@@ -4851,6 +4891,7 @@ async function startHermes() {
{ allowDecrease: true }
)
connectionPromise = null
+ portPool.release(reservedPort)
throw error
})
@@ -5125,8 +5166,8 @@ ipcMain.handle('hermes:bootstrap:reset', async () => {
// reset connection state so the next startHermes() call restarts the
// full backend flow (including a fresh runBootstrap pass).
rememberLog('[bootstrap] reset requested by renderer; clearing latched failure')
+ await teardownPrimaryBackendAndWait()
bootstrapFailure = null
- connectionPromise = null
bootstrapState = {
active: false,
manifest: null,
diff --git a/apps/desktop/electron/port-pool.cjs b/apps/desktop/electron/port-pool.cjs
new file mode 100644
index 00000000000..35131090814
--- /dev/null
+++ b/apps/desktop/electron/port-pool.cjs
@@ -0,0 +1,73 @@
+'use strict'
+
+/**
+ * In-process port reservation pool for the desktop backend launcher.
+ *
+ * pickPort() probes a localhost port with a throwaway server and closes it
+ * before the real bind happens in a separate Python child. Between that probe
+ * and the child's bind there is a TOCTOU window: a second concurrent spawn
+ * (the primary backend racing a pool backend) can be handed the SAME port, and
+ * one then dies with EADDRINUSE ("address already in use" -> "Object has been
+ * destroyed" boot loop). Reserving the chosen port in THIS process until the
+ * child exits closes that window.
+ *
+ * The OS bind remains the source of truth; this only deconflicts racers inside
+ * this process — it can't stop a foreign squatter, which the probe + the
+ * EADDRINUSE self-heal still cover.
+ *
+ * The pool is dependency-injected (the availability probe is passed in) and
+ * free of Electron/Node socket I/O, so it is unit-tested without real sockets
+ * (see port-pool.test.cjs).
+ */
+class PortPool {
+ /**
+ * @param {number} floor inclusive lowest port to hand out
+ * @param {number} ceiling inclusive highest port to hand out
+ */
+ constructor(floor, ceiling) {
+ this.floor = floor
+ this.ceiling = ceiling
+ this._reserved = new Set()
+ }
+
+ /** @returns {boolean} whether `port` is currently reserved in-process. */
+ has(port) {
+ return this._reserved.has(port)
+ }
+
+ /** Release a previously reserved port. No-op if it was not reserved. */
+ release(port) {
+ this._reserved.delete(port)
+ }
+
+ /** Drop all reservations. */
+ clear() {
+ this._reserved.clear()
+ }
+
+ /** @returns {number} count of currently reserved ports. */
+ get size() {
+ return this._reserved.size
+ }
+
+ /**
+ * Reserve and return the lowest port in [floor, ceiling] that is neither
+ * already reserved in-process nor rejected by `isAvailable(port)`, or null
+ * if every port is taken. `isAvailable` may be sync (boolean) or async
+ * (Promise); it is awaited either way.
+ *
+ * @param {(port: number) => boolean | Promise} isAvailable
+ * @returns {Promise}
+ */
+ async reserve(isAvailable) {
+ for (let port = this.floor; port <= this.ceiling; port += 1) {
+ if (this._reserved.has(port)) continue
+ if (!(await isAvailable(port))) continue
+ this._reserved.add(port)
+ return port
+ }
+ return null
+ }
+}
+
+module.exports = { PortPool }
diff --git a/apps/desktop/electron/port-pool.test.cjs b/apps/desktop/electron/port-pool.test.cjs
new file mode 100644
index 00000000000..f2600ce7d5f
--- /dev/null
+++ b/apps/desktop/electron/port-pool.test.cjs
@@ -0,0 +1,77 @@
+/**
+ * Tests for electron/port-pool.cjs.
+ *
+ * Run with: node --test electron/port-pool.test.cjs
+ *
+ * PortPool is the in-process reservation that closes the pickPort() TOCTOU
+ * window. These cover selection order, skipping reserved/unavailable ports,
+ * release/reuse, exhaustion, and async probes — without real sockets.
+ */
+
+const test = require('node:test')
+const assert = require('node:assert/strict')
+
+const { PortPool } = require('./port-pool.cjs')
+
+const allFree = () => true
+
+test('reserve returns the lowest free port and reserves it', async () => {
+ const pool = new PortPool(9120, 9199)
+ const port = await pool.reserve(allFree)
+ assert.equal(port, 9120)
+ assert.ok(pool.has(9120))
+ assert.equal(pool.size, 1)
+})
+
+test('reserve skips ports already reserved in-process', async () => {
+ const pool = new PortPool(9120, 9199)
+ const first = await pool.reserve(allFree)
+ const second = await pool.reserve(allFree)
+ assert.equal(first, 9120)
+ assert.equal(second, 9121)
+})
+
+test('reserve skips ports the probe rejects', async () => {
+ const pool = new PortPool(9120, 9199)
+ const busy = new Set([9120, 9121])
+ const port = await pool.reserve(p => !busy.has(p))
+ assert.equal(port, 9122)
+})
+
+test('reserve returns null when every port is taken', async () => {
+ const pool = new PortPool(9120, 9121)
+ await pool.reserve(allFree)
+ await pool.reserve(allFree)
+ assert.equal(await pool.reserve(allFree), null)
+})
+
+test('release frees a reserved port for reuse', async () => {
+ const pool = new PortPool(9120, 9120)
+ assert.equal(await pool.reserve(allFree), 9120)
+ assert.equal(await pool.reserve(allFree), null) // exhausted
+ pool.release(9120)
+ assert.ok(!pool.has(9120))
+ assert.equal(await pool.reserve(allFree), 9120) // reusable
+})
+
+test('release is a no-op for an unreserved port', () => {
+ const pool = new PortPool(9120, 9199)
+ pool.release(9120)
+ assert.equal(pool.size, 0)
+})
+
+test('reserve awaits an async probe', async () => {
+ const pool = new PortPool(9120, 9199)
+ const busy = new Set([9120])
+ const port = await pool.reserve(p => Promise.resolve(!busy.has(p)))
+ assert.equal(port, 9121)
+})
+
+test('clear drops all reservations', async () => {
+ const pool = new PortPool(9120, 9199)
+ await pool.reserve(allFree)
+ await pool.reserve(allFree)
+ assert.equal(pool.size, 2)
+ pool.clear()
+ assert.equal(pool.size, 0)
+})
diff --git a/apps/desktop/electron/windows-child-process.test.cjs b/apps/desktop/electron/windows-child-process.test.cjs
index 6bcc58a0a33..92989c978bb 100644
--- a/apps/desktop/electron/windows-child-process.test.cjs
+++ b/apps/desktop/electron/windows-child-process.test.cjs
@@ -8,7 +8,7 @@ const path = require('node:path')
const ELECTRON_DIR = __dirname
function readElectronFile(name) {
- return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8')
+ return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n')
}
function requireHiddenChildOptions(source, needle) {
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index d247476ebeb..a552f950f20 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -35,7 +35,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
- "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs",
+ "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/port-pool.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs",
"typecheck": "tsc -p . --noEmit",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
diff --git a/apps/desktop/src/app/artifacts/index.tsx b/apps/desktop/src/app/artifacts/index.tsx
index fd1569d7caf..8e98dd9d40d 100644
--- a/apps/desktop/src/app/artifacts/index.tsx
+++ b/apps/desktop/src/app/artifacts/index.tsx
@@ -18,7 +18,7 @@ import {
} from '@/components/ui/pagination'
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
import { Tip } from '@/components/ui/tooltip'
-import { getSessionMessages, listSessions } from '@/hermes'
+import { getSessionMessages, listAllProfileSessions } from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
@@ -388,8 +388,8 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
setRefreshing(true)
try {
- const sessions = (await listSessions(30, 1)).sessions
- const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id)))
+ const sessions = (await listAllProfileSessions(30, 1)).sessions
+ const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id, session.profile)))
const nextArtifacts: ArtifactRecord[] = []
results.forEach((result, index) => {
diff --git a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx
index 4d7ebf946ce..3d51ab8f8bb 100644
--- a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx
+++ b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx
@@ -88,7 +88,7 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
label: r.export,
onSelect: () => {
triggerHaptic('selection')
- void exportSession(sessionId, { title })
+ void exportSession(sessionId, { profile, title })
}
},
{
diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx
index 3872d24d5f9..2e3a45d771e 100644
--- a/apps/desktop/src/app/command-palette/index.tsx
+++ b/apps/desktop/src/app/command-palette/index.tsx
@@ -8,7 +8,7 @@ import { HUD_HEADING, HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from '@/ap
import { setTerminalTakeover } from '@/app/right-sidebar/store'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { KbdGroup } from '@/components/ui/kbd'
-import { getHermesConfigRecord, listSessions } from '@/hermes'
+import { getHermesConfigRecord, listAllProfileSessions } from '@/hermes'
import { useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import {
@@ -119,7 +119,7 @@ const paletteFilter = (value: string, search: string, keywords?: string[]): numb
return needle.split(/\s+/).every(term => haystack.includes(term)) ? 1 : 0
}
-type SessionRow = Awaited>['sessions'][number]
+type SessionRow = Awaited>['sessions'][number]
const toSessionEntry = (session: SessionRow): SessionEntry => ({
id: session.id,
@@ -218,13 +218,13 @@ export function CommandPalette() {
const sessionsQuery = useQuery({
queryKey: ['command-palette', 'sessions'],
- queryFn: () => listSessions(200, 1, 'exclude'),
+ queryFn: () => listAllProfileSessions(200, 1, 'exclude'),
enabled: open
})
const archivedQuery = useQuery({
queryKey: ['command-palette', 'archived'],
- queryFn: () => listSessions(200, 0, 'only'),
+ queryFn: () => listAllProfileSessions(200, 0, 'only'),
enabled: open
})
diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx
index f04ade8f80e..0130eb7c613 100644
--- a/apps/desktop/src/app/desktop-controller.tsx
+++ b/apps/desktop/src/app/desktop-controller.tsx
@@ -547,7 +547,9 @@ export function DesktopController() {
return
}
- const storedProfile = $sessions.get().find(session => session.id === storedSessionId)?.profile
+ const storedProfile = $sessions
+ .get()
+ .find(session => session.id === storedSessionId || session._lineage_root_id === storedSessionId)?.profile
for (let index = 0; index < Math.max(1, attempts); index += 1) {
try {
diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.ts b/apps/desktop/src/app/session/hooks/use-session-actions.ts
index 9980c90809d..8a419488740 100644
--- a/apps/desktop/src/app/session/hooks/use-session-actions.ts
+++ b/apps/desktop/src/app/session/hooks/use-session-actions.ts
@@ -2,7 +2,7 @@ import type { MutableRefObject } from 'react'
import { useCallback, useRef } from 'react'
import type { NavigateFunction } from 'react-router-dom'
-import { deleteSession, getSessionMessages, setSessionArchived } from '@/hermes'
+import { deleteSession, getSessionMessages, listAllProfileSessions, setSessionArchived } from '@/hermes'
import { useI18n } from '@/i18n'
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
import { normalizePersonalityValue } from '@/lib/chat-runtime'
@@ -209,6 +209,46 @@ function patchSessionWorkspace(sessionId: string, cwd: string | undefined) {
setSessions(prev => prev.map(session => (session.id === sessionId ? { ...session, cwd } : session)))
}
+function sessionMatchesStoredId(session: SessionInfo, storedSessionId: string): boolean {
+ return session.id === storedSessionId || session._lineage_root_id === storedSessionId
+}
+
+function upsertResolvedSession(session: SessionInfo, storedSessionId: string) {
+ const lineage = session._lineage_root_id ?? session.id
+
+ setSessions(prev => [
+ session,
+ ...prev.filter(existing => {
+ if (sessionMatchesStoredId(existing, storedSessionId)) {
+ return false
+ }
+
+ return (existing._lineage_root_id ?? existing.id) !== lineage
+ })
+ ])
+}
+
+async function resolveStoredSession(storedSessionId: string): Promise {
+ const cached = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
+
+ if (cached) {
+ return cached
+ }
+
+ try {
+ const result = await listAllProfileSessions(500, 0, 'include', 'recent', 'all')
+ const resolved = result.sessions.find(session => sessionMatchesStoredId(session, storedSessionId))
+
+ if (resolved) {
+ upsertResolvedSession(resolved, storedSessionId)
+ }
+
+ return resolved
+ } catch {
+ return undefined
+ }
+}
+
type SessionRuntimeStatePatch = Partial<
Pick<
ClientSessionState,
@@ -480,8 +520,13 @@ export function useSessionActions({
// Swap the single live gateway to this session's profile before any
// gateway call (no-op when it's already on that profile / single-profile).
- const storedForProfile = $sessions.get().find(session => session.id === storedSessionId)
+ const storedForProfile = await resolveStoredSession(storedSessionId)
const sessionProfile = storedForProfile?.profile
+
+ if (resumeRequestRef.current !== requestId) {
+ return
+ }
+
await ensureGatewayProfile(sessionProfile)
const cachedRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
@@ -549,7 +594,7 @@ export function useSessionActions({
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
setSessionStartedAt(Date.now())
- const stored = $sessions.get().find(session => session.id === storedSessionId)
+ const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
applyStoredSessionPreviewRuntimeInfo(stored)
if (stored) {
@@ -799,7 +844,7 @@ export function useSessionActions({
async (storedSessionId: string) => {
clearNotifications()
- const removed = $sessions.get().find(s => s.id === storedSessionId)
+ const removed = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
const wasSelected = selectedStoredSessionId === storedSessionId
const closingRuntimeId = wasSelected ? activeSessionId : null
const previousMessages = $messages.get()
@@ -808,7 +853,7 @@ export function useSessionActions({
// live tip after compression. Drop both so the pin can't linger.
const removedPinId = removed ? sessionPinId(removed) : storedSessionId
- setSessions(prev => prev.filter(s => s.id !== storedSessionId))
+ setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
// Keep $sessionsTotal in sync so the sidebar's "Load N more" footer
// doesn't keep claiming the removed row is still on the server.
setSessionsTotal(prev => Math.max(0, prev - 1))
@@ -843,7 +888,7 @@ export function useSessionActions({
setFreshDraftReady(false)
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
- const stored = $sessions.get().find(session => session.id === storedSessionId)
+ const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
if (stored) {
setCurrentUsage(current => ({
@@ -882,7 +927,7 @@ export function useSessionActions({
async (storedSessionId: string) => {
clearNotifications()
- const archived = $sessions.get().find(s => s.id === storedSessionId)
+ const archived = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
const wasSelected = selectedStoredSessionId === storedSessionId
const previousPinned = $pinnedSessionIds.get()
// Pins are keyed on the durable lineage-root id; the stored id may be the
@@ -890,7 +935,7 @@ export function useSessionActions({
const archivedPinId = archived ? sessionPinId(archived) : storedSessionId
// Soft-hide: drop from the sidebar immediately, keep the data.
- setSessions(prev => prev.filter(s => s.id !== storedSessionId))
+ setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
// Archived sessions are hidden by the listSessions(min_messages=1) query
// on the next refresh, so they count as "removed" for the load-more
// footer math.
@@ -907,12 +952,12 @@ export function useSessionActions({
// in flight and briefly reinsert the still-unarchived backend row. Win
// that race after the mutation succeeds so right-click → Archive does
// not appear to do nothing until the next full refresh.
- setSessions(prev => prev.filter(s => s.id !== storedSessionId))
+ setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
$pinnedSessionIds.set($pinnedSessionIds.get().filter(id => id !== storedSessionId && id !== archivedPinId))
notify({ durationMs: 2_000, kind: 'success', message: copy.archived })
} catch (err) {
if (archived) {
- setSessions(prev => [archived, ...prev.filter(s => s.id !== storedSessionId)])
+ setSessions(prev => [archived, ...prev.filter(session => !sessionMatchesStoredId(session, storedSessionId))])
setSessionsTotal(prev => prev + 1)
}
diff --git a/apps/desktop/src/app/settings/sessions-settings.tsx b/apps/desktop/src/app/settings/sessions-settings.tsx
index 2e043ff0ef3..f644ded929c 100644
--- a/apps/desktop/src/app/settings/sessions-settings.tsx
+++ b/apps/desktop/src/app/settings/sessions-settings.tsx
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Tip } from '@/components/ui/tooltip'
-import { deleteSession, listSessions, setSessionArchived } from '@/hermes'
+import { deleteSession, listAllProfileSessions, setSessionArchived } from '@/hermes'
import { useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
@@ -43,14 +43,14 @@ export function SessionsSettings() {
setLoading(true)
try {
- const result = await listSessions(ARCHIVED_FETCH_LIMIT, 0, 'only')
+ const result = await listAllProfileSessions(ARCHIVED_FETCH_LIMIT, 0, 'only')
setLocalSessions(result.sessions)
} catch (err) {
notifyError(err, s.failedLoad)
} finally {
setLoading(false)
}
- }, [])
+ }, [s.failedLoad])
useEffect(() => {
void load()
diff --git a/apps/desktop/src/components/session-picker.tsx b/apps/desktop/src/components/session-picker.tsx
index 048fa32a208..67012d9a3f0 100644
--- a/apps/desktop/src/components/session-picker.tsx
+++ b/apps/desktop/src/components/session-picker.tsx
@@ -3,7 +3,7 @@ import { Dialog as DialogPrimitive } from 'radix-ui'
import { useEffect, useMemo, useState } from 'react'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
-import { listSessions } from '@/hermes'
+import { listAllProfileSessions } from '@/hermes'
import { useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { Check, MessageCircle } from '@/lib/icons'
@@ -35,7 +35,7 @@ export function SessionPickerDialog({
const sessionsQuery = useQuery({
enabled: open,
- queryFn: () => listSessions(200, 1, 'exclude'),
+ queryFn: () => listAllProfileSessions(200, 1, 'exclude'),
queryKey: ['session-picker', 'sessions']
})
diff --git a/apps/desktop/src/hermes.test.ts b/apps/desktop/src/hermes.test.ts
index 0dcf58b3640..290f6aac96d 100644
--- a/apps/desktop/src/hermes.test.ts
+++ b/apps/desktop/src/hermes.test.ts
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { listAllProfileSessions, listSessions } from './hermes'
+import { getSessionMessages, listAllProfileSessions, listSessions } from './hermes'
const emptySessionsResponse = {
limit: 0,
@@ -46,4 +46,15 @@ describe('Hermes REST session helpers', () => {
})
)
})
+
+ it('tags cross-profile message reads for Electron routing and backend lookup', async () => {
+ api.mockResolvedValue({ messages: [], session_id: 'session-1' })
+
+ await getSessionMessages('session-1', 'xiaoxuxu')
+
+ expect(api).toHaveBeenCalledWith({
+ path: '/api/sessions/session-1/messages?profile=xiaoxuxu',
+ profile: 'xiaoxuxu'
+ })
+ })
})
diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts
index c21fb0a106b..b765390f019 100644
--- a/apps/desktop/src/hermes.ts
+++ b/apps/desktop/src/hermes.ts
@@ -54,10 +54,10 @@ export type {
AnalyticsSkillEntry,
AnalyticsSkillsSummary,
AnalyticsTotals,
- BackendUpdateCheckResponse,
AudioSpeakResponse,
AudioTranscriptionResponse,
AuxiliaryModelsResponse,
+ BackendUpdateCheckResponse,
ConfigFieldSchema,
ConfigSchemaResponse,
CronJob,
@@ -218,6 +218,7 @@ export function getSessionMessages(id: string, profile?: string | null): Promise
const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : ''
return window.hermesDesktop.api({
+ ...(profile ? { profile } : {}),
path: `/api/sessions/${encodeURIComponent(id)}/messages${suffix}`
})
}
diff --git a/apps/desktop/src/lib/session-export.ts b/apps/desktop/src/lib/session-export.ts
index b32a705b7eb..8aa31c695cb 100644
--- a/apps/desktop/src/lib/session-export.ts
+++ b/apps/desktop/src/lib/session-export.ts
@@ -5,6 +5,7 @@ import { notify, notifyError } from '@/store/notifications'
interface ExportSessionParams {
sessionId: string
+ profile?: string | null
title?: string | null
session?: SessionInfo
}
@@ -31,7 +32,8 @@ export async function exportSession(sessionId: string, params: Omit