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:
@@ -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,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
|
||||
}
|
||||
|
||||
+3
-1
@@ -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 }))
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'>
|
||||
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
|
||||
@@ -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' }>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
@@ -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,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 })
|
||||
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,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,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,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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user