File size: 9,206 Bytes
a1b6014
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
import { JsonRpcGatewayClient } from '@hermes/shared'

import type { HermesApiRequest } from '@/global'

// 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
// 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 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 as request scope. Local calls whose
// REST handlers accept profile reuse the primary dashboard via ?profile=;
// unscoped handlers retain a profile backend. Remote overrides still route to
// their owning backend. Null β†’ primary, so single-profile users are unaffected.
let _apiProfile: null | string = null

export function setApiRequestProfile(profile: null | string): void {
  _apiProfile = profile || null
}

export function profileScoped(profile?: null | string): { profile?: string } {
  const selected = profile === undefined ? _apiProfile : profile

  return selected ? { profile: selected } : {}
}

/** Profile that profile-scoped REST/WS calls should target (null β†’ primary).
 *  Read-only twin of setApiRequestProfile for modules (e.g. voice playback)
 *  that build their own connection URLs and must stay on the same backend. */
export function getApiRequestProfile(): null | string {
  return _apiProfile
}

// Registry connection serving the active gateway (null β†’ the local pool).
// Pushed from store/gateway's setActive β€” the single seam BOTH
// ensureGatewayProfile and ensureGatewayAgent funnel through β€” so WS calls
// that dial their own backend (pluginSocket) resolve it through the SAME
// source of truth those paths maintain for $connection. That makes the plugin
// socket follow registry-agent activations too, not just profile switches.
// Same no-store-import contract as _apiProfile (avoids a cycle).
let _apiConnectionId: null | string = null

export function setApiRequestConnection(connectionId: null | string): void {
  _apiConnectionId = connectionId || null
}

// Registry connection scope for a REST request. A registered remote gateway
// owns its own state.db β€” cron jobs and their run sessions live THERE β€” so
// requests for gateway-owned data must carry the connection id for the main
// process to route them to that host (hermes:api's registry branch). Null
// resolves to no tag, keeping single-source users byte-identical; explicit
// 'local' must remain tagged when the legacy primary points elsewhere.
export function connectionScoped(): { connectionId?: string } {
  return _apiConnectionId ? { connectionId: _apiConnectionId } : {}
}

// Whether the window's primary connection is the local pool. Pushed from
// store/session's setConnection (same no-store-import contract as _apiProfile)
// so api/ helpers can name the backend an UNTAGGED request lands on without
// importing the heavy session store β€” which would close a module cycle
// through @/hermes.
let _apiLocalMode = false

export function setApiRequestLocalMode(local: boolean): void {
  _apiLocalMode = local
}

/** The connection an ambient (untagged) request is served by: the registry
 *  tag when one is active, else `'local'` for the local pool. Identity only β€”
 *  never send this as a request pin (an explicit `'local'` bypasses Electron's
 *  legacy per-profile remote overrides). */
export function ambientOwnerConnectionId(): string | undefined {
  return _apiConnectionId ?? (_apiLocalMode ? 'local' : undefined)
}

/** Send a REST request to the renderer's active registry source. Request-level
 *  routing may override the active source for an explicitly-owned resource.
 *
 *  Helpers under `api/` go through here rather than calling the preload bridge
 *  directly, so the connection tag cannot be forgotten on a new one.
 *  capabilityScoped() now emits an explicit `connectionId` for EVERY object
 *  pin β€” `'local'` included β€” so a pin always overrides the ambient tag spread
 *  underneath it. (It used to omit the key for 'local', which made the pin
 *  unable to beat the ambient tag; helpers then had to bypass this wrapper.) */
export function hermesApi<T>(request: HermesApiRequest): Promise<T> {
  return window.hermesDesktop.api<T>({ ...connectionScoped(), ...request })
}

// ── Capability scope: (connection, profile) routing for the Capabilities
// surface (skills / toolsets / MCP / hub / env / toolset config) ────────────
//
// A profile is not a machine-global name β€” it belongs to ONE gateway. The
// Capabilities surface can be pointed at any (connection, profile) pair
// (SkillsView's scope selector, Bot Mode's fixedProfile/fixedConnection), so
// its REST helpers accept either the legacy string form or an explicit scope
// object:
//
//   - `undefined` / string β†’ the legacy profile path, PLUS the active registry
//     connection tag (connectionScoped, same contract the cron helpers adopted
//     in #87882). Without the tag, a window activated onto a registered remote
//     gateway read the LOCAL pool's skills/tools/MCP β€” the wrong machine.
//   - `{ connectionId, profile }` β†’ explicit pin. A non-empty connection id β€”
//     `'local'` INCLUDED β€” is sent through so Electron's registry resolver
//     owns the routing. Dropping the `'local'` pin (the pre-#91564 behavior,
//     when an absent id always meant the local pool) silently re-routes a
//     "This device" pick to the registry PRIMARY once that primary is a
//     remote/cloud/ssh gateway: the v1 fallback route treats a remote registry
//     primary as global-remote, so the explicit pin is the ONLY way back to
//     this machine (see apiRequestRegistryConnectionId in Electron main).
export type ProfileScope = undefined | null | string | { connectionId?: null | string; profile?: null | string }

export function capabilityScoped(scope?: ProfileScope): { connectionId?: string; profile?: string } {
  if (scope && typeof scope === 'object') {
    const profile = (scope.profile ?? '').trim()
    const connectionId = (scope.connectionId ?? '').trim()

    return {
      ...(profile ? { profile } : {}),
      ...(connectionId ? { connectionId } : {})
    }
  }

  return { ...profileScoped(scope), ...connectionScoped() }
}

/** Stable cache-key for a capability scope: `profile` for the ambient/legacy
 *  path, `connectionId::profile` for ANY explicit pin β€” `local` included. An
 *  explicit "This device" pick and the ambient path are no longer guaranteed
 *  to hit the same backend (a remote registry PRIMARY makes the ambient path
 *  remote), so sharing the bare-profile cache row between them painted one
 *  machine's config under the other's scope (AGENTS.md scope-in-key rule). */
export function profileScopeKey(scope?: ProfileScope): string {
  if (scope && typeof scope === 'object') {
    const profile = (scope.profile ?? '').trim() || 'default'
    const connectionId = (scope.connectionId ?? '').trim()

    return connectionId ? `${connectionId}::${profile}` : profile
  }

  return (scope ?? '').trim() || 'default'
}

/** Registry connection id that connection-scoped WS calls should target
 *  (null β†’ the local pool). Read-only twin of setApiRequestConnection. */
export function getApiRequestConnection(): null | string {
  return _apiConnectionId
}