feat(desktop): openExternalFileForIpc opens files via OS handler

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m4
2026-09-17 22:28:07 +08:00
3398 changed files with 379040 additions and 22836 deletions
+5
View File
@@ -134,6 +134,11 @@ Two auth-flavored corollaries worth naming because they are easy to get wrong:
- **A connection test must exercise the leg you'll actually use.** An HTTP
status probe passing while the WebSocket/auth leg fails is a false positive
that ships as "it said connected but nothing works."
- **Cookie-jar partition names contain nothing Electron percent-escapes.** A
`persist:` partition becomes a `Partitions/<escaped name>` folder; a folder
name with `%3A` (an escaped `:`) gets a cookie store Windows can neither read
nor write, so the session silently never persists. `electron/oauth-partition.ts`
pins the invariant; renaming a partition signs its users out once — say so.
## Compatibility without carrying the past forever
+21 -3
View File
@@ -118,9 +118,10 @@ do **not** pass `h-*`, `px-*`, `py-*`, or icon-size overrides.
**Variants:** `default` (primary), `destructive`, `secondary` (soft fill —
the default non-primary look), `outline` (transparent + 1px inset ring, no
fill/shadow), `ghost`, `link`, `text` (boxless quiet inline — "Cancel",
"Clear"), `textStrong` (bold underlined inline affordance — "Change",
"Open logs").
fill/shadow), `ghost`, `floating` (a control loose from any surface — opaque
popover fill + `shadow-md`, hover lifts the glyph only), `link`, `text`
(boxless quiet inline — "Cancel", "Clear"), `textStrong` (bold underlined
inline affordance — "Change", "Open logs").
**Sizes:** `default`, `xs`, `sm`, `lg`, `inline` (flush, zero box — for buttons
that sit inside a heading/sentence; replaces `h-auto px-0 py-0`), `micro`
@@ -177,6 +178,14 @@ Notes:
`warn`, `destructive`, `outline`, `solid` (primary fill — icon-corner counts).
Sizes: `default`, `xs`, `overlay` (titlebar glyph counts).
## Context-sensitive dialogs
Sudo password dialogs keep the backdrop unblurred (`DialogContent`'s
`blurBackdrop={false}`) and show the complete, selectable command before the
password field. Long commands wrap and scroll; missing backend context is
explicit, never inferred from another tool row. Other dialogs retain the shared
blurred backdrop.
## Form controls
- **`controlVariants`** (`src/components/ui/control.ts`) is the shared shape for
@@ -188,6 +197,11 @@ Sizes: `default`, `xs`, `overlay` (titlebar glyph counts).
(color mode, tool-call display, usage period). Replaces radio piles and
pill rows.
- **`Switch`** (`size="xs"`) — bare, with `aria-label`. No bordered text wrapper.
- **`FanMenu`** (`src/components/ui/fan-menu.tsx`) — one hub control that
fans sibling toggles out on hover: `direction` `vertical` | `horizontal`
(split around the hub) | `arc`. Discs are `Button` `floating` off /
`default` on; tips anchor left by default. Use it where a row of rarely
touched toggles is costing input width (the composer's voice controls).
## Layout
@@ -271,6 +285,10 @@ so glass and message-bubble transparency do not reveal scrolling text.
from the chip to the floating pill; leaving both dismisses it.
- A tool result may expose an inline action that opens a preview. It must not
open the rail automatically.
- Tool rows reserve destructive red for explicit failures. Missing read paths and
ambiguous exit-1 results use neutral notices, with details still available.
Errors described inside returned data are not tool failures. Expanded failures
show the actual explanation; supporting output keeps its normal text color.
- Composer status groups start collapsed except todos. Progress updates and queue
pause/resume preserve the user's disclosure choice. Error banners meet the
stack's top edge without a blank padding strip. File and preview links remain
@@ -0,0 +1,164 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
import { expect, test } from './test'
// #100406: in a Bot Mode group room a teammate's `@hermes` handoff must give
// the primary profile (internal name `default`) its turn, exactly like
// `@hermes → @code-farmer` already does. The live roster stamps the primary
// row's handle as the bare profile id ("default"), and the mention parser let
// that stamped handle shadow the `@hermes` alias — so the room settled with
// Hermes never driven. The mock inference server scripts each member's line
// from the user's send (`E2E_SAY(<handle>)[…]`), so the assertion is on the
// persisted room log: an entry authored by `default` saying "B".
let fixture: MockBackendFixture | null = null
type Page = MockBackendFixture['page']
interface RoomLogEntry {
from?: { kind?: string; name?: string }
text?: string
}
async function openBots(page: Page): Promise<void> {
const tab = page
.getByRole('button', { name: 'Bots', exact: true })
.or(page.getByRole('tab', { name: 'Bots', exact: true }))
.first()
await tab.click()
await expect(page.getByRole('button', { name: 'New bot or group chat' })).toBeVisible()
}
async function createAgent(page: Page, name: string, title: string): Promise<void> {
await page.getByRole('button', { name: 'New bot or group chat' }).click()
await page.getByRole('menuitem', { name: 'New Bot' }).click()
const dialog = page.getByRole('dialog', { name: 'New Bot' })
await dialog.getByPlaceholder('inbox-triage').fill(name)
await dialog.getByPlaceholder('Inbox Triage').fill(title)
await dialog.getByRole('button', { name: 'Create Bot' }).click()
await expect(dialog).toBeHidden({ timeout: 30_000 })
await expect(page.getByRole('button', { name: new RegExp(`^${title}\\b`) }).first()).toBeVisible({ timeout: 30_000 })
}
/** The plugin's persisted room log (`hermes.plugin.hermes-bots.group-chats`). */
async function roomLog(page: Page, group: string): Promise<RoomLogEntry[]> {
return page.evaluate(name => {
const raw = window.localStorage.getItem('hermes.plugin.hermes-bots.group-chats')
const rooms = raw ? (JSON.parse(raw) as Record<string, { log?: RoomLogEntry[] }>) : {}
return rooms[name]?.log ?? []
}, group)
}
test.beforeAll(async () => {
fixture = await setupMockBackend()
await waitForAppReady(fixture, 120_000)
})
// Playwright requires an object-destructured fixture argument.
// eslint-disable-next-line no-empty-pattern
test.afterEach(async ({}, info) => {
if (!fixture) {
return
}
await info.attach('room-log', {
body: JSON.stringify(await roomLog(fixture.page, 'Hermes, Code Farmer'), null, 2),
contentType: 'application/json'
})
await info.attach('native-window', { body: await fixture.page.screenshot(), contentType: 'image/png' })
await info.attach('runtime-source', {
body: JSON.stringify(
await fixture.app.evaluate(() => ({
cwd: process.cwd(),
argv: process.argv,
root: process.env.HERMES_DESKTOP_HERMES_ROOT,
home: process.env.HERMES_HOME
})),
null,
2
),
contentType: 'application/json'
})
await info.attach('desktop-log', {
body: readFileSync(join(fixture.sandbox.hermesHome, 'logs/desktop.log')),
contentType: 'text/plain'
})
})
test.afterAll(async () => {
await fixture?.cleanup()
fixture = null
})
test('a teammate handing off with @hermes drives the primary profile', async () => {
test.setTimeout(420_000)
const page = fixture!.page
const group = 'Hermes, Code Farmer'
await openBots(page)
await createAgent(page, 'code-farmer', 'Code Farmer')
await page.getByRole('button', { name: 'New bot or group chat' }).click()
await page.getByRole('menuitem', { name: 'New Group Chat' }).click()
const dialog = page.getByRole('dialog', { name: 'New Group Chat' })
for (const title of ['Hermes', 'Code Farmer']) {
await dialog.getByText(title, { exact: true }).locator('xpath=ancestor::label').getByRole('checkbox').click()
}
await dialog.getByRole('textbox', { name: 'Group name' }).fill(group)
await dialog.getByRole('button', { name: 'Create Group (2)' }).click()
const composer = page.getByRole('textbox', { name: `Message ${group}` }).filter({ visible: true })
await expect(composer).toBeVisible({ timeout: 20_000 })
// Only Code Farmer is addressed by the user. Its scripted reply hands off
// to @hermes; Hermes' scripted reply is "B". Neither script token carries
// a literal `@`, so the user send itself never mentions Hermes.
await composer.fill(
'@code-farmer Please reply with one line only. ' +
'E2E_SAY(code-farmer)[{at}hermes Please reply with the letter B.] E2E_SAY(hermes)[B]'
)
await composer.press('Enter')
// Code Farmer's handoff line lands first (the reverse direction is not in
// question); then the room must NOT settle without Hermes' turn.
await expect
.poll(
async () =>
(await roomLog(page, group)).some(e => e.from?.name === 'code-farmer' && /@hermes/.test(e.text || '')),
{
timeout: 180_000
}
)
.toBe(true)
await expect
.poll(
async () => (await roomLog(page, group)).some(e => e.from?.name === 'default' && (e.text || '').trim() === 'B'),
{
timeout: 180_000,
message: 'the primary profile (default / @hermes) never took its turn after being @mentioned by a teammate'
}
)
.toBe(true)
// And the transcript shows the handoff answered.
await expect(page.getByText('B', { exact: true }).filter({ visible: true }).first()).toBeVisible()
await composer.fill(
'@hermes Begin the reverse handoff. E2E_SAY(hermes)[{at}code-farmer Reply with D.] E2E_SAY(code-farmer)[D]'
)
await composer.press('Enter')
await expect
.poll(async () => (await roomLog(page, group)).some(e => e.from?.name === 'code-farmer' && e.text?.trim() === 'D'), {
timeout: 180_000
})
.toBe(true)
})
@@ -0,0 +1,235 @@
import { MOCK_REPLY } from '../../../tests-js/scripts/mock-server'
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
import { expect, test } from './test'
const FIRST_REPLY = 'FIRST_REPLY: initial work completed'
const FOLLOWUP_REPLY = 'FOLLOWUP_REPLY: subsequent work completed'
let fixture: MockBackendFixture | null = null
async function publicLog(page: MockBackendFixture['page']) {
return page.evaluate(() => {
const rooms = JSON.parse(localStorage.getItem('hermes.plugin.hermes-bots.group-chats') || '{}')
return (rooms['Programmer, Reviewer']?.log || []).map((entry: any) => ({
from: entry.from.name, text: entry.text, thread: entry.thread
})) as { from: string; text: string; thread: string }[]
})
}
async function openBots(page: MockBackendFixture['page']): Promise<void> {
const tab = page.getByRole('button', { name: 'Bots', exact: true }).or(page.getByRole('tab', { name: 'Bots', exact: true })).first()
await tab.click()
await expect(page.getByRole('button', { name: 'New bot or group chat' })).toBeVisible()
}
async function createAgent(page: MockBackendFixture['page'], name: string, title: string): Promise<void> {
await page.getByRole('button', { name: 'New bot or group chat' }).click()
await page.getByRole('menuitem', { name: 'New Bot' }).click()
const dialog = page.getByRole('dialog', { name: 'New Bot' })
await dialog.getByPlaceholder('inbox-triage').fill(name)
await dialog.getByPlaceholder('Inbox Triage').fill(title)
await dialog.getByRole('button', { name: 'Create Bot' }).click()
await expect(dialog).toBeHidden({ timeout: 30_000 })
await expect(page.getByRole('button', { name: new RegExp(`^${title}\\b`) }).first()).toBeVisible({ timeout: 30_000 })
}
async function createRoom(page: MockBackendFixture['page']) {
await openBots(page)
await createAgent(page, 'programmer', 'Programmer')
await createAgent(page, 'reviewer', 'Reviewer')
await page.getByRole('button', { name: 'New bot or group chat' }).click()
await page.getByRole('menuitem', { name: 'New Group Chat' }).click()
const dialog = page.getByRole('dialog', { name: 'New Group Chat' })
for (const title of ['Programmer', 'Reviewer']) {
await dialog.getByText(title, { exact: true }).locator('xpath=ancestor::label').getByRole('checkbox').click()
}
await dialog.getByRole('textbox', { name: 'Group name' }).fill('Programmer, Reviewer')
await dialog.getByRole('button', { name: 'Create Group (2)' }).click()
const groupTab = page.getByRole('tab', { name: /Programmer, Reviewer Close/ })
const groupComposer = page.getByRole('textbox', { name: 'Message Programmer, Reviewer' }).filter({ visible: true })
await expect(groupTab).toBeVisible({ timeout: 20_000 })
await expect(groupTab).toHaveAttribute('aria-selected', 'true')
await expect(groupComposer).toBeVisible()
return groupComposer
}
test.beforeEach(async () => {
fixture = await setupMockBackend({ mockServer: {
holdFirstCompletionContaining: 'LANE_A_FIRST',
replyForPrompt: prompt => prompt.includes('LANE_A_FOLLOWUP') ? FOLLOWUP_REPLY : prompt.includes('LANE_A_FIRST') ? FIRST_REPLY : MOCK_REPLY
} })
await waitForAppReady(fixture, 120_000)
})
test.afterEach(async () => {
await fixture?.cleanup()
fixture = null
})
test('group follow-up waits for its active member and retains the reply', async () => {
test.setTimeout(240_000)
const page = fixture!.page
const groupComposer = await createRoom(page)
await groupComposer.fill('@programmer LANE_A_FIRST')
await groupComposer.press('Enter')
await fixture!.mock.waitForHeldCompletion()
console.log('PRODUCTION CLOCK: first inference held; no second submit before provider release.')
await page.screenshot({ path: '/tmp/botmode-campaign/lane-a-held.png' })
await page.getByRole('button', { name: 'Reply in thread', exact: true }).click()
const replyComposer = page.getByRole('textbox', { name: 'Reply in thread', exact: true })
await replyComposer.fill('@programmer LANE_A_FOLLOWUP')
await replyComposer.press('Enter')
await page.waitForTimeout(3000)
const overlapping = fixture!.mock.receivedPrompts.filter(p => p.includes('LANE_A_FOLLOWUP'))
console.log('OVERLAPPING', overlapping)
fixture!.mock.releaseHeldStream()
expect(overlapping).toHaveLength(0)
await expect.poll(() => fixture!.mock.receivedPrompts.some(p => p.includes('LANE_A_FOLLOWUP')), { timeout: 60000 }).toBe(true)
const delivered = fixture!.mock.receivedPrompts.find(p => p.includes('LANE_A_FOLLOWUP'))!
expect(delivered).toContain(FIRST_REPLY)
expect(delivered).not.toContain('LANE_A_FIRST')
expect(delivered.indexOf('LANE_A_FOLLOWUP')).toBeLessThan(delivered.indexOf(FIRST_REPLY))
await expect(page.getByText(FIRST_REPLY, { exact: true }).filter({ visible: true })).toHaveCount(1)
await expect(page.getByText(FOLLOWUP_REPLY, { exact: true }).filter({ visible: true })).toHaveCount(1)
await expect(page.getByRole('button', { name: 'Stop', exact: true })).toHaveCount(0)
const log = await publicLog(page)
expect(log.map(({ from, text }) => [from, text])).toEqual([
['You', '@programmer LANE_A_FIRST'], ['You', '@programmer LANE_A_FOLLOWUP'],
['programmer', FIRST_REPLY], ['programmer', FOLLOWUP_REPLY]
])
expect(new Set(log.map(entry => entry.thread)).size).toBe(1)
expect(fixture!.mock.receivedPrompts.filter(p => p.includes('LANE_A_FIRST'))).toHaveLength(1)
expect(fixture!.mock.receivedPrompts.filter(p => p.includes('LANE_A_FOLLOWUP'))).toHaveLength(1)
console.log('PRODUCTION CLOCK: released; exact public log', JSON.stringify(log))
await page.screenshot({ path: '/tmp/botmode-campaign/lane-a-followup-after.png' })
})
test('quiet group still harvests a late answer after sixty observation ticks', async () => {
test.setTimeout(240_000)
const page = fixture!.page
const groupComposer = await createRoom(page)
await groupComposer.fill('@programmer LANE_A_FIRST')
await groupComposer.press('Enter')
await fixture!.mock.waitForHeldCompletion()
// Jump only the deadline clock, not WebSocket heartbeats or reconnect timers.
await page.evaluate(() => {
const now = Date.now
Date.now = () => now() + 21 * 60_000
const timeout = window.setTimeout.bind(window)
;(window as any).__harvestTicks = 0
window.setTimeout = ((handler: TimerHandler, delay?: number, ...args: any[]) => {
if (delay === 5000) {
return timeout(() => {
(window as any).__harvestTicks++
if (typeof handler === 'function') { handler(...args) }
}, 100)
}
return timeout(handler, delay, ...args)
}) as typeof window.setTimeout
})
await expect.poll(() => page.evaluate(() => (window as any).__harvestTicks), { timeout: 60000 }).toBeGreaterThanOrEqual(60)
await new Promise(resolve => setTimeout(resolve, 1500))
console.log('Harvest ticks before release:', await page.evaluate(() => (window as any).__harvestTicks))
console.log('Activity before release:', await page.getByRole('button', { name: /^Activity/ }).textContent())
fixture!.mock.releaseHeldStream()
await expect(page.getByText(FIRST_REPLY, { exact: true }).filter({ visible: true })).toHaveCount(1, { timeout: 5000 })
await page.waitForTimeout(1000)
expect((await publicLog(page)).filter(entry => entry.from === 'programmer').map(entry => entry.text)).toEqual([FIRST_REPLY])
console.log('ACCELERATED DEADLINE/OBSERVATION CLOCK ONLY: exact late public log', JSON.stringify(await publicLog(page)))
await page.screenshot({ path: '/tmp/botmode-campaign/lane-a-late-after.png' })
})
test('a rejected member turn stays visible when the room settles', async () => {
test.setTimeout(240_000)
const page = fixture!.page
await page.evaluate(() => {
const send = WebSocket.prototype.send
WebSocket.prototype.send = function(data) {
const frame = JSON.parse(String(data))
if (frame.method === 'prompt.submit' && JSON.stringify(frame.params).includes('LANE_A_FAILURE')) {
(window as any).__rejected = ((window as any).__rejected || 0) + 1
const reject = () => this.dispatchEvent(new MessageEvent('message', { data: JSON.stringify({ jsonrpc: '2.0', id: frame.id, error: { code: 4003, message: 'Controlled member admission refusal' } }) }))
if ((window as any).__rejected <= 3) { (window as any).__releaseRefusal = reject } else { queueMicrotask(reject) }
} else {
send.call(this, data)
}
}
})
const groupComposer = await createRoom(page)
await groupComposer.fill('@programmer LANE_A_FAILURE')
await groupComposer.press('Enter')
await expect.poll(() => page.evaluate(() => (window as any).__rejected), { timeout: 30000 }).toBe(1)
await page.getByRole('button', { name: 'Reply in thread', exact: true }).click()
const replyComposer = page.getByRole('textbox', { name: 'Reply in thread', exact: true })
await replyComposer.fill('@programmer prequeued LANE_A_FAILURE')
await replyComposer.press('Enter')
await groupComposer.fill('@programmer cross-thread LANE_A_FAILURE')
await groupComposer.press('Enter')
console.log('PRODUCTION CLOCK / CONTROLLED TRANSPORT: two sends queued before refusal released')
await page.evaluate(() => (window as any).__releaseRefusal())
await expect(page.getByRole('button', { name: 'Stop', exact: true })).toHaveCount(0)
await page.waitForTimeout(2000)
expect(await page.evaluate(() => (window as any).__rejected)).toBe(1)
expect((await publicLog(page)).filter(entry => entry.from !== 'You')).toEqual([])
await expect(page.getByRole('button', { name: /^Activity/ })).toContainText('Programmer hit an error')
await page.screenshot({ path: '/tmp/botmode-campaign/lane-a-error-after.png' })
// One epoch: A fails, B waits, the user retries A, then B and A fail.
// The retry is explicit and after A's failure, unlike the prequeued sends above.
await groupComposer.fill('@programmer LANE_A_FAILURE recency')
await groupComposer.press('Enter')
await expect.poll(() => page.evaluate(() => (window as any).__rejected)).toBe(2)
await groupComposer.fill('@reviewer LANE_A_FAILURE recency')
await groupComposer.press('Enter')
await page.evaluate(() => (window as any).__releaseRefusal())
await expect.poll(() => page.evaluate(() => (window as any).__rejected)).toBe(3)
await groupComposer.fill('@programmer LANE_A_FAILURE explicit retry')
await groupComposer.press('Enter')
await page.evaluate(() => (window as any).__releaseRefusal())
await expect.poll(() => page.evaluate(() => (window as any).__rejected), { timeout: 30000 }).toBe(4)
await expect(page.getByRole('button', { name: 'Stop', exact: true })).toHaveCount(0)
await expect(page.getByRole('button', { name: /^Activity/ })).toContainText('Programmer hit an error')
console.log('CONTROLLED REFUSAL: A failed, B failed, A failed again; newest unresolved summary:', await page.getByRole('button', { name: /^Activity/ }).textContent())
})
test('Stop clears a queued follow-up and a direct mention resumes the held member', async () => {
test.setTimeout(240_000)
const page = fixture!.page
const groupComposer = await createRoom(page)
await groupComposer.fill('@programmer LANE_A_FIRST')
await groupComposer.press('Enter')
await fixture!.mock.waitForHeldCompletion()
await page.getByRole('button', { name: 'Reply in thread', exact: true }).click()
const replyComposer = page.getByRole('textbox', { name: 'Reply in thread', exact: true })
await replyComposer.fill('@programmer LANE_A_CANCELLED')
await replyComposer.press('Enter')
await page.getByRole('button', { name: 'Stop', exact: true }).click()
fixture!.mock.releaseHeldStream()
await expect(page.getByRole('button', { name: 'Stop', exact: true })).toHaveCount(0)
await page.waitForTimeout(2000)
expect(fixture!.mock.receivedPrompts.some(p => p.includes('LANE_A_CANCELLED'))).toBe(false)
console.log('Stop: queued inference count = 0; paused status:', await page.getByText(/Paused:/).allTextContents())
await groupComposer.fill('@programmer LANE_A_RESUME')
await groupComposer.press('Enter')
await expect.poll(() => fixture!.mock.receivedPrompts.some(p => p.includes('LANE_A_RESUME')), { timeout: 60000 }).toBe(true)
await expect(page.getByText(MOCK_REPLY, { exact: true }).first()).toBeVisible()
await page.screenshot({ path: '/tmp/botmode-campaign/lane-a-stop-resume.png' })
})
@@ -19,6 +19,7 @@ import { afterAll, describe, expect, it } from 'vitest'
import {
destroyKeepaliveAgents,
downloadAgentFor,
htmlResponseError,
httpStatusError,
isIdempotentMethod,
isTransientTransportError,
@@ -404,3 +405,38 @@ describe('httpStatusError', () => {
expect(httpStatusError(0, 'boom').statusCode).toBe(500)
})
})
describe('htmlResponseError', () => {
it('names an auth redirect with its Location for 3xx and keeps the endpoint-missing capability wording for 2xx HTML', () => {
const redirected = htmlResponseError(
'https://gateway.example.com/api/profiles',
302,
'https://sso.example.com/login?next=%2Fapi%2Fprofiles'
).message
expect(redirected).toContain('status 302')
expect(redirected).toContain('to https://sso.example.com/login?next=%2Fapi%2Fprofiles')
expect(redirected).toMatch(/authentication proxy/)
expect(redirected).not.toContain('endpoint is likely missing')
expect(htmlResponseError('https://gateway.example.com/api/profiles', 307).message).toMatch(/redirected \(status 307\)\. This is usually/)
expect(htmlResponseError('https://gateway.example.com/api/missing', 200).message).toContain(
'endpoint is likely missing'
)
})
it('does not blame credentials when the redirect only fixes the scheme or a trailing slash', () => {
for (const [url, location] of [
['http://gateway.example.com/api/profiles', 'https://gateway.example.com/api/profiles'],
['https://gateway.example.com/api/profiles', '/api/profiles/'],
['https://gateway.example.com/hermes/api/health', 'https://gateway.example.com/hermes/api/health/']
]) {
const message = htmlResponseError(url, 301, location).message
expect(message).toContain(`to ${location}`)
expect(message).toMatch(/scheme or trailing slash/)
expect(message).not.toMatch(/authentication proxy/)
}
expect(htmlResponseError('https://gateway.example.com/api/profiles', 302, 'https://gateway.example.com/login').message).toMatch(/authentication proxy/)
})
})
+52
View File
@@ -194,9 +194,61 @@ function readStatusCode(error: unknown): number {
return Number(error && typeof error === 'object' ? (error as { statusCode?: unknown }).statusCode : NaN)
}
/**
* Error for a JSON endpoint that did not answer JSON. A 2xx HTML body is the
* SPA index.html for an unregistered /api path, and downstream capability
* probes (isMissingHealthEndpointError, gateway-rpc) key on the "endpoint is
* likely missing" wording. A 3xx (callers never follow redirects, whatever
* the body) is the request being bounced elsewhere: an access proxy sending
* it to its login page, or a scheme / trailing-slash redirect when the saved
* URL is off by that much — so it must neither carry that wording nor blame
* the backend, and it names the Location when the server sent one.
*/
function htmlResponseError(url: string, statusCode: unknown, location?: unknown) {
const status = Number(statusCode)
if (status >= 300 && status < 400) {
const target = typeof location === 'string' && location.trim() ? location.trim() : null
const hint = isSchemeOrSlashRedirect(url, target)
? 'The saved gateway URL differs from the server by scheme or trailing slash; update it to match.'
: 'This is usually an authentication proxy in front of the gateway; check the saved token and extra gateway headers.'
return new Error(
`Expected JSON from ${url} but the request was redirected (status ${statusCode})${target ? ` to ${target}` : ''}. ${hint}`
)
}
return new Error(
`Expected JSON from ${url} but got HTML (status ${statusCode}). The endpoint is likely missing on the Hermes backend.`
)
}
function isSchemeOrSlashRedirect(requestUrl: string, location: null | string): boolean {
if (!location) {
return false
}
try {
const from = new URL(requestUrl)
const to = new URL(location, requestUrl)
const strip = (value: string) => value.replace(/\/+$/, '')
return (
from.host === to.host &&
from.search === to.search &&
(from.protocol !== to.protocol || from.pathname !== to.pathname) &&
strip(from.pathname) === strip(to.pathname)
)
} catch {
return false
}
}
export {
destroyKeepaliveAgents,
downloadAgentFor,
htmlResponseError,
httpStatusError,
isIdempotentMethod,
isTransientTransportError,
+7 -1
View File
@@ -26,13 +26,19 @@ import { hiddenWindowsChildOptions } from './windows-child-options'
export function execText(command: string, args: string[], { timeout = 3000 } = {}): Promise<string> {
return new Promise<string>((resolve, reject) => {
execFile(command, args, hiddenWindowsChildOptions({ encoding: 'utf8', timeout }), (error, stdout) => {
const child = execFile(command, args, hiddenWindowsChildOptions({ encoding: 'utf8', timeout }), (error, stdout) => {
if (error) {
reject(error)
} else if (timeout > 0 && child.killed) {
// A SIGTERM handler can exit zero after execFile's timeout fired.
reject(new Error(`${command} timed out after ${timeout}ms`))
} else {
resolve(String(stdout || '').trim())
}
})
// These probes are noninteractive; do not leave readers waiting for input.
child.stdin?.end()
})
}
@@ -48,6 +48,12 @@ export function createBackendConnectionState<TProcess, TConnection>() {
return attempt.generation === generation
},
assertCurrentAttempt(attempt: BackendConnectionAttempt<TConnection>): void {
if (attempt.generation !== generation) {
throw new Error('Hermes backend start was superseded by a newer connection attempt.')
}
},
attachProcess(
attempt: BackendConnectionAttempt<TConnection>,
nextProcess: TProcess
@@ -195,9 +195,9 @@ describe('main.ts wiring for #90812', () => {
it('routes every registry-scoped REST dispatch (hermes:api) through the single-owner claim', () => {
const handlerStart = mainSource.indexOf('async function dispatchRegistryApiRequest(')
expect(handlerStart).toBeGreaterThan(-1)
const body = mainSource.slice(handlerStart, handlerStart + 900)
const body = mainSource.slice(handlerStart, handlerStart + 1_000)
expect(body).toContain('backendDialClaims.run(backendScopeKey(registryConnectionId, routeProfile)')
expect(body).toContain('ensureRegistryBackend(registryConnectionId, routeProfile)')
expect(body).toContain("ensureRegistryBackend(registryConnectionId, routeProfile, '', { spawnPriority })")
})
})
+67 -18
View File
@@ -7,6 +7,7 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import net from 'node:net'
import os from 'node:os'
import path from 'node:path'
@@ -15,6 +16,7 @@ import { test } from 'vitest'
import {
canImportHermesCli,
DEFAULT_PROBE_TIMEOUT_MS,
execProbe,
hermesRuntimeImportProbe,
PROBE_TIMEOUT_MS,
resolveProbeTimeoutMs,
@@ -29,24 +31,71 @@ import {
// (a tiny script we write to disk that exits 0 on --version).
const NODE_BIN = process.execPath
test('canImportHermesCli returns false when path is falsy', () => {
assert.equal(canImportHermesCli(''), false)
assert.equal(canImportHermesCli(null), false)
assert.equal(canImportHermesCli(undefined), false)
test('execProbe keeps the parent event loop available to the child', async () => {
let unexpectedSocketError: Error | undefined
const server = net.createServer((socket) => {
socket.on('error', (error) => {
// A successful child exits immediately after reading the sentinel. On
// Windows that peer close can surface as ECONNRESET on the server side.
if ((error as NodeJS.ErrnoException).code !== 'ECONNRESET') {
unexpectedSocketError ??= error
}
})
socket.end('pong')
})
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
const address = server.address()
assert.ok(address && typeof address === 'object')
const childScript = `
const net = require('node:net')
let reply = ''
const socket = net.createConnection(${address.port}, '127.0.0.1')
socket.setEncoding('utf8')
socket.on('data', (chunk) => { reply += chunk })
socket.on('end', () => process.exit(reply === 'pong' ? 0 : 1))
socket.on('error', () => process.exit(1))
`
try {
await execProbe(NODE_BIN, ['-e', childScript], {
stdio: 'ignore',
timeout: 5_000,
windowsHide: true
})
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()))
})
}
assert.ifError(unexpectedSocketError)
})
test('canImportHermesCli returns false when interpreter cannot run -c', () => {
test('canImportHermesCli returns false when path is falsy', async () => {
assert.equal(await canImportHermesCli(''), false)
assert.equal(await canImportHermesCli(null), false)
assert.equal(await canImportHermesCli(undefined), false)
})
test('canImportHermesCli returns false when interpreter cannot run -c', async () => {
// node IS an interpreter, but `node -c "import hermes_cli"` is a
// SyntaxError -- different exit reason from a real Python's
// ModuleNotFoundError, but the predicate is "exit 0 or not" and
// both land on "not", which is exactly what we want for the
// resolver fall-through.
assert.equal(canImportHermesCli(NODE_BIN), false)
assert.equal(await canImportHermesCli(NODE_BIN), false)
})
test('canImportHermesCli returns false when binary does not exist', () => {
test('canImportHermesCli returns false when binary does not exist', async () => {
const ghost = path.join(os.tmpdir(), 'hermes-probes-ghost-' + Date.now() + '.exe')
assert.equal(canImportHermesCli(ghost), false)
assert.equal(await canImportHermesCli(ghost), false)
})
test('hermes runtime import probe checks config dependencies', () => {
@@ -68,18 +117,18 @@ test('empty Hermes override is not authoritative', () => {
assert.equal(shouldTrustHermesOverride(undefined), false)
})
test('verifyHermesCli returns false when command is falsy', () => {
assert.equal(verifyHermesCli(''), false)
assert.equal(verifyHermesCli(null), false)
assert.equal(verifyHermesCli(undefined), false)
test('verifyHermesCli returns false when command is falsy', async () => {
assert.equal(await verifyHermesCli(''), false)
assert.equal(await verifyHermesCli(null), false)
assert.equal(await verifyHermesCli(undefined), false)
})
test('verifyHermesCli returns false when binary does not exist', () => {
test('verifyHermesCli returns false when binary does not exist', async () => {
const ghost = path.join(os.tmpdir(), 'hermes-probes-ghost-' + Date.now() + '.exe')
assert.equal(verifyHermesCli(ghost), false)
assert.equal(await verifyHermesCli(ghost), false)
})
test('verifyHermesCli returns true when --version exits 0', () => {
test('verifyHermesCli returns true when --version exits 0', async () => {
// Write a tiny script that exits 0 regardless of args, then invoke
// it through node. This stands in for a working hermes binary --
// verifyHermesCli only cares about the exit code.
@@ -92,7 +141,7 @@ test('verifyHermesCli returns true when --version exits 0', () => {
// execFileSync passes ['--version'] as args, which node ignores
// gracefully (well, it prints its version and exits 0, which is
// perfect -- exit code 0 is the only signal we read).
assert.equal(verifyHermesCli(NODE_BIN), true)
assert.equal(await verifyHermesCli(NODE_BIN), true)
} finally {
try {
fs.unlinkSync(scriptPath)
@@ -102,12 +151,12 @@ test('verifyHermesCli returns true when --version exits 0', () => {
}
})
test('verifyHermesCli swallows timeouts (does not throw)', () => {
test('verifyHermesCli swallows timeouts (does not throw)', async () => {
// We can't easily provoke a real hang in CI without slowing the
// suite, but we CAN confirm that an invocation that DOES throw
// (because the binary is missing) returns false rather than
// propagating. Same code path the timeout case takes.
assert.equal(verifyHermesCli('/definitely/not/a/real/binary/anywhere'), false)
assert.equal(await verifyHermesCli('/definitely/not/a/real/binary/anywhere'), false)
})
test('default probe timeout is 15s (not the old 5s death-loop value)', () => {
+33 -12
View File
@@ -33,7 +33,7 @@
* as bootstrap-platform.ts and hardening.ts).
*/
import { execFileSync } from 'node:child_process'
import { spawn } from 'node:child_process'
/** Default probe budget. 5s false-negativeed healthy Windows cold starts (#61764). */
const DEFAULT_PROBE_TIMEOUT_MS = 15_000
@@ -85,10 +85,10 @@ function isTimeoutError(err: unknown): boolean {
}
/**
* Run execFileSync; on timeout only, retry once before failing.
* Run without blocking the event loop; on timeout only, retry once before failing.
* Non-timeout failures (ENOENT, non-zero exit) fail immediately.
*/
function execProbeSync(
async function execProbe(
command: string,
args: string[],
options: {
@@ -99,16 +99,36 @@ function execProbeSync(
shell?: boolean
windowsHide?: boolean
}
): void {
): Promise<void> {
const run = () =>
new Promise<void>((resolve, reject) => {
const child = spawn(command, args, options)
child.once('error', reject)
child.once('close', (code, signal) => {
// A timed-out probe may handle SIGTERM and exit zero; it is still a timeout.
if (code === 0 && !child.killed) {
resolve()
} else {
reject(
Object.assign(new Error(`Runtime probe failed: ${command} (${signal || code})`), {
code,
signal,
killed: child.killed
})
)
}
})
})
try {
execFileSync(command, args, options)
await run()
} catch (err) {
if (!isTimeoutError(err)) {
throw err
}
// One cold-cache / AV miss should not force hermes-setup --update (#61764).
execFileSync(command, args, options)
await run()
}
}
@@ -141,13 +161,13 @@ function hermesRuntimeImportProbe() {
* @param {object} [opts.env] - Additional environment for the probe.
* @returns {boolean}
*/
function canImportHermesCli(pythonPath: string, opts: { env?: Record<string, string> } = {}) {
async function canImportHermesCli(pythonPath: string, opts: { env?: Record<string, string> } = {}) {
if (!pythonPath) {
return false
}
try {
execProbeSync(pythonPath, ['-c', hermesRuntimeImportProbe()], {
await execProbe(pythonPath, ['-c', hermesRuntimeImportProbe()], {
env: { ...process.env, ...(opts.env || {}) },
stdio: 'ignore',
timeout: PROBE_TIMEOUT_MS,
@@ -175,7 +195,7 @@ function canImportHermesCli(pythonPath: string, opts: { env?: Record<string, str
* @param {string} hermesCommand - Resolved absolute path to a hermes
* executable (or an interpreter+script wrapper).
* @param {boolean} [opts.shell] - Whether to run through a shell. For
* .cmd/.bat shims on Windows execFileSync needs shell:true to find
* .cmd/.bat shims on Windows spawn needs shell:true to find
* the cmd interpreter; mirrors the same flag isCommandScript() drives
* in resolveHermesBackend.
* @returns {boolean}
@@ -190,13 +210,13 @@ function shouldTrustHermesOverride(hermesOverride?: string) {
return typeof hermesOverride === 'string' && hermesOverride.trim().length > 0
}
function verifyHermesCli(hermesCommand: string, opts?: { shell?: boolean }) {
async function verifyHermesCli(hermesCommand: string, opts?: { shell?: boolean }) {
if (!hermesCommand) {
return false
}
try {
execProbeSync(hermesCommand, ['--version'], {
await execProbe(hermesCommand, ['--version'], {
stdio: 'ignore',
timeout: PROBE_TIMEOUT_MS,
shell: Boolean(opts?.shell),
@@ -212,8 +232,9 @@ function verifyHermesCli(hermesCommand: string, opts?: { shell?: boolean }) {
export {
canImportHermesCli,
DEFAULT_PROBE_TIMEOUT_MS,
execProbeSync,
execProbe,
hermesRuntimeImportProbe,
isTimeoutError,
PROBE_TIMEOUT_MS,
resolveProbeTimeoutMs,
shouldTrustHermesOverride,
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict'
import { test, vi } from 'vitest'
import * as probes from './backend-probes'
import { createBackendServeSupportResolver } from './backend-serve-support'
test('concurrent serve checks share one pending probe and retain its negative result', async () => {
let fail!: (error: Error) => void
const pending = new Promise<void>((_resolve, reject) => {
fail = reject
})
const probe = vi.spyOn(probes, 'execProbe').mockReturnValue(pending)
const supportsServe = createBackendServeSupportResolver('/unused', () => {})
const backend = { command: '/unused/hermes', args: ['serve'] }
try {
const first = supportsServe(backend)
const second = supportsServe(backend)
const callsWhilePending = probe.mock.calls.length
fail(new Error('unsupported'))
assert.deepEqual(await Promise.all([first, second]), [false, false])
assert.equal(callsWhilePending, 1)
assert.equal(await supportsServe(backend), false)
assert.equal(probe.mock.calls.length, 1)
} finally {
probe.mockRestore()
}
})
test('a probe that fails by timeout is not cached, so the next check re-probes', async () => {
const probe = vi
.spyOn(probes, 'execProbe')
.mockRejectedValueOnce(Object.assign(new Error('timed out'), { killed: true }))
.mockResolvedValueOnce(undefined)
const supportsServe = createBackendServeSupportResolver('/unused', () => {})
const backend = { command: '/unused/hermes', args: ['serve'] }
try {
assert.equal(await supportsServe(backend), false)
assert.equal(await supportsServe(backend), true)
assert.equal(probe.mock.calls.length, 2)
} finally {
probe.mockRestore()
}
})
@@ -0,0 +1,105 @@
import fs from 'node:fs'
import path from 'node:path'
import { sourceDeclaresServe } from './backend-command'
import { execProbe, isTimeoutError, PROBE_TIMEOUT_MS } from './backend-probes'
interface ServeCandidate {
command?: string | null
root?: string
args?: string[]
env?: Record<string, string>
shell?: boolean
label?: string
}
// Does the resolved runtime understand the `serve` subcommand? The desktop
// spawns `hermes serve`; runtimes older than serve only have `dashboard`, so
// main.ts routes those through the legacy `dashboard --no-open` form instead
// of crashing on an unknown subcommand.
//
// Fast path: read the runtime's own dashboard.py (instant, covers managed
// installs, dev checkouts, and the Windows venv). Fallback: probe the CLI once
// (covers a bare `hermes` resolved from PATH with no known source root). Result
// is cached per resolved runtime so we probe at most once per backend — except
// a probe that failed by timeout, which is evicted so the next start re-probes
// rather than pinning a cold-AV false negative for the process lifetime.
//
// One cache per desktop runtime context; source inspection precedes a CLI probe.
export function createBackendServeSupportResolver(hermesHome: string, rememberLog: (message: string) => void) {
const cache = new Map<string, Promise<boolean>>()
return async function backendSupportsServe(backend: ServeCandidate): Promise<boolean> {
if (!backend || !backend.command) {
return true
}
const key = `${backend.command}::${backend.root || ''}`
if (cache.has(key)) {
return cache.get(key)!
}
const pending = (async () => {
let supported: boolean | null = null
if (backend.root) {
try {
const src = await fs.promises.readFile(
path.join(backend.root, 'hermes_cli', 'subcommands', 'dashboard.py'),
'utf8'
)
supported = sourceDeclaresServe(src)
} catch {
supported = null // source unreadable — fall through to the probe
}
}
if (supported === null) {
try {
const prefix = backend.args && backend.args[0] === '-m' ? backend.args.slice(0, 2) : []
// Same cold-Windows Python-startup class as the runtime probes
// (#61764/#72632/#72707): `serve --help` imports at least as much as
// `hermes --version` (~10.5s measured cold), and a false negative here
// is cached for the process lifetime, silently routing a modern
// runtime through the legacy `dashboard` form. Share the probe budget
// and its timeout-only retry instead of a thinner local bound.
await execProbe(backend.command, [...prefix, 'serve', '--help'], {
cwd: backend.root || undefined,
env: { ...process.env, HERMES_HOME: hermesHome, ...(backend.env || {}) },
timeout: PROBE_TIMEOUT_MS,
stdio: 'ignore',
// `.cmd`/`.bat` shim backends carry shell: true in their descriptor
// (see resolveHermesBackend step 4); execFileSync of a .cmd without
// shell throws EINVAL on modern Node, which the catch below would
// mis-cache as "serve unsupported" for the process lifetime.
shell: Boolean(backend.shell),
windowsHide: true
})
supported = true
} catch (err) {
// A timeout says nothing about the runtime, only about this machine
// right now (cold AV scan, slow disk). Evict so the next call
// re-probes; a genuine "unknown subcommand" exit stays cached.
if (isTimeoutError(err) && cache.get(key) === pending) {
cache.delete(key)
}
supported = false
}
}
rememberLog(
`[backend] \`serve\` ${supported ? 'supported' : 'unsupported → routing via legacy `dashboard`'} for ${backend.label || key}`
)
return supported
})()
// Publish the promise before yielding; late results never overwrite a newer entry.
cache.set(key, pending)
return pending
}
}
@@ -0,0 +1,40 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { bootstrapStageLabel, describeBootstrapFailure, missingInstallPartMessage } from './bootstrap-failure-copy'
test('known stage names get everyday labels; unknown ones are humanized', () => {
assert.equal(bootstrapStageLabel('venv'), 'Python environment')
assert.equal(bootstrapStageLabel('system-packages'), 'System packages')
assert.equal(bootstrapStageLabel('some-new_stage'), 'Some new stage')
assert.equal(bootstrapStageLabel(null), null)
})
test('lead sentence is plain and actionable; raw error is confined to the Details line', () => {
const raw = "install.ps1 exited 1: spawn ENOENT (stage 'venv')"
const message = describeBootstrapFailure('venv', raw)
const [lead, ...rest] = message.split('\n')
assert.match(lead, /'Python environment' step/)
assert.match(lead, /Reload and retry/)
assert.match(lead, /open the logs/)
assert.doesNotMatch(lead, /bootstrap|stage|venv|ENOENT|exited|desktop\.log/)
assert.equal(rest.join('\n'), `Details: ${raw}`)
})
test('missing stage and missing error still produce a complete message', () => {
const message = describeBootstrapFailure(null, undefined)
assert.match(message, /^Setting up Hermes stopped before it could finish\./)
assert.match(message, /\nDetails: unknown error$/)
})
test('missing-install-part copy names Repair install and keeps the path in Details', () => {
const message = missingInstallPartMessage('Python environment missing at /home/me/.hermes/venv')
const [lead, details] = message.split('Details: ')
assert.match(lead, /Repair install/)
assert.doesNotMatch(lead, /venv|install\.ps1|\//)
assert.equal(details, 'Python environment missing at /home/me/.hermes/venv')
})
@@ -0,0 +1,88 @@
/**
* User-facing copy for a failed first-run install (bootstrap).
*
* The install runner reports the manifest stage name that failed (see
* electron/bootstrap-runner.ts and the stage manifests in scripts/install.ps1 /
* scripts/install.sh) plus the raw error text. This module turns that into an
* Error.message the install overlay can show verbatim: a plain lead sentence
* naming the step in everyday words and what to do next, with the raw error on
* a trailing "Details:" line.
*
* Pure module: no Electron imports, unit-tested next to it.
*/
/** Manifest stage name -> everyday label. Unknown names fall back to humanizeStageName. */
export const BOOTSTRAP_STAGE_LABELS: ReadonlyMap<string, string> = new Map([
// scripts/install.ps1 manifest
['uv', 'Package installer'],
['git', 'Git'],
['node', 'Node.js'],
['system-packages', 'System packages'],
['repository', 'Hermes source code'],
['python', 'Python runtime'],
['venv', 'Python environment'],
['dependencies', 'Python packages'],
['node-deps', 'Browser tool packages'],
['desktop', 'Desktop app build'],
['platform-sdks', 'Platform tools'],
['configure', 'Settings'],
['config-templates', 'Settings templates'],
['path', 'Hermes command'],
['gateway', 'Hermes service'],
['bootstrap-marker', 'Finishing touches'],
// scripts/install.sh manifest (names that differ from the Windows one)
['prerequisites', 'System prerequisites'],
['python-deps', 'Python packages'],
['config', 'Settings'],
['setup', 'Settings'],
['complete', 'Finishing touches']
])
/** `system-packages` -> `System packages`. */
export function humanizeStageName(stage: string): string {
const words = stage.replace(/[-_]+/g, ' ').trim()
return words ? words.charAt(0).toUpperCase() + words.slice(1) : ''
}
export function bootstrapStageLabel(stage: string | null | undefined): string | null {
if (!stage) {
return null
}
return BOOTSTRAP_STAGE_LABELS.get(stage) ?? humanizeStageName(stage)
}
const BOOTSTRAP_FAILURE_REMEDY =
'Common causes: no internet connection, antivirus blocking the installer, or another copy of Hermes running. ' +
'Close other Hermes windows and choose Reload and retry; if it fails again, open the logs and send them to support.'
/**
* Build the Error.message for a failed bootstrap. First line is the plain
* explanation; the raw error follows on its own "Details:" line.
*/
export function describeBootstrapFailure(failedStage: string | null | undefined, rawError: unknown): string {
const label = bootstrapStageLabel(failedStage)
const lead = label
? `Setting up Hermes stopped during the '${label}' step.`
: 'Setting up Hermes stopped before it could finish.'
const details = typeof rawError === 'string' && rawError.trim() ? rawError.trim() : 'unknown error'
return `${lead} ${BOOTSTRAP_FAILURE_REMEDY}\nDetails: ${details}`
}
/**
* Error.message for an installed Hermes with a piece missing (source tree,
* Python environment). The renderer's install overlay offers the Repair install
* button ('hermes:bootstrap:repair'), so the copy points there. `whatIsMissing`
* names the missing part and its path, e.g. "Python environment missing at /x".
*/
export function missingInstallPartMessage(whatIsMissing: string): string {
return (
"Part of Hermes' installation is missing (it may have been deleted or quarantined by antivirus). " +
'Choose Repair install below to put it back — your chats and settings are not affected. ' +
`Details: ${whatIsMissing}`
)
}
@@ -2,7 +2,7 @@ import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
migrateProfileScopedDesktopPlugins,
@@ -101,6 +101,54 @@ describe('reconcileUnifiedDesktopHalves', () => {
expect(fs.existsSync(path.join(appRoot, 'media'))).toBe(false)
})
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
'skips a package the app cannot read and still materializes its siblings',
async () => {
// #111804: one unreadable plugin folder rejected the whole reconcile, so the
// desktop-plugins root never resolved and no desktop plugin loaded.
const home = makeHome()
const appRoot = path.join(home, 'desktop-plugins')
const denied = path.join(home, 'plugins', 'denied', 'desktop', 'plugin.js')
write(denied, 'x')
write(path.join(home, 'plugins', 'good', 'desktop', 'plugin.js'), 'y')
fs.chmodSync(denied, 0)
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
try {
expect(await reconcileUnifiedDesktopHalves(home, appRoot)).toEqual([path.join(appRoot, 'good')])
expect(warn).toHaveBeenCalledWith(expect.stringContaining('skipping unreadable package denied'))
} finally {
warn.mockRestore()
fs.chmodSync(denied, 0o600)
}
}
)
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
'keeps (and warns about) the desktop half of a package folder it can no longer read',
async () => {
// A source the app cannot stat (Windows ACL EPERM, mode-000 folder) is not
// an uninstall: the ghost-prune loop must not rm the materialized half.
const home = makeHome()
const appRoot = path.join(home, 'desktop-plugins')
const denied = path.join(home, 'plugins', 'denied')
write(path.join(denied, 'desktop', 'plugin.js'), 'x')
await reconcileUnifiedDesktopHalves(home, appRoot)
expect(fs.existsSync(path.join(appRoot, 'denied'))).toBe(true)
fs.chmodSync(denied, 0)
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
try {
expect(await reconcileUnifiedDesktopHalves(home, appRoot)).toEqual([])
expect(fs.existsSync(path.join(appRoot, 'denied'))).toBe(true)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('unreadable package denied'))
} finally {
warn.mockRestore()
fs.chmodSync(denied, 0o700)
}
}
)
it('stamps the package origin (catalog sidecar, else git remote) so "Install here" can reinstall the agent half', async () => {
const home = makeHome()
const appRoot = path.join(home, 'desktop-plugins')
+43 -3
View File
@@ -164,7 +164,11 @@ export async function materializeDesktopHalf(
try {
stat = await fs.promises.stat(entry)
} catch {
} catch (error) {
if (!isMissing(error)) {
console.warn(`[desktop-plugins] cannot read ${packageName}: ${String(error)}`)
}
return null
}
@@ -202,6 +206,31 @@ export async function materializeDesktopHalf(
return target
}
function isMissing(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException | null)?.code
return code === 'ENOENT' || code === 'ENOTDIR'
}
/** `true` only when the package's `desktop/plugin.js` is genuinely gone. A
* source the app is not ALLOWED to stat (Windows ACL EPERM, a mode-000 folder)
* is not an uninstall — pruning its root copy would silently drop the pane. */
async function sourceGone(name: string, entry: string): Promise<boolean> {
try {
await fs.promises.stat(entry)
return false
} catch (error) {
if (isMissing(error)) {
return true
}
console.warn(`[desktop-plugins] keeping desktop half of unreadable package ${name}: ${String(error)}`)
return false
}
}
/** Walk every local home's `plugins/` root and materialize each package's
* desktop half. First home wins for a name that appears in several profiles
* (the default home is first). Also drops root copies whose source package
@@ -218,7 +247,18 @@ export async function reconcileUnifiedDesktopHalves(hermesHome: string, appRoot:
continue
}
const result = await materializeDesktopHalf(path.join(pluginsRoot, name), appRoot, name)
let result: null | string
try {
result = await materializeDesktopHalf(path.join(pluginsRoot, name), appRoot, name)
} catch (error) {
// One package the app cannot read (Windows ACL EPERM on lstat/copy, a
// mode-000 folder) must not reject the whole reconcile — the root would
// never resolve and EVERY desktop plugin would silently stop loading.
console.warn(`[desktop-plugins] skipping unreadable package ${name}: ${String(error)}`)
continue
}
if (result || fs.existsSync(path.join(pluginsRoot, name, 'desktop', 'plugin.js'))) {
seen.add(name)
@@ -234,7 +274,7 @@ export async function reconcileUnifiedDesktopHalves(hermesHome: string, appRoot:
const dir = path.join(appRoot, name)
const marker = await readMarker(dir)
if (marker && !fs.existsSync(path.join(marker.source, 'plugin.js'))) {
if (marker && (await sourceGone(name, path.join(marker.source, 'plugin.js')))) {
await fs.promises.rm(dir, { force: true, recursive: true })
touched.push(dir)
}
@@ -45,6 +45,7 @@ test('a first-run bootstrap-needed remote apply connects without ensuring or boo
const prepareLocalBackend = vi.fn(async () => bootstrapBackend)
const pendingConnection = runPrimaryBackendStartup({
assertCurrentAttempt: () => {},
connectRemote,
ensureLocalRuntime,
prepareLocalBackend,
@@ -0,0 +1,113 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { beforeEach, expect, it, vi } from 'vitest'
const host = vi.hoisted(() => ({ opened: [] as string[], openPathError: '' }))
vi.mock('electron', () => ({
shell: {
openPath: vi.fn(async (target: string) => {
host.opened.push(target)
return host.openPathError
})
}
}))
import { openExternalFileForIpc } from './fs-open-external'
async function withTempDir<T>(run: (dir: string) => T | Promise<T>): Promise<T> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-open-external-'))
try {
return await run(dir)
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
}
beforeEach(() => {
host.opened.length = 0
host.openPathError = ''
})
it('opens a hardened local path via shell.openPath', async () => {
await withTempDir(async dir => {
const file = path.join(dir, 'report.docx')
fs.writeFileSync(file, 'PK\x03\x04')
const resolveReadableFileForIpc = vi.fn(async () => ({ resolvedPath: file, stat: fs.statSync(file) }))
await expect(
openExternalFileForIpc({ path: file }, { resolveReadableFileForIpc, stagingRoot: () => dir })
).resolves.toEqual({ ok: true, path: file })
expect(resolveReadableFileForIpc).toHaveBeenCalledWith(file, { purpose: 'Open with system app' })
expect(host.opened).toEqual([file])
})
})
it('returns ok:false without opening when hardening rejects the path', async () => {
const resolveReadableFileForIpc = vi.fn(async () => {
throw new Error('Open with system app failed: path points to a directory.')
})
await expect(
openExternalFileForIpc({ path: '/tmp' }, { resolveReadableFileForIpc, stagingRoot: () => '/tmp' })
).resolves.toEqual({ ok: false, error: 'Open with system app failed: path points to a directory.' })
expect(host.opened).toEqual([])
})
it('stages remote bytes under external-open then opens the staged copy', async () => {
await withTempDir(async dir => {
const result = await openExternalFileForIpc(
{ data: [0x50, 0x4b], ext: 'pptx', name: '../decks/Q3 结果.pptx' },
{ resolveReadableFileForIpc: vi.fn(), stagingRoot: () => dir }
)
expect(result.ok).toBe(true)
const staged = host.opened[0] ?? ''
expect(path.dirname(staged)).toBe(path.join(dir, 'external-open'))
expect(path.basename(staged)).toMatch(/^Q3_结果_[0-9a-f]{6}\.pptx$/)
expect(fs.statSync(staged).size).toBe(2)
})
})
it('falls back to .bin for an unsafe extension and rejects empty payloads', async () => {
await withTempDir(async dir => {
const result = await openExternalFileForIpc(
{ data: [0x00], ext: '.exe/evil', name: 'weird' },
{ resolveReadableFileForIpc: vi.fn(), stagingRoot: () => dir }
)
expect(result.ok).toBe(true)
expect(path.basename(host.opened[0] ?? '')).toMatch(/^weird_[0-9a-f]{6}\.bin$/)
})
await expect(
openExternalFileForIpc({} as never, { resolveReadableFileForIpc: vi.fn(), stagingRoot: () => '/tmp' })
).resolves.toEqual({ ok: false, error: 'Invalid payload' })
})
it('maps a shell.openPath error string into ok:false', async () => {
host.openPathError = 'No default application configured to open this file'
await withTempDir(async dir => {
const file = path.join(dir, 'x.docx')
fs.writeFileSync(file, 'PK')
await expect(
openExternalFileForIpc(
{ path: file },
{
resolveReadableFileForIpc: vi.fn(async () => ({ resolvedPath: file, stat: fs.statSync(file) })),
stagingRoot: () => dir
}
)
).resolves.toEqual({ error: 'No default application configured to open this file', ok: false })
})
})
+85
View File
@@ -0,0 +1,85 @@
// Open a file with the OS default application. `{ path }` payloads are local
// files hardened through resolveReadableFileForIpc; `{ data, ext, name }`
// payloads are remote-gateway bytes the renderer already downloaded — staged
// under <stagingRoot>/external-open so the OS association can open them.
import crypto from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import { shell } from 'electron'
type ReadableFileResolver = (
filePath: string,
options?: { purpose?: string }
) => Promise<{ resolvedPath: string; stat: fs.Stats }>
export interface OpenExternalFileDeps {
resolveReadableFileForIpc: ReadableFileResolver
stagingRoot: () => string
}
export type OpenExternalFilePayload =
| { data: Uint8Array | number[]; ext: string; name: string }
| { path: string }
export type OpenExternalFileResult = { error?: string; ok: boolean; path?: string }
const SAFE_EXT = /^\.[a-z0-9]{1,8}$/i
function sanitizeBaseName(value: string) {
const base = String(value || '')
.split(/[\\/]/)
.pop()
?.replace(/\.[^.]+$/, '')
return (base || '')
.replace(/[^\p{L}\p{N}._-]+/gu, '_')
.replace(/^[._-]+|[._-]+$/g, '')
.slice(0, 80)
}
async function stageExternalFile(
deps: OpenExternalFileDeps,
data: Uint8Array | number[],
rawExt: string,
rawName: string
): Promise<string> {
const normalizedExt = rawExt.startsWith('.') ? rawExt : `.${rawExt}`
const ext = SAFE_EXT.test(normalizedExt) ? normalizedExt.toLowerCase() : '.bin'
const base = sanitizeBaseName(rawName)
const fileName = `${base || 'remote'}_${crypto.randomBytes(3).toString('hex')}${ext}`
const dir = path.join(deps.stagingRoot(), 'external-open')
await fs.promises.mkdir(dir, { recursive: true })
const staged = path.join(dir, fileName)
await fs.promises.writeFile(staged, Buffer.isBuffer(data) ? data : Buffer.from(data))
return staged
}
export async function openExternalFileForIpc(
payload: OpenExternalFilePayload,
deps: OpenExternalFileDeps
): Promise<OpenExternalFileResult> {
try {
let target: string
if (payload && 'path' in payload && payload.path) {
;({ resolvedPath: target } = await deps.resolveReadableFileForIpc(String(payload.path), {
purpose: 'Open with system app'
}))
} else if (payload && 'data' in payload && payload.data) {
target = await stageExternalFile(deps, payload.data, String(payload.ext || ''), String(payload.name || ''))
} else {
return { ok: false, error: 'Invalid payload' }
}
const error = await shell.openPath(target)
return error ? { ok: false, error } : { ok: true, path: target }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) }
}
}
+2 -1
View File
@@ -8,7 +8,8 @@ export const GUEST_ONBOARDING_ENV = 'HERMES_GUEST_ONBOARDING'
export const GUEST_ONBOARDING_FLAG = '--guest-onboarding'
// Skip the first-run film. A rehearsal aid: the intro is a one-time reveal,
// so anyone iterating on the guided chat behind it otherwise sits through it
// on every fresh HERMES_HOME. Renderer-only; the backend never sees it.
// on every fresh HERMES_HOME. The guide still runs — only the film is
// skipped. Renderer-only; the backend never sees it.
export const SKIP_INTRO_ENV = 'HERMES_SKIP_INTRO'
export const SKIP_INTRO_FLAG = '--skip-intro'
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { LAUNCHER_READY_FD_ENV, notifyLauncherWindowRevealed } from './linux-launcher-ready'
function createIo() {
const writes: Array<[number, string]> = []
const closes: number[] = []
return {
closes,
io: {
closeSync: (fd: number) => {
closes.push(fd)
},
writeSync: (fd: number, data: string) => {
writes.push([fd, data])
return data.length
}
},
writes
}
}
test('writes one byte to the launcher fd, closes it and consumes the variable', () => {
const env: NodeJS.ProcessEnv = { [LAUNCHER_READY_FD_ENV]: '7' }
const { closes, io, writes } = createIo()
assert.equal(notifyLauncherWindowRevealed(env, io), true)
assert.deepEqual(writes, [[7, 'r']])
assert.deepEqual(closes, [7])
assert.equal(LAUNCHER_READY_FD_ENV in env, false)
// A second reveal must not touch fd 7 again: the number may now belong to another file.
assert.equal(notifyLauncherWindowRevealed(env, io), false)
assert.equal(writes.length, 1)
})
test('a launch without the variable, or with a garbage value, is a no-op', () => {
const { io, writes } = createIo()
assert.equal(notifyLauncherWindowRevealed({}, io), false)
assert.equal(notifyLauncherWindowRevealed({ [LAUNCHER_READY_FD_ENV]: 'seven' }, io), false)
assert.equal(notifyLauncherWindowRevealed({ [LAUNCHER_READY_FD_ENV]: '-1' }, io), false)
assert.deepEqual(writes, [])
})
@@ -0,0 +1,48 @@
/**
* Tells the `hermes desktop` launcher that the main window is on screen.
*
* On Linux an app-grid launch defers writing the app's own `.desktop` entry
* until the window has mapped: unpatched gnome-shell (before GNOME MR !4428)
* drops a STARTING ShellApp's last reference when its entry changes, and the
* next idle GC takes down the whole Wayland session (#111906). The launcher
* hands us the write end of a pipe in `HERMES_DESKTOP_READY_FD`; one byte means
* "mapped, safe to heal". A launch without the variable is a no-op.
*/
import fs from 'node:fs'
export const LAUNCHER_READY_FD_ENV = 'HERMES_DESKTOP_READY_FD'
type ReadyFdIo = {
writeSync: (fd: number, data: string) => number
closeSync: (fd: number) => void
}
/**
* Signal the launcher once. The fd is closed after the write and the variable
* removed from `env`, so a second reveal (or a child inheriting the env) can
* never write into whatever file later reuses that descriptor number.
*/
export function notifyLauncherWindowRevealed(env: NodeJS.ProcessEnv = process.env, io: ReadyFdIo = fs): boolean {
const raw = env[LAUNCHER_READY_FD_ENV]
if (raw === undefined) {
return false
}
delete env[LAUNCHER_READY_FD_ENV]
const fd = Number(raw)
if (!Number.isInteger(fd) || fd < 0) {
return false
}
try {
io.writeSync(fd, 'r')
io.closeSync(fd)
return true
} catch {
return false
}
}
@@ -104,6 +104,7 @@ test('the quit barrier cancels real first-run startup and waits for both local a
const startup = lifecycle.start(() =>
runPrimaryBackendStartup({
assertCurrentAttempt: () => {},
signal: lifecycle.signal,
resolveRemote: async () => null,
connectRemote: async () => ({}),
File diff suppressed because it is too large Load Diff
@@ -59,6 +59,37 @@ test('listen binds a loopback listener and wait resolves with the redirect param
await assert.rejects(fetch(`${redirectUri}?code=again&state=st-1`))
})
test('wait relays the RFC 9207 iss parameter from the redirect', async () => {
// mcp 2.x rejects an authorization response omitting `iss` when the server
// advertised `authorization_response_iss_parameter_supported` (Cloudflare,
// Resend), so the listener must not drop it.
const { id, redirectUri } = (await invoke('hermes:mcp-oauth:listen')) as { id: string; redirectUri: string }
const waitPromise = invoke('hermes:mcp-oauth:wait', id, 5000) as Promise<{
code: null | string
iss: null | string
state: null | string
}>
await fetch(`${redirectUri}?code=abc123&state=st-1&iss=${encodeURIComponent('https://mcp.cloudflare.com')}`)
const result = await waitPromise
assert.equal(result.code, 'abc123')
assert.equal(result.iss, 'https://mcp.cloudflare.com')
})
test('a redirect without iss reports it as null rather than undefined', async () => {
// Providers that do not advertise RFC 9207 keep working unchanged.
const { id, redirectUri } = (await invoke('hermes:mcp-oauth:listen')) as { id: string; redirectUri: string }
const waitPromise = invoke('hermes:mcp-oauth:wait', id, 5000) as Promise<{ iss: null | string }>
await fetch(`${redirectUri}?code=abc123&state=st-1`)
assert.equal((await waitPromise).iss, null)
})
test('non-callback noise (favicon) does not settle the listener', async () => {
const { id, redirectUri } = (await invoke('hermes:mcp-oauth:listen')) as { id: string; redirectUri: string }
const origin = redirectUri.replace(/\/callback$/, '')
@@ -41,6 +41,10 @@ const DONE_HTML =
interface CallbackResult {
code: null | string
error: null | string
// RFC 9207 issuer identifier. Cloudflare and Resend advertise
// `authorization_response_iss_parameter_supported`, and mcp 2.x rejects an
// authorization response that omits it — relay it rather than dropping it.
iss: null | string
state: null | string
}
@@ -83,7 +87,7 @@ function dispose(id: string) {
}
if (!entry.settled) {
settle(id, { code: null, error: 'cancelled', state: null })
settle(id, { code: null, error: 'cancelled', iss: null, state: null })
}
pending.delete(id)
@@ -112,6 +116,7 @@ export function registerMcpOauthCallbackIpc() {
let code: null | string = null
let state: null | string = null
let error: null | string = null
let iss: null | string = null
try {
const parsed = new URL(url, 'http://127.0.0.1')
@@ -119,11 +124,12 @@ export function registerMcpOauthCallbackIpc() {
code = parsed.searchParams.get('code')
state = parsed.searchParams.get('state')
error = parsed.searchParams.get('error')
iss = parsed.searchParams.get('iss')
} catch {
error = 'unparseable callback URL'
}
settle(id, { code, error, state })
settle(id, { code, error, iss, state })
})
await new Promise<void>((resolve, reject) => {
@@ -143,7 +149,7 @@ export function registerMcpOauthCallbackIpc() {
const entry = pending.get(String(id || ''))
if (!entry) {
return { code: null, error: 'listener not found', state: null }
return { code: null, error: 'listener not found', iss: null, state: null }
}
if (entry.result) {
@@ -158,7 +164,7 @@ export function registerMcpOauthCallbackIpc() {
const result = await new Promise<CallbackResult>(resolve => {
const timer = setTimeout(() => {
settle(String(id), { code: null, error: 'timeout waiting for OAuth callback', state: null })
settle(String(id), { code: null, error: 'timeout waiting for OAuth callback', iss: null, state: null })
}, timeout)
entry.waiters.push(value => {
@@ -164,6 +164,44 @@ describe('createMediaProtocolHandler', () => {
expect(headers.get('range')).toBe('bytes=0-1023')
})
it('sends the connection extra gateway headers on remote media without clobbering range negotiation', async () => {
const deps = dependencies({
resolveRemoteConnection: vi.fn(async () => ({
authMode: 'token' as const,
baseUrl: 'https://gateway.test',
headers: { 'CF-Access-Client-Id': 'client-id', Range: 'bytes=9-9' },
mode: 'remote' as const,
token: 'secret'
}))
})
await createMediaProtocolHandler(deps)(
request('hermes-media://remote/%2Ftmp%2Fclip.mp4', { Range: 'bytes=0-1023' })
)
const [, headers] = vi.mocked(deps.fetchRemote).mock.calls[0]
expect(headers.get('cf-access-client-id')).toBe('client-id')
expect(headers.get('range')).toBe('bytes=0-1023')
expect(headers.get('x-hermes-session-token')).toBe('secret')
})
it('sends the connection extra gateway headers on OAuth cookie-session remote media', async () => {
const deps = dependencies({
resolveRemoteConnection: vi.fn(async () => ({
authMode: 'oauth' as const,
baseUrl: 'https://gateway.test',
headers: { 'CF-Access-Client-Id': 'client-id' },
mode: 'remote' as const,
token: null
}))
})
await createMediaProtocolHandler(deps)(request('hermes-media://remote/%2Ftmp%2Fclip.mp4'))
const [, headers] = vi.mocked(deps.fetchRemoteWithCookies).mock.calls[0]
expect(headers.get('cf-access-client-id')).toBe('client-id')
})
it('adds profile scope when one registry backend serves multiple profiles', async () => {
const deps = dependencies({
resolveRemoteConnection: vi.fn(async () => ({
+9
View File
@@ -36,6 +36,7 @@ export interface MediaRemoteScope {
export interface MediaRemoteConnection {
authMode?: 'oauth' | 'token'
baseUrl: string
headers?: Record<string, string>
mode?: 'local' | 'remote'
token?: null | string
sharedRemote?: boolean
@@ -163,6 +164,14 @@ export function createMediaProtocolHandler(dependencies: MediaProtocolDependenci
connection.sharedRemote ? target.profile : undefined
)
// The gateway's configured extra headers (access-proxy gates) travel on
// every remote request; forwarded range/cache negotiation headers win.
for (const [name, value] of Object.entries(connection.headers ?? {})) {
if (!headers.has(name)) {
headers.set(name, value)
}
}
if (connection.authMode === 'oauth') {
return await requestWithOauthFallback(connection.baseUrl, {
ensureNativeAccessToken: dependencies.ensureRemoteBearer,
@@ -131,6 +131,24 @@ describe('resolveOauthPartition (#92183 per-connection cookie jars)', () => {
expect(resolveOauthPartition('https://gw-a.example.com', { registry: reg })).toBe(got)
})
it('keeps the on-disk path component colon-free and %-free (Windows cookie-store regression)', () => {
// Electron escapes ':' in a partition name to '%3A' for the folder name.
// A Windows profile folder containing '%3A' gets a cookie store that reads
// empty and never persists, so every cookie-auth connection would 401 and
// re-prompt for sign-in on each dial. The partition path component must
// therefore never need escaping, for ANY connection id.
for (const id of ['10-0-0-88-9119', 'we ird/id:€', 'a:b/c%3Ad']) {
const reg = registry('local', [remote(id, 'https://gw-a.example.com')])
const pathComponent = resolveOauthPartition('https://gw-a.example.com/api/auth/ws-ticket', {
registry: reg
}).slice('persist:'.length)
expect(pathComponent).not.toContain(':')
expect(pathComponent).not.toContain('%')
}
})
it('breaks same-URL ties deterministically (identical jar for identical gateway)', () => {
const reg = registry('local', [
remote('zeta', 'https://gw-a.example.com'),
+16 -1
View File
@@ -36,7 +36,22 @@
export const LEGACY_OAUTH_PARTITION = 'persist:hermes-remote-oauth'
const CONNECTION_PARTITION_PREFIX = `${LEGACY_OAUTH_PARTITION}:conn:`
// Colon-free ON PURPOSE: Electron escapes ':' in a partition name to '%3A' in
// the on-disk profile folder, and a Windows profile folder whose name contains
// '%3A' gets a cookie store the network stack can neither read nor write (a
// jar seeded into it reads back zero cookies, `cookies.set()` never reaches
// disk, and every cookie-authenticated request 401s as `no_cookie`). A jar
// the app cannot see means the dial always looks signed-out and the user is
// asked to sign in again on every connect. The whole path component (prefix
// AND the sanitized id below) must stay within the characters Electron never
// percent-escapes — the invariant test pins "no ':' and no '%'".
//
// The name used to be `${LEGACY_OAUTH_PARTITION}:conn:<id>` (#92183). That
// jar never worked on Windows; on macOS/Linux a non-primary remote signed in
// under the old name is re-prompted ONCE after this change (the old
// `Partitions/hermes-remote-oauth%3Aconn%3A<id>` folder is left on disk,
// inert). One name on every platform beats a per-OS partition scheme.
const CONNECTION_PARTITION_PREFIX = `${LEGACY_OAUTH_PARTITION}-conn-`
export interface PartitionRegistrySnapshot {
primary?: unknown
@@ -43,19 +43,22 @@ test('no report file → no notice', () => {
assert.equal(pendingNotice(tmp(), tmp()), null)
})
test('report → one notice naming plugins, date and the CLI command', () => {
test('report → one notice naming the plugins and the date, with no config keys or CLI commands', () => {
const home = tmp()
fs.writeFileSync(path.join(home, REPORT_FILE), JSON.stringify(REPORT))
const n = pendingNotice(home, tmp())
assert.ok(n)
assert.equal(n.title, 'Plugins need an update')
assert.match(n.message, /2 plugins import module paths that stop working on 2026-09-14/)
assert.equal(n.title, 'Some plugins need an update')
assert.match(n.message, /stop working on 2026-09-14: alpha, beta\./)
assert.match(
n.detail,
/• alpha — 1 import \(e\.g\. tools\.web_tools\.prefers_gateway → tools\.tool_backend_helpers\.prefers_gateway\)/
)
assert.match(n.detail, /• beta — 2 imports/)
assert.match(n.detail, /hermes plugins compat/)
for (const text of [n.title, n.message, n.detail]) {
assert.doesNotMatch(text, /config\.yaml|hermes plugins compat|allow_deprecated_imports|module path/)
}
})
test('dismissal is remembered for the same report and forgotten for a different one', () => {
@@ -84,8 +87,9 @@ test('dismissal is remembered for the same report and forgotten for a different
fs.writeFileSync(path.join(home, REPORT_FILE), JSON.stringify(disabled))
const third = pendingNotice(home, userData)
assert.ok(third)
assert.equal(third.title, 'Some plugins were not loaded')
assert.match(third.detail, /allow_deprecated_imports/)
assert.equal(third.title, 'Some plugins were turned off')
assert.match(third.message, /were turned off: alpha, beta\. Hermes works normally without them\./)
assert.doesNotMatch(third.detail, /config\.yaml|hermes plugins compat|allow_deprecated_imports/)
assert.notEqual(reportKey(disabled as any), reportKey(REPORT as any))
})
+15 -10
View File
@@ -114,6 +114,7 @@ export function pendingNotice(hermesHome: string, userData: string): PendingNoti
}
const names = Object.keys(report.plugins).sort()
const nameList = names.join(', ')
const list = names
.map(n => {
@@ -124,15 +125,19 @@ export function pendingNotice(hermesHome: string, userData: string): PendingNoti
})
.join('\n')
const title = report.in_effect ? 'Some plugins were not loaded' : 'Plugins need an update'
// User copy: what happened, what it means for them, what to do. Config keys
// and CLI commands stay out; the per-plugin list is for the plugin author.
const copy = report.in_effect
? {
title: 'Some plugins were turned off',
message: `These plugins were built for an older Hermes and were turned off: ${nameList}. Hermes works normally without them.`,
detail: `Look for an updated version of each plugin or ask its author.\n\n${list}`
}
: {
title: 'Some plugins need an update',
message: `These plugins were built for an older Hermes and will stop working on ${report.removal_date}: ${nameList}.`,
detail: `Look for an updated version or ask the plugin's author before then.\n\n${list}`
}
const message = report.in_effect
? `${names.length} plugin${names.length === 1 ? '' : 's'} import${names.length === 1 ? 's' : ''} module paths that were removed on ${report.removal_date} and ${names.length === 1 ? 'was' : 'were'} not loaded.`
: `${names.length} plugin${names.length === 1 ? '' : 's'} import${names.length === 1 ? 's' : ''} module paths that stop working on ${report.removal_date}.`
const detail = report.in_effect
? `${list}\n\nUpdate the plugin(s), or force-load them with plugins.allow_deprecated_imports: true in config.yaml (they will still break once the compatibility layer is removed).\n\nFull list: hermes plugins compat`
: `${list}\n\nCheck for plugin updates or notify the author before ${report.removal_date}. After that date these plugins are not loaded.\n\nFull list: hermes plugins compat`
return { key, title, message, detail }
return { key, ...copy }
}
@@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url'
import { test } from 'vitest'
import {
BackgroundSlotRetryBackoff,
LocalBackendSlotWaitTimeoutError,
LocalBackendSpawnCoordinator,
releaseLocalBackendSlotAfterExit
@@ -24,6 +25,20 @@ const deferred = () => {
const flush = () => new Promise<void>(resolve => setImmediate(resolve))
test('background slot failures back off per profile and clear after a later success', () => {
const retries = new BackgroundSlotRetryBackoff({ baseDelayMs: 1_000, maxDelayMs: 8_000 })
assert.equal(retries.canAttempt('over-cap', 0), true)
assert.equal(retries.recordFailure('over-cap', 0), 1_000)
assert.equal(retries.canAttempt('over-cap', 999), false)
assert.equal(retries.canAttempt('over-cap', 1_000), true)
assert.equal(retries.recordFailure('over-cap', 1_000), 2_000)
assert.equal(retries.canAttempt('other-profile', 1_001), true)
retries.clear('over-cap')
assert.equal(retries.canAttempt('over-cap', 1_001), true)
})
test('100 concurrent local requests never hold more than the configured slots', async () => {
const limit = 12
const coordinator = new LocalBackendSpawnCoordinator(limit)
@@ -42,6 +42,58 @@ export function isBackgroundSlotWaitTimeout(error: unknown): boolean {
return error instanceof LocalBackendSlotWaitTimeoutError && error.silent
}
/** A retry deferment is expected background control flow, not a start failure. */
export class BackgroundSlotRetryDeferredError extends Error {
constructor(key: string) {
super(`Local backend start for "${key}" is backing off after slot saturation.`)
this.name = 'BackgroundSlotRetryDeferredError'
}
}
export function isBackgroundSlotRetryDeferred(error: unknown): boolean {
return error instanceof BackgroundSlotRetryDeferredError
}
/**
* Per-profile cooldown for background hydration after a pool-slot timeout.
*
* A full local pool is commonly structural (more profiles than the configured
* cap), so retrying every roster refresh creates a permanent timeout and log
* storm. Foreground opens bypass this guard; a successful slot acquisition
* clears it. Keeping this state outside the coordinator preserves its role as
* a fair slot allocator rather than teaching it about hydration policy.
*/
export class BackgroundSlotRetryBackoff {
#failures = new Map<string, { nextRetryAt: number; attempts: number }>()
readonly #baseDelayMs: number
readonly #maxDelayMs: number
constructor({ baseDelayMs = 60_000, maxDelayMs = 15 * 60_000 } = {}) {
if (!Number.isFinite(baseDelayMs) || baseDelayMs < 1 || !Number.isFinite(maxDelayMs) || maxDelayMs < baseDelayMs) {
throw new RangeError('Background slot retry delays must be positive and ordered.')
}
this.#baseDelayMs = baseDelayMs
this.#maxDelayMs = maxDelayMs
}
canAttempt(key: string, now = Date.now()): boolean {
return (this.#failures.get(key)?.nextRetryAt ?? 0) <= now
}
recordFailure(key: string, now = Date.now()): number {
const attempts = (this.#failures.get(key)?.attempts ?? 0) + 1
const delay = Math.min(this.#baseDelayMs * 2 ** (attempts - 1), this.#maxDelayMs)
this.#failures.set(key, { attempts, nextRetryAt: now + delay })
return delay
}
clear(key: string): void {
this.#failures.delete(key)
}
}
export async function releaseLocalBackendSlotAfterExit(
release: ReleaseLocalBackendSlot,
waitForExit: () => Promise<void>
+51
View File
@@ -146,6 +146,57 @@ test('stopAll joins a stop whose pool entry was already evicted', async () => {
assert.equal(stopper.hasPending(), false)
})
test('afterStop holds inFlight until extra teardown finishes (process-less SSH)', async () => {
const pool = new Map<string, PoolStopEntry>()
const events: string[] = []
let releaseAfter: (() => void) | undefined
const afterGate = new Promise<void>(resolve => {
releaseAfter = resolve
})
const stopper = createPoolStopper({
pool,
stopChild: () => {
events.push('stop')
},
waitForExit: async () => {
events.push('exit')
},
afterStop: async () => {
events.push('after-start')
await afterGate
events.push('after-done')
}
})
pool.set('ssh', { process: null })
const stop = stopper.stop('ssh')
await Promise.resolve()
await Promise.resolve()
assert.equal(stopper.inFlight('ssh'), stop)
assert.deepEqual(events, ['stop', 'exit', 'after-start'])
let spawned = false
const respawn = (async () => {
const dying = stopper.inFlight('ssh')
if (dying) {
await dying
}
spawned = true
})()
await Promise.resolve()
assert.equal(spawned, false, 'reconnect must wait for SSH teardown, not just child exit')
releaseAfter?.()
await stop
await respawn
assert.equal(spawned, true)
assert.deepEqual(events, ['stop', 'exit', 'after-start', 'after-done'])
assert.equal(stopper.inFlight('ssh'), undefined)
})
test('a respawn can await the in-flight stop before reusing the key', async () => {
const { addChild, exitResolvers, stopper } = harness()
const child = addChild('selena')
+8
View File
@@ -33,6 +33,11 @@ export interface PoolStopperDeps {
stopChild: (child: unknown) => void
/** Bounded wait: resolves when the child exits, escalating to SIGKILL. */
waitForExit: (child: unknown) => Promise<void>
/**
* Extra per-key work that must finish before a replacement may spawn.
* Held on the same in-flight promise as child exit (SSH teardown, etc.).
*/
afterStop?: (key: string) => Promise<void>
}
export interface PoolStopper {
@@ -69,6 +74,9 @@ export function createPoolStopper(deps: PoolStopperDeps): PoolStopper {
const stopping = (async () => {
deps.stopChild(entry.process)
await deps.waitForExit(entry.process)
if (deps.afterStop) {
await deps.afterStop(key)
}
})().finally(() => {
stops.delete(key)
})
@@ -11,6 +11,7 @@ test('quit cancels first-run startup without entering the installer', async () =
let installed = false
const startup = runPrimaryBackendStartup({
assertCurrentAttempt: () => {},
signal: controller.signal,
resolveRemote: async () => null,
connectRemote: async () => ({}),
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
import { test, vi } from 'vitest'
import { createBackendConnectionState } from './backend-connection-state'
import { createFirstRunSetupGate } from './first-run-setup-gate'
import {
createPrimaryRemoteConnection,
@@ -17,6 +18,7 @@ const bootstrapBackend = {
function startupOptions(overrides: Record<string, unknown> = {}) {
return {
assertCurrentAttempt: () => {},
connectRemote: vi.fn(async remote => ({ baseUrl: remote.baseUrl, mode: 'remote' as const })),
ensureLocalRuntime: vi.fn(async backend => ({ ...backend, command: 'hermes' })),
prepareLocalBackend: vi.fn(async () => bootstrapBackend),
@@ -48,6 +50,30 @@ test('primary remote descriptor preserves a resolved registry connection id', ()
assert.equal(connection.isFullscreen, false)
})
test('primary remote descriptor preserves the gateway extra headers for REST calls', () => {
// Chat and the Test button carry the headers via the exact-URL WS store, but
// fetchJsonForBackend reads descriptor.headers — dropping them here made every
// Settings/session-history call hit an access proxy unauthenticated (#112072).
const headers = { 'CF-Access-Client-Id': 'client-id', 'CF-Access-Client-Secret': 'client-secret' }
const connection = createPrimaryRemoteConnection(
{
authMode: 'token',
baseUrl: 'https://gateway.example.com',
connectionId: 'gateway',
headers,
remoteKind: 'url',
source: 'settings',
token: 'secret',
wsUrl: 'wss://gateway.example.com/api/ws?token=secret'
},
[],
{}
)
assert.deepEqual(connection.headers, headers)
})
test('primary remote descriptor preserves the effective SSH dialing identity', () => {
const ssh = {
effectiveConfigFingerprint: 'effective-config',
@@ -161,6 +187,36 @@ test('continue local waits for update exclusion and ensures the prepared runtime
assert.deepEqual(options.resolveRemote.mock.calls, [[]])
})
test('invalidating a pending runtime discovery prevents setup and bootstrap', async () => {
const state = createBackendConnectionState()
const attempt = state.startAttempt()
let finish!: (backend: typeof bootstrapBackend) => void
let started!: () => void
const resolving = new Promise<void>(resolve => {
started = resolve
})
const options = startupOptions({
assertCurrentAttempt: () => state.assertCurrentAttempt(attempt),
prepareLocalBackend: async () => {
started()
return new Promise<typeof bootstrapBackend>(resolve => {
finish = resolve
})
}
})
const pending = runPrimaryBackendStartup(options)
await resolving
state.invalidate()
finish(bootstrapBackend)
await assert.rejects(pending, /superseded/)
assert.equal(options.waitForDecision.mock.calls.length, 0)
assert.equal(options.ensureLocalRuntime.mock.calls.length, 0)
})
test('reset rejects with a typed error and never enters either backend', async () => {
const gate = createFirstRunSetupGate({ stuckAfterMs: 0 })
const options = startupOptions({ waitForDecision: gate.wait })
@@ -2,6 +2,7 @@ import { runBackendStartStep } from './backend-start-cancellation'
import type { FirstRunSetupDecision } from './first-run-setup-gate'
export interface PrimaryBackendStartupOptions<Backend, RuntimeBackend, Remote, Connection> {
assertCurrentAttempt: () => void
signal?: AbortSignal
connectRemote: (remote: Remote) => Promise<Connection>
ensureLocalRuntime: (backend: Backend) => Promise<RuntimeBackend>
@@ -18,6 +19,7 @@ interface ResolvedPrimaryRemote {
authMode?: 'oauth' | 'token'
baseUrl: string
connectionId?: string
headers?: Record<string, string>
remoteHermesVersion?: string
remoteHost?: string
remoteKind?: 'cloud' | 'ssh' | 'url'
@@ -55,6 +57,9 @@ export function createPrimaryRemoteConnection<State extends object>(
remoteHermesVersion: remote.remoteHermesVersion,
...(remote.connectionId ? { connectionId: remote.connectionId } : {}),
...(remote.ssh ? { ssh: remote.ssh } : {}),
// fetchJsonForBackend reads descriptor.headers for every REST call; the
// WebSocket header store is keyed by exact URL and cannot stand in for it.
headers: remote.headers,
token: remote.token,
wsUrl: remote.wsUrl,
logs,
@@ -77,6 +82,7 @@ export class FirstRunSetupResetError extends Error {
// and local backend resolution happen before the setup gate, and a remote Apply
// re-resolves persisted config without ever entering ensureRuntime/bootstrap.
export async function runPrimaryBackendStartup<Backend, RuntimeBackend, Remote, Connection>({
assertCurrentAttempt,
connectRemote,
ensureLocalRuntime,
prepareLocalBackend,
@@ -87,7 +93,13 @@ export async function runPrimaryBackendStartup<Backend, RuntimeBackend, Remote,
}: PrimaryBackendStartupOptions<Backend, RuntimeBackend, Remote, Connection>): Promise<
PrimaryBackendStartupResult<RuntimeBackend, Connection>
> {
const step = <T>(run: () => T | Promise<T>) => runBackendStartStep(signal, run)
const step = async <T>(run: () => T | Promise<T>) => {
const result = await runBackendStartStep(signal, run)
assertCurrentAttempt()
return result
}
const savedRemote = await step(resolveRemote)
if (savedRemote) {
+142 -1
View File
@@ -26,6 +26,7 @@ import {
openForward,
ownershipDirectory,
pidIsOurDashboard,
probeHermesVersion,
probeRemotePlatform,
PROTOCOL_VERSION,
readLockfile,
@@ -476,7 +477,9 @@ test('connect() fails closed on lockfile schema/ownership skew: skips reap, touc
`${label}: connect must refuse with remote-lockfile-skew`
)
assert.ok(
!ssh.calls.some(c => /(^|[^-\d])kill -?9? ?\d/.test(c) && !/kill -0/.test(c)),
// Any signal, a literal pid: the probe watchdog's `kill -9 $__htp`
// targets its own child, not a lockfile pid.
!ssh.calls.some(c => /(^|[^-\d])kill(?: -\w+)? \d/.test(c) && !/kill -0/.test(c)),
`${label}: must not kill any pid`
)
assert.ok(!ssh.calls.some(c => /rm -f/.test(c)), `${label}: must not remove any remote file`)
@@ -1810,6 +1813,69 @@ test('remote SSH ownership capability requires both secure bootstrap flags', asy
assert.equal(await remoteSupportsSshOwnership(unsupported, '/x/hermes'), false)
})
test.skipIf(process.platform === 'win32')('capability probe survives a zsh login shell on the remote (#111949)', async t => {
// sshd runs the remote command under the account's LOGIN shell. A bare
// `set -m` is fatal in a non-interactive zsh, so the watchdog-wrapped probe
// used to return nothing and a current remote was reported as unsupported.
const zsh = await exec('command -v zsh || true').then(r => r.stdout.trim())
// CI installs zsh (js-tests.yml); locally a missing zsh must show as a
// skip, not a pass, or a wrapper regression stays green unnoticed.
if (!zsh) {
t.skip('zsh not installed')
return
}
const dir = await mkdtemp(path.join(os.tmpdir(), 'hermes-zsh-probe-'))
try {
const hermes = path.join(dir, 'hermes')
await writeFile(hermes, '#!/bin/sh\necho "--ssh-session-token-file --ssh-owner-nonce"\n', { mode: 0o700 })
const ssh = { exec: async (command: string) => (await exec(command, { shell: zsh })).stdout }
assert.equal(await remoteSupportsSshOwnership(ssh, hermes), true)
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('probes run under the remote watchdog so a hung CLI cannot orphan (#110478)', async () => {
let versionProbe = ''
const versionSsh = fakeSsh([
[
/--version/,
(cmd: string) => {
versionProbe = cmd
return 'Hermes Agent v0.18.2 (abc123)\n'
}
]
])
assert.equal(await probeHermesVersion(versionSsh, '/x/hermes'), 'Hermes Agent v0.18.2 (abc123)')
assert.ok(versionProbe.includes('kill -9'), 'version probe wrapped in the remote watchdog')
let helpProbe = ''
const helpSsh = fakeSsh([
[
/serve --help/,
(cmd: string) => {
helpProbe = cmd
return 'YES\n'
}
]
])
assert.equal(await remoteSupportsSshOwnership(helpSsh, '/x/hermes'), true)
assert.ok(helpProbe.includes('kill -9'), 'ownership probe wrapped in the remote watchdog')
assert.ok(/\$\(.*\(.*serve --help.*\) <\/dev\/null &/.test(helpProbe), 'watchdog nested around the inner serve --help')
})
test('cleanupStale escalates to SIGKILL when the backend survives the graceful wait (#91668 quit-during-active-turn)', async () => {
// A serve mid-turn (in-flight LLM call, live MCP children) can ride out
// SIGTERM well past the 5s graceful wait. Before-quit races the whole
@@ -1921,3 +1987,78 @@ test.skipIf(process.platform === 'win32')(
}
}
)
// The liveness and ownership probes answer over the same SSH channel that is
// often mid-teardown right after the served token resolved. An exec that
// returns neither sentinel is indeterminate (#111810): read as DEAD it tore
// down a live backend; read as FOREIGN it skipped the reap while removing the
// lockfile — one orphaned `serve --isolated` per failed attempt.
test('connect() does not declare a live dashboard dead when the liveness probe answers nothing once', async () => {
let liveness = 0
const ssh = fakeSsh([
[/uname/, 'Linux\nx86_64'],
[/\[ -x/, 'OK'],
[/cat .*lock\.json/, ''],
[/grep -q ssh-session-token-file/, 'YES\n'],
[/python3 -c/, ''],
[/printf '%s\\n'/, ''],
[/setsid/, '777\n'],
[(cmd: string) => /kill -0 777/.test(cmd) && !cmd.includes('while'), () => (liveness++ === 0 ? '' : 'ALIVE\n')],
[/cat .*\.log/, 'HERMES_DASHBOARD_READY port=51999\n']
])
const result = await connect(connectDeps(ssh, { platform: { os: 'Linux', arch: 'x86_64' } }))
assert.equal(result.reused, false)
assert.equal(result.pid, 777)
assert.ok(!ssh.calls.some(c => /(^|[^-\d])kill(?: -\w+)? 777\b/.test(c) && !/kill -0/.test(c)), 'must not reap a live backend')
})
test('cleanupStale reaps after one lost ownership answer and keeps the lockfile when none ever settles', async () => {
let ownership = 0
const flaky = fakeSsh([
[/print\("OWNED"/, () => (ownership++ === 0 ? '' : 'OWNED\n')],
[/kill 777 &&/, 'TERMINATED\n']
])
await cleanupStale(flaky, OWNERSHIP_ID, ownedLock({ pid: 777 }))
assert.ok(flaky.calls.some(c => /kill 777 &&/.test(c)), 'must reap the owned backend')
assert.ok(flaky.calls.some(c => /rm -f .*backend\.lock\.json/.test(c)))
const silent = fakeSsh([[/print\("OWNED"/, '']])
await assert.rejects(
cleanupStale(silent, OWNERSHIP_ID, ownedLock({ pid: 777 })),
(error: any) => error.kind === 'transient-transport-error'
)
assert.ok(!silent.calls.some(c => /(^|[^-\d])kill(?: -\w+)? 777\b/.test(c) && !/kill -0/.test(c)), 'must not kill unproven')
assert.ok(!silent.calls.some(c => /rm -f .*backend\.lock\.json/.test(c)), 'record must survive for the next connect to reap')
})
test('connect() post-spawn cleanup that cannot prove ownership keeps the original boot error', async () => {
const boot: any = new Error('dashboard never answered')
boot.kind = 'boot-failed'
const ssh = fakeSsh([
[/uname/, 'Linux\nx86_64'],
[/\[ -x/, 'OK'],
[/cat .*lock\.json/, ''],
[/grep -q ssh-session-token-file/, 'YES\n'],
[/python3 -c/, ''],
[/printf '%s\\n'/, ''],
[/setsid/, '777\n'],
[/kill -0 777/, 'ALIVE\n'],
[/cat .*\.log/, 'HERMES_DASHBOARD_READY port=51999\n'],
[/print\("OWNED"/, '']
])
await assert.rejects(
connect(connectDeps(ssh, { platform: { os: 'Linux', arch: 'x86_64' }, waitForHermes: async () => { throw boot } })),
(error: any) => error === boot && error.cleanupCause?.kind === 'transient-transport-error'
)
assert.ok(!ssh.calls.some(c => /rm -f .*backend\.lock\.json/.test(c)), 'record must survive for the next connect to reap')
})
+76 -23
View File
@@ -29,7 +29,7 @@ import crypto from 'node:crypto'
import { READY_IN_MERGED_OUTPUT_RE } from './backend-ready'
import { parseRemoteProfileListing } from './connection-registry'
import { assertBootstrapNotSuperseded } from './ssh-connection'
import { assertBootstrapNotSuperseded, withRemoteTimeout } from './ssh-connection'
const LOCKFILE_SCHEMA_VERSION = 2
// Bumped when the desktop<->dashboard reuse contract changes in a way that makes
@@ -250,7 +250,9 @@ async function locateHermes(ssh, remoteHermesPath) {
// connection uses, so a stale/unexpected install is visible.
async function probeHermesVersion(ssh, hermesPath) {
try {
const out = (await ssh.exec(`${expandRemotePath(hermesPath)} --version 2>&1`)).trim()
// Watchdogged: a hung remote CLI must die remotely instead of orphaning
// when the local ssh child is SIGKILLed (#110478).
const out = (await ssh.exec(withRemoteTimeout(`${expandRemotePath(hermesPath)} --version 2>&1`))).trim()
return (out.split('\n')[0] || '').trim()
} catch {
@@ -514,21 +516,58 @@ async function removeLockfile(ssh, ownershipId) {
}
}
const PROBE_VERDICT_ATTEMPTS = 3
const PROBE_VERDICT_RETRY_MS = 500
// Liveness and ownership probes print exactly one of two sentinels. An exec
// that resolves with neither — the channel died before the remote shell ran,
// which is exactly the state of an SSH session mid-teardown right after the
// served token was resolved — is indeterminate, not the negative verdict:
// reading it as DEAD tore down a live backend as "exited while its served
// token was being resolved", and reading it as FOREIGN skipped the reap while
// still removing the lockfile, leaving one orphaned `serve --isolated` per
// attempt (#111810). Retry over a short window; with no definite answer fail
// closed with a transient error so callers keep the ownership record.
async function execProbeVerdict(ssh, command, sentinels, failureMessage) {
for (let attempt = 0; attempt < PROBE_VERDICT_ATTEMPTS; attempt++) {
if (attempt > 0) {
await new Promise(resolve => setTimeout(resolve, PROBE_VERDICT_RETRY_MS))
}
let out
try {
out = String((await ssh.exec(command)) || '').trim()
} catch (cause) {
const error: any = new Error(failureMessage)
error.kind = 'transient-transport-error'
error.cause = cause
throw error
}
if (sentinels.includes(out)) {
return out
}
}
const error: any = new Error(failureMessage)
error.kind = 'transient-transport-error'
throw error
}
async function remotePidAlive(ssh, pid) {
if (!pid || !Number.isInteger(Number(pid))) {
return false
}
try {
const out = (await ssh.exec(`kill -0 ${Number(pid)} 2>/dev/null && echo ALIVE || echo DEAD`)).trim()
const verdict = await execProbeVerdict(
ssh,
`kill -0 ${Number(pid)} 2>/dev/null && echo ALIVE || echo DEAD`,
['ALIVE', 'DEAD'],
'Could not verify the SSH backend process.'
)
return out === 'ALIVE'
} catch (cause) {
const error: any = new Error('Could not verify the SSH backend process.')
error.kind = 'transient-transport-error'
error.cause = cause
throw error
}
return verdict === 'ALIVE'
}
// Stable kernel process-start identity used to fence a later managed-update
@@ -585,8 +624,7 @@ async function pidIsOurDashboard(
return false
}
try {
const script =
const script =
'import os,shlex,subprocess,sys\n' +
`pid=${Number(pid)}\n` +
`expected=os.path.expanduser(${shq(hermesPath)})\n` +
@@ -633,15 +671,14 @@ async function pidIsOurDashboard(
'except (ValueError,IndexError):pass\n' +
'print("OWNED" if ok else "FOREIGN")'
const out = await ssh.exec(`python3 -c ${shq(script)}`)
const verdict = await execProbeVerdict(
ssh,
`python3 -c ${shq(script)}`,
['OWNED', 'FOREIGN'],
'Could not verify SSH backend process ownership.'
)
return String(out || '').trim() === 'OWNED'
} catch (cause) {
const error: any = new Error('Could not verify SSH backend process ownership.')
error.kind = 'transient-transport-error'
error.cause = cause
throw error
}
return verdict === 'OWNED'
}
// Kill the stale dashboard ONLY if provably ours, then drop the lockfile.
@@ -1123,8 +1160,11 @@ function buildSpawnCommand(hermesPath, profile, opts: any = {}) {
async function remoteSupportsSshOwnership(ssh, hermesPath) {
const hermes = expandRemotePath(hermesPath)
// The watchdog wraps the inner `serve --help` so the hung CLI is its direct
// child and dies remotely instead of orphaning (#110478). The `$( (` space
// is load-bearing: without it the shell parses `$((` as arithmetic expansion.
const out = await ssh.exec(
`help="$(${hermes} serve --help 2>&1)"; ` +
`help="$( ${withRemoteTimeout(`${hermes} serve --help 2>&1`)} )"; ` +
`printf '%s' "$help" | grep -q ssh-session-token-file && ` +
`printf '%s' "$help" | grep -q ssh-owner-nonce && echo YES || echo NO`
)
@@ -1655,7 +1695,20 @@ async function connect(deps) {
void 0
}
await cleanupStale(ssh, ownershipId, ownedSpawn)
// This record IS the child this attempt spawned. A liveness probe that
// cannot be settled must not become "leave it running": assume alive so
// cleanupStale re-runs the ownership proof, which keeps the record when
// nothing can be proven and lets the next connect reap by exact ownership.
const pidAlive = await remotePidAlive(ssh, pid).catch(() => true)
try {
await cleanupStale(ssh, ownershipId, ownedSpawn, pidAlive)
} catch (cleanupError) {
// An unsettled ownership proof must not replace the boot failure the
// user needs to see; keep it reachable for diagnostics instead.
error.cleanupCause = cleanupError
}
throw error
}
}
@@ -208,6 +208,40 @@ test('cancelAndWait force-cleans pending resources before awaiting rollback', as
assert.equal(cleaned, 1)
})
test('cancelAndWait keeps the drain up through afterCancel teardown', async () => {
const coordinator = createBootstrapCoordinator()
const events: string[] = []
let releaseAfter: (() => void) | undefined
const afterGate = new Promise<void>(resolve => {
releaseAfter = resolve
})
let teardownStarted: (() => void) | undefined
const started = new Promise<void>(resolve => {
teardownStarted = resolve
})
const drain = coordinator.cancelAndWait('scope', async () => {
events.push('teardown-start')
teardownStarted?.()
await afterGate
events.push('teardown-done')
})
await started
const next = coordinator.start('scope', 'new', async () => {
events.push('new-start')
return 'new'
})
await Promise.resolve()
assert.deepEqual(events, ['teardown-start'])
releaseAfter?.()
await drain
assert.equal(await next, 'new')
assert.deepEqual(events, ['teardown-start', 'teardown-done', 'new-start'])
})
test('a generation started during cancelAndWait cannot run before the drain completes', async () => {
const coordinator = createBootstrapCoordinator()
const oldGate = deferred()
@@ -236,3 +270,39 @@ test('a generation started during cancelAndWait cannot run before the drain comp
assert.equal(await next, 'new')
assert.deepEqual(events, ['old-start', 'new-start'])
})
test('a second cancelAndWait on the same scope composes with the teardown still in flight', async () => {
// Pool stop is blocked in SSH teardown; a connection apply cancels the same
// scope with no bootstrap left to drain. The apply's drain must not replace
// and clear the barrier, or start() runs before the first teardown finishes.
const coordinator = createBootstrapCoordinator()
const events: string[] = []
const teardownGate = deferred()
const teardownStarted = deferred()
const poolStop = coordinator.cancelAndWait('scope', async () => {
events.push('teardown-start')
teardownStarted.resolve()
await teardownGate.promise
events.push('teardown-done')
})
await teardownStarted.promise
const apply = coordinator.cancelAndWait('scope').then(() => events.push('apply-drained'))
const next = coordinator.start('scope', 'new', async () => {
events.push('new-start')
return 'new'
})
// A macrotask, not a microtask tick: nothing may run before the first teardown finishes.
await new Promise(resolve => setTimeout(resolve, 0))
assert.deepEqual(events, ['teardown-start'])
teardownGate.resolve()
await Promise.all([poolStop, apply])
assert.equal(await next, 'new')
// Both the apply and the new bootstrap wake on the same drained barrier; only
// their position after teardown-done is the contract.
assert.deepEqual(events.slice(0, 2), ['teardown-start', 'teardown-done'])
assert.deepEqual(events.slice(2).sort(), ['apply-drained', 'new-start'])
})
@@ -93,14 +93,26 @@ function createBootstrapCoordinator() {
pending.get(scope)?.controller.abort()
}
async function cancelAndWait(scope) {
async function cancelAndWait(scope, afterCancel?: () => Promise<void>) {
let release
const barrier = new Promise<void>(resolve => {
const own = new Promise<void>(resolve => {
release = resolve
})
// Compose with any drain already in flight for this scope (a pool stop
// still tearing down SSH while a connection apply cancels the same scope):
// start() must wait for every active teardown, and the map entry is
// cleared only once the composed barrier settles.
const prior = drains.get(scope)
// Drain barriers never reject, so chaining is equivalent to allSettled.
const barrier = prior ? prior.then(() => own) : own
drains.set(scope, barrier)
void barrier.finally(() => {
if (drains.get(scope) === barrier) {
drains.delete(scope)
}
})
const entries = [...active].filter(entry => entry.scope === scope)
for (const entry of entries) {
@@ -114,13 +126,18 @@ function createBootstrapCoordinator() {
// drain barrier still prevents stale resurrection.
await Promise.allSettled(entries.flatMap(entry => [...entry.forceCleanups]).map(cleanup => cleanup()))
await Promise.allSettled(entries.map(entry => entry.promise))
} finally {
if (drains.get(scope) === barrier) {
drains.delete(scope)
// Keep the drain up through caller teardown (SSH keepalive / tunnel)
// so a replacement start() cannot publish before the old scope is gone.
if (afterCancel) {
await afterCancel()
}
} finally {
release()
}
// "Cancel and wait" means the scope is drained: callers tear down SSH right
// after this returns, so wait for the composed barrier, not just our own.
await barrier
}
function cancelAll() {
+97 -1
View File
@@ -1,8 +1,10 @@
import assert from 'node:assert/strict'
import { execFile } from 'node:child_process'
import { EventEmitter } from 'node:events'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { promisify } from 'node:util'
import { test } from 'vitest'
@@ -18,15 +20,19 @@ import {
forwardSpec,
hostArgs,
redactSecrets,
REMOTE_PROBE_TIMEOUT_SECS,
runSsh,
SSH_ERROR,
SshConnection,
sshErrorMessage,
stopTunnelChild,
target,
validateSshTarget
validateSshTarget,
withRemoteTimeout
} from './ssh-connection'
const execFileAsync = promisify(execFile)
test('redactSecrets scrubs the spawn-time session token env var', () => {
const line = 'setsid env HERMES_DASHBOARD_SESSION_TOKEN=abc123deadbeef HERMES_DESKTOP=1 hermes dashboard'
const out = redactSecrets(line)
@@ -1067,3 +1073,93 @@ test('stopTunnelChild waits for process exit', async () => {
await stopping
assert.equal(stopped, true)
})
test.skipIf(process.platform === 'win32')('withRemoteTimeout runs a healthy probe under a zsh login shell (#111949)', async t => {
// SSH runs the remote command through the account's login shell. In
// non-interactive zsh, a bare `set -m` is fatal, so the wrapper must still
// run a healthy probe rather than reporting the remote as unsupported.
const zsh = await execFileAsync('sh', ['-c', 'command -v zsh || true']).then(r => r.stdout.trim())
// CI installs zsh (js-tests.yml); locally a missing zsh must show as a
// skip, not a pass, or a wrapper regression stays green unnoticed.
if (!zsh) {
t.skip('zsh not installed')
return
}
const { stdout: zshStdout } = await execFileAsync(zsh, ['-fc', withRemoteTimeout('echo zsh-ok', 5)])
assert.equal(zshStdout, 'zsh-ok\n')
})
test('withRemoteTimeout kills a hung probe remotely instead of orphaning it (#110478)', async () => {
if (process.platform === 'win32') {
return
}
// Shape: POSIX watchdog — macOS remotes have no GNU `timeout`.
const wrapped = withRemoteTimeout('hermes --version 2>&1', 15)
assert.ok(wrapped.includes('sleep 15'), 'watchdog duration honored')
assert.ok(wrapped.includes('kill -9'), 'watchdog kills the hung child remotely')
assert.ok(!/(^|[ ;(])timeout[ ;]/.test(wrapped), 'no GNU timeout dependency')
assert.ok(wrapped.endsWith('exit $__htrc'), 'inner exit code propagated')
assert.ok(
withRemoteTimeout('true').includes(`sleep ${REMOTE_PROBE_TIMEOUT_SECS}`),
'defaults to REMOTE_PROBE_TIMEOUT_SECS'
)
assert.ok(REMOTE_PROBE_TIMEOUT_SECS * 1000 < 20_000, 'remote watchdog fires before the local exec timeout')
// Behavior through a real POSIX shell: healthy output passes through …
const healthyStart = Date.now()
const { stdout } = await execFileAsync('sh', ['-c', withRemoteTimeout('echo hello', 5)])
const healthyElapsed = Date.now() - healthyStart
assert.equal(stdout, 'hello\n')
// … and returns promptly: the watchdog's orphaned `sleep` must not hold the
// session pipes open until the full timeout on the healthy path.
assert.ok(healthyElapsed < 4000, `healthy probe returned fast (took ${healthyElapsed}ms)`)
// … a hung command is killed promptly with a non-zero exit … The duration
// is unique to this run so the orphan sweep below cannot match an unrelated
// `sleep` on a busy host.
const hungSecs = 30_000 + (process.pid % 10_000)
const start = Date.now()
const err: any = await execFileAsync('sh', ['-c', withRemoteTimeout(`sleep ${hungSecs}`, 1)]).then(
() => null,
e => e
)
const elapsed = Date.now() - start
assert.ok(err && err.code !== 0, 'hung command must exit non-zero')
assert.ok(elapsed < 15000, `watchdog fired promptly instead of waiting ${hungSecs}s (took ${elapsed}ms)`)
// … and no orphan is left behind.
const { stdout: strays } = await execFileAsync('sh', ['-c', `ps -eo args | grep "[s]leep ${hungSecs}$" || true`])
assert.equal(strays.trim(), '', 'killed probe left no orphan process')
// … including the grandchild of a launcher that runs the CLI without exec
// (the broken-launcher class of #110478). Needs a shell with job control
// off a tty; bash has it, dash does not.
const bash = await execFileAsync('sh', ['-c', 'command -v bash || true']).then(r => r.stdout.trim())
if (bash) {
const grandSecs = hungSecs + 1
const launcher = `sh -c 'sleep ${grandSecs}; echo done'`
const err2: any = await execFileAsync(bash, ['-c', withRemoteTimeout(launcher, 1)]).then(
() => null,
e => e
)
assert.ok(err2 && err2.code !== 0, 'hung launcher must exit non-zero')
const { stdout: grandStrays } = await execFileAsync('sh', ['-c', `ps -eo args | grep "[s]leep ${grandSecs}$" || true`])
assert.equal(grandStrays.trim(), '', 'watchdog killed the launcher’s grandchild too')
}
})
+44 -1
View File
@@ -39,6 +39,13 @@ import path from 'node:path'
const DEFAULT_CONNECT_TIMEOUT_MS = 15_000
const DEFAULT_EXEC_TIMEOUT_MS = 20_000
const DEFAULT_FORWARD_TIMEOUT_MS = 15_000
// Remote-side watchdog for probe commands, in seconds. runSsh SIGKILLs the
// LOCAL ssh child on timeout, but the remote command keeps running as an
// orphan (ppid=1) — a hung remote CLI (e.g. a wedged `hermes --version`)
// accumulates orphans that busy-loop (#110478). Kept under
// DEFAULT_EXEC_TIMEOUT_MS so the remote kill lands before the local timeout.
const REMOTE_PROBE_TIMEOUT_SECS = 15
// No-mux tunnels are one `ssh -N -L` child each; a transient child death
// (network blip, sshd restart, laptop resume) used to instantly poison
// isAlive() and cascade upstream into a full teardown that SIGTERM'd a
@@ -321,6 +328,40 @@ function buildInteractiveSshArgs(conn, remoteCwd, connectTimeoutMs?, remoteComma
return args
}
// Wrap a remote probe command in a POSIX watchdog so a hung remote CLI is
// killed REMOTELY after `timeoutSecs` instead of orphaning when the local ssh
// child is SIGKILLed (#110478). Pure POSIX sh (dash, macOS sh) — deliberately
// not GNU `timeout`, which macOS remotes do not ship.
//
// The wrapped command must be a SINGLE command: the watchdog kills its direct
// child, so the exact invocation that can hang must be the direct child —
// a hung grandchild of a compound wrapper would orphan anyway. (The ownership
// probe nests the watchdog around the inner `serve --help` inside its
// `$( ... )` for this reason; note the load-bearing space in `$( (`.)
// The wrapped command keeps its stdout; the shell exits non-zero when the
// watchdog fires and the probe's existing failure path handles it.
//
// The sleeper's stdio is detached (</dev/null >/dev/null 2>&1): killing the
// sleeper subshell orphans its `sleep` grandchild, and an orphan holding the
// session pipes would keep the ssh channel open until the full timeout even on
// the healthy path. Detached, the orphan is a benign self-reaping `sleep`.
function withRemoteTimeout(remoteCommand, timeoutSecs = REMOTE_PROBE_TIMEOUT_SECS) {
const secs = Number.isFinite(timeoutSecs) && timeoutSecs > 0 ? Math.floor(timeoutSecs) : REMOTE_PROBE_TIMEOUT_SECS
// Job control (`set -m`) puts the probe in its own process group so the
// watchdog can also reach a grandchild left behind by a launcher that runs
// the CLI without exec. Non-interactive zsh exits when asked to enable
// monitor mode, so skip that setup there and fall back to killing the direct
// child. Other shells retain the process-group cleanup where supported.
return (
`[ -n "\${ZSH_VERSION-}" ] || set -m 2>/dev/null; (${remoteCommand}) </dev/null & __htp=$!; set +m 2>/dev/null; ` +
`(sleep ${secs} </dev/null >/dev/null 2>&1; kill -9 -- -$__htp 2>/dev/null; kill -9 $__htp 2>/dev/null) & __htw=$!; ` +
`wait $__htp; __htrc=$?; ` +
`kill $__htw 2>/dev/null; wait $__htw 2>/dev/null; ` +
`exit $__htrc`
)
}
// Bind the local end to 127.0.0.1 ONLY — never 0.0.0.0 — so the tunnel does not
// re-expose the remote dashboard to the client's LAN.
function forwardSpec(localPort, remotePort, remoteHost = '127.0.0.1') {
@@ -1137,6 +1178,7 @@ export {
hostArgs,
pickLocalPort,
redactSecrets,
REMOTE_PROBE_TIMEOUT_SECS,
runSsh,
SSH_ERROR,
SshConnection,
@@ -1144,5 +1186,6 @@ export {
stopTunnelChild,
target,
validateKeyPath,
validateSshTarget
validateSshTarget,
withRemoteTimeout
}
@@ -0,0 +1,118 @@
/**
* #106935: Desktop main must hold a long-lived keep-alive WebSocket for every
* published SSH-isolated backend. Idle-exit (#101626) treats accepted WS as
* ownership liveness; renderer sockets can drop while sshConnections still owns
* the scope. Sticky artifacts (nonce / token file / lockfile) are NOT liveness.
*
* The pool-stop teardown fence that closes this socket is asserted in
* pool-stop.test.ts (afterStop) — AGENTS.md forbids reading `.ts` source from tests.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
function makeFakeWs(): { FakeWs: new (url: string) => any; instances: any[] } {
const instances: any[] = []
class FakeWs {
url: string
closed = false
listeners: Record<string, Array<(event?: any) => void>> = {}
constructor(url: string) {
this.url = url
instances.push(this)
}
addEventListener(type: string, fn: (event?: any) => void) {
;(this.listeners[type] ||= []).push(fn)
}
close() {
this.closed = true
}
emit(type: string, event?: any) {
for (const fn of this.listeners[type] || []) {
fn(event)
}
}
}
return { FakeWs, instances }
}
describe('ssh-isolated keep-alive registry (#106935)', () => {
afterEach(() => {
vi.useRealTimers()
})
it('holds an open WebSocket per scope until that scope stops, then never reconnects it', async () => {
const { createSshIsolatedKeepaliveRegistry } = await import('./ssh-isolated-keepalive')
const { FakeWs, instances } = makeFakeWs()
const registry = createSshIsolatedKeepaliveRegistry({
WebSocketImpl: FakeWs,
reconnectDelayMs: 25
})
registry.start('conn:office::work', {
baseUrl: 'http://127.0.0.1:53101',
token: 'sess-work'
})
registry.start('conn:office::less', { baseUrl: 'http://127.0.0.1:53102', token: 'sess-less' })
expect(instances).toHaveLength(2)
expect(instances[0].url).toBe('ws://127.0.0.1:53101/api/ws?token=sess-work')
expect(registry.isArmed('conn:office::work')).toBe(true)
instances[0].emit('open')
expect(registry.openUrl('conn:office::work')).toBe('ws://127.0.0.1:53101/api/ws?token=sess-work')
// A dropped socket is redialled with backoff (25 → 50 ms), so a dead tunnel is not
// hammered every interval until the scope is torn down.
vi.useFakeTimers()
instances[1].emit('close', { code: 1006 })
await vi.advanceTimersByTimeAsync(25)
expect(instances).toHaveLength(3)
instances[2].emit('close', { code: 1006 })
await vi.advanceTimersByTimeAsync(25)
expect(instances).toHaveLength(3)
await vi.advanceTimersByTimeAsync(25)
expect(instances).toHaveLength(4)
vi.useRealTimers()
registry.stop('conn:office::work')
expect(instances[0].closed).toBe(true)
expect(registry.isArmed('conn:office::work')).toBe(false)
// Tearing down one sibling must not drop the other owned scope.
expect(instances[3].closed).toBe(false)
expect(registry.isArmed('conn:office::less')).toBe(true)
instances[0].emit('close', { code: 1006 })
await new Promise(resolve => setTimeout(resolve, 50))
expect(instances).toHaveLength(4)
expect(registry.openUrl('conn:office::work')).toBeNull()
})
it('treats empty-string scope as the v1/global SSH primary but still requires baseUrl and token', async () => {
const { createSshIsolatedKeepaliveRegistry } = await import('./ssh-isolated-keepalive')
const { FakeWs, instances } = makeFakeWs()
const registry = createSshIsolatedKeepaliveRegistry({ WebSocketImpl: FakeWs })
registry.start('', { baseUrl: '', token: 'tok' })
registry.start('', { baseUrl: 'http://127.0.0.1:9', token: '' })
expect(instances).toHaveLength(0)
expect(registry.isArmed('')).toBe(false)
registry.start('', { baseUrl: 'http://127.0.0.1:53100', token: 'primary' })
expect(instances).toHaveLength(1)
expect(instances[0].url).toBe('ws://127.0.0.1:53100/api/ws?token=primary')
expect(registry.isArmed('')).toBe(true)
instances[0].emit('open')
expect(registry.openUrl('')).toBe('ws://127.0.0.1:53100/api/ws?token=primary')
registry.stop('')
expect(instances[0].closed).toBe(true)
expect(registry.isArmed('')).toBe(false)
})
})
@@ -0,0 +1,192 @@
/**
* Long-lived keep-alive WebSocket from Electron main for every published
* SSH-isolated backend (#106935).
*
* `web_server_idle_exit` (#101626) treats accepted WebSockets as ownership
* liveness. Renderer sockets can vanish (`disposeSecondary` /
* `pruneSecondaryGateways`) while Desktop still owns the backend via
* `sshConnections`. Sticky spawn artifacts (owner-nonce, token file, lockfile)
* are NOT liveness and must not suppress idle-exit.
*
* If the socket drops the registry reconnects with capped exponential backoff;
* while it is down the backend may idle-exit as before. This module never consults
* nonce/lock/token files.
*/
import { buildGatewayWsUrl } from './connection-config'
export type SshIsolatedKeepaliveTarget = {
baseUrl: string
token: string
}
export type SshIsolatedKeepaliveOptions = {
WebSocketImpl?: any
buildWsUrl?: (baseUrl: string, token: string) => string
log?: (message: string) => void
reconnectDelayMs?: number
}
type KeepaliveEntry = {
failures: number
reconnectTimer: ReturnType<typeof setTimeout> | null
scope: string
socket: { close?: () => void; url?: string } | null
target: SshIsolatedKeepaliveTarget
}
const DEFAULT_RECONNECT_DELAY_MS = 2_000
const MAX_RECONNECT_DELAY_MS = 30_000
function addListener(socket: any, type: string, handler: (event?: any) => void) {
if (typeof socket?.addEventListener === 'function') {
socket.addEventListener(type, handler)
return
}
if (typeof socket?.on === 'function') {
socket.on(type, handler)
}
}
export function createSshIsolatedKeepaliveRegistry(options: SshIsolatedKeepaliveOptions = {}) {
const WebSocketImpl =
'WebSocketImpl' in options ? options.WebSocketImpl : (globalThis as { WebSocket?: unknown }).WebSocket
const buildWsUrl = options.buildWsUrl ?? buildGatewayWsUrl
const log = options.log
const reconnectDelayMs = options.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS
const entries = new Map<string, KeepaliveEntry>()
function clearTimer(entry: KeepaliveEntry) {
if (entry.reconnectTimer == null) {
return
}
clearTimeout(entry.reconnectTimer)
entry.reconnectTimer = null
}
function closeSocket(entry: KeepaliveEntry) {
const socket = entry.socket
entry.socket = null
if (!socket) {
return
}
try {
socket.close?.()
} catch {
// Best-effort teardown; a dead socket must not block scope cleanup.
}
}
function scheduleReconnect(entry: KeepaliveEntry) {
if (entries.get(entry.scope) !== entry || entry.reconnectTimer != null) {
return
}
// A dead tunnel would otherwise be redialled every 2 s until the scope is torn down.
const delay = Math.min(reconnectDelayMs * 2 ** entry.failures, MAX_RECONNECT_DELAY_MS)
entry.failures += 1
entry.reconnectTimer = setTimeout(() => {
entry.reconnectTimer = null
connect(entry)
}, delay)
}
function connect(entry: KeepaliveEntry) {
if (entries.get(entry.scope) !== entry) {
return
}
clearTimer(entry)
closeSocket(entry)
let socket: any
let url: string
try {
url = buildWsUrl(entry.target.baseUrl, entry.target.token)
socket = new WebSocketImpl(url)
} catch (error) {
log?.(`[ssh] keep-alive WS failed to open for ${entry.scope}: ${error instanceof Error ? error.message : error}`)
scheduleReconnect(entry)
return
}
entry.socket = socket
// Staleness is derivable: stop() removes the entry, connect() replaces entry.socket.
const abandonIfStale = () => {
if (entries.get(entry.scope) !== entry || entry.socket !== socket) {
return
}
entry.socket = null
scheduleReconnect(entry)
}
addListener(socket, 'open', () => {
if (entry.socket === socket) {
entry.failures = 0
}
})
addListener(socket, 'close', abandonIfStale)
addListener(socket, 'error', abandonIfStale)
}
function start(scope: string, target: SshIsolatedKeepaliveTarget) {
// '' is a real published key: sshScopeKey(null) for the v1/global SSH primary.
if (typeof scope !== 'string') {
return
}
const baseUrl = typeof target?.baseUrl === 'string' ? target.baseUrl : ''
const token = typeof target?.token === 'string' ? target.token : ''
if (!baseUrl || !token) {
return
}
stop(scope)
const entry: KeepaliveEntry = {
failures: 0,
reconnectTimer: null,
scope,
socket: null,
target: { baseUrl, token }
}
entries.set(scope, entry)
connect(entry)
}
function stop(scope: string) {
const entry = entries.get(scope)
if (!entry) {
return
}
entries.delete(scope)
clearTimer(entry)
closeSocket(entry)
}
function stopAll() {
for (const scope of [...entries.keys()]) {
stop(scope)
}
}
function isArmed(scope: string) {
return entries.has(scope)
}
function openUrl(scope: string) {
const url = entries.get(scope)?.socket?.url
return typeof url === 'string' ? url : null
}
return { isArmed, openUrl, start, stop, stopAll }
}
@@ -6,6 +6,7 @@ import { test } from 'vitest'
import {
collectRelaunchArgs,
describeUpdaterHandoffFailure,
MARKER_SELF_ADOPT_EPOCH_MS,
observeUpdaterHandoff,
resolvePosixScriptHandoff,
@@ -393,6 +394,20 @@ test('observeUpdaterHandoff reports a non-zero early exit', async () => {
assert.equal(outcome.code, 127)
})
test('describeUpdaterHandoffFailure leads with plain copy and confines the raw outcome to Details', () => {
for (const raw of ['updater exited 127 before the settle window elapsed', 'updater spawn failed: ENOENT']) {
const text = describeUpdaterHandoffFailure({ message: raw })
const [lead, details] = text.split('\n\nDetails: ')
assert.match(lead, /Hermes keeps running/)
assert.match(lead, /Try again/)
assert.doesNotMatch(lead, /exited|spawn|ENOENT|settle window|hermes update|\d/)
assert.equal(details, raw)
}
assert.doesNotMatch(describeUpdaterHandoffFailure({}), /Details:/)
})
test('observeUpdaterHandoff reports a signal death inside the window', async () => {
const child = new FakeChild()
const timer = manualTimer()
+14
View File
@@ -357,6 +357,20 @@ export interface ObserveUpdaterHandoffDeps {
clearTimeoutFn?: (timer: unknown) => void
}
/**
* User-facing copy for a hand-off that did not take (spawn error or early exit).
* The lead sentence is plain: nothing changed and Hermes keeps running. The raw
* outcome message (exit code / signal / spawn error) stays on a trailing
* "Details:" line for logs and support.
*/
export function describeUpdaterHandoffFailure(outcome: Pick<UpdaterHandoffOutcome, 'message'>): string {
const lead =
"The updater couldn't start, so nothing was changed and Hermes keeps running as before. " +
'Try again; if it keeps failing, open the logs and send them to support.'
return outcome.message ? `${lead}\n\nDetails: ${outcome.message}` : lead
}
/**
* Watch a just-spawned detached updater for the duration of the quit dwell
* and report whether the hand-off actually became viable (#66753).
+31 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { type EnumeratedWindow, enumerationFailureNote, pickWindowBelow } from './window-below'
import { type EnumeratedWindow, enumerationFailureNote, pickWindowBelow, resolveOutsideAsar } from './window-below'
const win = (pid: number, x = 0, y = 0, width = 800, height = 600, app = `app-${pid}`): EnumeratedWindow => ({
app,
@@ -145,3 +145,33 @@ describe('enumerationFailureNote', () => {
expect(note).not.toMatch(/ENOENT/)
})
})
describe('resolveOutsideAsar', () => {
// The helper binary get-windows execs cannot run from inside the archive
// (execFile on a path through app.asar fails ENOTDIR), so the import must
// land on the unpacked copy electron-builder ships beside it.
it('redirects a packaged specifier into app.asar.unpacked', () => {
expect(
resolveOutsideAsar('file:///Applications/Hermes.app/Contents/Resources/app.asar/dist/node_modules/get-windows/index.js')
).toBe('file:///Applications/Hermes.app/Contents/Resources/app.asar.unpacked/dist/node_modules/get-windows/index.js')
})
// The staged specifier is built with path.join, so on Windows the archive
// segment is delimited by backslashes, not the slashes a file: URL has.
it('redirects a Windows packaged path built with backslashes', () => {
expect(resolveOutsideAsar('C:\\Users\\me\\AppData\\Local\\Hermes\\resources\\app.asar\\dist\\node_modules\\get-windows\\index.js')).toBe(
'C:\\Users\\me\\AppData\\Local\\Hermes\\resources\\app.asar.unpacked\\dist\\node_modules\\get-windows\\index.js'
)
})
// Only the exact archive segment counts — a directory that merely starts
// with the name must not be rewritten, and a dev tree has nothing to rewrite.
it('requires app.asar to be a complete path segment', () => {
for (const untouched of [
'file:///opt/app.asar-tools/node_modules/get-windows/index.js',
'file:///Users/dev/hermes-agent/node_modules/get-windows/index.js'
]) {
expect(resolveOutsideAsar(untouched)).toBe(untouched)
}
})
})
+24 -2
View File
@@ -148,6 +148,17 @@ export const enumerationFailed = <T>(result: EnumerationFailure | T): result is
const describeError = (error: unknown): string =>
error instanceof Error ? error.message : String(error ?? 'unknown error')
/**
* Redirect a resolved module specifier out of `app.asar` so its files exist on
* the real filesystem. `app.asar` only ever appears as a complete path
* segment in a packaged build (electron-builder names the archive exactly
* that), so in dev — where nothing is archived — this is a no-op. The segment
* is matched against either separator because the staged specifier comes from
* `path.join`, which on Windows yields backslashes; same regex as main.ts.
*/
export const resolveOutsideAsar = (specifier: string): string =>
specifier.replace(/app\.asar(?=$|[\\/])/, 'app.asar.unpacked')
let getWindowsModule: Promise<GetWindowsModule | EnumerationFailure> | null = null
const loadGetWindows = (): Promise<GetWindowsModule | EnumerationFailure> => {
@@ -169,7 +180,18 @@ const loadGetWindows = (): Promise<GetWindowsModule | EnumerationFailure> => {
// scripts/stage-native-deps.mjs writes a staged lib/windows.js that requires
// the binding directly, so it is the more reliable of the two everywhere.
getWindowsModule ??= (async () => {
const staged = path.join(app.getAppPath(), 'dist', 'node_modules', 'get-windows', 'index.js')
// Both specifiers are redirected out of app.asar in a packaged build.
// get-windows does its real work by exec'ing a helper binary whose path it
// derives from its own module URL, so a module imported through an
// `/app.asar/` path derives an in-archive helper path and the spawn fails
// ENOTDIR: the kernel sees app.asar as a file. Electron rewrites asar
// paths for child_process only once that module has been pulled through
// the CJS loader, which this ESM main process never does (every import
// here is `from 'node:child_process'`). Pointing the import at the
// unpacked copy gives get-windows a real path to derive from.
const staged = resolveOutsideAsar(
path.join(app.getAppPath(), 'dist', 'node_modules', 'get-windows', 'index.js')
)
let stagedError = 'not staged in this build'
if (fs.existsSync(staged)) {
@@ -181,7 +203,7 @@ const loadGetWindows = (): Promise<GetWindowsModule | EnumerationFailure> => {
}
try {
return (await import('get-windows')) as GetWindowsModule
return (await import(resolveOutsideAsar(import.meta.resolve('get-windows')))) as GetWindowsModule
} catch (error) {
return {
reason:
@@ -90,7 +90,7 @@ function makeDeps(overrides: Partial<Parameters<typeof resolveVenvHermesCommand>
isCommandScript: () => false,
fileExists: () => true,
directoryExists: () => false,
canImportHermesCli: () => true,
canImportHermesCli: async () => true,
getVenvPython: (venvRoot: string) => `${venvRoot}/Scripts/python.exe`,
getVenvSitePackagesEntries: () => [],
buildDesktopBackendEnv: () => ({ FAKE_ENV: '1' }),
@@ -103,41 +103,41 @@ function makeDeps(overrides: Partial<Parameters<typeof resolveVenvHermesCommand>
}
}
test('resolveVenvHermesCommand: returns null off Windows', () => {
test('resolveVenvHermesCommand: returns null off Windows', async () => {
const deps = makeDeps({ isWindows: false })
assert.equal(resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', [], deps), null)
assert.equal(await resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', [], deps), null)
})
test('resolveVenvHermesCommand: returns null for a .cmd/.bat script command', () => {
test('resolveVenvHermesCommand: returns null for a .cmd/.bat script command', async () => {
const deps = makeDeps({ isCommandScript: () => true })
assert.equal(resolveVenvHermesCommand('/root/venv/Scripts/hermes.cmd', [], deps), null)
assert.equal(await resolveVenvHermesCommand('/root/venv/Scripts/hermes.cmd', [], deps), null)
})
test('resolveVenvHermesCommand: returns null when the basename is not hermes/hermes.exe', () => {
test('resolveVenvHermesCommand: returns null when the basename is not hermes/hermes.exe', async () => {
const deps = makeDeps()
assert.equal(resolveVenvHermesCommand('/root/venv/Scripts/python.exe', [], deps), null)
assert.equal(await resolveVenvHermesCommand('/root/venv/Scripts/python.exe', [], deps), null)
})
test('resolveVenvHermesCommand: returns null when the parent dir is not Scripts', () => {
test('resolveVenvHermesCommand: returns null when the parent dir is not Scripts', async () => {
const deps = makeDeps()
assert.equal(resolveVenvHermesCommand('/root/venv/bin/hermes.exe', [], deps), null)
assert.equal(await resolveVenvHermesCommand('/root/venv/bin/hermes.exe', [], deps), null)
})
test('resolveVenvHermesCommand: returns null when the venv python does not exist on disk', () => {
test('resolveVenvHermesCommand: returns null when the venv python does not exist on disk', async () => {
const deps = makeDeps({ fileExists: () => false })
assert.equal(resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', [], deps), null)
assert.equal(await resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', [], deps), null)
})
test('resolveVenvHermesCommand: probes the venv python before trusting it (returns null on failed probe)', () => {
test('resolveVenvHermesCommand: probes the venv python before trusting it (returns null on failed probe)', async () => {
let probed = false
const deps = makeDeps({
canImportHermesCli: (python: string) => {
canImportHermesCli: async (python: string) => {
probed = true
assert.equal(python, '/root/venv/Scripts/python.exe')
@@ -145,15 +145,15 @@ test('resolveVenvHermesCommand: probes the venv python before trusting it (retur
}
})
const result = resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', ['serve'], deps)
const result = await resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', ['serve'], deps)
assert.equal(probed, true, 'must probe the venv interpreter; a broken venv must not be re-selected forever')
assert.equal(result, null, 'a failed probe must fall through (return null) so the resolver reaches bootstrap')
})
test('resolveVenvHermesCommand: returns the resolved python backend descriptor when the probe passes', () => {
test('resolveVenvHermesCommand: returns the resolved python backend descriptor when the probe passes', async () => {
const deps = makeDeps()
const result = resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', ['serve', '--port', '0'], deps)
const result = await resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', ['serve', '--port', '0'], deps)
assert.ok(result, 'a passing probe must return a backend descriptor, not null')
assert.equal(result.command, '/root/venv/Scripts/python.exe')
@@ -164,11 +164,11 @@ test('resolveVenvHermesCommand: returns the resolved python backend descriptor w
assert.deepEqual(result.env, { FAKE_ENV: '1' })
})
test('resolveVenvHermesCommand: is case-insensitive on hermes.exe and the Scripts dir name', () => {
test('resolveVenvHermesCommand: is case-insensitive on hermes.exe and the Scripts dir name', async () => {
const deps = makeDeps()
assert.ok(resolveVenvHermesCommand('/root/venv/Scripts/HERMES.EXE', [], deps))
assert.ok(resolveVenvHermesCommand('/root/venv/SCRIPTS/hermes.exe', [], deps))
assert.ok(await resolveVenvHermesCommand('/root/venv/Scripts/HERMES.EXE', [], deps))
assert.ok(await resolveVenvHermesCommand('/root/venv/SCRIPTS/hermes.exe', [], deps))
})
// ── getVenvSitePackagesEntries ─────────────────────────────────────────────
@@ -194,13 +194,15 @@ test('getVenvSitePackagesEntries: returns empty on Windows when site-packages do
})
test('getVenvSitePackagesEntries: reads pyvenv.cfg version on POSIX and resolves lib/pythonX.Y/site-packages', () => {
const expected = path.join('/venv', 'lib', 'python3.12', 'site-packages')
const result = getVenvSitePackagesEntries('/venv', {
isWindows: false,
directoryExists: p => p === '/venv/lib/python3.12/site-packages',
directoryExists: p => p === expected,
readFile: () => 'version_info = 3.12.1\n'
})
assert.deepEqual(result, ['/venv/lib/python3.12/site-packages'])
assert.deepEqual(result, [expected])
})
test('getVenvSitePackagesEntries: returns empty on POSIX when pyvenv.cfg is missing', () => {
+6 -6
View File
@@ -171,7 +171,7 @@ export interface ResolveVenvHermesCommandDeps {
isCommandScript: (command: string) => boolean
fileExists: (filePath: string) => boolean
directoryExists: (filePath: string) => boolean
canImportHermesCli: (python: string, opts?: { env?: Record<string, string> }) => boolean
canImportHermesCli: (python: string, opts?: { env?: Record<string, string> }) => Promise<boolean>
getVenvPython: (venvRoot: string) => string
getVenvSitePackagesEntries: (venvRoot: string) => string[]
buildDesktopBackendEnv: (opts: {
@@ -205,11 +205,11 @@ export interface ResolveVenvHermesCommandDeps {
* python doesn't exist, or the import probe fails. Otherwise returns the
* resolved backend descriptor.
*/
export function resolveVenvHermesCommand(
export async function resolveVenvHermesCommand(
command: string,
backendArgs: string[],
deps: ResolveVenvHermesCommandDeps
): {
): Promise<{
label: string
command: string
args: string[]
@@ -218,7 +218,7 @@ export function resolveVenvHermesCommand(
kind: 'python'
root: string
shell: false
} | null {
} | null> {
const {
isWindows,
isCommandScript,
@@ -261,13 +261,13 @@ export function resolveVenvHermesCommand(
const root = dirname(venvRoot)
if (
!canImportHermesCli(python, {
!(await canImportHermesCli(python, {
env: {
PYTHONPATH: [...(directoryExists(root) ? [root] : []), process.env.PYTHONPATH]
.filter((entry): entry is string => Boolean(entry))
.join(path.delimiter)
}
})
}))
) {
rememberLog?.(
`Ignoring venv Hermes at ${python}: runtime import probe failed (broken/partial venv); falling through to bootstrap.`
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "hermes",
"productName": "Hermes",
"private": true,
"version": "0.17.2",
"version": "0.17.3",
"description": "Native desktop shell for Hermes Agent.",
"author": "Nous Research",
"repository": {
+19 -3
View File
@@ -15,7 +15,7 @@ The desktop has **no build/runtime dependency on the dashboard frontend**: it sp
NOT embed `hermes --tui` — own composer, transcript, slash pipeline.
**One backward-compat fallback:** `serve` is newer, so the spawn (`electron/backend-command.ts` +
`backendSupportsServe()` in `electron/main.ts`) checks whether the resolved runtime registers `serve`
`createBackendServeSupportResolver()` in `electron/backend-serve-support.ts`) checks whether the runtime registers `serve`
and ONLY when it does not (older managed install / PATH `hermes` not yet updated) rewrites argv to
legacy `dashboard --no-open`. Without it a new app against an un-upgraded runtime crashes on an
unknown subcommand and bricks every mid-upgrade user. Keep it narrow and tested.
@@ -23,13 +23,29 @@ unknown subcommand and bricks every mid-upgrade user. Keep it narrow and tested.
Lifecycle: `serve` dies with the app by design; the messaging gateway survives it (spawned detached
via `/api/gateway/*`). Never re-parent the gateway under the backend — `gateway/AGENTS.md`.
The backend the app spawns is a **pooled `hermes serve --port 0` per (connection, profile)**: its
launch home is that profile, `HERMES_DESKTOP=1` is set, and its in-process cron ticker stands down
for homes a running gateway already serves. One process may still host sessions from several homes
(`tui_gateway/AGENTS.md` § Profile scope); the first non-launch home flips `set_multiplex_active`.
Remote connections (SSH, URL+token, Cloud) reach a backend with no desktop env var that may serve
several profiles from one process. Every lifecycle/status/settings REST call against a pooled
backend carries `?profile=` (or the `profile` param) and every new-session tile records an owner
route; a backend-side scope fix is probed twice — with the profile as the launch home of a pooled
backend (env-bound) and as a secondary served by one process (override-bound).
## Slash commands: curated client-side, dispatched to the backend
- The backend already provides everything: `commands.catalog` and `complete.slash` include built-ins,
user `quick_commands`, AND skill-derived commands. No new RPC is needed to see skills.
- `src/lib/desktop-slash-commands.ts` is the load-bearing file: `DESKTOP_COMMAND_SPECS` (built-ins
and their desktop surfaces) + `NO_DESKTOP_SURFACE` block-lists (terminal-only / messaging-only /
picker-owned / settings-owned / advanced). `isDesktopSlashCommand(name)` gates **execution** (true
and their desktop surfaces) + the block-list. A command's desktop disposition (terminal-only /
messaging-only / settings-owned / advanced / hidden) is authored ONCE, as `desktop=` on its
`CommandDef` in `hermes_cli/commands.py`; the live `commands.catalog` carries it, and
`src/lib/desktop-slash-registry.json` (regenerate with `scripts/dump_desktop_slash_registry.py`;
`tests/hermes_cli/test_desktop_slash_registry.py` + the vitest file fail on drift) is the offline
fallback. Only names the Python registry has never heard of (`/density`, `/details`, `/logs`,
`/mouse` — Ink-local; `/pets`) live in `TS_ONLY_NO_DESKTOP_SURFACE`. `isDesktopSlashCommand(name)`
gates **execution** (true
for built-ins AND any non-built-in so typed skill/quick commands run);
`isDesktopSlashSuggestion(name)` gates **discovery** — used by BOTH completion paths in
`app/chat/composer/hooks/use-slash-completions.ts` and by `filterDesktopCommandsCatalog`;
+9
View File
@@ -155,6 +155,15 @@ export function capabilityScoped(scope?: ProfileScope): { connectionId?: string;
return { ...profileScoped(scope), ...connectionScoped() }
}
/** Spawn priority for a REST call that may cold-start a pooled backend. An
* explicit scope is a user pointing a scope selector (Settings "Applies to",
* Capabilities) at another profile — a visible action that may take the
* pool's reserved foreground slot (#111651). The ambient path stays untagged,
* main's background default, so hydration cannot consume that slot. */
export function scopedDialPriority(scope?: ProfileScope): { priority?: 'foreground' } {
return scope == null ? {} : { priority: 'foreground' }
}
/** Stable cache-key for a capability scope: `profile` for the ambient/legacy
* path, `connectionId::profile` for ANY explicit pin — `local` included. An
* explicit "This device" pick and the ambient path are no longer guaranteed
+22 -1
View File
@@ -14,7 +14,14 @@ import type {
StatusResponse
} from '@/types/hermes'
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped, STARTUP_REQUEST_TIMEOUT_MS } from './client'
import {
capabilityScoped,
hermesApi,
type ProfileScope,
profileScoped,
scopedDialPriority,
STARTUP_REQUEST_TIMEOUT_MS
} from './client'
export function getStatus(): Promise<StatusResponse> {
return hermesApi<StatusResponse>({
@@ -74,6 +81,7 @@ export function getHermesConfigRecord(
): Promise<HermesConfigRecord> {
return window.hermesDesktop.api<HermesConfigRecord>({
...capabilityScoped(profile),
...scopedDialPriority(profile),
path: includeDefaults ? '/api/config' : '/api/config?include_defaults=false'
})
}
@@ -89,6 +97,7 @@ export function getHermesConfigDefaults(): Promise<HermesConfigRecord> {
export function getHermesConfigSchema(profile?: null | string): Promise<ConfigSchemaResponse> {
return hermesApi<ConfigSchemaResponse>({
...profileScoped(profile),
...scopedDialPriority(profile),
path: '/api/config/schema'
})
}
@@ -100,6 +109,7 @@ export function saveHermesConfig(
): Promise<{ ok: boolean }> {
return hermesApi<{ ok: boolean }>({
...profileScoped(profile),
...scopedDialPriority(profile),
path: preserveLanguage ? '/api/config?preserve_language=true' : '/api/config',
method: 'PUT',
body: { config }
@@ -112,6 +122,7 @@ export function saveHermesConfig(
export function saveHermesConfigRecord(config: HermesConfigRecord, profile?: ProfileScope): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...capabilityScoped(profile),
...scopedDialPriority(profile),
path: '/api/config',
method: 'PUT',
body: { config }
@@ -121,6 +132,7 @@ export function saveHermesConfigRecord(config: HermesConfigRecord, profile?: Pro
export function getEnvVars(profile?: null | string): Promise<Record<string, EnvVarInfo>> {
return hermesApi<Record<string, EnvVarInfo>>({
...profileScoped(profile),
...scopedDialPriority(profile),
path: '/api/env'
})
}
@@ -128,6 +140,7 @@ export function getEnvVars(profile?: null | string): Promise<Record<string, EnvV
export function setEnvVar(key: string, value: string, profile?: ProfileScope): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...capabilityScoped(profile),
...scopedDialPriority(profile),
path: '/api/env',
method: 'PUT',
body: { key, value }
@@ -137,6 +150,7 @@ export function setEnvVar(key: string, value: string, profile?: ProfileScope): P
export function deleteEnvVar(key: string, profile?: ProfileScope): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...capabilityScoped(profile),
...scopedDialPriority(profile),
path: '/api/env',
method: 'DELETE',
body: { key }
@@ -146,6 +160,7 @@ export function deleteEnvVar(key: string, profile?: ProfileScope): Promise<{ ok:
export function revealEnvVar(key: string, profile?: ProfileScope): Promise<{ key: string; value: string }> {
return window.hermesDesktop.api<{ key: string; value: string }>({
...capabilityScoped(profile),
...scopedDialPriority(profile),
path: '/api/env/reveal',
method: 'POST',
body: { key }
@@ -208,6 +223,7 @@ export function deleteCustomEndpoint(id: string): Promise<CustomEndpointsRespons
export function listOAuthProviders(profile?: null | string): Promise<OAuthProvidersResponse> {
return hermesApi<OAuthProvidersResponse>({
...profileScoped(profile),
...scopedDialPriority(profile),
path: '/api/providers/oauth'
})
}
@@ -218,6 +234,7 @@ export function disconnectOAuthProvider(
): Promise<{ ok: boolean; provider: string }> {
return hermesApi<{ ok: boolean; provider: string }>({
...profileScoped(profile),
...scopedDialPriority(profile),
path: `/api/providers/oauth/${encodeURIComponent(providerId)}`,
method: 'DELETE'
})
@@ -226,6 +243,7 @@ export function disconnectOAuthProvider(
export function startOAuthLogin(providerId: string, profile?: ProfileScope): Promise<OAuthStartResponse> {
return window.hermesDesktop.api<OAuthStartResponse>({
...capabilityScoped(profile),
...scopedDialPriority(profile),
path: `/api/providers/oauth/${encodeURIComponent(providerId)}/start`,
method: 'POST',
body: {}
@@ -240,6 +258,7 @@ export function submitOAuthCode(
): Promise<OAuthSubmitResponse> {
return hermesApi<OAuthSubmitResponse>({
...profileScoped(profile),
...scopedDialPriority(profile),
path: `/api/providers/oauth/${encodeURIComponent(providerId)}/submit`,
method: 'POST',
body: { session_id: sessionId, code }
@@ -253,6 +272,7 @@ export function pollOAuthSession(
): Promise<OAuthPollResponse> {
return window.hermesDesktop.api<OAuthPollResponse>({
...capabilityScoped(profile),
...scopedDialPriority(profile),
path: `/api/providers/oauth/${encodeURIComponent(providerId)}/poll/${encodeURIComponent(sessionId)}`
})
}
@@ -260,6 +280,7 @@ export function pollOAuthSession(
export function cancelOAuthSession(sessionId: string, profile?: null | string): Promise<{ ok: boolean }> {
return hermesApi<{ ok: boolean }>({
...profileScoped(profile),
...scopedDialPriority(profile),
path: `/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`,
method: 'DELETE'
})
+2 -1
View File
@@ -1,6 +1,6 @@
import type { McpCatalogResponse, McpServerSummary } from '@/types/hermes'
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client'
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped, scopedDialPriority } from './client'
export interface McpTestResult {
ok: boolean
@@ -117,6 +117,7 @@ export function setMcpServerEnabled(name: string, enabled: boolean): Promise<{ o
export function getMcpCatalog(profile?: ProfileScope): Promise<McpCatalogResponse> {
return window.hermesDesktop.api<McpCatalogResponse>({
...capabilityScoped(profile),
...scopedDialPriority(profile),
path: '/api/mcp/catalog'
})
}
+5 -4
View File
@@ -1,11 +1,12 @@
import type { ModelOptionsResult } from '@hermes/shared'
import type {
AnalyticsResponse,
AuxiliaryModelsResponse,
MoaConfigResponse,
ModelAssignmentRequest,
ModelAssignmentResponse,
ModelInfoResponse,
ModelOptionsResponse
ModelInfoResponse
} from '@/types/hermes'
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped, STARTUP_REQUEST_TIMEOUT_MS } from './client'
@@ -32,7 +33,7 @@ export function getGlobalModelOptions(
explicitOnly?: boolean
},
profile?: null | string
): Promise<ModelOptionsResponse> {
): Promise<ModelOptionsResult> {
const params = new URLSearchParams()
if (opts?.refresh) {
@@ -47,7 +48,7 @@ export function getGlobalModelOptions(
params.set('explicit_only', '1')
}
return hermesApi<ModelOptionsResponse>({
return hermesApi<ModelOptionsResult>({
...profileScoped(profile),
path: params.size > 0 ? `/api/model/options?${params.toString()}` : '/api/model/options',
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
+2 -1
View File
@@ -1,5 +1,6 @@
import { reconnectBackoffDelayMs } from '@hermes/shared'
import type { HermesConnection } from '@/global'
import { reconnectBackoffDelayMs } from '@/lib/reconnect-backoff'
import { RECONNECT_ATTEMPT_TIMEOUT_MS, withTimeout } from '@/lib/with-timeout'
import { getApiRequestConnection, getApiRequestProfile, hermesApi, profileScoped } from './client'
+2 -1
View File
@@ -8,8 +8,9 @@ import type {
import { capabilityScoped, hermesApi, type ProfileScope, STARTUP_REQUEST_TIMEOUT_MS } from './client'
export function getProfiles(): Promise<ProfilesResponse> {
export function getProfiles(scope?: ProfileScope): Promise<ProfilesResponse> {
return hermesApi<ProfilesResponse>({
...(scope === undefined ? {} : capabilityScoped(scope)),
path: '/api/profiles',
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
})
+2 -1
View File
@@ -9,11 +9,12 @@ import type {
} from '@/types/hermes'
import type { ActionResponse } from '@/types/hermes'
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client'
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped, scopedDialPriority } from './client'
export function getSkills(profile?: ProfileScope): Promise<SkillInfo[]> {
return window.hermesDesktop.api<SkillInfo[]>({
...capabilityScoped(profile),
...scopedDialPriority(profile),
path: '/api/skills'
})
}
+2 -1
View File
@@ -7,7 +7,7 @@ import type {
ToolsetModelsResponse
} from '@/types/hermes'
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client'
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped, scopedDialPriority } from './client'
// The optional trailing `profile` on every capability fetcher below is the
// Capabilities view's profile-scope override: it lets the Skills/Tools/MCP
@@ -17,6 +17,7 @@ import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from '.
export function getToolsets(profile?: ProfileScope): Promise<ToolsetInfo[]> {
return window.hermesDesktop.api<ToolsetInfo[]>({
...capabilityScoped(profile),
...scopedDialPriority(profile),
path: '/api/tools/toolsets'
})
}
+1 -1
View File
@@ -1,3 +1,4 @@
import { compactNumber } from '@hermes/shared'
import { useStore } from '@nanostores/react'
import { type ReactNode, useEffect, useMemo, useState } from 'react'
@@ -8,7 +9,6 @@ import { Codicon } from '@/components/ui/codicon'
import { FadeText } from '@/components/ui/fade-text'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { type Translations, useI18n } from '@/i18n'
import { compactNumber } from '@/lib/format'
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
@@ -1,10 +1,11 @@
import type { Unstable_TriggerItem } from '@assistant-ui/core'
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import {
acceptsTriggerCompletion,
implicitSlashAcceptIndex,
isPendingDraftPersistCurrent,
liveComposerDraft,
type PendingDraftPersist,
pickPlaceholder,
shouldDisableComposerInput,
@@ -13,6 +14,7 @@ import {
slashCommandToken,
type TriggerAcceptInput
} from './composer-utils'
import { normalizeComposerEditorDom, RICH_INPUT_SLOT } from './rich-editor'
const item = (group: string): Unstable_TriggerItem =>
({ id: 'x', type: 'slash', label: 'x', metadata: { group } }) as unknown as Unstable_TriggerItem
@@ -177,3 +179,38 @@ describe('isPendingDraftPersistCurrent (#54527 integrity guard)', () => {
expect(isPendingDraftPersistCurrent(null, null)).toBe(false)
})
})
/** Real contentEditable, built the way `empty-composer.test.ts` builds one. */
function editorWith(text: string): HTMLDivElement {
const el = document.createElement('div')
el.dataset.slot = RICH_INPUT_SLOT
el.contentEditable = 'true'
el.append(document.createTextNode(text))
normalizeComposerEditorDom(el)
document.body.append(el)
return el
}
// editorWith appends to the shared JSDOM body; empty it so the element does not
// leak into other cases in this file.
afterEach(() => {
document.body.replaceChildren()
})
describe('liveComposerDraft (stale-mirror guard for the ArrowUp recall)', () => {
it('reads the live editor text even when the mirror is still empty', () => {
// The race this exists for: a keystroke or paste flushed only by the
// coalesced rAF, so `draftRef.current` holds the pre-keystroke text while
// the editor already holds what the user typed. The recall guard must see
// the typed text, not the stale empty mirror.
const editor = editorWith('just typed this')
expect(liveComposerDraft(editor, '')).toBe('just typed this')
})
it('falls back to the mirror before the editor mounts', () => {
expect(liveComposerDraft(null, 'mirrored draft')).toBe('mirrored draft')
})
})
@@ -5,6 +5,7 @@ import type { SlashChipKind } from '@/components/assistant-ui/directive-text'
import type { ComposerAttachment } from '@/store/composer'
import { setSessionPickerOpen } from '@/store/session'
import { composerPlainText } from './rich-editor'
import type { TriggerState } from './text-utils'
export const COMPOSER_STACK_BREAKPOINT_PX = 320
@@ -217,3 +218,15 @@ export function isPendingDraftPersistCurrent(
): boolean {
return pending !== null && expected !== null && pending.scope === expected.scope && pending.text === expected.text
}
/**
* The composer text a keystroke should decide from.
*
* `mirror` (the composer's draftRef) is refreshed by a coalesced per-frame
* flush, so within a frame of a keystroke or paste it still holds the previous
* text. A decision that can act on the draft — the sent-message recall guard
* replaces the composer — has to read the live editor instead.
*/
export function liveComposerDraft(editor: HTMLElement | null | undefined, mirror: string): string {
return editor ? composerPlainText(editor) : mirror
}
@@ -47,7 +47,7 @@ export function ContextMenu({
return (
<>
<DropdownMenu>
<Tip label={state.tools.label} side="top">
<Tip label={state.tools.label} side="left">
<DropdownMenuTrigger asChild>
<Button
aria-label={state.tools.label}
@@ -62,19 +62,28 @@ afterEach(() => {
$hudMode.set(false)
})
// The HUD is a Spotlight bar a few hundred pixels wide: the four voice
// controls fold into one menu there, and the way out of HUD mode joins the
// row instead of floating above the bar in a reserved strip. The docked
// composer keeps every control inline and shows no exit.
// The HUD is a Spotlight bar a few hundred pixels wide: the voice controls
// fold into one menu there, and the way out of HUD mode joins the row instead
// of floating above the bar in a reserved strip. The docked composer keeps the
// mic inline, with the other voice toggles fanned out of it on hover, and
// shows no exit.
describe('HUD mode', () => {
it('keeps the voice controls inline and offers no exit in the docked composer', () => {
it('keeps the mic inline, fans the toggles on hover, and offers no exit in the docked composer', async () => {
renderControls()
expect(screen.getByLabelText('Voice dictation')).toBeTruthy()
expect(screen.getByLabelText('Read replies aloud')).toBeTruthy()
const mic = screen.getByLabelText('Voice dictation')
expect(mic).toBeTruthy()
expect(screen.queryByLabelText('Read replies aloud')).toBeNull()
fireEvent.pointerEnter(mic.parentElement!)
expect(await screen.findByLabelText('Read replies aloud')).toBeTruthy()
expect(screen.getByLabelText('Wake word "hey hermes"')).toBeTruthy()
expect(screen.queryByLabelText('Exit HUD mode')).toBeNull()
expect(screen.queryByLabelText('Reset HUD size and position')).toBeNull()
expect(screen.queryByLabelText('Voice')).toBeNull()
// No folded menu trigger — the fan's group shares the "Voice" name.
expect(screen.queryByRole('button', { name: 'Voice' })).toBeNull()
})
it('folds them into one menu and offers the way out in the HUD', () => {
@@ -173,38 +182,37 @@ describe('wake-word ear visibility', () => {
resetWakeWordState()
})
it('stays mounted during a busy agent turn', () => {
// The ear lives in the mic's fan now: hover the mic to reach it.
const findEar = async () => {
fireEvent.pointerEnter(screen.getByLabelText('Voice dictation').parentElement!)
return screen.findByLabelText('Wake word "hey hermes"')
}
it('stays reachable during a busy agent turn', async () => {
applyWakeStatus({ available: true, enabled: true, listening: true, phrase: 'hey hermes' })
renderControls({ busy: true, busyAction: 'stop' })
expect(screen.getByLabelText('Wake word: "hey hermes" — listening')).toBeTruthy()
expect((await findEar()).getAttribute('aria-pressed')).toBe('true')
})
it('stays mounted (enabled in config) even when a start was refused', () => {
it('stays reachable (enabled in config) even when a start was refused', async () => {
applyWakeStatus({ available: true, enabled: true, listening: false, phrase: 'hey hermes' })
// Transient refusal marks available false but enabled keeps it mounted.
applyWakeStartResult({ hint: 'mic busy', reason: 'unavailable', started: false })
renderControls()
expect(screen.getByLabelText('Wake word: "hey hermes" — off')).toBeTruthy()
expect((await findEar()).getAttribute('aria-pressed')).toBe('false')
})
it('stays visible (never hides) even when unavailable and not enabled', () => {
applyWakeStatus({ available: false, enabled: false, listening: false, phrase: 'hey hermes' })
renderControls()
// The ear ALWAYS shows so the user can click to enable; a failed start
// surfaces its reason in the tooltip rather than hiding the control.
expect(screen.getByLabelText('Wake word: "hey hermes" — off')).toBeTruthy()
})
it('surfaces the backend refusal reason in the tooltip, still visible', () => {
it('stays reachable (never hides) even when unavailable and not enabled', async () => {
applyWakeStatus({ available: false, enabled: false, listening: false, phrase: 'hey hermes' })
applyWakeStartResult({ hint: 'run `hermes tools` (Voice section)', reason: 'unavailable', started: false })
renderControls()
const ear = screen.getByLabelText('Wake word: "hey hermes" — off')
expect(ear).toBeTruthy()
// The ear ALWAYS shows so the user can click to enable; a refused start
// never hides the control.
expect(((await findEar()) as HTMLButtonElement).disabled).toBe(false)
})
it('shows a disabled paused ear inside the voice-conversation pill', () => {
+24 -93
View File
@@ -5,7 +5,7 @@ import { Codicon } from '@/components/ui/codicon'
import { Tip, TipKeybindLabel } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Ear, EarOff, iconSize, Layers3, Loader2, Square, Volume2, VolumeX } from '@/lib/icons'
import { Ear, EarOff, iconSize, Layers3, Loader2, Square } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $hudMode, closeHud, resetHudLayout } from '@/store/hud'
import { $wakeWord, toggleWakeWord } from '@/store/wake-word'
@@ -14,8 +14,10 @@ import { ApprovalModePill } from './approval-mode-pill'
import { ACTIVE_ICON_BTN, GHOST_ICON_BTN, PRIMARY_ICON_BTN } from './control-classes'
import type { ConversationStatus } from './hooks/use-voice-conversation'
import { ModelPill } from './model-pill'
import { ReasoningPill } from './reasoning-pill'
import { StartVoiceButton } from './start-voice-button'
import type { ChatBarState, VoiceStatus } from './types'
import { VoiceFan } from './voice-fan'
import { VoiceMenu } from './voice-menu'
// Re-exported: `context-menu.tsx` and other row neighbours have always reached
@@ -100,11 +102,15 @@ export function ComposerControls({
voiceStatus={voiceStatus}
/>
) : (
<>
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
<AutoSpeakButton active={autoSpeak} disabled={disabled} onToggle={onToggleAutoSpeak} />
<WakeWordButton disabled={disabled} />
</>
// One mic in the row; hovering it fans the other voice toggles out of it.
<VoiceFan
autoSpeak={autoSpeak}
disabled={disabled}
onDictate={onDictate}
onToggleAutoSpeak={onToggleAutoSpeak}
state={state}
voiceStatus={voiceStatus}
/>
)
return (
@@ -112,12 +118,17 @@ export function ComposerControls({
{minimal ? null : (
<>
<ApprovalModePill compact={compactModelPill} />
{hideModelPill ? null : <ModelPill compact={compactModelPill} disabled={disabled} model={state.model} />}
{hideModelPill ? null : (
<>
<ModelPill compact={compactModelPill} disabled={disabled} model={state.model} />
{compactModelPill ? null : <ReasoningPill disabled={disabled} model={state.model} />}
</>
)}
{voiceControls}
</>
)}
{showQueueButton ? (
<Tip label={<TipKeybindLabel actionId="composer.queue" text={c.queueMessage} />}>
<Tip label={<TipKeybindLabel actionId="composer.queue" text={c.queueMessage} />} side="left">
<Button
aria-label={c.queueMessage}
className={GHOST_ICON_BTN}
@@ -142,6 +153,7 @@ export function ComposerControls({
<TipKeybindLabel actionId="composer.send" text={c.send} />
)
}
side="left"
>
<Button
aria-label={showStop ? c.stop : c.send}
@@ -174,7 +186,7 @@ function HudWindowButtons() {
return (
<>
<Tip label={t.titlebar.resetHudLayout}>
<Tip label={t.titlebar.resetHudLayout} side="left">
<Button
aria-label={t.titlebar.resetHudLayout}
className={cn(GHOST_ICON_BTN, 'p-0')}
@@ -186,7 +198,7 @@ function HudWindowButtons() {
<Codicon name="discard" size="0.875rem" />
</Button>
</Tip>
<Tip label={t.titlebar.exitHud}>
<Tip label={t.titlebar.exitHud} side="left">
<Button
aria-label={t.titlebar.exitHud}
className={cn(GHOST_ICON_BTN, 'p-0')}
@@ -232,7 +244,7 @@ function ConversationPill({
{/* Keep the ear visible during voice chat — shown paused, since the
conversation holds the mic (the one time wake must not listen). */}
<WakeWordButton disabled={disabled} pausedForVoice />
<Tip label={muted ? c.unmuteMic : c.muteMic}>
<Tip label={muted ? c.unmuteMic : c.muteMic} side="left">
<Button
aria-label={muted ? c.unmuteMic : c.muteMic}
aria-pressed={muted}
@@ -312,35 +324,6 @@ function ConversationIndicator({
)
}
// Pure-TTS toggle: type normally, but have every assistant reply read aloud —
// no dictation, no full conversation loop. Filled/accent when on, mirroring the
// muted-mic pressed state above. Persisted locally, independently of gateway TTS.
function AutoSpeakButton({ active, disabled, onToggle }: { active: boolean; disabled: boolean; onToggle: () => void }) {
const { t } = useI18n()
const c = t.composer
const label = active ? c.stopSpeakingReplies : c.speakReplies
return (
<Tip label={label}>
<Button
aria-label={label}
aria-pressed={active}
className={cn(GHOST_ICON_BTN, 'p-0', active && ACTIVE_ICON_BTN)}
disabled={disabled}
onClick={() => {
triggerHaptic(active ? 'close' : 'open')
onToggle()
}}
size="icon"
type="button"
variant="ghost"
>
{active ? <Volume2 className={iconSize.sm} /> : <VolumeX className={iconSize.sm} />}
</Button>
</Tip>
)
}
// "Hey Hermes" wake-word toggle. ALWAYS rendered — the ear never hides. A
// user must always be able to click it to turn passive listening on; if the
// backend can't start (missing STT/TTS, deps still installing, no mic
@@ -366,7 +349,7 @@ function WakeWordButton({ disabled, pausedForVoice = false }: { disabled: boolea
const tooltip = !pausedForVoice && wake.notice ? `${label} — ${wake.notice}` : label
return (
<Tip label={tooltip}>
<Tip label={tooltip} side="left">
<Button
aria-label={label}
aria-pressed={wake.listening && !pausedForVoice}
@@ -385,55 +368,3 @@ function WakeWordButton({ disabled, pausedForVoice = false }: { disabled: boolea
</Tip>
)
}
function DictationButton({
disabled,
state,
status,
onToggle
}: {
disabled: boolean
state: ChatBarState['voice']
status: VoiceStatus
onToggle: () => void
}) {
const { t } = useI18n()
const c = t.composer
const active = state.active || status !== 'idle'
const aria =
status === 'recording' ? c.stopDictation : status === 'transcribing' ? c.transcribingDictation : c.voiceDictation
return (
<Tip label={aria}>
<Button
aria-label={aria}
aria-pressed={active}
className={cn(
GHOST_ICON_BTN,
'p-0',
'data-[active=true]:bg-accent data-[active=true]:text-foreground',
status === 'recording' && ACTIVE_ICON_BTN,
status === 'transcribing' && 'bg-primary/10 text-primary'
)}
data-active={active}
disabled={disabled || !state.enabled || status === 'transcribing'}
onClick={() => {
triggerHaptic(active ? 'close' : 'open')
onToggle()
}}
size="icon"
type="button"
variant="ghost"
>
{status === 'recording' ? (
<Square className={cn('fill-current', iconSize.xs)} />
) : status === 'transcribing' ? (
<Loader2 className={cn('animate-spin', iconSize.sm)} />
) : (
<Codicon name="mic" size="0.875rem" />
)}
</Button>
</Tip>
)
}
@@ -0,0 +1,98 @@
import { type MutableRefObject, useCallback } from 'react'
import { listRepoBranches, requestStartWorkSession, startWorkInRepo, switchBranchInRepo } from '@/store/projects'
import { useComposerScope } from '../scope'
interface UseComposerBranchOptions {
clearDraft: () => void
cwd: null | string | undefined
draftRef: MutableRefObject<string>
}
/**
* Branch / worktree engine — the `CodingStatusRow` hand-offs. Each action opens
* a fresh session anchored in a worktree carrying the current composer draft as
* its first turn; clearing here means the draft travels to the new session
* instead of getting stashed under this one. Backend coupling (cwd + the
* projects store) is the only dependency; nothing about ChatBar's render.
*/
export function useComposerBranch({ clearDraft, cwd, draftRef }: UseComposerBranchOptions) {
const scope = useComposerScope()
// Hand a worktree off to the controller: open a fresh session anchored there,
// carrying the composer draft as its first turn. Clearing here means the draft
// travels to the new session instead of getting stashed under this one.
const openInWorktree = useCallback(
(path: string) => {
const text = draftRef.current
clearDraft()
scope.attachments.clear()
requestStartWorkSession(path, text)
},
[clearDraft, draftRef]
)
// Branch off into a NEW worktree (base = branch name, or current HEAD). A
// create failure throws back to the row (which toasts) before we touch the
// draft; a missing cwd / remote backend no-ops (the row hides the affordance).
const handleBranchOff = useCallback(
async (branch: string, base?: string) => {
const repoPath = cwd?.trim()
const result = repoPath && (await startWorkInRepo(repoPath, { base, branch, name: branch }))
if (result) {
openInWorktree(result.path)
}
},
[cwd, openInWorktree]
)
// Convert an EXISTING branch into a fresh worktree + session (no new branch).
// Mirrors handleBranchOff's hand-off: create the worktree, then open a session
// anchored there carrying the draft.
const handleConvertBranch = useCallback(
async (branch: string, path?: null | string, isDefault?: boolean) => {
if (path?.trim()) {
openInWorktree(path)
return
}
const repoPath = cwd?.trim()
if (repoPath && isDefault) {
await switchBranchInRepo(repoPath, branch)
openInWorktree(repoPath)
return
}
const result = repoPath && (await startWorkInRepo(repoPath, { existingBranch: branch }))
if (result) {
openInWorktree(result.path)
}
},
[cwd, openInWorktree]
)
const handleListBranches = useCallback(async () => {
const repoPath = cwd?.trim()
return repoPath ? listRepoBranches(repoPath) : []
}, [cwd])
const handleSwitchBranch = useCallback(
async (branch: string) => {
const repoPath = cwd?.trim()
if (repoPath) {
await switchBranchInRepo(repoPath, branch)
}
},
[cwd]
)
return { handleBranchOff, handleConvertBranch, handleListBranches, handleSwitchBranch, openInWorktree }
}
@@ -7,10 +7,10 @@ import '@/store/suggestion-providers/mcp'
import '@/store/suggestion-providers/skill'
import { useAui, useAuiState, useComposerRuntime } from '@assistant-ui/react'
import { SLASH_COMMAND_RE } from '@hermes/shared'
import { type RefObject, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { usePaneVisible } from '@/components/pane-shell/pane-visibility'
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
import {
type ComposerAttachment,
@@ -6,7 +6,6 @@ import { PaneVisibleContext } from '@/components/pane-shell/pane-visibility'
import { $clarifyRequests } from '@/store/clarify'
import type { ComposerAttachment } from '@/store/composer'
import { clearQueuedPrompts, getQueuedPrompts } from '@/store/composer-queue'
import { $gateway } from '@/store/gateway'
import {
clearAllPrompts,
hasBlockingPromptRequest,
@@ -14,6 +13,7 @@ import {
setSecretRequest,
setSudoRequest
} from '@/store/prompts'
import { hasOpenServerRequest, rememberServerRequest, resetServerRequestsForTests } from '@/store/server-requests'
import { type ComposerTarget, requestComposerSubmit } from '../focus'
import { ComposerScopeProvider, ComposerSurfaceProvider, MAIN_COMPOSER_SCOPE } from '../scope'
@@ -457,26 +457,30 @@ describe('useComposerSubmit busy-turn routing', () => {
})
describe('useComposerSubmit with a clarify parked on the session', () => {
const gatewayRequest = vi.fn(async () => ({ ok: true }))
// The clarify is a live server→client request: skipping it answers that
// request frame (`{ answer: '' }`), not a `clarify.respond` RPC.
const respond = vi.fn()
const parkClarify = (sessionId: string) => {
const requestId = `req-${sessionId}`
rememberServerRequest({ fail: vi.fn(), id: requestId, method: 'clarify', params: {}, respond })
$clarifyRequests.set({
[sessionId]: {
requestId: `req-${sessionId}`,
requestId,
question: 'which one?',
choices: ['a', 'b'],
multiSelect: false,
sessionId
}
})
$gateway.set({ request: gatewayRequest } as unknown as ReturnType<typeof $gateway.get>)
}
afterEach(() => {
cleanup()
gatewayRequest.mockClear()
respond.mockClear()
resetServerRequestsForTests()
$clarifyRequests.set({})
$gateway.set(null)
vi.restoreAllMocks()
})
@@ -488,16 +492,12 @@ describe('useComposerSubmit with a clarify parked on the session', () => {
hook.result.current.submitDraft()
})
await waitFor(() =>
expect(gatewayRequest).toHaveBeenCalledWith('clarify.respond', {
request_id: 'req-runtime-session',
answer: ''
})
)
await waitFor(() => expect(respond).toHaveBeenCalledWith({ answer: '' }))
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith('actually do this instead', expect.objectContaining({ attachments: [] }))
)
expect($clarifyRequests.get()['runtime-session']).toBeUndefined()
expect(hasOpenServerRequest('req-runtime-session')).toBe(false)
})
it('skips the question before steering a busy turn', async () => {
@@ -509,7 +509,7 @@ describe('useComposerSubmit with a clarify parked on the session', () => {
})
await waitFor(() => expect(onSteer).toHaveBeenCalledWith('change course'))
expect(gatewayRequest).toHaveBeenCalledWith('clarify.respond', { request_id: 'req-runtime-session', answer: '' })
expect(respond).toHaveBeenCalledWith({ answer: '' })
})
it('leaves the question alone for an empty Enter (Stop, not an answer)', () => {
@@ -520,7 +520,8 @@ describe('useComposerSubmit with a clarify parked on the session', () => {
hook.result.current.submitDraft()
})
expect(gatewayRequest).not.toHaveBeenCalled()
expect(respond).not.toHaveBeenCalled()
expect(hasOpenServerRequest('req-runtime-session')).toBe(true)
expect($clarifyRequests.get()['runtime-session']).toBeDefined()
expect(onCancel).toHaveBeenCalledTimes(1)
})
@@ -534,7 +535,8 @@ describe('useComposerSubmit with a clarify parked on the session', () => {
})
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
expect(gatewayRequest).not.toHaveBeenCalled()
expect(respond).not.toHaveBeenCalled()
expect(hasOpenServerRequest('req-other-session')).toBe(true)
expect($clarifyRequests.get()['other-session']).toBeDefined()
})
})
@@ -1,13 +1,13 @@
import { SLASH_COMMAND_RE } from '@hermes/shared'
import { type RefObject, useLayoutEffect, useRef } from 'react'
import { usePaneVisible } from '@/components/pane-shell/pane-visibility'
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { hasClarifyRequest, skipClarifyRequest } from '@/store/clarify'
import { clearSessionDraft, type ComposerAttachment } from '@/store/composer'
import { resetBrowseState } from '@/store/composer-input-history'
import { enqueueQueuedPrompt, type QueuedPromptEntry } from '@/store/composer-queue'
import { hasMcpSetupRequest, skipMcpSetupRequest } from '@/store/mcp-setup'
import { hasConnectionRequest, skipConnectionRequest } from '@/store/connection-request'
import { hasBlockingPromptRequest } from '@/store/prompts'
import { cloneAttachments, type QueueEditState } from '../composer-utils'
@@ -234,10 +234,9 @@ export function useComposerSubmit({
void skipClarifyRequest(sessionId)
}
// Same deal for a pending MCP setup card: the agent is blocked on
// mcp.setup.respond, so a typed message declines the card and rides on.
if (payloadPresent && !queueEdit && hasMcpSetupRequest(sessionId)) {
void skipMcpSetupRequest(sessionId)
// Same for a pending connection card: typing declines every target.
if (payloadPresent && !queueEdit && hasConnectionRequest(sessionId)) {
void skipConnectionRequest(sessionId)
}
// Approval / sudo / secret prompts also park the turn inside a tool batch,
@@ -33,7 +33,11 @@ interface MicRecorderHandle {
cancel: () => void
}
function micError(error: unknown, copy: MicRecorderErrorCopy): Error {
/** Recorder + live-start mic failures → the same friendly copy: a DOMException
* name is mapped, an unrecognized DOMException falls back to the generic start
* copy, and anything else keeps its own message (non-mic failures must not be
* mislabeled as microphone problems). */
export function micError(error: unknown, copy: MicRecorderErrorCopy): Error {
const name = error instanceof DOMException ? error.name : ''
if (name === 'NotAllowedError' || name === 'SecurityError') {
@@ -52,6 +56,10 @@ function micError(error: unknown, copy: MicRecorderErrorCopy): Error {
return new Error(copy.microphoneConstraintsUnsupported)
}
if (error instanceof DOMException) {
return new Error(copy.microphoneStartFailed)
}
if (error instanceof Error) {
return error
}
@@ -1,9 +1,128 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { chunkForCommentary, toLiveHistory } from '@/lib/voice-live'
import { en } from '@/i18n/en'
import { chunkForCommentary, toLiveHistory, type VoiceLiveHandlers } from '@/lib/voice-live'
import { $notifications, clearNotifications } from '@/store/notifications'
import { delegationPrompt } from './use-voice-live-conversation'
import { delegationPrompt, useVoiceLiveConversation } from './use-voice-live-conversation'
// The live-voice toasts must not show machine strings — neither the wire close
// reasons (`connection_lost`, `closed`) nor the raw `DOMException` text a denied
// `getUserMedia` throws (#111987).
//
// The transport is the only seam in the live hook, so the fake session records
// the handlers it registers (tests drive the close/error paths directly) and
// lets `start` reject with exactly what the real `getUserMedia` would throw.
const transport = vi.hoisted(() => ({ failure: null as unknown, handlers: [] as VoiceLiveHandlers[] }))
vi.mock('@/lib/voice-live', async importOriginal => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
VoiceLiveSession: class {
close = vi.fn()
instruct = vi.fn()
setMuted = vi.fn()
speak = vi.fn()
think = vi.fn()
constructor(handlers: VoiceLiveHandlers) {
transport.handlers.push(handlers)
}
async start(): Promise<void> {
if (transport.failure) {
throw transport.failure
}
}
}
}
})
const voice = en.notifications.voice
function mountLive() {
return renderHook(() =>
useVoiceLiveConversation({
busy: false,
consumePendingResponse: vi.fn(),
enabled: true,
onSubmit: vi.fn(),
pendingResponse: () => null,
seedHistory: () => []
})
)
}
/** Mount, start, and hand back the handlers the started session registered. */
async function openSession(): Promise<VoiceLiveHandlers> {
const hook = mountLive()
await act(async () => {
await hook.result.current.start()
})
return transport.handlers.at(-1) as VoiceLiveHandlers
}
/** Start a session whose transport fails the way the real one can. */
async function failedStart(failure: unknown): Promise<void> {
transport.failure = failure
const hook = mountLive()
await act(async () => {
await hook.result.current.start()
})
}
function resetToasts() {
clearNotifications()
transport.failure = null
transport.handlers.length = 0
}
describe('Voice-live toast copy', () => {
beforeEach(resetToasts)
afterEach(cleanup)
it('names our own close reasons in copy and passes server-sent reasons through verbatim', async () => {
let handlers = await openSession()
act(() => {
handlers.onClosed('connection_lost', 127)
})
expect($notifications.get()[0].title).toBe(voice.liveEnded)
expect($notifications.get()[0].message).toBe(`${voice.liveEndedConnectionLost} (127s)`)
cleanup()
resetToasts()
handlers = await openSession()
act(() => {
handlers.onClosed('quota_exhausted', 12)
})
// Server strings are unbounded: no mapping, no redaction claim.
expect($notifications.get()[0].message).toBe('quota_exhausted (12s)')
})
it('maps a getUserMedia DOMException to the recorder mic copy and leaves non-mic failures alone', async () => {
await failedStart(new DOMException('The request is not allowed by the user agent.', 'NotAllowedError'))
expect($notifications.get()[0].title).toBe(voice.couldNotStartSession)
expect($notifications.get()[0].message).toBe(voice.microphonePermissionDenied)
cleanup()
resetToasts()
await failedStart(new Error('Missing local SDP offer'))
expect($notifications.get()[0].message).toBe('Missing local SDP offer')
})
})
describe('GPT-Live delegation → Hermes turn', () => {
it('sends the latest user words as the turn and the exchange as model-only context', () => {
@@ -6,6 +6,7 @@ import { type LiveHistoryMessage, type LiveTranscriptFragment, VoiceLiveSession
import { isVoiceStopCommand } from '@/lib/voice-stop-word'
import { notify, notifyError } from '@/store/notifications'
import { micError } from './use-mic-recorder'
import type { ConversationStatus } from './use-voice-conversation'
/** How long an accepted delegation may sit before the gateway shows the turn running. */
@@ -67,6 +68,29 @@ export function delegationPrompt(context: LiveTranscriptFragment[]): { context:
return { context: transcript, prompt: prompt || transcript.slice(-400) }
}
/**
* Body of the session-end toast. `connection_lost` and `closed` are our own
* machine reasons (`lib/voice-live.ts`) and get i18n copy; so does a blank
* reason, which has no wording of its own. Any other reason is server-sent and
* unbounded, so it passes through verbatim (issue #111987 — no redaction claim
* for vendor strings).
*/
export function liveEndedMessage(
reason: string,
usageSeconds: null | number,
copy: { liveEndedClosed: string; liveEndedConnectionLost: string }
): string {
let text = reason?.trim() ?? ''
if (text === 'connection_lost') {
text = copy.liveEndedConnectionLost
} else if (text === 'closed' || !text) {
text = copy.liveEndedClosed
}
return usageSeconds != null ? `${text} (${Math.round(usageSeconds)}s)` : text
}
/**
* GPT-Live conversation engine — same public shape as `useVoiceConversation`
* so the composer can mount either from `voice.voice_chat_mode`.
@@ -252,7 +276,7 @@ export function useVoiceLiveConversation({
if (reason !== 'close_requested') {
notify({
kind: 'warning',
message: usageSeconds != null ? `${reason} (${Math.round(usageSeconds)}s)` : reason,
message: liveEndedMessage(reason, usageSeconds, voiceCopy),
title: voiceCopy.liveEnded
})
latest.current.onFatalError?.()
@@ -330,19 +354,14 @@ export function useVoiceLiveConversation({
return
}
notifyError(error, voiceCopy.couldNotStartSession)
// Only a mic DOMException gets the recorder's copy: this catch also
// takes non-mic start failures ('GPT-Live session already started',
// 'Missing local SDP offer', API errors) — those keep their own message.
notifyError(error instanceof DOMException ? micError(error, voiceCopy) : error, voiceCopy.couldNotStartSession)
setStatus('idle')
latest.current.onFatalError?.()
}
}, [
end,
refreshStatus,
setDelegation,
voiceCopy.couldNotStartSession,
voiceCopy.liveDelegationFailed,
voiceCopy.liveEnded,
voiceCopy.liveError
])
}, [end, refreshStatus, setDelegation, voiceCopy])
// Drive the reply back into the voice: stream commentary as Hermes writes
// it (sentence-chunked), quiet tool progress as thinking appends, and clear
+75 -7
View File
@@ -16,6 +16,7 @@ import { PR_COMMENT_URL_RE } from '@/lib/chat-runtime'
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { triggerHaptic } from '@/lib/haptics'
import { useStoreSelector, useStoresSelector } from '@/lib/use-session-slice'
import { cn } from '@/lib/utils'
import { interceptsTypedVoiceStop } from '@/lib/voice-stop-word'
import { sessionCompacting } from '@/store/compaction'
@@ -24,8 +25,10 @@ import { POPOUT_WIDTH_REM } from '@/store/composer-popout'
import { parkQueuedPrompts, removeQueuedPrompt, unparkQueuedPrompts } from '@/store/composer-queue'
import { $hudMode } from '@/store/hud'
import { sessionBlockingPrompt } from '@/store/prompts'
import { toggleReview } from '@/store/review'
import { $gatewayState } from '@/store/session'
import { $threadScrolledUp } from '@/store/thread-scroll'
import { $botChatSessionIds, $sessionStates, $sessionTiles, isBotChatSession } from '@/store/session-states'
import { $threadScrolledUpBySession } from '@/store/thread-scroll'
import { $autoSpeakReplies } from '@/store/voice-prefs'
import { useTheme } from '@/themes'
@@ -34,6 +37,7 @@ import {
acceptsTriggerCompletion,
COMPOSER_FADE_BACKGROUND,
implicitSlashAcceptIndex,
liveComposerDraft,
type QueueEditState,
shouldDisableComposerInput,
slashArgStage
@@ -46,6 +50,7 @@ import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from './drop-aff
import { markActiveComposer, onComposerAttachImagesRequest } from './focus'
import { HelpHint } from './help-hint'
import { useAtCompletions } from './hooks/use-at-completions'
import { useComposerBranch } from './hooks/use-composer-branch'
import { useComposerDraft } from './hooks/use-composer-draft'
import { useComposerDrop } from './hooks/use-composer-drop'
import { useComposerEscCancel } from './hooks/use-composer-esc-cancel'
@@ -75,15 +80,22 @@ import {
normalizeComposerEditorDom,
RICH_INPUT_SLOT
} from './rich-editor'
import { useComposerScope } from './scope'
import { useComposerScope, useComposerSurfaceId } from './scope'
import { ComposerStatusStack } from './status-stack'
import { CodingStatusRow } from './status-stack/coding-row'
import { SuggestionPills } from './suggestion-pills'
import { extractClipboardImageBlobs, openDirectiveScope } from './text-utils'
import { ComposerTriggerPopover } from './trigger-popover'
import type { ChatBarProps } from './types'
import { isRedoShortcut, isUndoShortcut } from './undo-history'
import { UrlDialog } from './url-dialog'
import { chipTypedUrlOnSpace, linkifyUrls } from './url-refs'
import {
chipTypedUrlOnSpace,
linkifyUrls,
markdownLinkFor,
resolveExactLinkPaste,
selectionLinkLabel
} from './url-refs'
import { VoiceActivity, VoicePlaybackActivity } from './voice-activity'
export function ChatBar({
@@ -163,7 +175,13 @@ export function ChatBar({
const scope = useComposerScope()
const attachments = useStore(scope.attachments.$attachments)
const compacting = useStore(useMemo(() => sessionCompacting(sessionId ?? null), [sessionId]))
const scrolledUp = useStore($threadScrolledUp)
const surfaceId = useComposerSurfaceId()
const scrollSessionId = sessionId ?? surfaceId
const scrolledUp = useStoreSelector($threadScrolledUpBySession, map =>
Boolean(scrollSessionId && map[scrollSessionId])
)
const autoSpeak = useStore($autoSpeakReplies)
// The turn is parked on the user (clarify / approval / sudo / secret). Esc must
// not interrupt it — there's nothing actively running to stop, and stopping
@@ -559,6 +577,24 @@ export function ChatBar({
event.preventDefault()
// Pasting exactly one link while composer text is selected turns that text
// into a markdown link instead of replacing it — the behavior every rich
// editor ships (ported from block/buzz#6684). Selections that span chips
// or lines fall through to the normal replace-with-chip path.
const exactLink = resolveExactLinkPaste(pastedText)
if (exactLink) {
const label = selectionLinkLabel(event.currentTarget)
if (label) {
recordUndoPoint()
insertComposerContentsAtCaret(event.currentTarget, markdownLinkFor(label, exactLink))
scheduleFlushEditorToDraft(event.currentTarget)
return
}
}
// A paste past the large-paste threshold becomes a `.txt` attachment chip
// instead of flooding the composer.
// The instruction the user types stays in the input; the pasted source
@@ -817,7 +853,9 @@ export function ChatBar({
// place) then sent-message history. The history ring is derived from live
// session messages each press — single source of truth, no mirror.
if (event.key === 'ArrowUp') {
const currentDraft = draftRef.current
// Decide from the live editor: the mirror is a frame behind typing or a
// paste, and this branch can replace what the user just wrote.
const currentDraft = liveComposerDraft(editorRef.current, draftRef.current)
// Editing a queued turn → walk to the older entry.
if (queueEdit && stepQueuedEdit(-1)) {
@@ -891,7 +929,7 @@ export function ChatBar({
if (busy && !disabled) {
// As with plain Enter, source the just-typed content from the DOM so a
// fast keypress cannot queue a stale draft.
const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
const editorText = liveComposerDraft(editorRef.current, draftRef.current)
if (editorText !== draftRef.current) {
draftRef.current = editorText
@@ -913,7 +951,7 @@ export function ChatBar({
// Without the live read, a real message typed while prompts are queued
// would drain the queue instead of sending. submitDraft() re-syncs and
// sends the live editor text.
const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
const editorText = liveComposerDraft(editorRef.current, draftRef.current)
const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0
if (disabled) {
@@ -981,6 +1019,19 @@ export function ChatBar({
handleInputDrop
} = useComposerDrop({ cwd, insertInlineRefs, onAttachDroppedItems, requestMainFocus })
// A bot chat is a companion conversation, not a working session, so it has no
// repo to speak of — see the blank repoPath handed to CodingStatusRow below.
// Three stores: the scope set records the answer, and resolving this runtime
// id to the stored one it is filed under reads the other two.
const botChat = useStoresSelector([$botChatSessionIds, $sessionStates, $sessionTiles], () =>
isBotChatSession(sessionId)
)
// Branch / worktree hand-offs (CodingStatusRow). Owns the worktree open +
// branch-off/convert/list/switch actions; draft travels into the new session.
const { handleBranchOff, handleConvertBranch, handleListBranches, handleSwitchBranch, openInWorktree } =
useComposerBranch({ clearDraft, cwd, draftRef })
// Global Esc-to-cancel when the chat (not the composer input) has focus.
// Same explicit-halt semantics as the Stop button: park the queue.
useComposerEscCancel({ awaitingInput, busy, onCancel: haltRun, target: scope.target })
@@ -1341,6 +1392,23 @@ export function ChatBar({
composerSurfaceGlass
)}
/>
{!guidedChat && (
<CodingStatusRow
onBranchOff={handleBranchOff}
onConvertBranch={handleConvertBranch}
onListBranches={handleListBranches}
// A tile's rail reviews ITS worktree: pin the pane's scope to
// this surface's cwd. Main keeps the classic follow-the-
// active-session scope (null).
onOpen={() => toggleReview(scope.target === 'main' ? null : (cwd ?? null), scope.target)}
onOpenWorktree={openInWorktree}
onSwitchBranch={handleSwitchBranch}
// Blank in a bot chat: the row hides itself without a repo,
// and stops probing git / GitHub for a surface that has no
// branch to show. Cheaper than a second composer.
repoPath={botChat ? undefined : cwd}
/>
)}
<div
className={cn(
'relative z-1 flex min-h-0 w-full flex-col gap-(--composer-row-gap) overflow-hidden rounded-[inherit] px-(--composer-surface-pad-x) py-(--composer-surface-pad-y) transition-opacity duration-200 ease-out',
@@ -38,11 +38,15 @@ export function dragHasAttachments(transfer: DataTransfer | null, pathsMime: str
return false
}
if (Array.from(transfer.types || []).includes(pathsMime)) {
const types = Array.from(transfer.types || [])
// A browser link drag carries `text/uri-list` (on Windows also a virtual
// shortcut File); accept it so the link lands as an `@url:` chip.
if (types.includes(pathsMime) || types.includes('text/uri-list')) {
return true
}
if (Array.from(transfer.types || []).includes('Files')) {
if (types.includes('Files')) {
return true
}
@@ -50,6 +54,10 @@ export function dragHasAttachments(transfer: DataTransfer | null, pathsMime: str
}
export function droppedFileInlineRef(candidate: DroppedFile, cwd: string | null | undefined) {
if (candidate.url) {
return `@url:${formatRefValue(candidate.url)}`
}
if (!candidate.path) {
return null
}
@@ -158,7 +158,7 @@ describe('ModelPill per-surface model label', () => {
</SessionViewProvider>
)
expect(screen.getByText('Sonnet · High')).toBeTruthy()
expect(screen.getByText('Sonnet')).toBeTruthy()
expect(screen.queryByText(/primary/i)).toBeNull()
})
})
@@ -12,9 +12,9 @@ import { releaseTypingFocus } from '@/components/ui/keyboard-first'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { ChevronDown } from '@/lib/icons'
import { formatModelStatusLabel } from '@/lib/model-status-label'
import { formatModelPillLabel } from '@/lib/model-status-label'
import { cn } from '@/lib/utils'
import { $currentModelSource, $defaultReasoningEffort, setModelPickerOpen } from '@/store/session'
import { $currentModelSource, setModelPickerOpen } from '@/store/session'
import { onComposerModelMenuRequest } from './focus'
import { RICH_INPUT_SLOT } from './rich-editor'
@@ -57,9 +57,7 @@ export function ModelPill({
const currentModel = model.model || viewModel
const currentProvider = model.provider || viewProvider
const fastMode = useStore(view.$fast)
const reasoningEffort = useStore(view.$reasoningEffort)
const modelSource = useStore($currentModelSource)
const defaultEffort = useStore($defaultReasoningEffort)
const runtimeId = useStore(view.$runtimeId)
const [open, setOpen] = useState(false)
const restoreSelection = useRef<(() => void) | null>(null)
@@ -131,9 +129,7 @@ export function ModelPill({
) : (
<>
{currentModel.trim() ? (
<span className="truncate">
{formatModelStatusLabel(currentModel, { defaultEffort, fastMode, reasoningEffort })}
</span>
<span className="truncate">{formatModelPillLabel(currentModel, { fastMode })}</span>
) : (
<GlyphSpinner className="opacity-50" spinner="braille" />
)}
@@ -0,0 +1,81 @@
import { cleanup, render, screen } from '@testing-library/react'
import { atom } from 'nanostores'
import { afterEach, describe, expect, it } from 'vitest'
import type { ChatBarState } from '@/app/chat/composer/types'
import { type SessionView, SessionViewProvider } from '@/app/chat/session-view'
import { $defaultReasoningEffort } from '@/store/session'
import { ReasoningPill } from './reasoning-pill'
const modelState = (over: Partial<ChatBarState['model']> = {}): ChatBarState['model'] => ({
canSwitch: true,
model: 'gpt-6',
provider: 'openai',
reasoningMenuContent: <div>menu</div>,
...over
})
const tileView = (reasoningEffort: string): SessionView => ({
kind: 'tile',
$awaitingResponse: atom(false),
$busy: atom(false),
$cwd: atom(''),
$fast: atom(false),
$lastVisibleIsUser: atom(false),
$messages: atom([]),
$messagesEmpty: atom(true),
$model: atom('tile/claude-sonnet'),
$provider: atom('anthropic'),
$reasoningEffort: atom(reasoningEffort),
$runtimeId: atom('tile-runtime'),
$storedId: atom('stored-tile'),
$turnStartedAt: atom<number | null>(null)
})
afterEach(() => {
cleanup()
$defaultReasoningEffort.set('')
})
describe('ReasoningPill', () => {
it("shows THIS surface's live effort, falling back to the profile default when the session has none", () => {
$defaultReasoningEffort.set('high')
const { unmount } = render(
<SessionViewProvider value={tileView('low')}>
<ReasoningPill disabled={false} model={modelState()} />
</SessionViewProvider>
)
expect(screen.getByTestId('reasoning-pill').textContent).toBe('Low')
unmount()
render(
<SessionViewProvider value={tileView('')}>
<ReasoningPill disabled={false} model={modelState()} />
</SessionViewProvider>
)
expect(screen.getByTestId('reasoning-pill').textContent).toBe('High')
})
it('hides when the catalog says the model has no reasoning control, but not while that is unknown', () => {
const { unmount } = render(
<SessionViewProvider value={tileView('medium')}>
<ReasoningPill disabled={false} model={modelState({ supportsReasoning: false })} />
</SessionViewProvider>
)
expect(screen.queryByTestId('reasoning-pill')).toBeNull()
unmount()
render(
<SessionViewProvider value={tileView('medium')}>
<ReasoningPill disabled={false} model={modelState({ supportsReasoning: undefined })} />
</SessionViewProvider>
)
expect(screen.getByTestId('reasoning-pill')).toBeTruthy()
})
})
@@ -0,0 +1,82 @@
import { DEFAULT_REASONING_EFFORT } from '@hermes/shared'
import { useStore } from '@nanostores/react'
import { useState } from 'react'
import { useSessionView } from '@/app/chat/session-view'
import { ModelMenuCloseContext } from '@/app/shell/model-menu-panel'
import { Button } from '@/components/ui/button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { releaseTypingFocus } from '@/components/ui/keyboard-first'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { ChevronDown } from '@/lib/icons'
import { reasoningEffortLabel } from '@/lib/reasoning-effort'
import { cn } from '@/lib/utils'
import { $defaultReasoningEffort } from '@/store/session'
import type { ChatBarState } from './types'
const PILL = cn(
'h-(--composer-control-size) shrink-0 gap-1 rounded-md px-2 text-xs font-normal',
'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)
/**
* Composer reasoning selector: the active model's effort level as its own
* pill next to the model pill, opening the same Thinking / Fast / Effort rows
* the catalog offers per model — without having to find the model's row and
* hover its submenu. Hidden when the catalog says the model has no reasoning
* control, and while there is no live menu (gateway closed).
*
* Reads THIS surface's SessionView (primary or tile), like the model pill.
*/
export function ReasoningPill({ disabled, model }: { disabled: boolean; model: ChatBarState['model'] }) {
const copy = useI18n().t.shell.modelOptions
const view = useSessionView()
const reasoningEffort = useStore(view.$reasoningEffort)
const defaultEffort = useStore($defaultReasoningEffort)
const [open, setOpen] = useState(false)
if (!model.reasoningMenuContent || model.supportsReasoning === false) {
return null
}
const label = reasoningEffortLabel(reasoningEffort || defaultEffort || DEFAULT_REASONING_EFFORT)
const title = `${copy.effort}: ${label}`
// Closing the menu ends its claim on the keyboard: Radix restores focus to
// this pill (a toolbar button), so without the release the Enter that
// committed a level also swallows whatever you type next.
const setMenuOpen = (next: boolean) => {
setOpen(next)
if (!next) {
releaseTypingFocus()
}
}
return (
<DropdownMenu onOpenChange={setMenuOpen} open={open}>
<Tip label={title} side="top">
<DropdownMenuTrigger asChild>
<Button
aria-label={title}
className={PILL}
data-testid="reasoning-pill"
disabled={disabled}
type="button"
variant="ghost"
>
<span>{label}</span>
<ChevronDown className="size-2.5 shrink-0 opacity-50" />
</Button>
</DropdownMenuTrigger>
</Tip>
<DropdownMenuContent align="end" className="w-52 p-0" side="top" sideOffset={8}>
<ModelMenuCloseContext.Provider value={() => setMenuOpen(false)}>
{model.reasoningMenuContent}
</ModelMenuCloseContext.Provider>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -33,7 +33,7 @@ export function StartVoiceButton({
return (
<span className="flex items-center">
<Tip label={engine ? `${label} — ${engine}` : label}>
<Tip label={engine ? `${label} — ${engine}` : label} side="left">
<Button
aria-label={label}
className={cn(PRIMARY_ICON_BTN, engine && 'rounded-r-none')}
@@ -50,7 +50,7 @@ export function StartVoiceButton({
</Tip>
{engine ? (
<DropdownMenu>
<Tip label={t.composer.voiceEngine}>
<Tip label={t.composer.voiceEngine} side="left">
<DropdownMenuTrigger asChild>
<Button
aria-label={t.composer.voiceEngine}
@@ -0,0 +1,93 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { atom } from 'nanostores'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { $notifications, clearNotifications } from '@/store/notifications'
vi.mock('@/store/coding-status', () => ({
registerRepoStatusCwd: () => undefined,
repoStatusForCwd: () =>
atom({
added: 12,
ahead: 0,
behind: 0,
branch: 'bb/hitbox',
defaultBranch: 'main',
detached: false,
removed: 3,
untracked: 0
}),
repoWorktreesForCwd: () => atom([])
}))
const { CodingStatusRow } = await import('./coding-row')
describe('CodingStatusRow', () => {
afterEach(() => {
cleanup()
})
it('opens the review pane from the branch and the diff counts, never the bar itself', () => {
const onOpen = vi.fn()
const { container } = render(<CodingStatusRow onOpen={onOpen} repoPath="/repo" />)
const bar = container.querySelector<HTMLElement>('.coding-status-bar')
expect(bar).not.toBeNull()
fireEvent.click(bar!)
expect(onOpen).not.toHaveBeenCalled()
fireEvent.click(screen.getByText('bb/hitbox'))
expect(onOpen).toHaveBeenCalledTimes(1)
fireEvent.click(screen.getByText('12'))
expect(onOpen).toHaveBeenCalledTimes(2)
})
it('wraps the click targets without adding a layout box', () => {
const { container } = render(<CodingStatusRow onOpen={() => undefined} repoPath="/repo" />)
// `display: contents` is what keeps the branch label and the counts direct
// flex children of the row — the hit areas cost nothing visually.
expect(screen.getByText('bb/hitbox').parentElement?.classList.contains('contents')).toBe(true)
expect(screen.getByText('12').closest('button')?.classList.contains('contents')).toBe(true)
// The glyph button fills the row's existing 3.5 leading slot exactly.
expect(container.querySelector('button[class~="size-3.5"]')).not.toBeNull()
})
it('parks the copy glyph against the end of the path, not the end of the row', () => {
render(<CodingStatusRow onOpen={() => undefined} repoPath="/Users/someone/www/repo" />)
const path = screen.getByText('~/www/repo')
// The path sizes to its content and the glyph is its immediate sibling, so
// the pair reads as one unit. `flex-1` belongs to the wrapper (which holds
// the row's slack open) — on the label it stretched the text and pushed the
// glyph out to the kebab.
expect(path.classList.contains('flex-1')).toBe(false)
expect(path.parentElement?.classList.contains('flex-1')).toBe(true)
expect(path.nextElementSibling?.tagName).toBe('BUTTON')
})
it('copies the absolute cwd inline — checkmark feedback, no toast', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
clearNotifications()
render(<CodingStatusRow onOpen={() => undefined} repoPath="/Users/someone/www/repo" />)
// Painted tildified, copied raw.
expect(screen.getByText('~/www/repo')).toBeTruthy()
const copy = screen.getByRole('button', { name: 'Copy path' })
fireEvent.click(copy)
await waitFor(() => expect(writeText).toHaveBeenCalledWith('/Users/someone/www/repo'))
// Confirmation is the button turning into a checkmark, not a notification.
await waitFor(() => expect(screen.getByRole('button', { name: 'Copied' })).toBeTruthy())
expect($notifications.get()).toHaveLength(0)
})
})
@@ -0,0 +1,332 @@
import { useStore } from '@nanostores/react'
import { memo, useEffect } from 'react'
import { PrTag } from '@/app/chat/pr-tag'
import { StatusRow } from '@/components/chat/status-row'
import {
type ActionItemSpec,
ActionsContextMenu,
ActionsMenu,
type MenuKit,
renderActionItem
} from '@/components/ui/actions-menu'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { CopyButton } from '@/components/ui/copy-button'
import { DiffCount } from '@/components/ui/diff-count'
import type { HermesGitBranch } from '@/global'
import { useI18n } from '@/i18n'
import { displayPath } from '@/lib/display-path'
import { openWorktreeDialog, registerRepoStatusCwd, repoStatusForCwd, repoWorktreesForCwd } from '@/store/coding-status'
import { notifyError } from '@/store/notifications'
import { $pullRequestsByBranch, branchPrKey, refreshPullRequests } from '@/store/pull-requests'
// Tiny uppercase section header, matching the composer "+" menu's labels.
const MENU_SECTION = 'text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary)'
interface CodingStatusRowProps {
/** Branch the current draft off into a fresh worktree + session, based on
* `base` (a branch name; omitted = current HEAD). The composer owns the
* draft, so it supplies the orchestration; the row just collects the new
* branch name + base. Omitted (e.g. remote backend) hides the affordance. */
onBranchOff?: (branch: string, base?: string) => Promise<void>
/** Check an existing branch out into a fresh worktree + session (no new
* branch). Drives the dialog's "convert a branch" picker. */
onConvertBranch?: (branch: string, path?: null | string, isDefault?: boolean) => Promise<void>
/** List the repo's local branches for the "convert a branch" picker. */
onListBranches?: () => Promise<HermesGitBranch[]>
/** Open the review pane (changed files + diffs). */
onOpen?: () => void
/** Jump into an existing worktree (open a fresh session anchored there). */
onOpenWorktree?: (path: string) => void
/** Switch the current repo checkout to another branch. */
onSwitchBranch?: (branch: string) => Promise<void>
/** Repo root path for the worktree dialog. */
repoPath?: null | string
}
/**
* The always-on coding-context row, the BASE of the composer status stack:
* current branch, dirty summary (+/-), and ahead/behind. A touch more prominent
* than the per-turn rows above it (larger branch label, accent glyph), and the
* entry point to the review pane. Hidden when the active session isn't in a
* local git repo (the probe returns null).
*/
export const CodingStatusRow = memo(function CodingStatusRow({
onBranchOff,
onConvertBranch,
onListBranches,
onOpen,
onOpenWorktree,
onSwitchBranch,
repoPath
}: CodingStatusRowProps) {
const { t } = useI18n()
const s = t.statusStack.coding
const p = t.sidebar.projects
const fileMenu = t.fileMenu
const resolvedRepoPath = repoPath?.trim() || undefined
// This surface's OWN worktree, always — never the primary's. The row used to
// fall back to the global `$repoStatus` for a blank repoPath, which painted
// the main pane's branch/± onto a tile whose cwd hadn't resolved yet. That
// fallback bought nothing (the primary's computed is keyed to `$currentCwd`,
// which is blank in exactly the same case) and cost a wrong-tree rail.
const status = useStore(repoStatusForCwd(resolvedRepoPath))
const worktrees = useStore(repoWorktreesForCwd(resolvedRepoPath))
// While mounted, keep this worktree in the coding-status refresh set so the
// turn-settle / tool-complete / focus edges re-probe it too (tiles otherwise
// only refreshed when the MAIN cwd probe happened to cover them).
useEffect(() => registerRepoStatusCwd(resolvedRepoPath), [resolvedRepoPath])
// The branch's PR, so the rail links to it instead of leaving you to go find
// it. One `gh` lookup for this one branch, TTL-cached in the store and shared
// with the sidebar's badges.
const prBranch = status?.detached ? null : status?.branch || null
useEffect(() => {
if (resolvedRepoPath && prBranch) {
void refreshPullRequests({ [resolvedRepoPath]: [prBranch] })
}
}, [resolvedRepoPath, prBranch])
const pr =
useStore($pullRequestsByBranch)[resolvedRepoPath && prBranch ? branchPrKey(resolvedRepoPath, prBranch) : '']
const switchToBranch = async (branch: string) => {
if (!onSwitchBranch) {
return
}
try {
await onSwitchBranch(branch)
} catch (err) {
notifyError(err, s.switchFailed(branch))
}
}
// useKeybinds now handles the ⌘⇧B hotkey globally, through
// openWorktreeDialog. One dialog is mounted in the sidebar, so N mounted
// rails can no longer each open their own copy. The menu items below only
// publish the intent. They pin the repo of THIS rail, so the kebab of a tile
// targets the worktree of that tile.
const startBranch = (base: string | undefined) => {
void openWorktreeDialog({ base, repoPath: resolvedRepoPath })
}
if (!status) {
return null
}
const branchLabel = status.detached ? s.detached : status.branch || s.noBranch
// The kebab offers branching off the trunk and/or the current branch. The
// worktree-add bases the new branch on `base` (a branch name; undefined =
// current HEAD). We dedupe so "on main" shows a single trunk entry, and fall
// back to a plain off-HEAD branch when no trunk is detected.
const current = status.detached ? null : status.branch
const branchTargets: { base: string | undefined; label: string }[] = []
// Current branch first (the 99% "branch off where I am"), then the trunk just
// below it ("New branch from main"), deduped when they're the same.
if (current) {
branchTargets.push({ base: current, label: s.branchOffFrom(current) })
}
if (status.defaultBranch && status.defaultBranch !== current) {
branchTargets.push({ base: status.defaultBranch, label: s.branchOffFrom(status.defaultBranch) })
}
if (branchTargets.length === 0) {
branchTargets.push({ base: undefined, label: s.newBranch })
}
const switchTarget =
onSwitchBranch && current && status.defaultBranch && status.defaultBranch !== current ? status.defaultBranch : null
// Other worktrees to jump into — everything except the one we're already in
// (matched by its checked-out branch) and the bare/main placeholder entry.
const otherWorktrees = onOpenWorktree
? worktrees.filter(w => w.path && !w.detached && w.branch && w.branch !== current)
: []
const hasLineDelta = status.added > 0 || status.removed > 0
// Untracked files carry no line delta vs HEAD, so surface them as a count when
// they're the only change (otherwise +/- tells the story).
const untrackedOnly = !hasLineDelta && status.untracked > 0
// The branch actions, rendered identically by the kebab dropdown and the
// row's right-click menu so the two never drift. `onBranchOff` gates the
// whole menu (omitted = remote backend), matching the kebab.
const renderBranchItems = (kit: MenuKit) => {
const branchItems: ActionItemSpec[] = branchTargets.map(target => ({
key: target.base ?? '__head__',
label: <span className="truncate">{target.label}</span>,
onSelect: () => startBranch(target.base)
}))
const worktreeItems: ActionItemSpec[] = otherWorktrees.map(worktree => ({
key: worktree.path,
label: <span className="truncate">{worktree.branch}</span>,
onSelect: () => onOpenWorktree?.(worktree.path)
}))
return (
<>
<kit.Label className={MENU_SECTION}>{s.newBranch}</kit.Label>
{branchItems.map(item => renderActionItem(kit, item))}
{switchTarget &&
renderActionItem(kit, {
key: '__switch__',
label: <span className="truncate">{s.switchTo(switchTarget)}</span>,
onSelect: () => void switchToBranch(switchTarget)
})}
<kit.Separator />
<kit.Label className={MENU_SECTION}>{s.worktrees}</kit.Label>
{worktreeItems.map(item => renderActionItem(kit, item))}
{/* Create a fresh worktree off the current HEAD (the generic "spin up a
worktree here", mirroring the sidebar's + button). */}
{renderActionItem(kit, {
key: '__start__',
label: <span className="truncate">{p.startWork}</span>,
onSelect: () => startBranch(undefined)
})}
{onConvertBranch &&
renderActionItem(kit, {
key: '__convert__',
label: <span className="truncate">{p.convertBranch}</span>,
onSelect: () => startBranch(undefined)
})}
</>
)
}
return (
<>
<ActionsContextMenu contentClassName="w-60" disabled={!onBranchOff} items={renderBranchItems}>
<StatusRow
// The base "where am I working" strip is part of the composer surface
// itself, so it inherits the composer's width and clipped top radius.
className="coding-status-bar min-h-7 rounded-t-[inherit] rounded-b-none border-b border-(--ui-stroke-tertiary) px-3.5 py-1.5 hover:bg-transparent"
// Static branch glyph — never the loading spinner. This row only renders
// once `status` exists, so a spinner here only ever fired on *refreshes*
// of an already-loaded repo (window focus, turn settle), reading as an
// annoying icon "blip" with no first-load value. Refreshes are silent.
// It's a button (not the whole row) so the glyph opens the review pane
// while the strip around it stays inert; size-3.5 fills the slot exactly.
leading={
<button className="flex size-3.5 items-center justify-center" onClick={onOpen} type="button">
<Codicon className="text-(--ui-green)" name="git-branch" size="0.8rem" />
</button>
}
>
<div className="flex min-w-0 flex-1 items-center gap-1">
{/* PR number first, right against the leading git glyph — the chip
borrows that icon instead of carrying a second one of its own
(`showIcon={false}`), so the row reads glyph → #number → branch. */}
{pr && <PrTag pr={pr} showIcon={false} />}
{/* Branch name — the other half of the review-pane target. `contents`
so the button lays out nothing of its own: the label stays the
same flex child it always was, and the hit area is the text. */}
<button className="contents" onClick={onOpen} type="button">
<span className="min-w-0 truncate text-xs font-normal text-muted-foreground/92" title={branchLabel}>
{branchLabel}
</span>
</button>
{/* Worktree path + copy — plain muted text, not a chip. Always in the
flex so hover doesn't reflow the row; opacity alone reveals the
pair. The path sizes to its content (the `flex-1` lives on the
wrapper) so the glyph sits against the end of the text instead of
drifting to the far edge of the row. `displayPath` collapses
home → ~; the copy still takes the real absolute path, and it's
the shared `CopyButton` so it confirms with the same inline
checkmark as every other copy in the app. */}
{resolvedRepoPath && (
<div className="flex min-w-0 flex-1 items-center gap-0.5 opacity-0 transition-opacity group-hover/status-row:opacity-100 group-focus-within/status-row:opacity-100">
<span
className="min-w-0 truncate font-mono text-[0.62rem] leading-4 text-muted-foreground/50"
data-slot="coding-status-cwd"
>
{displayPath(resolvedRepoPath)}
</span>
<CopyButton
appearance="icon"
buttonSize="icon-xs"
className="pointer-events-none size-4 shrink-0 text-muted-foreground/50 hover:text-foreground group-hover/status-row:pointer-events-auto group-focus-within/status-row:pointer-events-auto"
iconClassName="size-3"
label={fileMenu.copyPath}
side="top"
stopPropagation
text={resolvedRepoPath}
/>
</div>
)}
{/* Branch actions kebab — same pattern as the session/worktree rows.
ALWAYS laid out; only its opacity flips on hover/focus/open, so
revealing it never reflows the row (no layout shift). pointer-events
follow opacity so the invisible trigger isn't clickable at rest. */}
{onBranchOff && (
<ActionsMenu
align="end"
contentClassName="w-60"
// The row sits at the bottom of the screen (above the composer),
// so the menu opens upward.
items={renderBranchItems}
side="top"
>
<Button
aria-label={s.newBranch}
className="pointer-events-none size-4 shrink-0 text-muted-foreground/60 opacity-0 transition hover:text-foreground group-hover/status-row:pointer-events-auto group-hover/status-row:opacity-100 group-focus-within/status-row:pointer-events-auto group-focus-within/status-row:opacity-100 data-[state=open]:pointer-events-auto data-[state=open]:opacity-100"
size="icon-xs"
variant="ghost"
>
<Codicon name="kebab-vertical" size="0.8rem" />
</Button>
</ActionsMenu>
)}
</div>
{/* The counts describe what's in the review pane, so clicking them
opens it. `contents` again: the two spans stay direct flex children
of the row, keeping their gap and `ml-auto` behaviour untouched. */}
{(status.ahead > 0 || status.behind > 0 || hasLineDelta || untrackedOnly) && (
<button className="contents" onClick={onOpen} type="button">
{(status.ahead > 0 || status.behind > 0) && (
<span className="ml-auto flex shrink-0 items-center gap-1.5 text-[0.68rem] leading-4 text-muted-foreground/75 tabular-nums">
{status.ahead > 0 && (
<span className="flex items-center gap-0.5" title={s.ahead(status.ahead)}>
<span aria-hidden>↑</span>
{status.ahead}
</span>
)}
{status.behind > 0 && (
<span className="flex items-center gap-0.5" title={s.behind(status.behind)}>
<span aria-hidden>↓</span>
{status.behind}
</span>
)}
</span>
)}
{hasLineDelta ? (
<DiffCount
added={status.added}
className={`text-[0.72rem] leading-4 ${status.ahead === 0 && status.behind === 0 ? 'ml-auto' : ''}`}
removed={status.removed}
/>
) : untrackedOnly ? (
<span
className={`shrink-0 text-[0.72rem] leading-4 text-amber-500/90 ${status.ahead === 0 && status.behind === 0 ? 'ml-auto' : ''}`}
>
{s.changed(status.untracked)}
</span>
) : null}
</button>
)}
</StatusRow>
</ActionsContextMenu>
</>
)
})
@@ -3,7 +3,7 @@ import { MemoryRouter } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { I18nProvider } from '@/i18n'
import { $threadScrolledUp, resetThreadScroll } from '@/store/thread-scroll'
import { resetThreadScroll, setThreadAtBottom } from '@/store/thread-scroll'
import { ComposerStatusStack } from './index'
@@ -17,19 +17,20 @@ vi.stubGlobal('ResizeObserver', TestResizeObserver)
describe('ComposerStatusStack scroll treatment', () => {
beforeEach(() => {
$threadScrolledUp.set(true)
setThreadAtBottom(false, 'sess-a')
})
afterEach(() => {
cleanup()
resetThreadScroll()
resetThreadScroll('sess-a')
})
it('dims only the status content while keeping the dock card opaque', () => {
const view = render(
<MemoryRouter>
<I18nProvider configClient={null} initialLocale="en">
<ComposerStatusStack queue={<div>Queued task</div>} sessionId={null} />
<ComposerStatusStack queue={<div>Queued task</div>} sessionId="sess-a" />
<ComposerStatusStack queue={<div>Sibling task</div>} sessionId="sess-b" />
</I18nProvider>
</MemoryRouter>
)
@@ -42,5 +43,6 @@ describe('ComposerStatusStack scroll treatment', () => {
expect(dimmedContent).not.toBeNull()
expect(dimmedContent).not.toBe(card)
expect(card?.contains(dimmedContent)).toBe(true)
expect(screen.getByText('Sibling task').closest('.opacity-30')).toBeNull()
})
})
@@ -3,6 +3,7 @@ import { type ReactNode, useEffect, useMemo } from 'react'
import { useNavigate } from 'react-router'
import { blurComposerInput } from '@/app/chat/composer/focus'
import { useComposerSurfaceId } from '@/app/chat/composer/scope'
import { AGENTS_ROUTE } from '@/app/routes'
import type { SubmitTextOptions } from '@/app/session/hooks/use-prompt-actions/utils'
import { BillingBanner } from '@/components/billing-banner'
@@ -15,7 +16,7 @@ import { Codicon } from '@/components/ui/codicon'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { Tip, TipKeybindLabel } from '@/components/ui/tooltip'
import { type Translations, useI18n } from '@/i18n'
import { useSessionSlice } from '@/lib/use-session-slice'
import { useSessionSlice, useStoreSelector } from '@/lib/use-session-slice'
import { cn } from '@/lib/utils'
import { $billingBlock } from '@/store/billing-block'
import {
@@ -30,7 +31,7 @@ import {
import { $freeTierRoute, $freeTierStatus, freeTierStripPending } from '@/store/free-tier'
import { $previewStatusBySession, dismissPreviewArtifact } from '@/store/preview-status'
import { $sessionControlBySession, refreshSessionControl } from '@/store/session-control'
import { $threadScrolledUp } from '@/store/thread-scroll'
import { $threadScrolledUpBySession } from '@/store/thread-scroll'
import { openSessionInNewWindow } from '@/store/windows'
import { PreviewStatusRow } from './preview-row'
@@ -107,7 +108,13 @@ export function ComposerStatusStack({ onSubmit, queue, sessionId }: ComposerStat
const previews = useSessionSlice($previewStatusBySession, sessionId)
const controlEntry = useSessionValue($sessionControlBySession, sessionId)
const scrolledUp = useStore($threadScrolledUp)
const surfaceId = useComposerSurfaceId()
const scrollSessionId = sessionId ?? surfaceId
const scrolledUp = useStoreSelector($threadScrolledUpBySession, map =>
Boolean(scrollSessionId && map[scrollSessionId])
)
const billing = useStore($billingBlock)
const freeTierStatus = useStore($freeTierStatus)
const freeTierRoute = useStore($freeTierRoute)
@@ -26,6 +26,11 @@ export interface ChatBarState {
quickModels?: QuickModelOption[]
/** Reused status-bar dropdown (built with gateway + selectModel upstream). */
modelMenuContent?: ReactNode
/** The reasoning pill's dropdown (same host + controller as the model menu). */
reasoningMenuContent?: ReactNode
/** False when the catalog says the active model has no reasoning control;
* undefined while unknown (loading) so the pill stays put. */
supportsReasoning?: boolean
}
tools: { enabled: boolean; label: string; suggestions?: ContextSuggestion[] }
voice: { enabled: boolean; active: boolean }
@@ -1,8 +1,14 @@
import type { KeyboardEvent } from 'react'
import { describe, expect, it } from 'vitest'
import { composerPlainText, RICH_INPUT_SLOT } from './rich-editor'
import { chipTypedUrlOnSpace, linkifyUrls } from './url-refs'
import { composerPlainText, refChipElement, RICH_INPUT_SLOT } from './rich-editor'
import {
chipTypedUrlOnSpace,
linkifyUrls,
markdownLinkFor,
resolveExactLinkPaste,
selectionLinkLabel
} from './url-refs'
/** An editor holding `text` with a collapsed caret at `caret`, plus the space
* keydown the composer would hand `chipTypedUrlOnSpace`. */
@@ -51,6 +57,95 @@ describe('linkifyUrls', () => {
})
})
describe('resolveExactLinkPaste', () => {
it('accepts a lone bare link', () => {
expect(resolveExactLinkPaste('https://example.dev/a/b')).toBe('https://example.dev/a/b')
})
it('accepts a wrapped <link> and surrounding whitespace', () => {
expect(resolveExactLinkPaste(' <https://example.dev/a> ')).toBe('https://example.dev/a')
})
it('rejects prose around the link', () => {
expect(resolveExactLinkPaste('see https://example.dev')).toBeNull()
expect(resolveExactLinkPaste('https://example.dev is nice')).toBeNull()
})
it('rejects multiple links', () => {
expect(resolveExactLinkPaste('https://a.dev https://b.dev')).toBeNull()
})
it('rejects trailing sentence punctuation and hostless schemes', () => {
expect(resolveExactLinkPaste('https://example.dev.')).toBeNull()
expect(resolveExactLinkPaste('https://')).toBeNull()
})
})
describe('selectionLinkLabel', () => {
const selectAll = (build: (editor: HTMLElement) => void) => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
build(editor)
document.body.append(editor)
const selection = window.getSelection()!
const range = document.createRange()
range.selectNodeContents(editor)
selection.removeAllRanges()
selection.addRange(range)
return editor
}
it('returns the selected text', () => {
const editor = selectAll(node => {
node.textContent = 'the docs'
})
expect(selectionLinkLabel(editor)).toBe('the docs')
editor.remove()
})
it('rejects a collapsed selection', () => {
const editor = document.createElement('div')
editor.textContent = 'text'
document.body.append(editor)
window.getSelection()?.removeAllRanges()
expect(selectionLinkLabel(editor)).toBeNull()
editor.remove()
})
it('rejects a selection containing a chip', () => {
const editor = selectAll(node => {
node.append(document.createTextNode('see '), refChipElement('url', '`https://a.dev`'))
})
expect(selectionLinkLabel(editor)).toBeNull()
editor.remove()
})
it('rejects a multi-line selection', () => {
const editor = selectAll(node => {
node.append(document.createTextNode('one'), document.createElement('br'), document.createTextNode('two'))
})
expect(selectionLinkLabel(editor)).toBeNull()
editor.remove()
})
})
describe('markdownLinkFor', () => {
it('builds a markdown link', () => {
expect(markdownLinkFor('the docs', 'https://example.dev')).toBe('[the docs](https://example.dev)')
})
it('escapes square brackets in the label', () => {
expect(markdownLinkFor('a [b] c', 'https://example.dev')).toBe('[a \\[b\\] c](https://example.dev)')
})
})
describe('chipTypedUrlOnSpace', () => {
it('chips a link typed right before the caret and adds the space', () => {
const { editor, event } = spaceOn('see https://example.dev/a', 25)
@@ -59,6 +59,71 @@ export function linkifyUrls(text: string) {
return out + text.slice(cursor)
}
/** The href to apply when a clipboard payload is exactly ONE supported link —
* a bare or `<…>`-wrapped `http(s)` URL with a host and nothing else. Null for
* anything that isn't a lone link (prose, multiple links, trailing text), so
* callers fall through to the normal paste pipeline. Ported from
* block/buzz#6684's `resolveExactLinkPaste`. */
export function resolveExactLinkPaste(raw: string): string | null {
const text = raw.trim()
const unwrapped = text.startsWith('<') && text.endsWith('>') && text.length > 2 ? text.slice(1, -1).trim() : text
URL_RE.lastIndex = 0
const match = URL_RE.exec(unwrapped)
if (!match || match.index !== 0 || match[0].length !== unwrapped.length) {
return null
}
const { trailing, url } = splitUrlTail(unwrapped)
// Trailing sentence punctuation means the user copied prose, not a link.
if (trailing || !hasHost(url)) {
return null
}
return url
}
/** The selected composer text a link paste should hyperlink, or null when the
* selection can't take a link mark: collapsed, outside `editor`, spanning
* chips or line breaks, or whitespace-only. */
export function selectionLinkLabel(editor: HTMLElement): string | null {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
return null
}
const range = selection.getRangeAt(0)
if (!editor.contains(range.commonAncestorContainer)) {
return null
}
const probe = document.createElement('div')
probe.append(range.cloneContents())
// A chip inside the selection is a directive, not prose — linking over it
// would destroy the reference. Multi-line selections don't read as a label.
if (probe.querySelector('[data-ref-text], br')) {
return null
}
const label = probe.textContent?.replace(/\s+/g, ' ').trim() ?? ''
return label || null
}
/** Markdown link for a paste-over-selection: the label the user selected, the
* URL they pasted. Square brackets in the label are escaped so the link
* survives markdown parsing downstream. */
export function markdownLinkFor(label: string, url: string): string {
return `[${label.replace(/([[\]])/g, '\\$1')}](${url})`
}
/** A plain space finishing a typed link commits it as a chip (followed by
* whatever punctuation ended it, then the space). Returns whether it ran, so a
* keydown handler can fall through on anything else. */
@@ -0,0 +1,110 @@
import { useStore } from '@nanostores/react'
import { useCallback, useMemo } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { FanMenu, type FanMenuItem } from '@/components/ui/fan-menu'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Ear, EarOff, iconSize, Loader2, Square, Volume2, VolumeX } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $wakeWord, toggleWakeWord } from '@/store/wake-word'
import { ACTIVE_ICON_BTN, GHOST_ICON_BTN } from './control-classes'
import type { ChatBarState, VoiceStatus } from './types'
export interface VoiceFanProps {
autoSpeak: boolean
disabled: boolean
state: ChatBarState
voiceStatus: VoiceStatus
onDictate: () => void
onToggleAutoSpeak: () => void
}
/**
* The voice toggles behind one hub. The mic is the button in the row; hovering
* it fans the other two — spoken replies and the wake word — out of it.
* Starting a conversation stays on the primary button beside it.
*
* The hub is the mic and reports dictation only (recording, transcribing);
* each disc carries its own on-state.
*
* Items are memoized on the handful of state bits they read, so the fan only
* re-renders when a toggle actually flips — not on every composer keystroke.
*/
export function VoiceFan({ autoSpeak, disabled, state, voiceStatus, onDictate, onToggleAutoSpeak }: VoiceFanProps) {
const { t } = useI18n()
const c = t.composer
const wake = useStore($wakeWord)
const phrase = wake.phrase || 'hey hermes'
const dictating = state.voice.active || voiceStatus !== 'idle'
const wakeListening = wake.listening
const wakePending = wake.pending
const dictationLabel =
voiceStatus === 'recording'
? c.stopDictation
: voiceStatus === 'transcribing'
? c.transcribingDictation
: c.voiceDictation
const hubLabel = dictating ? dictationLabel : c.voiceDictation
const dictate = useCallback(() => {
triggerHaptic(dictating ? 'close' : 'open')
onDictate()
}, [dictating, onDictate])
// The hub is the mic and only dictation lights it. The wake word has its own
// disc with its own on-state; mirroring it here read as dictation being on.
const hub = useMemo(
() => ({
id: 'dictate',
active: dictating,
className: cn(GHOST_ICON_BTN, 'rounded-full p-0', dictating && ACTIVE_ICON_BTN),
disabled: disabled || !state.voice.enabled || voiceStatus === 'transcribing',
icon:
voiceStatus === 'recording' ? (
<Square className={cn('fill-current', iconSize.xs)} />
) : voiceStatus === 'transcribing' ? (
<Loader2 className={cn('animate-spin', iconSize.sm)} />
) : (
<Codicon name="mic" size="0.875rem" />
),
label: hubLabel,
onSelect: dictate
}),
[dictate, dictating, disabled, hubLabel, state.voice.enabled, voiceStatus]
)
const items = useMemo<FanMenuItem[]>(
() => [
{
id: 'speak',
active: autoSpeak,
disabled,
icon: autoSpeak ? <Volume2 className={iconSize.sm} /> : <VolumeX className={iconSize.sm} />,
label: autoSpeak ? c.stopSpeakingReplies : c.speakReplies,
onSelect: () => {
triggerHaptic(autoSpeak ? 'close' : 'open')
onToggleAutoSpeak()
}
},
{
id: 'wake',
active: wakeListening,
disabled: disabled || wakePending,
icon: wakeListening ? <Ear className={iconSize.sm} /> : <EarOff className={iconSize.sm} />,
label: c.wakeWord(phrase),
onSelect: () => {
triggerHaptic(wakeListening ? 'close' : 'open')
void toggleWakeWord()
}
}
],
[autoSpeak, c, disabled, onToggleAutoSpeak, phrase, wakeListening, wakePending]
)
return <FanMenu direction="vertical" hub={hub} items={items} label={c.voiceControls} />
}
@@ -79,7 +79,7 @@ export function VoiceMenu({
return (
<DropdownMenu>
<Tip label={wake.notice && !dictating ? `${triggerLabel} — ${wake.notice}` : triggerLabel}>
<Tip label={wake.notice && !dictating ? `${triggerLabel} — ${wake.notice}` : triggerLabel} side="left">
<DropdownMenuTrigger asChild>
<Button
aria-label={triggerLabel}
@@ -4,6 +4,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { $composerAttachments, type ComposerAttachment, updateComposerAttachment } from '@/store/composer'
import { $connection } from '@/store/session'
import { droppedFileInlineRefs } from '../composer/inline-refs'
import {
attachmentPreviewDataUrl,
type DroppedFile,
@@ -87,9 +89,14 @@ interface StubEntry {
isDirectory: boolean
}
function stubTransfer(entries: StubEntry[], internalRaw = ''): DataTransfer & { _pathByFile: Map<File, string> } {
function stubTransfer(
entries: StubEntry[],
internalRaw = '',
uriList = ''
): DataTransfer & { _pathByFile: Map<File, string> } {
const files = entries.map(entry => new File(['x'], entry.path.split('/').pop() || 'f'))
const pathByFile = new Map(files.map((file, i) => [file, entries[i].path]))
// A virtual shortcut File (browser link drag on Windows) has a name but no path.
const pathByFile = new Map(files.map((file, i) => [file, entries[i].path.includes('/') ? entries[i].path : '']))
const items: Record<number | string, unknown> = { length: entries.length }
entries.forEach((entry, i) => {
@@ -101,7 +108,7 @@ function stubTransfer(entries: StubEntry[], internalRaw = ''): DataTransfer & {
})
return {
getData: (mime: string) => (mime === HERMES_PATHS_MIME ? internalRaw : ''),
getData: (mime: string) => (mime === HERMES_PATHS_MIME ? internalRaw : mime === 'text/uri-list' ? uriList : ''),
files: {
length: files.length,
item: (i: number) => files[i] ?? null
@@ -174,6 +181,41 @@ describe('extractDroppedFiles', () => {
expect(osDrops.map(entry => entry.path)).toEqual(['/abs/notes.txt'])
})
it('turns a browser link drag into an @url chip instead of failing on the virtual .url stub', () => {
// Dragging a link out of a browser on Windows lands as `text/uri-list` plus a
// path-less `<title>.url` shortcut File. That stub used to reach the upload
// pipeline and toast "Could not attach agent-wiki.url".
const transfer = stubTransfer(
[{ path: 'agent-wiki.url', isDirectory: false }],
'',
'https://example.com/wiki/agent?x=1\r\n'
) as DataTransfer & { _pathByFile: Map<File, string> }
stubBridge(transfer)
const result = extractDroppedFiles(transfer)
expect(result).toEqual([{ path: '', url: 'https://example.com/wiki/agent?x=1' }])
expect(partitionDroppedFiles(result).osDrops).toEqual([])
expect(droppedFileInlineRefs(result, '/w')).toEqual(['@url:https://example.com/wiki/agent?x=1'])
})
it('keeps a path-less image dragged off a web page as an upload, not a link chip', () => {
const transfer = stubTransfer(
[{ path: 'logo.png', isDirectory: false }],
'',
'https://example.com/logo.png'
) as DataTransfer & { _pathByFile: Map<File, string> }
stubBridge(transfer)
const result = extractDroppedFiles(transfer)
expect(result).toHaveLength(1)
expect(result[0]?.file).toBeInstanceOf(File)
expect(result[0]?.url).toBeUndefined()
})
it('does not duplicate a folder that appears in both items and files', () => {
// Chromium lists a dropped folder in transfer.files too (as a size-0 File);
// the items pass claims its path first so the files fallback skips it.
@@ -90,6 +90,8 @@ export interface DroppedFile {
line?: number
/** Last line number for line-range drags (`line..lineEnd` inclusive). */
lineEnd?: number
/** A link dragged out of a browser (`text/uri-list`). Path-less; becomes an `@url:` chip. */
url?: string
}
/** MIME emitted by in-app drag sources (project tree, gutter line numbers).
@@ -110,6 +112,7 @@ export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] {
const seenPaths = new Set<string>()
const seenFiles = new Set<File>()
const getPath = window.hermesDesktop?.getPathForFile
const urls = droppedLinkUrls(transfer)
// In-app drags first — they carry richer metadata (isDirectory) than the
// File-based fallback can provide, and produce no overlapping native files.
@@ -169,6 +172,20 @@ export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] {
}
}
// A link dragged out of a browser rides along as a virtual shortcut File
// (`<title>.url` on Windows, `.webloc` on macOS) with no on-disk path. It
// is the same link `text/uri-list` already carries, so drop the stub and
// let the URL become a chip instead of toasting "Could not attach X.url".
// A path-less *image* (dragged off a web page) keeps its bytes and wins
// over the link to its own src.
if (!path && urls.length) {
if (isImagePath(file.name) || file.type.startsWith('image/')) {
urls.length = 0
} else {
return
}
}
if (path && seenPaths.has(path)) {
return
}
@@ -238,9 +255,37 @@ export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] {
}
}
for (const url of urls) {
result.push({ path: '', url })
}
return result
}
/** `http(s)` links from a `text/uri-list` payload (one per line, `#` comments
* skipped), deduped. Empty when the drag carried none. */
function droppedLinkUrls(transfer: DataTransfer): string[] {
let raw = ''
try {
raw = transfer.getData('text/uri-list') || ''
} catch {
return []
}
const urls: string[] = []
for (const line of raw.split(/\r?\n/)) {
const url = line.trim()
if (/^https?:\/\/[^/\s]/i.test(url) && !urls.includes(url)) {
urls.push(url)
}
}
return urls
}
/**
* Split dropped entries by origin. OS/Finder drops carry a native `File`
* handle; in-app drags (project tree, gutter line refs) are path-only.
+1
View File
@@ -43,6 +43,7 @@ vi.mock('@/components/Backdrop', async () => {
vi.mock('@/components/prompt-overlays', () => ({ PromptOverlays: () => null }))
vi.mock('@/components/chat/vibe-hearts', () => ({ COMPOSER_HEART_CONFIG: {}, HeartField: () => null }))
vi.mock('@/lib/model-options', () => ({
currentModelCapabilities: () => undefined,
modelOptionsQueryKey: (...parts: unknown[]) => ['model-options', ...parts],
requestModelOptions: vi.fn(async () => ({ models: [] }))
}))
+82 -38
View File
@@ -1,4 +1,5 @@
import { type AppendMessage, AssistantRuntimeProvider, type ThreadMessage } from '@assistant-ui/react'
import type { ModelOptionsResult } from '@hermes/shared'
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import type { ReadableAtom } from 'nanostores'
@@ -23,7 +24,7 @@ import { useI18n } from '@/i18n'
import type { ChatMessage } from '@/lib/chat-messages'
import { NEW_SESSION_TITLE, quickModelOptions, sessionTitle } from '@/lib/chat-runtime'
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options'
import { currentModelCapabilities, modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options'
import { useStoreSelector } from '@/lib/use-session-slice'
import { cn } from '@/lib/utils'
import { migrateSessionDraft } from '@/store/composer'
@@ -48,10 +49,9 @@ import {
sessionPinId,
shouldMigrateComposerScope
} from '@/store/session'
import { $focusedStoredSessionId, sessionTileDelegate } from '@/store/session-states'
import { $focusedStoredSessionId, $sessionStates, sessionTileDelegate } from '@/store/session-states'
import { $transcriptTailBySessionId, transcriptTailState } from '@/store/transcript-tail'
import { isAuxiliaryWindow, isWatchWindow } from '@/store/windows'
import type { ModelOptionsResponse } from '@/types/hermes'
import { primaryRouteSelectedSessionId, routeSessionId } from '../routes'
import { titlebarHeaderBaseClass, titlebarHeaderShadowClass, titlebarHeaderTitleClass } from '../shell/titlebar'
@@ -86,6 +86,7 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
modelOptionsOwnerConnectionId?: string
modelOptionsProfile?: string
modelMenuContent?: React.ReactNode
reasoningMenuContent?: React.ReactNode
requestModelOptionsForOwner?: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
onToggleSelectedPin: () => void
onDeleteSelectedSession: () => void
@@ -235,7 +236,7 @@ function useMessagesWhileVisible($messages: ReadableAtom<ChatMessage[]>): ChatMe
* of re-rendering them by element identity and the stream's render cost stays
* confined to the streaming message's own subtree.
*/
function ChatRuntimeBoundary({
export function ChatRuntimeBoundary({
busy,
children,
onCancel,
@@ -307,38 +308,54 @@ function ChatRuntimeBoundary({
const tailState = storedId && transcriptTailStates ? transcriptTailState(storedId, tailProfile) : undefined
const restBackfillAvailable = Boolean(tailState?.possiblyTruncated)
const expandWindow = useCallback(() => {
// The store window still holds older messages: growing pages is enough.
// Otherwise the whole in-memory transcript is already materialized — if
// the REST tail hydration was truncated, fetch the next older page and
// PREPEND it to the session store before growing, so the grown window has
// something older to show. Fire-and-forget: the prepend lands through the
// session-state write path and re-renders this boundary.
if (
!windowStateRef.current.get(runtimeIdRef.current ?? '')?.state.window.windowed &&
runtimeId &&
storedId &&
transcriptBackfillAvailable(storedId, tailProfile)
) {
void backfillOlderTranscriptPage({
storedSessionId: storedId,
profile: tailProfile,
// Stale-response guard: a session switch remounts/re-keys this view;
// checking the live atoms (not captured props) discards a page that
// resolves after the user moved on — same pattern as isCurrentResume.
isCurrent: () => view.$storedId.get() === storedId && view.$runtimeId.get() === runtimeId,
applyOlderPage: olderPage => {
sessionTileDelegate()?.updateSession(runtimeId, state => {
const merged = mergeOlderTranscriptPage(state.messages, olderPage)
const expandWindow = useCallback(
async (beforePrepend?: () => void) => {
// Network latency is not scroll intent. Capture at arrival, immediately
// before the store prepend, and only grow a window that has a page to show.
if (
!windowStateRef.current.get(runtimeIdRef.current ?? '')?.state.window.windowed &&
runtimeId &&
storedId &&
transcriptBackfillAvailable(storedId, tailProfile)
) {
let grew = false
await backfillOlderTranscriptPage({
storedSessionId: storedId,
profile: tailProfile,
// Stale-response guard: a session switch remounts/re-keys this view;
// checking the live atoms (not captured props) discards a page that
// resolves after the user moved on — same pattern as isCurrentResume.
isCurrent: () => view.$storedId.get() === storedId && view.$runtimeId.get() === runtimeId,
applyOlderPage: olderPage => {
const current = view.$messages.get()
return merged === state.messages ? state : { ...state, messages: merged }
})
}
})
}
if (mergeOlderTranscriptPage(current, olderPage) === current) {
return
}
setWindowPages(pages => pages + 1)
}, [runtimeId, storedId, tailProfile, view])
beforePrepend?.()
setWindowPages(pages => pages + 1)
sessionTileDelegate()?.updateSession(runtimeId, state => {
const merged = mergeOlderTranscriptPage(state.messages, olderPage)
grew = merged !== state.messages
return grew ? { ...state, messages: merged } : state
})
}
})
// Exhaustion and overlapping-only pages have no structural publication.
// Do not leave the list waiting for a commit that will never arrive.
return grew
}
beforePrepend?.()
setWindowPages(pages => pages + 1)
return true
},
[runtimeId, storedId, tailProfile, view]
)
const olderAvailable = windowed || restBackfillAvailable
@@ -383,6 +400,7 @@ const ChatViewContent = memo(function ChatViewContent({
modelOptionsOwnerConnectionId,
modelOptionsProfile,
modelMenuContent,
reasoningMenuContent,
requestModelOptionsForOwner,
onToggleSelectedPin,
onDeleteSelectedSession,
@@ -421,6 +439,11 @@ const ChatViewContent = memo(function ChatViewContent({
const composerSurfaceId = useComposerSurfaceId()
const isPrimary = view.kind === 'primary'
const activeSessionId = useStore(view.$runtimeId)
const transcriptStoredSessionId = useStoreSelector($sessionStates, states =>
activeSessionId ? (states[activeSessionId]?.storedSessionId ?? null) : null
)
const storedId = useStore(view.$storedId)
// Multi-pane dimming: only the focused surface paints at full strength, so
// two sessions side by side read as "this one, and that one over there".
@@ -514,7 +537,14 @@ const ChatViewContent = memo(function ChatViewContent({
// direct nav). Derived in render so the swap reads instantly: the same frame
// the id changes we drop the old transcript and show the loader, instead of
// waiting for the resume effect (which paints a frame later) to clear them.
const routeSessionMismatch = isPrimary ? isRouteSessionMismatch(routedSessionId, selectedSessionId, sessions) : false
const routeSessionMismatch = isPrimary
? isRouteSessionMismatch(routedSessionId, selectedSessionId, sessions, {
activeRuntimeId: activeSessionId,
contextSwitching: Boolean(gatewaySwapTarget),
messagesEmpty,
transcriptStoredSessionId
})
: false
// The compact new-session pop-out skips the wordmark/tagline intro — it's a
// scratch window, not the full-height empty state. The Appearance toggle
@@ -565,7 +595,7 @@ const ChatViewContent = memo(function ChatViewContent({
const showChatBar = !loadingSession && !resumeExhausted && !isWatchWindow()
const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new')
const modelOptionsQuery = useQuery<ModelOptionsResponse>({
const modelOptionsQuery = useQuery<ModelOptionsResult>({
queryKey: modelOptionsQueryKey(
modelOptionsProfile || activeGatewayProfile,
activeSessionId,
@@ -586,6 +616,8 @@ const ChatViewContent = memo(function ChatViewContent({
[currentModel, currentProvider, modelOptionsQuery.data]
)
const supportsReasoning = currentModelCapabilities(modelOptionsQuery.data, currentProvider, currentModel)?.reasoning
const chatBarState = useMemo<ChatBarState>(
() => ({
model: {
@@ -594,7 +626,9 @@ const ChatViewContent = memo(function ChatViewContent({
canSwitch: gatewayOpen,
loading: !gatewayOpen || (!currentModel && !currentProvider),
modelMenuContent,
quickModels
quickModels,
reasoningMenuContent,
supportsReasoning
},
tools: {
enabled: true,
@@ -606,7 +640,16 @@ const ChatViewContent = memo(function ChatViewContent({
active: false
}
}),
[contextSuggestions, currentModel, currentProvider, gatewayOpen, modelMenuContent, quickModels]
[
contextSuggestions,
currentModel,
currentProvider,
gatewayOpen,
modelMenuContent,
quickModels,
reasoningMenuContent,
supportsReasoning
]
)
// Drop files anywhere in the conversation area, not just on the composer
@@ -703,6 +746,7 @@ const ChatViewContent = memo(function ChatViewContent({
onCancel={haltRun}
onDismissError={onDismissError}
onRestoreToMessage={onRestoreToMessage}
scrollProfile={modelOptionsProfile || activeGatewayProfile}
sessionId={activeSessionId}
sessionKey={threadKey}
/>
@@ -21,6 +21,7 @@ import { CopyButton } from '@/components/ui/copy-button'
import { Input } from '@/components/ui/input'
import { PaneStripGlyph } from '@/components/ui/pane-tab'
import { useI18n } from '@/i18n'
import { isSubmitEnter } from '@/lib/ime'
import { ANNOTATE_BLUE } from '@/lib/preview-annotate'
import { cn } from '@/lib/utils'
@@ -191,7 +192,7 @@ export function PreviewBrowserBar({
event.currentTarget.select()
}}
onKeyDown={event => {
if (event.key === 'Enter') {
if (isSubmitEnter(event)) {
commit(event.currentTarget.value)
event.currentTarget.blur()
}
@@ -84,17 +84,14 @@ describe('RealProfileConsentDialog', () => {
fireEvent.click(screen.getByRole('button', { name: promptCopy.enable }))
})
// Saves the WHOLE merged record with only use_real_profile added — the
// same shape the Capabilities toggle writes, through the same cache, so
// the existing toggle flips on without a refetch.
expect(mocks.save).toHaveBeenCalledWith(
{
browser: { allow_private_urls: false, use_real_profile: true },
model: { provider: 'nous' }
},
undefined
)
expect(mocks.cache).toHaveBeenCalledWith(mocks.save.mock.calls[0][0])
// Saves ONLY the toggled key (PUT deep-merges) — the same shape the
// Capabilities toggle writes — while the shared cache gets the merged
// record so the existing toggle flips on without a refetch.
expect(mocks.save).toHaveBeenCalledWith({ browser: { use_real_profile: true } }, undefined)
expect(mocks.cache).toHaveBeenCalledWith({
browser: { allow_private_urls: false, use_real_profile: true },
model: { provider: 'nous' }
})
expect(mocks.notify).toHaveBeenCalled()
})
@@ -78,7 +78,9 @@ export function RealProfileConsentDialog({ tabId }: RealProfileConsentDialogProp
setConfig(next)
try {
await saveHermesConfigRecord(next)
// Sparse patch: PUT /api/config deep-merges, and echoing the cached
// snapshot would overwrite keys other surfaces changed since it loaded.
await saveHermesConfigRecord({ browser: { use_real_profile: true } })
notify({ kind: 'info', title: copy.enabledTitle, message: copy.enabledMessage })
} catch (err) {
setConfig(config)

Some files were not shown because too many files have changed in this diff Show More