From b9bb1ff80416aa56a7e6d9491f0a614dcb82cc46 Mon Sep 17 00:00:00 2001 From: dave Date: Wed, 29 Apr 2026 14:31:16 +0000 Subject: [PATCH] huskies: merge 840 --- frontend/src/api/client.ts | 795 --------------------------- frontend/src/api/client/http.ts | 260 +++++++++ frontend/src/api/client/index.ts | 38 ++ frontend/src/api/client/types.ts | 285 ++++++++++ frontend/src/api/client/websocket.ts | 331 +++++++++++ 5 files changed, 914 insertions(+), 795 deletions(-) delete mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/api/client/http.ts create mode 100644 frontend/src/api/client/index.ts create mode 100644 frontend/src/api/client/types.ts create mode 100644 frontend/src/api/client/websocket.ts diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts deleted file mode 100644 index 8b78b224..00000000 --- a/frontend/src/api/client.ts +++ /dev/null @@ -1,795 +0,0 @@ -export type WsRequest = - | { - type: "chat"; - messages: Message[]; - config: ProviderConfig; - } - | { - type: "cancel"; - } - | { - type: "permission_response"; - request_id: string; - approved: boolean; - always_allow: boolean; - } - | { type: "ping" } - | { - type: "side_question"; - question: string; - context_messages: Message[]; - config: ProviderConfig; - }; - -export interface WizardStepInfo { - step: string; - label: string; - status: string; - content?: string; -} - -export interface WizardStateData { - steps: WizardStepInfo[]; - current_step_index: number; - completed: boolean; -} - -export interface AgentAssignment { - agent_name: string; - model: string | null; - status: string; -} - -export interface PipelineStageItem { - story_id: string; - name: string | null; - error: string | null; - merge_failure: string | null; - agent: AgentAssignment | null; - review_hold: boolean | null; - qa: string | null; - depends_on: number[] | null; -} - -export interface PipelineState { - backlog: PipelineStageItem[]; - current: PipelineStageItem[]; - qa: PipelineStageItem[]; - merge: PipelineStageItem[]; - done: PipelineStageItem[]; -} - -export type WsResponse = - | { type: "token"; content: string } - | { type: "update"; messages: Message[] } - | { type: "session_id"; session_id: string } - | { type: "error"; message: string } - | { - type: "pipeline_state"; - backlog: PipelineStageItem[]; - current: PipelineStageItem[]; - qa: PipelineStageItem[]; - merge: PipelineStageItem[]; - done: PipelineStageItem[]; - } - | { - type: "permission_request"; - request_id: string; - tool_name: string; - tool_input: Record; - } - | { type: "tool_activity"; tool_name: string } - | { - type: "reconciliation_progress"; - story_id: string; - status: string; - message: string; - } - /** `.story_kit/project.toml` was modified; re-fetch the agent roster. */ - | { type: "agent_config_changed" } - /** An agent started, stopped, or changed state; re-fetch agent list. */ - | { type: "agent_state_changed" } - | { type: "tool_activity"; tool_name: string } - /** Heartbeat response confirming the connection is alive. */ - | { type: "pong" } - /** Sent on connect when the project still needs onboarding (specs are placeholders). */ - | { type: "onboarding_status"; needs_onboarding: boolean } - /** Sent on connect when a setup wizard is active. */ - | { - type: "wizard_state"; - steps: WizardStepInfo[]; - current_step_index: number; - completed: boolean; - } - /** Streaming thinking token from an extended-thinking block, separate from regular text. */ - | { type: "thinking_token"; content: string } - /** Streaming token from a /btw side question response. */ - | { type: "side_question_token"; content: string } - /** Final signal that the /btw side question has been fully answered. */ - | { type: "side_question_done"; response: string } - /** A single server log entry (bulk on connect, then live). */ - | { type: "log_entry"; timestamp: string; level: string; message: string } - /** A structured pipeline status event from the status broadcaster. */ - | { type: "status_update"; event: StatusEvent }; - -/** - * A structured pipeline status event emitted by the status broadcaster. - * - * The discriminant `type` field enables per-event-type rendering without - * parsing strings. All fields from the original event are preserved so - * future UI stories can add dedicated icons, banners, or filters. - */ -export type StatusEvent = - | { - type: "stage_transition"; - story_id: string; - story_name: string | null; - from_stage: string; - to_stage: string; - } - | { - type: "merge_failure"; - story_id: string; - story_name: string | null; - reason: string; - } - | { - type: "story_blocked"; - story_id: string; - story_name: string | null; - reason: string; - } - | { - type: "rate_limit_warning"; - story_id: string; - story_name: string | null; - agent_name: string; - } - | { - type: "rate_limit_hard_block"; - story_id: string; - story_name: string | null; - agent_name: string; - reset_at: string; - }; - -export interface ProviderConfig { - provider: string; - model: string; - base_url?: string; - enable_tools?: boolean; - session_id?: string; -} - -export type Role = "system" | "user" | "assistant" | "tool"; - -export interface ToolCall { - id?: string; - type: string; - function: { - name: string; - arguments: string; - }; -} - -export interface Message { - role: Role; - content: string; - tool_calls?: ToolCall[]; - tool_call_id?: string; -} - -export interface AnthropicModelInfo { - id: string; - context_window: number; -} - -export interface WorkItemContent { - content: string; - stage: string; - name: string | null; - agent: string | null; -} - -export interface TestCaseResult { - name: string; - status: "pass" | "fail"; - details: string | null; -} - -export interface TestResultsResponse { - unit: TestCaseResult[]; - integration: TestCaseResult[]; -} - -export interface FileEntry { - name: string; - kind: "file" | "dir"; -} - -export interface SearchResult { - path: string; - matches: number; -} - -export interface AgentCostEntry { - agent_name: string; - model: string | null; - input_tokens: number; - output_tokens: number; - cache_creation_input_tokens: number; - cache_read_input_tokens: number; - total_cost_usd: number; -} - -export interface TokenCostResponse { - total_cost_usd: number; - agents: AgentCostEntry[]; -} - -export interface TokenUsageRecord { - story_id: string; - agent_name: string; - model: string | null; - timestamp: string; - input_tokens: number; - output_tokens: number; - cache_creation_input_tokens: number; - cache_read_input_tokens: number; - total_cost_usd: number; -} - -export interface AllTokenUsageResponse { - records: TokenUsageRecord[]; -} - -export interface CommandOutput { - stdout: string; - stderr: string; - exit_code: number; -} - -export interface OAuthStatus { - authenticated: boolean; - expired: boolean; - expires_at: number; - has_refresh_token: boolean; -} - -declare const __HUSKIES_PORT__: string; - -const DEFAULT_API_BASE = "/api"; -const DEFAULT_WS_PATH = "/ws"; - -export function resolveWsHost( - isDev: boolean, - envPort: string | undefined, - locationHost: string, -): string { - return isDev ? `127.0.0.1:${envPort || "3001"}` : locationHost; -} - -function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string { - return `${baseUrl}${path}`; -} - -async function requestJson( - path: string, - options: RequestInit = {}, - baseUrl = DEFAULT_API_BASE, -): Promise { - const res = await fetch(buildApiUrl(path, baseUrl), { - headers: { - "Content-Type": "application/json", - ...(options.headers ?? {}), - }, - ...options, - }); - - if (!res.ok) { - const text = await res.text(); - throw new Error(text || `Request failed (${res.status})`); - } - - return res.json() as Promise; -} - -export const api = { - getCurrentProject(baseUrl?: string) { - return requestJson("/project", {}, baseUrl); - }, - getKnownProjects(baseUrl?: string) { - return requestJson("/projects", {}, baseUrl); - }, - forgetKnownProject(path: string, baseUrl?: string) { - return requestJson( - "/projects/forget", - { method: "POST", body: JSON.stringify({ path }) }, - baseUrl, - ); - }, - openProject(path: string, baseUrl?: string) { - return requestJson( - "/project", - { method: "POST", body: JSON.stringify({ path }) }, - baseUrl, - ); - }, - closeProject(baseUrl?: string) { - return requestJson("/project", { method: "DELETE" }, baseUrl); - }, - getModelPreference(baseUrl?: string) { - return requestJson("/model", {}, baseUrl); - }, - setModelPreference(model: string, baseUrl?: string) { - return requestJson( - "/model", - { method: "POST", body: JSON.stringify({ model }) }, - baseUrl, - ); - }, - getOllamaModels(baseUrlParam?: string, baseUrl?: string) { - const url = new URL( - buildApiUrl("/ollama/models", baseUrl), - window.location.origin, - ); - if (baseUrlParam) { - url.searchParams.set("base_url", baseUrlParam); - } - return requestJson(url.pathname + url.search, {}, ""); - }, - getAnthropicApiKeyExists(baseUrl?: string) { - return requestJson("/anthropic/key/exists", {}, baseUrl); - }, - getAnthropicModels(baseUrl?: string) { - return requestJson("/anthropic/models", {}, baseUrl); - }, - setAnthropicApiKey(api_key: string, baseUrl?: string) { - return requestJson( - "/anthropic/key", - { method: "POST", body: JSON.stringify({ api_key }) }, - baseUrl, - ); - }, - readFile(path: string, baseUrl?: string) { - return requestJson( - "/fs/read", - { method: "POST", body: JSON.stringify({ path }) }, - baseUrl, - ); - }, - writeFile(path: string, content: string, baseUrl?: string) { - return requestJson( - "/fs/write", - { method: "POST", body: JSON.stringify({ path, content }) }, - baseUrl, - ); - }, - listDirectory(path: string, baseUrl?: string) { - return requestJson( - "/fs/list", - { method: "POST", body: JSON.stringify({ path }) }, - baseUrl, - ); - }, - listDirectoryAbsolute(path: string, baseUrl?: string) { - return requestJson( - "/io/fs/list/absolute", - { method: "POST", body: JSON.stringify({ path }) }, - baseUrl, - ); - }, - createDirectoryAbsolute(path: string, baseUrl?: string) { - return requestJson( - "/io/fs/create/absolute", - { method: "POST", body: JSON.stringify({ path }) }, - baseUrl, - ); - }, - getHomeDirectory(baseUrl?: string) { - return requestJson("/io/fs/home", {}, baseUrl); - }, - listProjectFiles(baseUrl?: string) { - return requestJson("/io/fs/files", {}, baseUrl); - }, - searchFiles(query: string, baseUrl?: string) { - return requestJson( - "/fs/search", - { method: "POST", body: JSON.stringify({ query }) }, - baseUrl, - ); - }, - execShell(command: string, args: string[], baseUrl?: string) { - return requestJson( - "/shell/exec", - { method: "POST", body: JSON.stringify({ command, args }) }, - baseUrl, - ); - }, - cancelChat(baseUrl?: string) { - return requestJson("/chat/cancel", { method: "POST" }, baseUrl); - }, - getWorkItemContent(storyId: string, baseUrl?: string) { - return requestJson( - `/work-items/${encodeURIComponent(storyId)}`, - {}, - baseUrl, - ); - }, - getTestResults(storyId: string, baseUrl?: string) { - return requestJson( - `/work-items/${encodeURIComponent(storyId)}/test-results`, - {}, - baseUrl, - ); - }, - getTokenCost(storyId: string, baseUrl?: string) { - return requestJson( - `/work-items/${encodeURIComponent(storyId)}/token-cost`, - {}, - baseUrl, - ); - }, - getAllTokenUsage(baseUrl?: string) { - return requestJson("/token-usage", {}, baseUrl); - }, - /** Trigger a server rebuild and restart. */ - rebuildAndRestart() { - return callMcpTool("rebuild_and_restart", {}); - }, - /** Approve a story in QA, moving it to merge. */ - approveQa(storyId: string) { - return callMcpTool("approve_qa", { story_id: storyId }); - }, - /** Reject a story in QA, moving it back to current with notes. */ - rejectQa(storyId: string, notes: string) { - return callMcpTool("reject_qa", { story_id: storyId, notes }); - }, - /** Launch the QA app for a story's worktree. */ - launchQaApp(storyId: string) { - return callMcpTool("launch_qa_app", { story_id: storyId }); - }, - /** Delete a story from the pipeline, stopping any running agent and removing the worktree. */ - deleteStory(storyId: string) { - return callMcpTool("delete_story", { story_id: storyId }); - }, - /** Fetch OAuth status from the server. */ - getOAuthStatus() { - return requestJson("/oauth/status", {}, ""); - }, - /** Execute a bot slash command without LLM invocation. Returns markdown response text. */ - botCommand(command: string, args: string, baseUrl?: string) { - return requestJson<{ response: string }>( - "/bot/command", - { method: "POST", body: JSON.stringify({ command, args }) }, - baseUrl, - ); - }, -}; - -async function callMcpTool( - toolName: string, - args: Record, -): Promise { - const res = await fetch("/mcp", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: 1, - method: "tools/call", - params: { name: toolName, arguments: args }, - }), - }); - const json = await res.json(); - if (json.error) { - throw new Error(json.error.message); - } - const text = json.result?.content?.[0]?.text ?? ""; - return text; -} - -export class ChatWebSocket { - private static sharedSocket: WebSocket | null = null; - private static refCount = 0; - private socket?: WebSocket; - private onToken?: (content: string) => void; - private onThinkingToken?: (content: string) => void; - private onUpdate?: (messages: Message[]) => void; - private onSessionId?: (sessionId: string) => void; - private onError?: (message: string) => void; - private onPipelineState?: (state: PipelineState) => void; - private onPermissionRequest?: ( - requestId: string, - toolName: string, - toolInput: Record, - ) => void; - private onActivity?: (toolName: string) => void; - private onReconciliationProgress?: ( - storyId: string, - status: string, - message: string, - ) => void; - private onAgentConfigChanged?: () => void; - private onAgentStateChanged?: () => void; - private onOnboardingStatus?: (needsOnboarding: boolean) => void; - private onWizardState?: (state: WizardStateData) => void; - private onSideQuestionToken?: (content: string) => void; - private onSideQuestionDone?: (response: string) => void; - private onLogEntry?: ( - timestamp: string, - level: string, - message: string, - ) => void; - private onStatusUpdate?: (event: StatusEvent) => void; - private onConnected?: () => void; - private connected = false; - private closeTimer?: number; - private wsPath = DEFAULT_WS_PATH; - private reconnectTimer?: number; - private reconnectDelay = 1000; - private shouldReconnect = false; - private heartbeatInterval?: number; - private heartbeatTimeout?: number; - private static readonly HEARTBEAT_INTERVAL = 30_000; - private static readonly HEARTBEAT_TIMEOUT = 5_000; - - private _startHeartbeat(): void { - this._stopHeartbeat(); - this.heartbeatInterval = window.setInterval(() => { - if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return; - const ping: WsRequest = { type: "ping" }; - this.socket.send(JSON.stringify(ping)); - this.heartbeatTimeout = window.setTimeout(() => { - // No pong received within timeout; close socket to trigger reconnect. - this.socket?.close(); - }, ChatWebSocket.HEARTBEAT_TIMEOUT); - }, ChatWebSocket.HEARTBEAT_INTERVAL); - } - - private _stopHeartbeat(): void { - window.clearInterval(this.heartbeatInterval); - window.clearTimeout(this.heartbeatTimeout); - this.heartbeatInterval = undefined; - this.heartbeatTimeout = undefined; - } - - private _buildWsUrl(): string { - const protocol = window.location.protocol === "https:" ? "wss" : "ws"; - const wsHost = resolveWsHost( - import.meta.env.DEV, - typeof __HUSKIES_PORT__ !== "undefined" ? __HUSKIES_PORT__ : undefined, - window.location.host, - ); - return `${protocol}://${wsHost}${this.wsPath}`; - } - - private _attachHandlers(): void { - if (!this.socket) return; - this.socket.onopen = () => { - this.reconnectDelay = 1000; - this._startHeartbeat(); - this.onConnected?.(); - }; - this.socket.onmessage = (event) => { - try { - const data = JSON.parse(event.data) as WsResponse; - if (data.type === "token") this.onToken?.(data.content); - if (data.type === "thinking_token") - this.onThinkingToken?.(data.content); - if (data.type === "update") this.onUpdate?.(data.messages); - if (data.type === "session_id") this.onSessionId?.(data.session_id); - if (data.type === "error") this.onError?.(data.message); - if (data.type === "pipeline_state") - this.onPipelineState?.({ - backlog: data.backlog, - current: data.current, - qa: data.qa, - merge: data.merge, - done: data.done, - }); - if (data.type === "permission_request") - this.onPermissionRequest?.( - data.request_id, - data.tool_name, - data.tool_input, - ); - if (data.type === "tool_activity") this.onActivity?.(data.tool_name); - if (data.type === "reconciliation_progress") - this.onReconciliationProgress?.( - data.story_id, - data.status, - data.message, - ); - if (data.type === "agent_config_changed") this.onAgentConfigChanged?.(); - if (data.type === "agent_state_changed") this.onAgentStateChanged?.(); - if (data.type === "onboarding_status") - this.onOnboardingStatus?.(data.needs_onboarding); - if (data.type === "wizard_state") - this.onWizardState?.({ - steps: data.steps, - current_step_index: data.current_step_index, - completed: data.completed, - }); - if (data.type === "side_question_token") - this.onSideQuestionToken?.(data.content); - if (data.type === "side_question_done") - this.onSideQuestionDone?.(data.response); - if (data.type === "log_entry") - this.onLogEntry?.(data.timestamp, data.level, data.message); - if (data.type === "status_update") this.onStatusUpdate?.(data.event); - if (data.type === "pong") { - window.clearTimeout(this.heartbeatTimeout); - this.heartbeatTimeout = undefined; - } - } catch (err) { - this.onError?.(String(err)); - } - }; - this.socket.onerror = () => { - this.onError?.("WebSocket error"); - }; - this.socket.onclose = () => { - if (this.shouldReconnect && this.connected) { - this._scheduleReconnect(); - } - }; - } - - private _scheduleReconnect(): void { - window.clearTimeout(this.reconnectTimer); - const delay = this.reconnectDelay; - this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000); - this.reconnectTimer = window.setTimeout(() => { - this.reconnectTimer = undefined; - const wsUrl = this._buildWsUrl(); - ChatWebSocket.sharedSocket = new WebSocket(wsUrl); - this.socket = ChatWebSocket.sharedSocket; - this._attachHandlers(); - }, delay); - } - - connect( - handlers: { - onToken?: (content: string) => void; - onThinkingToken?: (content: string) => void; - onUpdate?: (messages: Message[]) => void; - onSessionId?: (sessionId: string) => void; - onError?: (message: string) => void; - onPipelineState?: (state: PipelineState) => void; - onPermissionRequest?: ( - requestId: string, - toolName: string, - toolInput: Record, - ) => void; - onActivity?: (toolName: string) => void; - onReconciliationProgress?: ( - storyId: string, - status: string, - message: string, - ) => void; - onAgentConfigChanged?: () => void; - onAgentStateChanged?: () => void; - onOnboardingStatus?: (needsOnboarding: boolean) => void; - onWizardState?: (state: WizardStateData) => void; - onSideQuestionToken?: (content: string) => void; - onSideQuestionDone?: (response: string) => void; - onLogEntry?: (timestamp: string, level: string, message: string) => void; - onStatusUpdate?: (event: StatusEvent) => void; - onConnected?: () => void; - }, - wsPath = DEFAULT_WS_PATH, - ) { - this.onToken = handlers.onToken; - this.onThinkingToken = handlers.onThinkingToken; - this.onUpdate = handlers.onUpdate; - this.onSessionId = handlers.onSessionId; - this.onError = handlers.onError; - this.onPipelineState = handlers.onPipelineState; - this.onPermissionRequest = handlers.onPermissionRequest; - this.onActivity = handlers.onActivity; - this.onReconciliationProgress = handlers.onReconciliationProgress; - this.onAgentConfigChanged = handlers.onAgentConfigChanged; - this.onAgentStateChanged = handlers.onAgentStateChanged; - this.onOnboardingStatus = handlers.onOnboardingStatus; - this.onWizardState = handlers.onWizardState; - this.onSideQuestionToken = handlers.onSideQuestionToken; - this.onSideQuestionDone = handlers.onSideQuestionDone; - this.onLogEntry = handlers.onLogEntry; - this.onStatusUpdate = handlers.onStatusUpdate; - this.onConnected = handlers.onConnected; - this.wsPath = wsPath; - this.shouldReconnect = true; - - if (this.connected) { - return; - } - this.connected = true; - ChatWebSocket.refCount += 1; - - if ( - !ChatWebSocket.sharedSocket || - ChatWebSocket.sharedSocket.readyState === WebSocket.CLOSED || - ChatWebSocket.sharedSocket.readyState === WebSocket.CLOSING - ) { - const wsUrl = this._buildWsUrl(); - ChatWebSocket.sharedSocket = new WebSocket(wsUrl); - } - this.socket = ChatWebSocket.sharedSocket; - this._attachHandlers(); - } - - sendChat(messages: Message[], config: ProviderConfig) { - this.send({ type: "chat", messages, config }); - } - - sendSideQuestion( - question: string, - contextMessages: Message[], - config: ProviderConfig, - ) { - this.send({ - type: "side_question", - question, - context_messages: contextMessages, - config, - }); - } - - cancel() { - this.send({ type: "cancel" }); - } - - sendPermissionResponse( - requestId: string, - approved: boolean, - alwaysAllow = false, - ) { - this.send({ - type: "permission_response", - request_id: requestId, - approved, - always_allow: alwaysAllow, - }); - } - - close() { - this.shouldReconnect = false; - this._stopHeartbeat(); - window.clearTimeout(this.reconnectTimer); - this.reconnectTimer = undefined; - - if (!this.connected) return; - this.connected = false; - ChatWebSocket.refCount = Math.max(0, ChatWebSocket.refCount - 1); - - if (import.meta.env.DEV) { - if (this.closeTimer) { - window.clearTimeout(this.closeTimer); - } - this.closeTimer = window.setTimeout(() => { - if (ChatWebSocket.refCount === 0) { - ChatWebSocket.sharedSocket?.close(); - ChatWebSocket.sharedSocket = null; - } - this.socket = ChatWebSocket.sharedSocket ?? undefined; - this.closeTimer = undefined; - }, 250); - return; - } - - if (ChatWebSocket.refCount === 0) { - ChatWebSocket.sharedSocket?.close(); - ChatWebSocket.sharedSocket = null; - } - this.socket = ChatWebSocket.sharedSocket ?? undefined; - } - - private send(payload: WsRequest) { - if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { - this.onError?.("WebSocket is not connected"); - return; - } - this.socket.send(JSON.stringify(payload)); - } -} diff --git a/frontend/src/api/client/http.ts b/frontend/src/api/client/http.ts new file mode 100644 index 00000000..aacc172f --- /dev/null +++ b/frontend/src/api/client/http.ts @@ -0,0 +1,260 @@ +/** + * HTTP transport layer for the Huskies API client. + * Provides the low-level `requestJson` helper, the `callMcpTool` function + * for MCP JSON-RPC calls, the `resolveWsHost` utility, and the `api` + * object exposing all REST endpoints. + */ + +import type { + AllTokenUsageResponse, + AnthropicModelInfo, + CommandOutput, + FileEntry, + OAuthStatus, + SearchResult, + TestResultsResponse, + TokenCostResponse, + WorkItemContent, +} from "./types"; + +/** Base URL prefix for all REST API requests in production. */ +export const DEFAULT_API_BASE = "/api"; + +/** + * Resolve the WebSocket host to connect to. + * In development, uses the injected port (or 3001); in production, uses the + * current page's host so the socket connects to the same origin. + */ +export function resolveWsHost( + isDev: boolean, + envPort: string | undefined, + locationHost: string, +): string { + return isDev ? `127.0.0.1:${envPort || "3001"}` : locationHost; +} + +function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string { + return `${baseUrl}${path}`; +} + +async function requestJson( + path: string, + options: RequestInit = {}, + baseUrl = DEFAULT_API_BASE, +): Promise { + const res = await fetch(buildApiUrl(path, baseUrl), { + headers: { + "Content-Type": "application/json", + ...(options.headers ?? {}), + }, + ...options, + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(text || `Request failed (${res.status})`); + } + + return res.json() as Promise; +} + +/** + * Invoke an MCP tool via the server's JSON-RPC `/mcp` endpoint. + * Returns the first text content block from the tool result, or an empty + * string if the result has no content. + */ +export async function callMcpTool( + toolName: string, + args: Record, +): Promise { + const res = await fetch("/mcp", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: toolName, arguments: args }, + }), + }); + const json = await res.json(); + if (json.error) { + throw new Error(json.error.message); + } + const text = json.result?.content?.[0]?.text ?? ""; + return text; +} + +/** Typed REST and MCP wrappers for all Huskies server endpoints. */ +export const api = { + getCurrentProject(baseUrl?: string) { + return requestJson("/project", {}, baseUrl); + }, + getKnownProjects(baseUrl?: string) { + return requestJson("/projects", {}, baseUrl); + }, + forgetKnownProject(path: string, baseUrl?: string) { + return requestJson( + "/projects/forget", + { method: "POST", body: JSON.stringify({ path }) }, + baseUrl, + ); + }, + openProject(path: string, baseUrl?: string) { + return requestJson( + "/project", + { method: "POST", body: JSON.stringify({ path }) }, + baseUrl, + ); + }, + closeProject(baseUrl?: string) { + return requestJson("/project", { method: "DELETE" }, baseUrl); + }, + getModelPreference(baseUrl?: string) { + return requestJson("/model", {}, baseUrl); + }, + setModelPreference(model: string, baseUrl?: string) { + return requestJson( + "/model", + { method: "POST", body: JSON.stringify({ model }) }, + baseUrl, + ); + }, + getOllamaModels(baseUrlParam?: string, baseUrl?: string) { + const url = new URL( + buildApiUrl("/ollama/models", baseUrl), + window.location.origin, + ); + if (baseUrlParam) { + url.searchParams.set("base_url", baseUrlParam); + } + return requestJson(url.pathname + url.search, {}, ""); + }, + getAnthropicApiKeyExists(baseUrl?: string) { + return requestJson("/anthropic/key/exists", {}, baseUrl); + }, + getAnthropicModels(baseUrl?: string) { + return requestJson("/anthropic/models", {}, baseUrl); + }, + setAnthropicApiKey(api_key: string, baseUrl?: string) { + return requestJson( + "/anthropic/key", + { method: "POST", body: JSON.stringify({ api_key }) }, + baseUrl, + ); + }, + readFile(path: string, baseUrl?: string) { + return requestJson( + "/fs/read", + { method: "POST", body: JSON.stringify({ path }) }, + baseUrl, + ); + }, + writeFile(path: string, content: string, baseUrl?: string) { + return requestJson( + "/fs/write", + { method: "POST", body: JSON.stringify({ path, content }) }, + baseUrl, + ); + }, + listDirectory(path: string, baseUrl?: string) { + return requestJson( + "/fs/list", + { method: "POST", body: JSON.stringify({ path }) }, + baseUrl, + ); + }, + listDirectoryAbsolute(path: string, baseUrl?: string) { + return requestJson( + "/io/fs/list/absolute", + { method: "POST", body: JSON.stringify({ path }) }, + baseUrl, + ); + }, + createDirectoryAbsolute(path: string, baseUrl?: string) { + return requestJson( + "/io/fs/create/absolute", + { method: "POST", body: JSON.stringify({ path }) }, + baseUrl, + ); + }, + getHomeDirectory(baseUrl?: string) { + return requestJson("/io/fs/home", {}, baseUrl); + }, + listProjectFiles(baseUrl?: string) { + return requestJson("/io/fs/files", {}, baseUrl); + }, + searchFiles(query: string, baseUrl?: string) { + return requestJson( + "/fs/search", + { method: "POST", body: JSON.stringify({ query }) }, + baseUrl, + ); + }, + execShell(command: string, args: string[], baseUrl?: string) { + return requestJson( + "/shell/exec", + { method: "POST", body: JSON.stringify({ command, args }) }, + baseUrl, + ); + }, + cancelChat(baseUrl?: string) { + return requestJson("/chat/cancel", { method: "POST" }, baseUrl); + }, + getWorkItemContent(storyId: string, baseUrl?: string) { + return requestJson( + `/work-items/${encodeURIComponent(storyId)}`, + {}, + baseUrl, + ); + }, + getTestResults(storyId: string, baseUrl?: string) { + return requestJson( + `/work-items/${encodeURIComponent(storyId)}/test-results`, + {}, + baseUrl, + ); + }, + getTokenCost(storyId: string, baseUrl?: string) { + return requestJson( + `/work-items/${encodeURIComponent(storyId)}/token-cost`, + {}, + baseUrl, + ); + }, + getAllTokenUsage(baseUrl?: string) { + return requestJson("/token-usage", {}, baseUrl); + }, + /** Trigger a server rebuild and restart. */ + rebuildAndRestart() { + return callMcpTool("rebuild_and_restart", {}); + }, + /** Approve a story in QA, moving it to merge. */ + approveQa(storyId: string) { + return callMcpTool("approve_qa", { story_id: storyId }); + }, + /** Reject a story in QA, moving it back to current with notes. */ + rejectQa(storyId: string, notes: string) { + return callMcpTool("reject_qa", { story_id: storyId, notes }); + }, + /** Launch the QA app for a story's worktree. */ + launchQaApp(storyId: string) { + return callMcpTool("launch_qa_app", { story_id: storyId }); + }, + /** Delete a story from the pipeline, stopping any running agent and removing the worktree. */ + deleteStory(storyId: string) { + return callMcpTool("delete_story", { story_id: storyId }); + }, + /** Fetch OAuth status from the server. */ + getOAuthStatus() { + return requestJson("/oauth/status", {}, ""); + }, + /** Execute a bot slash command without LLM invocation. Returns markdown response text. */ + botCommand(command: string, args: string, baseUrl?: string) { + return requestJson<{ response: string }>( + "/bot/command", + { method: "POST", body: JSON.stringify({ command, args }) }, + baseUrl, + ); + }, +}; diff --git a/frontend/src/api/client/index.ts b/frontend/src/api/client/index.ts new file mode 100644 index 00000000..1a229672 --- /dev/null +++ b/frontend/src/api/client/index.ts @@ -0,0 +1,38 @@ +/** + * Public API surface for the Huskies client module. + * Re-exports all types, HTTP helpers, and the WebSocket client so that + * callers importing from `api/client` continue to work without changes + * after the module was decomposed into focused submodules. + */ + +/** All domain types and interfaces from the client module. */ +export type { + AgentAssignment, + AgentCostEntry, + AllTokenUsageResponse, + AnthropicModelInfo, + CommandOutput, + FileEntry, + Message, + OAuthStatus, + PipelineState, + PipelineStageItem, + ProviderConfig, + Role, + SearchResult, + StatusEvent, + TestCaseResult, + TestResultsResponse, + TokenCostResponse, + TokenUsageRecord, + ToolCall, + WizardStateData, + WizardStepInfo, + WorkItemContent, + WsRequest, + WsResponse, +} from "./types"; + +export { api, callMcpTool, DEFAULT_API_BASE, resolveWsHost } from "./http"; + +export { ChatWebSocket } from "./websocket"; diff --git a/frontend/src/api/client/types.ts b/frontend/src/api/client/types.ts new file mode 100644 index 00000000..1cff0982 --- /dev/null +++ b/frontend/src/api/client/types.ts @@ -0,0 +1,285 @@ +/** + * Type and interface definitions for the Huskies API client. + * All shared domain types — WebSocket messages, pipeline state, + * provider configuration, and response shapes — live here. + */ + +/** A message sent from the browser to the Huskies server over WebSocket. */ +export type WsRequest = + | { + type: "chat"; + messages: Message[]; + config: ProviderConfig; + } + | { + type: "cancel"; + } + | { + type: "permission_response"; + request_id: string; + approved: boolean; + always_allow: boolean; + } + | { type: "ping" } + | { + type: "side_question"; + question: string; + context_messages: Message[]; + config: ProviderConfig; + }; + +/** Metadata for a single step in the setup wizard flow. */ +export interface WizardStepInfo { + step: string; + label: string; + status: string; + content?: string; +} + +/** Full state snapshot of the setup wizard, including all steps and completion flag. */ +export interface WizardStateData { + steps: WizardStepInfo[]; + current_step_index: number; + completed: boolean; +} + +/** Describes the agent currently assigned to a pipeline work item. */ +export interface AgentAssignment { + agent_name: string; + model: string | null; + status: string; +} + +/** A single item in any pipeline stage (backlog, current, QA, merge, or done). */ +export interface PipelineStageItem { + story_id: string; + name: string | null; + error: string | null; + merge_failure: string | null; + agent: AgentAssignment | null; + review_hold: boolean | null; + qa: string | null; + depends_on: number[] | null; +} + +/** Snapshot of all pipeline stages returned via WebSocket or REST. */ +export interface PipelineState { + backlog: PipelineStageItem[]; + current: PipelineStageItem[]; + qa: PipelineStageItem[]; + merge: PipelineStageItem[]; + done: PipelineStageItem[]; +} + +/** A message received from the Huskies server over WebSocket. */ +export type WsResponse = + | { type: "token"; content: string } + | { type: "update"; messages: Message[] } + | { type: "session_id"; session_id: string } + | { type: "error"; message: string } + | { + type: "pipeline_state"; + backlog: PipelineStageItem[]; + current: PipelineStageItem[]; + qa: PipelineStageItem[]; + merge: PipelineStageItem[]; + done: PipelineStageItem[]; + } + | { + type: "permission_request"; + request_id: string; + tool_name: string; + tool_input: Record; + } + | { type: "tool_activity"; tool_name: string } + | { + type: "reconciliation_progress"; + story_id: string; + status: string; + message: string; + } + /** `.story_kit/project.toml` was modified; re-fetch the agent roster. */ + | { type: "agent_config_changed" } + /** An agent started, stopped, or changed state; re-fetch agent list. */ + | { type: "agent_state_changed" } + /** Heartbeat response confirming the connection is alive. */ + | { type: "pong" } + /** Sent on connect when the project still needs onboarding (specs are placeholders). */ + | { type: "onboarding_status"; needs_onboarding: boolean } + /** Sent on connect when a setup wizard is active. */ + | { + type: "wizard_state"; + steps: WizardStepInfo[]; + current_step_index: number; + completed: boolean; + } + /** Streaming thinking token from an extended-thinking block, separate from regular text. */ + | { type: "thinking_token"; content: string } + /** Streaming token from a /btw side question response. */ + | { type: "side_question_token"; content: string } + /** Final signal that the /btw side question has been fully answered. */ + | { type: "side_question_done"; response: string } + /** A single server log entry (bulk on connect, then live). */ + | { type: "log_entry"; timestamp: string; level: string; message: string } + /** A structured pipeline status event from the status broadcaster. */ + | { type: "status_update"; event: StatusEvent }; + +/** + * A structured pipeline status event emitted by the status broadcaster. + * + * The discriminant `type` field enables per-event-type rendering without + * parsing strings. All fields from the original event are preserved so + * future UI stories can add dedicated icons, banners, or filters. + */ +export type StatusEvent = + | { + type: "stage_transition"; + story_id: string; + story_name: string | null; + from_stage: string; + to_stage: string; + } + | { + type: "merge_failure"; + story_id: string; + story_name: string | null; + reason: string; + } + | { + type: "story_blocked"; + story_id: string; + story_name: string | null; + reason: string; + } + | { + type: "rate_limit_warning"; + story_id: string; + story_name: string | null; + agent_name: string; + } + | { + type: "rate_limit_hard_block"; + story_id: string; + story_name: string | null; + agent_name: string; + reset_at: string; + }; + +/** LLM provider configuration used when initiating a chat request. */ +export interface ProviderConfig { + provider: string; + model: string; + base_url?: string; + enable_tools?: boolean; + session_id?: string; +} + +/** Valid role values for a chat message. */ +export type Role = "system" | "user" | "assistant" | "tool"; + +/** An LLM tool call embedded in an assistant message. */ +export interface ToolCall { + id?: string; + type: string; + function: { + name: string; + arguments: string; + }; +} + +/** A single chat message exchanged with the LLM. */ +export interface Message { + role: Role; + content: string; + tool_calls?: ToolCall[]; + tool_call_id?: string; +} + +/** Anthropic model metadata returned by the models endpoint. */ +export interface AnthropicModelInfo { + id: string; + context_window: number; +} + +/** Content and metadata for a pipeline work item fetched from the server. */ +export interface WorkItemContent { + content: string; + stage: string; + name: string | null; + agent: string | null; +} + +/** Result for a single test case from the server's test runner. */ +export interface TestCaseResult { + name: string; + status: "pass" | "fail"; + details: string | null; +} + +/** Combined unit and integration test results for a work item. */ +export interface TestResultsResponse { + unit: TestCaseResult[]; + integration: TestCaseResult[]; +} + +/** A file-system entry (file or directory) returned by listing endpoints. */ +export interface FileEntry { + name: string; + kind: "file" | "dir"; +} + +/** A single file-search match with path and match count. */ +export interface SearchResult { + path: string; + matches: number; +} + +/** Per-agent token usage and cost breakdown within a story. */ +export interface AgentCostEntry { + agent_name: string; + model: string | null; + input_tokens: number; + output_tokens: number; + cache_creation_input_tokens: number; + cache_read_input_tokens: number; + total_cost_usd: number; +} + +/** Total token cost for a work item, broken down by agent. */ +export interface TokenCostResponse { + total_cost_usd: number; + agents: AgentCostEntry[]; +} + +/** A single token-usage record from the server's usage log. */ +export interface TokenUsageRecord { + story_id: string; + agent_name: string; + model: string | null; + timestamp: string; + input_tokens: number; + output_tokens: number; + cache_creation_input_tokens: number; + cache_read_input_tokens: number; + total_cost_usd: number; +} + +/** All token-usage records returned by the usage endpoint. */ +export interface AllTokenUsageResponse { + records: TokenUsageRecord[]; +} + +/** Output captured from a shell command executed on the server. */ +export interface CommandOutput { + stdout: string; + stderr: string; + exit_code: number; +} + +/** OAuth authentication status returned by the server. */ +export interface OAuthStatus { + authenticated: boolean; + expired: boolean; + expires_at: number; + has_refresh_token: boolean; +} diff --git a/frontend/src/api/client/websocket.ts b/frontend/src/api/client/websocket.ts new file mode 100644 index 00000000..7ce62541 --- /dev/null +++ b/frontend/src/api/client/websocket.ts @@ -0,0 +1,331 @@ +/** + * WebSocket client for real-time communication with the Huskies server. + * Manages a shared socket with reference counting, automatic reconnection, + * and heartbeat keepalive. All inbound message types are dispatched to + * caller-supplied handler callbacks. + */ + +import { resolveWsHost } from "./http"; +import type { + Message, + PipelineState, + ProviderConfig, + StatusEvent, + WizardStateData, + WsRequest, + WsResponse, +} from "./types"; + +declare const __HUSKIES_PORT__: string; + +const DEFAULT_WS_PATH = "/ws"; + +/** + * Singleton-backed WebSocket client with automatic reconnection and heartbeat. + * Multiple callers share one underlying socket via reference counting; the + * socket is closed only when the last caller disconnects. + */ +export class ChatWebSocket { + private static sharedSocket: WebSocket | null = null; + private static refCount = 0; + private socket?: WebSocket; + private onToken?: (content: string) => void; + private onThinkingToken?: (content: string) => void; + private onUpdate?: (messages: Message[]) => void; + private onSessionId?: (sessionId: string) => void; + private onError?: (message: string) => void; + private onPipelineState?: (state: PipelineState) => void; + private onPermissionRequest?: ( + requestId: string, + toolName: string, + toolInput: Record, + ) => void; + private onActivity?: (toolName: string) => void; + private onReconciliationProgress?: ( + storyId: string, + status: string, + message: string, + ) => void; + private onAgentConfigChanged?: () => void; + private onAgentStateChanged?: () => void; + private onOnboardingStatus?: (needsOnboarding: boolean) => void; + private onWizardState?: (state: WizardStateData) => void; + private onSideQuestionToken?: (content: string) => void; + private onSideQuestionDone?: (response: string) => void; + private onLogEntry?: ( + timestamp: string, + level: string, + message: string, + ) => void; + private onStatusUpdate?: (event: StatusEvent) => void; + private onConnected?: () => void; + private connected = false; + private closeTimer?: number; + private wsPath = DEFAULT_WS_PATH; + private reconnectTimer?: number; + private reconnectDelay = 1000; + private shouldReconnect = false; + private heartbeatInterval?: number; + private heartbeatTimeout?: number; + private static readonly HEARTBEAT_INTERVAL = 30_000; + private static readonly HEARTBEAT_TIMEOUT = 5_000; + + private _startHeartbeat(): void { + this._stopHeartbeat(); + this.heartbeatInterval = window.setInterval(() => { + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return; + const ping: WsRequest = { type: "ping" }; + this.socket.send(JSON.stringify(ping)); + this.heartbeatTimeout = window.setTimeout(() => { + // No pong received within timeout; close socket to trigger reconnect. + this.socket?.close(); + }, ChatWebSocket.HEARTBEAT_TIMEOUT); + }, ChatWebSocket.HEARTBEAT_INTERVAL); + } + + private _stopHeartbeat(): void { + window.clearInterval(this.heartbeatInterval); + window.clearTimeout(this.heartbeatTimeout); + this.heartbeatInterval = undefined; + this.heartbeatTimeout = undefined; + } + + private _buildWsUrl(): string { + const protocol = window.location.protocol === "https:" ? "wss" : "ws"; + const wsHost = resolveWsHost( + import.meta.env.DEV, + typeof __HUSKIES_PORT__ !== "undefined" ? __HUSKIES_PORT__ : undefined, + window.location.host, + ); + return `${protocol}://${wsHost}${this.wsPath}`; + } + + private _attachHandlers(): void { + if (!this.socket) return; + this.socket.onopen = () => { + this.reconnectDelay = 1000; + this._startHeartbeat(); + this.onConnected?.(); + }; + this.socket.onmessage = (event) => { + try { + const data = JSON.parse(event.data) as WsResponse; + if (data.type === "token") this.onToken?.(data.content); + if (data.type === "thinking_token") + this.onThinkingToken?.(data.content); + if (data.type === "update") this.onUpdate?.(data.messages); + if (data.type === "session_id") this.onSessionId?.(data.session_id); + if (data.type === "error") this.onError?.(data.message); + if (data.type === "pipeline_state") + this.onPipelineState?.({ + backlog: data.backlog, + current: data.current, + qa: data.qa, + merge: data.merge, + done: data.done, + }); + if (data.type === "permission_request") + this.onPermissionRequest?.( + data.request_id, + data.tool_name, + data.tool_input, + ); + if (data.type === "tool_activity") this.onActivity?.(data.tool_name); + if (data.type === "reconciliation_progress") + this.onReconciliationProgress?.( + data.story_id, + data.status, + data.message, + ); + if (data.type === "agent_config_changed") this.onAgentConfigChanged?.(); + if (data.type === "agent_state_changed") this.onAgentStateChanged?.(); + if (data.type === "onboarding_status") + this.onOnboardingStatus?.(data.needs_onboarding); + if (data.type === "wizard_state") + this.onWizardState?.({ + steps: data.steps, + current_step_index: data.current_step_index, + completed: data.completed, + }); + if (data.type === "side_question_token") + this.onSideQuestionToken?.(data.content); + if (data.type === "side_question_done") + this.onSideQuestionDone?.(data.response); + if (data.type === "log_entry") + this.onLogEntry?.(data.timestamp, data.level, data.message); + if (data.type === "status_update") this.onStatusUpdate?.(data.event); + if (data.type === "pong") { + window.clearTimeout(this.heartbeatTimeout); + this.heartbeatTimeout = undefined; + } + } catch (err) { + this.onError?.(String(err)); + } + }; + this.socket.onerror = () => { + this.onError?.("WebSocket error"); + }; + this.socket.onclose = () => { + if (this.shouldReconnect && this.connected) { + this._scheduleReconnect(); + } + }; + } + + private _scheduleReconnect(): void { + window.clearTimeout(this.reconnectTimer); + const delay = this.reconnectDelay; + this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000); + this.reconnectTimer = window.setTimeout(() => { + this.reconnectTimer = undefined; + const wsUrl = this._buildWsUrl(); + ChatWebSocket.sharedSocket = new WebSocket(wsUrl); + this.socket = ChatWebSocket.sharedSocket; + this._attachHandlers(); + }, delay); + } + + connect( + handlers: { + onToken?: (content: string) => void; + onThinkingToken?: (content: string) => void; + onUpdate?: (messages: Message[]) => void; + onSessionId?: (sessionId: string) => void; + onError?: (message: string) => void; + onPipelineState?: (state: PipelineState) => void; + onPermissionRequest?: ( + requestId: string, + toolName: string, + toolInput: Record, + ) => void; + onActivity?: (toolName: string) => void; + onReconciliationProgress?: ( + storyId: string, + status: string, + message: string, + ) => void; + onAgentConfigChanged?: () => void; + onAgentStateChanged?: () => void; + onOnboardingStatus?: (needsOnboarding: boolean) => void; + onWizardState?: (state: WizardStateData) => void; + onSideQuestionToken?: (content: string) => void; + onSideQuestionDone?: (response: string) => void; + onLogEntry?: (timestamp: string, level: string, message: string) => void; + onStatusUpdate?: (event: StatusEvent) => void; + onConnected?: () => void; + }, + wsPath = DEFAULT_WS_PATH, + ) { + this.onToken = handlers.onToken; + this.onThinkingToken = handlers.onThinkingToken; + this.onUpdate = handlers.onUpdate; + this.onSessionId = handlers.onSessionId; + this.onError = handlers.onError; + this.onPipelineState = handlers.onPipelineState; + this.onPermissionRequest = handlers.onPermissionRequest; + this.onActivity = handlers.onActivity; + this.onReconciliationProgress = handlers.onReconciliationProgress; + this.onAgentConfigChanged = handlers.onAgentConfigChanged; + this.onAgentStateChanged = handlers.onAgentStateChanged; + this.onOnboardingStatus = handlers.onOnboardingStatus; + this.onWizardState = handlers.onWizardState; + this.onSideQuestionToken = handlers.onSideQuestionToken; + this.onSideQuestionDone = handlers.onSideQuestionDone; + this.onLogEntry = handlers.onLogEntry; + this.onStatusUpdate = handlers.onStatusUpdate; + this.onConnected = handlers.onConnected; + this.wsPath = wsPath; + this.shouldReconnect = true; + + if (this.connected) { + return; + } + this.connected = true; + ChatWebSocket.refCount += 1; + + if ( + !ChatWebSocket.sharedSocket || + ChatWebSocket.sharedSocket.readyState === WebSocket.CLOSED || + ChatWebSocket.sharedSocket.readyState === WebSocket.CLOSING + ) { + const wsUrl = this._buildWsUrl(); + ChatWebSocket.sharedSocket = new WebSocket(wsUrl); + } + this.socket = ChatWebSocket.sharedSocket; + this._attachHandlers(); + } + + sendChat(messages: Message[], config: ProviderConfig) { + this.send({ type: "chat", messages, config }); + } + + sendSideQuestion( + question: string, + contextMessages: Message[], + config: ProviderConfig, + ) { + this.send({ + type: "side_question", + question, + context_messages: contextMessages, + config, + }); + } + + cancel() { + this.send({ type: "cancel" }); + } + + sendPermissionResponse( + requestId: string, + approved: boolean, + alwaysAllow = false, + ) { + this.send({ + type: "permission_response", + request_id: requestId, + approved, + always_allow: alwaysAllow, + }); + } + + close() { + this.shouldReconnect = false; + this._stopHeartbeat(); + window.clearTimeout(this.reconnectTimer); + this.reconnectTimer = undefined; + + if (!this.connected) return; + this.connected = false; + ChatWebSocket.refCount = Math.max(0, ChatWebSocket.refCount - 1); + + if (import.meta.env.DEV) { + if (this.closeTimer) { + window.clearTimeout(this.closeTimer); + } + this.closeTimer = window.setTimeout(() => { + if (ChatWebSocket.refCount === 0) { + ChatWebSocket.sharedSocket?.close(); + ChatWebSocket.sharedSocket = null; + } + this.socket = ChatWebSocket.sharedSocket ?? undefined; + this.closeTimer = undefined; + }, 250); + return; + } + + if (ChatWebSocket.refCount === 0) { + ChatWebSocket.sharedSocket?.close(); + ChatWebSocket.sharedSocket = null; + } + this.socket = ChatWebSocket.sharedSocket ?? undefined; + } + + private send(payload: WsRequest) { + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + this.onError?.("WebSocket is not connected"); + return; + } + this.socket.send(JSON.stringify(payload)); + } +}