hermes-agent/apps/desktop/src/global.d.ts
Brooklyn Nicholson b94b3622b5 feat(desktop): per-session profile switching + cross-profile sessions
Add first-class profile support to the desktop app without app reloads.

- Swap the single live gateway onto a session's profile lazily (spawned on
  demand by the Electron backend pool), so one backend serves the active
  profile and others stay cold — no OOM with many profiles.
- Aggregate sessions across profiles by reading each profile's state.db
  read-only; unified "All profiles" view groups sessions per profile with
  per-profile pagination, while the default view stays scoped to one profile.
- Add an Arc-style profile rail at the sidebar foot: a default<->all toggle
  pinned left, colored named-profile squares scrolling between, Manage pinned
  right. Profile identity is a deterministic per-name color.
- Route profile-scoped REST (config/env/skills/tools/model) to the active
  gateway profile and invalidate React Query caches on swap. Single-profile
  users never trigger a swap, so their path is unchanged.

Backend:
- web_server: profile-aware active/list endpoints + per-profile session
  totals; hermes_state: session_count(exclude_children); main.py: honor
  --profile over HERMES_HOME env for pooled backends.

UI primitives:
- Add a position-aware Tip tooltip (instant, themed) as a drop-in for native
  title=, and strip redundant tooltips from self-descriptive chrome.
2026-06-04 16:35:34 -05:00

388 lines
12 KiB
TypeScript

export {}
declare global {
interface Window {
hermesDesktop: {
// Resolve a backend connection. Omit `profile` (or pass the primary) for
// the window's backend; pass a named profile to lazily spawn/reuse that
// profile's backend from the pool.
getConnection: (profile?: string | null) => Promise<HermesConnection>
// Keepalive: mark a pool profile backend as recently used so the idle
// reaper spares it while its chat is active.
touchBackend: (profile?: string | null) => Promise<{ ok: boolean }>
getGatewayWsUrl: (profile?: null | string) => Promise<string>
getBootProgress: () => Promise<DesktopBootProgress>
getConnectionConfig: () => Promise<DesktopConnectionConfig>
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
applyConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
testConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionTestResult>
probeConnectionConfig: (remoteUrl: string) => Promise<DesktopConnectionProbeResult>
oauthLoginConnectionConfig: (remoteUrl: string) => Promise<DesktopOauthLoginResult>
oauthLogoutConnectionConfig: (remoteUrl?: string) => Promise<DesktopOauthLogoutResult>
profile: {
get: () => Promise<DesktopActiveProfile>
// Persists the desktop's profile choice and relaunches the local
// backend under the new HERMES_HOME (reloads the window). Pass null to
// clear the preference.
set: (name: string | null) => Promise<DesktopActiveProfile>
}
api: <T>(request: HermesApiRequest) => Promise<T>
notify: (payload: HermesNotification) => Promise<boolean>
requestMicrophoneAccess: () => Promise<boolean>
readFileDataUrl: (filePath: string) => Promise<string>
readFileText: (filePath: string) => Promise<HermesReadFileTextResult>
selectPaths: (options?: HermesSelectPathsOptions) => Promise<string[]>
writeClipboard: (text: string) => Promise<boolean>
saveImageFromUrl: (url: string) => Promise<boolean>
saveImageBuffer: (data: ArrayBuffer | Uint8Array, ext: string) => Promise<string>
saveClipboardImage: () => Promise<string>
getPathForFile: (file: File) => string
normalizePreviewTarget: (target: string, baseDir?: string) => Promise<HermesPreviewTarget | null>
watchPreviewFile: (url: string) => Promise<HermesPreviewWatch>
stopPreviewFileWatch: (id: string) => Promise<boolean>
setTitleBarTheme?: (payload: HermesTitleBarTheme) => void
setPreviewShortcutActive?: (active: boolean) => void
openExternal: (url: string) => Promise<void>
fetchLinkTitle: (url: string) => Promise<string>
settings: {
getDefaultProjectDir: () => Promise<{ defaultLabel: string; dir: null | string }>
pickDefaultProjectDir: () => Promise<{ canceled: boolean; dir: null | string }>
setDefaultProjectDir: (dir: null | string) => Promise<{ dir: null | string }>
}
revealLogs: () => Promise<{ ok: boolean; path: string; error?: string }>
getRecentLogs: () => Promise<{ path: string; lines: string[] }>
readDir: (path: string) => Promise<HermesReadDirResult>
gitRoot?: (path: string) => Promise<string | null>
terminal: {
dispose: (id: string) => Promise<boolean>
onData: (id: string, callback: (payload: string) => void) => () => void
onExit: (id: string, callback: (payload: HermesTerminalExit) => void) => () => void
resize: (id: string, size: { cols: number; rows: number }) => Promise<boolean>
start: (options?: { cols?: number; cwd?: string; rows?: number }) => Promise<HermesTerminalSession>
write: (id: string, data: string) => Promise<boolean>
}
onClosePreviewRequested?: (callback: () => void) => () => void
onOpenUpdatesRequested?: (callback: () => void) => () => void
onWindowStateChanged?: (callback: (payload: HermesWindowState) => void) => () => void
onPreviewFileChanged: (callback: (payload: HermesPreviewFileChanged) => void) => () => void
onBackendExit: (callback: (payload: BackendExit) => void) => () => void
onPowerResume?: (callback: () => void) => () => void
onBootProgress: (callback: (payload: DesktopBootProgress) => void) => () => void
getBootstrapState: () => Promise<DesktopBootstrapState>
resetBootstrap: () => Promise<{ ok: boolean }>
repairBootstrap: () => Promise<{ ok: boolean }>
cancelBootstrap: () => Promise<{ ok: boolean; cancelled: boolean }>
onBootstrapEvent: (callback: (payload: DesktopBootstrapEvent) => void) => () => void
getVersion: () => Promise<DesktopVersionInfo>
updates: {
check: () => Promise<DesktopUpdateStatus>
apply: (opts?: DesktopUpdateApplyOptions) => Promise<DesktopUpdateApplyResult>
getBranch: () => Promise<{ branch: string }>
setBranch: (name: string) => Promise<{ branch: string }>
onProgress: (callback: (payload: DesktopUpdateProgress) => void) => () => void
}
}
}
}
export interface HermesTerminalSession {
cwd: string
id: string
shell: string
}
export interface HermesTerminalExit {
code: number | null
signal: string | null
}
export interface DesktopVersionInfo {
appVersion: string
electronVersion: string
nodeVersion: string
platform: string
hermesRoot: string
}
export interface DesktopUpdateCommit {
sha: string
summary: string
author: string
at: number
}
export interface DesktopUpdateStatus {
supported: boolean
branch?: string
currentBranch?: string
reason?: string
message?: string
error?: string
behind?: number
currentSha?: string
targetSha?: string
commits?: DesktopUpdateCommit[]
dirty?: boolean
fetchedAt?: number
}
export type DesktopUpdateDirtyStrategy = 'abort' | 'stash' | 'force'
export interface DesktopUpdateApplyOptions {
dirtyStrategy?: DesktopUpdateDirtyStrategy
}
export interface DesktopUpdateApplyResult {
ok: boolean
branch?: string
error?: string
message?: string
/** True when no staged updater exists (CLI install) and the user should run
* `hermes update` themselves. `command` is the exact line to run. */
manual?: boolean
command?: string
hermesRoot?: string
}
export type DesktopUpdateStage = 'idle' | 'prepare' | 'fetch' | 'pull' | 'pydeps' | 'restart' | 'manual' | 'error'
export interface DesktopUpdateProgress {
stage: DesktopUpdateStage
message: string
percent: number | null
error: string | null
at: number
}
export interface HermesConnection {
baseUrl: string
isFullscreen: boolean
mode?: 'local' | 'remote'
authMode?: 'oauth' | 'token'
nativeOverlayWidth: number
source?: 'env' | 'local' | 'settings'
token: string
wsUrl: string
logs: string[]
// Set for pool (non-primary) backends so the renderer knows which profile a
// connection belongs to.
profile?: string
windowButtonPosition: { x: number; y: number } | null
}
export interface HermesTitleBarTheme {
background: string
foreground: string
}
export interface HermesWindowState {
isFullscreen: boolean
nativeOverlayWidth: number
windowButtonPosition: { x: number; y: number } | null
}
export interface DesktopActiveProfile {
// The desktop's stored profile preference, or null when unset (legacy launch
// that defers to the sticky active_profile / default).
profile: string | null
}
export interface DesktopConnectionConfig {
envOverride: boolean
mode: 'local' | 'remote'
remoteAuthMode: 'oauth' | 'token'
remoteOauthConnected: boolean
remoteTokenPreview: string | null
remoteTokenSet: boolean
remoteUrl: string
}
export interface DesktopConnectionConfigInput {
mode: 'local' | 'remote'
remoteAuthMode?: 'oauth' | 'token'
remoteToken?: string
remoteUrl?: string
}
export interface DesktopConnectionTestResult {
baseUrl: string
ok: boolean
version: string | null
}
export interface DesktopAuthProvider {
name: string
displayName: string
// True when this provider authenticates with a username + password
// (the gateway's /login page renders a credential form) rather than an
// OAuth redirect. The session/cookie/ws-ticket machinery is identical;
// only the login-page form and the desktop's button copy differ.
supportsPassword?: boolean
}
export interface DesktopConnectionProbeResult {
baseUrl: string
reachable: boolean
authMode: 'oauth' | 'token' | 'unknown'
providers: DesktopAuthProvider[]
version: string | null
error: string | null
}
export interface DesktopOauthLoginResult {
ok: boolean
baseUrl: string
connected: boolean
}
export interface DesktopOauthLogoutResult {
ok: boolean
connected: boolean
}
export interface DesktopBootProgress {
error: string | null
fakeMode: boolean
message: string
phase: string
progress: number
running: boolean
timestamp: number
}
// First-launch install ("bootstrap") event types -- emitted by
// electron/bootstrap-runner.cjs and observed by the renderer install overlay.
// Mirrors the event shapes emitted by runBootstrap()'s onEvent callback.
export interface DesktopBootstrapStageDescriptor {
name: string
title?: string
category?: string
needs_user_input?: boolean
}
export type DesktopBootstrapStageState = 'pending' | 'running' | 'succeeded' | 'skipped' | 'failed'
export interface DesktopBootstrapStageResult {
state: DesktopBootstrapStageState
durationMs: number | null
startedAt: number | null
json: { ok: boolean; skipped?: boolean; reason?: string | null; stage: string } | null
error: string | null
}
export interface DesktopBootstrapUnsupportedPlatform {
platform: string
activeRoot: string
installCommand: string
docsUrl: string
}
export interface DesktopBootstrapState {
active: boolean
manifest: { type: 'manifest'; stages: DesktopBootstrapStageDescriptor[]; protocolVersion: number | null } | null
stages: Record<string, DesktopBootstrapStageResult>
error: string | null
log: Array<{ ts: number; stage: string | null; line: string; stream?: 'stdout' | 'stderr' }>
startedAt: number | null
completedAt: number | null
unsupportedPlatform: DesktopBootstrapUnsupportedPlatform | null
}
export type DesktopBootstrapEvent =
| { type: 'manifest'; stages: DesktopBootstrapStageDescriptor[]; protocolVersion: number | null }
| {
type: 'stage'
name: string
state: DesktopBootstrapStageState
durationMs?: number
json?: DesktopBootstrapStageResult['json']
error?: string | null
}
| { type: 'log'; stage?: string | null; line: string; stream?: 'stdout' | 'stderr' }
| { type: 'complete'; marker: Record<string, unknown> }
| { type: 'failed'; stage?: string | null; error: string }
| {
type: 'unsupported-platform'
platform: string
activeRoot: string
installCommand: string
docsUrl: string
}
export interface HermesApiRequest {
path: string
method?: string
body?: unknown
timeoutMs?: number
// Route this REST call to a specific profile's backend. Omit for the primary
// (window) backend. Read-only cross-profile data is served by the primary, so
// this is only needed for profile-scoped live/settings calls.
profile?: string | null
}
export interface HermesNotification {
title?: string
body?: string
silent?: boolean
}
export interface HermesPreviewTarget {
binary?: boolean
byteSize?: number
kind: 'file' | 'url'
label: string
large?: boolean
language?: string
mimeType?: string
path?: string
previewKind?: 'binary' | 'html' | 'image' | 'text'
renderMode?: 'preview' | 'source'
source: string
url: string
}
export interface HermesReadFileTextResult {
binary?: boolean
byteSize?: number
language?: string
mimeType?: string
path: string
text: string
truncated?: boolean
}
export interface HermesPreviewWatch {
id: string
path: string
}
export interface HermesReadDirEntry {
name: string
path: string
isDirectory: boolean
}
export interface HermesReadDirResult {
entries: HermesReadDirEntry[]
error?: string
}
export interface HermesPreviewFileChanged {
id: string
path: string
url: string
}
export interface HermesSelectPathsOptions {
title?: string
defaultPath?: string
directories?: boolean
multiple?: boolean
filters?: Array<{ name: string; extensions: string[] }>
}
export interface BackendExit {
code: number | null
signal: string | null
}