mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-10-20 05:15:31 +00:00
feat(desktop): confirm before quitting with a turn in flight
Cmd-Q went straight through to teardown, killing the backend mid-tool-call — the turn is gone and whatever the agent was part-way through writing stays part-way written, with nothing on screen to warn about it. Renderers now report which chats are mid-turn; before-quit merges the reports and asks, naming them, defaulting to Keep Running. Update, swap, and uninstall relaunches skip the prompt: those are the app replacing itself, and a modal there would strand the detached script waiting on a PID that never exits.
This commit is contained in:
parent
2f5926ed05
commit
9ae3bd73c9
8 changed files with 338 additions and 0 deletions
|
|
@ -146,6 +146,7 @@ import { rehomePrimaryConnection } from './primary-connection-rehome'
|
|||
import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing'
|
||||
import { fetchPrimaryProfileSessions } from './profile-session-routing'
|
||||
import { createQuickEntryShortcut, quickEntryWindowBounds, sanitizeQuickEntrySettings } from './quick-entry'
|
||||
import { type ActiveWork, mergeActiveWork, normalizeActiveWork, quitPromptFor } from './quit-guard'
|
||||
import * as remoteLifecycle from './remote-lifecycle'
|
||||
import {
|
||||
RemoteLivenessTracker,
|
||||
|
|
@ -2510,6 +2511,12 @@ let updateInFlight = false
|
|||
// actually dies and the hand-off script can proceed immediately.
|
||||
let isQuittingForHandoff = false
|
||||
|
||||
// Quit-guard latches: one while the confirmation is on screen (a second
|
||||
// Cmd-Q must not stack dialogs), one after the user has said "quit anyway"
|
||||
// (the app.quit() that follows re-enters before-quit and must pass through).
|
||||
let quitPromptOpen = false
|
||||
let quitConfirmedWithActiveWork = false
|
||||
|
||||
// Resolve the staged updater binary. The Tauri installer copies itself to
|
||||
// HERMES_HOME/hermes-setup.exe on a successful install (see
|
||||
// apps/bootstrap-installer paths::copy_self_to_hermes_home). That binary owns
|
||||
|
|
@ -10134,6 +10141,20 @@ ipcMain.handle('hermes:watchPreviewFile', (_event, url) => watchPreviewFile(Stri
|
|||
|
||||
ipcMain.handle('hermes:stopPreviewFileWatch', (_event, id) => stopPreviewFileWatch(String(id || '')))
|
||||
|
||||
// Each renderer reports the turns it has in flight; the quit guard reads the
|
||||
// merged picture. Keyed by webContents id so a closed window stops counting.
|
||||
const activeWorkByWebContents = new Map<number, ActiveWork>()
|
||||
|
||||
ipcMain.on('hermes:active-work', (event, payload) => {
|
||||
const id = event.sender.id
|
||||
|
||||
if (!activeWorkByWebContents.has(id)) {
|
||||
event.sender.once('destroyed', () => activeWorkByWebContents.delete(id))
|
||||
}
|
||||
|
||||
activeWorkByWebContents.set(id, normalizeActiveWork(payload))
|
||||
})
|
||||
|
||||
ipcMain.on('hermes:titlebar-theme', (_event, payload) => {
|
||||
if (!payload || !isHexColor(payload.background) || !isHexColor(payload.foreground)) {
|
||||
return
|
||||
|
|
@ -11399,7 +11420,58 @@ function configureSpellChecker() {
|
|||
}
|
||||
}
|
||||
|
||||
// Ask before a quit kills a turn in flight. True when the quit was intercepted
|
||||
// and the confirmation is on screen; "Quit Anyway" re-enters before-quit with
|
||||
// the latch set and falls straight through to the teardown below.
|
||||
function heldQuitForActiveWork(event: Electron.Event): boolean {
|
||||
if (quitConfirmedWithActiveWork || quitPromptOpen) {
|
||||
return false
|
||||
}
|
||||
|
||||
const prompt = quitPromptFor(mergeActiveWork(activeWorkByWebContents.values()), isQuittingForHandoff)
|
||||
const parent = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]
|
||||
|
||||
if (!prompt || !parent || parent.isDestroyed()) {
|
||||
return false
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
quitPromptOpen = true
|
||||
|
||||
void dialog
|
||||
.showMessageBox(parent, {
|
||||
buttons: ['Keep Running', 'Quit Anyway'],
|
||||
cancelId: 0,
|
||||
defaultId: 0,
|
||||
detail: prompt.detail,
|
||||
message: prompt.message,
|
||||
type: 'question'
|
||||
})
|
||||
.then(({ response }) => {
|
||||
quitPromptOpen = false
|
||||
|
||||
if (response === 1) {
|
||||
quitConfirmedWithActiveWork = true
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// A dialog we can't show must not become a quit we can't perform.
|
||||
quitPromptOpen = false
|
||||
quitConfirmedWithActiveWork = true
|
||||
app.quit()
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
app.on('before-quit', event => {
|
||||
// Runs ahead of every teardown below, so "Keep Running" leaves the app
|
||||
// exactly as it was.
|
||||
if (heldQuitForActiveWork(event)) {
|
||||
return
|
||||
}
|
||||
|
||||
if ((sshConnections.size > 0 || sshBootstrapCoordinator.promises().length > 0) && !sshQuitTeardownDone) {
|
||||
event.preventDefault()
|
||||
sshBootstrapCoordinator.cancelAll()
|
||||
|
|
|
|||
|
|
@ -114,6 +114,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||
normalizePreviewTarget: (target, baseDir) => ipcRenderer.invoke('hermes:normalizePreviewTarget', target, baseDir),
|
||||
watchPreviewFile: url => ipcRenderer.invoke('hermes:watchPreviewFile', url),
|
||||
stopPreviewFileWatch: id => ipcRenderer.invoke('hermes:stopPreviewFileWatch', id),
|
||||
setActiveWork: payload => ipcRenderer.send('hermes:active-work', payload),
|
||||
setTitleBarTheme: payload => ipcRenderer.send('hermes:titlebar-theme', payload),
|
||||
setNativeTheme: mode => ipcRenderer.send('hermes:native-theme', mode),
|
||||
setTranslucency: payload => ipcRenderer.send('hermes:translucency', payload),
|
||||
|
|
|
|||
62
apps/desktop/electron/quit-guard.test.ts
Normal file
62
apps/desktop/electron/quit-guard.test.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { mergeActiveWork, normalizeActiveWork, quitPromptFor } from './quit-guard'
|
||||
|
||||
test('normalizeActiveWork drops junk and keeps the count at least the title count', () => {
|
||||
assert.deepEqual(normalizeActiveWork(null), { count: 0, titles: [] })
|
||||
assert.deepEqual(normalizeActiveWork({ count: 'many', titles: 'nope' }), { count: 0, titles: [] })
|
||||
assert.deepEqual(normalizeActiveWork({ count: -3, titles: [' Fix login ', '', 7] }), {
|
||||
count: 1,
|
||||
titles: ['Fix login']
|
||||
})
|
||||
})
|
||||
|
||||
test('normalizeActiveWork keeps untitled sessions in the count', () => {
|
||||
assert.deepEqual(normalizeActiveWork({ count: 3, titles: ['Fix login'] }), { count: 3, titles: ['Fix login'] })
|
||||
})
|
||||
|
||||
test('mergeActiveWork de-dupes a session two windows both report', () => {
|
||||
const merged = mergeActiveWork([
|
||||
{ count: 2, titles: ['Fix login', 'Ship docs'] },
|
||||
{ count: 1, titles: ['Fix login'] }
|
||||
])
|
||||
|
||||
assert.deepEqual(merged, { count: 2, titles: ['Fix login', 'Ship docs'] })
|
||||
})
|
||||
|
||||
test('quitPromptFor stays out of the way when nothing is running', () => {
|
||||
assert.equal(quitPromptFor({ count: 0, titles: [] }, false), null)
|
||||
})
|
||||
|
||||
test('quitPromptFor stays out of the way during an update handoff', () => {
|
||||
assert.equal(quitPromptFor({ count: 2, titles: ['Fix login'] }, true), null)
|
||||
})
|
||||
|
||||
test('quitPromptFor names the running chats', () => {
|
||||
const prompt = quitPromptFor({ count: 2, titles: ['Fix login', 'Ship docs'] }, false)
|
||||
|
||||
assert.ok(prompt)
|
||||
assert.equal(prompt.message, 'Hermes is still working on 2 chats.')
|
||||
assert.ok(prompt.detail.includes('• Fix login'))
|
||||
assert.ok(prompt.detail.includes('• Ship docs'))
|
||||
})
|
||||
|
||||
test('quitPromptFor summarizes past the list cap and counts untitled work', () => {
|
||||
const prompt = quitPromptFor({ count: 9, titles: ['a', 'b', 'c', 'd', 'e', 'f'] }, false)
|
||||
|
||||
assert.ok(prompt)
|
||||
assert.equal(prompt.message, 'Hermes is still working on 9 chats.')
|
||||
assert.ok(prompt.detail.includes('• d'))
|
||||
assert.ok(!prompt.detail.includes('• e'))
|
||||
assert.ok(prompt.detail.includes('• 5 more'))
|
||||
})
|
||||
|
||||
test('quitPromptFor speaks singular for one chat', () => {
|
||||
const prompt = quitPromptFor({ count: 1, titles: [] }, false)
|
||||
|
||||
assert.ok(prompt)
|
||||
assert.equal(prompt.message, 'Hermes is still working on 1 chat.')
|
||||
assert.ok(prompt.detail.includes('mid-turn'))
|
||||
})
|
||||
92
apps/desktop/electron/quit-guard.ts
Normal file
92
apps/desktop/electron/quit-guard.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
// Quitting with a turn in flight kills the backend mid-tool-call: the work is
|
||||
// lost, and anything the agent had half-written to disk stays half-written.
|
||||
// Renderers publish what they're running; the main process asks before it lets
|
||||
// that go. The decision + copy live here (pure, testable) so main.ts only owns
|
||||
// the IPC and the dialog call.
|
||||
|
||||
const MAX_LISTED = 4
|
||||
|
||||
export interface ActiveWork {
|
||||
/** Titles of sessions running a turn. Untitled sessions contribute a count only. */
|
||||
titles: string[]
|
||||
/** Running turns, including untitled ones — always >= titles.length. */
|
||||
count: number
|
||||
}
|
||||
|
||||
export const NO_ACTIVE_WORK: ActiveWork = { count: 0, titles: [] }
|
||||
|
||||
/** Coerce an IPC payload from an untrusted renderer into an ActiveWork. */
|
||||
export function normalizeActiveWork(payload: unknown): ActiveWork {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return NO_ACTIVE_WORK
|
||||
}
|
||||
|
||||
const raw = payload as { count?: unknown; titles?: unknown }
|
||||
|
||||
const titles = Array.isArray(raw.titles)
|
||||
? raw.titles
|
||||
.filter((title): title is string => typeof title === 'string')
|
||||
.map(title => title.trim())
|
||||
.filter(Boolean)
|
||||
: []
|
||||
|
||||
const count = typeof raw.count === 'number' && Number.isFinite(raw.count) ? Math.max(0, Math.floor(raw.count)) : 0
|
||||
|
||||
return { count: Math.max(count, titles.length), titles }
|
||||
}
|
||||
|
||||
/** Merge every window's report into one. Windows can show the same session. */
|
||||
export function mergeActiveWork(reports: Iterable<ActiveWork>): ActiveWork {
|
||||
const titles: string[] = []
|
||||
let count = 0
|
||||
|
||||
for (const report of reports) {
|
||||
count = Math.max(count, report.count)
|
||||
|
||||
for (const title of report.titles) {
|
||||
if (!titles.includes(title)) {
|
||||
titles.push(title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { count: Math.max(count, titles.length), titles }
|
||||
}
|
||||
|
||||
export interface QuitPrompt {
|
||||
detail: string
|
||||
message: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The confirmation to show, or null when quitting should just proceed.
|
||||
*
|
||||
* `quittingForHandoff` covers the update / swap / uninstall relaunches: those
|
||||
* are the app replacing itself, not the user walking away, and a modal there
|
||||
* would strand the detached script waiting on a PID that never exits.
|
||||
*/
|
||||
export function quitPromptFor(work: ActiveWork, quittingForHandoff: boolean): null | QuitPrompt {
|
||||
if (quittingForHandoff || work.count < 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
const listed = work.titles.slice(0, MAX_LISTED)
|
||||
const remaining = work.count - listed.length
|
||||
const lines = listed.map(title => `• ${title}`)
|
||||
|
||||
if (remaining > 0) {
|
||||
lines.push(remaining === 1 ? '• 1 more' : `• ${remaining} more`)
|
||||
}
|
||||
|
||||
return {
|
||||
detail: [
|
||||
lines.join('\n'),
|
||||
lines.length > 0 ? '' : null,
|
||||
'Quitting stops the agent mid-turn. Any work it has not finished writing is lost.'
|
||||
]
|
||||
.filter(line => line !== null)
|
||||
.join('\n')
|
||||
.trim(),
|
||||
message: work.count === 1 ? 'Hermes is still working on 1 chat.' : `Hermes is still working on ${work.count} chats.`
|
||||
}
|
||||
}
|
||||
7
apps/desktop/src/global.d.ts
vendored
7
apps/desktop/src/global.d.ts
vendored
|
|
@ -124,6 +124,7 @@ declare global {
|
|||
normalizePreviewTarget: (target: string, baseDir?: string) => Promise<HermesPreviewTarget | null>
|
||||
watchPreviewFile: (url: string) => Promise<HermesPreviewWatch>
|
||||
stopPreviewFileWatch: (id: string) => Promise<boolean>
|
||||
setActiveWork?: (payload: HermesActiveWork) => void
|
||||
setTitleBarTheme?: (payload: HermesTitleBarTheme) => void
|
||||
setNativeTheme?: (mode: 'dark' | 'light' | 'system') => void
|
||||
setTranslucency?: (payload: { intensity: number }) => void
|
||||
|
|
@ -458,6 +459,12 @@ export interface HermesTitleBarTheme {
|
|||
foreground: string
|
||||
}
|
||||
|
||||
/** Turns in flight, so the main process can confirm before a quit kills them. */
|
||||
export interface HermesActiveWork {
|
||||
count: number
|
||||
titles: string[]
|
||||
}
|
||||
|
||||
export interface HermesWindowState {
|
||||
isFullscreen: boolean
|
||||
isMinimized?: boolean
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import './styles.css'
|
||||
// Side-effect: reports in-flight turns to the main process for the quit guard.
|
||||
import './store/active-work'
|
||||
// Side-effect: applies the persisted window translucency on load.
|
||||
import './store/translucency'
|
||||
// Dev-only render/state churn counters. MUST precede the `react-dom` import
|
||||
|
|
|
|||
60
apps/desktop/src/store/active-work.test.ts
Normal file
60
apps/desktop/src/store/active-work.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
|
||||
import { $sessions } from './session'
|
||||
import { clearAllSessionStates, publishSessionState } from './session-states'
|
||||
|
||||
const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
|
||||
const setActiveWork = vi.fn()
|
||||
|
||||
const busy = (storedSessionId: string, isBusy: boolean) =>
|
||||
({ busy: isBusy, needsInput: false, storedSessionId }) as ClientSessionState
|
||||
|
||||
const session = (id: string, title: null | string) => ({ id, title }) as (typeof $sessions.value)[number]
|
||||
|
||||
beforeAll(async () => {
|
||||
desktopWindow.hermesDesktop = { setActiveWork } as unknown as Window['hermesDesktop']
|
||||
// Subscribes at import time, so the bridge has to exist first.
|
||||
await import('./active-work')
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllSessionStates()
|
||||
$sessions.set([])
|
||||
setActiveWork.mockClear()
|
||||
})
|
||||
|
||||
describe('active work bridge', () => {
|
||||
it('reports a busy session by title', () => {
|
||||
$sessions.set([session('s1', 'Fix login'), session('s2', 'Idle chat')])
|
||||
publishSessionState('runtime-1', busy('s1', true))
|
||||
|
||||
expect(setActiveWork).toHaveBeenLastCalledWith({ count: 1, titles: ['Fix login'] })
|
||||
})
|
||||
|
||||
it('counts an untitled busy session without inventing a title', () => {
|
||||
$sessions.set([session('s1', null)])
|
||||
publishSessionState('runtime-1', busy('s1', true))
|
||||
|
||||
expect(setActiveWork).toHaveBeenLastCalledWith({ count: 1, titles: [] })
|
||||
})
|
||||
|
||||
it('drops back to nothing when the turn ends', () => {
|
||||
$sessions.set([session('s1', 'Fix login')])
|
||||
publishSessionState('runtime-1', busy('s1', true))
|
||||
publishSessionState('runtime-1', busy('s1', false))
|
||||
|
||||
expect(setActiveWork).toHaveBeenLastCalledWith({ count: 0, titles: [] })
|
||||
})
|
||||
|
||||
it('does not re-send an unchanged summary', () => {
|
||||
$sessions.set([session('s1', 'Fix login')])
|
||||
publishSessionState('runtime-1', busy('s1', true))
|
||||
setActiveWork.mockClear()
|
||||
|
||||
$sessions.set([session('s1', 'Fix login'), session('s2', 'Something else')])
|
||||
|
||||
expect(setActiveWork).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
42
apps/desktop/src/store/active-work.ts
Normal file
42
apps/desktop/src/store/active-work.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* Mirror of "which chats are mid-turn" to the main process.
|
||||
*
|
||||
* The renderer is the only side that knows a turn is in flight, and the main
|
||||
* process is the only side that can intercept a quit. This module bridges the
|
||||
* two: it publishes a small summary on every membership change, and
|
||||
* `electron/quit-guard.ts` turns that into the confirmation dialog.
|
||||
*
|
||||
* Imported for its side effect from `main.tsx`, alongside `store/translucency`.
|
||||
*/
|
||||
|
||||
import { computed } from 'nanostores'
|
||||
|
||||
import type { HermesActiveWork } from '@/global'
|
||||
import { $sessions } from '@/store/session'
|
||||
import { $workingSessionIds } from '@/store/session-states'
|
||||
|
||||
const $activeWork = computed([$workingSessionIds, $sessions], (workingIds, sessions): HermesActiveWork => {
|
||||
const titleById = new Map(sessions.map(session => [session.id, session.title?.trim() ?? '']))
|
||||
|
||||
return {
|
||||
count: workingIds.length,
|
||||
titles: workingIds.map(id => titleById.get(id) ?? '').filter(Boolean)
|
||||
}
|
||||
})
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
// `$sessions` republishes on unrelated churn (previews, heartbeats), so only
|
||||
// send when the summary itself moved — this crosses a process boundary.
|
||||
let lastSent = ''
|
||||
|
||||
$activeWork.subscribe(work => {
|
||||
const next = JSON.stringify(work)
|
||||
|
||||
if (next === lastSent) {
|
||||
return
|
||||
}
|
||||
|
||||
lastSent = next
|
||||
window.hermesDesktop?.setActiveWork?.(work)
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue