refactor(ts): one GatewayEventMap in apps/shared typed from tui_gateway emitters; drop never-emitted tool.progress

Three TypeScript clients each declared their own copy of the tui_gateway wire
types and had drifted apart: apps/shared had a partial GatewayEventName union
with a `(string & {})` escape hatch, ui-tui/gatewayTypes.ts a 150-line
discriminated union, and apps/desktop an `RpcEvent<T>` that was field-for-field
the shared GatewayEvent with `type: string`. None matched the emitter:
message.complete lacked warning/status/error/recoverable/error_surface,
tool.start/tool.complete lacked args/result, SessionResumeResponse lacked
session_key/messages_omitted/hydrating/auto_continue/todo_state, three
different ModelOptionProvider shapes disagreed on fields, and all three unions
handled a `tool.progress` event that no Python emitter has ever produced.

Now:

* `apps/shared/src/gateway-events.ts` is the single home: payload interfaces
  typed from the Python emitters (file::symbol cited per interface),
  `BackendGatewayEventMap` (89 backend names) + `ClientLocalGatewayEventMap`
  (5 TUI-synthetic transport events, clearly marked, excluded from the
  contract) merged into `GatewayEventMap`; `GatewayEvent<K>` is discriminated
  on `type` with `seq` typed. RPC shapes shared by 2+ surfaces live beside it
  (ModelOptionProvider = union of every field hermes_cli/inventory.py sets,
  incl. pricing_pending/free_tier_pending; SessionResumeResponse<Info>;
  SessionListItem with resolved_id; Usage).
* `JsonRpcGatewayClient.on<K>` is keyed by event name; the gateway.ready
  heartbeat/replay_epoch and per-frame `seq` reads are typed instead of cast.
* ui-tui and apps/desktop import the shared names; their local duplicates are
  deleted (no re-export shims — importers are repointed; the desktop plugin
  SDK barrel keeps its public `RpcEvent` name as an alias of GatewayEvent).
  web/src repoints ModelOptionProvider/ModelOptionsResponse.
* `tool.progress` handling is removed from the TUI handler/turnController,
  desktop event sets/tools handler, shared union, tests, and two docs
  (`grep '"tool.progress"' tui_gateway/` = 0 hits; the `display.tool_progress`
  config mode is unrelated and untouched).
* `message.complete.warning` (history-commit note from
  prompt_turn.py::_complete_turn_payload) is typed and surfaced on both
  surfaces through their existing notice paths (TUI pushActivity 'warn',
  desktop notify kind 'warning').

Contract: `apps/shared/src/gateway-events.json` is the sorted list of
backend-emitted names. `tests/tui_gateway/test_gateway_event_contract.py`
collects names from the Python emitter side (emit-helper literals, the
`.request → .expire` table, change-watcher table, child delta mirror,
subagent relay, desktop_ui tool emitters, gateway.ready/setup.ready/
browser-controller frames) and asserts emitted == JSON in both directions.
`apps/shared/src/gateway-events.test.ts` asserts BACKEND_EVENT_NAMES (which
the map type is `satisfies`-checked against) == JSON. Sabotage-verified: a
fake JSON name fails both tests; a fake TS name fails tsc + vitest; a fake
Python `_emit("...")` fails pytest.
This commit is contained in:
teknium1
2026-09-12 20:26:24 -07:00
committed by Teknium
parent 9939e3375e
commit 36773e0d78
74 changed files with 1305 additions and 663 deletions
+3 -9
View File
@@ -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'
+1 -1
View File
@@ -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'
@@ -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<HermesConnection, 'c
interface GatewayBootOptions {
beforeConnectionSwitch: () => void
handleGatewayEvent: (event: RpcEvent) => void
handleGatewayEvent: (event: GatewayEvent) => void
onConnectionReady: (
connection: Awaited<ReturnType<NonNullable<typeof window.hermesDesktop>['getConnection']>> | null
) => void
@@ -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)
@@ -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 }))
}
@@ -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 }))
}
@@ -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
@@ -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
}
@@ -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',
@@ -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)
@@ -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
}
@@ -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
@@ -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
@@ -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 }))
}
@@ -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 }))
}
@@ -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 }))
}
@@ -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 }))
}
@@ -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(() => {
@@ -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(() => {
@@ -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<boolean>) | null = null
let states: Map<string, ClientSessionState>
@@ -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) => {
@@ -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<Parameters<typeof u
export interface MessageStreamHarness {
/** Feed a gateway event into the mounted hook. */
handleEvent: (event: RpcEvent) => 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<string, ClientSessionState>(), ...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
@@ -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<string, unknown> = {}) =>
const event = (type: GatewayEventName, timestamp: number, payload: Record<string, unknown> = {}) =>
act(() => stream.handleEvent({ payload: { ...payload, timestamp }, session_id: SID, type }))
describe('live transcript timeline events', () => {
@@ -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 }))
}
@@ -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
@@ -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
@@ -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<StepUpVerificationPayload>('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
@@ -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'
@@ -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'
@@ -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'
@@ -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'
+2 -1
View File
@@ -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'
@@ -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
@@ -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'
+3 -3
View File
@@ -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<string, Set<GatewayEventListener>>()
@@ -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
}
-4
View File
@@ -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,
@@ -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
+2 -1
View File
@@ -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'
-1
View File
@@ -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',
+3 -2
View File
@@ -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<ModelOptionProvider, 'aliases' | 'name' | 'slug'>
+3 -1
View File
@@ -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
@@ -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,
+1 -1
View File
@@ -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'
+2 -1
View File
@@ -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<OAuthStartResponse, { flow: 'pkce' }>
type DeviceStart = Extract<OAuthStartResponse, { flow: 'device_code' }>
+5 -4
View File
@@ -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<PetDraft & { token: string; count: number }>('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
-96
View File
@@ -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:<key>`, normalized display name, …). A session's
* `model.options` reports the canonical `custom:<key>` 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<string, ModelPricing>
/** 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<string, ModelCapabilities>
}
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<T = unknown> {
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
+1
View File
@@ -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",
+93
View File
@@ -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"
]
+22
View File
@@ -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)
})
})
+766
View File
@@ -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<string, unknown>`;
* 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<string, unknown>
/** 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<string, unknown>
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<string, string>
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<string, unknown>
'browser.controller.cancel': Record<string, unknown>
'browser.controller.command': Record<string, unknown>
'browser.progress': BrowserProgressPayload
'btw.complete': SideAgentCompletePayload
'clarify.expire': RequestExpirePayload
'clarify.request': ClarifyRequestPayload
'cron.changed': Record<string, unknown>
error: ErrorPayload
'gateway.ready': GatewayReadyPayload
'layout.apply': Record<string, unknown>
'mcp.setup.expire': RequestExpirePayload
'mcp.setup.request': McpSetupRequestPayload
'message.complete': MessageCompletePayload
'message.delta': StreamDeltaPayload
'message.interim': MessageInterimPayload
'message.reaction': Record<string, unknown>
'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<string, unknown>
'pane.reveal': Record<string, unknown>
'pet.changed': Record<string, unknown>
'pet.generate.progress': Record<string, unknown>
'pet.hatch.progress': Record<string, unknown>
'platforms.changed': Record<string, unknown>
'preview.act.expire': RequestExpirePayload
'preview.act.request': Record<string, unknown>
'preview.close': Record<string, unknown>
'preview.open': Record<string, unknown>
'preview.read.expire': RequestExpirePayload
'preview.read.request': Record<string, unknown>
'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<string, unknown>
'session.reclaimed': SessionReclaimedPayload
'session.resume_progress': SessionResumeProgressPayload
'session.title': SessionTitlePayload
'session.usage': SessionUsagePayload
'sessions.changed': Record<string, unknown>
'setup.ready': Record<string, unknown>
'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<string, unknown>
'thinking.delta': StreamDeltaPayload
'tip.show': Record<string, unknown>
'todo.updated': TodoStatePayload
'tool.complete': ToolCompletePayload
'tool.generating': ToolGeneratingPayload
'tool.output_risk': ToolOutputRiskPayload
'tool.start': ToolStartPayload
'tour.expire': RequestExpirePayload
'tour.request': Record<string, unknown>
'vault.code.expire': RequestExpirePayload
'vault.code.request': VaultCodeRequestPayload
'vault.save_login.expire': RequestExpirePayload
'vault.save_login.request': Record<string, unknown>
'vault.unlock.expire': RequestExpirePayload
'vault.unlock.request': VaultUnlockRequestPayload
'voice.interrupted': Record<string, unknown>
'voice.status': VoiceStatusPayload
'voice.transcript': VoiceTranscriptPayload
'wake.detected': WakeDetectedPayload
'window.read.expire': RequestExpirePayload
'window.read.request': Record<string, unknown>
}
/**
* 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<K extends GatewayEventName = GatewayEventName> {
/** 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:<key>`, normalized display name, …). A session's
* `model.options` reports the canonical `custom:<key>` 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<string, ModelCapabilities>
/** 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<string, ModelPricing>
/** 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<Info = Record<string, unknown>, 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
}
+42 -2
View File
@@ -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,
+13 -47
View File
@@ -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<P = unknown> {
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<P = unknown>(type: GatewayEventName, handler: (event: GatewayEvent<P>) => void): () => void {
on<K extends GatewayEventName>(type: K, handler: (event: GatewayEvent<K>) => 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<GatewayEventName>) => 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()
@@ -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": "<name>"`` 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")
+4 -1
View File
@@ -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_<topic>.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` |
@@ -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', () => {
@@ -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)
@@ -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,
+1 -1
View File
@@ -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 })
+109 -28
View File
@@ -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) }))
}
+1 -1
View File
@@ -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'
+2 -1
View File
@@ -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
+4 -31
View File
@@ -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 <Md>; 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
+3 -3
View File
@@ -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 | ScrollBoxHandle>(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()
+4 -5
View File
@@ -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<SessionResumeResponse>('session.resume', { cols: colsRef.current, session_id: id })
gw.request<SessionResumeResponse<SessionInfo>>('session.resume', { cols: colsRef.current, session_id: id })
.then(raw => {
const r = asRpcResult<SessionResumeResponse>(raw)
const r = asRpcResult<SessionResumeResponse<SessionInfo>>(raw)
if (!r) {
sys('error: invalid response: session.resume')
@@ -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'
+2 -1
View File
@@ -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'
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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 }
+6 -6
View File
@@ -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<string>(MAX_GATEWAY_LOG_LINES)
private pending = new Map<string, Pending>()
private bufferedEvents = new CircularBuffer<GatewayEvent>(MAX_BUFFERED_EVENTS)
private bufferedEvents = new CircularBuffer<AnyGatewayEvent>(MAX_BUFFERED_EVENTS)
private pendingExit: number | null | undefined
private ready = false
private readyTimer: ReturnType<typeof setTimeout> | 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)
+13 -252
View File
@@ -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<K>` so `switch (ev.type)`
* narrows `ev.payload` per case (the generic-defaulted interface does not). */
export type AnyGatewayEvent = { [K in GatewayEventName]: GatewayEvent<K> }[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<string, unknown>
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<string, string>
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' }
+2 -26
View File
@@ -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
}
+7 -4
View File
@@ -194,13 +194,16 @@ export function ChatSidebar({
});
const offState = gw.onState(setState);
const offSessionInfo = gw.on<SessionInfo>("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) {
+1 -15
View File
@@ -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;
+6 -18
View File
@@ -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;
@@ -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