diff --git a/apps/desktop/src/api/models.ts b/apps/desktop/src/api/models.ts index 0aa1ca951f..718bfad7ae 100644 --- a/apps/desktop/src/api/models.ts +++ b/apps/desktop/src/api/models.ts @@ -1,12 +1,6 @@ -import type { - AnalyticsResponse, - AuxiliaryModelsResponse, - MoaConfigResponse, - ModelAssignmentRequest, - ModelAssignmentResponse, - ModelInfoResponse, - ModelOptionsResponse -} from '@/types/hermes' +import type { ModelOptionsResponse } from '@hermes/shared' + +import type { AnalyticsResponse, AuxiliaryModelsResponse, MoaConfigResponse, ModelAssignmentRequest, ModelAssignmentResponse, ModelInfoResponse } from '@/types/hermes' import { capabilityScoped, hermesApi, type ProfileScope, profileScoped, STARTUP_REQUEST_TIMEOUT_MS } from './client' diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 14151e1278..94304b28c3 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -1,4 +1,5 @@ import { type AppendMessage, AssistantRuntimeProvider, type ThreadMessage } from '@assistant-ui/react' +import type { ModelOptionsResponse } from '@hermes/shared' import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' import type { ReadableAtom } from 'nanostores' @@ -51,7 +52,6 @@ import { import { $focusedStoredSessionId, sessionTileDelegate } from '@/store/session-states' import { $transcriptTailBySessionId, transcriptTailState } from '@/store/transcript-tail' import { isAuxiliaryWindow, isWatchWindow } from '@/store/windows' -import type { ModelOptionsResponse } from '@/types/hermes' import { primaryRouteSelectedSessionId, routeSessionId } from '../routes' import { titlebarHeaderBaseClass, titlebarHeaderShadowClass, titlebarHeaderTitleClass } from '../shell/titlebar' diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index 411f22f9ce..7f1cec9706 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -1,9 +1,4 @@ -import { - isGatewayReauthRequired, - isGatewayWebSocketUrl, - JsonRpcGatewayError, - resolveGatewayWsUrl -} from '@hermes/shared' +import { type GatewayEvent, isGatewayReauthRequired, isGatewayWebSocketUrl, JsonRpcGatewayError, resolveGatewayWsUrl } from '@hermes/shared' import { useEffect, useRef } from 'react' import { shouldApplyPostBootProgressError } from '@/components/boot-failure-reauth' @@ -88,7 +83,6 @@ import { resetTileRuntimeBindings } from '@/store/session-states' import { windowProfileOverride } from '@/store/windows' -import type { RpcEvent } from '@/types/hermes' import { stashGatewaySurvivor, survivorIsStale, takeGatewaySurvivor } from './gateway-hmr-survivor' @@ -147,7 +141,7 @@ export function primaryRuntimeConnectionId(connection: Pick void - handleGatewayEvent: (event: RpcEvent) => void + handleGatewayEvent: (event: GatewayEvent) => void onConnectionReady: ( connection: Awaited['getConnection']>> | null ) => void diff --git a/apps/desktop/src/app/session/hooks/preview-open.test.tsx b/apps/desktop/src/app/session/hooks/preview-open.test.tsx index 4f5b72986e..e547db1420 100644 --- a/apps/desktop/src/app/session/hooks/preview-open.test.tsx +++ b/apps/desktop/src/app/session/hooks/preview-open.test.tsx @@ -1,3 +1,4 @@ +import type { GatewayEvent } from '@hermes/shared' import { act, cleanup, render, waitFor } from '@testing-library/react' import { useEffect } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -5,7 +6,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { assistantTextPart, type ChatMessage } from '@/lib/chat-messages' import { $previewTabs, $previewTarget, closeRightRail, type PreviewTarget } from '@/store/preview' import { $activeSessionId, $currentCwd, $messages, $selectedStoredSessionId } from '@/store/session' -import type { RpcEvent } from '@/types/hermes' import { usePreviewRouting } from './use-preview-routing' @@ -19,7 +19,7 @@ function fileTarget(path: string): PreviewTarget { return { kind: 'file', label: path, path, previewKind: 'html', source: path, url: `file://${path}` } } -let handleEvent: (event: RpcEvent) => void = () => undefined +let handleEvent: (event: GatewayEvent) => void = () => undefined function Harness() { const routing = usePreviewRouting({ @@ -41,7 +41,7 @@ async function emitPreviewOpen(url = '/tmp/artifact-test.html', sessionId = RUNT payload: { label: 'hi bestie', url }, session_id: sessionId, type: 'preview.open' - } as unknown as RpcEvent) + } as unknown as GatewayEvent) }) } @@ -51,7 +51,7 @@ async function emitPreviewClose(url?: string, sessionId = RUNTIME_SESSION_ID) { payload: url === undefined ? {} : { url }, session_id: sessionId, type: 'preview.close' - } as unknown as RpcEvent) + } as unknown as GatewayEvent) }) } @@ -117,7 +117,7 @@ describe('preview routing', () => { payload: { url: '/tmp/other.html' }, session_id: 'some-other-session', type: 'preview.open' - } as unknown as RpcEvent) + } as unknown as GatewayEvent) }) expect($previewTabs.get()).toHaveLength(0) @@ -194,12 +194,12 @@ describe('preview routing', () => { payload: { inline_diff: 'a/preview-demo.html -> b/preview-demo.html\n' }, session_id: RUNTIME_SESSION_ID, type: 'tool.complete' - } as unknown as RpcEvent) + } as unknown as GatewayEvent) handleEvent({ payload: { path: './dist/index.html' }, session_id: RUNTIME_SESSION_ID, type: 'tool.complete' - } as unknown as RpcEvent) + } as unknown as GatewayEvent) }) expect($previewTabs.get()).toHaveLength(0) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/btw-complete-event.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/btw-complete-event.test.tsx index 8552b60258..13c7ce53d1 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/btw-complete-event.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-message-stream/btw-complete-event.test.tsx @@ -1,8 +1,7 @@ +import type { GatewayEvent } from '@hermes/shared' import { act, cleanup } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { RpcEvent } from '@/types/hermes' - import { type MessageStreamHarness, renderMessageStream } from './test-harness' const SID = 'session-1' @@ -14,7 +13,7 @@ function mountStream() { stream = renderMessageStream(SID) } -function emit(type: RpcEvent['type'], payload: RpcEvent['payload'] = {}, sessionId = SID) { +function emit(type: GatewayEvent['type'], payload: GatewayEvent['payload'] = {}, sessionId = SID) { act(() => stream.handleEvent({ payload, session_id: sessionId, type })) } diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/compaction-event.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/compaction-event.test.tsx index 6a72ea9705..af283e7743 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/compaction-event.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-message-stream/compaction-event.test.tsx @@ -1,9 +1,9 @@ +import type { GatewayEvent } from '@hermes/shared' import { act, cleanup } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createClientSessionState } from '@/lib/chat-runtime' import { $compactingSessions, setSessionCompacting } from '@/store/compaction' -import type { RpcEvent } from '@/types/hermes' import { type MessageStreamHarness, renderMessageStream } from './test-harness' @@ -15,7 +15,7 @@ function mountStream() { stream = renderMessageStream(SID) } -function emit(type: RpcEvent['type'], payload: RpcEvent['payload'] = {}) { +function emit(type: GatewayEvent['type'], payload: GatewayEvent['payload'] = {}) { act(() => stream.handleEvent({ payload, session_id: SID, type })) } diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/index.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/index.ts index 572e5c138e..6432dfdfe1 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/index.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/index.ts @@ -1,4 +1,4 @@ -import { registryBackendScopeKey } from '@hermes/shared' +import { type GatewayEvent, registryBackendScopeKey } from '@hermes/shared' import { useCallback, useEffect, useRef } from 'react' import type { GatewayEventPayload } from '@/lib/chat-messages' @@ -14,7 +14,6 @@ import { replayPendingApproval } from '@/store/prompts' import { setSessionProviderWait } from '@/store/provider-wait' import { isSessionGone } from '@/store/session-gone-latch' import { setSessionDraftingTool } from '@/store/tool-drafting' -import type { RpcEvent } from '@/types/hermes' import { handleDesktopBridgeEvent } from './desktop-bridge' import { handleInputRequestEvent } from './input-requests' @@ -46,7 +45,6 @@ const DRAFT_SUPERSEDING_EVENT_TYPES = new Set([ 'reasoning.delta', 'thinking.delta', 'tool.complete', - 'tool.progress', 'tool.start' ]) @@ -61,7 +59,6 @@ const COMPACTION_RESUME_EVENT_TYPES = new Set([ 'moa.progress', 'moa.phase', 'tool.start', - 'tool.progress', 'tool.generating', 'tool.complete' ]) @@ -76,7 +73,6 @@ const PROVIDER_WAIT_SUPERSEDING_EVENT_TYPES = new Set([ 'reasoning.delta', 'tool.complete', 'tool.generating', - 'tool.progress', 'tool.start' ]) @@ -135,7 +131,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { ) return useCallback( - (event: RpcEvent) => { + (event: GatewayEvent) => { const payload = event.payload as GatewayEventPayload | undefined // "From the active profile" must mean "from the active SOURCE": every diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/lifecycle.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/lifecycle.ts index 16633910dc..1c2ff50b63 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/lifecycle.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/lifecycle.ts @@ -1,3 +1,4 @@ +import type { GatewayEvent } from '@hermes/shared' import type { HermesSkin } from '@hermes/shared/skin' import { @@ -23,12 +24,13 @@ export function handleLifecycleEvent(ctx: GatewayEventContext): boolean { const { deps, event, payload, fromActiveSource } = ctx if (event.type === 'gateway.ready') { + const ready = (event as GatewayEvent<'gateway.ready'>).payload // Seed the active skin into the desktop theme registry without applying, // so a fresh connect never overrides the user's persisted desktop theme. - ingestBackendSkin((payload as { skin?: HermesSkin } | undefined)?.skin, { apply: false }) + ingestBackendSkin(ready?.skin, { apply: false }) // Backends with the change watcher broadcast pet/cron/sessions change // events; consumers demote their legacy polls to slow backstops. - setChangeEventsAvailable(Boolean((payload as { change_events?: boolean } | undefined)?.change_events)) + setChangeEventsAvailable(Boolean(ready?.change_events)) return true } diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/message-stream.test.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/message-stream.test.ts index db81acdab1..a5612b1cc8 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/message-stream.test.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/message-stream.test.ts @@ -6,10 +6,12 @@ const { refreshSupportedSessionControlAfterTurn } = vi.hoisted(() => ({ vi.mock('@/store/session-control', () => ({ refreshSupportedSessionControlAfterTurn })) +import type { GatewayEventName } from '@hermes/shared' + import { handleMessageStreamEvent } from './message-stream' import type { GatewayEventContext } from './types' -function context(type: string): GatewayEventContext { +function context(type: GatewayEventName): GatewayEventContext { return { deps: { activeGatewayProfile: 'default', diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/message-stream.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/message-stream.ts index d7aa70ac73..a1fc314e0c 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/message-stream.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/message-stream.ts @@ -362,6 +362,11 @@ export function handleMessageStreamEvent(ctx: GatewayEventContext): boolean { surfaceBillingBlock(sessionId, payload.billing) } + // History-commit note (e.g. a mid-turn desync) the gateway chose to surface. + if (typeof payload?.warning === 'string' && payload.warning.trim()) { + notify({ kind: 'warning', message: payload.warning }) + } + if (isActiveEvent) { setTurnStartedAt(null) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/tools.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/tools.ts index 546e168c08..4b919e863a 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/tools.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/tools.ts @@ -14,7 +14,7 @@ import { SUBAGENT_EVENT_TYPES, toTodoPayload } from '../utils' import type { GatewayEventContext } from './types' -/** tool.generating / tool.start / tool.progress / tool.complete / subagent.*. */ +/** tool.generating / tool.start / tool.complete / subagent.*. */ export function handleToolEvent(ctx: GatewayEventContext): boolean { const { deps, event, payload, sessionId, isActiveEvent, occurredAt } = ctx const { flushQueuedDeltas, nativeSubagentSessionsRef, sessionInterrupted, updateSessionState, upsertToolCall } = deps @@ -50,7 +50,7 @@ export function handleToolEvent(ctx: GatewayEventContext): boolean { return true } - if (event.type === 'tool.start' || event.type === 'tool.progress') { + if (event.type === 'tool.start') { if (!sessionId) { return true } diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/types.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/types.ts index 551d1a0d51..1733428ffd 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/types.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/types.ts @@ -1,8 +1,8 @@ +import type { GatewayEvent } from '@hermes/shared' import type { QueryClient } from '@tanstack/react-query' import type { MutableRefObject } from 'react' import type { GatewayEventPayload } from '@/lib/chat-messages' -import type { RpcEvent } from '@/types/hermes' import type { ClientSessionState } from '../../../../types' @@ -52,7 +52,7 @@ export interface GatewayEventDeps { * the routing preamble in index.ts computes it once per event. */ export interface GatewayEventContext { deps: GatewayEventDeps - event: RpcEvent + event: GatewayEvent payload: GatewayEventPayload | undefined /** Routed session id (explicit, pinned unscoped stream, or active fallback). */ sessionId: null | string diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/interim-sealing.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/interim-sealing.test.tsx index 82f806fc07..8778836db9 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/interim-sealing.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-message-stream/interim-sealing.test.tsx @@ -1,3 +1,4 @@ +import type { GatewayEvent } from '@hermes/shared' import { act, cleanup } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -5,7 +6,6 @@ import type { ClientSessionState } from '@/app/types' import { chatMessageText, textPart } from '@/lib/chat-messages' import { createClientSessionState } from '@/lib/chat-runtime' import { clearSessionTodos } from '@/store/todos' -import type { RpcEvent } from '@/types/hermes' import { type MessageStreamHarness, renderMessageStream } from './test-harness' @@ -317,12 +317,12 @@ describe('useMessageStream interim text sealing', () => { await start() // No payload at all - await act(() => stream.handleEvent({ type: 'message.interim' } as RpcEvent)) + await act(() => stream.handleEvent({ type: 'message.interim' } as GatewayEvent)) // Empty text - await act(() => stream.handleEvent({ payload: { text: '' }, session_id: SID, type: 'message.interim' } as RpcEvent)) + await act(() => stream.handleEvent({ payload: { text: '' }, session_id: SID, type: 'message.interim' } as GatewayEvent)) // Undefined text await act(() => - stream.handleEvent({ payload: { text: undefined }, session_id: SID, type: 'message.interim' } as RpcEvent) + stream.handleEvent({ payload: { text: undefined }, session_id: SID, type: 'message.interim' } as GatewayEvent) ) // Turn continues without finalizing or throwing diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/moa-progress-event.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/moa-progress-event.test.tsx index 73de97db07..71c568b110 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/moa-progress-event.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-message-stream/moa-progress-event.test.tsx @@ -1,8 +1,7 @@ +import type { GatewayEvent } from '@hermes/shared' import { act, cleanup } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RpcEvent } from '@/types/hermes' - import { type MessageStreamHarness, renderMessageStream } from './test-harness' const SID = 'session-1' @@ -12,7 +11,7 @@ function mountStream() { stream = renderMessageStream(SID) } -function emit(type: RpcEvent['type'], payload: RpcEvent['payload'] = {}) { +function emit(type: GatewayEvent['type'], payload: GatewayEvent['payload'] = {}) { act(() => stream.handleEvent({ payload, session_id: SID, type })) } diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/moa-reference-event.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/moa-reference-event.test.tsx index 2bbb4c3325..7ec86c3146 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/moa-reference-event.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-message-stream/moa-reference-event.test.tsx @@ -1,8 +1,7 @@ +import type { GatewayEvent } from '@hermes/shared' import { act, cleanup } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RpcEvent } from '@/types/hermes' - import { type MessageStreamHarness, renderMessageStream } from './test-harness' const SID = 'session-1' @@ -12,7 +11,7 @@ function mountStream() { stream = renderMessageStream(SID) } -function emit(type: RpcEvent['type'], payload: RpcEvent['payload'] = {}) { +function emit(type: GatewayEvent['type'], payload: GatewayEvent['payload'] = {}) { act(() => stream.handleEvent({ payload, session_id: SID, type })) } diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/pet-tool-failure-event.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/pet-tool-failure-event.test.tsx index f508e7501f..855c61c0db 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/pet-tool-failure-event.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-message-stream/pet-tool-failure-event.test.tsx @@ -1,8 +1,8 @@ +import type { GatewayEvent } from '@hermes/shared' import { act, cleanup } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { $petActivity, $petState, setPetActivity } from '@/store/pet' -import type { RpcEvent } from '@/types/hermes' import { type MessageStreamHarness, renderMessageStream } from './test-harness' @@ -15,7 +15,7 @@ function mountStream() { stream = renderMessageStream(SID) } -function emit(type: RpcEvent['type'], payload: RpcEvent['payload'] = {}, sessionId = SID) { +function emit(type: GatewayEvent['type'], payload: GatewayEvent['payload'] = {}, sessionId = SID) { act(() => stream.handleEvent({ payload, session_id: sessionId, type })) } diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/provider-wait-event.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/provider-wait-event.test.tsx index 1136a14119..82526396bb 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/provider-wait-event.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-message-stream/provider-wait-event.test.tsx @@ -1,16 +1,16 @@ +import type { GatewayEvent } from '@hermes/shared' import { act, cleanup } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { $providerWaitSessions } from '@/store/provider-wait' import { clearAllSessionStates, dropSessionState } from '@/store/session-states' -import type { RpcEvent } from '@/types/hermes' import { type MessageStreamHarness, renderMessageStream } from './test-harness' const SID = 'session-1' let stream: MessageStreamHarness -function emit(type: RpcEvent['type'], payload: RpcEvent['payload'] = {}) { +function emit(type: GatewayEvent['type'], payload: GatewayEvent['payload'] = {}) { act(() => stream.handleEvent({ payload, session_id: SID, type })) } diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/session-reclaimed.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/session-reclaimed.test.tsx index 0dcface159..f1adc9ceac 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/session-reclaimed.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-message-stream/session-reclaimed.test.tsx @@ -1,3 +1,4 @@ +import type { GatewayEvent } from '@hermes/shared' import { QueryClient } from '@tanstack/react-query' import { act, cleanup } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -7,7 +8,6 @@ import { createClientSessionState } from '@/lib/chat-runtime' import { resetRuntimeGoneHealing } from '@/store/runtime-gone' import { $activeSessionId, $sessionResumeRequest } from '@/store/session' import { $sessionStates, $sessionTiles, publishSessionState } from '@/store/session-states' -import type { RpcEvent } from '@/types/hermes' import { type MessageStreamHarness, renderMessageStream } from './test-harness' @@ -33,7 +33,7 @@ const reclaim = (sessionId: string, reason = 'ws_orphan_reap') => payload: { reason, session_id: sessionId, stored_session_id: 'stored-1' }, session_id: '', type: 'session.reclaimed' - } as RpcEvent) + } as GatewayEvent) ) beforeEach(() => { diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/stale-pending-settle.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/stale-pending-settle.test.tsx index 568d39f02f..ad0182177e 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/stale-pending-settle.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-message-stream/stale-pending-settle.test.tsx @@ -1,3 +1,4 @@ +import type { GatewayEvent } from '@hermes/shared' // A turn that ends WITHOUT its message.complete (turn crash, reconnect gap, // steer race) used to leave its streaming bubble pending:true forever. The // next user message then landed after it, stranding a live thinking indicator @@ -7,8 +8,6 @@ import { act, cleanup } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RpcEvent } from '@/types/hermes' - import { type MessageStreamHarness, renderMessageStream } from './test-harness' import { STREAM_DELTA_FLUSH_MS } from './utils' @@ -30,7 +29,7 @@ const flushDeltas = async () => { }) } -const emit = (event: RpcEvent) => act(() => stream.handleEvent(event)) +const emit = (event: GatewayEvent) => act(() => stream.handleEvent(event)) describe('turn end without message.complete (session.info running=false)', () => { afterEach(() => { diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/steer-arrival-order.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/steer-arrival-order.test.tsx index 2a7e4aedb6..893d8a8c5e 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/steer-arrival-order.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-message-stream/steer-arrival-order.test.tsx @@ -1,3 +1,4 @@ +import type { GatewayEvent } from '@hermes/shared' // Repro for "when I steer it often sends out of order — a user bubble way // above" (#73793 / #83151 class). Drives the REAL stream reducer // (useMessageStream.handleGatewayEvent) and the REAL steer entry point @@ -23,7 +24,6 @@ import { usePromptActions } from '@/app/session/hooks/use-prompt-actions' import type { ClientSessionState } from '@/app/types' import { chatMessageText } from '@/lib/chat-messages' import { createClientSessionState } from '@/lib/chat-runtime' -import type { RpcEvent } from '@/types/hermes' import { STREAM_DELTA_FLUSH_MS } from './utils' @@ -31,7 +31,7 @@ import { useMessageStream } from './index' const SID = 'steer-order-session' -let handleEvent: ((event: RpcEvent) => void) | null = null +let handleEvent: ((event: GatewayEvent) => void) | null = null let redirect: ((text: string) => Promise) | null = null let states: Map @@ -116,7 +116,7 @@ const flushDeltas = async () => { }) } -const emit = (event: RpcEvent) => act(() => handleEvent?.(event)) +const emit = (event: GatewayEvent) => act(() => handleEvent?.(event)) /** A real steer: redirectPrompt's optimistic insert + the gateway round-trip. */ const steer = async (text: string) => { diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/test-harness.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/test-harness.tsx index 4104bd5e87..b1d702643f 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/test-harness.tsx +++ b/apps/desktop/src/app/session/hooks/use-message-stream/test-harness.tsx @@ -1,3 +1,4 @@ +import type { GatewayEvent } from '@hermes/shared' import { QueryClient } from '@tanstack/react-query' import { render } from '@testing-library/react' import { useEffect, useRef } from 'react' @@ -5,7 +6,6 @@ import { vi } from 'vitest' import type { ClientSessionState } from '@/app/types' import { createClientSessionState } from '@/lib/chat-runtime' -import type { RpcEvent } from '@/types/hermes' import { useMessageStream } from './index' @@ -16,7 +16,7 @@ export interface MessageStreamHarnessOptions extends Partial void + handleEvent: (event: GatewayEvent) => void /** Push streaming assistant text, bypassing the event envelope. For the specs * about flush scheduling rather than about a particular event. */ appendDelta: (sessionId: string, delta: string) => void @@ -47,7 +47,7 @@ export function renderMessageStream( sessionId: string | null, { states = new Map(), ...overrides }: MessageStreamHarnessOptions = {} ): MessageStreamHarness { - let dispatch: ((event: RpcEvent) => void) | null = null + let dispatch: ((event: GatewayEvent) => void) | null = null let appendDelta: ((sessionId: string, delta: string) => void) | null = null let latest: ClientSessionState | null = null diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/timeline-events.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/timeline-events.test.tsx index 07d45ca56a..f1122caca1 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/timeline-events.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-message-stream/timeline-events.test.tsx @@ -1,3 +1,4 @@ +import type { GatewayEventName } from '@hermes/shared' import { act, cleanup } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -7,7 +8,7 @@ const SID = 'timeline-session' let stream: MessageStreamHarness -const event = (type: string, timestamp: number, payload: Record = {}) => +const event = (type: GatewayEventName, timestamp: number, payload: Record = {}) => act(() => stream.handleEvent({ payload: { ...payload, timestamp }, session_id: SID, type })) describe('live transcript timeline events', () => { diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/tool-drafting-event.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/tool-drafting-event.test.tsx index 119c5b5cae..b81167c1e8 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/tool-drafting-event.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-message-stream/tool-drafting-event.test.tsx @@ -1,10 +1,10 @@ +import type { GatewayEvent } from '@hermes/shared' import { act, cleanup } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ClientSessionState } from '@/app/types' import { createClientSessionState } from '@/lib/chat-runtime' import { $draftingToolSessions } from '@/store/tool-drafting' -import type { RpcEvent } from '@/types/hermes' import { type MessageStreamHarness, renderMessageStream } from './test-harness' @@ -20,7 +20,7 @@ function mountStream() { stream = renderMessageStream(SID, { states: sessionStates }) } -function emit(type: RpcEvent['type'], payload: RpcEvent['payload'] = {}, sessionId = SID) { +function emit(type: GatewayEvent['type'], payload: GatewayEvent['payload'] = {}, sessionId = SID) { act(() => stream.handleEvent({ payload, session_id: sessionId, type })) } diff --git a/apps/desktop/src/app/session/hooks/use-model-controls.ts b/apps/desktop/src/app/session/hooks/use-model-controls.ts index e6ee91440e..b10b868f81 100644 --- a/apps/desktop/src/app/session/hooks/use-model-controls.ts +++ b/apps/desktop/src/app/session/hooks/use-model-controls.ts @@ -1,3 +1,4 @@ +import type { ModelOptionsResponse } from '@hermes/shared' import { type QueryClient } from '@tanstack/react-query' import { useCallback, useRef } from 'react' @@ -21,7 +22,6 @@ import { setCurrentProvider } from '@/store/session' import { $sessionStates, sessionTileDelegate } from '@/store/session-states' -import type { ModelOptionsResponse } from '@/types/hermes' interface ModelControlsOptions { cacheOwnerConnectionId?: string diff --git a/apps/desktop/src/app/session/hooks/use-preview-routing.ts b/apps/desktop/src/app/session/hooks/use-preview-routing.ts index e8d3728c31..ac21a264dd 100644 --- a/apps/desktop/src/app/session/hooks/use-preview-routing.ts +++ b/apps/desktop/src/app/session/hooks/use-preview-routing.ts @@ -1,3 +1,4 @@ +import type { GatewayEvent } from '@hermes/shared' import { useCallback } from 'react' import { gatewayEventCompletedFileDiff } from '@/lib/gateway-events' @@ -15,9 +16,8 @@ import { } from '@/store/preview' import { $activeSessionId, $currentCwd } from '@/store/session' import { $focusedRuntimeId, $sessionTiles } from '@/store/session-states' -import type { RpcEvent } from '@/types/hermes' -type EventHandler = (event: RpcEvent) => void +type EventHandler = (event: GatewayEvent) => void interface PreviewRoutingOptions { baseHandleGatewayEvent: EventHandler diff --git a/apps/desktop/src/app/settings/billing/use-step-up.ts b/apps/desktop/src/app/settings/billing/use-step-up.ts index 2966f602a4..9cb276afa6 100644 --- a/apps/desktop/src/app/settings/billing/use-step-up.ts +++ b/apps/desktop/src/app/settings/billing/use-step-up.ts @@ -20,11 +20,6 @@ export interface StepUpMessage { title: string } -interface StepUpVerificationPayload { - user_code?: unknown - verification_url?: unknown -} - export function useStepUpFlow() { const api = useBillingApi() const gateway = useStore($gateway) @@ -82,7 +77,7 @@ export function useStepUpFlow() { setPhase('waiting') offRef.current = - gateway?.on('billing.step_up.verification', event => { + gateway?.on('billing.step_up.verification', event => { const payload = event.payload const url = typeof payload?.verification_url === 'string' ? payload.verification_url : null diff --git a/apps/desktop/src/app/settings/model-settings.tsx b/apps/desktop/src/app/settings/model-settings.tsx index 580b881ec9..8c169070db 100644 --- a/apps/desktop/src/app/settings/model-settings.tsx +++ b/apps/desktop/src/app/settings/model-settings.tsx @@ -1,3 +1,4 @@ +import type { ModelOptionProvider } from '@hermes/shared' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Button } from '@/components/ui/button' @@ -21,7 +22,6 @@ import type { AuxiliaryTaskAssignment, MoaConfigResponse, MoaModelSlot, - ModelOptionProvider, StaleAuxAssignment } from '@/hermes' import { useI18n } from '@/i18n' diff --git a/apps/desktop/src/app/shell/model-catalog-menu.tsx b/apps/desktop/src/app/shell/model-catalog-menu.tsx index e8f24a44a6..58d836e0f5 100644 --- a/apps/desktop/src/app/shell/model-catalog-menu.tsx +++ b/apps/desktop/src/app/shell/model-catalog-menu.tsx @@ -1,3 +1,4 @@ +import type { ModelOptionProvider, ModelOptionsResponse } from '@hermes/shared' import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' import { createContext, type ReactNode, useContext, useEffect, useMemo, useRef, useState } from 'react' @@ -40,7 +41,7 @@ import { } from '@/store/model-visibility' import { $collapsedProviders, toggleCollapsedProvider } from '@/store/provider-collapse' import { $defaultReasoningEffort } from '@/store/session' -import type { LocalModelLoadProgress, ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes' +import type { LocalModelLoadProgress } from '@/types/hermes' import { type FastControl, ModelEditSubmenu, resolveFastControl } from './model-edit-submenu' diff --git a/apps/desktop/src/app/shell/model-menu-panel.tsx b/apps/desktop/src/app/shell/model-menu-panel.tsx index 58044d00fd..a63d8da6d5 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.tsx @@ -1,3 +1,4 @@ +import type { ModelOptionsResponse } from '@hermes/shared' import { useStore } from '@nanostores/react' import { useQuery, useQueryClient } from '@tanstack/react-query' import { useState } from 'react' @@ -21,7 +22,6 @@ import { setCurrentReasoningEffort } from '@/store/session' import { sessionTileDelegate } from '@/store/session-states' -import type { ModelOptionsResponse } from '@/types/hermes' import { ModelCatalogMenu, type ModelMenuController } from './model-catalog-menu' diff --git a/apps/desktop/src/components/model-picker.test.tsx b/apps/desktop/src/components/model-picker.test.tsx index 8cc1b767a3..1569091525 100644 --- a/apps/desktop/src/components/model-picker.test.tsx +++ b/apps/desktop/src/components/model-picker.test.tsx @@ -1,3 +1,4 @@ +import type { ModelOptionsResponse } from '@hermes/shared' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { cleanup, render, screen, waitFor } from '@testing-library/react' import type { ReactElement } from 'react' @@ -7,7 +8,7 @@ import { I18nProvider } from '@/i18n' import { $localModelsEnabled } from '@/store/local-models-flag' import { $localRuntimeJobs } from '@/store/local-runtime-jobs' import { stubMenuDomApis, stubResizeObserver } from '@/test/jsdom' -import type { LocalRuntimeJob, ModelOptionsResponse } from '@/types/hermes' +import type { LocalRuntimeJob } from '@/types/hermes' import { ModelPickerDialog } from './model-picker' diff --git a/apps/desktop/src/components/model-picker.tsx b/apps/desktop/src/components/model-picker.tsx index 700d7bc8cc..406068751e 100644 --- a/apps/desktop/src/components/model-picker.tsx +++ b/apps/desktop/src/components/model-picker.tsx @@ -1,3 +1,4 @@ +import type { ModelOptionProvider, ModelPricing } from '@hermes/shared' import { useQuery } from '@tanstack/react-query' import { useEffect, useMemo, useState } from 'react' @@ -10,7 +11,7 @@ import { foldIncludes, normalize } from '@/lib/text' import { useStoreSelector } from '@/lib/use-session-slice' import { $localModelsEnabled } from '@/store/local-models-flag' import { $localRuntimeJobs, runningModelDownloads, watchLocalRuntimeJobs } from '@/store/local-runtime-jobs' -import type { LocalModelLoadProgress, ModelOptionProvider, ModelPricing } from '@/types/hermes' +import type { LocalModelLoadProgress } from '@/types/hermes' import type { HermesGateway } from '../hermes' import { cn } from '../lib/utils' diff --git a/apps/desktop/src/components/model-visibility-dialog.tsx b/apps/desktop/src/components/model-visibility-dialog.tsx index ffde6f2f6c..ea31f38bfe 100644 --- a/apps/desktop/src/components/model-visibility-dialog.tsx +++ b/apps/desktop/src/components/model-visibility-dialog.tsx @@ -1,3 +1,4 @@ +import type { ModelOptionProvider, ModelOptionsResponse } from '@hermes/shared' import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' import { useMemo, useState } from 'react' @@ -25,7 +26,6 @@ import { toggleModelVisibility } from '@/store/model-visibility' import { $collapsedProviders, toggleCollapsedProvider } from '@/store/provider-collapse' -import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes' interface ModelVisibilityDialogProps { gw?: HermesGateway diff --git a/apps/desktop/src/components/onboarding/index.tsx b/apps/desktop/src/components/onboarding/index.tsx index d431b3bba9..d8549be89a 100644 --- a/apps/desktop/src/components/onboarding/index.tsx +++ b/apps/desktop/src/components/onboarding/index.tsx @@ -1,3 +1,4 @@ +import type { ModelOptionProvider } from '@hermes/shared' import { useStore } from '@nanostores/react' import { useEffect, useMemo, useRef, useState } from 'react' @@ -35,7 +36,7 @@ import { startProviderOAuth } from '@/store/onboarding' import { $onboardingSurfaces, onboardingSurfaceActive } from '@/store/onboarding-presence' -import type { ModelOptionProvider, OAuthProvider } from '@/types/hermes' +import type { OAuthProvider } from '@/types/hermes' import { DocsLink, FlowPanel, Status } from './flow' import { DecodedLabel } from './glyph' diff --git a/apps/desktop/src/contrib/events.ts b/apps/desktop/src/contrib/events.ts index e76ddff7ff..ef0f6897d6 100644 --- a/apps/desktop/src/contrib/events.ts +++ b/apps/desktop/src/contrib/events.ts @@ -6,9 +6,9 @@ * listens. */ -import type { RpcEvent } from '@/types/hermes' +import type { GatewayEvent } from '@hermes/shared' -export type GatewayEventListener = (event: RpcEvent) => void +export type GatewayEventListener = (event: GatewayEvent) => void const listeners = new Map>() @@ -28,7 +28,7 @@ export function onGatewayEvent(type: string, listener: GatewayEventListener): () } /** Fan an event to subscribers (wiring-side; call before app dispatch). */ -export function emitGatewayEvent(event: RpcEvent): void { +export function emitGatewayEvent(event: GatewayEvent): void { if (listeners.size === 0) { return } diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index 2beecf0583..4c86d44057 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -65,7 +65,6 @@ export type { ElevenLabsVoice, ElevenLabsVoicesResponse, EnvVarInfo, - GatewayReadyPayload, HermesConfig, HermesConfigRecord, LogsResponse, @@ -87,8 +86,6 @@ export type { ModelAssignmentRequest, ModelAssignmentResponse, ModelInfoResponse, - ModelOptionProvider, - ModelOptionsResponse, PaginatedSessions, PairingResponse, PairingUser, @@ -101,7 +98,6 @@ export type { ProjectFolder, ProjectInfo, ProjectsPayload, - RpcEvent, SessionCreateResponse, SessionInfo, SessionMessage, diff --git a/apps/desktop/src/lib/chat-messages/types.ts b/apps/desktop/src/lib/chat-messages/types.ts index 3107aa2c12..d29f7dab1c 100644 --- a/apps/desktop/src/lib/chat-messages/types.ts +++ b/apps/desktop/src/lib/chat-messages/types.ts @@ -178,6 +178,8 @@ export type GatewayEventPayload = { // message.complete — signals the final text was already previewed via // interim_assistant_callback, so the UI can settle instead of duplicating. response_previewed?: boolean + // message.complete — history-commit note the gateway surfaced instead of dropping. + warning?: string // message.complete with status "error" — `text` is streamed partial output // (keep it visible), not the error string. partial?: boolean diff --git a/apps/desktop/src/lib/chat-runtime.ts b/apps/desktop/src/lib/chat-runtime.ts index 78fafc2faf..7c048a5ec6 100644 --- a/apps/desktop/src/lib/chat-runtime.ts +++ b/apps/desktop/src/lib/chat-runtime.ts @@ -1,4 +1,5 @@ import type { ThreadMessage } from '@assistant-ui/react' +import type { ModelOptionsResponse } from '@hermes/shared' import type { QuickModelOption } from '@/app/chat/composer/types' import type { ClientSessionState, CommandDispatchResponse } from '@/app/types' @@ -6,7 +7,7 @@ import { formatRefValue } from '@/components/assistant-ui/directive-text' import { type ChatMessage, type ChatMessagePart, chatMessageText, textPart } from '@/lib/chat-messages' import { normalize } from '@/lib/text' import type { ComposerAttachment } from '@/store/composer' -import type { ModelOptionsResponse, SessionInfo } from '@/types/hermes' +import type { SessionInfo } from '@/types/hermes' export const SLASH_COMMAND_RE = /^\/[^\s/]*(?:\s|$)/ export { BUILTIN_PERSONALITIES } from '@/lib/personalities' diff --git a/apps/desktop/src/lib/gateway-events.ts b/apps/desktop/src/lib/gateway-events.ts index 44aecca4b3..fb89644929 100644 --- a/apps/desktop/src/lib/gateway-events.ts +++ b/apps/desktop/src/lib/gateway-events.ts @@ -40,7 +40,6 @@ export const UNSCOPED_STREAM_EVENT_TYPES = new Set([ 'thinking.delta', 'tool.complete', 'tool.generating', - 'tool.progress', 'tool.start', 'vault.code.expire', 'vault.code.request', diff --git a/apps/desktop/src/lib/model-options.ts b/apps/desktop/src/lib/model-options.ts index 65284e3b9f..1a94e2186b 100644 --- a/apps/desktop/src/lib/model-options.ts +++ b/apps/desktop/src/lib/model-options.ts @@ -1,5 +1,6 @@ -import { getGlobalModelOptions, type HermesGateway, type ModelOptionsResponse } from '@/hermes' -import type { ModelOptionProvider } from '@/types/hermes' +import type { ModelOptionProvider, ModelOptionsResponse } from '@hermes/shared' + +import { getGlobalModelOptions, type HermesGateway } from '@/hermes' type CatalogProviderIdentity = Pick diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index 4ce472aa50..5720b09008 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -1780,7 +1780,9 @@ export { requestTheme } from '@/themes/request' export { retintTheme, themeHue } from '@/themes/retint' export type { DesktopTheme, DesktopThemeColors } from '@/themes/types' export { THEMES_AREA } from '@/themes/user-themes' -export type { RpcEvent, StatusResponse } from '@/types/hermes' +export type { StatusResponse } from '@/types/hermes' +/** Public SDK name for the shared gateway wire event; kept stable for plugins. */ +export type { GatewayEvent as RpcEvent } from '@hermes/shared' /** Subscribe a component to a `host.state` atom. */ export { useStore as useValue } from '@nanostores/react' /** The app's data-fetching layer. Plugins share the ONE QueryClient mounted at diff --git a/apps/desktop/src/store/model-visibility.test.ts b/apps/desktop/src/store/model-visibility.test.ts index a63aede8aa..7bb784e86d 100644 --- a/apps/desktop/src/store/model-visibility.test.ts +++ b/apps/desktop/src/store/model-visibility.test.ts @@ -1,7 +1,6 @@ +import type { ModelOptionProvider } from '@hermes/shared' import { describe, expect, it } from 'vitest' -import type { ModelOptionProvider } from '@/types/hermes' - import { collapseModelFamilies, defaultVisibleKeys, diff --git a/apps/desktop/src/store/model-visibility.ts b/apps/desktop/src/store/model-visibility.ts index c3a3754ac8..c43eddbdb4 100644 --- a/apps/desktop/src/store/model-visibility.ts +++ b/apps/desktop/src/store/model-visibility.ts @@ -1,7 +1,7 @@ +import type { ModelOptionProvider } from '@hermes/shared' import { atom } from 'nanostores' import { persistString, storedString } from '@/lib/storage' -import type { ModelOptionProvider } from '@/types/hermes' const STORAGE_KEY = 'hermes.desktop.visible-models' diff --git a/apps/desktop/src/store/onboarding.ts b/apps/desktop/src/store/onboarding.ts index 262675c10f..9b6c486886 100644 --- a/apps/desktop/src/store/onboarding.ts +++ b/apps/desktop/src/store/onboarding.ts @@ -1,3 +1,4 @@ +import type { ModelOptionProvider } from '@hermes/shared' import { atom } from 'nanostores' import { @@ -18,7 +19,7 @@ import { setMainModelAssignment } from '@/store/cron-model-impact' import { ackFreeTierNotice, freeTierReadyPending, refreshFreeTierStatus, setFreeTierRoute } from '@/store/free-tier' import { notify, notifyError } from '@/store/notifications' import { guidedOnboardingActive } from '@/store/onboarding-gate' -import type { ModelOptionProvider, OAuthProvider, OAuthStartResponse } from '@/types/hermes' +import type { OAuthProvider, OAuthStartResponse } from '@/types/hermes' type PkceStart = Extract type DeviceStart = Extract diff --git a/apps/desktop/src/store/pet-generate.ts b/apps/desktop/src/store/pet-generate.ts index bbb6986edc..3ad6ac3c33 100644 --- a/apps/desktop/src/store/pet-generate.ts +++ b/apps/desktop/src/store/pet-generate.ts @@ -361,8 +361,9 @@ export async function generateDrafts(request: GatewayRequest, options: GenerateO // Stream drafts in as the backend finishes each one (pet.generate.progress), // so the grid fills live instead of sitting on placeholders until all N land. const off = - $gateway.get()?.on('pet.generate.progress', event => { - const draft = event.payload + $gateway.get()?.on('pet.generate.progress', event => { + // Shared map types this payload as an open record; the pet backend's draft shape is desktop-owned. + const draft = event.payload as (PetDraft & { count: number; token: string }) | undefined // Token-only init event (no draft yet): learn the token immediately so an // early Stop can still tell the backend to cancel this run. @@ -492,8 +493,8 @@ export async function hatchSelected(request: GatewayRequest, options: HatchOptio const offProgress = $gateway .get() - ?.on<{ event: string; state?: string; done?: string; total?: string }>('pet.hatch.progress', event => { - const p = event.payload + ?.on('pet.hatch.progress', event => { + const p = event.payload as { done?: string; event: string; state?: string; total?: string } | undefined if (!p || !hatch.isCurrent(hatchRunId) || $petGenStatus.get() !== 'hatching') { return diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 8773659e64..3ddc3f9ec2 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -400,10 +400,6 @@ export interface WebhookEnableResponse { restart_started?: boolean } -export interface GatewayReadyPayload { - skin?: unknown -} - export interface HermesConfig { agent?: { reasoning_effort?: string @@ -447,88 +443,6 @@ export interface ModelInfoResponse { provider: string } -export interface ModelPricing { - /** Formatted $/Mtok input price, e.g. "$3.00", or "free", or "" if unknown. */ - input: string - /** Formatted $/Mtok output price. */ - output: string - /** Formatted $/Mtok cached-input price, or null when the model has none. */ - cache: string | null - /** True when the model costs nothing (free tier eligible). */ - free: boolean - /** Sale: rounded percent off list when gateway sends pricing.original. */ - discount_percent?: number - /** Sale: formatted pre-discount input $/Mtok ("was"). */ - was_input?: string - /** Sale: formatted pre-discount output $/Mtok ("was"). */ - was_output?: string -} - -export interface ModelOptionProvider { - is_current?: boolean - models?: string[] - name: string - slug: string - total_models?: number - warning?: string - /** Curated shortlist (one flagship per lab) the picker shows by default for - * aggregator providers that serve dozens of models across many labs. Empty - * for providers with no manifest entry — the picker falls back to top-N. - * The rest of `models` stays reachable via search / Edit Models. */ - featured_models?: string[] - /** True when the provider has usable credentials. False for canonical - * providers surfaced by `include_unconfigured` that the user hasn't set up - * yet — render these with a setup affordance instead of hiding them. */ - authenticated?: boolean - /** Auth flow for an unconfigured provider: "api_key" can be activated inline - * by pasting `key_env`; anything else (oauth_*, external, aws_sdk, …) needs - * the `hermes model` CLI / onboarding OAuth flow. */ - auth_type?: string - /** Env var to paste an API key into, for unconfigured `api_key` providers. */ - key_env?: string - /** True for providers defined via the user's `providers:` config block. */ - is_user_defined?: boolean - /** User-defined providers only: every accepted identity for this endpoint - * (bare config key, `custom:`, normalized display name, …). A session's - * `model.options` reports the canonical `custom:` form, so "is this row - * the current provider?" must check membership here, not slug equality. */ - aliases?: string[] - /** OpenAI-compatible endpoint for a user-defined provider. The backend - * exposes this as `api_url`; model assignments send it back as `base_url` - * so switching providers does not discard the selected local endpoint. */ - api_url?: string - /** Per-model pricing keyed by model id (present when the picker requested - * pricing and the provider supports live pricing). */ - pricing?: Record - /** Nous only: whether the current account is on the free plan. Set by - * pricing for a signed-in account — NOT the same thing as `free_tier_row`, - * which marks the no-account route. */ - free_tier?: boolean - /** True for the free-tier route's own provider row (no account behind it). - * Never match this row by `name` — the label is copy and can change. */ - free_tier_row?: boolean - /** Nous only: paid models a free-tier user cannot select (shown disabled). */ - unavailable_models?: string[] - /** Per-model option support, keyed by model id (present when the picker - * requested capabilities). Lets the UI gate fast/reasoning controls. */ - capabilities?: Record -} - -export interface ModelCapabilities { - /** False when the route rejects a reasoning disable ("mandatory" in the - * provider catalog), so the Thinking toggle must not be offered. Absent - * when the catalog doesn't say. */ - can_disable_reasoning?: boolean - fast: boolean - reasoning: boolean -} - -export interface ModelOptionsResponse { - model?: string - provider?: string - providers?: ModelOptionProvider[] -} - export interface PaginatedSessions { limit: number offset: number @@ -544,16 +458,6 @@ export interface PaginatedSessions { errors?: Array<{ profile: string; error: string }> } -export interface RpcEvent { - payload?: T - profile?: string - /** Registry connection whose socket delivered the event (renderer-side tag; - * absent for the local/legacy primary path). */ - connectionId?: string - session_id?: string - type: string -} - export interface SessionCreateResponse { info?: SessionRuntimeInfo message_count?: number diff --git a/apps/shared/package.json b/apps/shared/package.json index 40b4fa4fe3..9cc9a92d4e 100644 --- a/apps/shared/package.json +++ b/apps/shared/package.json @@ -8,6 +8,7 @@ "./billing": "./src/billing-types.ts", "./billing-policy": "./src/billing-policy.ts", "./charge-settlement": "./src/charge-settlement.ts", + "./gateway-events": "./src/gateway-events.ts", "./skin": "./src/skin.ts" }, "types": "./src/index.ts", diff --git a/apps/shared/src/gateway-events.json b/apps/shared/src/gateway-events.json new file mode 100644 index 0000000000..fe5088fe87 --- /dev/null +++ b/apps/shared/src/gateway-events.json @@ -0,0 +1,93 @@ +[ + "agent.terminal.output", + "approval.request", + "background.complete", + "billing.step_up.verification", + "bot_relay.outbox.pending", + "browser.controller.cancel", + "browser.controller.command", + "browser.progress", + "btw.complete", + "clarify.expire", + "clarify.request", + "cron.changed", + "error", + "gateway.ready", + "layout.apply", + "mcp.setup.expire", + "mcp.setup.request", + "message.complete", + "message.delta", + "message.interim", + "message.reaction", + "message.start", + "moa.aggregating", + "moa.phase", + "moa.progress", + "moa.reference", + "notice", + "notification.clear", + "notification.show", + "pairing.changed", + "pane.reveal", + "pet.changed", + "pet.generate.progress", + "pet.hatch.progress", + "platforms.changed", + "preview.act.expire", + "preview.act.request", + "preview.close", + "preview.open", + "preview.read.expire", + "preview.read.request", + "preview.restart.complete", + "preview.restart.progress", + "reaction", + "reasoning.available", + "reasoning.delta", + "review.summary", + "secret.expire", + "secret.request", + "session.control.update", + "session.info", + "session.reclaimed", + "session.resume_progress", + "session.title", + "session.usage", + "sessions.changed", + "setup.ready", + "skin.changed", + "status.update", + "subagent.complete", + "subagent.progress", + "subagent.spawn_requested", + "subagent.start", + "subagent.thinking", + "subagent.tool", + "sudo.expire", + "sudo.request", + "terminal.close", + "terminal.read.expire", + "terminal.read.request", + "thinking.delta", + "tip.show", + "todo.updated", + "tool.complete", + "tool.generating", + "tool.output_risk", + "tool.start", + "tour.expire", + "tour.request", + "vault.code.expire", + "vault.code.request", + "vault.save_login.expire", + "vault.save_login.request", + "vault.unlock.expire", + "vault.unlock.request", + "voice.interrupted", + "voice.status", + "voice.transcript", + "wake.detected", + "window.read.expire", + "window.read.request" +] diff --git a/apps/shared/src/gateway-events.test.ts b/apps/shared/src/gateway-events.test.ts new file mode 100644 index 0000000000..3232e3098e --- /dev/null +++ b/apps/shared/src/gateway-events.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' + +import { BACKEND_EVENT_NAMES } from './gateway-events' +import contract from './gateway-events.json' + +/** + * Two-sided contract with `tests/tui_gateway/test_gateway_event_contract.py`: the + * Python side pins the emitter call sites to `gateway-events.json`; this side pins + * `BACKEND_EVENT_NAMES` (which `BackendGatewayEventMap` is checked against via + * `satisfies`) to the same JSON. A name added on either side alone goes red here. + */ +describe('gateway-events.json ⇄ BackendGatewayEventMap', () => { + it('lists exactly the backend-emitted notification names', () => { + expect([...BACKEND_EVENT_NAMES]).toEqual(contract) + }) + + it('keeps both lists sorted and duplicate-free (stable diffs)', () => { + const sorted = [...contract].sort() + expect(contract).toEqual(sorted) + expect(new Set(contract).size).toBe(contract.length) + }) +}) diff --git a/apps/shared/src/gateway-events.ts b/apps/shared/src/gateway-events.ts new file mode 100644 index 0000000000..65df723e68 --- /dev/null +++ b/apps/shared/src/gateway-events.ts @@ -0,0 +1,766 @@ +/** + * Wire types for `tui_gateway` JSON-RPC notifications and the RPC responses the + * TypeScript surfaces (Ink TUI, Desktop, web dashboard) share. + * + * Every notification arrives as `{jsonrpc: '2.0', method: 'event', params: GatewayEvent}` + * (`tui_gateway/server.py::_event_frame`). `GatewayEventMap` is the single map from + * event `type` to payload shape; `BACKEND_EVENT_NAMES` mirrors the emitter side and is + * pinned to `gateway-events.json` by `gateway-events.test.ts` (vitest) and + * `tests/tui_gateway/test_gateway_event_contract.py` (Python), so a name added on one + * side without the other fails a test instead of drifting silently. + * + * Payload interfaces are typed from the Python emitters (file::symbol noted per + * interface). Events whose payload no TS client reads yet are `Record`; + * they still MUST be keys so `on('x', …)` stays exhaustive. + */ +import type { BillingBlock } from './billing-types.js' +import type { HermesSkin } from './skin.js' + +// ── Shared value shapes ────────────────────────────────────────────── + +/** `tui_gateway/server.py::_get_usage` — a session's token/cost counters. */ +export interface Usage { + active_subagents?: number + /** Rolling mean API latency over the last 10 calls (seconds). */ + avg_latency_s?: number + /** Rolling output tokens/sec over the last 10 calls. */ + avg_tps?: number + /** Session prompt-cache hit ratio (cache_read / prompt tokens, %). Omitted (not 0) + * when the provider reports no cache reads. */ + cache_hit_pct?: number + cache_read?: number + cache_write?: number + calls: number + compressions?: number + context_max?: number + context_percent?: number + context_estimated?: boolean + context_source?: string + context_used?: number + cost_status?: string + cost_usd?: number + dev_credits_spent_micros?: number + input: number + output: number + reasoning?: number + total: number +} + +/** Advisory `{layer, code, retryable}` descriptor (`agent/error_surface.py`). */ +export interface ErrorSurface { + code?: string + layer?: string + retryable?: boolean +} + +/** `tui_gateway/tool_progress.py::_normalize_todo_state` — full task snapshot. */ +export interface TodoStatePayload { + revision?: number + todos?: unknown[] +} + +export type SubagentStatus = 'completed' | 'error' | 'failed' | 'interrupted' | 'queued' | 'running' | 'timeout' + +/** `tui_gateway/tool_progress.py::_progress_subagent` — every `subagent.*` frame. */ +export interface SubagentEventPayload { + api_calls?: number + cost_usd?: number + /** Batch (delegation) id this subagent belongs to — distinguishes + * interleaved `[n/N]` progress from concurrent or nested fan-outs. */ + delegation_id?: string + depth?: number + duration_seconds?: number + files_read?: string[] + files_written?: string[] + goal: string + input_tokens?: number + iteration?: number + model?: string + output_tail?: { is_error?: boolean; preview?: string; tool?: string }[] + output_tokens?: number + parent_id?: null | string + reasoning_tokens?: number + status?: SubagentStatus + subagent_id?: string + summary?: string + task_count?: number + task_index: number + text?: string + tool_count?: number + tool_name?: string + tool_preview?: string + toolsets?: string[] +} + +// ── Event payloads ─────────────────────────────────────────────────── + +/** `tui_gateway/entry.py` (stdio) / `tui_gateway/ws.py` (WebSocket) first frame. */ +export interface GatewayReadyPayload { + /** Backends with the change watcher broadcast `*.changed` events; consumers + * demote their legacy polls to slow backstops. */ + change_events?: boolean + /** WebSocket transport only: the server answers heartbeat pings. */ + heartbeat?: boolean + /** Opaque token for this server process's `seq` numbering; a new epoch means + * replay watermarks must be discarded. */ + replay_epoch?: string + skin?: HermesSkin +} + +/** `tui_gateway/prompt_turn.py::_complete_turn_payload` and + * `tui_gateway/session_auto_continue.py::_emit_terminal_turn_error`. */ +export interface MessageCompletePayload { + /** Structured billing wall when the turn failed with FailoverReason.billing. */ + billing?: BillingBlock + /** `status: "error"` — the failure message (`text` may be streamed output). */ + error?: string + error_surface?: ErrorSurface + failure_reason?: string + /** `status: "error"` — `text` is streamed partial output to keep, not the error string. */ + partial?: boolean + reasoning?: string + /** `status: "error"` — the failed turn was retained and replays via `session.resume.inflight`. */ + recoverable?: boolean + rendered?: string + /** The final text was already previewed via `message.interim`; settle, don't duplicate. */ + response_previewed?: boolean + status?: 'complete' | 'error' | 'interrupted' | string + text?: string + usage?: Usage + /** History-commit note (e.g. a mid-turn desync the gateway surfaced instead of dropping). */ + warning?: string +} + +/** `tui_gateway/tool_progress.py::_on_tool_start`. */ +export interface ToolStartPayload { + /** Full tool arguments — the 80-char `context` preview is display-only. */ + args?: Record + /** Verbose mode only: pretty-printed args. */ + args_text?: string + context?: string + name?: string + /** Mirrored child tool rows carry a short preview instead of args. */ + preview?: string + tool_id: string + todos?: unknown[] +} + +/** `tui_gateway/tool_progress.py::_on_tool_complete`. */ +export interface ToolCompletePayload { + args?: Record + duration_s?: number + error?: string + inline_diff?: string + name?: string + /** Parsed JSON when the tool returned JSON, else the raw string. */ + result?: unknown + /** Verbose mode only. */ + result_text?: string + revision?: number + summary?: string + tool_id: string + todos?: unknown[] +} + +export interface ToolGeneratingPayload { + name?: string +} + +/** `tui_gateway/tool_progress.py::_progress_output_risk`. */ +export interface ToolOutputRiskPayload { + findings?: string[] + name?: string + redacted?: boolean + risk?: string + tool_id?: string +} + +export interface StatusUpdatePayload { + kind?: string + text?: string +} + +export interface NotificationShowPayload { + id?: string + key?: string + kind?: 'sticky' | 'ttl' | string + level?: 'error' | 'info' | 'success' | 'warn' | string + text?: string + ttl_ms?: null | number +} + +export interface NotificationClearPayload { + key?: string +} + +export interface TextPayload { + text?: string +} + +/** `message.delta` / `reasoning.delta` / `reasoning.available` / `thinking.delta`. */ +export interface StreamDeltaPayload { + rendered?: string + text?: string + /** Verbose reasoning mode is on for this session. */ + verbose?: boolean +} + +export interface MessageInterimPayload { + already_streamed?: boolean + text: string +} + +export interface SessionUsagePayload { + usage?: Usage +} + +export interface SessionTitlePayload { + session_id?: string + title?: string +} + +/** `tui_gateway/methods_session.py` resume hydration progress. */ +export interface SessionResumeProgressPayload { + message?: string + message_count?: number + phase?: string + status?: 'complete' | 'failed' | 'loading' | string +} + +/** `tui_gateway/session_lifecycle.py::_announce_session_reclaimed`. */ +export interface SessionReclaimedPayload { + reason?: string + session_id?: string + stored_session_id?: string +} + +export interface SessionControlUpdatePayload { + control?: unknown +} + +export interface ErrorPayload { + message?: string + reason?: string +} + +export interface NoticePayload { + message?: string +} + +export interface ReactionPayload { + kind?: string +} + +export interface BillingStepUpVerificationPayload { + user_code?: string + verification_url: string +} + +export interface VoiceStatusPayload { + state?: 'idle' | 'listening' | 'transcribing' | string +} + +export interface VoiceTranscriptPayload { + no_speech_limit?: boolean + stop_phrase?: boolean + text?: string + typed?: boolean + voice_stopped?: boolean +} + +export interface WakeDetectedPayload { + phrase?: string + profile?: null | string + start_new_session?: boolean +} + +export interface BrowserProgressPayload { + level?: 'error' | 'info' | 'warn' | string + message?: string +} + +export interface MoaReferencePayload { + count?: number + index?: number + label?: string + text?: string +} + +export interface MoaAggregatingPayload { + aggregator?: string +} + +export interface MoaProgressPayload { + label?: string + refs_done?: number + refs_total?: number +} + +export interface MoaPhasePayload { + aggregator?: string + phase?: string + refs_done?: number + refs_total?: number +} + +// Blocking bridges (`tui_gateway/server.py::_block`): every `*.request` carries a +// `request_id`; the matching `*.expire` names the same id when the wait timed out. + +export interface RequestExpirePayload { + request_id: string +} + +export interface ClarifyQuestion { + choices?: null | string[] + multi_select?: boolean + qid: string + question: string +} + +export interface ClarifyRequestPayload { + answers?: Record + choices?: null | string[] + multi_select?: boolean + question?: string + questions?: ClarifyQuestion[] + request_id: string +} + +/** `tui_gateway/server.py::_approval_request_payload` (command redacted server-side). */ +export interface ApprovalRequestPayload { + allow_permanent?: boolean + choices?: string[] + command: string + description: string + request_id?: string + smart_denied?: boolean +} + +export interface SudoRequestPayload { + request_id: string +} + +export interface SecretRequestPayload { + env_var: string + prompt: string + request_id: string +} + +export interface VaultUnlockRequestPayload { + backend: string + display_name: string + request_id: string +} + +export interface VaultCodeRequestPayload { + hint?: string + request_id: string + site?: string +} + +export interface McpSetupRequestPayload { + action?: string + reason?: string + request_id: string + server?: string +} + +/** Side agents (`tui_gateway/methods_prompt.py::_spawn_side_agent`). */ +export interface SideAgentCompletePayload { + question?: string + task_id: string + text: string +} + +export interface PreviewRestartProgressPayload { + task_id: string + text: string +} + +export interface TerminalOutputPayload { + chunk?: string + process_id?: string +} + +export interface TerminalClosePayload { + process_id?: string +} + +// ── The map ────────────────────────────────────────────────────────── + +/** + * Backend-emitted notification names. Derived from the `tui_gateway` emitter call + * sites and pinned to `gateway-events.json`; keep sorted. Adding a name here without + * the JSON (or vice versa) fails `gateway-events.test.ts`, and a Python emitter that + * names an event missing from the JSON fails `test_gateway_event_contract.py`. + */ +export const BACKEND_EVENT_NAMES = [ + 'agent.terminal.output', + 'approval.request', + 'background.complete', + 'billing.step_up.verification', + 'bot_relay.outbox.pending', + 'browser.controller.cancel', + 'browser.controller.command', + 'browser.progress', + 'btw.complete', + 'clarify.expire', + 'clarify.request', + 'cron.changed', + 'error', + 'gateway.ready', + 'layout.apply', + 'mcp.setup.expire', + 'mcp.setup.request', + 'message.complete', + 'message.delta', + 'message.interim', + 'message.reaction', + 'message.start', + 'moa.aggregating', + 'moa.phase', + 'moa.progress', + 'moa.reference', + 'notice', + 'notification.clear', + 'notification.show', + 'pairing.changed', + 'pane.reveal', + 'pet.changed', + 'pet.generate.progress', + 'pet.hatch.progress', + 'platforms.changed', + 'preview.act.expire', + 'preview.act.request', + 'preview.close', + 'preview.open', + 'preview.read.expire', + 'preview.read.request', + 'preview.restart.complete', + 'preview.restart.progress', + 'reaction', + 'reasoning.available', + 'reasoning.delta', + 'review.summary', + 'secret.expire', + 'secret.request', + 'session.control.update', + 'session.info', + 'session.reclaimed', + 'session.resume_progress', + 'session.title', + 'session.usage', + 'sessions.changed', + 'setup.ready', + 'skin.changed', + 'status.update', + 'subagent.complete', + 'subagent.progress', + 'subagent.spawn_requested', + 'subagent.start', + 'subagent.thinking', + 'subagent.tool', + 'sudo.expire', + 'sudo.request', + 'terminal.close', + 'terminal.read.expire', + 'terminal.read.request', + 'thinking.delta', + 'tip.show', + 'todo.updated', + 'tool.complete', + 'tool.generating', + 'tool.output_risk', + 'tool.start', + 'tour.expire', + 'tour.request', + 'vault.code.expire', + 'vault.code.request', + 'vault.save_login.expire', + 'vault.save_login.request', + 'vault.unlock.expire', + 'vault.unlock.request', + 'voice.interrupted', + 'voice.status', + 'voice.transcript', + 'wake.detected', + 'window.read.expire', + 'window.read.request' +] as const satisfies readonly (keyof BackendGatewayEventMap)[] + +export type BackendGatewayEventName = (typeof BACKEND_EVENT_NAMES)[number] + +/** Payload per backend-emitted notification `type`. Keys are exactly `BACKEND_EVENT_NAMES`. */ +export interface BackendGatewayEventMap { + 'agent.terminal.output': TerminalOutputPayload + 'approval.request': ApprovalRequestPayload + 'background.complete': SideAgentCompletePayload + 'billing.step_up.verification': BillingStepUpVerificationPayload + 'bot_relay.outbox.pending': Record + 'browser.controller.cancel': Record + 'browser.controller.command': Record + 'browser.progress': BrowserProgressPayload + 'btw.complete': SideAgentCompletePayload + 'clarify.expire': RequestExpirePayload + 'clarify.request': ClarifyRequestPayload + 'cron.changed': Record + error: ErrorPayload + 'gateway.ready': GatewayReadyPayload + 'layout.apply': Record + 'mcp.setup.expire': RequestExpirePayload + 'mcp.setup.request': McpSetupRequestPayload + 'message.complete': MessageCompletePayload + 'message.delta': StreamDeltaPayload + 'message.interim': MessageInterimPayload + 'message.reaction': Record + 'message.start': undefined + 'moa.aggregating': MoaAggregatingPayload + 'moa.phase': MoaPhasePayload + 'moa.progress': MoaProgressPayload + 'moa.reference': MoaReferencePayload + notice: NoticePayload + 'notification.clear': NotificationClearPayload + 'notification.show': NotificationShowPayload + 'pairing.changed': Record + 'pane.reveal': Record + 'pet.changed': Record + 'pet.generate.progress': Record + 'pet.hatch.progress': Record + 'platforms.changed': Record + 'preview.act.expire': RequestExpirePayload + 'preview.act.request': Record + 'preview.close': Record + 'preview.open': Record + 'preview.read.expire': RequestExpirePayload + 'preview.read.request': Record + 'preview.restart.complete': SideAgentCompletePayload + 'preview.restart.progress': PreviewRestartProgressPayload + reaction: ReactionPayload + 'reasoning.available': StreamDeltaPayload + 'reasoning.delta': StreamDeltaPayload + 'review.summary': TextPayload + 'secret.expire': RequestExpirePayload + 'secret.request': SecretRequestPayload + 'session.control.update': SessionControlUpdatePayload + /** Surface-specific shape (`tui_gateway/server.py::_session_info`); each client narrows. */ + 'session.info': Record + 'session.reclaimed': SessionReclaimedPayload + 'session.resume_progress': SessionResumeProgressPayload + 'session.title': SessionTitlePayload + 'session.usage': SessionUsagePayload + 'sessions.changed': Record + 'setup.ready': Record + 'skin.changed': HermesSkin + 'status.update': StatusUpdatePayload + 'subagent.complete': SubagentEventPayload + 'subagent.progress': SubagentEventPayload + 'subagent.spawn_requested': SubagentEventPayload + 'subagent.start': SubagentEventPayload + 'subagent.thinking': SubagentEventPayload + 'subagent.tool': SubagentEventPayload + 'sudo.expire': RequestExpirePayload + 'sudo.request': SudoRequestPayload + 'terminal.close': TerminalClosePayload + 'terminal.read.expire': RequestExpirePayload + 'terminal.read.request': Record + 'thinking.delta': StreamDeltaPayload + 'tip.show': Record + 'todo.updated': TodoStatePayload + 'tool.complete': ToolCompletePayload + 'tool.generating': ToolGeneratingPayload + 'tool.output_risk': ToolOutputRiskPayload + 'tool.start': ToolStartPayload + 'tour.expire': RequestExpirePayload + 'tour.request': Record + 'vault.code.expire': RequestExpirePayload + 'vault.code.request': VaultCodeRequestPayload + 'vault.save_login.expire': RequestExpirePayload + 'vault.save_login.request': Record + 'vault.unlock.expire': RequestExpirePayload + 'vault.unlock.request': VaultUnlockRequestPayload + 'voice.interrupted': Record + 'voice.status': VoiceStatusPayload + 'voice.transcript': VoiceTranscriptPayload + 'wake.detected': WakeDetectedPayload + 'window.read.expire': RequestExpirePayload + 'window.read.request': Record +} + +/** + * Client-local synthetic events. Never emitted by `tui_gateway`; the Ink TUI's + * `gatewayClient` publishes them into the same handler stream to report transport + * state. Excluded from `gateway-events.json` on purpose. + */ +export interface ClientLocalGatewayEventMap { + 'dashboard.new_session_requested': { reason?: string } + 'gateway.protocol_error': { preview?: string } + 'gateway.reconnecting': { attempt?: number; delay_ms?: number } + 'gateway.start_timeout': { cwd?: string; python?: string; stderr_tail?: string } + 'gateway.stderr': { line: string } +} + +export interface GatewayEventMap extends BackendGatewayEventMap, ClientLocalGatewayEventMap {} + +export type GatewayEventName = keyof GatewayEventMap + +/** One `event` notification's `params`. */ +export interface GatewayEvent { + /** Registry connection whose socket delivered the event (renderer-side tag; + * absent for the local/legacy primary path). */ + connectionId?: string + payload?: GatewayEventMap[K] + /** Renderer-side source tag added by the Desktop gateway registry. */ + profile?: string + /** Per-session monotonic counter stamped by `tui_gateway/event_replay.py::_stamp_event`; + * absent on session-less broadcasts. */ + seq?: number + session_id?: string + type: K +} + +// ── RPC responses shared across surfaces ───────────────────────────── + +/** `hermes_cli/inventory.py` one `model.options` provider row (union of every field the + * backend sets; `pricing_pending` / `free_tier_pending` mark the cached-only fail-closed path). */ +export interface ModelOptionProvider { + /** User-defined providers only: every accepted identity for this endpoint + * (bare config key, `custom:`, normalized display name, …). A session's + * `model.options` reports the canonical `custom:` form, so "is this row + * the current provider?" must check membership here, not slug equality. */ + aliases?: string[] + /** OpenAI-compatible endpoint for a user-defined provider. The backend + * exposes this as `api_url`; model assignments send it back as `base_url`. */ + api_url?: string + /** Auth flow for an unconfigured provider: "api_key" can be activated inline + * by pasting `key_env`; anything else (oauth_*, external, aws_sdk, …) needs + * the `hermes model` CLI / onboarding OAuth flow. */ + auth_type?: string + /** True when the provider has usable credentials. False for canonical + * providers surfaced by `include_unconfigured` that the user hasn't set up + * yet — render these with a setup affordance instead of hiding them. */ + authenticated?: boolean + /** Per-model option support, keyed by model id (present when the picker + * requested capabilities). Lets the UI gate fast/reasoning controls. */ + capabilities?: Record + /** Curated shortlist (one flagship per lab) the picker shows by default for + * aggregator providers that serve dozens of models across many labs. */ + featured_models?: string[] + /** Nous only: whether the current account is on the free plan. */ + free_tier?: boolean + /** Nous only, cached-only inventory: entitlement unknown, every model rendered locked. */ + free_tier_pending?: boolean + /** True for the free-tier route's own provider row (no account behind it). + * Never match this row by `name` — the label is copy and can change. */ + free_tier_row?: boolean + is_current?: boolean + /** True for providers defined via the user's `providers:` config block. */ + is_user_defined?: boolean + /** Env var to paste an API key into, for unconfigured `api_key` providers. */ + key_env?: string + models?: string[] + name: string + /** Per-model pricing keyed by model id (present when the picker requested + * pricing and the provider supports live pricing). */ + pricing?: Record + /** Cached-only inventory: pricing not fetched yet. */ + pricing_pending?: boolean + slug: string + source?: string + total_models?: number + /** Nous only: paid models a free-tier user cannot select (shown disabled). */ + unavailable_models?: string[] + warning?: string +} + +export interface ModelPricing { + /** Formatted $/Mtok cached-input price, or null when the model has none. */ + cache: null | string + /** Sale: rounded percent off list when gateway sends pricing.original. */ + discount_percent?: number + /** True when the model costs nothing (free tier eligible). */ + free: boolean + /** Formatted $/Mtok input price, e.g. "$3.00", or "free", or "" if unknown. */ + input: string + /** Formatted $/Mtok output price. */ + output: string + /** Sale: formatted pre-discount input $/Mtok ("was"). */ + was_input?: string + /** Sale: formatted pre-discount output $/Mtok ("was"). */ + was_output?: string +} + +export interface ModelCapabilities { + /** False when the route rejects a reasoning disable ("mandatory" in the + * provider catalog), so the Thinking toggle must not be offered. */ + can_disable_reasoning?: boolean + fast: boolean + reasoning: boolean +} + +export interface ModelOptionsResponse { + model?: string + provider?: string + providers?: ModelOptionProvider[] +} + +/** `tui_gateway/methods_session.py::_session_row_summary` — one `session.list` row. */ +export interface SessionListItem { + id: string + message_count: number + preview: string + /** The runtime id this stored session is currently attached to, when live. */ + resolved_id?: string + source?: string + started_at: number + title: string +} + +export interface SessionListResponse { + sessions?: SessionListItem[] +} + +/** Transcript row as projected by the gateway (`session.resume` / `session.activate`). */ +export interface GatewayTranscriptMessage { + args?: unknown + context?: string + display_kind?: string + display_metadata?: unknown + name?: string + role: 'assistant' | 'system' | 'tool' | 'user' + text?: string +} + +export interface SessionInflightTurn { + assistant?: string + correction_offsets?: number[] + corrections?: string[] + error?: string + error_surface?: ErrorSurface + recoverable?: boolean + status?: string + streaming?: boolean + user?: string +} + +/** `tui_gateway/methods_session.py::_resume_response`. `info` is surface-specific + * (`SessionInfo` in the TUI, `SessionRuntimeInfo` on Desktop); narrow at the call site. */ +export interface SessionResumeResponse, Message = GatewayTranscriptMessage> { + /** Present when the backend found a fresh crash-interrupted turn and scheduled its + * automatic continuation; the turn arrives as a normal message.start stream. */ + auto_continue?: { attempt: number; interrupted_at: number } + /** Deferred hydration: history arrives via `session.resume_progress`. */ + hydrating?: boolean + inflight?: null | SessionInflightTurn + info?: Info + message_count?: number + messages: Message[] + /** `omit_messages` resume: the client still learns the stored size. */ + messages_omitted?: boolean + resumed?: string + running?: boolean + session_id: string + session_key?: string + started_at?: number + status?: string + todo_state?: TodoStatePayload +} diff --git a/apps/shared/src/index.ts b/apps/shared/src/index.ts index e563361fc5..3d82d72dad 100644 --- a/apps/shared/src/index.ts +++ b/apps/shared/src/index.ts @@ -46,11 +46,51 @@ export { DATA_URL_READ_MAX_MAX_MB, DATA_URL_READ_MIN_MAX_MB } from './data-url-read-max' +export { + type ApprovalRequestPayload, + BACKEND_EVENT_NAMES, + type BackendGatewayEventMap, + type BackendGatewayEventName, + type ClarifyQuestion, + type ClarifyRequestPayload, + type ClientLocalGatewayEventMap, + type ErrorPayload, + type ErrorSurface, + type GatewayEvent, + type GatewayEventMap, + type GatewayEventName, + type GatewayReadyPayload, + type GatewayTranscriptMessage, + type McpSetupRequestPayload, + type MessageCompletePayload, + type MessageInterimPayload, + type ModelCapabilities, + type ModelOptionProvider, + type ModelOptionsResponse, + type ModelPricing, + type NotificationShowPayload, + type RequestExpirePayload, + type SecretRequestPayload, + type SessionInflightTurn, + type SessionListItem, + type SessionListResponse, + type SessionReclaimedPayload, + type SessionResumeResponse, + type SessionUsagePayload, + type StatusUpdatePayload, + type StreamDeltaPayload, + type SubagentEventPayload, + type SubagentStatus, + type TodoStatePayload, + type ToolCompletePayload, + type ToolStartPayload, + type Usage, + type VaultUnlockRequestPayload, + type WakeDetectedPayload +} from './gateway-events' export { type ConnectionState, type GatewayClientOptions, - type GatewayEvent, - type GatewayEventName, type GatewayRequestId, isGatewayWebSocketUrl, type JsonRpcErrorPayload, diff --git a/apps/shared/src/json-rpc-gateway.ts b/apps/shared/src/json-rpc-gateway.ts index 420555fd59..cff8f15691 100644 --- a/apps/shared/src/json-rpc-gateway.ts +++ b/apps/shared/src/json-rpc-gateway.ts @@ -1,39 +1,6 @@ -export type GatewayEventName = - | 'gateway.ready' - | 'session.info' - | 'session.usage' - | 'message.start' - | 'message.delta' - | 'message.interim' - | 'message.complete' - | 'thinking.delta' - | 'reasoning.delta' - | 'reasoning.available' - | 'status.update' - | 'tool.start' - | 'tool.progress' - | 'tool.complete' - | 'tool.generating' - | 'todo.updated' - | 'clarify.request' - | 'approval.request' - | 'sudo.request' - | 'secret.request' - | 'background.complete' - | 'error' - | 'skin.changed' - | (string & {}) +import type { GatewayEvent, GatewayEventName } from './gateway-events.js' -export interface GatewayEvent

{ - payload?: P - /** Renderer-side source tag added by the Desktop gateway registry. */ - profile?: string - /** Registry connection whose socket delivered the event (renderer-side tag; - * absent for the local/legacy primary path). */ - connectionId?: string - session_id?: string - type: GatewayEventName -} +export type { GatewayEvent, GatewayEventName } from './gateway-events.js' export type ConnectionState = 'idle' | 'connecting' | 'open' | 'closed' | 'error' export type GatewayRequestId = number | string @@ -89,6 +56,8 @@ export interface GatewayClientOptions { } const ANY = '*' + +const isGatewayReady = (event: GatewayEvent): event is GatewayEvent<'gateway.ready'> => event.type === 'gateway.ready' const DEFAULT_REQUEST_TIMEOUT_MS = 120_000 // Replay fetch after reconnect: bounded so a wedged backend can't hold the // guard open; generous enough for a 512-frame ring to drain. @@ -314,7 +283,7 @@ export class JsonRpcGatewayClient { this.invalidateSocket(socket, new Error(message)) } - on

(type: GatewayEventName, handler: (event: GatewayEvent

) => void): () => void { + on(type: K, handler: (event: GatewayEvent) => void): () => void { let handlers = this.eventHandlers.get(type) if (!handlers) { @@ -328,7 +297,8 @@ export class JsonRpcGatewayClient { } onAny(handler: (event: GatewayEvent) => void): () => void { - return this.on(ANY as GatewayEventName, handler) + // ANY is a client-side wildcard, not a wire name; it never reaches the typed map. + return this.on(ANY as GatewayEventName, handler as (event: GatewayEvent) => void) } onEvent(handler: (event: GatewayEvent) => void): () => void { @@ -464,8 +434,8 @@ export class JsonRpcGatewayClient { } if (frame.method === 'event' && frame.params?.type) { - if (frame.params.type === 'gateway.ready') { - if (this.gatewayReadyAdvertisesHeartbeat(frame.params.payload)) { + if (isGatewayReady(frame.params)) { + if (frame.params.payload?.heartbeat === true) { const socket = this.socket if (socket) { @@ -473,7 +443,7 @@ export class JsonRpcGatewayClient { } } - const epoch = (frame.params.payload as { replay_epoch?: unknown } | undefined)?.replay_epoch + const epoch = frame.params.payload?.replay_epoch if (typeof epoch === 'string' && epoch) { this.adoptReplayEpoch(epoch) @@ -481,7 +451,7 @@ export class JsonRpcGatewayClient { } const sid = frame.params.session_id - const seqValue = (frame.params as { seq?: unknown }).seq + const seqValue = frame.params.seq if (this.replayHold && sid && typeof seqValue === 'number' && this.replayHold.has(sid)) { // Replay in flight for this session: park the frame; flushReplayHold @@ -502,7 +472,7 @@ export class JsonRpcGatewayClient { */ private recordSeq(event: GatewayEvent): void { const sid = event.session_id - const seq = (event as { seq?: unknown }).seq + const seq = event.seq if (!sid || typeof seq !== 'number' || !Number.isFinite(seq)) { return @@ -600,7 +570,7 @@ export class JsonRpcGatewayClient { */ private dispatchIfNewer(event: GatewayEvent): void { const sid = event.session_id - const seq = (event as { seq?: unknown }).seq + const seq = event.seq if (sid && typeof seq === 'number' && Number.isFinite(seq)) { const prev = this.lastSeenSeq.get(sid) ?? 0 @@ -648,10 +618,6 @@ export class JsonRpcGatewayClient { } } - private gatewayReadyAdvertisesHeartbeat(payload: unknown): boolean { - return Boolean(payload && typeof payload === 'object' && (payload as { heartbeat?: unknown }).heartbeat === true) - } - private startHeartbeat(socket: WebSocketLike): void { this.stopHeartbeat() this.lastInboundAt = Date.now() diff --git a/tests/tui_gateway/test_gateway_event_contract.py b/tests/tui_gateway/test_gateway_event_contract.py new file mode 100644 index 0000000000..ccbddfc2c4 --- /dev/null +++ b/tests/tui_gateway/test_gateway_event_contract.py @@ -0,0 +1,97 @@ +"""Two-sided contract: every notification name ``tui_gateway`` emits is listed in +``apps/shared/src/gateway-events.json`` and nothing in the JSON is orphaned. + +The TypeScript half (``apps/shared/src/gateway-events.test.ts``) pins the typed +``GatewayEventMap`` to the same JSON, so a name added on either side alone goes red +somewhere. This file reads only Python sources and the JSON (never ``.ts`` text — +see ``tui_gateway/AGENTS.md``). + +Names are collected from the emitter side: literal first arguments to the emit +helpers, plus the tables that derive names at runtime (``_EXPIRING_REQUESTS`` → +``*.expire``, the change-watcher table, child delta mirroring, the subagent relay +events from ``tools/delegate_tool*.py``, the ``desktop_ui`` tool emitters, and the +literal ``gateway.ready`` / ``setup.ready`` / browser-controller frames). +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[2] +CONTRACT = REPO / "apps" / "shared" / "src" / "gateway-events.json" +GATEWAY_DIR = REPO / "tui_gateway" + +# Every helper whose first positional argument is the wire ``type``. +_EMIT_HELPERS = ( + "_emit", "_block", "_read_block", "_broadcast_global_event", "_voice_emit", "_pet_emit", "_emit_tool_lifecycle") +_LITERAL_EMIT = re.compile(r"\b(?:%s)\(\s*\"([a-z_][a-z0-9_.]*)\"" % "|".join(_EMIT_HELPERS)) +# ``{"type": "gateway.ready", ...}`` literal frames (entry.py / ws.py) and other +# ``"type": ""`` params written straight into an ``event`` frame. +_LITERAL_FRAME = re.compile(r"\"method\":\s*\"event\".{0,120}?\"type\":\s*\"([a-z_][a-z0-9_.]*)\"", re.S) +_SIDE_AGENT = re.compile(r"_spawn_side_agent\((?:[^()]|\([^()]*\))*?\"([a-z_][a-z0-9_.]*\.complete)\"", re.S) +_SUBAGENT_RELAY = re.compile(r"\"(subagent\.[a-z_]+)\"") +_DESKTOP_UI_EMIT = re.compile(r"desktop_ui\.(?:emit|emit_or_error)\(\s*\"([a-z_][a-z0-9_.]*)\"") +_BROKER_FRAME = re.compile(r"^FRAME_[A-Z_]+ = \"(browser\.controller\.[a-z_]+)\"", re.M) +_SETUP_READY = re.compile(r"^SETUP_READY_EVENT = \"([a-z_.]+)\"", re.M) + + +def _read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def emitted_event_names() -> set[str]: + names: set[str] = set() + for src in GATEWAY_DIR.glob("*.py"): + text = _read(src) + names.update(_LITERAL_EMIT.findall(text)) + names.update(_LITERAL_FRAME.findall(text)) + names.update(_SIDE_AGENT.findall(text)) + # ``.request`` bridges that time out fire ``f"{event.removesuffix('.request')}.expire"``. + from tui_gateway.server import _EXPIRING_REQUESTS + + names.update(f"{event.removesuffix('.request')}.expire" for event in _EXPIRING_REQUESTS) + from tui_gateway.change_watcher import _CHANGE_WATCHES + + names.update(_CHANGE_WATCHES) + from tui_gateway.agent_callbacks import _CHILD_DELTA_EVENTS + + names.update(_CHILD_DELTA_EVENTS.values()) + # ``_progress_subagent`` relays every ``subagent.*`` event verbatim EXCEPT the child's per-token + # ``subagent.text`` (mirrored into the watch window as ``message.delta`` instead); the names live in the relay. + for src in (REPO / "tools").glob("delegate_tool*.py"): + names.update(_SUBAGENT_RELAY.findall(_read(src))) + names.discard("subagent.text") + for src in (REPO / "tools").glob("*.py"): + names.update(_DESKTOP_UI_EMIT.findall(_read(src))) + names.update(_BROKER_FRAME.findall(_read(REPO / "gateway" / "browser_control_broker.py"))) + names.update(_SETUP_READY.findall(_read(REPO / "hermes_cli" / "free_tier_bootstrap.py"))) + # Dispatch-table keys that double as the emitted name (``_PROGRESS_HANDLERS`` re-emits + # ``event_type``) are already literal ``_emit("...")`` calls inside their handlers. + return names + + +@pytest.fixture(scope="module") +def contract() -> list[str]: + return json.loads(_read(CONTRACT)) + + +def test_contract_is_sorted_and_unique(contract): + assert contract == sorted(set(contract)), "gateway-events.json must be a sorted, duplicate-free list" + + +def test_every_emitted_event_is_in_the_contract(contract): + missing = emitted_event_names() - set(contract) + assert not missing, ( + f"tui_gateway emits {sorted(missing)} but apps/shared/src/gateway-events.json does not list them; " + "add the name(s) there AND to BACKEND_EVENT_NAMES / GatewayEventMap in apps/shared/src/gateway-events.ts") + + +def test_contract_has_no_orphan_names(contract): + orphans = set(contract) - emitted_event_names() + assert not orphans, ( + f"apps/shared/src/gateway-events.json lists {sorted(orphans)} but no tui_gateway emitter names them; " + "drop the entry (and its GatewayEventMap key) or wire the emitter") diff --git a/tui_gateway/AGENTS.md b/tui_gateway/AGENTS.md index d8b0ec5456..c3063b42b8 100644 --- a/tui_gateway/AGENTS.md +++ b/tui_gateway/AGENTS.md @@ -24,13 +24,16 @@ is the facade with the method/event catalog; methods live in `methods_*.py` sibl `event_publisher.py` / `event_replay.py`. Desktop reaches the same server over WebSocket via `apps/shared` (`JsonRpcGatewayClient`). New RPC = a new `methods_.py` or an entry in an existing topical sibling, registered in the table — no `if method == ...` chain (root shape rules). +New event = a new key in `apps/shared/src/gateway-events.ts::GatewayEventMap` + `BACKEND_EVENT_NAMES` +AND `apps/shared/src/gateway-events.json`; `tests/tui_gateway/test_gateway_event_contract.py` (emitter +side) and `apps/shared/src/gateway-events.test.ts` (type side) both fail when either drifts. ## Key surfaces | Surface | Ink component | Gateway method / event | |---|---|---| | Chat streaming | `app.tsx` + `messageLine.tsx` | `prompt.submit` → `message.delta` / `message.complete` | -| Tool activity | `thinking.tsx` | `tool.start` / `tool.progress` / `tool.complete` | +| Tool activity | `thinking.tsx` | `tool.start` / `tool.generating` / `tool.complete` | | Approvals | `prompts.tsx` | `approval.request` → `approval.respond` | | Clarify / sudo / secret | `prompts.tsx`, `maskedPrompt.tsx` | `clarify.respond`, `sudo.respond`, `secret.respond` | | Session picker | `sessionPicker.tsx` | `session.list` / `session.resume` | diff --git a/ui-tui/src/__tests__/activeSessionSwitcher.test.ts b/ui-tui/src/__tests__/activeSessionSwitcher.test.ts index 175ee05bfc..c8e6e5f095 100644 --- a/ui-tui/src/__tests__/activeSessionSwitcher.test.ts +++ b/ui-tui/src/__tests__/activeSessionSwitcher.test.ts @@ -1,3 +1,4 @@ +import type { SessionListItem } from '@hermes/shared/gateway-events' import { describe, expect, it } from 'vitest' import { @@ -28,7 +29,6 @@ import { } from '../components/activeSessionSwitcher.js' import { listRowStyle } from '../components/overlayPrimitives.js' import type { SessionActiveItem } from '../gatewayTypes.js' -import type { SessionListItem } from '../gatewayTypes.js' import { DEFAULT_THEME } from '../theme.js' describe('session orchestrator helpers', () => { diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index b6df3a23e9..3ee8bdcaf4 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -344,10 +344,6 @@ describe('createGatewayEventHandler', () => { payload: { context: 'home page', name: 'search', tool_id: 'tool-1' }, type: 'tool.start' } as any) - onEvent({ - payload: { name: 'search', preview: 'hero cards' }, - type: 'tool.progress' - } as any) onEvent({ payload: { summary: 'done', tool_id: 'tool-1' }, type: 'tool.complete' @@ -360,7 +356,7 @@ describe('createGatewayEventHandler', () => { expect(appended).toHaveLength(2) expect(appended[0]).toMatchObject({ kind: 'trail', role: 'system', text: '', thinking: 'mapped the page' }) expect(appended[0]?.tools).toHaveLength(1) - expect(appended[0]?.tools?.[0]).toContain('hero cards') + expect(appended[0]?.tools?.[0]).toContain('home page') expect(appended[0]?.toolTokens).toBeGreaterThan(0) expect(appended[1]).toMatchObject({ role: 'assistant', text: 'final answer' }) }) @@ -402,10 +398,6 @@ describe('createGatewayEventHandler', () => { const onEvent = createGatewayEventHandler(buildCtx(appended)) - onEvent({ - payload: { name: 'search', preview: 'hero cards' }, - type: 'tool.progress' - } as any) onEvent({ payload: { summary: 'done', tool_id: 'tool-1' }, type: 'tool.complete' @@ -1510,7 +1502,6 @@ describe('createGatewayEventHandler', () => { const trailBefore = getTurnState().turnTrail.length onEvent({ payload: { name: 'browser' }, type: 'tool.generating' } as any) expect(getTurnState().turnTrail.length).toBe(trailBefore) - onEvent({ payload: { name: 'browser', preview: 'loading' }, type: 'tool.progress' } as any) onEvent({ payload: { summary: 'done', tool_id: 't-2' }, type: 'tool.complete' } as any) onEvent({ payload: { text: 'late chunk' }, type: 'message.delta' } as any) diff --git a/ui-tui/src/__tests__/mergeUsageStable.test.ts b/ui-tui/src/__tests__/mergeUsageStable.test.ts index ded9c0dc2c..378cadcab7 100644 --- a/ui-tui/src/__tests__/mergeUsageStable.test.ts +++ b/ui-tui/src/__tests__/mergeUsageStable.test.ts @@ -1,7 +1,7 @@ +import type { Usage } from '@hermes/shared/gateway-events' import { describe, expect, it } from 'vitest' import { mergeUsageStable, usageChanged } from '../app/createGatewayEventHandler.js' -import type { Usage } from '../types.js' const baseUsage: Usage = { calls: 3, diff --git a/ui-tui/src/__tests__/modelPicker.test.ts b/ui-tui/src/__tests__/modelPicker.test.ts index c98ae52738..e831db53bc 100644 --- a/ui-tui/src/__tests__/modelPicker.test.ts +++ b/ui-tui/src/__tests__/modelPicker.test.ts @@ -1,7 +1,7 @@ +import type { ModelOptionProvider } from '@hermes/shared/gateway-events' import { describe, expect, it } from 'vitest' import { providerIndexAfterClearingFilter } from '../components/modelPicker.js' -import type { ModelOptionProvider } from '../gatewayTypes.js' const provider = (slug: string, name = slug): ModelOptionProvider => ({ name, slug }) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index cf2d23bdf0..6be1717d92 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -1,15 +1,16 @@ import { execFile } from 'child_process' import { forceRedraw, onTerminalBackground, onTerminalForeground } from '@hermes/ink' +import type { SubagentStatus, Usage } from '@hermes/shared/gateway-events' import { STARTUP_IMAGE, STARTUP_QUERY } from '../config/env.js' import { STREAM_BATCH_MS } from '../config/timing.js' import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js' import type { + AnyGatewayEvent, CommandsCatalogResponse, ConfigFullResponse, DelegationStatusResponse, - GatewayEvent, GatewaySkin, SessionMostRecentResponse } from '../gatewayTypes.js' @@ -23,10 +24,10 @@ import { isPaintableHex, setTerminalBackground, setTerminalForeground } from '.. import { formatAbandonedClarify, formatAbandonedClarifyBatch, formatToolCall, stripAnsi } from '../lib/text.js' import { bootSeededPin, invalidateBootBackground, writeBootTheme } from '../lib/themeBoot.js' import { defaultThemeForCurrentBackground, fromSkin, skinIsLight, type Theme, themeToneHex } from '../theme.js' -import type { Msg, SubagentProgress, SubagentStatus, Usage } from '../types.js' +import type { Msg, SessionInfo, SubagentProgress } from '../types.js' import { applyDelegationStatus, getDelegationState } from './delegationStore.js' -import type { GatewayEventHandlerContext } from './interfaces.js' +import type { GatewayEventHandlerContext, NoticeLevel } from './interfaces.js' import { getOverlayState, patchOverlayState } from './overlayStore.js' import { flashGoodVibes, flashPet } from './petFlashStore.js' import { turnController } from './turnController.js' @@ -36,6 +37,9 @@ import { isWakeUserDisabled } from './wakeState.js' const NO_PROVIDER_RE = /\bNo (?:LLM|inference) provider configured\b/i +const NOTICE_LEVELS: readonly NoticeLevel[] = ['error', 'info', 'success', 'warn'] +const isNoticeLevel = (value: unknown): value is NoticeLevel => NOTICE_LEVELS.includes(value as NoticeLevel) + type VoiceSubmitMode = 'direct' | 'draft' const normalizeVoiceSubmitMode = (value: unknown): VoiceSubmitMode => @@ -415,7 +419,7 @@ const normalizeSubagentStatus = (status: unknown, fallback: SubagentStatus): Sub return KNOWN_SUBAGENT_STATUSES.has(normalized) ? normalized : fallback } -export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: GatewayEvent) => void { +export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: AnyGatewayEvent) => void { syncThemeToTerminalBackground() const { rpc } = ctx.gateway @@ -762,7 +766,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: }) } - return (ev: GatewayEvent) => { + return (ev: AnyGatewayEvent) => { const sid = getUiState().sid if (ev.session_id && sid && ev.session_id !== sid && !ev.type.startsWith('gateway.')) { @@ -782,7 +786,11 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return case 'session.info': { - const info = ev.payload + const info = ev.payload as SessionInfo | undefined + + if (!info) { + return + } // A replayed snapshot can be the only terminal signal after reconnect. // Missing running on older gateways must not clear a live turn. @@ -912,8 +920,8 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: turnController.showNotice({ id: p.id, key: p.key, - kind: p.kind ?? 'sticky', - level: p.level ?? 'info', + kind: p.kind === 'ttl' ? 'ttl' : 'sticky', + level: isNoticeLevel(p.level) ? p.level : 'info', text: p.text, ttl_ms: p.ttl_ms ?? null }) @@ -934,6 +942,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: // open it via the TUI process's own opener. This event arrives while the // billing.step_up RPC is still polling (and may even outlive the RPC's // 120s timeout), so the link — not the RPC result — is the source of truth. + if (!ev.payload) { + return + } + const url = ev.payload.verification_url const code = ev.payload.user_code @@ -954,6 +966,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: } case 'gateway.stderr': { + if (!ev.payload) { + return + } + const line = String(ev.payload.line).slice(0, 120) turnController.pushActivity(line, 'info') @@ -1171,13 +1187,6 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return - case 'tool.progress': - if (ev.payload?.preview && ev.payload.name) { - turnController.recordToolProgress(ev.payload.name, ev.payload.preview) - } - - return - case 'tool.generating': if (ev.payload?.name) { turnController.pushTrail(`drafting ${ev.payload.name}…`) @@ -1194,6 +1203,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return case 'tool.start': + if (!ev.payload) { + return + } + turnController.recordTodos(ev.payload.todos) turnController.recordToolStart( ev.payload.tool_id, @@ -1208,6 +1221,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: // abandoned (backend _block timed out, empty answer). A real answer // clears the overlay in answerClarify() before this fires, so this // no-ops there. Persist the question + options so they don't vanish. + if (!ev.payload) { + return + } + if (ev.payload.name === 'clarify') { flushAbandonedClarify() } @@ -1242,6 +1259,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: } case 'clarify.request': { + if (!ev.payload) { + return + } + const batch = (ev.payload.questions ?? []) .filter(q => typeof q?.qid === 'string' && q.qid && typeof q?.question === 'string' && q.question.trim()) .map(q => ({ @@ -1273,6 +1294,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: } case 'approval.request': { + if (!ev.payload) { + return + } + const description = String(ev.payload.description ?? 'dangerous command') // Only an explicit false (tirith warning) drops the permanent-allow option. const allowPermanent = ev.payload.allow_permanent !== false @@ -1293,6 +1318,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: } case 'sudo.request': + if (!ev.payload) { + return + } + patchOverlayState({ sudo: { requestId: ev.payload.request_id } }) setStatus('sudo password needed') ringPromptBell() @@ -1300,6 +1329,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return case 'secret.request': + if (!ev.payload) { + return + } + patchOverlayState({ secret: { envVar: ev.payload.env_var, prompt: ev.payload.prompt, requestId: ev.payload.request_id } }) @@ -1307,18 +1340,27 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: ringPromptBell() return + case 'sudo.expire': { + const expired = ev.payload?.request_id - case 'sudo.expire': - patchOverlayState(prev => (prev.sudo?.requestId === ev.payload.request_id ? { ...prev, sudo: null } : prev)) + patchOverlayState(prev => (prev.sudo?.requestId === expired ? { ...prev, sudo: null } : prev)) return + } - case 'secret.expire': - patchOverlayState(prev => (prev.secret?.requestId === ev.payload.request_id ? { ...prev, secret: null } : prev)) + case 'secret.expire': { + const expired = ev.payload?.request_id + + patchOverlayState(prev => (prev.secret?.requestId === expired ? { ...prev, secret: null } : prev)) return + } case 'vault.unlock.request': + if (!ev.payload) { + return + } + patchOverlayState({ vaultUnlock: { backend: ev.payload.backend, @@ -1330,21 +1372,29 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: ringPromptBell() return + case 'vault.unlock.expire': { + const expired = ev.payload?.request_id - case 'vault.unlock.expire': - patchOverlayState(prev => - prev.vaultUnlock?.requestId === ev.payload.request_id ? { ...prev, vaultUnlock: null } : prev - ) + patchOverlayState(prev => (prev.vaultUnlock?.requestId === expired ? { ...prev, vaultUnlock: null } : prev)) return + } case 'background.complete': + if (!ev.payload) { + return + } + dropBgTask(ev.payload.task_id) sys(`[bg ${ev.payload.task_id}] ${ev.payload.text}`) return case 'btw.complete': + if (!ev.payload) { + return + } + sys(`[btw${ev.payload.question ? ` "${ev.payload.question}"` : ''}] ${ev.payload.text}`) return @@ -1366,6 +1416,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: case 'subagent.spawn_requested': // Child built but not yet running (waiting on ThreadPoolExecutor slot). // Preserve completed state if a later event races in before this one. + if (!ev.payload) { + return + } + turnController.upsertSubagent(ev.payload, c => (isTerminalStatus(c.status) ? {} : { status: 'queued' })) // First sign of delegation this turn → nudge toward /agents. @@ -1382,6 +1436,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return case 'subagent.start': + if (!ev.payload) { + return + } + turnController.upsertSubagent(ev.payload, c => (isTerminalStatus(c.status) ? {} : { status: 'running' })) // `subagent.start` is the first delegation event the TUI reliably @@ -1392,6 +1450,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return case 'subagent.thinking': { + if (!ev.payload) { + return + } + const text = String(ev.payload.text ?? '').trim() if (!text) { @@ -1413,6 +1475,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: } case 'subagent.tool': { + if (!ev.payload) { + return + } + const line = formatToolCall( ev.payload.tool_name ?? 'delegate_task', ev.payload.tool_preview ?? ev.payload.text ?? '' @@ -1431,6 +1497,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: } case 'subagent.progress': { + if (!ev.payload) { + return + } + const text = String(ev.payload.text ?? '').trim() if (!text) { @@ -1449,18 +1519,25 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return } - case 'subagent.complete': + case 'subagent.complete': { + const done = ev.payload + + if (!done) { + return + } + turnController.upsertSubagent( - ev.payload, + done, c => ({ - durationSeconds: ev.payload.duration_seconds ?? c.durationSeconds, - status: normalizeSubagentStatus(ev.payload.status, 'completed'), - summary: ev.payload.summary || ev.payload.text || c.summary + durationSeconds: done.duration_seconds ?? c.durationSeconds, + status: normalizeSubagentStatus(done.status, 'completed'), + summary: done.summary || done.text || c.summary }), { createIfMissing: false } ) return + } case 'message.delta': turnController.recordMessageDelta(ev.payload ?? {}) @@ -1493,6 +1570,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: setStatus('ready') + if (ev.payload?.warning) { + turnController.pushActivity(ev.payload.warning, 'warn') + } + if (ev.payload?.usage) { patchUiState(state => ({ ...state, usage: mergeUsageStable(state.usage, ev.payload!.usage) })) } diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 5d9c050a7f..e469186f00 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -1,4 +1,5 @@ import type { MouseTrackingMode, ScrollBoxHandle } from '@hermes/ink' +import type { Usage } from '@hermes/shared/gateway-events' import type { MutableRefObject, ReactNode, RefObject, SetStateAction } from 'react' import type { PasteEvent } from '../components/textInput.js' @@ -29,7 +30,6 @@ import type { SessionInfo, SlashCatalog, SudoReq, - Usage, VaultUnlockReq } from '../types.js' diff --git a/ui-tui/src/app/spawnHistoryStore.ts b/ui-tui/src/app/spawnHistoryStore.ts index ec36148403..e522a1d390 100644 --- a/ui-tui/src/app/spawnHistoryStore.ts +++ b/ui-tui/src/app/spawnHistoryStore.ts @@ -1,7 +1,8 @@ +import type { SubagentStatus } from '@hermes/shared/gateway-events' import { atom } from 'nanostores' import type { SpawnTreeLoadResponse } from '../gatewayTypes.js' -import type { SubagentProgress, SubagentStatus } from '../types.js' +import type { SubagentProgress } from '../types.js' export interface SpawnSnapshot { finishedAt: number diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index f6a1c53ef3..b31f7ac3e2 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -1,3 +1,5 @@ +import type { MessageCompletePayload, SubagentEventPayload } from '@hermes/shared/gateway-events' + import { REASONING_PULSE_MS, STREAM_BATCH_MS, @@ -5,7 +7,7 @@ import { STREAM_SCROLL_BATCH_MS, STREAM_TYPING_BATCH_MS } from '../config/timing.js' -import type { SessionInterruptResponse, SubagentEventPayload } from '../gatewayTypes.js' +import type { SessionInterruptResponse } from '../gatewayTypes.js' import { appendToolShelfMessage, isToolShelfMessage } from '../lib/liveProgress.js' import { hasReasoningTag, splitReasoning } from '../lib/reasoning.js' import { @@ -136,7 +138,6 @@ class TurnController { private reasoningTimer: Timer = null private streamTimer: Timer = null private streamDelay = STREAM_IDLE_BATCH_MS - private toolProgressTimer: Timer = null // ── Credits notice machinery (Strategy B) ─────────────────────────── // @@ -568,12 +569,7 @@ class TurnController { this.flushPendingNotice() } - recordMessageComplete(payload: { - rendered?: string - reasoning?: string - response_previewed?: boolean - text?: string - }) { + recordMessageComplete(payload: MessageCompletePayload) { this.closeReasoningSegment() // Ink renders markdown via ; the gateway's Rich-rendered ANSI @@ -887,29 +883,6 @@ class TurnController { }) } - recordToolProgress(toolName: string, preview: string) { - if (this.interrupted) { - return - } - - const index = this.activeTools.findIndex(tool => tool.name === toolName) - - if (index < 0) { - return - } - - this.activeTools = this.activeTools.map((tool, i) => (i === index ? { ...tool, context: preview } : tool)) - - if (this.toolProgressTimer) { - return - } - - this.toolProgressTimer = setTimeout(() => { - this.toolProgressTimer = null - patchTurnState({ tools: [...this.activeTools] }) - }, STREAM_BATCH_MS) - } - recordToolStart(toolId: string, name: string, context: string, verboseArgs?: string) { if (this.interrupted) { return diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index b588af103b..bb5d22beaa 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -21,9 +21,9 @@ import { sessionScopedModelArg } from '../domain/slash.js' import { type GatewayClient } from '../gatewayClient.js' import type { SubagentListResponse } from '../gatewayTypes.js' import type { + AnyGatewayEvent, ClarifyRespondResponse, ConfigSetResponse, - GatewayEvent, SessionActiveListResponse, SessionCloseResponse, TerminalResizeResponse @@ -231,7 +231,7 @@ export function useMainApp(gw: GatewayClient) { const slashRef = useRef<(cmd: string) => boolean>(() => false) const colsRef = useRef(cols) const scrollRef = useRef(null) - const onEventRef = useRef<(ev: GatewayEvent) => void>(() => {}) + const onEventRef = useRef<(ev: AnyGatewayEvent) => void>(() => {}) const sysRef = useRef<(text: string) => void>(() => {}) const submitRef = useRef<(value: string) => void>(() => {}) const submitLiteralRef = useRef<(value: string) => void>(() => {}) @@ -909,7 +909,7 @@ export function useMainApp(gw: GatewayClient) { onEventRef.current = onEvent useEffect(() => { - const handler = (ev: GatewayEvent) => onEventRef.current(ev) + const handler = (ev: AnyGatewayEvent) => onEventRef.current(ev) const exitHandler = () => { turnController.reset() diff --git a/ui-tui/src/app/useSessionLifecycle.ts b/ui-tui/src/app/useSessionLifecycle.ts index cc6d29d162..5457fb0226 100644 --- a/ui-tui/src/app/useSessionLifecycle.ts +++ b/ui-tui/src/app/useSessionLifecycle.ts @@ -2,6 +2,7 @@ import { writeFileSync } from 'node:fs' import type { ScrollBoxHandle } from '@hermes/ink' import { evictInkCaches } from '@hermes/ink' +import type { SessionInflightTurn, SessionResumeResponse, Usage } from '@hermes/shared/gateway-events' import { type RefObject, useCallback, useEffect, useMemo, useRef } from 'react' import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js' @@ -12,13 +13,11 @@ import type { SessionActivateResponse, SessionCloseResponse, SessionCreateResponse, - SessionInflightTurn, - SessionResumeResponse, SessionTitleResponse, SetupStatusResponse } from '../gatewayTypes.js' import { asRpcResult } from '../lib/rpc.js' -import type { Msg, PanelSection, SessionInfo, Usage } from '../types.js' +import type { Msg, PanelSection, SessionInfo } from '../types.js' import type { ComposerActions, GatewayRpc, StateSetter } from './interfaces.js' import { patchOverlayState } from './overlayStore.js' @@ -341,9 +340,9 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { const previousSid = getUiState().sid - gw.request('session.resume', { cols: colsRef.current, session_id: id }) + gw.request>('session.resume', { cols: colsRef.current, session_id: id }) .then(raw => { - const r = asRpcResult(raw) + const r = asRpcResult>(raw) if (!r) { sys('error: invalid response: session.resume') diff --git a/ui-tui/src/components/activeSessionSwitcher.tsx b/ui-tui/src/components/activeSessionSwitcher.tsx index beb4683f62..ebd88078d5 100644 --- a/ui-tui/src/components/activeSessionSwitcher.tsx +++ b/ui-tui/src/components/activeSessionSwitcher.tsx @@ -1,4 +1,5 @@ import { Box, Text, useInput, useStdout } from '@hermes/ink' +import type { SessionListItem, SessionListResponse } from '@hermes/shared/gateway-events' import { useCallback, useEffect, useRef, useState } from 'react' import { sessionScopedModelArg } from '../domain/slash.js' @@ -7,9 +8,7 @@ import type { SessionActiveItem, SessionActiveListResponse, SessionCloseResponse, - SessionDeleteResponse, - SessionListItem, - SessionListResponse + SessionDeleteResponse } from '../gatewayTypes.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import type { Theme } from '../theme.js' diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index fd7ba38dce..4deef2c9bb 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -1,4 +1,5 @@ import { Box, type ScrollBoxHandle, stringWidth, Text } from '@hermes/ink' +import type { Usage } from '@hermes/shared/gateway-events' import { useStore } from '@nanostores/react' import { type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from 'react' import unicodeSpinners from 'unicode-animations' @@ -16,7 +17,7 @@ import { buildSubagentTree, treeTotals, widthByDepth } from '../lib/subagentTree import { fmtK } from '../lib/text.js' import { useScrollbarSnapshot, useViewportSnapshot } from '../lib/viewportStore.js' import type { Theme } from '../theme.js' -import type { Msg, Usage } from '../types.js' +import type { Msg } from '../types.js' import { scrollbarColors } from './overlayPrimitives.js' diff --git a/ui-tui/src/components/modelPicker.tsx b/ui-tui/src/components/modelPicker.tsx index df95434c4a..fda9d5378b 100644 --- a/ui-tui/src/components/modelPicker.tsx +++ b/ui-tui/src/components/modelPicker.tsx @@ -1,10 +1,10 @@ import { Box, Text, useInput, useStdout } from '@hermes/ink' +import type { ModelOptionProvider, ModelOptionsResponse } from '@hermes/shared/gateway-events' import { useEffect, useMemo, useState } from 'react' import { providerDisplayNames } from '../domain/providers.js' import { TUI_SESSION_MODEL_FLAG } from '../domain/slash.js' import type { GatewayClient } from '../gatewayClient.js' -import type { ModelOptionProvider, ModelOptionsResponse } from '../gatewayTypes.js' import { fuzzyRank } from '../lib/fuzzy.js' import { modelSearchText } from '../lib/model-search-text.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' diff --git a/ui-tui/src/domain/usage.ts b/ui-tui/src/domain/usage.ts index 508195f253..3f3b67efc2 100644 --- a/ui-tui/src/domain/usage.ts +++ b/ui-tui/src/domain/usage.ts @@ -1,3 +1,3 @@ -import type { Usage } from '../types.js' +import type { Usage } from '@hermes/shared/gateway-events' export const ZERO: Usage = { calls: 0, input: 0, output: 0, total: 0 } diff --git a/ui-tui/src/gatewayClient.ts b/ui-tui/src/gatewayClient.ts index 8b30d0e7f9..fa4e5bb832 100644 --- a/ui-tui/src/gatewayClient.ts +++ b/ui-tui/src/gatewayClient.ts @@ -6,7 +6,7 @@ import { createInterface } from 'node:readline' import { WebSocket as UndiciWebSocket } from 'undici' -import type { GatewayEvent } from './gatewayTypes.js' +import type { AnyGatewayEvent } from './gatewayTypes.js' import { CircularBuffer } from './lib/circularBuffer.js' import { recordParentLifecycle } from './lib/parentLog.js' @@ -80,9 +80,9 @@ const resolvePython = (root: string) => { return hit || (process.platform === 'win32' ? 'python' : 'python3') } -const asGatewayEvent = (value: unknown): GatewayEvent | null => +const asGatewayEvent = (value: unknown): AnyGatewayEvent | null => value && typeof value === 'object' && !Array.isArray(value) && typeof (value as { type?: unknown }).type === 'string' - ? (value as GatewayEvent) + ? (value as AnyGatewayEvent) : null // Hoisted decoder: attach mode can drive high-frequency binary frames @@ -153,7 +153,7 @@ export class GatewayClient extends EventEmitter { private reqId = 0 private logs = new CircularBuffer(MAX_GATEWAY_LOG_LINES) private pending = new Map() - private bufferedEvents = new CircularBuffer(MAX_BUFFERED_EVENTS) + private bufferedEvents = new CircularBuffer(MAX_BUFFERED_EVENTS) private pendingExit: number | null | undefined private ready = false private readyTimer: ReturnType | null = null @@ -178,7 +178,7 @@ export class GatewayClient extends EventEmitter { this.setMaxListeners(0) } - private publish(ev: GatewayEvent) { + private publish(ev: AnyGatewayEvent) { if (ev.type === 'gateway.ready') { this.ready = true @@ -442,7 +442,7 @@ export class GatewayClient extends EventEmitter { } } - publishLocalEvent(ev: GatewayEvent) { + publishLocalEvent(ev: AnyGatewayEvent) { const frame = JSON.stringify({ jsonrpc: '2.0', method: 'event', params: ev }) this.mirrorEventToSidecar(frame) diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 84a6f7a515..b19e915bd5 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -1,12 +1,23 @@ -import type { BillingBlock, UsageModelData } from '@hermes/shared/billing' +import type { UsageModelData } from '@hermes/shared/billing' +import type { + GatewayEvent, + GatewayEventName, + GatewayTranscriptMessage, + SessionInflightTurn, + Usage +} from '@hermes/shared/gateway-events' import type { HermesSkin } from '@hermes/shared/skin' -import type { SessionInfo, SlashCategory, SubagentStatus, Usage } from './types.js' +import type { SessionInfo, SlashCategory } from './types.js' /** The cross-surface skin contract (canonical shape in `@hermes/shared`). * Includes the paired light_colors/dark_colors overlays from #20379. */ export type GatewaySkin = HermesSkin +/** Distributive form of the shared `GatewayEvent` so `switch (ev.type)` + * narrows `ev.payload` per case (the generic-defaulted interface does not). */ +export type AnyGatewayEvent = { [K in GatewayEventName]: GatewayEvent }[GatewayEventName] + export interface GatewayCompletionItem { display: string /** Completion class, set by the gateway. `skill` covers skill commands and @@ -16,15 +27,6 @@ export interface GatewayCompletionItem { text: string } -export interface GatewayTranscriptMessage { - context?: string - display_kind?: string - display_metadata?: Record - name?: string - role: 'assistant' | 'system' | 'tool' | 'user' - text?: string -} - // ── Commands / completion ──────────────────────────────────────────── export interface CommandsCatalogResponse { @@ -186,18 +188,6 @@ export interface SessionCreateResponse { session_id: string } -export interface SessionResumeResponse { - inflight?: null | SessionInflightTurn - info?: SessionInfo - message_count?: number - messages: GatewayTranscriptMessage[] - resumed?: string - running?: boolean - session_id: string - started_at?: number - status?: LiveSessionStatus -} - export type LiveSessionStatus = 'idle' | 'starting' | 'waiting' | 'working' export interface SessionActiveItem { @@ -217,12 +207,6 @@ export interface SessionActiveListResponse { sessions?: SessionActiveItem[] } -export interface SessionInflightTurn { - assistant?: string - streaming?: boolean - user?: string -} - export interface SessionActivateResponse { inflight?: null | SessionInflightTurn info?: SessionInfo @@ -235,19 +219,6 @@ export interface SessionActivateResponse { status?: LiveSessionStatus } -export interface SessionListItem { - id: string - message_count: number - preview: string - source?: string - started_at: number - title: string -} - -export interface SessionListResponse { - sessions?: SessionListItem[] -} - export interface SessionDeleteResponse { deleted: string } @@ -470,26 +441,6 @@ export interface ToolsConfigureResponse { unknown?: string[] } -// ── Model picker ───────────────────────────────────────────────────── - -export interface ModelOptionProvider { - auth_type?: string - authenticated?: boolean - is_current?: boolean - key_env?: string - models?: string[] - name: string - slug: string - total_models?: number - warning?: string -} - -export interface ModelOptionsResponse { - model?: string - provider?: string - providers?: ModelOptionProvider[] -} - // ── MCP ────────────────────────────────────────────────────────────── export interface ReloadMcpResponse { @@ -541,38 +492,6 @@ export interface RollbackRestoreResponse { success?: boolean } -// ── Subagent events ────────────────────────────────────────────────── - -export interface SubagentEventPayload { - api_calls?: number - cost_usd?: number - /** Batch (delegation) id this subagent belongs to — distinguishes - * interleaved `[n/N]` progress from concurrent or nested fan-outs. */ - delegation_id?: string - depth?: number - duration_seconds?: number - files_read?: string[] - files_written?: string[] - goal: string - input_tokens?: number - iteration?: number - model?: string - output_tail?: { is_error?: boolean; preview?: string; tool?: string }[] - output_tokens?: number - parent_id?: null | string - reasoning_tokens?: number - status?: SubagentStatus - subagent_id?: string - summary?: string - task_count?: number - task_index: number - text?: string - tool_count?: number - tool_name?: string - tool_preview?: string - toolsets?: string[] -} - // ── Delegation control RPCs ────────────────────────────────────────── export interface DelegationStatusResponse { @@ -649,161 +568,3 @@ export interface SpawnTreeLoadResponse { started_at?: null | number subagents?: unknown[] } - -export type GatewayEvent = - | { payload?: { heartbeat?: boolean; skin?: GatewaySkin }; session_id?: string; type: 'gateway.ready' } - | { payload?: GatewaySkin; session_id?: string; type: 'skin.changed' } - | { payload: SessionInfo; session_id?: string; type: 'session.info' } - | { payload?: { text?: string }; session_id?: string; type: 'thinking.delta' } - | { payload?: { kind?: string }; session_id?: string; type: 'reaction' } - | { payload?: undefined; session_id?: string; type: 'message.start' } - | { payload?: { kind?: string; text?: string }; session_id?: string; type: 'status.update' } - | { - payload?: { - id?: string - key?: string - kind?: 'sticky' | 'ttl' - level?: 'error' | 'info' | 'success' | 'warn' - text?: string - ttl_ms?: null | number - } - session_id?: string - type: 'notification.show' - } - | { payload?: { key?: string }; session_id?: string; type: 'notification.clear' } - | { - payload: { user_code?: string; verification_url: string } - session_id?: string - type: 'billing.step_up.verification' - } - | { payload?: { state?: 'idle' | 'listening' | 'transcribing' }; session_id?: string; type: 'voice.status' } - | { - payload?: { no_speech_limit?: boolean; stop_phrase?: boolean; text?: string; typed?: boolean } - session_id?: string - type: 'voice.transcript' - } - | { - payload?: { phrase?: string; profile?: null | string; start_new_session?: boolean } - session_id?: string - type: 'wake.detected' - } - | { payload?: { reason?: string }; session_id?: string; type: 'dashboard.new_session_requested' } - | { payload: { line: string }; session_id?: string; type: 'gateway.stderr' } - | { payload?: { attempt?: number; delay_ms?: number }; session_id?: string; type: 'gateway.reconnecting' } - | { - payload?: { level?: 'info' | 'warn' | 'error'; message?: string } - session_id?: string - type: 'browser.progress' - } - | { - payload?: { cwd?: string; python?: string; stderr_tail?: string } - session_id?: string - type: 'gateway.start_timeout' - } - | { payload?: { preview?: string }; session_id?: string; type: 'gateway.protocol_error' } - | { - payload?: { text?: string; verbose?: boolean } - session_id?: string - type: 'reasoning.delta' | 'reasoning.available' - } - | { - payload: { count?: number; index?: number; label?: string; text?: string } - session_id?: string - type: 'moa.reference' - } - | { payload?: { aggregator?: string }; session_id?: string; type: 'moa.aggregating' } - | { - payload?: { label?: string; refs_done?: number; refs_total?: number } - session_id?: string - type: 'moa.progress' - } - | { - payload?: { aggregator?: string; phase?: string; refs_done?: number; refs_total?: number } - session_id?: string - type: 'moa.phase' - } - | { payload: { name?: string; preview?: string }; session_id?: string; type: 'tool.progress' } - | { payload: { name?: string }; session_id?: string; type: 'tool.generating' } - | { - payload: { args_text?: string; context?: string; name?: string; tool_id: string; todos?: unknown[] } - session_id?: string - type: 'tool.start' - } - | { - payload: { - duration_s?: number - error?: string - inline_diff?: string - name?: string - result_text?: string - summary?: string - tool_id: string - todos?: unknown[] - } - session_id?: string - type: 'tool.complete' - } - | { - payload: { - answers?: Record - choices?: string[] | null - question?: string - questions?: { choices?: string[] | null; multi_select?: boolean; qid: string; question: string }[] - request_id: string - } - session_id?: string - type: 'clarify.request' - } - | { - payload: { - allow_permanent?: boolean - choices?: string[] - command: string - description: string - smart_denied?: boolean - } - session_id?: string - type: 'approval.request' - } - | { payload: { request_id: string }; session_id?: string; type: 'sudo.request' } - | { payload: { env_var: string; prompt: string; request_id: string }; session_id?: string; type: 'secret.request' } - | { - payload: { request_id: string } - session_id?: string - type: 'secret.expire' | 'sudo.expire' | 'vault.unlock.expire' - } - | { - payload: { backend: string; display_name: string; request_id: string } - session_id?: string - type: 'vault.unlock.request' - } - | { payload: { task_id: string; text: string }; session_id?: string; type: 'background.complete' } - | { payload: { question?: string; task_id: string; text: string }; session_id?: string; type: 'btw.complete' } - | { payload?: { text?: string }; session_id?: string; type: 'review.summary' } - | { payload: SubagentEventPayload; session_id?: string; type: 'subagent.spawn_requested' } - | { payload: SubagentEventPayload; session_id?: string; type: 'subagent.start' } - | { payload: SubagentEventPayload; session_id?: string; type: 'subagent.thinking' } - | { payload: SubagentEventPayload; session_id?: string; type: 'subagent.tool' } - | { payload: SubagentEventPayload; session_id?: string; type: 'subagent.progress' } - | { payload: SubagentEventPayload; session_id?: string; type: 'subagent.complete' } - | { payload: { rendered?: string; text?: string }; session_id?: string; type: 'message.delta' } - | { - payload: { already_streamed?: boolean; text: string } - session_id?: string - type: 'message.interim' - } - | { - payload?: { - billing?: BillingBlock - failure_reason?: string - reasoning?: string - rendered?: string - response_previewed?: boolean - text?: string - usage?: Usage - } - session_id?: string - type: 'message.complete' - } - | { payload?: { usage?: Usage }; session_id?: string; type: 'session.usage' } - | { payload?: { message?: string }; session_id?: string; type: 'error' } diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index e75fffe0ec..4e2dfeb286 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -1,3 +1,5 @@ +import type { SubagentStatus, Usage } from '@hermes/shared/gateway-events' + export interface ActiveTool { context?: string id: string @@ -20,8 +22,6 @@ export interface ActivityItem { tone: 'error' | 'info' | 'warn' } -export type SubagentStatus = 'completed' | 'error' | 'failed' | 'interrupted' | 'queued' | 'running' | 'timeout' - export interface SubagentProgress { apiCalls?: number costUsd?: number @@ -209,30 +209,6 @@ export interface SessionInfo { version?: string } -export interface Usage { - active_subagents?: number - /** Rolling mean API latency over the last 10 calls (seconds). */ - avg_latency_s?: number - /** Rolling output tokens/sec over the last 10 calls. */ - avg_tps?: number - /** Session prompt-cache hit ratio (cache_read / prompt tokens, %). */ - cache_hit_pct?: number - calls: number - compressions?: number - context_max?: number - context_percent?: number - context_estimated?: boolean - context_source?: string - context_used?: number - cost_status?: string - cost_usd?: number - dev_credits_spent_micros?: number - input: number - output: number - reasoning?: number - total: number -} - export interface SudoReq { requestId: string } diff --git a/web/src/components/ChatSidebar.tsx b/web/src/components/ChatSidebar.tsx index e766992112..ab8f7b338c 100644 --- a/web/src/components/ChatSidebar.tsx +++ b/web/src/components/ChatSidebar.tsx @@ -194,13 +194,16 @@ export function ChatSidebar({ }); const offState = gw.onState(setState); - const offSessionInfo = gw.on("session.info", (ev) => { - if (ev.payload) { - setInfo((prev) => ({ ...prev, ...ev.payload })); + const offSessionInfo = gw.on("session.info", (ev) => { + // session.info is surface-specific on the wire; narrow to the fields this sidebar reads. + const payload = ev.payload as SessionInfo | undefined; + + if (payload) { + setInfo((prev) => ({ ...prev, ...payload })); } }); - const offError = gw.on<{ message?: string }>("error", (ev) => { + const offError = gw.on("error", (ev) => { const message = ev.payload?.message; if (message) { diff --git a/web/src/components/ModelPickerDialog.tsx b/web/src/components/ModelPickerDialog.tsx index 20256cbd6a..e859c7b1f0 100644 --- a/web/src/components/ModelPickerDialog.tsx +++ b/web/src/components/ModelPickerDialog.tsx @@ -6,6 +6,7 @@ import { Input } from "@nous-research/ui/ui/components/input"; import { Label } from "@nous-research/ui/ui/components/label"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import type { GatewayClient } from "@/lib/gatewayClient"; +import type { ModelOptionProvider, ModelOptionsResponse } from "@hermes/shared"; import { Check, RefreshCw, Search, X } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; @@ -34,21 +35,6 @@ import { modelSearchText } from "@/lib/model-search-text"; * requiring an open chat PTY. */ -interface ModelOptionProvider { - name: string; - slug: string; - models?: string[]; - total_models?: number; - is_current?: boolean; - warning?: string; -} - -interface ModelOptionsResponse { - model?: string; - provider?: string; - providers?: ModelOptionProvider[]; -} - interface ExpensiveModelConfirmResponse { confirm_message?: string; confirm_required?: boolean; diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 773b3c45b7..b0ce13ae7d 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,4 +1,8 @@ -import { buildHermesWebSocketUrl } from "@hermes/shared"; +import { + buildHermesWebSocketUrl, + type ModelOptionProvider, + type ModelOptionsResponse, +} from "@hermes/shared"; // The dashboard can be served either at the root of its host (e.g. // https://kanban.tilos.com/) or under a URL prefix when reverse-proxied @@ -2437,23 +2441,7 @@ export interface ModelInfoResponse { // ── Model options / assignment types ────────────────────────────────── -export interface ModelOptionProvider { - name: string; - slug: string; - models?: string[]; - total_models?: number; - is_current?: boolean; - is_user_defined?: boolean; - source?: string; - warning?: string; - authenticated?: boolean; -} - -export interface ModelOptionsResponse { - model?: string; - provider?: string; - providers?: ModelOptionProvider[]; -} +export type { ModelOptionProvider, ModelOptionsResponse }; export interface AuxiliaryTaskAssignment { task: string; diff --git a/website/docs/developer-guide/programmatic-integration.md b/website/docs/developer-guide/programmatic-integration.md index 050ae6ff4a..449c009d79 100644 --- a/website/docs/developer-guide/programmatic-integration.md +++ b/website/docs/developer-guide/programmatic-integration.md @@ -76,7 +76,7 @@ On a successful truncating submit against a durable session, the `prompt.submit` ### Events streamed back -`message.delta`, `message.complete`, `tool.start`, `tool.progress`, `tool.complete`, `approval.request`, `clarify.request`, `sudo.request`, `sudo.expire`, `secret.request`, `secret.expire`, `gateway.ready`, plus session lifecycle and error events. Expiry events carry the original `{ request_id }`; external hosts should clear only the matching pending prompt. +`message.delta`, `message.complete`, `tool.start`, `tool.generating`, `tool.complete`, `approval.request`, `clarify.request`, `sudo.request`, `sudo.expire`, `secret.request`, `secret.expire`, `gateway.ready`, plus session lifecycle and error events. Expiry events carry the original `{ request_id }`; external hosts should clear only the matching pending prompt. ### Pi-style RPC mapping