Files
hermes-agent/apps/desktop/src/hermes.ts
T
Austin Pickett 0600ac2f77 feat(desktop): add Cron Blueprints to the GUI (#70066)
* feat(desktop): add Cron Blueprints to the GUI

The desktop app had a Cron jobs panel but no Blueprints tab, so the
parameterized automation templates the dashboard offers were unreachable
there (parity matrix: Dashboard=Y, GUI=N).

Adds a Jobs/Blueprints segmented toggle to the cron panel. The Blueprints
tab renders the catalog from the existing GET /api/cron/blueprints endpoint,
one card per template with a typed form (time/enum/weekdays/text slots).
Submitting POSTs to /api/cron/blueprints/instantiate, which fills the slots
and creates a real cron job via the same create_job path as a hand-written
one. The new job is merged into the shared  atom so the Jobs tab
and sidebar reflect it immediately.

Backend already served both endpoints; this is desktop frontend only.
Strings added to all four locales.

* fix(desktop): stop cron blueprint cards clipping and tabs overlapping close X

Blueprint cards were wrapped in PanelBlock (a max-h-48 overflow-auto <pre>
for monospace code), which capped each card and forced an inner scroll,
clipping the copy. Use a plain auto-height card div so rows grow with their
content and the gallery scrolls as one.

PanelHeader actions sat under the overlay's absolutely-positioned close X
(no layout space reserved). Reserve pr-8 clearance when actions are present.

* refactor(desktop): narrow blueprint card i18n dep, document intentional scoping

Address review nits on the Cron Blueprints PR:
- BlueprintCard's submit useCallback depended on the whole t.cron object; it
  only uses the blueprints slice. Bind const b = c.blueprints and use it (plus
  narrow the dep) throughout the card.
- Document the intentional GET-vs-POST profile asymmetry on the blueprint
  endpoints (global catalog vs per-profile instantiate) — the prior comment
  claimed both were profile-scoped.
- Note why the blueprints tab collapses 'all' scope to 'default' (a blueprint
  creates a real per-profile job; 'all' is not a writable target).

* fix(desktop): default blueprint delivery to This desktop, not origin

The blueprint catalog is shared with the dashboard, so its deliver slot
defaults to 'origin' (the chat/home-channel a dashboard or gateway job was
created from). Desktop has no origin chat and no home-channel picker, so the
seeded 'origin' rendered unlabeled and, at delivery, fell through the
home-channel fallback to nowhere when no gateway was configured.

Seed the deliver slot to 'local' (This desktop) when the backend default is
'origin' or empty, drop the origin option from the desktop dropdown, and label
the remaining options with the desktop's own delivery labels — matching the
manual cron editor (local/telegram/discord/slack/email). Also skip the
backend's origin-centric deliver help, which contradicts desktop semantics.

* refactor(desktop): align blueprint cards with the Panel/settings idiom

Address PR review (UI consistency with neighboring surfaces):
- Card container: drop the standalone-card look (bg-foreground/5) for the
  shared in-panel grouping token bg-(--ui-bg-quinary), matching the cron
  editor's in-surface groupings so blueprints sit in the Panel family.
- Form fields: replace the bespoke <label>+<Input> rows with the shared
  ListRow primitive from settings/primitives (label+help on the left, control
  on the right, stacks in a narrow pane) — same idiom as settings/messaging.

No behavior change; blueprint deliver remap and $cronJobs merge untouched.

* fix(desktop): satisfy eslint on the cron blueprint files

CI check:lint failed on import/export ordering and an unused import in the
blueprint changes:
- hermes.ts: sort AutomationBlueprint before AuxiliaryModelsResponse in the
  type import + re-export blocks, and drop the unused AutomationBlueprintField
  import (still re-exported for blueprints.tsx).
- cron/index.tsx: alphabetize the dialog/segmented-control imports and the
  ./blueprints vs ../shell/statusbar-controls group.
- blueprints.tsx: add the required blank lines between statements.

eslint --fix only; no behavior change. typecheck + blueprint tests green.

* refactor(desktop): blueprints reuse the cron editor dialog + shared card

Address review: the blueprint UI was still going its own way on the card and
form. Reuse the app's canonical pieces instead of a bespoke surface.

- CronEditorDialog gains a 'blueprint' mode: EditorState carries the blueprint
  + target profile, the dialog renders the typed slots with the same
  Field/FieldHint/DialogFooter/error-block chrome as manual New cron, and
  submit routes to instantiateAutomationBlueprint. One dialog, one editor state
  machine. Resolves the accordion, the border-t divider, ListRow-vs-Field, the
  ad-hoc buttons, and the plain error <p> in one move.
- Blueprint cards use selectableCardClass({ prominent: true }) (the shared
  theme/pet/gateway card idiom), caller owns padding (p-2), whole card is a
  button that opens the dialog pre-filled. No inline form.
- Gallery renders via PanelDetail, not PanelBody's master/detail row.
- i18n: drop the now-unused blueprints.setUp/cancel, add blueprints.dialogDesc
  across en/ja/zh/zh-hant + types.
- Dropped the stray \u2014 literal comment.

Logic (origin->local deliver, desktopDeliverOptions, merge into $cronJobs) is
unchanged and stays unit-tested. typecheck + eslint + tests green.

* fix(desktop): dropdown no longer closes the cron dialog; unify deliver targets

Two cron-dialog bugs:

1. Dismissing any Select dropdown inside the cron editor dialog closed the whole
   dialog. Radix portals Select/Popover content outside the dialog, so the
   dismiss pointerdown reached the Dialog's DismissableLayer as an
   outside-interaction. Guard DialogContent.onInteractOutside: swallow
   interactions originating from a [data-radix-popper-content-wrapper] (a
   dropdown dismiss inside our own dialog), compose with any caller handler. Fix
   is at the shared Dialog level so every dialog benefits.

2. Blueprint deliver only offered 'This desktop'. The blueprint used the backend
   blueprint field.options (configured gateways only) while the manual editor
   hardcoded local/telegram/discord/slack/email regardless of what's connected.
   Wire the desktop to GET /api/cron/delivery-targets (the documented single
   source of truth, already used by the dashboard) via getCronDeliveryTargets,
   and render both the manual editor and the blueprint deliver slot through one
   shared DeliverSelect. Now all three surfaces agree and only offer connected
   platforms; unconfigured-home-channel targets show a hint.

i18n: add cron.deliverNeedsHomeChannel across en/ja/zh/zh-hant + types.
typecheck + eslint + vitest green.

* fix(desktop): clicking away from an open dropdown no longer closes the dialog

The onInteractOutside guard only caught pointerdowns whose target was inside
the popper wrapper. But dismissing an open Select by clicking elsewhere inside
the dialog also closes the popover, which moves focus — and Radix Dialog reads
that as focusOutside and closes the whole dialog. (Radix Select 2.3.1 has no
modal prop, so that escape hatch isn't available.)

Guard both paths at the shared DialogContent level: onInteractOutside AND
onFocusOutside now swallow the event when it originates from a Radix popper OR
when any [data-radix-popper-content-wrapper] is open at event time (covers the
focus/re-dispatch case where the target is no longer the popper). A genuine
backdrop click with no dropdown open still closes the dialog. Export
isInteractionFromPopper + unit-test the three cases.

typecheck + eslint + vitest green (7 dialog tests).

* fix(desktop): portal popovers into their dialog so dropdowns don't close it

Root cause (affected every dialog, not just cron): Radix Select/Popover/
DropdownMenu portal to document.body — a SIBLING of the dialog, outside its DOM
subtree. Dismissing a dropdown (or clicking another field) moves focus out of
the dialog subtree, which the Dialog's modal FocusScope/DismissableLayer reads
as an outside interaction and closes the whole dialog. Separate body-level
portals also make z-index across the two fragile.

The earlier onInteractOutside/onFocusOutside guards treated symptoms and didn't
hold (and Radix Select 2.3.1 has no modal prop to disable its layer). Real fix
is a layering system: DialogContent publishes its content node via
DialogPortalContainerContext; SelectContent/PopoverContent/DropdownMenuContent
call usePopoverPortalContainer() and portal INTO that node when inside a dialog
(document.body otherwise). The popover is then a true DOM descendant of the
dialog — focus stays in, dismissal no longer closes the dialog, and both share
one stacking context so z-index is deterministic.

Test: with a Dialog open, an open Select's item is a descendant of the dialog
(portalled in), verified in jsdom. typecheck + eslint + component tests green.

* fix(desktop): bump radix-ui so dismissing a dropdown can't close its dialog

Upstream bug, not app-layer: with radix-ui 1.6.0 (dismissable-layer 1.1.13),
an open modal Select sets pointer-events: none on the dialog body, so a click
anywhere inside the dialog hit-tests through to the overlay. The Dialog's
DismissableLayer defers its outside-pointerdown decision to the click, but the
overlay is a registered dismissable surface exempt from the interception check
— so the Select swallowing the press didn't count, the deferred onDismiss
fired, and the dialog closed along with the dropdown.

dismissable-layer 1.1.17 adds shouldHandlePointerDownOutside, which makes the
dialog's layer ignore the press entirely while a higher layer has its pointer
events disabled. Bump radix-ui ^1.4.3 -> ^1.6.5 (dismissable-layer 1.1.17,
dialog 1.1.21, select 2.3.5); lockfile diff is Radix-only.

Repro test fires the pointerdown/up/click sequence on the overlay with a
Select open inside the dialog: red on 1.6.0 (dialog closed), green on 1.6.5.
Counterpart test keeps a genuine overlay click closing the dialog.

typecheck + dialog/select component tests green. Full-suite failures on this
Windows host reproduce identically without the bump (pre-existing env issues).
2026-07-24 14:22:43 -04:00

1714 lines
56 KiB
TypeScript

import { JsonRpcGatewayClient } from '@hermes/shared'
import type {
ActionResponse,
ActionStatusResponse,
AnalyticsResponse,
AudioSpeakResponse,
AudioTranscriptionResponse,
AutomationBlueprint,
AuxiliaryModelsResponse,
BackendUpdateCheckResponse,
ComputerUseStatus,
ConfigSchemaResponse,
CronDeliveryTarget,
CronJob,
CronJobCreatePayload,
CronJobUpdates,
CuratorStatusResponse,
CustomEndpointsResponse,
CustomEndpointUpdate,
CustomEndpointValidationResponse,
DebugShareResponse,
ElevenLabsVoicesResponse,
EnvVarInfo,
HermesConfig,
HermesConfigRecord,
LogsResponse,
McpCatalogResponse,
McpServerSummary,
MemoryProviderConfig,
MemoryProviderOAuthStatus,
MemoryStatusResponse,
MessagingPlatformsResponse,
MessagingPlatformTestResponse,
MessagingPlatformUpdate,
MoaConfigResponse,
ModelAssignmentRequest,
ModelAssignmentResponse,
ModelInfoResponse,
ModelOptionsResponse,
OAuthPollResponse,
OAuthProvidersResponse,
OAuthStartResponse,
OAuthSubmitResponse,
PaginatedSessions,
ProfileCreatePayload,
ProfileSetupCommand,
ProfileSoul,
ProfilesResponse,
SessionInfo,
SessionMessagesResponse,
SessionSearchResponse,
SkillHubPreview,
SkillHubScanResult,
SkillHubSearchResponse,
SkillHubSourcesResponse,
SkillInfo,
StarmapGraph,
StatusResponse,
TerminalBackendsResponse,
ToolsetConfig,
ToolsetInfo,
ToolsetModelsResponse,
WebhookCreatePayload,
WebhookCreateResponse,
WebhookEnableResponse,
WebhooksResponse
} from '@/types/hermes'
// Desktop startup fires a burst of read-only data calls (config, profiles,
// model info/options, cron) the moment the backend passes readiness. On a
// profile-heavy or remote install these can each take tens of seconds — e.g.
// /api/profiles runs list_profiles(), which does a recursive skill-tree walk
// per profile — so the 15s default (DEFAULT_FETCH_TIMEOUT_MS in hardening.ts)
// times out a backend that is alive-but-busy, surfacing as a spurious
// "Timed out connecting to Hermes backend" that hangs the UI (#48504).
//
// Give the boot burst a generous per-call timeout instead of raising the
// global default: interactive/runtime calls and the liveness poll (/api/status)
// keep the short default so a genuinely-dead backend is still detected fast.
export const STARTUP_REQUEST_TIMEOUT_MS = 60_000
const DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS = 30_000
const SESSION_LIST_REQUEST_TIMEOUT_MS = 60_000
// prompt.submit is effectively fire-and-forget: turn completion is signaled by
// stream / message.complete events, NOT by the RPC return. A long turn (MoA
// presets running references + aggregator in series, deep reasoning, large tool
// chains) can legitimately take minutes to ACK, so bounding the ack by the
// generic 30s default surfaces a false "request timed out" toast while the turn
// is still running and will succeed (issue #55024). Match the backend's
// agent-turn ceiling (agent.gateway_timeout = 1800s) so the ack timeout only
// ever fires when the turn itself would have been abandoned server-side.
export const PROMPT_SUBMIT_REQUEST_TIMEOUT_MS = 1_800_000
export const AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS = 180_000
export const AUDIO_SPEAK_MAX_REQUEST_TIMEOUT_MS = 600_000
const AUDIO_SPEAK_TIMEOUT_MS_PER_CHAR = 35
export function audioSpeakRequestTimeoutMs(text: string): number {
const estimated = Math.max(
AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS,
Math.ceil(String(text || '').length * AUDIO_SPEAK_TIMEOUT_MS_PER_CHAR)
)
return Math.min(AUDIO_SPEAK_MAX_REQUEST_TIMEOUT_MS, estimated)
}
export const AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS = 180_000
export const AUDIO_TRANSCRIBE_MAX_REQUEST_TIMEOUT_MS = 600_000
// The transcribe payload is the base64 audio data URL itself, so its string
// length tracks clip size. ~0.1ms/char keeps short clips at the floor while
// letting multi-minute recordings scale toward the cap (a base64 char is
// ~0.75 bytes, so at 128kbps ≈ 21k chars/s of audio this budgets ~2s of
// timeout per 1s of audio before the cap clamps it).
const AUDIO_TRANSCRIBE_TIMEOUT_MS_PER_CHAR = 0.1
export function audioTranscribeRequestTimeoutMs(dataUrl: string): number {
const estimated = Math.max(
AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS,
Math.ceil(String(dataUrl || '').length * AUDIO_TRANSCRIBE_TIMEOUT_MS_PER_CHAR)
)
return Math.min(AUDIO_TRANSCRIBE_MAX_REQUEST_TIMEOUT_MS, estimated)
}
export type {
ActionResponse,
ActionStatusResponse,
AnalyticsDailyEntry,
AnalyticsModelEntry,
AnalyticsResponse,
AnalyticsSkillEntry,
AnalyticsSkillsSummary,
AnalyticsTotals,
AudioSpeakResponse,
AudioTranscriptionResponse,
AutomationBlueprint,
AutomationBlueprintField,
AuxiliaryModelsResponse,
BackendUpdateCheckResponse,
ComputerUseCheck,
ComputerUsePermissionSource,
ComputerUseStatus,
ConfigFieldSchema,
ConfigSchemaResponse,
CronDeliveryTarget,
CronJob,
CronJobCreatePayload,
CronJobSchedule,
CronJobUpdates,
CuratorStatusResponse,
CustomEndpoint,
CustomEndpointsResponse,
CustomEndpointUpdate,
CustomEndpointValidationResponse,
DebugShareResponse,
ElevenLabsVoice,
ElevenLabsVoicesResponse,
EnvVarInfo,
GatewayReadyPayload,
HermesConfig,
HermesConfigRecord,
LogsResponse,
McpCatalogEntry,
McpCatalogResponse,
McpServerSummary,
McpServerTestResponse,
MemoryProviderConfig,
MemoryProviderOAuthStatus,
MemoryStatusResponse,
MessagingEnvVarInfo,
MessagingHomeChannel,
MessagingPlatformInfo,
MessagingPlatformsResponse,
MessagingPlatformTestResponse,
MessagingPlatformUpdate,
MoaConfigResponse,
MoaModelSlot,
ModelAssignmentRequest,
ModelAssignmentResponse,
ModelInfoResponse,
ModelOptionProvider,
ModelOptionsResponse,
PaginatedSessions,
ProfileCreatePayload,
ProfileInfo,
ProfileSetupCommand,
ProfileSoul,
ProfilesResponse,
ProjectFolder,
ProjectInfo,
ProjectsPayload,
RpcEvent,
SessionCreateResponse,
SessionInfo,
SessionMessage,
SessionMessagesResponse,
SessionResumeResponse,
SessionRuntimeInfo,
SessionSearchResponse,
SessionSearchResult,
SkillHubInstalledEntry,
SkillHubPreview,
SkillHubResult,
SkillHubScanResult,
SkillHubSearchResponse,
SkillHubSource,
SkillHubSourcesResponse,
SkillInfo,
StaleAuxAssignment,
StarmapGraph,
StatusResponse,
ToolsetConfig,
ToolsetInfo,
ToolsetModel,
ToolsetModelsResponse,
WebhookCreatePayload,
WebhookCreateResponse,
WebhookEnableResponse,
WebhookRoute,
WebhooksResponse
} from '@/types/hermes'
export class HermesGateway extends JsonRpcGatewayClient {
constructor() {
super({
closedErrorMessage: 'Hermes gateway connection closed',
connectErrorMessage: 'Could not connect to Hermes gateway',
createRequestId: nextId => nextId,
notConnectedErrorMessage: 'Hermes gateway is not connected',
requestTimeoutMs: DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS
})
}
}
// Profile that profile-scoped REST settings (config/env/skills/tools/model/…)
// should target. Mirrors $activeGatewayProfile, pushed in from the store via
// setApiRequestProfile so this module needs no store import (avoids a cycle).
// Electron main consumes request.profile to pick which backend *process* serves
// the call; each pooled backend already has its own HERMES_HOME, so no backend
// change is needed. Null → primary, so single-profile users are unaffected.
let _apiProfile: null | string = null
export function setApiRequestProfile(profile: null | string): void {
_apiProfile = profile || null
}
function profileScoped(profile?: null | string): { profile?: string } {
const selected = profile === undefined ? _apiProfile : profile
return selected ? { profile: selected } : {}
}
/** Options for a plugin REST call — mirrors the app's own `hermesDesktop.api`
* shape, minus the path (which is namespace-derived). */
export interface PluginRestOptions {
method?: string
body?: unknown
/** Single-file multipart upload (see HermesApiRequest.upload). */
upload?: { filename: string; contentType?: string; bytes: ArrayBuffer }
timeoutMs?: number
}
// Normalize `path` to a leading-slash suffix relative to `/api/plugins/<id>`.
// The namespace is the boundary — reject `..` so a relative segment can't
// normalize out into another plugin's API or a core route. Check the path
// portion only (before any query/hash).
function pluginPathSuffix(caller: string, path: string): string {
const suffix = path.startsWith('/') ? path : `/${path}`
if (suffix.split(/[?#]/, 1)[0].split('/').includes('..')) {
throw new Error(`${caller}: illegal path traversal in "${path}"`)
}
return suffix
}
/** The plugin REST door. Every call is scoped BY CONSTRUCTION to the plugin's
* own backend namespace — `path` is relative to `/api/plugins/<pluginId>`
* ('/board' → `/api/plugins/kanban/board`), so a plugin can't address another
* plugin's API or a core route through it. Profile-aware like every desktop
* REST call. Broader reach (core endpoints, another namespace) is the future
* declared-capability seam; today the namespace IS the boundary. */
export async function pluginRest<T>(pluginId: string, path: string, opts: PluginRestOptions = {}): Promise<T> {
if (!window.hermesDesktop?.api) {
throw new Error('Hermes desktop bridge unavailable')
}
const suffix = pluginPathSuffix('pluginRest', path)
return window.hermesDesktop.api<T>({
path: `/api/plugins/${pluginId}${suffix}`,
method: opts.method,
body: opts.body,
upload: opts.upload,
timeoutMs: opts.timeoutMs,
...profileScoped()
})
}
/** The plugin WebSocket door — the live twin of `pluginRest`, scoped the same
* way: `path` is relative to `/api/plugins/<pluginId>` ('/events' → the
* plugin's own event stream). Token-mode backends auth via the same query
* credential the app's own sockets use; OAuth remotes resolve null (callers
* keep their polling fallback — every consumer must have one anyway, since a
* socket can drop). Auto-reconnects with backoff until disposed. */
export function pluginSocket(pluginId: string, path: string, onMessage: (data: unknown) => void): () => void {
const suffix = pluginPathSuffix('pluginSocket', path)
let socket: null | WebSocket = null
let disposed = false
let attempt = 0
const connect = async () => {
const connection = await window.hermesDesktop.getConnection().catch(() => null)
// No bridge / OAuth cookie auth (WS tickets are single-use, core-managed):
// stay on the polling fallback rather than half-working.
if (disposed || !connection || connection.authMode === 'oauth') {
return
}
const base = connection.baseUrl.replace(/^http/, 'ws')
const join = suffix.includes('?') ? '&' : '?'
socket = new WebSocket(
`${base}/api/plugins/${pluginId}${suffix}${join}token=${encodeURIComponent(connection.token)}`
)
socket.onmessage = event => {
attempt = 0
try {
onMessage(JSON.parse(String(event.data)))
} catch {
// Non-JSON frame — plugin streams are JSON by contract; skip it.
}
}
socket.onclose = () => {
socket = null
if (!disposed) {
attempt += 1
window.setTimeout(() => void connect(), Math.min(30_000, 1_000 * 2 ** attempt))
}
}
}
void connect()
return () => {
disposed = true
socket?.close()
}
}
export async function listSessions(
limit = 40,
minMessages = 0,
archived: 'exclude' | 'include' | 'only' = 'exclude',
order: 'created' | 'recent' = 'recent'
): Promise<PaginatedSessions> {
const result = await window.hermesDesktop.api<PaginatedSessions>({
path:
`/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}` +
`&archived=${archived}&order=${order}`,
timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS
})
return {
...result,
sessions: result.sessions.slice(0, limit),
offset: 0
}
}
// Unified, read-only session list aggregated across ALL profiles. Served by the
// primary backend straight off each profile's state.db — no per-profile backend
// is spawned. Single-profile users get the same rows as listSessions(), tagged
// profile="default".
// Source scoping lets callers split the unified list into independent slices:
// recents pass `excludeSources: ['cron']`, the cron-jobs section passes
// `source: 'cron'`. Without this a burst of (always-newest) cron sessions
// consumes the whole recents page and starves real conversations.
export interface SessionSourceFilter {
source?: string
excludeSources?: string[]
}
export async function listAllProfileSessions(
limit = 40,
minMessages = 0,
archived: 'exclude' | 'include' | 'only' = 'exclude',
order: 'created' | 'recent' = 'recent',
profile: 'all' | (string & {}) = 'all',
filter: SessionSourceFilter = {}
): Promise<PaginatedSessions> {
const sourceParam = filter.source ? `&source=${encodeURIComponent(filter.source)}` : ''
const excludeParam = filter.excludeSources?.length
? `&exclude_sources=${encodeURIComponent(filter.excludeSources.join(','))}`
: ''
const result = await window.hermesDesktop.api<PaginatedSessions>({
path:
`/api/profiles/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}` +
`&archived=${archived}&order=${order}&profile=${encodeURIComponent(profile)}${sourceParam}${excludeParam}`,
timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS
})
return {
...result,
sessions: result.sessions.slice(0, limit),
offset: 0
}
}
// Batched sidebar slices in one request: recents (scoped to the active profile),
// cron, and messaging. The backend opens each profile's state.db once and runs
// all three filtered queries, replacing three separate listAllProfileSessions
// calls that each reopened + re-counted every profile DB per refresh. Electron
// splices remote profiles per slice (see interceptSessionRequestForRemote).
export interface SidebarSessionSlice {
sessions: SessionInfo[]
total?: number
profile_totals?: Record<string, number>
}
export interface SidebarSessionsResponse {
recents: SidebarSessionSlice
cron: SidebarSessionSlice
messaging: SidebarSessionSlice
errors?: Array<{ profile: string; error: string }>
}
export interface SidebarSessionsRequest {
recentsProfile: 'all' | (string & {})
recentsLimit: number
recentsExclude: string[]
cronLimit: number
messagingLimit: number
messagingExclude: string[]
}
// The batched /sidebar endpoint shipped later than the per-slice route, so a
// newer desktop can meet an older backend that 404s it ("No such API
// endpoint"). Endpoint-missing is a capability signal, not a transient
// failure: remember it (per renderer lifetime — a runtime home change reloads
// the window and re-probes) and serve every subsequent refresh straight from
// the three proven per-slice calls instead of re-probing a known-dead route
// once per turn/broadcast.
let sidebarBatchEndpointMissing = false
// Capability flags are per-backend facts. A hard re-home reloads the window
// (module state resets naturally), but a soft gateway switch re-dials in
// place — the next backend may well have the batched route, so the switch
// paths call this to re-probe rather than leak the old backend's capability.
export function resetSidebarBatchCapability() {
sidebarBatchEndpointMissing = false
}
// True only for "the route does not exist on this backend" shapes: the
// backend catch-all ('404: {"detail":"No such API endpoint: ...}'), FastAPI's
// bare 404 on headless serve (surfaces as '404: ...' directly or as
// "Error invoking remote method 'hermes:api': Error: 404: ..." through the
// IPC bridge), and the Electron JSON-guard ("endpoint is likely missing").
// This GET has no path params, so a 404 status can only mean route-missing —
// but transient failures (timeouts, 5xx, connection refused) must NOT match,
// or one blip would silently degrade the fast path for the whole session.
function isEndpointMissingError(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err)
return (
/no such api endpoint/i.test(message) ||
/endpoint is likely missing/i.test(message) ||
/(?:^\s*|error:\s*)404\b/i.test(message)
)
}
// Compatibility fallback: reassemble the three sidebar slices from the
// per-slice endpoint, mirroring the batched route's semantics (min_messages=1,
// archived excluded, recency order; recents scoped to the caller's profile,
// cron + messaging cross-profile). Rides the same Electron remote-splice
// interception as the pre-batching desktop, so remote profiles stay correct.
async function listSidebarSessionsLegacy(req: SidebarSessionsRequest): Promise<SidebarSessionsResponse> {
const [recents, cron, messaging] = await Promise.all([
listAllProfileSessions(req.recentsLimit, 1, 'exclude', 'recent', req.recentsProfile, {
excludeSources: req.recentsExclude
}),
listAllProfileSessions(req.cronLimit, 1, 'exclude', 'recent', 'all', { source: 'cron' }),
listAllProfileSessions(req.messagingLimit, 1, 'exclude', 'recent', 'all', {
excludeSources: req.messagingExclude
})
])
const errors = [...(recents.errors ?? []), ...(cron.errors ?? []), ...(messaging.errors ?? [])]
return {
recents: { profile_totals: recents.profile_totals, sessions: recents.sessions, total: recents.total },
cron: { sessions: cron.sessions },
messaging: { sessions: messaging.sessions },
...(errors.length ? { errors } : {})
}
}
export async function listSidebarSessions(req: SidebarSessionsRequest): Promise<SidebarSessionsResponse> {
if (sidebarBatchEndpointMissing) {
return listSidebarSessionsLegacy(req)
}
const params = new URLSearchParams({
recents_profile: req.recentsProfile,
recents_limit: String(Math.max(1, req.recentsLimit)),
cron_limit: String(Math.max(1, req.cronLimit)),
messaging_limit: String(Math.max(1, req.messagingLimit))
})
if (req.recentsExclude.length) {
params.set('recents_exclude', req.recentsExclude.join(','))
}
if (req.messagingExclude.length) {
params.set('messaging_exclude', req.messagingExclude.join(','))
}
let result: SidebarSessionsResponse
try {
result = await window.hermesDesktop.api<SidebarSessionsResponse>({
path: `/api/profiles/sessions/sidebar?${params.toString()}`,
timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS
})
} catch (err) {
if (!isEndpointMissingError(err)) {
throw err
}
// Older backend without the batched route (desktop/runtime version skew).
sidebarBatchEndpointMissing = true
return listSidebarSessionsLegacy(req)
}
return {
recents: { ...result.recents, sessions: result.recents?.sessions ?? [] },
cron: { ...result.cron, sessions: result.cron?.sessions ?? [] },
messaging: { ...result.messaging, sessions: result.messaging?.sessions ?? [] },
errors: result.errors
}
}
// Mutations take the owning `profile` so Electron routes them to that profile's
// backend (remote pool or local primary) via request.profile — matching the
// read path. A remote session's row lives only on its remote host, so a mutation
// that hit the local primary would no-op or 404. Omit for the current/default.
export function setSessionArchived(id: string, archived: boolean, profile?: string | null): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...(profile ? { profile } : {}),
path: `/api/sessions/${encodeURIComponent(id)}`,
method: 'PATCH',
body: { archived }
})
}
// Mirror a sidebar pin to the backend "keep" flag so the sessions.auto_archive
// sweep (which runs backend-side, blind to Desktop localStorage) never hides a
// pinned chat. Best-effort: the sidebar stays localStorage-driven for its own
// display; this only feeds the backend policy.
export function setSessionPinnedRemote(id: string, pinned: boolean, profile?: string | null): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...(profile ? { profile } : {}),
path: `/api/sessions/${encodeURIComponent(id)}`,
method: 'PATCH',
body: { pinned }
})
}
export function searchSessions(query: string): Promise<SessionSearchResponse> {
return window.hermesDesktop.api<SessionSearchResponse>({
path: `/api/sessions/search?q=${encodeURIComponent(query)}`
})
}
// Resolves a single session row by id on one backend (the active profile, or
// the given `profile`). The backend resolves exact ids and unique prefixes and
// 404s when the id isn't on that profile — so a cheap by-id lookup replaces the
// cross-profile list scan when locating an unknown id's owner.
export function getSession(id: string, profile?: string | null): Promise<SessionInfo> {
const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : ''
return window.hermesDesktop.api<SessionInfo>({
...(profile ? { profile } : {}),
path: `/api/sessions/${encodeURIComponent(id)}${suffix}`
})
}
// Reads another profile's transcript. For a remote profile Electron reroutes
// this GET to the remote backend (which serves its own state.db); for a local
// profile the primary opens that profile's state.db via ?profile=. Omit for
// the current/default profile.
export function getSessionMessages(id: string, profile?: string | null): Promise<SessionMessagesResponse> {
const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : ''
return window.hermesDesktop.api<SessionMessagesResponse>({
...(profile ? { profile } : {}),
path: `/api/sessions/${encodeURIComponent(id)}/messages${suffix}`
})
}
export function deleteSession(id: string, profile?: string | null): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...(profile ? { profile } : {}),
path: `/api/sessions/${encodeURIComponent(id)}`,
method: 'DELETE'
})
}
export function renameSession(
id: string,
title: string,
profile?: string | null
): Promise<{ ok: boolean; title: string }> {
return window.hermesDesktop.api<{ ok: boolean; title: string }>({
...(profile ? { profile } : {}),
path: `/api/sessions/${encodeURIComponent(id)}`,
method: 'PATCH',
body: { title, ...(profile ? { profile } : {}) }
})
}
export function getGlobalModelInfo(): Promise<ModelInfoResponse> {
return window.hermesDesktop.api<ModelInfoResponse>({
...profileScoped(),
path: '/api/model/info',
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
})
}
export function getStatus(): Promise<StatusResponse> {
return window.hermesDesktop.api<StatusResponse>({
...profileScoped(),
path: '/api/status'
})
}
export function getLogs(params: {
component?: string
file?: string
level?: string
lines?: number
search?: string
}): Promise<LogsResponse> {
const query = new URLSearchParams()
if (params.file) {
query.set('file', params.file)
}
if (typeof params.lines === 'number') {
query.set('lines', String(params.lines))
}
if (params.level && params.level !== 'ALL') {
query.set('level', params.level)
}
if (params.component && params.component !== 'all') {
query.set('component', params.component)
}
if (params.search) {
query.set('search', params.search)
}
const suffix = query.toString()
return window.hermesDesktop.api<LogsResponse>({
...profileScoped(),
path: suffix ? `/api/logs?${suffix}` : '/api/logs'
})
}
export function getHermesConfig(profile?: string): Promise<HermesConfig> {
return window.hermesDesktop.api<HermesConfig>({
...profileScoped(profile),
path: '/api/config',
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
})
}
export function getHermesConfigRecord(): Promise<HermesConfigRecord> {
return window.hermesDesktop.api<HermesConfigRecord>({
...profileScoped(),
path: '/api/config'
})
}
export function getHermesConfigDefaults(): Promise<HermesConfigRecord> {
return window.hermesDesktop.api<HermesConfigRecord>({
...profileScoped(),
path: '/api/config/defaults',
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
})
}
export function getHermesConfigSchema(): Promise<ConfigSchemaResponse> {
return window.hermesDesktop.api<ConfigSchemaResponse>({
...profileScoped(),
path: '/api/config/schema'
})
}
export function saveHermesConfig(config: HermesConfigRecord): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...profileScoped(),
path: '/api/config',
method: 'PUT',
body: { config }
})
}
// surface=declared serves the curated desktop schema; the dashboard consumes the raw plugin schema.
export function getMemoryProviderConfig(provider: string): Promise<MemoryProviderConfig> {
return window.hermesDesktop.api<MemoryProviderConfig>({
...profileScoped(),
path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared`
})
}
export function saveMemoryProviderConfig(provider: string, values: Record<string, string>): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...profileScoped(),
path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared`,
method: 'PUT',
body: { values }
})
}
export function getEnvVars(): Promise<Record<string, EnvVarInfo>> {
return window.hermesDesktop.api<Record<string, EnvVarInfo>>({
...profileScoped(),
path: '/api/env'
})
}
export function setEnvVar(key: string, value: string): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...profileScoped(),
path: '/api/env',
method: 'PUT',
body: { key, value }
})
}
export function validateProviderCredential(
key: string,
value: string,
apiKey?: string
): Promise<{ ok: boolean; reachable: boolean; message: string; models?: string[] }> {
return window.hermesDesktop.api<{ ok: boolean; reachable: boolean; message: string; models?: string[] }>({
...profileScoped(),
path: '/api/providers/validate',
method: 'POST',
body: { key, value, api_key: apiKey ?? '' }
})
}
export function getCustomEndpoints(): Promise<CustomEndpointsResponse> {
return window.hermesDesktop.api<CustomEndpointsResponse>({
path: '/api/providers/custom-endpoints'
})
}
export function saveCustomEndpoint(endpoint: CustomEndpointUpdate): Promise<CustomEndpointsResponse> {
return window.hermesDesktop.api<CustomEndpointsResponse>({
path: '/api/providers/custom-endpoints',
method: 'POST',
body: endpoint
})
}
export function validateCustomEndpoint(endpoint: CustomEndpointUpdate): Promise<CustomEndpointValidationResponse> {
return window.hermesDesktop.api<CustomEndpointValidationResponse>({
path: '/api/providers/custom-endpoints/validate',
method: 'POST',
body: endpoint
})
}
export function activateCustomEndpoint(id: string): Promise<{ ok: boolean; provider: string; model: string }> {
return window.hermesDesktop.api<{ ok: boolean; provider: string; model: string }>({
path: `/api/providers/custom-endpoints/${encodeURIComponent(id)}/activate`,
method: 'POST'
})
}
export function deleteCustomEndpoint(id: string): Promise<CustomEndpointsResponse> {
return window.hermesDesktop.api<CustomEndpointsResponse>({
path: `/api/providers/custom-endpoints/${encodeURIComponent(id)}`,
method: 'DELETE'
})
}
export function deleteEnvVar(key: string): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...profileScoped(),
path: '/api/env',
method: 'DELETE',
body: { key }
})
}
export function revealEnvVar(key: string): Promise<{ key: string; value: string }> {
return window.hermesDesktop.api<{ key: string; value: string }>({
...profileScoped(),
path: '/api/env/reveal',
method: 'POST',
body: { key }
})
}
export function listOAuthProviders(): Promise<OAuthProvidersResponse> {
return window.hermesDesktop.api<OAuthProvidersResponse>({
...profileScoped(),
path: '/api/providers/oauth'
})
}
export function disconnectOAuthProvider(providerId: string): Promise<{ ok: boolean; provider: string }> {
return window.hermesDesktop.api<{ ok: boolean; provider: string }>({
...profileScoped(),
path: `/api/providers/oauth/${encodeURIComponent(providerId)}`,
method: 'DELETE'
})
}
export function startOAuthLogin(providerId: string): Promise<OAuthStartResponse> {
return window.hermesDesktop.api<OAuthStartResponse>({
...profileScoped(),
path: `/api/providers/oauth/${encodeURIComponent(providerId)}/start`,
method: 'POST',
body: {}
})
}
export function submitOAuthCode(providerId: string, sessionId: string, code: string): Promise<OAuthSubmitResponse> {
return window.hermesDesktop.api<OAuthSubmitResponse>({
...profileScoped(),
path: `/api/providers/oauth/${encodeURIComponent(providerId)}/submit`,
method: 'POST',
body: { session_id: sessionId, code }
})
}
export function pollOAuthSession(providerId: string, sessionId: string): Promise<OAuthPollResponse> {
return window.hermesDesktop.api<OAuthPollResponse>({
...profileScoped(),
path: `/api/providers/oauth/${encodeURIComponent(providerId)}/poll/${encodeURIComponent(sessionId)}`
})
}
export function cancelOAuthSession(sessionId: string): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...profileScoped(),
path: `/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`,
method: 'DELETE'
})
}
// Memory-provider OAuth connect (provider-keyed; 404s for providers without an
// OAuth flow). Profile-scoped: the grant lands in the active profile's config.
export function startMemoryProviderOAuth(provider: string): Promise<MemoryProviderOAuthStatus> {
return window.hermesDesktop.api<MemoryProviderOAuthStatus>({
...profileScoped(),
path: `/api/memory/providers/${encodeURIComponent(provider)}/oauth/start`,
method: 'POST'
})
}
export function getMemoryProviderOAuthStatus(provider: string): Promise<MemoryProviderOAuthStatus> {
return window.hermesDesktop.api<MemoryProviderOAuthStatus>({
...profileScoped(),
path: `/api/memory/providers/${encodeURIComponent(provider)}/oauth/status`
})
}
export function getSkills(): Promise<SkillInfo[]> {
return window.hermesDesktop.api<SkillInfo[]>({
...profileScoped(),
path: '/api/skills'
})
}
export function getStarmapGraph(): Promise<StarmapGraph> {
return window.hermesDesktop.api<StarmapGraph>({
...profileScoped(),
// Backend REST contract — stays /api/learning even though the UI feature is
// now "star map". Renaming this would break against an un-upgraded backend.
path: '/api/learning/graph'
})
}
export interface LearningNodeDetail {
content: string
kind: 'memory' | 'skill'
label: string
ok: boolean
}
export function getLearningNode(id: string): Promise<LearningNodeDetail> {
return window.hermesDesktop.api<LearningNodeDetail>({
...profileScoped(),
path: `/api/learning/node?id=${encodeURIComponent(id)}`
})
}
export function deleteLearningNode(id: string): Promise<{ message: string; ok: boolean }> {
return window.hermesDesktop.api<{ message: string; ok: boolean }>({
...profileScoped(),
path: '/api/learning/node',
method: 'DELETE',
body: { id }
})
}
export function editLearningNode(id: string, content: string): Promise<{ message: string; ok: boolean }> {
return window.hermesDesktop.api<{ message: string; ok: boolean }>({
...profileScoped(),
path: '/api/learning/node',
method: 'PUT',
body: { content, id }
})
}
export function toggleSkill(name: string, enabled: boolean): Promise<{ ok: boolean; name: string; enabled: boolean }> {
return window.hermesDesktop.api<{ ok: boolean; name: string; enabled: boolean }>({
...profileScoped(),
path: '/api/skills/toggle',
method: 'PUT',
body: { name, enabled }
})
}
export interface McpTestResult {
ok: boolean
error?: string
tools: { name: string; description: string }[]
/** Capability counts (absent on older backends / failed probes). */
prompts?: number
resources?: number
}
export interface McpOAuthFlow {
flow_id: string
server_name: string
status: 'starting' | 'authorization_required' | 'approved' | 'error'
authorization_url: string | null
error: string | null
tools?: { name: string; description: string }[]
}
/** Connect to the server, list its tools, disconnect. Slow (spawns/handshakes
* for real) — well past the 15s default fetch timeout. */
export function testMcpServer(name: string): Promise<McpTestResult> {
return window.hermesDesktop.api<McpTestResult>({
...profileScoped(),
path: `/api/mcp/servers/${encodeURIComponent(name)}/test`,
method: 'POST',
timeoutMs: 60_000
})
}
/** Replace the whole `mcp_servers` map (the mcp.json editor's save). Unlike
* `saveHermesConfig`, this REPLACES rather than deep-merges, so deletes,
* re-enables (dropping `enabled: false`), and removed nested fields persist. */
export function saveMcpServers(servers: Record<string, Record<string, unknown>>): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...profileScoped(),
path: '/api/mcp/servers',
method: 'PUT',
body: { servers }
})
}
/** Start an MCP OAuth flow and return the authorization URL. */
export function authMcpServer(name: string): Promise<McpOAuthFlow> {
return window.hermesDesktop.api<McpOAuthFlow>({
...profileScoped(),
path: `/api/mcp/servers/${encodeURIComponent(name)}/auth`,
method: 'POST',
timeoutMs: 60_000
})
}
export function getMcpOAuthFlow(flowId: string): Promise<McpOAuthFlow> {
return window.hermesDesktop.api<McpOAuthFlow>({
...profileScoped(),
path: `/api/mcp/oauth/flows/${encodeURIComponent(flowId)}`
})
}
export function getToolsets(): Promise<ToolsetInfo[]> {
return window.hermesDesktop.api<ToolsetInfo[]>({
...profileScoped(),
path: '/api/tools/toolsets'
})
}
export function toggleToolset(
name: string,
enabled: boolean
): Promise<{ ok: boolean; name: string; enabled: boolean }> {
return window.hermesDesktop.api<{ ok: boolean; name: string; enabled: boolean }>({
...profileScoped(),
path: `/api/tools/toolsets/${encodeURIComponent(name)}`,
method: 'PUT',
body: { enabled }
})
}
export function getToolsetConfig(name: string): Promise<ToolsetConfig> {
return window.hermesDesktop.api<ToolsetConfig>({
...profileScoped(),
path: `/api/tools/toolsets/${encodeURIComponent(name)}/config`
})
}
export function getToolsetModels(name: string, provider?: string): Promise<ToolsetModelsResponse> {
const suffix = provider ? `?provider=${encodeURIComponent(provider)}` : ''
return window.hermesDesktop.api<ToolsetModelsResponse>({
...profileScoped(),
path: `/api/tools/toolsets/${encodeURIComponent(name)}/models${suffix}`
})
}
export function selectToolsetModel(
name: string,
model: string,
provider?: string
): Promise<{ ok: boolean; name: string; model: string }> {
return window.hermesDesktop.api<{ ok: boolean; name: string; model: string }>({
...profileScoped(),
path: `/api/tools/toolsets/${encodeURIComponent(name)}/model`,
method: 'PUT',
body: { model, provider }
})
}
export interface SelectToolsetProviderResponse {
ok: boolean
name: string
provider: string
/** Present when the selection was scoped to one web capability. */
capability?: string
/** Present (true) when a managed Nous row was selected but the Portal
* entitlement is missing — the row won't activate until the user signs
* in to Nous Portal. */
needs_nous_auth?: boolean
/** The managed feature key (e.g. "browser") when needs_nous_auth is set. */
feature?: string
}
export function selectToolsetProvider(
name: string,
provider: string,
capability?: 'search' | 'extract'
): Promise<SelectToolsetProviderResponse> {
return window.hermesDesktop.api<SelectToolsetProviderResponse>({
...profileScoped(),
path: `/api/tools/toolsets/${encodeURIComponent(name)}/provider`,
method: 'PUT',
body: capability ? { provider, capability } : { provider }
})
}
export function runToolsetPostSetup(name: string, key: string): Promise<ActionResponse & { key: string }> {
return window.hermesDesktop.api<ActionResponse & { key: string }>({
...profileScoped(),
path: `/api/tools/toolsets/${encodeURIComponent(name)}/post-setup`,
method: 'POST',
body: { key }
})
}
export function getTerminalBackends(): Promise<TerminalBackendsResponse> {
return window.hermesDesktop.api<TerminalBackendsResponse>({
...profileScoped(),
path: '/api/tools/terminal/backends'
})
}
export function selectTerminalBackend(backend: string): Promise<{ ok: boolean; backend: string }> {
return window.hermesDesktop.api<{ ok: boolean; backend: string }>({
...profileScoped(),
path: '/api/tools/terminal/backend',
method: 'PUT',
body: { backend }
})
}
export function getComputerUseStatus(): Promise<ComputerUseStatus> {
return window.hermesDesktop.api<ComputerUseStatus>({
...profileScoped(),
path: '/api/tools/computer-use/status'
})
}
export function grantComputerUsePermissions(): Promise<ActionResponse> {
return window.hermesDesktop.api<ActionResponse>({
...profileScoped(),
path: '/api/tools/computer-use/permissions/grant',
method: 'POST'
})
}
export function getMessagingPlatforms(): Promise<MessagingPlatformsResponse> {
return window.hermesDesktop.api<MessagingPlatformsResponse>({
path: '/api/messaging/platforms'
})
}
export function updateMessagingPlatform(
platformId: string,
body: MessagingPlatformUpdate
): Promise<{ ok: boolean; platform: string }> {
return window.hermesDesktop.api<{ ok: boolean; platform: string }>({
path: `/api/messaging/platforms/${encodeURIComponent(platformId)}`,
method: 'PUT',
body
})
}
export function testMessagingPlatform(platformId: string): Promise<MessagingPlatformTestResponse> {
return window.hermesDesktop.api<MessagingPlatformTestResponse>({
path: `/api/messaging/platforms/${encodeURIComponent(platformId)}/test`,
method: 'POST'
})
}
// -- Webhooks (subscription CRUD) --------------------------------------------
// The webhook receiver is its own gateway platform; subscriptions live in a
// shared JSON store the CLI/dashboard also drive. Enable mutates config and
// best-effort restarts the gateway; subscription changes hot-reload.
export function getWebhooks(): Promise<WebhooksResponse> {
return window.hermesDesktop.api<WebhooksResponse>({
...profileScoped(),
path: '/api/webhooks'
})
}
export function enableWebhooks(): Promise<WebhookEnableResponse> {
return window.hermesDesktop.api<WebhookEnableResponse>({
...profileScoped(),
path: '/api/webhooks/enable',
method: 'POST'
})
}
export function createWebhook(body: WebhookCreatePayload): Promise<WebhookCreateResponse> {
return window.hermesDesktop.api<WebhookCreateResponse>({
...profileScoped(),
path: '/api/webhooks',
method: 'POST',
body
})
}
export function deleteWebhook(name: string): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...profileScoped(),
path: `/api/webhooks/${encodeURIComponent(name)}`,
method: 'DELETE'
})
}
export function setWebhookEnabled(
name: string,
enabled: boolean
): Promise<{ enabled: boolean; name: string; ok: boolean }> {
return window.hermesDesktop.api<{ enabled: boolean; name: string; ok: boolean }>({
...profileScoped(),
path: `/api/webhooks/${encodeURIComponent(name)}/enabled`,
method: 'PUT',
body: { enabled }
})
}
// Cron jobs are stored per-profile (<HERMES_HOME>/cron/jobs.json), and the
// backend's list endpoint defaults to 'all'. Pass a concrete profile key to
// list just that profile's jobs, or 'all' for the unified cross-profile view.
// Omitting the arg keeps the legacy 'all' default for non-profile callers.
// profileScoped() still rides along for backend-process routing.
export function getCronJobs(profile?: string): Promise<CronJob[]> {
const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : ''
return window.hermesDesktop.api<CronJob[]>({
...profileScoped(),
path: `/api/cron/jobs${suffix}`,
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
})
}
export function getCronJob(jobId: string): Promise<CronJob> {
return window.hermesDesktop.api<CronJob>({
...profileScoped(),
path: `/api/cron/jobs/${encodeURIComponent(jobId)}`
})
}
export async function getCronJobRuns(jobId: string, limit = 20): Promise<SessionInfo[]> {
const { runs } = await window.hermesDesktop.api<{ runs: SessionInfo[] }>({
...profileScoped(),
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/runs?limit=${limit}`
})
return runs ?? []
}
// The single source of truth for cron delivery targets (local + configured
// gateways). Both the manual cron editor and the blueprint dialog use this so
// they never offer a platform that isn't connected. Mirrors the dashboard.
export async function getCronDeliveryTargets(): Promise<CronDeliveryTarget[]> {
const { targets } = await window.hermesDesktop.api<{ targets: CronDeliveryTarget[] }>({
...profileScoped(),
path: '/api/cron/delivery-targets'
})
return targets ?? []
}
export function createCronJob(body: CronJobCreatePayload): Promise<CronJob> {
return window.hermesDesktop.api<CronJob>({
...profileScoped(),
path: '/api/cron/jobs',
method: 'POST',
body
})
}
export function updateCronJob(jobId: string, updates: CronJobUpdates): Promise<CronJob> {
return window.hermesDesktop.api<CronJob>({
...profileScoped(),
path: `/api/cron/jobs/${encodeURIComponent(jobId)}`,
method: 'PUT',
body: { updates }
})
}
export function pauseCronJob(jobId: string): Promise<CronJob> {
return window.hermesDesktop.api<CronJob>({
...profileScoped(),
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/pause`,
method: 'POST'
})
}
export function resumeCronJob(jobId: string): Promise<CronJob> {
return window.hermesDesktop.api<CronJob>({
...profileScoped(),
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/resume`,
method: 'POST'
})
}
export function triggerCronJob(jobId: string): Promise<CronJob> {
return window.hermesDesktop.api<CronJob>({
...profileScoped(),
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/trigger`,
method: 'POST'
})
}
export function deleteCronJob(jobId: string): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...profileScoped(),
path: `/api/cron/jobs/${encodeURIComponent(jobId)}`,
method: 'DELETE'
})
}
// Automation Blueprints — parameterized cron templates the backend serves from
// cron/blueprint_catalog.py. getAutomationBlueprints returns the gallery
// (deliver options already rewritten to this machine's configured gateways);
// instantiateAutomationBlueprint fills the slots and creates a real cron job via
// the same create_job path as createCronJob.
//
// Profile-scoping is intentionally asymmetric: the GET catalog is global (the
// list endpoint takes no profile — only deliver options are rewritten from the
// configured gateways), so it carries only the profileScoped() header for
// routing. instantiate creates a real per-profile job, so it names the target
// profile explicitly via ?profile=. This mirrors the dashboard's api.ts.
export function getAutomationBlueprints(): Promise<{ blueprints: AutomationBlueprint[] }> {
return window.hermesDesktop.api<{ blueprints: AutomationBlueprint[] }>({
...profileScoped(),
path: '/api/cron/blueprints',
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
})
}
export function instantiateAutomationBlueprint(
body: { blueprint: string; values: Record<string, string> },
profile: string
): Promise<CronJob> {
return window.hermesDesktop.api<CronJob>({
...profileScoped(),
path: `/api/cron/blueprints/instantiate?profile=${encodeURIComponent(profile)}`,
method: 'POST',
body
})
}
export function getProfiles(): Promise<ProfilesResponse> {
return window.hermesDesktop.api<ProfilesResponse>({
path: '/api/profiles',
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
})
}
export function createProfile(body: ProfileCreatePayload): Promise<{ name: string; ok: boolean; path: string }> {
return window.hermesDesktop.api<{ name: string; ok: boolean; path: string }>({
path: '/api/profiles',
method: 'POST',
body
})
}
export function renameProfile(name: string, newName: string): Promise<{ name: string; ok: boolean; path: string }> {
return window.hermesDesktop.api<{ name: string; ok: boolean; path: string }>({
path: `/api/profiles/${encodeURIComponent(name)}`,
method: 'PATCH',
body: { new_name: newName }
})
}
export function deleteProfile(name: string): Promise<{ ok: boolean; path: string }> {
return window.hermesDesktop.api<{ ok: boolean; path: string }>({
path: `/api/profiles/${encodeURIComponent(name)}`,
method: 'DELETE'
})
}
export function getProfileSoul(name: string): Promise<ProfileSoul> {
return window.hermesDesktop.api<ProfileSoul>({
path: `/api/profiles/${encodeURIComponent(name)}/soul`
})
}
export function updateProfileSoul(name: string, content: string): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
path: `/api/profiles/${encodeURIComponent(name)}/soul`,
method: 'PUT',
body: { content }
})
}
export function getProfileSetupCommand(name: string): Promise<ProfileSetupCommand> {
return window.hermesDesktop.api<ProfileSetupCommand>({
path: `/api/profiles/${encodeURIComponent(name)}/setup-command`
})
}
export function getUsageAnalytics(days = 30): Promise<AnalyticsResponse> {
return window.hermesDesktop.api<AnalyticsResponse>({
...profileScoped(),
path: `/api/analytics/usage?days=${Math.max(1, Math.floor(days))}`
})
}
export function getGlobalModelOptions(opts?: {
refresh?: boolean
includeUnconfigured?: boolean
explicitOnly?: boolean
}): Promise<ModelOptionsResponse> {
const params = new URLSearchParams()
if (opts?.refresh) {
params.set('refresh', '1')
}
if (opts?.includeUnconfigured) {
params.set('include_unconfigured', '1')
}
if (opts?.explicitOnly !== false) {
params.set('explicit_only', '1')
}
return window.hermesDesktop.api<ModelOptionsResponse>({
...profileScoped(),
path: params.size > 0 ? `/api/model/options?${params.toString()}` : '/api/model/options',
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
})
}
export interface RecommendedDefaultModel {
provider: string
model: string
/** True/false for Nous (free vs paid tier); null for other providers. */
free_tier: boolean | null
}
// Recommended default model for a freshly-authenticated provider. Mirrors the
// curation `hermes model` does — for Nous it honors the free/paid tier so a
// free user gets a free model instead of a paid default.
export function getRecommendedDefaultModel(provider: string): Promise<RecommendedDefaultModel> {
return window.hermesDesktop.api<RecommendedDefaultModel>({
...profileScoped(),
path: `/api/model/recommended-default?provider=${encodeURIComponent(provider)}`
})
}
export function setGlobalModel(
provider: string,
model: string
): Promise<{ ok: boolean; provider: string; model: string }> {
return window.hermesDesktop.api<{ ok: boolean; provider: string; model: string }>({
...profileScoped(),
path: '/api/model/set',
method: 'POST',
body: {
scope: 'main',
provider,
model
}
})
}
export function getAuxiliaryModels(): Promise<AuxiliaryModelsResponse> {
return window.hermesDesktop.api<AuxiliaryModelsResponse>({
...profileScoped(),
path: '/api/model/auxiliary'
})
}
export function getMoaModels(): Promise<MoaConfigResponse> {
return window.hermesDesktop.api<MoaConfigResponse>({
...profileScoped(),
path: '/api/model/moa'
})
}
export function saveMoaModels(body: MoaConfigResponse): Promise<MoaConfigResponse & { ok: boolean }> {
return window.hermesDesktop.api<MoaConfigResponse & { ok: boolean }>({
...profileScoped(),
path: '/api/model/moa',
method: 'PUT',
body
})
}
export function setModelAssignment(body: ModelAssignmentRequest): Promise<ModelAssignmentResponse> {
return window.hermesDesktop.api<ModelAssignmentResponse>({
...profileScoped(),
path: '/api/model/set',
method: 'POST',
body
})
}
export function restartGateway(): Promise<ActionResponse> {
return window.hermesDesktop.api<ActionResponse>({
...profileScoped(),
path: '/api/gateway/restart',
method: 'POST'
})
}
export function updateHermes(): Promise<ActionResponse> {
return window.hermesDesktop.api<ActionResponse>({
...profileScoped(),
path: '/api/hermes/update',
method: 'POST'
})
}
/** Query the connected backend's own update state. In remote mode this is the
* authoritative source for the backend's behind-count + "what's changed",
* distinct from the Electron client clone's git state. */
export function checkHermesUpdate(force = false): Promise<BackendUpdateCheckResponse> {
return window.hermesDesktop.api<BackendUpdateCheckResponse>({
...profileScoped(),
path: `/api/hermes/update/check${force ? '?force=true' : ''}`
})
}
export function getActionStatus(name: string, lines = 200): Promise<ActionStatusResponse> {
return window.hermesDesktop.api<ActionStatusResponse>({
...profileScoped(),
path: `/api/actions/${encodeURIComponent(name)}/status?lines=${Math.max(1, lines)}`
})
}
export function transcribeAudio(dataUrl: string, mimeType?: string): Promise<AudioTranscriptionResponse> {
return window.hermesDesktop.api<AudioTranscriptionResponse>({
path: '/api/audio/transcribe',
method: 'POST',
body: {
data_url: dataUrl,
mime_type: mimeType
},
// Transcription blocks until provider STT, file handling, and response
// encoding finish. Remote providers and long clips regularly exceed the
// default 15s Electron backend timeout.
timeoutMs: audioTranscribeRequestTimeoutMs(dataUrl)
})
}
export function speakText(text: string): Promise<AudioSpeakResponse> {
return window.hermesDesktop.api<AudioSpeakResponse>({
path: '/api/audio/speak',
method: 'POST',
body: { text },
// TTS blocks until provider synthesis, file read, and base64 encoding
// finish. Remote providers and large messages regularly exceed the
// default 15s Electron backend timeout.
timeoutMs: audioSpeakRequestTimeoutMs(text)
})
}
export function getElevenLabsVoices(): Promise<ElevenLabsVoicesResponse> {
return window.hermesDesktop.api<ElevenLabsVoicesResponse>({
path: '/api/audio/elevenlabs/voices'
})
}
// ---------------------------------------------------------------------------
// Skills hub — search / preview / scan / install (parity with `hermes skills`
// and the dashboard's Browse-hub tab). Installs spawn background actions whose
// logs are tailed via getActionStatus().
// ---------------------------------------------------------------------------
const HUB_REQUEST_TIMEOUT_MS = 45_000
export function getSkillHubSources(): Promise<SkillHubSourcesResponse> {
return window.hermesDesktop.api<SkillHubSourcesResponse>({
...profileScoped(),
path: '/api/skills/hub/sources',
timeoutMs: HUB_REQUEST_TIMEOUT_MS
})
}
export function searchSkillsHub(query: string, source = 'all', limit = 20): Promise<SkillHubSearchResponse> {
const params = new URLSearchParams({ q: query, source, limit: String(limit) })
return window.hermesDesktop.api<SkillHubSearchResponse>({
...profileScoped(),
path: `/api/skills/hub/search?${params.toString()}`,
timeoutMs: HUB_REQUEST_TIMEOUT_MS
})
}
export function previewSkillHub(identifier: string): Promise<SkillHubPreview> {
return window.hermesDesktop.api<SkillHubPreview>({
...profileScoped(),
path: `/api/skills/hub/preview?identifier=${encodeURIComponent(identifier)}`,
timeoutMs: HUB_REQUEST_TIMEOUT_MS
})
}
export function scanSkillHub(identifier: string): Promise<SkillHubScanResult> {
return window.hermesDesktop.api<SkillHubScanResult>({
...profileScoped(),
path: `/api/skills/hub/scan?identifier=${encodeURIComponent(identifier)}`,
timeoutMs: HUB_REQUEST_TIMEOUT_MS
})
}
export function installSkillFromHub(identifier: string): Promise<ActionResponse> {
return window.hermesDesktop.api<ActionResponse>({
...profileScoped(),
path: '/api/skills/hub/install',
method: 'POST',
body: { identifier }
})
}
export function uninstallSkillFromHub(name: string): Promise<ActionResponse> {
return window.hermesDesktop.api<ActionResponse>({
...profileScoped(),
path: '/api/skills/hub/uninstall',
method: 'POST',
body: { name }
})
}
export function updateSkillsFromHub(): Promise<ActionResponse> {
return window.hermesDesktop.api<ActionResponse>({
...profileScoped(),
path: '/api/skills/hub/update',
method: 'POST',
body: {}
})
}
// ---------------------------------------------------------------------------
// MCP servers — structured list / test / enable toggle / catalog (parity with
// `hermes mcp` and the dashboard MCP page). Raw JSON editing stays in
// config.yaml via saveHermesConfig.
// ---------------------------------------------------------------------------
export function listMcpServers(): Promise<{ servers: McpServerSummary[] }> {
return window.hermesDesktop.api<{ servers: McpServerSummary[] }>({
...profileScoped(),
path: '/api/mcp/servers'
})
}
export function setMcpServerEnabled(name: string, enabled: boolean): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...profileScoped(),
path: `/api/mcp/servers/${encodeURIComponent(name)}/enabled`,
method: 'PUT',
body: { enabled }
})
}
export function getMcpCatalog(): Promise<McpCatalogResponse> {
return window.hermesDesktop.api<McpCatalogResponse>({
...profileScoped(),
path: '/api/mcp/catalog'
})
}
export function installMcpCatalogEntry(
name: string,
env: Record<string, string> = {}
): Promise<{ ok: boolean; name?: string; pid?: number; action?: string; background?: boolean }> {
return window.hermesDesktop.api<{ ok: boolean; name?: string; pid?: number; action?: string; background?: boolean }>({
...profileScoped(),
path: '/api/mcp/catalog/install',
method: 'POST',
body: { name, env, enable: true },
timeoutMs: 60_000
})
}
// ---------------------------------------------------------------------------
// Memory data + curator (parity with `hermes memory` / `hermes curator`).
// ---------------------------------------------------------------------------
export function getMemoryStatus(): Promise<MemoryStatusResponse> {
return window.hermesDesktop.api<MemoryStatusResponse>({
...profileScoped(),
path: '/api/memory'
})
}
export function resetMemory(target: 'all' | 'memory' | 'user'): Promise<{ ok: boolean; deleted: string[] }> {
return window.hermesDesktop.api<{ ok: boolean; deleted: string[] }>({
...profileScoped(),
path: '/api/memory/reset',
method: 'POST',
body: { target }
})
}
export function getCuratorStatus(): Promise<CuratorStatusResponse> {
return window.hermesDesktop.api<CuratorStatusResponse>({
...profileScoped(),
path: '/api/curator'
})
}
export function setCuratorPaused(paused: boolean): Promise<{ ok: boolean; paused: boolean }> {
return window.hermesDesktop.api<{ ok: boolean; paused: boolean }>({
...profileScoped(),
path: '/api/curator/paused',
method: 'PUT',
body: { paused }
})
}
export function runCurator(): Promise<ActionResponse> {
return window.hermesDesktop.api<ActionResponse>({
...profileScoped(),
path: '/api/curator/run',
method: 'POST',
body: {}
})
}
// ---------------------------------------------------------------------------
// Maintenance operations (parity with `hermes doctor` / `hermes security
// audit` / `hermes backup` / `hermes debug share` and the dashboard System
// page). All except debug share are spawn-based background actions tailed via
// getActionStatus().
// ---------------------------------------------------------------------------
export function runDoctor(): Promise<ActionResponse> {
return window.hermesDesktop.api<ActionResponse>({ path: '/api/ops/doctor', method: 'POST', body: {} })
}
export function runSecurityAudit(): Promise<ActionResponse> {
return window.hermesDesktop.api<ActionResponse>({ path: '/api/ops/security-audit', method: 'POST', body: {} })
}
export function runBackup(): Promise<ActionResponse & { archive?: string }> {
return window.hermesDesktop.api<ActionResponse & { archive?: string }>({
path: '/api/ops/backup',
method: 'POST',
body: {}
})
}
export function runDebugShare(): Promise<DebugShareResponse> {
return window.hermesDesktop.api<DebugShareResponse>({
path: '/api/ops/debug-share',
method: 'POST',
body: {},
// Synchronous upload of report + logs to the paste service.
timeoutMs: 120_000
})
}