Files
nanobot/tui/src/protocol.ts
T
chengyongruandGitHub 3a62b0b744 fix(tui): surface chat connection failures (#5543)
* fix(tui): surface chat connection failures

* fix(tui): keep connection copy implementation-agnostic

* test(tui): avoid millisecond timing boundary

* fix(tui): use gateway health for connection status

* fix(tui): use product-level readiness copy
2026-08-27 11:37:30 +08:00

1411 lines
45 KiB
TypeScript

export type ConnectionStatus =
| "starting"
| "connecting"
| "connected"
| "reconnecting"
| "unavailable"
| "closed"
| "error"
export interface ConnectionStatusInfo {
endpoint: string
attempt: number
elapsedMs: number
retryInMs?: number
health?: GatewayHealthStatus
}
export type GatewayHealthStatus = "ready" | "degraded" | "unreachable"
export interface ToolProgressEvent {
version?: number
phase?: "start" | "end" | "error" | string
call_id?: string
name?: string
arguments?: unknown
result?: unknown
error?: unknown
files?: unknown[]
embeds?: unknown[]
}
export interface FileEditEvent {
version?: number
call_id?: string
tool?: string
path?: string
absolute_path?: string
phase?: "start" | "end" | "error" | string
added?: number
deleted?: number
approximate?: boolean
status?: "editing" | "done" | "error" | string
operation?: "edit" | "delete" | string
binary?: boolean
error?: string
diff?: FileDiff
}
interface FileDiff {
format: "unified" | string
context?: number
truncated?: boolean
text?: string
}
interface MediaAttachment {
kind: "image" | "video" | "file"
url: string
name?: string
}
export interface WorkspaceScopePayload {
project_path: string
project_name?: string
access_mode: "restricted" | "full"
restrict_to_workspace?: boolean
}
export interface RuntimeControls {
modelPresets: Array<{ name: string; model: string }>
canUseFullAccess: boolean
}
export type RecoveryStatus = "resuming" | "awaiting_user" | "recovered" | "failed"
export interface RecoveryState {
status: RecoveryStatus
recovery_id: string
reason?: string
attempts?: number
can_continue?: boolean
}
export type InboundEvent =
| { event: "ready"; chat_id: string; client_id: string }
| {
event: "attached"
chat_id: string
model_preset?: string | null
usage?: TokenUsage
recovery_state?: RecoveryState
}
| {
event: "message_accepted"
chat_id: string
turn_id: string
starts_turn?: boolean
active_turn_id?: string
started_at?: number
}
| {
event: "user_message"
chat_id: string
text: string
turn_id?: string
active_turn_id?: string
starts_turn: boolean
started_at?: number
media_urls?: MediaAttachment[]
}
| {
event: "message"
chat_id: string
text: string
kind?: "tool_hint" | "progress" | "reasoning"
tool_events?: ToolProgressEvent[]
turn_id?: string
}
| { event: "file_edit"; chat_id: string; edits: FileEditEvent[]; turn_id?: string }
| { event: "delta"; chat_id: string; text: string; stream_id?: string; turn_id?: string }
| {
event: "stream_end"
chat_id: string
text?: string
stream_id?: string
resuming?: boolean
merge_next?: boolean
turn_id?: string
}
| { event: "reasoning_delta"; chat_id: string; text: string; turn_id?: string }
| { event: "reasoning_end"; chat_id: string; turn_id?: string }
| {
event: "turn_end"
chat_id: string
latency_ms?: number
turn_id?: string
usage?: TokenUsage
context_window_tokens?: number
goal_state?: Record<string, unknown>
}
| {
event: "goal_status"
chat_id: string
status: "running" | "idle"
started_at?: number
turn_id?: string
}
| { event: "goal_state"; chat_id: string; goal_state: Record<string, unknown> }
| ({ event: "recovery_state"; chat_id: string } & RecoveryState)
| {
event: "session_updated"
chat_id: string
scope?: string
workspace_scope?: WorkspaceScopePayload
}
| { event: "runtime_model_updated"; model_name: string; model_preset?: string | null }
| {
event: "turn_model_updated"
chat_id: string
model_name: string
model_preset?: string | null
context_window_tokens?: number
}
| { event: "error"; chat_id?: string; detail?: string; reason?: string; turn_id?: string }
type OutboundEvent =
| { type: "new_chat"; workspace_scope?: WorkspaceScopePayload }
| { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string }
| { type: "attach"; chat_id: string }
| { type: "set_workspace_scope"; chat_id: string; workspace_scope: WorkspaceScopePayload }
| {
type: "webui_request"
request_id: string
action: string
payload: Record<string, unknown>
}
| {
type: "message"
chat_id: string
content: string
turn_id: string
webui: true
workspace_scope?: WorkspaceScopePayload
cli_apps?: Array<{ name: string }>
mcp_presets?: Array<{ name: string }>
session_mentions?: SessionMention[]
}
export interface ClientOptions {
url?: string
resolveConnection?: () => Promise<GatewayConnection>
checkHealth?: () => Promise<GatewayHealthStatus>
onConnection?: (connection: GatewayConnection) => void
targetEndpoint?: string
startupFailureDelayMs?: number
startupRetryMaxDelayMs?: number
chatId?: string
initialWorkspaceScope?: WorkspaceScopePayload
reconnectDelayMs?: number
onEvent: (event: InboundEvent) => void
onStatus: (status: ConnectionStatus, detail?: string, info?: ConnectionStatusInfo) => void
}
export interface GatewayApiConnection {
apiUrl: string
apiToken: string
}
export interface GatewayConnection extends GatewayApiConnection {
wsUrl: string
}
export type ApiReauthenticator = (
rejectedApiToken: string,
) => Promise<GatewayApiConnection>
export class GatewayConnectionError extends Error {
constructor(message: string, readonly retryable: boolean) {
super(message)
this.name = "GatewayConnectionError"
}
}
export interface HistoryMessage {
role: "user" | "assistant" | "activity"
content: string
turnId?: string
toolEvents?: ToolProgressEvent[]
fileEdits?: FileEditEvent[]
forkIndex?: number
}
export interface HistorySnapshot {
messages: HistoryMessage[]
hasMoreBefore: boolean
beforeCursor: string | null
userMessageOffset: number
}
export interface TokenUsage {
prompt_tokens?: number
completion_tokens?: number
cached_tokens?: number
cache_write_tokens?: number
total_tokens?: number
context_tokens?: number
request_count?: number
provider_tokens?: number
estimated_tokens?: number
cost_usd?: number
generation_ms?: number
measured_completion_tokens?: number
ttft_ms?: number
timed_requests?: number
}
export interface SessionContextSnapshot {
totalMessages: number
archivedMessages: number
replayMessages: number
estimatedReplayTokens: number
estimatedSummaryTokens: number
estimatedSessionTokens: number
archivedSummary: string | null
archivedSummaryAt: string | null
lastUsage: TokenUsage | null
}
interface SessionMention {
name: string
session_key: string
title?: string
}
export interface MentionCandidate {
kind: "session" | "cli" | "mcp"
name: string
targetName?: string
displayName: string
description: string
session?: SessionMention
}
export interface SkillCandidate {
name: string
description: string
source: string
}
export interface MessageOptions {
cliApps?: Array<{ name: string }>
mcpPresets?: Array<{ name: string }>
sessionMentions?: SessionMention[]
userShell?: boolean
}
export interface SlashCommand {
command: string
title: string
description: string
argHint: string
lifecycle: SlashCommandLifecycle
acceptsArgs: boolean
}
export type SlashCommandLifecycle =
| "side_channel"
| "finalize_active_turn"
| "stop_active_turn"
| "agent_turn"
| "agent_turn_with_args"
export interface SessionSummary {
chatId: string
title: string
preview: string
createdAt: string | null
updatedAt: string | null
runStartedAt: number | null
modelPreset: string | null
recoveryState?: RecoveryState | null
workspaceScope?: WorkspaceScopePayload | null
pinned: boolean
archived: boolean
}
const SKILL_REFERENCE_NAME = /^[A-Za-z0-9_-]+$/u
const SLASH_COMMAND_LIFECYCLES = new Set([
"side_channel",
"finalize_active_turn",
"stop_active_turn",
"agent_turn",
"agent_turn_with_args",
])
const CHAT_EVENTS = new Set([
"attached",
"message_accepted",
"user_message",
"message",
"file_edit",
"delta",
"stream_end",
"reasoning_delta",
"reasoning_end",
"turn_end",
"goal_status",
"goal_state",
"recovery_state",
"session_updated",
"turn_model_updated",
"error",
])
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value)
}
function optional(value: unknown, type: "boolean" | "number" | "string"): boolean {
return value === undefined || typeof value === type
}
function isToolEvent(value: unknown): value is ToolProgressEvent {
if (!isRecord(value)) return false
return optional(value.version, "number")
&& optional(value.phase, "string")
&& optional(value.call_id, "string")
&& optional(value.name, "string")
&& (value.files === undefined || Array.isArray(value.files))
&& (value.embeds === undefined || Array.isArray(value.embeds))
}
function isFileEdit(value: unknown): value is FileEditEvent {
if (!isRecord(value)) return false
return optional(value.version, "number")
&& optional(value.call_id, "string")
&& optional(value.tool, "string")
&& optional(value.path, "string")
&& optional(value.absolute_path, "string")
&& optional(value.phase, "string")
&& optional(value.status, "string")
&& optional(value.added, "number")
&& optional(value.deleted, "number")
&& optional(value.approximate, "boolean")
&& optional(value.operation, "string")
&& optional(value.binary, "boolean")
&& optional(value.error, "string")
&& (value.diff === undefined || isFileDiff(value.diff))
}
function isFileDiff(value: unknown): value is FileDiff {
if (!isRecord(value) || typeof value.format !== "string") return false
return optional(value.context, "number")
&& optional(value.truncated, "boolean")
&& optional(value.text, "string")
}
function isTokenUsage(value: unknown): value is TokenUsage {
if (!isRecord(value)) return false
return [
"prompt_tokens",
"completion_tokens",
"cached_tokens",
"cache_write_tokens",
"total_tokens",
"context_tokens",
"request_count",
"provider_tokens",
"estimated_tokens",
"cost_usd",
"generation_ms",
"measured_completion_tokens",
"ttft_ms",
"timed_requests",
].every((key) => optional(value[key], "number"))
}
function isMediaAttachment(value: unknown): value is MediaAttachment {
return isRecord(value)
&& (value.kind === "image" || value.kind === "video" || value.kind === "file")
&& typeof value.url === "string"
&& optional(value.name, "string")
}
function isWorkspaceScope(value: unknown): value is WorkspaceScopePayload {
return isRecord(value)
&& typeof value.project_path === "string"
&& (value.access_mode === "restricted" || value.access_mode === "full")
&& optional(value.project_name, "string")
&& optional(value.restrict_to_workspace, "boolean")
}
function isRecoveryState(value: unknown): value is RecoveryState {
return isRecord(value)
&& ["resuming", "awaiting_user", "recovered", "failed"].includes(String(value.status))
&& typeof value.recovery_id === "string"
&& optional(value.reason, "string")
&& optional(value.attempts, "number")
&& optional(value.can_continue, "boolean")
}
interface WebUIResponseEvent {
event: "webui_response"
request_id: string
ok: boolean
result?: unknown
error?: { status: number; message: string }
}
function decodeWebUIResponse(value: unknown): WebUIResponseEvent | null | undefined {
if (!isRecord(value) || value.event !== "webui_response") return undefined
if (typeof value.request_id !== "string" || typeof value.ok !== "boolean") return null
if (value.ok) return value as unknown as WebUIResponseEvent
return isRecord(value.error)
&& typeof value.error.status === "number"
&& typeof value.error.message === "string"
? value as unknown as WebUIResponseEvent
: null
}
function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
if (!isRecord(value)) return null
const record = value
const name = record.event
if (typeof name !== "string") return null
if (name === "ready") {
return typeof record.chat_id === "string" && typeof record.client_id === "string"
? value as InboundEvent
: null
}
if (name === "runtime_model_updated") {
return typeof record.model_name === "string"
&& (record.model_preset === undefined
|| record.model_preset === null
|| typeof record.model_preset === "string")
? value as InboundEvent
: null
}
if (name === "error" && (record.chat_id === undefined || typeof record.chat_id === "string")) {
return optional(record.detail, "string") && optional(record.reason, "string")
? value as InboundEvent
: null
}
if (!CHAT_EVENTS.has(name)) return undefined // Forward-compatible additive event.
if (typeof record.chat_id !== "string") return null
if (
name === "attached"
&& ((record.model_preset !== undefined
&& record.model_preset !== null
&& typeof record.model_preset !== "string")
|| (record.usage !== undefined && !isTokenUsage(record.usage))
|| (record.recovery_state !== undefined && !isRecoveryState(record.recovery_state)))
) return null
if (
["user_message", "message", "delta", "reasoning_delta"].includes(name)
&& typeof record.text !== "string"
) {
return null
}
if (
["message_accepted", "user_message"].includes(name)
&& (
(name === "user_message" && typeof record.starts_turn !== "boolean")
|| !optional(record.starts_turn, "boolean")
|| !optional(record.active_turn_id, "string")
|| !optional(record.started_at, "number")
)
) return null
if (
name === "user_message"
&& record.media_urls !== undefined
&& (!Array.isArray(record.media_urls) || !record.media_urls.every(isMediaAttachment))
) return null
if (
name === "message"
&& record.tool_events !== undefined
&& (!Array.isArray(record.tool_events) || !record.tool_events.every(isToolEvent))
) return null
if (name === "file_edit" && (!Array.isArray(record.edits) || !record.edits.every(isFileEdit))) {
return null
}
if (
name === "stream_end"
&& (!optional(record.text, "string")
|| !optional(record.resuming, "boolean")
|| !optional(record.merge_next, "boolean"))
) return null
if (
name === "turn_end"
&& (!optional(record.latency_ms, "number")
|| !optional(record.context_window_tokens, "number")
|| (record.usage !== undefined && !isTokenUsage(record.usage))
|| (record.goal_state !== undefined && !isRecord(record.goal_state)))
) return null
if (name === "goal_status" && record.status !== "running" && record.status !== "idle") return null
if (name === "goal_state" && !isRecord(record.goal_state)) return null
if (name === "recovery_state" && !isRecoveryState(record)) return null
if (
name === "session_updated"
&& (!optional(record.scope, "string")
|| (record.workspace_scope !== undefined && !isWorkspaceScope(record.workspace_scope)))
) return null
if (
name === "turn_model_updated"
&& (typeof record.model_name !== "string"
|| (record.model_preset !== undefined
&& record.model_preset !== null
&& typeof record.model_preset !== "string")
|| !optional(record.context_window_tokens, "number"))
) return null
return value as InboundEvent
}
async function fetchApi(
apiUrl: string,
apiToken: string,
path: string,
reauthenticate?: ApiReauthenticator,
): Promise<Response> {
const request = (connection: GatewayApiConnection) => fetch(`${connection.apiUrl}${path}`, {
headers: { Authorization: `Bearer ${connection.apiToken}` },
})
const response = await request({ apiUrl, apiToken })
if (response.status !== 401 || !reauthenticate) return response
return request(await reauthenticate(apiToken))
}
export async function fetchHistory(
apiUrl: string,
apiToken: string,
chatId: string,
beforeCursor?: string | null,
reauthenticate?: ApiReauthenticator,
): Promise<HistorySnapshot> {
if (!apiUrl || !apiToken) {
return { messages: [], hasMoreBefore: false, beforeCursor: null, userMessageOffset: 0 }
}
const key = encodeURIComponent(`websocket:${chatId}`)
const params = new URLSearchParams({ limit: "120", direction: "latest" })
if (beforeCursor) params.set("before", beforeCursor)
const response = await fetchApi(
apiUrl,
apiToken,
`/api/sessions/${key}/webui-thread?${params}`,
reauthenticate,
)
if (response.status === 404) {
return { messages: [], hasMoreBefore: false, beforeCursor: null, userMessageOffset: 0 }
}
if (!response.ok) throw new Error(`history request failed: HTTP ${response.status}`)
const payload = (await response.json()) as {
messages?: Array<Record<string, unknown>>
page?: { has_more_before?: boolean; before_cursor?: string; user_message_offset?: number }
}
let userIndex = typeof payload.page?.user_message_offset === "number"
? Math.max(0, payload.page.user_message_offset)
: 0
const messages: HistoryMessage[] = []
for (const message of payload.messages || []) {
const role = message.role
const content = message.content
if (role === "tool" && message.kind === "trace") {
const traces = Array.isArray(message.traces)
? message.traces.filter((value): value is string => typeof value === "string")
: []
const toolEvents = Array.isArray(message.toolEvents)
? message.toolEvents.filter(isToolEvent)
: undefined
const fileEdits = Array.isArray(message.fileEdits)
? message.fileEdits.filter(isFileEdit)
: undefined
const activity = traces.join("\n") || (typeof content === "string" ? content : "")
messages.push({
role: "activity",
content: activity,
...(toolEvents?.length ? { toolEvents } : {}),
...(fileEdits?.length ? { fileEdits } : {}),
})
continue
}
if (
(role !== "user" && role !== "assistant")
|| message.kind === "reasoning"
|| typeof content !== "string"
|| !content.trim()
) {
continue
}
if (role === "user") {
userIndex += 1
messages.push({
role: "user",
content,
...(typeof message.turnId === "string" ? { turnId: message.turnId } : {}),
})
} else {
messages.push({ role: "assistant", content, forkIndex: userIndex })
}
}
return {
messages,
hasMoreBefore: payload.page?.has_more_before === true,
beforeCursor: typeof payload.page?.before_cursor === "string"
? payload.page.before_cursor
: null,
userMessageOffset: typeof payload.page?.user_message_offset === "number"
? Math.max(0, payload.page.user_message_offset)
: 0,
}
}
export async function fetchSessionContext(
apiUrl: string,
apiToken: string,
chatId: string,
reauthenticate?: ApiReauthenticator,
): Promise<SessionContextSnapshot | null> {
if (!apiUrl || !apiToken) return null
const key = encodeURIComponent(`websocket:${chatId}`)
const response = await fetchApi(
apiUrl,
apiToken,
`/api/sessions/${key}/context`,
reauthenticate,
)
if (response.status === 404) return null
if (!response.ok) throw new Error(`context request failed: HTTP ${response.status}`)
const value = await response.json() as Record<string, unknown>
const number = (key: string) => typeof value[key] === "number" ? value[key] as number : 0
return {
totalMessages: number("total_messages"),
archivedMessages: number("archived_messages"),
replayMessages: number("replay_messages"),
estimatedReplayTokens: number("estimated_replay_tokens"),
estimatedSummaryTokens: number("estimated_summary_tokens"),
estimatedSessionTokens: number("estimated_session_tokens"),
archivedSummary: typeof value.archived_summary === "string" ? value.archived_summary : null,
archivedSummaryAt: typeof value.archived_summary_at === "string"
? value.archived_summary_at
: null,
lastUsage: isTokenUsage(value.last_usage) ? value.last_usage : null,
}
}
export async function fetchSlashCommands(
apiUrl: string,
apiToken: string,
reauthenticate?: ApiReauthenticator,
): Promise<SlashCommand[]> {
if (!apiUrl || !apiToken) return []
const response = await fetchApi(apiUrl, apiToken, "/api/commands", reauthenticate)
if (!response.ok) throw new Error(`command request failed: HTTP ${response.status}`)
const payload = await response.json() as { commands?: unknown[] }
return (payload.commands || []).flatMap((value) => {
if (
!isRecord(value)
|| typeof value.command !== "string"
|| typeof value.lifecycle !== "string"
|| !SLASH_COMMAND_LIFECYCLES.has(value.lifecycle)
) return []
return [{
command: value.command,
title: typeof value.title === "string" ? value.title : value.command,
description: typeof value.description === "string" ? value.description : "",
argHint: typeof value.arg_hint === "string" ? value.arg_hint : "",
lifecycle: value.lifecycle as SlashCommandLifecycle,
acceptsArgs: value.accepts_args === true,
}]
})
}
export async function fetchAvailableSkills(
apiUrl: string,
apiToken: string,
reauthenticate?: ApiReauthenticator,
): Promise<SkillCandidate[]> {
if (!apiUrl || !apiToken) return []
const response = await fetchApi(apiUrl, apiToken, "/api/webui/skills", reauthenticate)
if (!response.ok) throw new Error(`skill request failed: HTTP ${response.status}`)
const payload = await response.json() as { skills?: unknown[] }
return (payload.skills || []).flatMap((value) => {
if (
!isRecord(value)
|| typeof value.name !== "string"
|| !SKILL_REFERENCE_NAME.test(value.name)
|| value.enabled !== true
|| value.available !== true
) return []
return [{
name: value.name,
description: typeof value.description === "string" ? value.description : value.name,
source: typeof value.source === "string" ? value.source : "unknown",
}]
})
}
export async function fetchRuntimeControls(
apiUrl: string,
apiToken: string,
reauthenticate?: ApiReauthenticator,
): Promise<RuntimeControls> {
if (!apiUrl || !apiToken) return { modelPresets: [], canUseFullAccess: false }
const [settingsResponse, workspacesResponse] = await Promise.all([
fetchApi(apiUrl, apiToken, "/api/settings", reauthenticate),
fetchApi(apiUrl, apiToken, "/api/workspaces", reauthenticate).catch(() => null),
])
if (!settingsResponse.ok) {
throw new Error(`settings request failed: HTTP ${settingsResponse.status}`)
}
const settings = await settingsResponse.json() as { model_presets?: unknown[] }
const workspaces = workspacesResponse?.ok
? await workspacesResponse.json() as { controls?: unknown }
: {}
const modelPresets = (settings.model_presets || []).flatMap((value) => {
if (!isRecord(value) || typeof value.name !== "string" || typeof value.model !== "string") {
return []
}
const name = value.name.trim()
return name ? [{ name, model: value.model.trim() }] : []
})
const controls = isRecord(workspaces.controls) ? workspaces.controls : {}
return {
modelPresets,
canUseFullAccess: controls.can_use_full_access === true,
}
}
export async function fetchSessions(
apiUrl: string,
apiToken: string,
reauthenticate?: ApiReauthenticator,
): Promise<SessionSummary[]> {
if (!apiUrl || !apiToken) return []
const [response, sidebarResponse] = await Promise.all([
fetchApi(apiUrl, apiToken, "/api/sessions", reauthenticate),
fetchApi(apiUrl, apiToken, "/api/webui/sidebar-state", reauthenticate).catch(() => null),
])
if (!response.ok) throw new Error(`session request failed: HTTP ${response.status}`)
const payload = await response.json() as { sessions?: unknown[] }
let sidebar: Record<string, unknown> = {}
if (sidebarResponse?.ok) {
try {
const value: unknown = await sidebarResponse.json()
if (isRecord(value)) sidebar = value
} catch {
// Session navigation remains available against older or damaged sidebar state.
}
}
const pinned = new Set(Array.isArray(sidebar.pinned_keys) ? sidebar.pinned_keys : [])
const archived = new Set(Array.isArray(sidebar.archived_keys) ? sidebar.archived_keys : [])
const titles = isRecord(sidebar.title_overrides) ? sidebar.title_overrides : {}
return (payload.sessions || []).flatMap((value) => {
if (!isRecord(value) || typeof value.key !== "string" || !value.key.startsWith("websocket:")) {
return []
}
const chatId = value.key.slice("websocket:".length)
if (!chatId) return []
const titleOverride = titles[value.key]
return [{
chatId,
title: typeof titleOverride === "string"
? titleOverride
: typeof value.title === "string" ? value.title : "",
preview: typeof value.preview === "string" ? value.preview : "",
createdAt: typeof value.created_at === "string" ? value.created_at : null,
updatedAt: typeof value.updated_at === "string" ? value.updated_at : null,
runStartedAt: typeof value.run_started_at === "number" ? value.run_started_at : null,
modelPreset: typeof value.model_preset === "string" && value.model_preset.trim()
? value.model_preset.trim()
: null,
...(isRecoveryState(value.recovery_state)
? { recoveryState: value.recovery_state }
: {}),
...(isWorkspaceScope(value.workspace_scope) ? { workspaceScope: value.workspace_scope } : {}),
pinned: pinned.has(value.key),
archived: archived.has(value.key),
}]
})
}
function sessionMentionName(session: SessionSummary): string {
const label = (session.title || session.preview || "session")
.normalize("NFKC")
.replace(/\s+/gu, "-")
.replace(/[^\p{L}\p{N}_-]+/gu, "")
.replace(/-+/gu, "-")
.replace(/^-|-$/gu, "")
return Array.from(label || "session").slice(0, 40).join("")
}
/** Installed capabilities and saved chats share one mention namespace. */
export async function fetchMentionCandidates(
apiUrl: string,
apiToken: string,
reauthenticate?: ApiReauthenticator,
): Promise<MentionCandidate[]> {
if (!apiUrl || !apiToken) return []
const [sessions, appsResponse, mcpResponse] = await Promise.all([
fetchSessions(apiUrl, apiToken, reauthenticate),
fetchApi(
apiUrl,
apiToken,
"/api/settings/cli-apps?installed_only=1",
reauthenticate,
).catch(() => null),
fetchApi(apiUrl, apiToken, "/api/settings/mcp-presets", reauthenticate).catch(() => null),
])
const used = new Set<string>()
const uniqueName = (raw: string) => {
const base = raw || "session"
let name = base
let suffix = 2
while (used.has(name.toLocaleLowerCase())) name = `${base}-${suffix++}`
used.add(name.toLocaleLowerCase())
return name
}
const candidates: MentionCandidate[] = []
if (appsResponse?.ok) {
const payload = await appsResponse.json() as { apps?: unknown[] }
for (const value of payload.apps || []) {
if (!isRecord(value) || value.installed !== true || typeof value.name !== "string") continue
const name = uniqueName(value.name)
candidates.push({
kind: "cli",
name,
...(name === value.name ? {} : { targetName: value.name }),
displayName: typeof value.display_name === "string" ? value.display_name : name,
description: typeof value.description === "string" ? value.description : "CLI app",
})
}
}
if (mcpResponse?.ok) {
const payload = await mcpResponse.json() as { presets?: unknown[] }
for (const value of payload.presets || []) {
if (
!isRecord(value)
|| value.installed !== true
|| value.configured !== true
|| typeof value.name !== "string"
) continue
const name = uniqueName(value.name)
candidates.push({
kind: "mcp",
name,
...(name === value.name ? {} : { targetName: value.name }),
displayName: typeof value.display_name === "string" ? value.display_name : name,
description: typeof value.description === "string" ? value.description : "MCP server",
})
}
}
for (const session of sessions) {
const name = uniqueName(sessionMentionName(session))
candidates.push({
kind: "session",
name,
displayName: sessionLabelForMention(session),
description: session.preview || "Saved session",
session: {
name,
session_key: `websocket:${session.chatId}`,
title: session.title || undefined,
},
})
}
return candidates
}
function sessionLabelForMention(session: SessionSummary): string {
return (session.title || session.preview || "Untitled chat").replace(/\s+/gu, " ").trim()
}
/** Resolve fresh short-lived credentials once the local gateway is reachable. */
export async function fetchGatewayConnection(
bootstrapUrl: string,
bootstrapSecret: string,
apiUrl: string,
clientId: string,
): Promise<GatewayConnection> {
const response = await fetch(bootstrapUrl, {
headers: bootstrapSecret ? { "X-Nanobot-Auth": bootstrapSecret } : {},
})
if (!response.ok) {
const retryable = response.status === 408 || response.status === 429 || response.status >= 500
throw new GatewayConnectionError(
`gateway bootstrap failed: HTTP ${response.status}`,
retryable,
)
}
let payload: unknown
try {
payload = await response.json()
} catch {
throw new GatewayConnectionError("gateway bootstrap response is invalid", false)
}
if (!isRecord(payload)) {
throw new GatewayConnectionError("gateway bootstrap response is invalid", false)
}
if (typeof payload.ws_url !== "string" || !payload.ws_url.trim()) {
throw new GatewayConnectionError("gateway bootstrap response is missing ws_url", false)
}
let wsUrl: URL
try {
wsUrl = new URL(payload.ws_url)
} catch {
throw new GatewayConnectionError("gateway bootstrap response has an invalid ws_url", false)
}
if (wsUrl.protocol !== "ws:" && wsUrl.protocol !== "wss:") {
throw new GatewayConnectionError("gateway bootstrap response has an invalid ws_url", false)
}
if (typeof payload.token === "string" && payload.token) {
wsUrl.searchParams.append("token", payload.token)
}
wsUrl.searchParams.append("client_id", clientId)
return {
wsUrl: wsUrl.toString(),
apiUrl,
apiToken: typeof payload.api_token === "string" ? payload.api_token : "",
}
}
/** Read gateway readiness without sending bootstrap or API credentials. */
export async function fetchGatewayHealth(
healthUrl: string,
timeoutMs = 400,
): Promise<GatewayHealthStatus> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
const response = await fetch(healthUrl, { signal: controller.signal })
if (response.status !== 200 && response.status !== 503) return "unreachable"
const payload: unknown = await response.json()
if (!isRecord(payload)) return "unreachable"
if (
response.status === 503
&& payload.status === "degraded"
&& payload.ready === false
&& payload.process === "alive"
) return "degraded"
if (response.status === 200 && payload.status === "ok" && payload.ready !== false) {
return "ready"
}
return "unreachable"
} catch {
return "unreachable"
} finally {
clearTimeout(timer)
}
}
/** Return only the authority users can act on, never credentials or an authenticated path. */
export function connectionEndpoint(value: string | undefined): string {
if (!value) return "local gateway"
try {
return new URL(value).host || "local gateway"
} catch {
return "local gateway"
}
}
/** Reduce arbitrary fetch/WebSocket errors to a small set of credential-safe reasons. */
export function sanitizeConnectionFailure(error: unknown): string {
const signals: string[] = []
const seen = new Set<unknown>()
const collect = (value: unknown): void => {
if (value === null || value === undefined || seen.has(value)) return
if (typeof value === "object") seen.add(value)
if (typeof value === "string") {
signals.push(value)
return
}
if (value instanceof Error) {
signals.push(value.name, value.message)
collect(value.cause)
if (value instanceof AggregateError) {
for (const nested of value.errors) collect(nested)
}
return
}
if (!isRecord(value)) return
if (typeof value.code === "string") signals.push(value.code)
collect(value.cause)
if (Array.isArray(value.errors)) {
for (const nested of value.errors) collect(nested)
}
}
collect(error)
const signal = signals.join(" ")
if (/ECONNREFUSED|connection refused/iu.test(signal)) return "connection refused"
if (/ETIMEDOUT|timed? out|timeout/iu.test(signal)) return "connection timed out"
if (/ENOTFOUND|EAI_AGAIN|name not resolved|host not found/iu.test(signal)) {
return "host not found"
}
if (/certificate|TLS|SSL/iu.test(signal)) return "secure connection failed"
const bootstrapStatus = signal.match(/gateway bootstrap failed:\s*HTTP\s*(\d{3})/iu)
if (bootstrapStatus?.[1]) return `gateway bootstrap failed: HTTP ${bootstrapStatus[1]}`
if (/bootstrap response is missing ws_url/iu.test(signal)) {
return "gateway bootstrap response is missing ws_url"
}
if (/bootstrap response (?:has an invalid ws_url|is invalid)/iu.test(signal)) {
return "gateway bootstrap response is invalid"
}
if (/gateway is still starting/iu.test(signal)) return "gateway is still starting"
if (/fetch failed|failed to fetch|network error/iu.test(signal)) return "network request failed"
return "connection failed"
}
export class NanobotClient {
private socket: WebSocket | null = null
private chatId = ""
private workspaceScope?: WorkspaceScopePayload
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private reconnectAttempt = 0
private closedByClient = false
private opening = false
private connectedOnce = false
private connectionAttempt = 0
private retryStartedAt = 0
private nextRetryAt = 0
private lastFailure = ""
private healthStatus: GatewayHealthStatus | undefined
private failureEscalationTimer: ReturnType<typeof setTimeout> | null = null
private readonly endpoint: string
private readonly pendingMutations = new Map<string, {
resolve: (value: unknown) => void
reject: (error: Error) => void
timer: ReturnType<typeof setTimeout>
}>()
constructor(private readonly options: ClientOptions) {
this.endpoint = options.targetEndpoint || connectionEndpoint(options.url)
}
get activeChatId(): string {
return this.chatId
}
connect(): void {
this.closedByClient = false
this.connectionAttempt = 0
this.reconnectAttempt = 0
this.retryStartedAt = Date.now()
this.nextRetryAt = 0
this.lastFailure = ""
this.healthStatus = undefined
void this.open()
}
private async open(): Promise<void> {
if (this.socket || this.opening || this.closedByClient) return
this.opening = true
this.nextRetryAt = 0
this.connectionAttempt += 1
this.reportConnectionProgress()
let url = this.options.url
try {
if (this.options.resolveConnection) {
const connection = await this.options.resolveConnection()
if (this.closedByClient) return
this.options.onConnection?.(connection)
url = connection.wsUrl
}
} catch (error) {
if (!this.closedByClient) {
this.lastFailure = sanitizeConnectionFailure(error)
if (error instanceof GatewayConnectionError && !error.retryable) {
this.clearFailureEscalation()
this.options.onStatus("error", this.lastFailure, this.connectionInfo())
return
}
await this.checkHealthAndScheduleReconnect()
}
return
} finally {
this.opening = false
}
if (!url) {
this.options.onStatus("error", "gateway URL is not configured", this.connectionInfo())
return
}
let socket: WebSocket
try {
socket = new WebSocket(url)
} catch (error) {
this.lastFailure = sanitizeConnectionFailure(error)
await this.checkHealthAndScheduleReconnect()
return
}
let opened = false
this.socket = socket
socket.addEventListener("open", () => {
if (this.socket !== socket) return
opened = true
this.connectedOnce = true
this.connectionAttempt = 0
this.reconnectAttempt = 0
this.retryStartedAt = 0
this.nextRetryAt = 0
this.lastFailure = ""
this.healthStatus = "ready"
this.clearFailureEscalation()
this.options.onStatus("connected", undefined, this.connectionInfo())
})
socket.addEventListener("message", (message) => {
if (this.socket === socket) this.handleMessage(String(message.data))
})
socket.addEventListener("error", () => {
if (this.socket !== socket) return
this.lastFailure = "connection failed"
this.reportRetryState()
})
socket.addEventListener("close", () => {
if (this.socket !== socket) return
this.socket = null
this.rejectPendingMutations("gateway connection closed")
if (this.closedByClient) {
this.options.onStatus("closed")
return
}
if (opened) {
this.connectionAttempt = 0
this.reconnectAttempt = 0
this.retryStartedAt = Date.now()
}
if (!this.lastFailure) this.lastFailure = "connection closed"
this.reportRetryState()
void this.checkHealthAndScheduleReconnect()
})
}
close(): void {
this.closedByClient = true
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
this.clearFailureEscalation()
const socket = this.socket
this.socket = null
socket?.close()
this.rejectPendingMutations("gateway connection closed")
}
send(content: string, options: MessageOptions = {}): string {
if (!this.chatId) throw new Error("chat is not ready")
const turnId = crypto.randomUUID()
this.write({
type: "message",
chat_id: this.chatId,
content,
turn_id: turnId,
webui: true,
...(this.workspaceScope ? { workspace_scope: this.workspaceScope } : {}),
...(options.userShell ? { user_shell: true } : {}),
...(options.cliApps?.length ? { cli_apps: options.cliApps } : {}),
...(options.mcpPresets?.length ? { mcp_presets: options.mcpPresets } : {}),
...(options.sessionMentions?.length
? { session_mentions: options.sessionMentions }
: {}),
})
return turnId
}
attach(chatId: string): void {
if (!chatId) throw new Error("chat id is required")
this.workspaceScope = undefined
this.write({ type: "attach", chat_id: chatId })
}
newChat(scope?: WorkspaceScopePayload): void {
this.workspaceScope = scope
this.write({ type: "new_chat", ...(scope ? { workspace_scope: scope } : {}) })
}
forkChat(sourceChatId: string, beforeUserIndex: number, title?: string): void {
this.write({
type: "fork_chat",
source_chat_id: sourceChatId,
before_user_index: beforeUserIndex,
...(title?.trim() ? { title: title.trim() } : {}),
})
}
setWorkspaceScope(scope: WorkspaceScopePayload): void {
if (!this.chatId) throw new Error("chat is not ready")
this.workspaceScope = scope
this.write({ type: "set_workspace_scope", chat_id: this.chatId, workspace_scope: scope })
}
updateRecovery(
action: "continue" | "dismiss",
chatId: string,
recoveryId: string,
): Promise<RecoveryState> {
return this.requestMutation<unknown>(`recovery.${action}`, {
chat_id: chatId,
recovery_id: recoveryId,
}).then((result) => {
if (!isRecoveryState(result)) throw new Error("gateway returned an invalid recovery state")
return result
})
}
private requestMutation<T>(
action: string,
payload: Record<string, unknown> = {},
timeoutMs = 20_000,
): Promise<T> {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
return Promise.reject(new Error("gateway connection is not open"))
}
const requestId = crypto.randomUUID()
const frame = JSON.stringify({
type: "webui_request",
request_id: requestId,
action,
payload,
} satisfies OutboundEvent)
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingMutations.delete(requestId)
reject(new Error(`gateway request timed out after ${timeoutMs}ms`))
}, timeoutMs)
this.pendingMutations.set(requestId, {
resolve: (value) => resolve(value as T),
reject,
timer,
})
try {
this.socket?.send(frame)
} catch {
clearTimeout(timer)
this.pendingMutations.delete(requestId)
reject(new Error("could not send gateway request"))
}
})
}
private rejectPendingMutations(message: string): void {
for (const pending of this.pendingMutations.values()) {
clearTimeout(pending.timer)
pending.reject(new Error(message))
}
this.pendingMutations.clear()
}
private handleMessage(raw: string): void {
let value: unknown
try {
value = JSON.parse(raw) as unknown
} catch {
this.options.onStatus("error", "gateway sent invalid JSON")
return
}
const response = decodeWebUIResponse(value)
if (response === null) {
this.options.onStatus("error", "gateway sent an invalid event")
return
}
if (response) {
const pending = this.pendingMutations.get(response.request_id)
if (!pending) return
clearTimeout(pending.timer)
this.pendingMutations.delete(response.request_id)
if (response.ok) pending.resolve(response.result)
else pending.reject(new Error(response.error?.message || "gateway request failed"))
return
}
const event = decodeInboundEvent(value)
if (event === undefined) return
if (event === null) {
this.options.onStatus("error", "gateway sent an invalid event")
return
}
if (event.event === "ready") {
const requestedChatId = this.chatId || this.options.chatId
if (requestedChatId) {
this.chatId = requestedChatId
this.write({ type: "attach", chat_id: this.chatId })
} else {
this.newChat(this.options.initialWorkspaceScope)
}
} else if (event.event === "attached") {
this.chatId = event.chat_id
} else if (event.event === "session_updated" && event.workspace_scope) {
this.workspaceScope = event.workspace_scope
}
this.options.onEvent(event)
}
private scheduleReconnect(): void {
if (this.reconnectTimer || this.closedByClient) return
if (!this.retryStartedAt) this.retryStartedAt = Date.now()
const base = this.options.reconnectDelayMs ?? 500
const maxDelay = this.connectedOnce
? 8_000
: this.options.startupRetryMaxDelayMs ?? 8_000
const delay = Math.min(maxDelay, base * 2 ** Math.min(this.reconnectAttempt++, 4))
this.nextRetryAt = Date.now() + delay
this.reportRetryState()
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
void this.open()
}, delay)
}
private async checkHealthAndScheduleReconnect(): Promise<void> {
if (this.options.checkHealth) {
try {
this.healthStatus = await this.options.checkHealth()
} catch {
this.healthStatus = "unreachable"
}
}
if (!this.closedByClient) this.scheduleReconnect()
}
private connectionInfo(): ConnectionStatusInfo {
return {
endpoint: this.endpoint,
attempt: Math.max(1, this.connectionAttempt),
elapsedMs: this.retryStartedAt ? Math.max(0, Date.now() - this.retryStartedAt) : 0,
...(this.nextRetryAt
? { retryInMs: Math.max(0, this.nextRetryAt - Date.now()) }
: {}),
...(this.healthStatus ? { health: this.healthStatus } : {}),
}
}
private reportConnectionProgress(): void {
if (this.connectedOnce) {
this.options.onStatus("reconnecting", this.lastFailure || undefined, this.connectionInfo())
return
}
const phase = this.options.resolveConnection ? "starting" : "connecting"
this.options.onStatus(phase, undefined, this.connectionInfo())
}
private reportRetryState(): void {
const info = this.connectionInfo()
if (this.connectedOnce) {
this.options.onStatus("reconnecting", this.lastFailure, info)
return
}
const failureDelay = this.options.startupFailureDelayMs ?? 3_000
if (info.elapsedMs >= failureDelay) {
this.clearFailureEscalation()
this.options.onStatus("unavailable", this.lastFailure, info)
return
}
this.reportConnectionProgress()
if (this.failureEscalationTimer) return
this.failureEscalationTimer = setTimeout(() => {
this.failureEscalationTimer = null
if (this.closedByClient || this.connectedOnce || !this.lastFailure) return
this.options.onStatus("unavailable", this.lastFailure, this.connectionInfo())
}, Math.max(0, failureDelay - info.elapsedMs))
}
private clearFailureEscalation(): void {
if (this.failureEscalationTimer) clearTimeout(this.failureEscalationTimer)
this.failureEscalationTimer = null
}
private write(event: OutboundEvent): void {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
throw new Error("gateway connection is not open")
}
this.socket.send(JSON.stringify(event))
}
}