hermes-agent/apps/shared/src/json-rpc-gateway.ts
ethernet 41e00dcf87
fix(desktop): salvage /compress cluster — session.compress RPC + dedicated RPC routing (#68229)
* fix(desktop): route /compress through session.compress RPC with transcript replacement

Salvages #44462, #53755, and #68218 into a single canonical fix for the
desktop /compress cluster.

The desktop routed /compress through slash.exec, which sends it to the
_SlashWorker subprocess. Compressing a large session outlives both the
desktop's 30s WS timeout and the worker's 45s pipe timeout — the client
gives up, runExec's blanket catch swallows the error, and command.dispatch
surfaces a misleading "not a quick/plugin/skill command: compress" (#44456).
Even when compression succeeded via the _mirror_slash_side_effects path,
the desktop never received the post-compress message list, so summarized
bubbles stayed on screen forever — /compress looked like a no-op.

This change routes /compress to the dedicated session.compress RPC (the TUI's
path), combining the best of all three PRs:

- 120s client timeout matching the TUI's HERMES_TUI_RPC_TIMEOUT_MS (#44462)
- Transcript replacement from the response `messages` via toChatMessages,
  the same converter session.resume uses (#68218, teknium1 review on #44462)
- Session-isolation guard: updateSessionState only publishes for the active
  runtime, so a late result after a session switch can't clobber the
  foreground transcript (#53755, teknium1 review on #53755)
- Coalescing: dedup concurrent compress requests per session (#53755)
- Progress toast ("compressing context...") outside the transcript (#53755)
- Error unmasking in runExec: when slash.exec fails and command.dispatch only
  adds "not a quick/plugin/skill command" routing noise, surface the original
  worker error instead (#44462)
- /compact alias + focus_topic forwarding

Co-authored-by: AlliDev <AIalliAI@users.noreply.github.com>
Co-authored-by: PinkEVO <PINKIIILQWQ@users.noreply.github.com>

* feat(desktop): route slash commands with dedicated RPCs to those RPCs

Salvages #63513 — introduces a new `rpc` kind on DesktopCommandSurface so
commands with a first-class gateway @method handler bypass slash.exec /
command.dispatch entirely, and a `renderRpcResult` utility that shapes
each RPC's structured reply into readable transcript text.

Migrates 6 commands from exec() to rpc(...):
  /agents → agents.list
  /save   → session.save
  /status → session.status
  /steer  → session.steer
  /stop   → process.stop
  /usage  → session.usage

/compress stays as action('compress') — it needs transcript replacement
from the response `messages`, which the generic rpc path can't do (per
teknium1 review on #44462/#63513).

Also includes the json-rpc-gateway timeout message improvement: the error
now includes the configured timeout duration ("request timed out after 120s:
session.compress") so a user can tell whether the default 30s fired or a
per-call override.

Co-authored-by: Jelvin <SmallNew2003@users.noreply.github.com>

* fix(desktop): preserve provider choice during config initialization

* fix(desktop): preserve slash command and host compression semantics

Keep commands whose CLI behavior exceeds their current RPC contracts on slash.exec.
Propagate the full compression timeout through compute-host control, return structured
host compression outcomes with metadata, and retain successful compression feedback
in the desktop transcript.

Add regressions for timeout forwarding, host aborts and metadata sync, structured host
control responses, command routing parity, and numeric stop counts.

* fix(desktop): harden compression state handling

Preserve the invoking stored-session binding for delayed compression results,
normalize replacement histories, and serialize provider selection. Stabilize
gateway platform tests and guard the desktop Git facade during renderer teardown.

---------

Co-authored-by: AlliDev <AIalliAI@users.noreply.github.com>
Co-authored-by: PinkEVO <PINKIIILQWQ@users.noreply.github.com>
Co-authored-by: Jelvin <SmallNew2003@users.noreply.github.com>
2026-07-22 16:40:06 -04:00

422 lines
11 KiB
TypeScript

export type GatewayEventName =
| 'gateway.ready'
| 'session.info'
| 'message.start'
| 'message.delta'
| 'message.interim'
| 'message.complete'
| 'thinking.delta'
| 'reasoning.delta'
| 'reasoning.available'
| 'status.update'
| 'tool.start'
| 'tool.progress'
| 'tool.complete'
| 'tool.generating'
| 'clarify.request'
| 'approval.request'
| 'sudo.request'
| 'secret.request'
| 'background.complete'
| 'error'
| 'skin.changed'
| (string & {})
export interface GatewayEvent<P = unknown> {
payload?: P
/** Renderer-side source tag added by the Desktop gateway registry. */
profile?: string
session_id?: string
type: GatewayEventName
}
export type ConnectionState = 'idle' | 'connecting' | 'open' | 'closed' | 'error'
export type GatewayRequestId = number | string
export interface JsonRpcFrame {
error?: { message?: string }
id?: GatewayRequestId | null
method?: string
params?: GatewayEvent
result?: unknown
}
export type WebSocketLike = WebSocket
type PendingCall = {
reject: (error: Error) => void
resolve: (value: unknown) => void
timer?: ReturnType<typeof setTimeout>
}
export interface GatewayClientOptions {
closedErrorMessage?: string
connectErrorMessage?: string
connectTimeoutMs?: number
createRequestId?: (nextId: number) => GatewayRequestId
requestIdPrefix?: string
requestTimeoutMs?: number
socketFactory?: (url: string) => WebSocketLike
notConnectedErrorMessage?: string
}
const ANY = '*'
const DEFAULT_REQUEST_TIMEOUT_MS = 120_000
// A reconnect after sleep/wake must not hang forever in 'connecting' (which
// keeps the composer disabled and stuck on "Starting Hermes..."). If the open
// handshake doesn't land in this window, fail to 'error' so callers can retry.
const DEFAULT_CONNECT_TIMEOUT_MS = 15_000
export class JsonRpcGatewayClient {
private nextId = 0
private pending = new Map<GatewayRequestId, PendingCall>()
private socket: WebSocketLike | null = null
private state: ConnectionState = 'idle'
private readonly eventHandlers = new Map<string, Set<(event: GatewayEvent) => void>>()
private readonly stateHandlers = new Set<(state: ConnectionState) => void>()
private readonly options: Required<Omit<GatewayClientOptions, 'socketFactory'>> &
Pick<GatewayClientOptions, 'socketFactory'>
constructor(options: GatewayClientOptions = {}) {
this.options = {
closedErrorMessage: options.closedErrorMessage ?? 'WebSocket closed',
connectErrorMessage: options.connectErrorMessage ?? 'WebSocket connection failed',
connectTimeoutMs: options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
createRequestId: options.createRequestId ?? ((nextId: number) => `${options.requestIdPrefix ?? 'r'}${nextId}`),
notConnectedErrorMessage: options.notConnectedErrorMessage ?? 'gateway not connected',
requestIdPrefix: options.requestIdPrefix ?? 'r',
requestTimeoutMs: options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
socketFactory: options.socketFactory
}
}
get connectionState(): ConnectionState {
return this.state
}
async connect(wsUrl: string): Promise<void> {
// Refuse garbage; WebSocket coerces non-strings into
// `ws://<origin>/[object%20Object]` (#68250 stale-emit boot loop).
const invalidUrl = () => {
const got = typeof wsUrl === 'string' ? JSON.stringify(wsUrl) : `type "${typeof wsUrl}"`
return new Error(`gateway connect() requires a ws:// or wss:// URL string, got ${got}`)
}
if (typeof wsUrl !== 'string') {
throw invalidUrl()
}
let url: URL
try {
url = new URL(wsUrl)
} catch {
throw invalidUrl()
}
if (url.protocol !== 'ws:' && url.protocol !== 'wss:') {
throw invalidUrl()
}
if (this.socket?.readyState === WebSocket.OPEN || this.state === 'connecting') {
return
}
this.setState('connecting')
const socket = this.options.socketFactory?.(wsUrl) ?? new WebSocket(wsUrl)
this.socket = socket
socket.addEventListener('message', message => {
if (this.socket !== socket) {
return
}
this.handleMessage(message.data)
})
socket.addEventListener('close', () => {
if (this.socket !== socket) {
return
}
this.socket = null
this.setState('closed')
this.rejectAllPending(new Error(this.options.closedErrorMessage))
})
await new Promise<void>((resolve, reject) => {
let settled = false
let timer: ReturnType<typeof setTimeout> | undefined
const cleanup = () => {
if (timer !== undefined) {
clearTimeout(timer)
}
socket.removeEventListener('open', onOpen)
socket.removeEventListener('error', onError)
}
const onOpen = () => {
if (settled || this.socket !== socket) {
return
}
settled = true
cleanup()
this.setState('open')
resolve()
}
const onError = () => {
if (settled || this.socket !== socket) {
return
}
settled = true
cleanup()
this.setState('error')
reject(new Error(this.options.connectErrorMessage))
}
socket.addEventListener('open', onOpen, { once: true })
socket.addEventListener('error', onError, { once: true })
if (this.options.connectTimeoutMs > 0) {
timer = setTimeout(() => {
if (settled) {
return
}
settled = true
cleanup()
// Drop the half-open socket so the next connect() starts clean
// instead of short-circuiting on a zombie 'connecting' state.
if (this.socket === socket) {
try {
socket.close()
} catch {
// ignore
}
this.socket = null
}
this.setState('error')
reject(new Error(this.options.connectErrorMessage))
}, this.options.connectTimeoutMs)
}
})
}
close(): void {
const socket = this.socket
if (!socket) {
return
}
try {
socket.close()
} finally {
this.socket = null
this.setState('closed')
this.rejectAllPending(new Error(this.options.closedErrorMessage))
}
}
on<P = unknown>(type: GatewayEventName, handler: (event: GatewayEvent<P>) => void): () => void {
let handlers = this.eventHandlers.get(type)
if (!handlers) {
handlers = new Set()
this.eventHandlers.set(type, handlers)
}
handlers.add(handler as (event: GatewayEvent) => void)
return () => handlers?.delete(handler as (event: GatewayEvent) => void)
}
onAny(handler: (event: GatewayEvent) => void): () => void {
return this.on(ANY as GatewayEventName, handler)
}
onEvent(handler: (event: GatewayEvent) => void): () => void {
return this.onAny(handler)
}
onState(handler: (state: ConnectionState) => void): () => void {
this.stateHandlers.add(handler)
handler(this.state)
return () => this.stateHandlers.delete(handler)
}
request<T>(
method: string,
params: Record<string, unknown> = {},
timeoutMs = this.options.requestTimeoutMs,
signal?: AbortSignal
): Promise<T> {
const socket = this.socket
if (!socket || socket.readyState !== WebSocket.OPEN) {
return Promise.reject(new Error(this.options.notConnectedErrorMessage))
}
if (signal?.aborted) {
return Promise.reject(new DOMException('Aborted', 'AbortError'))
}
const id = this.options.createRequestId(++this.nextId)
return new Promise<T>((resolve, reject) => {
let onAbort: (() => void) | undefined
const detach = () => {
if (onAbort && signal) {
signal.removeEventListener('abort', onAbort)
}
}
const pending: PendingCall = {
resolve: value => {
detach()
resolve(value as T)
},
reject: error => {
detach()
reject(error)
}
}
if (timeoutMs > 0) {
pending.timer = setTimeout(() => {
if (this.pending.delete(id)) {
detach()
// Include the configured timeout so a caller (or a user looking
// at an error toast) can tell whether the default 30s window
// fired or a per-call override — e.g. /compress opts into 120s.
const seconds = Math.round(timeoutMs / 1000)
reject(new Error(`request timed out after ${seconds}s: ${method}`))
}
}, timeoutMs)
}
// Abort drops the pending call immediately (no dangling resolver/timer);
// server-side cancellation is a separate cooperative RPC where it matters.
if (signal) {
onAbort = () => {
const call = this.pending.get(id)
if (call?.timer) {
clearTimeout(call.timer)
}
this.pending.delete(id)
detach()
reject(new DOMException('Aborted', 'AbortError'))
}
signal.addEventListener('abort', onAbort, { once: true })
}
this.pending.set(id, pending)
try {
socket.send(
JSON.stringify({
jsonrpc: '2.0',
id,
method,
params
})
)
} catch (error) {
this.clearPending(id)
detach()
reject(error instanceof Error ? error : new Error(String(error)))
}
})
}
private handleMessage(raw: unknown): void {
const text = typeof raw === 'string' ? raw : String(raw)
let frame: JsonRpcFrame
try {
frame = JSON.parse(text) as JsonRpcFrame
} catch {
return
}
if (frame.id !== undefined && frame.id !== null) {
const call = this.pending.get(frame.id)
if (!call) {
return
}
this.clearPending(frame.id)
if (frame.error) {
call.reject(new Error(frame.error.message || 'Hermes RPC failed'))
} else {
call.resolve(frame.result)
}
return
}
if (frame.method === 'event' && frame.params?.type) {
this.dispatchEvent(frame.params)
}
}
private clearPending(id: GatewayRequestId): void {
const call = this.pending.get(id)
if (call?.timer) {
clearTimeout(call.timer)
}
this.pending.delete(id)
}
private dispatchEvent(event: GatewayEvent): void {
for (const handler of this.eventHandlers.get(event.type) ?? []) {
handler(event)
}
for (const handler of this.eventHandlers.get(ANY) ?? []) {
handler(event)
}
}
private rejectAllPending(error: Error): void {
for (const [id, call] of this.pending) {
if (call.timer) {
clearTimeout(call.timer)
}
call.reject(error)
this.pending.delete(id)
}
}
private setState(state: ConnectionState): void {
if (this.state === state) {
return
}
this.state = state
for (const handler of this.stateHandlers) {
handler(state)
}
}
}