mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-26 17:38:36 +00:00
Adds a first-run Desktop choice between installing Hermes locally and connecting to an existing remote Hermes gateway. The choice appears after backend resolution but before ensureRuntime(), so selecting remote cannot accidentally trigger local bootstrap. New modules: - first-run-setup-gate: concurrent first-run decision gate and reset semantics - primary-backend-startup: Electron-free orchestration seam (saved remote resolution, gate decision, remote re-resolution, local continuation) - primary-connection-rehome: prevents dual-owner race where both cold boot() and renderer softSwitch() could connect simultaneously - first-run-remote-form: extracted remote form with stale-result guards Reuses existing connection-config IPC, encrypted token storage, OAuth session partition, and primary backend resolution. Fixes #38602 Fixes #36970
272 lines
15 KiB
TypeScript
272 lines
15 KiB
TypeScript
import { contextBridge, ipcRenderer, webUtils } from 'electron'
|
|
|
|
contextBridge.exposeInMainWorld('hermesDesktop', {
|
|
getConnection: profile => ipcRenderer.invoke('hermes:connection', profile),
|
|
revalidateConnection: () => ipcRenderer.invoke('hermes:connection:revalidate'),
|
|
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
|
|
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
|
|
openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts),
|
|
openWindow: () => ipcRenderer.invoke('hermes:window:openInstance'),
|
|
claimAmbientCue: key => ipcRenderer.invoke('hermes:ambient:claim', key),
|
|
petOverlay: {
|
|
// Main renderer → main process: window lifecycle + drag. `request` is
|
|
// `{ bounds, screen }`; resolves with the screen bounds it actually used.
|
|
open: request => ipcRenderer.invoke('hermes:pet-overlay:open', request),
|
|
close: () => ipcRenderer.invoke('hermes:pet-overlay:close'),
|
|
setBounds: bounds => ipcRenderer.send('hermes:pet-overlay:set-bounds', bounds),
|
|
setIgnoreMouse: ignore => ipcRenderer.send('hermes:pet-overlay:ignore-mouse', ignore),
|
|
// Flip the overlay focusable (and focus it) while the composer needs keys.
|
|
setFocusable: focusable => ipcRenderer.send('hermes:pet-overlay:set-focusable', focusable),
|
|
// Main renderer → overlay (forwarded by main): push the latest pet state.
|
|
pushState: payload => ipcRenderer.send('hermes:pet-overlay:state', payload),
|
|
// Overlay → main renderer (forwarded by main): pop back in / composer submit.
|
|
control: payload => ipcRenderer.send('hermes:pet-overlay:control', payload),
|
|
// Overlay subscribes to state pushes.
|
|
onState: callback => {
|
|
const listener = (_event, payload) => callback(payload)
|
|
ipcRenderer.on('hermes:pet-overlay:state', listener)
|
|
|
|
return () => ipcRenderer.removeListener('hermes:pet-overlay:state', listener)
|
|
},
|
|
// Main renderer subscribes to overlay control messages.
|
|
onControl: callback => {
|
|
const listener = (_event, payload) => callback(payload)
|
|
ipcRenderer.on('hermes:pet-overlay:control', listener)
|
|
|
|
return () => ipcRenderer.removeListener('hermes:pet-overlay:control', listener)
|
|
}
|
|
},
|
|
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
|
|
getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile),
|
|
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
|
|
applyConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:apply', payload),
|
|
testConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:test', payload),
|
|
sshConfigHosts: () => ipcRenderer.invoke('hermes:ssh-config:hosts'),
|
|
sshResolveHost: host => ipcRenderer.invoke('hermes:ssh-config:resolve', host),
|
|
probeConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:probe', remoteUrl),
|
|
oauthLoginConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-login', remoteUrl),
|
|
oauthLogoutConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-logout', remoteUrl),
|
|
// Hermes Cloud: one portal login powers discovery + silent per-agent sign-in
|
|
// (cloud-auto-discovery Phase 3).
|
|
cloud: {
|
|
status: () => ipcRenderer.invoke('hermes:cloud:status'),
|
|
login: () => ipcRenderer.invoke('hermes:cloud:login'),
|
|
logout: () => ipcRenderer.invoke('hermes:cloud:logout'),
|
|
discover: org => ipcRenderer.invoke('hermes:cloud:discover', org),
|
|
agentSignIn: dashboardUrl => ipcRenderer.invoke('hermes:cloud:agent-sign-in', dashboardUrl)
|
|
},
|
|
profile: {
|
|
get: () => ipcRenderer.invoke('hermes:profile:get'),
|
|
set: name => ipcRenderer.invoke('hermes:profile:set', name)
|
|
},
|
|
api: request => ipcRenderer.invoke('hermes:api', request),
|
|
notify: payload => ipcRenderer.invoke('hermes:notify', payload),
|
|
requestMicrophoneAccess: () => ipcRenderer.invoke('hermes:requestMicrophoneAccess'),
|
|
readFileDataUrl: filePath => ipcRenderer.invoke('hermes:readFileDataUrl', filePath),
|
|
readFileText: filePath => ipcRenderer.invoke('hermes:readFileText', filePath),
|
|
selectPaths: options => ipcRenderer.invoke('hermes:selectPaths', options),
|
|
writeClipboard: text => ipcRenderer.invoke('hermes:writeClipboard', text),
|
|
saveImageFromUrl: url => ipcRenderer.invoke('hermes:saveImageFromUrl', url),
|
|
saveImageBuffer: (data, ext) => ipcRenderer.invoke('hermes:saveImageBuffer', { data, ext }),
|
|
saveClipboardImage: () => ipcRenderer.invoke('hermes:saveClipboardImage'),
|
|
getPathForFile: file => {
|
|
try {
|
|
return webUtils.getPathForFile(file) || ''
|
|
} catch {
|
|
return ''
|
|
}
|
|
},
|
|
normalizePreviewTarget: (target, baseDir) => ipcRenderer.invoke('hermes:normalizePreviewTarget', target, baseDir),
|
|
watchPreviewFile: url => ipcRenderer.invoke('hermes:watchPreviewFile', url),
|
|
stopPreviewFileWatch: id => ipcRenderer.invoke('hermes:stopPreviewFileWatch', id),
|
|
setTitleBarTheme: payload => ipcRenderer.send('hermes:titlebar-theme', payload),
|
|
setNativeTheme: mode => ipcRenderer.send('hermes:native-theme', mode),
|
|
setTranslucency: payload => ipcRenderer.send('hermes:translucency', payload),
|
|
setKeepAwake: on => ipcRenderer.send('hermes:keep-awake', on),
|
|
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
|
|
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
|
|
openPreviewInBrowser: url => ipcRenderer.invoke('hermes:openPreviewInBrowser', url),
|
|
fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url),
|
|
sanitizeWorkspaceCwd: cwd => ipcRenderer.invoke('hermes:workspace:sanitize', cwd),
|
|
settings: {
|
|
getDefaultProjectDir: () => ipcRenderer.invoke('hermes:setting:defaultProjectDir:get'),
|
|
setDefaultProjectDir: dir => ipcRenderer.invoke('hermes:setting:defaultProjectDir:set', dir),
|
|
pickDefaultProjectDir: () => ipcRenderer.invoke('hermes:setting:defaultProjectDir:pick')
|
|
},
|
|
zoom: {
|
|
// Current zoom of this window, as { level, percent }.
|
|
get: () => ipcRenderer.invoke('hermes:zoom:get'),
|
|
setPercent: percent => ipcRenderer.send('hermes:zoom:set-percent', percent),
|
|
// Fires on every zoom change, including the Ctrl/Cmd +/-/0 shortcuts,
|
|
// so the settings UI can stay in sync with the keyboard.
|
|
onChanged: callback => {
|
|
const listener = (_event, payload) => callback(payload)
|
|
ipcRenderer.on('hermes:zoom:changed', listener)
|
|
|
|
return () => ipcRenderer.removeListener('hermes:zoom:changed', listener)
|
|
}
|
|
},
|
|
revealLogs: () => ipcRenderer.invoke('hermes:logs:reveal'),
|
|
getRecentLogs: () => ipcRenderer.invoke('hermes:logs:recent'),
|
|
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
|
|
gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath),
|
|
revealPath: targetPath => ipcRenderer.invoke('hermes:fs:reveal', targetPath),
|
|
openDir: dirPath => ipcRenderer.invoke('hermes:fs:openDir', dirPath),
|
|
renamePath: (targetPath, newName) => ipcRenderer.invoke('hermes:fs:rename', targetPath, newName),
|
|
writeTextFile: (filePath, content) => ipcRenderer.invoke('hermes:fs:writeText', filePath, content),
|
|
trashPath: targetPath => ipcRenderer.invoke('hermes:fs:trash', targetPath),
|
|
git: {
|
|
worktreeList: repoPath => ipcRenderer.invoke('hermes:git:worktreeList', repoPath),
|
|
worktreeAdd: (repoPath, options) => ipcRenderer.invoke('hermes:git:worktreeAdd', repoPath, options),
|
|
worktreeRemove: (repoPath, worktreePath, options) =>
|
|
ipcRenderer.invoke('hermes:git:worktreeRemove', repoPath, worktreePath, options),
|
|
branchSwitch: (repoPath, branch) => ipcRenderer.invoke('hermes:git:branchSwitch', repoPath, branch),
|
|
branchList: repoPath => ipcRenderer.invoke('hermes:git:branchList', repoPath),
|
|
baseBranchList: repoPath => ipcRenderer.invoke('hermes:git:baseBranchList', repoPath),
|
|
repoStatus: repoPath => ipcRenderer.invoke('hermes:git:repoStatus', repoPath),
|
|
fileDiff: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:fileDiff', repoPath, filePath),
|
|
scanRepos: (roots, options) => ipcRenderer.invoke('hermes:git:scanRepos', roots, options),
|
|
review: {
|
|
list: (repoPath, scope, baseRef) => ipcRenderer.invoke('hermes:git:review:list', repoPath, scope, baseRef),
|
|
diff: (repoPath, filePath, scope, baseRef, staged) =>
|
|
ipcRenderer.invoke('hermes:git:review:diff', repoPath, filePath, scope, baseRef, staged),
|
|
stage: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:review:stage', repoPath, filePath),
|
|
unstage: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:review:unstage', repoPath, filePath),
|
|
revert: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:review:revert', repoPath, filePath),
|
|
revParse: (repoPath, ref) => ipcRenderer.invoke('hermes:git:review:revParse', repoPath, ref),
|
|
commit: (repoPath, message, push) => ipcRenderer.invoke('hermes:git:review:commit', repoPath, message, push),
|
|
commitContext: repoPath => ipcRenderer.invoke('hermes:git:review:commitContext', repoPath),
|
|
push: repoPath => ipcRenderer.invoke('hermes:git:review:push', repoPath),
|
|
shipInfo: repoPath => ipcRenderer.invoke('hermes:git:review:shipInfo', repoPath),
|
|
createPr: repoPath => ipcRenderer.invoke('hermes:git:review:createPr', repoPath)
|
|
}
|
|
},
|
|
terminal: {
|
|
cwd: id => ipcRenderer.invoke('hermes:terminal:cwd', id),
|
|
dispose: id => ipcRenderer.invoke('hermes:terminal:dispose', id),
|
|
resize: (id, size) => ipcRenderer.invoke('hermes:terminal:resize', id, size),
|
|
start: options => ipcRenderer.invoke('hermes:terminal:start', options),
|
|
write: (id, data) => ipcRenderer.invoke('hermes:terminal:write', id, data),
|
|
onData: (id, callback) => {
|
|
const channel = `hermes:terminal:${id}:data`
|
|
const listener = (_event, payload) => callback(payload)
|
|
ipcRenderer.on(channel, listener)
|
|
|
|
return () => ipcRenderer.removeListener(channel, listener)
|
|
},
|
|
onExit: (id, callback) => {
|
|
const channel = `hermes:terminal:${id}:exit`
|
|
const listener = (_event, payload) => callback(payload)
|
|
ipcRenderer.on(channel, listener)
|
|
|
|
return () => ipcRenderer.removeListener(channel, listener)
|
|
}
|
|
},
|
|
onClosePreviewRequested: callback => {
|
|
const listener = () => callback()
|
|
ipcRenderer.on('hermes:close-preview-requested', listener)
|
|
|
|
return () => ipcRenderer.removeListener('hermes:close-preview-requested', listener)
|
|
},
|
|
onOpenUpdatesRequested: callback => {
|
|
const listener = () => callback()
|
|
ipcRenderer.on('hermes:open-updates', listener)
|
|
|
|
return () => ipcRenderer.removeListener('hermes:open-updates', listener)
|
|
},
|
|
onDeepLink: callback => {
|
|
const listener = (_event, payload) => callback(payload)
|
|
ipcRenderer.on('hermes:deep-link', listener)
|
|
|
|
return () => ipcRenderer.removeListener('hermes:deep-link', listener)
|
|
},
|
|
signalDeepLinkReady: () => ipcRenderer.invoke('hermes:deep-link-ready'),
|
|
onWindowStateChanged: callback => {
|
|
const listener = (_event, payload) => callback(payload)
|
|
ipcRenderer.on('hermes:window-state-changed', listener)
|
|
|
|
return () => ipcRenderer.removeListener('hermes:window-state-changed', listener)
|
|
},
|
|
onFocusSession: callback => {
|
|
const listener = (_event, sessionId) => callback(sessionId)
|
|
ipcRenderer.on('hermes:focus-session', listener)
|
|
|
|
return () => ipcRenderer.removeListener('hermes:focus-session', listener)
|
|
},
|
|
onNotificationAction: callback => {
|
|
const listener = (_event, payload) => callback(payload)
|
|
ipcRenderer.on('hermes:notification-action', listener)
|
|
|
|
return () => ipcRenderer.removeListener('hermes:notification-action', listener)
|
|
},
|
|
onPreviewFileChanged: callback => {
|
|
const listener = (_event, payload) => callback(payload)
|
|
ipcRenderer.on('hermes:preview-file-changed', listener)
|
|
|
|
return () => ipcRenderer.removeListener('hermes:preview-file-changed', listener)
|
|
},
|
|
onBackendExit: callback => {
|
|
const listener = (_event, payload) => callback(payload)
|
|
ipcRenderer.on('hermes:backend-exit', listener)
|
|
|
|
return () => ipcRenderer.removeListener('hermes:backend-exit', listener)
|
|
},
|
|
// Soft gateway-mode apply finished tearing down the primary backend. Renderer
|
|
// should wipe session lists + re-dial without a window reload.
|
|
onConnectionApplied: callback => {
|
|
const listener = () => callback()
|
|
ipcRenderer.on('hermes:connection:applied', listener)
|
|
|
|
return () => ipcRenderer.removeListener('hermes:connection:applied', listener)
|
|
},
|
|
onPowerResume: callback => {
|
|
const listener = () => callback()
|
|
ipcRenderer.on('hermes:power-resume', listener)
|
|
|
|
return () => ipcRenderer.removeListener('hermes:power-resume', listener)
|
|
},
|
|
onBootProgress: callback => {
|
|
const listener = (_event, payload) => callback(payload)
|
|
ipcRenderer.on('hermes:boot-progress', listener)
|
|
|
|
return () => ipcRenderer.removeListener('hermes:boot-progress', listener)
|
|
},
|
|
// First-launch bootstrap progress -- emitted by the install.ps1 stage
|
|
// runner in main.ts (apps/desktop/electron/bootstrap-runner.ts).
|
|
// Renderer's install overlay subscribes to live events and queries the
|
|
// current snapshot via getBootstrapState() to recover after a devtools
|
|
// reload mid-bootstrap.
|
|
getBootstrapState: () => ipcRenderer.invoke('hermes:bootstrap:get'),
|
|
continueBootstrapLocal: () => ipcRenderer.invoke('hermes:bootstrap:continue-local'),
|
|
resetBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:reset'),
|
|
repairBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:repair'),
|
|
cancelBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:cancel'),
|
|
onBootstrapEvent: callback => {
|
|
const listener = (_event, payload) => callback(payload)
|
|
ipcRenderer.on('hermes:bootstrap:event', listener)
|
|
|
|
return () => ipcRenderer.removeListener('hermes:bootstrap:event', listener)
|
|
},
|
|
getVersion: () => ipcRenderer.invoke('hermes:version'),
|
|
getRemoteDisplayReason: () => ipcRenderer.invoke('hermes:get-remote-display-reason'),
|
|
uninstall: {
|
|
summary: () => ipcRenderer.invoke('hermes:uninstall:summary'),
|
|
run: mode => ipcRenderer.invoke('hermes:uninstall:run', { mode })
|
|
},
|
|
updates: {
|
|
check: () => ipcRenderer.invoke('hermes:updates:check'),
|
|
apply: opts => ipcRenderer.invoke('hermes:updates:apply', opts),
|
|
getBranch: () => ipcRenderer.invoke('hermes:updates:branch:get'),
|
|
setBranch: name => ipcRenderer.invoke('hermes:updates:branch:set', name),
|
|
onProgress: callback => {
|
|
const listener = (_event, payload) => callback(payload)
|
|
ipcRenderer.on('hermes:updates:progress', listener)
|
|
|
|
return () => ipcRenderer.removeListener('hermes:updates:progress', listener)
|
|
}
|
|
},
|
|
themes: {
|
|
fetchMarketplace: id => ipcRenderer.invoke('hermes:vscode-theme:fetch', id),
|
|
searchMarketplace: query => ipcRenderer.invoke('hermes:vscode-theme:search', query)
|
|
}
|
|
})
|