From 73fa2e10bc6238922f43429cf4c6007ea83a6ea5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 3 Jan 2026 18:14:07 +0100 Subject: [PATCH] refactor: split gateway server methods --- CHANGELOG.md | 2 + src/gateway/server-constants.ts | 9 + src/gateway/server-http.ts | 61 +- src/gateway/server-methods.ts | 2921 ++++++++++++++++++++++++++++++ src/gateway/server-providers.ts | 16 +- src/gateway/server-utils.test.ts | 12 +- src/gateway/server.ts | 2900 +---------------------------- src/wizard/onboarding.ts | 4 +- 8 files changed, 3065 insertions(+), 2860 deletions(-) create mode 100644 src/gateway/server-constants.ts create mode 100644 src/gateway/server-methods.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ca756ced0..2951c80d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ - Agent tools: scope the Discord tool to Discord surface runs. - Agent tools: format verbose tool summaries without brackets, with unique emojis and `tool: detail` style. - Gateway: split server helpers/tests into hooks/session-utils/ws-log/net modules for better isolation; add unit coverage for hooks/session utils/ws log. +- Gateway: extract WS method handling + HTTP/provider/constant helpers to shrink server wiring and improve testability. +- Onboarding: fix Control UI basePath usage when showing/opening gateway URLs. - macOS Connections: move to sidebar + detail layout with structured sections and header actions. - macOS onboarding: increase window height so the permissions page fits without scrolling. - Thinking: default to low for reasoning-capable models when no /think or config default is set. diff --git a/src/gateway/server-constants.ts b/src/gateway/server-constants.ts new file mode 100644 index 000000000..b3c7898c2 --- /dev/null +++ b/src/gateway/server-constants.ts @@ -0,0 +1,9 @@ +export const MAX_PAYLOAD_BYTES = 512 * 1024; // cap incoming frame size +export const MAX_BUFFERED_BYTES = 1.5 * 1024 * 1024; // per-connection send buffer limit + +export const MAX_CHAT_HISTORY_MESSAGES_BYTES = 6 * 1024 * 1024; // keep history responses comfortably under client WS limits +export const HANDSHAKE_TIMEOUT_MS = 10_000; +export const TICK_INTERVAL_MS = 30_000; +export const HEALTH_REFRESH_INTERVAL_MS = 60_000; +export const DEDUPE_TTL_MS = 5 * 60_000; +export const DEDUPE_MAX = 1000; diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 55c0b7e34..9979d00c2 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -1,21 +1,31 @@ import { createServer as createHttpServer, - type IncomingMessage, type Server as HttpServer, + type IncomingMessage, type ServerResponse, } from "node:http"; -import { type WebSocketServer } from "ws"; +import type { WebSocketServer } from "ws"; import { handleA2uiHttpRequest } from "../canvas-host/a2ui.js"; import type { CanvasHostHandler } from "../canvas-host/server.js"; -import { type HooksConfigResolved, extractHookToken, normalizeAgentPayload, normalizeHookHeaders, normalizeWakePayload, readJsonBody } from "./hooks.js"; -import { applyHookMappings } from "./hooks-mapping.js"; -import { handleControlUiHttpRequest } from "./control-ui.js"; import type { createSubsystemLogger } from "../logging.js"; +import { handleControlUiHttpRequest } from "./control-ui.js"; +import { + extractHookToken, + type HooksConfigResolved, + normalizeAgentPayload, + normalizeHookHeaders, + normalizeWakePayload, + readJsonBody, +} from "./hooks.js"; +import { applyHookMappings } from "./hooks-mapping.js"; type SubsystemLogger = ReturnType; type HookDispatchers = { - dispatchWakeHook: (value: { text: string; mode: "now" | "next-heartbeat" }) => void; + dispatchWakeHook: (value: { + text: string; + mode: "now" | "next-heartbeat"; + }) => void; dispatchAgentHook: (value: { message: string; name: string; @@ -46,13 +56,22 @@ export type HooksRequestHandler = ( res: ServerResponse, ) => Promise; -export function createHooksRequestHandler(opts: { - hooksConfig: HooksConfigResolved | null; - bindHost: string; - port: number; - logHooks: SubsystemLogger; -} & HookDispatchers): HooksRequestHandler { - const { hooksConfig, bindHost, port, logHooks, dispatchAgentHook, dispatchWakeHook } = opts; +export function createHooksRequestHandler( + opts: { + hooksConfig: HooksConfigResolved | null; + bindHost: string; + port: number; + logHooks: SubsystemLogger; + } & HookDispatchers, +): HooksRequestHandler { + const { + hooksConfig, + bindHost, + port, + logHooks, + dispatchAgentHook, + dispatchWakeHook, + } = opts; return async (req, res) => { if (!hooksConfig) return false; const url = new URL(req.url ?? "/", `http://${bindHost}:${port}`); @@ -97,7 +116,9 @@ export function createHooksRequestHandler(opts: { const headers = normalizeHookHeaders(req); if (subPath === "wake") { - const normalized = normalizeWakePayload(payload as Record); + const normalized = normalizeWakePayload( + payload as Record, + ); if (!normalized.ok) { sendJson(res, 400, { ok: false, error: normalized.error }); return true; @@ -108,7 +129,9 @@ export function createHooksRequestHandler(opts: { } if (subPath === "agent") { - const normalized = normalizeAgentPayload(payload as Record); + const normalized = normalizeAgentPayload( + payload as Record, + ); if (!normalized.ok) { sendJson(res, 400, { ok: false, error: normalized.error }); return true; @@ -178,8 +201,12 @@ export function createGatewayHttpServer(opts: { controlUiBasePath: string; handleHooksRequest: HooksRequestHandler; }): HttpServer { - const { canvasHost, controlUiEnabled, controlUiBasePath, handleHooksRequest } = - opts; + const { + canvasHost, + controlUiEnabled, + controlUiBasePath, + handleHooksRequest, + } = opts; const httpServer: HttpServer = createHttpServer((req, res) => { // Don't interfere with WebSocket upgrades; ws handles the 'upgrade' event. if (String(req.headers.upgrade ?? "").toLowerCase() === "websocket") return; diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts new file mode 100644 index 000000000..08ff66b9f --- /dev/null +++ b/src/gateway/server-methods.ts @@ -0,0 +1,2921 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; + +import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; +import type { ModelCatalogEntry } from "../agents/model-catalog.js"; +import { + buildAllowedModelSet, + buildModelAliasIndex, + modelKey, + resolveConfiguredModelRef, + resolveModelRefFromString, + resolveThinkingDefault, +} from "../agents/model-selection.js"; +import { installSkill } from "../agents/skills-install.js"; +import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; +import { DEFAULT_AGENT_WORKSPACE_DIR } from "../agents/workspace.js"; +import { normalizeGroupActivation } from "../auto-reply/group-activation.js"; +import { + normalizeThinkLevel, + normalizeVerboseLevel, +} from "../auto-reply/thinking.js"; +import type { createDefaultDeps } from "../cli/deps.js"; +import { agentCommand } from "../commands/agent.js"; +import type { HealthSummary } from "../commands/health.js"; +import { getStatusSummary } from "../commands/status.js"; +import type { ClawdisConfig } from "../config/config.js"; +import { + CONFIG_PATH_CLAWDIS, + loadConfig, + parseConfigJson5, + readConfigFileSnapshot, + validateConfigObject, + writeConfigFile, +} from "../config/config.js"; +import { buildConfigSchema } from "../config/schema.js"; +import { + loadSessionStore, + resolveStorePath, + type SessionEntry, + saveSessionStore, +} from "../config/sessions.js"; +import { + readCronRunLogEntries, + resolveCronRunLogPath, +} from "../cron/run-log.js"; +import type { CronService } from "../cron/service.js"; +import type { CronJobCreate, CronJobPatch } from "../cron/types.js"; +import { sendMessageDiscord } from "../discord/index.js"; +import { type DiscordProbe, probeDiscord } from "../discord/probe.js"; +import { shouldLogVerbose } from "../globals.js"; +import { sendMessageIMessage } from "../imessage/index.js"; +import { type IMessageProbe, probeIMessage } from "../imessage/probe.js"; +import type { startNodeBridgeServer } from "../infra/bridge/server.js"; +import { getLastHeartbeatEvent } from "../infra/heartbeat-events.js"; +import { setHeartbeatsEnabled } from "../infra/heartbeat-runner.js"; +import { + approveNodePairing, + listNodePairing, + rejectNodePairing, + renamePairedNode, + requestNodePairing, + verifyNodeToken, +} from "../infra/node-pairing.js"; +import { + enqueueSystemEvent, + isSystemEventContextChanged, +} from "../infra/system-events.js"; +import { + listSystemPresence, + updateSystemPresence, +} from "../infra/system-presence.js"; +import { + loadVoiceWakeConfig, + setVoiceWakeTriggers, +} from "../infra/voicewake.js"; +import { webAuthExists } from "../providers/web/index.js"; +import { defaultRuntime } from "../runtime.js"; +import { sendMessageSignal } from "../signal/index.js"; +import { probeSignal, type SignalProbe } from "../signal/probe.js"; +import { probeTelegram, type TelegramProbe } from "../telegram/probe.js"; +import { sendMessageTelegram } from "../telegram/send.js"; +import { resolveTelegramToken } from "../telegram/token.js"; +import { normalizeE164, resolveUserPath } from "../utils.js"; +import { startWebLoginWithQr, waitForWebLogin } from "../web/login-qr.js"; +import { sendMessageWhatsApp } from "../web/outbound.js"; +import { getWebAuthAgeMs, logoutWeb, readWebSelfId } from "../web/session.js"; +import { WizardSession } from "../wizard/session.js"; +import { buildMessageWithAttachments } from "./chat-attachments.js"; +import { + type ConnectParams, + ErrorCodes, + type ErrorShape, + errorShape, + formatValidationErrors, + type RequestFrame, + type SessionsCompactParams, + type SessionsDeleteParams, + type SessionsListParams, + type SessionsPatchParams, + type SessionsResetParams, + validateAgentParams, + validateChatAbortParams, + validateChatHistoryParams, + validateChatSendParams, + validateConfigGetParams, + validateConfigSchemaParams, + validateConfigSetParams, + validateCronAddParams, + validateCronListParams, + validateCronRemoveParams, + validateCronRunParams, + validateCronRunsParams, + validateCronStatusParams, + validateCronUpdateParams, + validateModelsListParams, + validateNodeDescribeParams, + validateNodeInvokeParams, + validateNodeListParams, + validateNodePairApproveParams, + validateNodePairListParams, + validateNodePairRejectParams, + validateNodePairRequestParams, + validateNodePairVerifyParams, + validateNodeRenameParams, + validateProvidersStatusParams, + validateSendParams, + validateSessionsCompactParams, + validateSessionsDeleteParams, + validateSessionsListParams, + validateSessionsPatchParams, + validateSessionsResetParams, + validateSkillsInstallParams, + validateSkillsStatusParams, + validateSkillsUpdateParams, + validateTalkModeParams, + validateWakeParams, + validateWebLoginStartParams, + validateWebLoginWaitParams, + validateWizardCancelParams, + validateWizardNextParams, + validateWizardStartParams, + validateWizardStatusParams, +} from "./protocol/index.js"; +import { + HEALTH_REFRESH_INTERVAL_MS, + MAX_CHAT_HISTORY_MESSAGES_BYTES, +} from "./server-constants.js"; +import type { ProviderRuntimeSnapshot } from "./server-providers.js"; +import { formatError, normalizeVoiceWakeTriggers } from "./server-utils.js"; +import { + archiveFileOnDisk, + capArrayByJsonBytes, + listSessionsFromStore, + loadSessionEntry, + readSessionMessages, + resolveSessionModelRef, + resolveSessionTranscriptCandidates, + type SessionsPatchResult, +} from "./session-utils.js"; +import { formatForLog } from "./ws-log.js"; + +export type GatewayClient = { + connect: ConnectParams; +}; + +export type RespondFn = ( + ok: boolean, + payload?: unknown, + error?: ErrorShape, + meta?: Record, +) => void; + +type DedupeEntry = { + ts: number; + ok: boolean; + payload?: unknown; + error?: ErrorShape; +}; + +export type GatewayRequestContext = { + deps: ReturnType; + cron: CronService; + cronStorePath: string; + loadGatewayModelCatalog: () => Promise; + getHealthCache: () => HealthSummary | null; + refreshHealthSnapshot: (opts?: { probe?: boolean }) => Promise; + logHealth: { error: (message: string) => void }; + incrementPresenceVersion: () => number; + getHealthVersion: () => number; + broadcast: ( + event: string, + payload: unknown, + opts?: { + dropIfSlow?: boolean; + stateVersion?: { presence?: number; health?: number }; + }, + ) => void; + bridge: Awaited> | null; + bridgeSendToSession: ( + sessionKey: string, + event: string, + payload: unknown, + ) => void; + hasConnectedMobileNode: () => boolean; + agentRunSeq: Map; + chatAbortControllers: Map< + string, + { controller: AbortController; sessionId: string; sessionKey: string } + >; + chatRunBuffers: Map; + chatDeltaSentAt: Map; + addChatRun: ( + sessionId: string, + entry: { sessionKey: string; clientRunId: string }, + ) => void; + removeChatRun: ( + sessionId: string, + clientRunId: string, + sessionKey?: string, + ) => { sessionKey: string; clientRunId: string } | undefined; + dedupe: Map; + wizardSessions: Map; + findRunningWizard: () => string | null; + purgeWizardSession: (id: string) => void; + getRuntimeSnapshot: () => ProviderRuntimeSnapshot; + startWhatsAppProvider: () => Promise; + stopWhatsAppProvider: () => Promise; + stopTelegramProvider: () => Promise; + markWhatsAppLoggedOut: (cleared: boolean) => void; + wizardRunner: ( + opts: import("../commands/onboard-types.js").OnboardOptions, + runtime: import("../runtime.js").RuntimeEnv, + prompter: import("../wizard/prompts.js").WizardPrompter, + ) => Promise; + broadcastVoiceWakeChanged: (triggers: string[]) => void; +}; + +export type GatewayRequestOptions = { + req: RequestFrame; + client: GatewayClient | null; + isWebchatConnect: (params: ConnectParams | null | undefined) => boolean; + respond: RespondFn; + context: GatewayRequestContext; +}; + +export async function handleGatewayRequest( + opts: GatewayRequestOptions, +): Promise { + const { req, respond, client, isWebchatConnect, context } = opts; + const { + deps, + cron, + cronStorePath, + loadGatewayModelCatalog, + getHealthCache, + refreshHealthSnapshot, + logHealth, + incrementPresenceVersion, + getHealthVersion, + broadcast, + bridge, + bridgeSendToSession, + hasConnectedMobileNode, + agentRunSeq, + chatAbortControllers, + chatRunBuffers, + chatDeltaSentAt, + addChatRun, + removeChatRun, + dedupe, + wizardSessions, + findRunningWizard, + purgeWizardSession, + getRuntimeSnapshot, + startWhatsAppProvider, + stopWhatsAppProvider, + stopTelegramProvider, + markWhatsAppLoggedOut, + wizardRunner, + broadcastVoiceWakeChanged, + } = context; + + switch (req.method) { + case "connect": { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "connect is only valid as the first request", + ), + ); + break; + } + case "voicewake.get": { + try { + const cfg = await loadVoiceWakeConfig(); + respond(true, { triggers: cfg.triggers }); + } catch (err) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), + ); + } + break; + } + case "voicewake.set": { + const params = (req.params ?? {}) as Record; + if (!Array.isArray(params.triggers)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "voicewake.set requires triggers: string[]", + ), + ); + break; + } + try { + const triggers = normalizeVoiceWakeTriggers(params.triggers); + const cfg = await setVoiceWakeTriggers(triggers); + broadcastVoiceWakeChanged(cfg.triggers); + respond(true, { triggers: cfg.triggers }); + } catch (err) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), + ); + } + break; + } + case "health": { + const now = Date.now(); + const cached = getHealthCache(); + if (cached && now - cached.ts < HEALTH_REFRESH_INTERVAL_MS) { + respond(true, cached, undefined, { cached: true }); + void refreshHealthSnapshot({ probe: false }).catch((err) => + logHealth.error( + `background health refresh failed: ${formatError(err)}`, + ), + ); + break; + } + try { + const snap = await refreshHealthSnapshot({ probe: false }); + respond(true, snap, undefined); + } catch (err) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), + ); + } + break; + } + case "providers.status": { + const params = (req.params ?? {}) as Record; + if (!validateProvidersStatusParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid providers.status params: ${formatValidationErrors(validateProvidersStatusParams.errors)}`, + ), + ); + break; + } + const probe = (params as { probe?: boolean }).probe === true; + const timeoutMsRaw = (params as { timeoutMs?: unknown }).timeoutMs; + const timeoutMs = + typeof timeoutMsRaw === "number" + ? Math.max(1000, timeoutMsRaw) + : 10_000; + const cfg = loadConfig(); + const telegramCfg = cfg.telegram; + const telegramEnabled = + Boolean(telegramCfg) && telegramCfg?.enabled !== false; + const { token: telegramToken, source: tokenSource } = telegramEnabled + ? resolveTelegramToken(cfg) + : { token: "", source: "none" as const }; + let telegramProbe: TelegramProbe | undefined; + let lastProbeAt: number | null = null; + if (probe && telegramToken && telegramEnabled) { + telegramProbe = await probeTelegram( + telegramToken, + timeoutMs, + telegramCfg?.proxy, + ); + lastProbeAt = Date.now(); + } + + const discordCfg = cfg.discord; + const discordEnabled = + Boolean(discordCfg) && discordCfg?.enabled !== false; + const discordEnvToken = discordEnabled + ? process.env.DISCORD_BOT_TOKEN?.trim() + : ""; + const discordConfigToken = discordEnabled + ? discordCfg?.token?.trim() + : ""; + const discordToken = discordEnvToken || discordConfigToken || ""; + const discordTokenSource = discordEnvToken + ? "env" + : discordConfigToken + ? "config" + : "none"; + let discordProbe: DiscordProbe | undefined; + let discordLastProbeAt: number | null = null; + if (probe && discordToken && discordEnabled) { + discordProbe = await probeDiscord(discordToken, timeoutMs); + discordLastProbeAt = Date.now(); + } + + const signalCfg = cfg.signal; + const signalEnabled = signalCfg?.enabled !== false; + const signalHost = signalCfg?.httpHost?.trim() || "127.0.0.1"; + const signalPort = signalCfg?.httpPort ?? 8080; + const signalBaseUrl = + signalCfg?.httpUrl?.trim() || `http://${signalHost}:${signalPort}`; + const signalConfigured = + Boolean(signalCfg) && + signalEnabled && + Boolean( + signalCfg?.account?.trim() || + signalCfg?.httpUrl?.trim() || + signalCfg?.cliPath?.trim() || + signalCfg?.httpHost?.trim() || + typeof signalCfg?.httpPort === "number" || + typeof signalCfg?.autoStart === "boolean", + ); + let signalProbe: SignalProbe | undefined; + let signalLastProbeAt: number | null = null; + if (probe && signalConfigured) { + signalProbe = await probeSignal(signalBaseUrl, timeoutMs); + signalLastProbeAt = Date.now(); + } + + const imessageCfg = cfg.imessage; + const imessageEnabled = imessageCfg?.enabled !== false; + const imessageConfigured = Boolean(imessageCfg) && imessageEnabled; + let imessageProbe: IMessageProbe | undefined; + let imessageLastProbeAt: number | null = null; + if (probe && imessageConfigured) { + imessageProbe = await probeIMessage(timeoutMs); + imessageLastProbeAt = Date.now(); + } + + const linked = await webAuthExists(); + const authAgeMs = getWebAuthAgeMs(); + const self = readWebSelfId(); + const runtime = getRuntimeSnapshot(); + + respond( + true, + { + ts: Date.now(), + whatsapp: { + configured: linked, + linked, + authAgeMs, + self, + running: runtime.whatsapp.running, + connected: runtime.whatsapp.connected, + lastConnectedAt: runtime.whatsapp.lastConnectedAt ?? null, + lastDisconnect: runtime.whatsapp.lastDisconnect ?? null, + reconnectAttempts: runtime.whatsapp.reconnectAttempts, + lastMessageAt: runtime.whatsapp.lastMessageAt ?? null, + lastEventAt: runtime.whatsapp.lastEventAt ?? null, + lastError: runtime.whatsapp.lastError ?? null, + }, + telegram: { + configured: telegramEnabled && Boolean(telegramToken), + tokenSource, + running: runtime.telegram.running, + mode: runtime.telegram.mode ?? null, + lastStartAt: runtime.telegram.lastStartAt ?? null, + lastStopAt: runtime.telegram.lastStopAt ?? null, + lastError: runtime.telegram.lastError ?? null, + probe: telegramProbe, + lastProbeAt, + }, + discord: { + configured: discordEnabled && Boolean(discordToken), + tokenSource: discordTokenSource, + running: runtime.discord.running, + lastStartAt: runtime.discord.lastStartAt ?? null, + lastStopAt: runtime.discord.lastStopAt ?? null, + lastError: runtime.discord.lastError ?? null, + probe: discordProbe, + lastProbeAt: discordLastProbeAt, + }, + signal: { + configured: signalConfigured, + baseUrl: signalBaseUrl, + running: runtime.signal.running, + lastStartAt: runtime.signal.lastStartAt ?? null, + lastStopAt: runtime.signal.lastStopAt ?? null, + lastError: runtime.signal.lastError ?? null, + probe: signalProbe, + lastProbeAt: signalLastProbeAt, + }, + imessage: { + configured: imessageConfigured, + running: runtime.imessage.running, + lastStartAt: runtime.imessage.lastStartAt ?? null, + lastStopAt: runtime.imessage.lastStopAt ?? null, + lastError: runtime.imessage.lastError ?? null, + cliPath: runtime.imessage.cliPath ?? null, + dbPath: runtime.imessage.dbPath ?? null, + probe: imessageProbe, + lastProbeAt: imessageLastProbeAt, + }, + }, + undefined, + ); + break; + } + case "chat.history": { + const params = (req.params ?? {}) as Record; + if (!validateChatHistoryParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid chat.history params: ${formatValidationErrors(validateChatHistoryParams.errors)}`, + ), + ); + break; + } + const { sessionKey, limit } = params as { + sessionKey: string; + limit?: number; + }; + const { cfg, storePath, entry } = loadSessionEntry(sessionKey); + const sessionId = entry?.sessionId; + const rawMessages = + sessionId && storePath ? readSessionMessages(sessionId, storePath) : []; + const hardMax = 1000; + const defaultLimit = 200; + const requested = typeof limit === "number" ? limit : defaultLimit; + const max = Math.min(hardMax, requested); + const sliced = + rawMessages.length > max ? rawMessages.slice(-max) : rawMessages; + const capped = capArrayByJsonBytes( + sliced, + MAX_CHAT_HISTORY_MESSAGES_BYTES, + ).items; + let thinkingLevel = entry?.thinkingLevel; + if (!thinkingLevel) { + const configured = cfg.agent?.thinkingDefault; + if (configured) { + thinkingLevel = configured; + } else { + const { provider, model } = resolveSessionModelRef(cfg, entry); + const catalog = await loadGatewayModelCatalog(); + thinkingLevel = resolveThinkingDefault({ + cfg, + provider, + model, + catalog, + }); + } + } + respond(true, { + sessionKey, + sessionId, + messages: capped, + thinkingLevel, + }); + break; + } + case "chat.abort": { + const params = (req.params ?? {}) as Record; + if (!validateChatAbortParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid chat.abort params: ${formatValidationErrors(validateChatAbortParams.errors)}`, + ), + ); + break; + } + const { sessionKey, runId } = params as { + sessionKey: string; + runId: string; + }; + const active = chatAbortControllers.get(runId); + if (!active) { + respond(true, { ok: true, aborted: false }); + break; + } + if (active.sessionKey !== sessionKey) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "runId does not match sessionKey", + ), + ); + break; + } + + active.controller.abort(); + chatAbortControllers.delete(runId); + chatRunBuffers.delete(runId); + chatDeltaSentAt.delete(runId); + removeChatRun(active.sessionId, runId, sessionKey); + + const payload = { + runId, + sessionKey, + seq: (agentRunSeq.get(active.sessionId) ?? 0) + 1, + state: "aborted" as const, + }; + broadcast("chat", payload); + bridgeSendToSession(sessionKey, "chat", payload); + respond(true, { ok: true, aborted: true }); + break; + } + case "chat.send": { + if ( + client && + isWebchatConnect(client.connect) && + !hasConnectedMobileNode() + ) { + respond( + false, + undefined, + errorShape( + ErrorCodes.UNAVAILABLE, + "web chat disabled: no connected iOS/Android nodes", + ), + ); + break; + } + const params = (req.params ?? {}) as Record; + if (!validateChatSendParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid chat.send params: ${formatValidationErrors(validateChatSendParams.errors)}`, + ), + ); + break; + } + const p = params as { + sessionKey: string; + message: string; + thinking?: string; + deliver?: boolean; + attachments?: Array<{ + type?: string; + mimeType?: string; + fileName?: string; + content?: unknown; + }>; + timeoutMs?: number; + idempotencyKey: string; + }; + const timeoutMs = Math.min(Math.max(p.timeoutMs ?? 30_000, 0), 30_000); + const normalizedAttachments = + p.attachments?.map((a) => ({ + type: typeof a?.type === "string" ? a.type : undefined, + mimeType: typeof a?.mimeType === "string" ? a.mimeType : undefined, + fileName: typeof a?.fileName === "string" ? a.fileName : undefined, + content: + typeof a?.content === "string" + ? a.content + : ArrayBuffer.isView(a?.content) + ? Buffer.from( + a.content.buffer, + a.content.byteOffset, + a.content.byteLength, + ).toString("base64") + : undefined, + })) ?? []; + let messageWithAttachments = p.message; + if (normalizedAttachments.length > 0) { + try { + messageWithAttachments = buildMessageWithAttachments( + p.message, + normalizedAttachments, + { maxBytes: 5_000_000 }, + ); + } catch (err) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, String(err)), + ); + break; + } + } + const { storePath, store, entry } = loadSessionEntry(p.sessionKey); + const now = Date.now(); + const sessionId = entry?.sessionId ?? randomUUID(); + const sessionEntry: SessionEntry = { + sessionId, + updatedAt: now, + thinkingLevel: entry?.thinkingLevel, + verboseLevel: entry?.verboseLevel, + systemSent: entry?.systemSent, + lastChannel: entry?.lastChannel, + lastTo: entry?.lastTo, + }; + const clientRunId = p.idempotencyKey; + + const cached = dedupe.get(`chat:${clientRunId}`); + if (cached) { + respond(cached.ok, cached.payload, cached.error, { + cached: true, + }); + break; + } + + try { + const abortController = new AbortController(); + chatAbortControllers.set(clientRunId, { + controller: abortController, + sessionId, + sessionKey: p.sessionKey, + }); + addChatRun(sessionId, { + sessionKey: p.sessionKey, + clientRunId, + }); + + if (store) { + store[p.sessionKey] = sessionEntry; + if (storePath) { + await saveSessionStore(storePath, store); + } + } + + await agentCommand( + { + message: messageWithAttachments, + sessionId, + thinking: p.thinking, + deliver: p.deliver, + timeout: Math.ceil(timeoutMs / 1000).toString(), + surface: "WebChat", + abortSignal: abortController.signal, + }, + defaultRuntime, + deps, + ); + const payload = { + runId: clientRunId, + status: "ok" as const, + }; + dedupe.set(`chat:${clientRunId}`, { + ts: Date.now(), + ok: true, + payload, + }); + respond(true, payload, undefined, { runId: clientRunId }); + } catch (err) { + const error = errorShape(ErrorCodes.UNAVAILABLE, String(err)); + const payload = { + runId: clientRunId, + status: "error" as const, + summary: String(err), + }; + dedupe.set(`chat:${clientRunId}`, { + ts: Date.now(), + ok: false, + payload, + error, + }); + respond(false, payload, error, { + runId: clientRunId, + error: formatForLog(err), + }); + } finally { + chatAbortControllers.delete(clientRunId); + } + break; + } + case "wake": { + const params = (req.params ?? {}) as Record; + if (!validateWakeParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid wake params: ${formatValidationErrors(validateWakeParams.errors)}`, + ), + ); + break; + } + const p = params as { + mode: "now" | "next-heartbeat"; + text: string; + }; + const result = cron.wake({ mode: p.mode, text: p.text }); + respond(true, result, undefined); + break; + } + case "cron.list": { + const params = (req.params ?? {}) as Record; + if (!validateCronListParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid cron.list params: ${formatValidationErrors(validateCronListParams.errors)}`, + ), + ); + break; + } + const p = params as { includeDisabled?: boolean }; + const jobs = await cron.list({ + includeDisabled: p.includeDisabled, + }); + respond(true, { jobs }, undefined); + break; + } + case "cron.status": { + const params = (req.params ?? {}) as Record; + if (!validateCronStatusParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid cron.status params: ${formatValidationErrors(validateCronStatusParams.errors)}`, + ), + ); + break; + } + const status = await cron.status(); + respond(true, status, undefined); + break; + } + case "cron.add": { + const params = (req.params ?? {}) as Record; + if (!validateCronAddParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid cron.add params: ${formatValidationErrors(validateCronAddParams.errors)}`, + ), + ); + break; + } + const job = await cron.add(params as unknown as CronJobCreate); + respond(true, job, undefined); + break; + } + case "cron.update": { + const params = (req.params ?? {}) as Record; + if (!validateCronUpdateParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid cron.update params: ${formatValidationErrors(validateCronUpdateParams.errors)}`, + ), + ); + break; + } + const p = params as { + id: string; + patch: Record; + }; + const job = await cron.update(p.id, p.patch as unknown as CronJobPatch); + respond(true, job, undefined); + break; + } + case "cron.remove": { + const params = (req.params ?? {}) as Record; + if (!validateCronRemoveParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid cron.remove params: ${formatValidationErrors(validateCronRemoveParams.errors)}`, + ), + ); + break; + } + const p = params as { id: string }; + const result = await cron.remove(p.id); + respond(true, result, undefined); + break; + } + case "cron.run": { + const params = (req.params ?? {}) as Record; + if (!validateCronRunParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid cron.run params: ${formatValidationErrors(validateCronRunParams.errors)}`, + ), + ); + break; + } + const p = params as { id: string; mode?: "due" | "force" }; + const result = await cron.run(p.id, p.mode); + respond(true, result, undefined); + break; + } + case "cron.runs": { + const params = (req.params ?? {}) as Record; + if (!validateCronRunsParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid cron.runs params: ${formatValidationErrors(validateCronRunsParams.errors)}`, + ), + ); + break; + } + const p = params as { id: string; limit?: number }; + const logPath = resolveCronRunLogPath({ + storePath: cronStorePath, + jobId: p.id, + }); + const entries = await readCronRunLogEntries(logPath, { + limit: p.limit, + jobId: p.id, + }); + respond(true, { entries }, undefined); + break; + } + case "status": { + const status = await getStatusSummary(); + respond(true, status, undefined); + break; + } + case "web.login.start": { + const params = (req.params ?? {}) as Record; + if (!validateWebLoginStartParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid web.login.start params: ${formatValidationErrors(validateWebLoginStartParams.errors)}`, + ), + ); + break; + } + try { + await stopWhatsAppProvider(); + const result = await startWebLoginWithQr({ + force: Boolean((params as { force?: boolean }).force), + timeoutMs: + typeof (params as { timeoutMs?: unknown }).timeoutMs === "number" + ? (params as { timeoutMs?: number }).timeoutMs + : undefined, + verbose: Boolean((params as { verbose?: boolean }).verbose), + }); + respond(true, result, undefined); + } catch (err) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), + ); + } + break; + } + case "web.login.wait": { + const params = (req.params ?? {}) as Record; + if (!validateWebLoginWaitParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid web.login.wait params: ${formatValidationErrors(validateWebLoginWaitParams.errors)}`, + ), + ); + break; + } + try { + const result = await waitForWebLogin({ + timeoutMs: + typeof (params as { timeoutMs?: unknown }).timeoutMs === "number" + ? (params as { timeoutMs?: number }).timeoutMs + : undefined, + }); + if (result.connected) { + await startWhatsAppProvider(); + } + respond(true, result, undefined); + } catch (err) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), + ); + } + break; + } + case "web.logout": { + try { + await stopWhatsAppProvider(); + const cleared = await logoutWeb(defaultRuntime); + markWhatsAppLoggedOut(cleared); + respond(true, { cleared }, undefined); + } catch (err) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), + ); + } + break; + } + case "telegram.logout": { + try { + await stopTelegramProvider(); + const snapshot = await readConfigFileSnapshot(); + if (!snapshot.valid) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "config invalid; fix it before logging out", + ), + ); + break; + } + const cfg = snapshot.config ?? {}; + const envToken = process.env.TELEGRAM_BOT_TOKEN?.trim() ?? ""; + const hadToken = Boolean(cfg.telegram?.botToken); + const nextTelegram = cfg.telegram ? { ...cfg.telegram } : undefined; + if (nextTelegram) { + delete nextTelegram.botToken; + } + const nextCfg = { ...cfg } as ClawdisConfig; + if (nextTelegram && Object.keys(nextTelegram).length > 0) { + nextCfg.telegram = nextTelegram; + } else { + delete nextCfg.telegram; + } + await writeConfigFile(nextCfg); + respond( + true, + { cleared: hadToken, envToken: Boolean(envToken) }, + undefined, + ); + } catch (err) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), + ); + } + break; + } + case "models.list": { + const params = (req.params ?? {}) as Record; + if (!validateModelsListParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid models.list params: ${formatValidationErrors(validateModelsListParams.errors)}`, + ), + ); + break; + } + try { + const models = await loadGatewayModelCatalog(); + respond(true, { models }, undefined); + } catch (err) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, String(err)), + ); + } + break; + } + case "config.get": { + const params = (req.params ?? {}) as Record; + if (!validateConfigGetParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid config.get params: ${formatValidationErrors(validateConfigGetParams.errors)}`, + ), + ); + break; + } + const snapshot = await readConfigFileSnapshot(); + respond(true, snapshot, undefined); + break; + } + case "config.schema": { + const params = (req.params ?? {}) as Record; + if (!validateConfigSchemaParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid config.schema params: ${formatValidationErrors(validateConfigSchemaParams.errors)}`, + ), + ); + break; + } + const schema = buildConfigSchema(); + respond(true, schema, undefined); + break; + } + case "config.set": { + const params = (req.params ?? {}) as Record; + if (!validateConfigSetParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid config.set params: ${formatValidationErrors(validateConfigSetParams.errors)}`, + ), + ); + break; + } + const rawValue = (params as { raw?: unknown }).raw; + if (typeof rawValue !== "string") { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "invalid config.set params: raw (string) required", + ), + ); + break; + } + const parsedRes = parseConfigJson5(rawValue); + if (!parsedRes.ok) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, parsedRes.error), + ); + break; + } + const validated = validateConfigObject(parsedRes.parsed); + if (!validated.ok) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "invalid config", { + details: { issues: validated.issues }, + }), + ); + break; + } + await writeConfigFile(validated.config); + respond( + true, + { + ok: true, + path: CONFIG_PATH_CLAWDIS, + config: validated.config, + }, + undefined, + ); + break; + } + case "wizard.start": { + const params = (req.params ?? {}) as Record; + if (!validateWizardStartParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid wizard.start params: ${formatValidationErrors(validateWizardStartParams.errors)}`, + ), + ); + break; + } + const running = findRunningWizard(); + if (running) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, "wizard already running"), + ); + break; + } + const sessionId = randomUUID(); + const opts = { + mode: params.mode as "local" | "remote" | undefined, + workspace: + typeof params.workspace === "string" ? params.workspace : undefined, + }; + const session = new WizardSession((prompter) => + wizardRunner(opts, defaultRuntime, prompter), + ); + wizardSessions.set(sessionId, session); + const result = await session.next(); + if (result.done) { + purgeWizardSession(sessionId); + } + respond(true, { sessionId, ...result }, undefined); + break; + } + case "wizard.next": { + const params = (req.params ?? {}) as Record; + if (!validateWizardNextParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid wizard.next params: ${formatValidationErrors(validateWizardNextParams.errors)}`, + ), + ); + break; + } + const sessionId = params.sessionId as string; + const session = wizardSessions.get(sessionId); + if (!session) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "wizard not found"), + ); + break; + } + const answer = params.answer as + | { stepId?: string; value?: unknown } + | undefined; + if (answer) { + if (session.getStatus() !== "running") { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "wizard not running"), + ); + break; + } + try { + await session.answer(String(answer.stepId ?? ""), answer.value); + } catch (err) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, formatForLog(err)), + ); + break; + } + } + const result = await session.next(); + if (result.done) { + purgeWizardSession(sessionId); + } + respond(true, result, undefined); + break; + } + case "wizard.cancel": { + const params = (req.params ?? {}) as Record; + if (!validateWizardCancelParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid wizard.cancel params: ${formatValidationErrors(validateWizardCancelParams.errors)}`, + ), + ); + break; + } + const sessionId = params.sessionId as string; + const session = wizardSessions.get(sessionId); + if (!session) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "wizard not found"), + ); + break; + } + session.cancel(); + const status = { + status: session.getStatus(), + error: session.getError(), + }; + wizardSessions.delete(sessionId); + respond(true, status, undefined); + break; + } + case "wizard.status": { + const params = (req.params ?? {}) as Record; + if (!validateWizardStatusParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid wizard.status params: ${formatValidationErrors(validateWizardStatusParams.errors)}`, + ), + ); + break; + } + const sessionId = params.sessionId as string; + const session = wizardSessions.get(sessionId); + if (!session) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "wizard not found"), + ); + break; + } + const status = { + status: session.getStatus(), + error: session.getError(), + }; + if (status.status !== "running") { + wizardSessions.delete(sessionId); + } + respond(true, status, undefined); + break; + } + case "talk.mode": { + if ( + client && + isWebchatConnect(client.connect) && + !hasConnectedMobileNode() + ) { + respond( + false, + undefined, + errorShape( + ErrorCodes.UNAVAILABLE, + "talk disabled: no connected iOS/Android nodes", + ), + ); + break; + } + const params = (req.params ?? {}) as Record; + if (!validateTalkModeParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid talk.mode params: ${formatValidationErrors(validateTalkModeParams.errors)}`, + ), + ); + break; + } + const payload = { + enabled: (params as { enabled: boolean }).enabled, + phase: (params as { phase?: string }).phase ?? null, + ts: Date.now(), + }; + broadcast("talk.mode", payload, { dropIfSlow: true }); + respond(true, payload, undefined); + break; + } + case "skills.status": { + const params = (req.params ?? {}) as Record; + if (!validateSkillsStatusParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid skills.status params: ${formatValidationErrors(validateSkillsStatusParams.errors)}`, + ), + ); + break; + } + const cfg = loadConfig(); + const workspaceDirRaw = + cfg.agent?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; + const workspaceDir = resolveUserPath(workspaceDirRaw); + const report = buildWorkspaceSkillStatus(workspaceDir, { + config: cfg, + }); + respond(true, report, undefined); + break; + } + case "skills.install": { + const params = (req.params ?? {}) as Record; + if (!validateSkillsInstallParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid skills.install params: ${formatValidationErrors(validateSkillsInstallParams.errors)}`, + ), + ); + break; + } + const p = params as { + name: string; + installId: string; + timeoutMs?: number; + }; + const cfg = loadConfig(); + const workspaceDirRaw = + cfg.agent?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; + const result = await installSkill({ + workspaceDir: workspaceDirRaw, + skillName: p.name, + installId: p.installId, + timeoutMs: p.timeoutMs, + config: cfg, + }); + respond( + result.ok, + result, + result.ok + ? undefined + : errorShape(ErrorCodes.UNAVAILABLE, result.message), + ); + break; + } + case "skills.update": { + const params = (req.params ?? {}) as Record; + if (!validateSkillsUpdateParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid skills.update params: ${formatValidationErrors(validateSkillsUpdateParams.errors)}`, + ), + ); + break; + } + const p = params as { + skillKey: string; + enabled?: boolean; + apiKey?: string; + env?: Record; + }; + const cfg = loadConfig(); + const skills = cfg.skills ? { ...cfg.skills } : {}; + const entries = skills.entries ? { ...skills.entries } : {}; + const current = entries[p.skillKey] ? { ...entries[p.skillKey] } : {}; + if (typeof p.enabled === "boolean") { + current.enabled = p.enabled; + } + if (typeof p.apiKey === "string") { + const trimmed = p.apiKey.trim(); + if (trimmed) current.apiKey = trimmed; + else delete current.apiKey; + } + if (p.env && typeof p.env === "object") { + const nextEnv = current.env ? { ...current.env } : {}; + for (const [key, value] of Object.entries(p.env)) { + const trimmedKey = key.trim(); + if (!trimmedKey) continue; + const trimmedVal = value.trim(); + if (!trimmedVal) delete nextEnv[trimmedKey]; + else nextEnv[trimmedKey] = trimmedVal; + } + current.env = nextEnv; + } + entries[p.skillKey] = current; + skills.entries = entries; + const nextConfig: ClawdisConfig = { + ...cfg, + skills, + }; + await writeConfigFile(nextConfig); + respond( + true, + { ok: true, skillKey: p.skillKey, config: current }, + undefined, + ); + break; + } + case "sessions.list": { + const params = (req.params ?? {}) as Record; + if (!validateSessionsListParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid sessions.list params: ${formatValidationErrors(validateSessionsListParams.errors)}`, + ), + ); + break; + } + const p = params as SessionsListParams; + const cfg = loadConfig(); + const storePath = resolveStorePath(cfg.session?.store); + const store = loadSessionStore(storePath); + const result = listSessionsFromStore({ + cfg, + storePath, + store, + opts: p, + }); + respond(true, result, undefined); + break; + } + case "sessions.patch": { + const params = (req.params ?? {}) as Record; + if (!validateSessionsPatchParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid sessions.patch params: ${formatValidationErrors(validateSessionsPatchParams.errors)}`, + ), + ); + break; + } + const p = params as SessionsPatchParams; + const key = String(p.key ?? "").trim(); + if (!key) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "key required"), + ); + break; + } + + const cfg = loadConfig(); + const storePath = resolveStorePath(cfg.session?.store); + const store = loadSessionStore(storePath); + const now = Date.now(); + + const existing = store[key]; + const next: SessionEntry = existing + ? { + ...existing, + updatedAt: Math.max(existing.updatedAt ?? 0, now), + } + : { sessionId: randomUUID(), updatedAt: now }; + + if ("thinkingLevel" in p) { + const raw = p.thinkingLevel; + if (raw === null) { + delete next.thinkingLevel; + } else if (raw !== undefined) { + const normalized = normalizeThinkLevel(String(raw)); + if (!normalized) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "invalid thinkingLevel (use off|minimal|low|medium|high)", + ), + ); + break; + } + if (normalized === "off") delete next.thinkingLevel; + else next.thinkingLevel = normalized; + } + } + + if ("verboseLevel" in p) { + const raw = p.verboseLevel; + if (raw === null) { + delete next.verboseLevel; + } else if (raw !== undefined) { + const normalized = normalizeVerboseLevel(String(raw)); + if (!normalized) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + 'invalid verboseLevel (use "on"|"off")', + ), + ); + break; + } + if (normalized === "off") delete next.verboseLevel; + else next.verboseLevel = normalized; + } + } + + if ("model" in p) { + const raw = p.model; + if (raw === null) { + delete next.providerOverride; + delete next.modelOverride; + } else if (raw !== undefined) { + const trimmed = String(raw).trim(); + if (!trimmed) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "invalid model: empty"), + ); + break; + } + const resolvedDefault = resolveConfiguredModelRef({ + cfg, + defaultProvider: DEFAULT_PROVIDER, + defaultModel: DEFAULT_MODEL, + }); + const aliasIndex = buildModelAliasIndex({ + cfg, + defaultProvider: resolvedDefault.provider, + }); + const resolved = resolveModelRefFromString({ + raw: trimmed, + defaultProvider: resolvedDefault.provider, + aliasIndex, + }); + if (!resolved) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid model: ${trimmed}`, + ), + ); + break; + } + const catalog = await loadGatewayModelCatalog(); + const allowed = buildAllowedModelSet({ + cfg, + catalog, + defaultProvider: resolvedDefault.provider, + }); + const key = modelKey(resolved.ref.provider, resolved.ref.model); + if (!allowed.allowAny && !allowed.allowedKeys.has(key)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `model not allowed: ${key}`, + ), + ); + break; + } + if ( + resolved.ref.provider === resolvedDefault.provider && + resolved.ref.model === resolvedDefault.model + ) { + delete next.providerOverride; + delete next.modelOverride; + } else { + next.providerOverride = resolved.ref.provider; + next.modelOverride = resolved.ref.model; + } + } + } + + if ("groupActivation" in p) { + const raw = p.groupActivation; + if (raw === null) { + delete next.groupActivation; + } else if (raw !== undefined) { + const normalized = normalizeGroupActivation(String(raw)); + if (!normalized) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + 'invalid groupActivation (use "mention"|"always")', + ), + ); + break; + } + next.groupActivation = normalized; + } + } + + store[key] = next; + await saveSessionStore(storePath, store); + const result: SessionsPatchResult = { + ok: true, + path: storePath, + key, + entry: next, + }; + respond(true, result, undefined); + break; + } + case "sessions.reset": { + const params = (req.params ?? {}) as Record; + if (!validateSessionsResetParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid sessions.reset params: ${formatValidationErrors(validateSessionsResetParams.errors)}`, + ), + ); + break; + } + const p = params as SessionsResetParams; + const key = String(p.key ?? "").trim(); + if (!key) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "key required"), + ); + break; + } + + const { storePath, store, entry } = loadSessionEntry(key); + const now = Date.now(); + const next: SessionEntry = { + sessionId: randomUUID(), + updatedAt: now, + systemSent: false, + abortedLastRun: false, + thinkingLevel: entry?.thinkingLevel, + verboseLevel: entry?.verboseLevel, + model: entry?.model, + contextTokens: entry?.contextTokens, + lastChannel: entry?.lastChannel, + lastTo: entry?.lastTo, + skillsSnapshot: entry?.skillsSnapshot, + }; + store[key] = next; + await saveSessionStore(storePath, store); + respond(true, { ok: true, key, entry: next }, undefined); + break; + } + case "sessions.delete": { + const params = (req.params ?? {}) as Record; + if (!validateSessionsDeleteParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid sessions.delete params: ${formatValidationErrors(validateSessionsDeleteParams.errors)}`, + ), + ); + break; + } + const p = params as SessionsDeleteParams; + const key = String(p.key ?? "").trim(); + if (!key) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "key required"), + ); + break; + } + + const deleteTranscript = + typeof p.deleteTranscript === "boolean" ? p.deleteTranscript : true; + + const { storePath, store, entry } = loadSessionEntry(key); + const sessionId = entry?.sessionId; + const existed = Boolean(store[key]); + if (existed) delete store[key]; + await saveSessionStore(storePath, store); + + const archived: string[] = []; + if (deleteTranscript && sessionId) { + for (const candidate of resolveSessionTranscriptCandidates( + sessionId, + storePath, + )) { + if (!fs.existsSync(candidate)) continue; + try { + archived.push(archiveFileOnDisk(candidate, "deleted")); + } catch { + // Best-effort. + } + } + } + + respond(true, { ok: true, key, deleted: existed, archived }, undefined); + break; + } + case "sessions.compact": { + const params = (req.params ?? {}) as Record; + if (!validateSessionsCompactParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid sessions.compact params: ${formatValidationErrors(validateSessionsCompactParams.errors)}`, + ), + ); + break; + } + const p = params as SessionsCompactParams; + const key = String(p.key ?? "").trim(); + if (!key) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "key required"), + ); + break; + } + + const maxLines = + typeof p.maxLines === "number" && Number.isFinite(p.maxLines) + ? Math.max(1, Math.floor(p.maxLines)) + : 400; + + const { storePath, store, entry } = loadSessionEntry(key); + const sessionId = entry?.sessionId; + if (!sessionId) { + respond( + true, + { ok: true, key, compacted: false, reason: "no sessionId" }, + undefined, + ); + break; + } + + const filePath = resolveSessionTranscriptCandidates( + sessionId, + storePath, + ).find((candidate) => fs.existsSync(candidate)); + if (!filePath) { + respond( + true, + { ok: true, key, compacted: false, reason: "no transcript" }, + undefined, + ); + break; + } + + const raw = fs.readFileSync(filePath, "utf-8"); + const lines = raw.split(/\r?\n/).filter((l) => l.trim().length > 0); + if (lines.length <= maxLines) { + respond( + true, + { ok: true, key, compacted: false, kept: lines.length }, + undefined, + ); + break; + } + + const archived = archiveFileOnDisk(filePath, "bak"); + const keptLines = lines.slice(-maxLines); + fs.writeFileSync(filePath, `${keptLines.join("\n")}\n`, "utf-8"); + + if (store[key]) { + delete store[key].inputTokens; + delete store[key].outputTokens; + delete store[key].totalTokens; + store[key].updatedAt = Date.now(); + await saveSessionStore(storePath, store); + } + + respond( + true, + { + ok: true, + key, + compacted: true, + archived, + kept: keptLines.length, + }, + undefined, + ); + break; + } + case "last-heartbeat": { + respond(true, getLastHeartbeatEvent(), undefined); + break; + } + case "set-heartbeats": { + const params = (req.params ?? {}) as Record; + const enabled = params.enabled; + if (typeof enabled !== "boolean") { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "invalid set-heartbeats params: enabled (boolean) required", + ), + ); + break; + } + setHeartbeatsEnabled(enabled); + respond(true, { ok: true, enabled }, undefined); + break; + } + case "system-presence": { + const presence = listSystemPresence(); + respond(true, presence, undefined); + break; + } + case "system-event": { + const params = (req.params ?? {}) as Record; + const text = typeof params.text === "string" ? params.text.trim() : ""; + if (!text) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "text required"), + ); + break; + } + const instanceId = + typeof params.instanceId === "string" ? params.instanceId : undefined; + const host = typeof params.host === "string" ? params.host : undefined; + const ip = typeof params.ip === "string" ? params.ip : undefined; + const mode = typeof params.mode === "string" ? params.mode : undefined; + const version = + typeof params.version === "string" ? params.version : undefined; + const platform = + typeof params.platform === "string" ? params.platform : undefined; + const deviceFamily = + typeof params.deviceFamily === "string" + ? params.deviceFamily + : undefined; + const modelIdentifier = + typeof params.modelIdentifier === "string" + ? params.modelIdentifier + : undefined; + const lastInputSeconds = + typeof params.lastInputSeconds === "number" && + Number.isFinite(params.lastInputSeconds) + ? params.lastInputSeconds + : undefined; + const reason = + typeof params.reason === "string" ? params.reason : undefined; + const tags = + Array.isArray(params.tags) && + params.tags.every((t) => typeof t === "string") + ? (params.tags as string[]) + : undefined; + const presenceUpdate = updateSystemPresence({ + text, + instanceId, + host, + ip, + mode, + version, + platform, + deviceFamily, + modelIdentifier, + lastInputSeconds, + reason, + tags, + }); + const isNodePresenceLine = text.startsWith("Node:"); + if (isNodePresenceLine) { + const next = presenceUpdate.next; + const changed = new Set(presenceUpdate.changedKeys); + const reasonValue = next.reason ?? reason; + const normalizedReason = (reasonValue ?? "").toLowerCase(); + const ignoreReason = + normalizedReason.startsWith("periodic") || + normalizedReason === "heartbeat"; + const hostChanged = changed.has("host"); + const ipChanged = changed.has("ip"); + const versionChanged = changed.has("version"); + const modeChanged = changed.has("mode"); + const reasonChanged = changed.has("reason") && !ignoreReason; + const hasChanges = + hostChanged || + ipChanged || + versionChanged || + modeChanged || + reasonChanged; + if (hasChanges) { + const contextChanged = isSystemEventContextChanged( + presenceUpdate.key, + ); + const parts: string[] = []; + if (contextChanged || hostChanged || ipChanged) { + const hostLabel = next.host?.trim() || "Unknown"; + const ipLabel = next.ip?.trim(); + parts.push(`Node: ${hostLabel}${ipLabel ? ` (${ipLabel})` : ""}`); + } + if (versionChanged) { + parts.push(`app ${next.version?.trim() || "unknown"}`); + } + if (modeChanged) { + parts.push(`mode ${next.mode?.trim() || "unknown"}`); + } + if (reasonChanged) { + parts.push(`reason ${reasonValue?.trim() || "event"}`); + } + const deltaText = parts.join(" ยท "); + if (deltaText) { + enqueueSystemEvent(deltaText, { + contextKey: presenceUpdate.key, + }); + } + } + } else { + enqueueSystemEvent(text); + } + const nextPresenceVersion = incrementPresenceVersion(); + broadcast( + "presence", + { presence: listSystemPresence() }, + { + dropIfSlow: true, + stateVersion: { + presence: nextPresenceVersion, + health: getHealthVersion(), + }, + }, + ); + respond(true, { ok: true }, undefined); + break; + } + case "node.pair.request": { + const params = (req.params ?? {}) as Record; + if (!validateNodePairRequestParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid node.pair.request params: ${formatValidationErrors(validateNodePairRequestParams.errors)}`, + ), + ); + break; + } + const p = params as { + nodeId: string; + displayName?: string; + platform?: string; + version?: string; + deviceFamily?: string; + modelIdentifier?: string; + caps?: string[]; + commands?: string[]; + remoteIp?: string; + silent?: boolean; + }; + try { + const result = await requestNodePairing({ + nodeId: p.nodeId, + displayName: p.displayName, + platform: p.platform, + version: p.version, + deviceFamily: p.deviceFamily, + modelIdentifier: p.modelIdentifier, + caps: p.caps, + commands: p.commands, + remoteIp: p.remoteIp, + silent: p.silent, + }); + if (result.status === "pending" && result.created) { + broadcast("node.pair.requested", result.request, { + dropIfSlow: true, + }); + } + respond(true, result, undefined); + } catch (err) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), + ); + } + break; + } + case "node.pair.list": { + const params = (req.params ?? {}) as Record; + if (!validateNodePairListParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid node.pair.list params: ${formatValidationErrors(validateNodePairListParams.errors)}`, + ), + ); + break; + } + try { + const list = await listNodePairing(); + respond(true, list, undefined); + } catch (err) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), + ); + } + break; + } + case "node.pair.approve": { + const params = (req.params ?? {}) as Record; + if (!validateNodePairApproveParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid node.pair.approve params: ${formatValidationErrors(validateNodePairApproveParams.errors)}`, + ), + ); + break; + } + const { requestId } = params as { requestId: string }; + try { + const approved = await approveNodePairing(requestId); + if (!approved) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"), + ); + break; + } + broadcast( + "node.pair.resolved", + { + requestId, + nodeId: approved.node.nodeId, + decision: "approved", + ts: Date.now(), + }, + { dropIfSlow: true }, + ); + respond(true, approved, undefined); + } catch (err) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), + ); + } + break; + } + case "node.pair.reject": { + const params = (req.params ?? {}) as Record; + if (!validateNodePairRejectParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid node.pair.reject params: ${formatValidationErrors(validateNodePairRejectParams.errors)}`, + ), + ); + break; + } + const { requestId } = params as { requestId: string }; + try { + const rejected = await rejectNodePairing(requestId); + if (!rejected) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"), + ); + break; + } + broadcast( + "node.pair.resolved", + { + requestId, + nodeId: rejected.nodeId, + decision: "rejected", + ts: Date.now(), + }, + { dropIfSlow: true }, + ); + respond(true, rejected, undefined); + } catch (err) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), + ); + } + break; + } + case "node.pair.verify": { + const params = (req.params ?? {}) as Record; + if (!validateNodePairVerifyParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid node.pair.verify params: ${formatValidationErrors(validateNodePairVerifyParams.errors)}`, + ), + ); + break; + } + const { nodeId, token } = params as { + nodeId: string; + token: string; + }; + try { + const result = await verifyNodeToken(nodeId, token); + respond(true, result, undefined); + } catch (err) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), + ); + } + break; + } + case "node.rename": { + const params = (req.params ?? {}) as Record; + if (!validateNodeRenameParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid node.rename params: ${formatValidationErrors(validateNodeRenameParams.errors)}`, + ), + ); + break; + } + const { nodeId, displayName } = params as { + nodeId: string; + displayName: string; + }; + try { + const trimmed = displayName.trim(); + if (!trimmed) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "displayName required"), + ); + break; + } + const updated = await renamePairedNode(nodeId, trimmed); + if (!updated) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "unknown nodeId"), + ); + break; + } + respond( + true, + { nodeId: updated.nodeId, displayName: updated.displayName }, + undefined, + ); + } catch (err) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), + ); + } + break; + } + case "node.list": { + const params = (req.params ?? {}) as Record; + if (!validateNodeListParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid node.list params: ${formatValidationErrors(validateNodeListParams.errors)}`, + ), + ); + break; + } + + try { + const list = await listNodePairing(); + const pairedById = new Map(list.paired.map((n) => [n.nodeId, n])); + + const connected = bridge?.listConnected?.() ?? []; + const connectedById = new Map(connected.map((n) => [n.nodeId, n])); + + const nodeIds = new Set([ + ...pairedById.keys(), + ...connectedById.keys(), + ]); + + const nodes = [...nodeIds].map((nodeId) => { + const paired = pairedById.get(nodeId); + const live = connectedById.get(nodeId); + + const caps = [ + ...new Set( + (live?.caps ?? paired?.caps ?? []) + .map((c) => String(c).trim()) + .filter(Boolean), + ), + ].sort(); + + const commands = [ + ...new Set( + (live?.commands ?? paired?.commands ?? []) + .map((c) => String(c).trim()) + .filter(Boolean), + ), + ].sort(); + + return { + nodeId, + displayName: live?.displayName ?? paired?.displayName, + platform: live?.platform ?? paired?.platform, + version: live?.version ?? paired?.version, + deviceFamily: live?.deviceFamily ?? paired?.deviceFamily, + modelIdentifier: live?.modelIdentifier ?? paired?.modelIdentifier, + remoteIp: live?.remoteIp ?? paired?.remoteIp, + caps, + commands, + permissions: live?.permissions ?? paired?.permissions, + paired: Boolean(paired), + connected: Boolean(live), + }; + }); + + nodes.sort((a, b) => { + if (a.connected !== b.connected) return a.connected ? -1 : 1; + const an = (a.displayName ?? a.nodeId).toLowerCase(); + const bn = (b.displayName ?? b.nodeId).toLowerCase(); + if (an < bn) return -1; + if (an > bn) return 1; + return a.nodeId.localeCompare(b.nodeId); + }); + + respond(true, { ts: Date.now(), nodes }, undefined); + } catch (err) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), + ); + } + break; + } + case "node.describe": { + const params = (req.params ?? {}) as Record; + if (!validateNodeDescribeParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid node.describe params: ${formatValidationErrors(validateNodeDescribeParams.errors)}`, + ), + ); + break; + } + const { nodeId } = params as { nodeId: string }; + const id = String(nodeId ?? "").trim(); + if (!id) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required"), + ); + break; + } + + try { + const list = await listNodePairing(); + const paired = list.paired.find((n) => n.nodeId === id); + const connected = bridge?.listConnected?.() ?? []; + const live = connected.find((n) => n.nodeId === id); + + if (!paired && !live) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "unknown nodeId"), + ); + break; + } + + const caps = [ + ...new Set( + (live?.caps ?? paired?.caps ?? []) + .map((c) => String(c).trim()) + .filter(Boolean), + ), + ].sort(); + + const commands = [ + ...new Set( + (live?.commands ?? paired?.commands ?? []) + .map((c) => String(c).trim()) + .filter(Boolean), + ), + ].sort(); + + respond( + true, + { + ts: Date.now(), + nodeId: id, + displayName: live?.displayName ?? paired?.displayName, + platform: live?.platform ?? paired?.platform, + version: live?.version ?? paired?.version, + deviceFamily: live?.deviceFamily ?? paired?.deviceFamily, + modelIdentifier: live?.modelIdentifier ?? paired?.modelIdentifier, + remoteIp: live?.remoteIp ?? paired?.remoteIp, + caps, + commands, + permissions: live?.permissions ?? paired?.permissions, + paired: Boolean(paired), + connected: Boolean(live), + }, + undefined, + ); + } catch (err) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), + ); + } + break; + } + case "node.invoke": { + const params = (req.params ?? {}) as Record; + if (!validateNodeInvokeParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid node.invoke params: ${formatValidationErrors(validateNodeInvokeParams.errors)}`, + ), + ); + break; + } + if (!bridge) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, "bridge not running"), + ); + break; + } + const p = params as { + nodeId: string; + command: string; + params?: unknown; + timeoutMs?: number; + idempotencyKey: string; + }; + const nodeId = String(p.nodeId ?? "").trim(); + const command = String(p.command ?? "").trim(); + if (!nodeId || !command) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "nodeId and command required"), + ); + break; + } + + try { + const paramsJSON = + "params" in p && p.params !== undefined + ? JSON.stringify(p.params) + : null; + const res = await bridge.invoke({ + nodeId, + command, + paramsJSON, + timeoutMs: p.timeoutMs, + }); + if (!res.ok) { + respond( + false, + undefined, + errorShape( + ErrorCodes.UNAVAILABLE, + res.error?.message ?? "node invoke failed", + { details: { nodeError: res.error ?? null } }, + ), + ); + break; + } + const payload = + typeof res.payloadJSON === "string" && res.payloadJSON.trim() + ? (() => { + try { + return JSON.parse(res.payloadJSON) as unknown; + } catch { + return { payloadJSON: res.payloadJSON }; + } + })() + : undefined; + respond( + true, + { + ok: true, + nodeId, + command, + payload, + payloadJSON: res.payloadJSON ?? null, + }, + undefined, + ); + } catch (err) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), + ); + } + break; + } + case "send": { + const p = (req.params ?? {}) as Record; + if (!validateSendParams(p)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid send params: ${formatValidationErrors(validateSendParams.errors)}`, + ), + ); + break; + } + const params = p as { + to: string; + message: string; + mediaUrl?: string; + provider?: string; + idempotencyKey: string; + }; + const idem = params.idempotencyKey; + const cached = dedupe.get(`send:${idem}`); + if (cached) { + respond(cached.ok, cached.payload, cached.error, { + cached: true, + }); + break; + } + const to = params.to.trim(); + const message = params.message.trim(); + const providerRaw = (params.provider ?? "whatsapp").toLowerCase(); + const provider = providerRaw === "imsg" ? "imessage" : providerRaw; + try { + if (provider === "telegram") { + const cfg = loadConfig(); + const { token } = resolveTelegramToken(cfg); + const result = await sendMessageTelegram(to, message, { + mediaUrl: params.mediaUrl, + verbose: shouldLogVerbose(), + token: token || undefined, + }); + const payload = { + runId: idem, + messageId: result.messageId, + chatId: result.chatId, + provider, + }; + dedupe.set(`send:${idem}`, { + ts: Date.now(), + ok: true, + payload, + }); + respond(true, payload, undefined, { provider }); + } else if (provider === "discord") { + const result = await sendMessageDiscord(to, message, { + mediaUrl: params.mediaUrl, + token: process.env.DISCORD_BOT_TOKEN, + }); + const payload = { + runId: idem, + messageId: result.messageId, + channelId: result.channelId, + provider, + }; + dedupe.set(`send:${idem}`, { + ts: Date.now(), + ok: true, + payload, + }); + respond(true, payload, undefined, { provider }); + } else if (provider === "signal") { + const cfg = loadConfig(); + const host = cfg.signal?.httpHost?.trim() || "127.0.0.1"; + const port = cfg.signal?.httpPort ?? 8080; + const baseUrl = + cfg.signal?.httpUrl?.trim() || `http://${host}:${port}`; + const result = await sendMessageSignal(to, message, { + mediaUrl: params.mediaUrl, + baseUrl, + account: cfg.signal?.account, + }); + const payload = { + runId: idem, + messageId: result.messageId, + provider, + }; + dedupe.set(`send:${idem}`, { + ts: Date.now(), + ok: true, + payload, + }); + respond(true, payload, undefined, { provider }); + } else if (provider === "imessage") { + const cfg = loadConfig(); + const result = await sendMessageIMessage(to, message, { + mediaUrl: params.mediaUrl, + cliPath: cfg.imessage?.cliPath, + dbPath: cfg.imessage?.dbPath, + maxBytes: cfg.imessage?.mediaMaxMb + ? cfg.imessage.mediaMaxMb * 1024 * 1024 + : undefined, + }); + const payload = { + runId: idem, + messageId: result.messageId, + provider, + }; + dedupe.set(`send:${idem}`, { + ts: Date.now(), + ok: true, + payload, + }); + respond(true, payload, undefined, { provider }); + } else { + const result = await sendMessageWhatsApp(to, message, { + mediaUrl: params.mediaUrl, + verbose: shouldLogVerbose(), + }); + const payload = { + runId: idem, + messageId: result.messageId, + toJid: result.toJid ?? `${to}@s.whatsapp.net`, + provider, + }; + dedupe.set(`send:${idem}`, { + ts: Date.now(), + ok: true, + payload, + }); + respond(true, payload, undefined, { provider }); + } + } catch (err) { + const error = errorShape(ErrorCodes.UNAVAILABLE, String(err)); + dedupe.set(`send:${idem}`, { + ts: Date.now(), + ok: false, + error, + }); + respond(false, undefined, error, { + provider, + error: formatForLog(err), + }); + } + break; + } + case "agent": { + const p = (req.params ?? {}) as Record; + if (!validateAgentParams(p)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid agent params: ${formatValidationErrors(validateAgentParams.errors)}`, + ), + ); + break; + } + const params = p as { + message: string; + to?: string; + sessionId?: string; + sessionKey?: string; + thinking?: string; + deliver?: boolean; + channel?: string; + idempotencyKey: string; + timeout?: number; + }; + const idem = params.idempotencyKey; + const cached = dedupe.get(`agent:${idem}`); + if (cached) { + respond(cached.ok, cached.payload, cached.error, { + cached: true, + }); + break; + } + const message = params.message.trim(); + + const requestedSessionKey = + typeof params.sessionKey === "string" && params.sessionKey.trim() + ? params.sessionKey.trim() + : undefined; + let resolvedSessionId = params.sessionId?.trim() || undefined; + let sessionEntry: SessionEntry | undefined; + let bestEffortDeliver = false; + let cfgForAgent: ReturnType | undefined; + + if (requestedSessionKey) { + const { cfg, storePath, store, entry } = + loadSessionEntry(requestedSessionKey); + cfgForAgent = cfg; + const now = Date.now(); + const sessionId = entry?.sessionId ?? randomUUID(); + sessionEntry = { + sessionId, + updatedAt: now, + thinkingLevel: entry?.thinkingLevel, + verboseLevel: entry?.verboseLevel, + systemSent: entry?.systemSent, + skillsSnapshot: entry?.skillsSnapshot, + lastChannel: entry?.lastChannel, + lastTo: entry?.lastTo, + }; + if (store) { + store[requestedSessionKey] = sessionEntry; + if (storePath) { + await saveSessionStore(storePath, store); + } + } + resolvedSessionId = sessionId; + const mainKey = (cfg.session?.mainKey ?? "main").trim() || "main"; + if (requestedSessionKey === mainKey) { + addChatRun(sessionId, { + sessionKey: requestedSessionKey, + clientRunId: idem, + }); + bestEffortDeliver = true; + } + } + + const runId = resolvedSessionId || randomUUID(); + + const requestedChannelRaw = + typeof params.channel === "string" ? params.channel.trim() : ""; + const requestedChannelNormalized = requestedChannelRaw + ? requestedChannelRaw.toLowerCase() + : "last"; + const requestedChannel = + requestedChannelNormalized === "imsg" + ? "imessage" + : requestedChannelNormalized; + + const lastChannel = sessionEntry?.lastChannel; + const lastTo = + typeof sessionEntry?.lastTo === "string" + ? sessionEntry.lastTo.trim() + : ""; + + const resolvedChannel = (() => { + if (requestedChannel === "last") { + // WebChat is not a deliverable surface. Treat it as "unset" for routing, + // so VoiceWake and CLI callers don't get stuck with deliver=false. + return lastChannel && lastChannel !== "webchat" + ? lastChannel + : "whatsapp"; + } + if ( + requestedChannel === "whatsapp" || + requestedChannel === "telegram" || + requestedChannel === "discord" || + requestedChannel === "signal" || + requestedChannel === "imessage" || + requestedChannel === "webchat" + ) { + return requestedChannel; + } + return lastChannel && lastChannel !== "webchat" + ? lastChannel + : "whatsapp"; + })(); + + const resolvedTo = (() => { + const explicit = + typeof params.to === "string" && params.to.trim() + ? params.to.trim() + : undefined; + if (explicit) return explicit; + if ( + resolvedChannel === "whatsapp" || + resolvedChannel === "telegram" || + resolvedChannel === "discord" || + resolvedChannel === "signal" || + resolvedChannel === "imessage" + ) { + return lastTo || undefined; + } + return undefined; + })(); + + const sanitizedTo = (() => { + // If we derived a WhatsApp recipient from session "lastTo", ensure it is still valid + // for the configured allowlist. Otherwise, fall back to the first allowed number so + // voice wake doesn't silently route to stale/test recipients. + if (resolvedChannel !== "whatsapp") return resolvedTo; + const explicit = + typeof params.to === "string" && params.to.trim() + ? params.to.trim() + : undefined; + if (explicit) return resolvedTo; + + const cfg = cfgForAgent ?? loadConfig(); + const rawAllow = cfg.whatsapp?.allowFrom ?? []; + if (rawAllow.includes("*")) return resolvedTo; + const allowFrom = rawAllow + .map((val) => normalizeE164(val)) + .filter((val) => val.length > 1); + if (allowFrom.length === 0) return resolvedTo; + + const normalizedLast = + typeof resolvedTo === "string" && resolvedTo.trim() + ? normalizeE164(resolvedTo) + : undefined; + if (normalizedLast && allowFrom.includes(normalizedLast)) { + return normalizedLast; + } + return allowFrom[0]; + })(); + + const deliver = params.deliver === true && resolvedChannel !== "webchat"; + + const accepted = { runId, status: "accepted" as const }; + // Store an in-flight ack so retries do not spawn a second run. + dedupe.set(`agent:${idem}`, { + ts: Date.now(), + ok: true, + payload: accepted, + }); + respond(true, accepted, undefined, { runId }); + + void agentCommand( + { + message, + to: sanitizedTo, + sessionId: resolvedSessionId, + thinking: params.thinking, + deliver, + provider: resolvedChannel, + timeout: params.timeout?.toString(), + bestEffortDeliver, + surface: "VoiceWake", + }, + defaultRuntime, + deps, + ) + .then(() => { + const payload = { + runId, + status: "ok" as const, + summary: "completed", + }; + dedupe.set(`agent:${idem}`, { + ts: Date.now(), + ok: true, + payload, + }); + // Send a second res frame (same id) so TS clients with expectFinal can wait. + // Swift clients will typically treat the first res as the result and ignore this. + respond(true, payload, undefined, { runId }); + }) + .catch((err) => { + const error = errorShape(ErrorCodes.UNAVAILABLE, String(err)); + const payload = { + runId, + status: "error" as const, + summary: String(err), + }; + dedupe.set(`agent:${idem}`, { + ts: Date.now(), + ok: false, + payload, + error, + }); + respond(false, payload, error, { + runId, + error: formatForLog(err), + }); + }); + break; + } + default: { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `unknown method: ${req.method}`), + ); + break; + } + } +} diff --git a/src/gateway/server-providers.ts b/src/gateway/server-providers.ts index 0d22e9dcd..09db77964 100644 --- a/src/gateway/server-providers.ts +++ b/src/gateway/server-providers.ts @@ -1,17 +1,17 @@ import type { ClawdisConfig } from "../config/config.js"; -import { shouldLogVerbose } from "../globals.js"; -import type { createSubsystemLogger } from "../logging.js"; -import type { RuntimeEnv } from "../runtime.js"; import { monitorDiscordProvider } from "../discord/index.js"; import { probeDiscord } from "../discord/probe.js"; +import { shouldLogVerbose } from "../globals.js"; import { monitorIMessageProvider } from "../imessage/index.js"; +import type { createSubsystemLogger } from "../logging.js"; +import { monitorWebProvider, webAuthExists } from "../providers/web/index.js"; +import type { RuntimeEnv } from "../runtime.js"; import { monitorSignalProvider } from "../signal/index.js"; -import { resolveTelegramToken } from "../telegram/token.js"; import { monitorTelegramProvider } from "../telegram/monitor.js"; import { probeTelegram } from "../telegram/probe.js"; -import { monitorWebProvider, webAuthExists } from "../providers/web/index.js"; -import { readWebSelfId } from "../web/session.js"; +import { resolveTelegramToken } from "../telegram/token.js"; import type { WebProviderStatus } from "../web/auto-reply.js"; +import { readWebSelfId } from "../web/session.js"; import { formatError } from "./server-utils.js"; export type TelegramRuntimeStatus = { @@ -245,7 +245,9 @@ export function createProviderManager( lastError: "disabled", }; if (shouldLogVerbose()) { - logTelegram.debug("telegram provider disabled (telegram.enabled=false)"); + logTelegram.debug( + "telegram provider disabled (telegram.enabled=false)", + ); } return; } diff --git a/src/gateway/server-utils.test.ts b/src/gateway/server-utils.test.ts index d48830ef5..614c310f4 100644 --- a/src/gateway/server-utils.test.ts +++ b/src/gateway/server-utils.test.ts @@ -5,7 +5,9 @@ import { formatError, normalizeVoiceWakeTriggers } from "./server-utils.js"; describe("normalizeVoiceWakeTriggers", () => { test("returns defaults when input is empty", () => { expect(normalizeVoiceWakeTriggers([])).toEqual(defaultVoiceWakeTriggers()); - expect(normalizeVoiceWakeTriggers(null)).toEqual(defaultVoiceWakeTriggers()); + expect(normalizeVoiceWakeTriggers(null)).toEqual( + defaultVoiceWakeTriggers(), + ); }); test("trims and limits entries", () => { @@ -20,8 +22,10 @@ describe("formatError", () => { }); test("handles status/code", () => { - expect(formatError({ status: 500, code: "EPIPE" })).toBe("500 EPIPE"); - expect(formatError({ status: 404 })).toBe("404"); - expect(formatError({ code: "ENOENT" })).toBe("ENOENT"); + expect(formatError({ status: 500, code: "EPIPE" })).toBe( + "status=500 code=EPIPE", + ); + expect(formatError({ status: 404 })).toBe("status=404 code=unknown"); + expect(formatError({ code: "ENOENT" })).toBe("status=unknown code=ENOENT"); }); }); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 565d05782..4d0041d14 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; -import { type Server as HttpServer } from "node:http"; +import type { Server as HttpServer } from "node:http"; import os from "node:os"; import path from "node:path"; import chalk from "chalk"; @@ -19,9 +19,6 @@ import { resolveModelRefFromString, resolveThinkingDefault, } from "../agents/model-selection.js"; -import { installSkill } from "../agents/skills-install.js"; -import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; -import { DEFAULT_AGENT_WORKSPACE_DIR } from "../agents/workspace.js"; import { normalizeGroupActivation } from "../auto-reply/group-activation.js"; import { normalizeThinkLevel, @@ -37,9 +34,7 @@ import { import { createDefaultDeps } from "../cli/deps.js"; import { agentCommand } from "../commands/agent.js"; import { getHealthSnapshot, type HealthSummary } from "../commands/health.js"; -import { getStatusSummary } from "../commands/status.js"; import { - type ClawdisConfig, CONFIG_PATH_CLAWDIS, isNixMode, loadConfig, @@ -58,20 +53,11 @@ import { saveSessionStore, } from "../config/sessions.js"; import { runCronIsolatedAgentTurn } from "../cron/isolated-agent.js"; -import { - appendCronRunLog, - readCronRunLogEntries, - resolveCronRunLogPath, -} from "../cron/run-log.js"; +import { appendCronRunLog, resolveCronRunLogPath } from "../cron/run-log.js"; import { CronService } from "../cron/service.js"; import { resolveCronStorePath } from "../cron/store.js"; -import type { CronJob, CronJobCreate, CronJobPatch } from "../cron/types.js"; -import { sendMessageDiscord } from "../discord/index.js"; -import { type DiscordProbe, probeDiscord } from "../discord/probe.js"; -import { shouldLogVerbose } from "../globals.js"; +import type { CronJob } from "../cron/types.js"; import { startGmailWatcher, stopGmailWatcher } from "../hooks/gmail-watcher.js"; -import { sendMessageIMessage } from "../imessage/index.js"; -import { type IMessageProbe, probeIMessage } from "../imessage/probe.js"; import { clearAgentRunContext, getAgentRunContext, @@ -82,32 +68,14 @@ import { startGatewayBonjourAdvertiser } from "../infra/bonjour.js"; import { startNodeBridgeServer } from "../infra/bridge/server.js"; import { resolveCanvasHostUrl } from "../infra/canvas-host-url.js"; import { GatewayLockError } from "../infra/gateway-lock.js"; -import { - getLastHeartbeatEvent, - onHeartbeatEvent, -} from "../infra/heartbeat-events.js"; -import { - setHeartbeatsEnabled, - startHeartbeatRunner, -} from "../infra/heartbeat-runner.js"; +import { onHeartbeatEvent } from "../infra/heartbeat-events.js"; +import { startHeartbeatRunner } from "../infra/heartbeat-runner.js"; import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; import { getMachineDisplayName } from "../infra/machine-name.js"; -import { - approveNodePairing, - listNodePairing, - rejectNodePairing, - renamePairedNode, - requestNodePairing, - verifyNodeToken, -} from "../infra/node-pairing.js"; import { ensureClawdisCliOnPath } from "../infra/path-env.js"; -import { - enqueueSystemEvent, - isSystemEventContextChanged, -} from "../infra/system-events.js"; +import { enqueueSystemEvent } from "../infra/system-events.js"; import { listSystemPresence, - updateSystemPresence, upsertPresence, } from "../infra/system-presence.js"; import { @@ -121,7 +89,10 @@ import { enableTailscaleServe, getTailnetHostname, } from "../infra/tailscale.js"; -import { loadVoiceWakeConfig, setVoiceWakeTriggers } from "../infra/voicewake.js"; +import { + loadVoiceWakeConfig, + setVoiceWakeTriggers, +} from "../infra/voicewake.js"; import { WIDE_AREA_DISCOVERY_DOMAIN, writeWideAreaBridgeZone, @@ -135,19 +106,9 @@ import { } from "../logging.js"; import { setCommandLaneConcurrency } from "../process/command-queue.js"; import { runExec } from "../process/exec.js"; -import { webAuthExists } from "../providers/web/index.js"; import { defaultRuntime } from "../runtime.js"; -import { sendMessageSignal } from "../signal/index.js"; -import { probeSignal, type SignalProbe } from "../signal/probe.js"; -import { probeTelegram, type TelegramProbe } from "../telegram/probe.js"; -import { sendMessageTelegram } from "../telegram/send.js"; -import { resolveTelegramToken } from "../telegram/token.js"; -import { normalizeE164, resolveUserPath } from "../utils.js"; -import { startWebLoginWithQr, waitForWebLogin } from "../web/login-qr.js"; -import { sendMessageWhatsApp } from "../web/outbound.js"; -import { getWebAuthAgeMs, logoutWeb, readWebSelfId } from "../web/session.js"; import { runOnboardingWizard } from "../wizard/onboarding.js"; -import { WizardSession } from "../wizard/session.js"; +import type { WizardSession } from "../wizard/session.js"; import { assertGatewayAuthConfigured, authorizeGatewayConnect, @@ -161,6 +122,24 @@ import { isLoopbackHost, resolveGatewayBindHost, } from "./net.js"; +import { + DEDUPE_MAX, + DEDUPE_TTL_MS, + HANDSHAKE_TIMEOUT_MS, + HEALTH_REFRESH_INTERVAL_MS, + MAX_BUFFERED_BYTES, + MAX_CHAT_HISTORY_MESSAGES_BYTES, + MAX_PAYLOAD_BYTES, + TICK_INTERVAL_MS, +} from "./server-constants.js"; +import { + attachGatewayUpgradeHandler, + createGatewayHttpServer, + createHooksRequestHandler, +} from "./server-http.js"; +import { handleGatewayRequest } from "./server-methods.js"; +import { createProviderManager } from "./server-providers.js"; +import { formatError, normalizeVoiceWakeTriggers } from "./server-utils.js"; import { archiveFileOnDisk, capArrayByJsonBytes, @@ -172,13 +151,6 @@ import { type SessionsPatchResult, } from "./session-utils.js"; import { formatForLog, logWs, summarizeAgentEventForWsLog } from "./ws-log.js"; -import { - attachGatewayUpgradeHandler, - createGatewayHttpServer, - createHooksRequestHandler, -} from "./server-http.js"; -import { createProviderManager } from "./server-providers.js"; -import { formatError, normalizeVoiceWakeTriggers } from "./server-utils.js"; ensureClawdisCliOnPath(); @@ -277,7 +249,6 @@ import { type SessionsPatchParams, type SessionsResetParams, type Snapshot, - validateAgentParams, validateChatAbortParams, validateChatHistoryParams, validateChatSendParams, @@ -285,42 +256,14 @@ import { validateConfigSchemaParams, validateConfigSetParams, validateConnectParams, - validateCronAddParams, - validateCronListParams, - validateCronRemoveParams, - validateCronRunParams, - validateCronRunsParams, - validateCronStatusParams, - validateCronUpdateParams, validateModelsListParams, - validateNodeDescribeParams, - validateNodeInvokeParams, - validateNodeListParams, - validateNodePairApproveParams, - validateNodePairListParams, - validateNodePairRejectParams, - validateNodePairRequestParams, - validateNodePairVerifyParams, - validateNodeRenameParams, - validateProvidersStatusParams, validateRequestFrame, - validateSendParams, validateSessionsCompactParams, validateSessionsDeleteParams, validateSessionsListParams, validateSessionsPatchParams, validateSessionsResetParams, - validateSkillsInstallParams, - validateSkillsStatusParams, - validateSkillsUpdateParams, validateTalkModeParams, - validateWakeParams, - validateWebLoginStartParams, - validateWebLoginWaitParams, - validateWizardCancelParams, - validateWizardNextParams, - validateWizardStartParams, - validateWizardStatusParams, } from "./protocol/index.js"; type Client = { @@ -492,15 +435,6 @@ function buildSnapshot(): Snapshot { }; } -const MAX_PAYLOAD_BYTES = 512 * 1024; // cap incoming frame size -const MAX_BUFFERED_BYTES = 1.5 * 1024 * 1024; // per-connection send buffer limit - -const MAX_CHAT_HISTORY_MESSAGES_BYTES = 6 * 1024 * 1024; // keep history responses comfortably under client WS limits -const HANDSHAKE_TIMEOUT_MS = 10_000; -const TICK_INTERVAL_MS = 30_000; -const HEALTH_REFRESH_INTERVAL_MS = 60_000; -const DEDUPE_TTL_MS = 5 * 60_000; -const DEDUPE_MAX = 1000; type DedupeEntry = { ts: number; ok: boolean; @@ -805,16 +739,6 @@ export async function startGatewayServer( maxPayload: MAX_PAYLOAD_BYTES, }); attachGatewayUpgradeHandler({ httpServer, wss, canvasHost }); - let whatsappAbort: AbortController | null = null; - let telegramAbort: AbortController | null = null; - let discordAbort: AbortController | null = null; - let signalAbort: AbortController | null = null; - let imessageAbort: AbortController | null = null; - let whatsappTask: Promise | null = null; - let telegramTask: Promise | null = null; - let discordTask: Promise | null = null; - let signalTask: Promise | null = null; - let imessageTask: Promise | null = null; const clients = new Set(); let seq = 0; // Track per-run sequence to detect out-of-order/lost agent events. @@ -956,13 +880,9 @@ export async function startGatewayServer( startProviders, startWhatsAppProvider, stopWhatsAppProvider, - startTelegramProvider, stopTelegramProvider, - startDiscordProvider, stopDiscordProvider, - startSignalProvider, stopSignalProvider, - startIMessageProvider, stopIMessageProvider, markWhatsAppLoggedOut, } = providerManager; @@ -2887,2722 +2807,47 @@ export async function startGatewayServer( }; void (async () => { - switch (req.method) { - case "connect": { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "connect is only valid as the first request", - ), - ); - break; - } - case "voicewake.get": { - try { - const cfg = await loadVoiceWakeConfig(); - respond(true, { triggers: cfg.triggers }); - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), - ); - } - break; - } - case "voicewake.set": { - const params = (req.params ?? {}) as Record; - if (!Array.isArray(params.triggers)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "voicewake.set requires triggers: string[]", - ), - ); - break; - } - try { - const triggers = normalizeVoiceWakeTriggers(params.triggers); - const cfg = await setVoiceWakeTriggers(triggers); - broadcastVoiceWakeChanged(cfg.triggers); - respond(true, { triggers: cfg.triggers }); - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), - ); - } - break; - } - case "health": { - const now = Date.now(); - const cached = healthCache; - if (cached && now - cached.ts < HEALTH_REFRESH_INTERVAL_MS) { - respond(true, cached, undefined, { cached: true }); - void refreshHealthSnapshot({ probe: false }).catch((err) => - logHealth.error( - `background health refresh failed: ${formatError(err)}`, - ), - ); - break; - } - try { - const snap = await refreshHealthSnapshot({ probe: false }); - respond(true, snap, undefined); - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), - ); - } - break; - } - case "providers.status": { - const params = (req.params ?? {}) as Record; - if (!validateProvidersStatusParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid providers.status params: ${formatValidationErrors(validateProvidersStatusParams.errors)}`, - ), - ); - break; - } - const probe = (params as { probe?: boolean }).probe === true; - const timeoutMsRaw = (params as { timeoutMs?: unknown }) - .timeoutMs; - const timeoutMs = - typeof timeoutMsRaw === "number" - ? Math.max(1000, timeoutMsRaw) - : 10_000; - const cfg = loadConfig(); - const telegramCfg = cfg.telegram; - const telegramEnabled = - Boolean(telegramCfg) && telegramCfg?.enabled !== false; - const { token: telegramToken, source: tokenSource } = - telegramEnabled - ? resolveTelegramToken(cfg) - : { token: "", source: "none" as const }; - let telegramProbe: TelegramProbe | undefined; - let lastProbeAt: number | null = null; - if (probe && telegramToken && telegramEnabled) { - telegramProbe = await probeTelegram( - telegramToken, - timeoutMs, - telegramCfg?.proxy, - ); - lastProbeAt = Date.now(); - } - - const discordCfg = cfg.discord; - const discordEnabled = - Boolean(discordCfg) && discordCfg?.enabled !== false; - const discordEnvToken = discordEnabled - ? process.env.DISCORD_BOT_TOKEN?.trim() - : ""; - const discordConfigToken = discordEnabled - ? discordCfg?.token?.trim() - : ""; - const discordToken = discordEnvToken || discordConfigToken || ""; - const discordTokenSource = discordEnvToken - ? "env" - : discordConfigToken - ? "config" - : "none"; - let discordProbe: DiscordProbe | undefined; - let discordLastProbeAt: number | null = null; - if (probe && discordToken && discordEnabled) { - discordProbe = await probeDiscord(discordToken, timeoutMs); - discordLastProbeAt = Date.now(); - } - - const signalCfg = cfg.signal; - const signalEnabled = signalCfg?.enabled !== false; - const signalHost = signalCfg?.httpHost?.trim() || "127.0.0.1"; - const signalPort = signalCfg?.httpPort ?? 8080; - const signalBaseUrl = - signalCfg?.httpUrl?.trim() || - `http://${signalHost}:${signalPort}`; - const signalConfigured = - Boolean(signalCfg) && - signalEnabled && - Boolean( - signalCfg?.account?.trim() || - signalCfg?.httpUrl?.trim() || - signalCfg?.cliPath?.trim() || - signalCfg?.httpHost?.trim() || - typeof signalCfg?.httpPort === "number" || - typeof signalCfg?.autoStart === "boolean", - ); - let signalProbe: SignalProbe | undefined; - let signalLastProbeAt: number | null = null; - if (probe && signalConfigured) { - signalProbe = await probeSignal(signalBaseUrl, timeoutMs); - signalLastProbeAt = Date.now(); - } - - const imessageCfg = cfg.imessage; - const imessageEnabled = imessageCfg?.enabled !== false; - const imessageConfigured = - Boolean(imessageCfg) && imessageEnabled; - let imessageProbe: IMessageProbe | undefined; - let imessageLastProbeAt: number | null = null; - if (probe && imessageConfigured) { - imessageProbe = await probeIMessage(timeoutMs); - imessageLastProbeAt = Date.now(); - } - - const linked = await webAuthExists(); - const authAgeMs = getWebAuthAgeMs(); - const self = readWebSelfId(); - const runtime = getRuntimeSnapshot(); - - respond( - true, - { - ts: Date.now(), - whatsapp: { - configured: linked, - linked, - authAgeMs, - self, - running: runtime.whatsapp.running, - connected: runtime.whatsapp.connected, - lastConnectedAt: runtime.whatsapp.lastConnectedAt ?? null, - lastDisconnect: runtime.whatsapp.lastDisconnect ?? null, - reconnectAttempts: runtime.whatsapp.reconnectAttempts, - lastMessageAt: runtime.whatsapp.lastMessageAt ?? null, - lastEventAt: runtime.whatsapp.lastEventAt ?? null, - lastError: runtime.whatsapp.lastError ?? null, - }, - telegram: { - configured: telegramEnabled && Boolean(telegramToken), - tokenSource, - running: runtime.telegram.running, - mode: runtime.telegram.mode ?? null, - lastStartAt: runtime.telegram.lastStartAt ?? null, - lastStopAt: runtime.telegram.lastStopAt ?? null, - lastError: runtime.telegram.lastError ?? null, - probe: telegramProbe, - lastProbeAt, - }, - discord: { - configured: discordEnabled && Boolean(discordToken), - tokenSource: discordTokenSource, - running: runtime.discord.running, - lastStartAt: runtime.discord.lastStartAt ?? null, - lastStopAt: runtime.discord.lastStopAt ?? null, - lastError: runtime.discord.lastError ?? null, - probe: discordProbe, - lastProbeAt: discordLastProbeAt, - }, - signal: { - configured: signalConfigured, - baseUrl: signalBaseUrl, - running: runtime.signal.running, - lastStartAt: runtime.signal.lastStartAt ?? null, - lastStopAt: runtime.signal.lastStopAt ?? null, - lastError: runtime.signal.lastError ?? null, - probe: signalProbe, - lastProbeAt: signalLastProbeAt, - }, - imessage: { - configured: imessageConfigured, - running: runtime.imessage.running, - lastStartAt: runtime.imessage.lastStartAt ?? null, - lastStopAt: runtime.imessage.lastStopAt ?? null, - lastError: runtime.imessage.lastError ?? null, - cliPath: runtime.imessage.cliPath ?? null, - dbPath: runtime.imessage.dbPath ?? null, - probe: imessageProbe, - lastProbeAt: imessageLastProbeAt, - }, - }, - undefined, - ); - break; - } - case "chat.history": { - const params = (req.params ?? {}) as Record; - if (!validateChatHistoryParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid chat.history params: ${formatValidationErrors(validateChatHistoryParams.errors)}`, - ), - ); - break; - } - const { sessionKey, limit } = params as { - sessionKey: string; - limit?: number; - }; - const { cfg, storePath, entry } = loadSessionEntry(sessionKey); - const sessionId = entry?.sessionId; - const rawMessages = - sessionId && storePath - ? readSessionMessages(sessionId, storePath) - : []; - const hardMax = 1000; - const defaultLimit = 200; - const requested = - typeof limit === "number" ? limit : defaultLimit; - const max = Math.min(hardMax, requested); - const sliced = - rawMessages.length > max - ? rawMessages.slice(-max) - : rawMessages; - const capped = capArrayByJsonBytes( - sliced, - MAX_CHAT_HISTORY_MESSAGES_BYTES, - ).items; - let thinkingLevel = entry?.thinkingLevel; - if (!thinkingLevel) { - const configured = cfg.agent?.thinkingDefault; - if (configured) { - thinkingLevel = configured; - } else { - const { provider, model } = resolveSessionModelRef( - cfg, - entry, - ); - const catalog = await loadGatewayModelCatalog(); - thinkingLevel = resolveThinkingDefault({ - cfg, - provider, - model, - catalog, - }); - } - } - respond(true, { - sessionKey, - sessionId, - messages: capped, - thinkingLevel, - }); - break; - } - case "chat.abort": { - const params = (req.params ?? {}) as Record; - if (!validateChatAbortParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid chat.abort params: ${formatValidationErrors(validateChatAbortParams.errors)}`, - ), - ); - break; - } - const { sessionKey, runId } = params as { - sessionKey: string; - runId: string; - }; - const active = chatAbortControllers.get(runId); - if (!active) { - respond(true, { ok: true, aborted: false }); - break; - } - if (active.sessionKey !== sessionKey) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "runId does not match sessionKey", - ), - ); - break; - } - - active.controller.abort(); - chatAbortControllers.delete(runId); - chatRunBuffers.delete(runId); - chatDeltaSentAt.delete(runId); - removeChatRun(active.sessionId, runId, sessionKey); - - const payload = { - runId, - sessionKey, - seq: (agentRunSeq.get(active.sessionId) ?? 0) + 1, - state: "aborted" as const, - }; - broadcast("chat", payload); - bridgeSendToSession(sessionKey, "chat", payload); - respond(true, { ok: true, aborted: true }); - break; - } - case "chat.send": { - if ( - client && - isWebchatConnect(client.connect) && - !hasConnectedMobileNode() - ) { - respond( - false, - undefined, - errorShape( - ErrorCodes.UNAVAILABLE, - "web chat disabled: no connected iOS/Android nodes", - ), - ); - break; - } - const params = (req.params ?? {}) as Record; - if (!validateChatSendParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid chat.send params: ${formatValidationErrors(validateChatSendParams.errors)}`, - ), - ); - break; - } - const p = params as { - sessionKey: string; - message: string; - thinking?: string; - deliver?: boolean; - attachments?: Array<{ - type?: string; - mimeType?: string; - fileName?: string; - content?: unknown; - }>; - timeoutMs?: number; - idempotencyKey: string; - }; - const timeoutMs = Math.min( - Math.max(p.timeoutMs ?? 30_000, 0), - 30_000, - ); - const normalizedAttachments = - p.attachments?.map((a) => ({ - type: typeof a?.type === "string" ? a.type : undefined, - mimeType: - typeof a?.mimeType === "string" ? a.mimeType : undefined, - fileName: - typeof a?.fileName === "string" ? a.fileName : undefined, - content: - typeof a?.content === "string" - ? a.content - : ArrayBuffer.isView(a?.content) - ? Buffer.from( - a.content.buffer, - a.content.byteOffset, - a.content.byteLength, - ).toString("base64") - : undefined, - })) ?? []; - let messageWithAttachments = p.message; - if (normalizedAttachments.length > 0) { - try { - messageWithAttachments = buildMessageWithAttachments( - p.message, - normalizedAttachments, - { maxBytes: 5_000_000 }, - ); - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, String(err)), - ); - break; - } - } - const { storePath, store, entry } = loadSessionEntry( - p.sessionKey, - ); - const now = Date.now(); - const sessionId = entry?.sessionId ?? randomUUID(); - const sessionEntry: SessionEntry = { - sessionId, - updatedAt: now, - thinkingLevel: entry?.thinkingLevel, - verboseLevel: entry?.verboseLevel, - systemSent: entry?.systemSent, - lastChannel: entry?.lastChannel, - lastTo: entry?.lastTo, - }; - const clientRunId = p.idempotencyKey; - - const cached = dedupe.get(`chat:${clientRunId}`); - if (cached) { - respond(cached.ok, cached.payload, cached.error, { - cached: true, - }); - break; - } - - try { - const abortController = new AbortController(); - chatAbortControllers.set(clientRunId, { - controller: abortController, - sessionId, - sessionKey: p.sessionKey, - }); - addChatRun(sessionId, { - sessionKey: p.sessionKey, - clientRunId, - }); - - if (store) { - store[p.sessionKey] = sessionEntry; - if (storePath) { - await saveSessionStore(storePath, store); - } - } - - await agentCommand( - { - message: messageWithAttachments, - sessionId, - thinking: p.thinking, - deliver: p.deliver, - timeout: Math.ceil(timeoutMs / 1000).toString(), - surface: "WebChat", - abortSignal: abortController.signal, - }, - defaultRuntime, - deps, - ); - const payload = { - runId: clientRunId, - status: "ok" as const, - }; - dedupe.set(`chat:${clientRunId}`, { - ts: Date.now(), - ok: true, - payload, - }); - respond(true, payload, undefined, { runId: clientRunId }); - } catch (err) { - const error = errorShape(ErrorCodes.UNAVAILABLE, String(err)); - const payload = { - runId: clientRunId, - status: "error" as const, - summary: String(err), - }; - dedupe.set(`chat:${clientRunId}`, { - ts: Date.now(), - ok: false, - payload, - error, - }); - respond(false, payload, error, { - runId: clientRunId, - error: formatForLog(err), - }); - } finally { - chatAbortControllers.delete(clientRunId); - } - break; - } - case "wake": { - const params = (req.params ?? {}) as Record; - if (!validateWakeParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid wake params: ${formatValidationErrors(validateWakeParams.errors)}`, - ), - ); - break; - } - const p = params as { - mode: "now" | "next-heartbeat"; - text: string; - }; - const result = cron.wake({ mode: p.mode, text: p.text }); - respond(true, result, undefined); - break; - } - case "cron.list": { - const params = (req.params ?? {}) as Record; - if (!validateCronListParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid cron.list params: ${formatValidationErrors(validateCronListParams.errors)}`, - ), - ); - break; - } - const p = params as { includeDisabled?: boolean }; - const jobs = await cron.list({ - includeDisabled: p.includeDisabled, - }); - respond(true, { jobs }, undefined); - break; - } - case "cron.status": { - const params = (req.params ?? {}) as Record; - if (!validateCronStatusParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid cron.status params: ${formatValidationErrors(validateCronStatusParams.errors)}`, - ), - ); - break; - } - const status = await cron.status(); - respond(true, status, undefined); - break; - } - case "cron.add": { - const params = (req.params ?? {}) as Record; - if (!validateCronAddParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid cron.add params: ${formatValidationErrors(validateCronAddParams.errors)}`, - ), - ); - break; - } - const job = await cron.add(params as unknown as CronJobCreate); - respond(true, job, undefined); - break; - } - case "cron.update": { - const params = (req.params ?? {}) as Record; - if (!validateCronUpdateParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid cron.update params: ${formatValidationErrors(validateCronUpdateParams.errors)}`, - ), - ); - break; - } - const p = params as { - id: string; - patch: Record; - }; - const job = await cron.update( - p.id, - p.patch as unknown as CronJobPatch, - ); - respond(true, job, undefined); - break; - } - case "cron.remove": { - const params = (req.params ?? {}) as Record; - if (!validateCronRemoveParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid cron.remove params: ${formatValidationErrors(validateCronRemoveParams.errors)}`, - ), - ); - break; - } - const p = params as { id: string }; - const result = await cron.remove(p.id); - respond(true, result, undefined); - break; - } - case "cron.run": { - const params = (req.params ?? {}) as Record; - if (!validateCronRunParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid cron.run params: ${formatValidationErrors(validateCronRunParams.errors)}`, - ), - ); - break; - } - const p = params as { id: string; mode?: "due" | "force" }; - const result = await cron.run(p.id, p.mode); - respond(true, result, undefined); - break; - } - case "cron.runs": { - const params = (req.params ?? {}) as Record; - if (!validateCronRunsParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid cron.runs params: ${formatValidationErrors(validateCronRunsParams.errors)}`, - ), - ); - break; - } - const p = params as { id: string; limit?: number }; - const logPath = resolveCronRunLogPath({ - storePath: cronStorePath, - jobId: p.id, - }); - const entries = await readCronRunLogEntries(logPath, { - limit: p.limit, - jobId: p.id, - }); - respond(true, { entries }, undefined); - break; - } - case "status": { - const status = await getStatusSummary(); - respond(true, status, undefined); - break; - } - case "web.login.start": { - const params = (req.params ?? {}) as Record; - if (!validateWebLoginStartParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid web.login.start params: ${formatValidationErrors(validateWebLoginStartParams.errors)}`, - ), - ); - break; - } - try { - await stopWhatsAppProvider(); - const result = await startWebLoginWithQr({ - force: Boolean((params as { force?: boolean }).force), - timeoutMs: - typeof (params as { timeoutMs?: unknown }).timeoutMs === - "number" - ? (params as { timeoutMs?: number }).timeoutMs - : undefined, - verbose: Boolean((params as { verbose?: boolean }).verbose), - }); - respond(true, result, undefined); - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), - ); - } - break; - } - case "web.login.wait": { - const params = (req.params ?? {}) as Record; - if (!validateWebLoginWaitParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid web.login.wait params: ${formatValidationErrors(validateWebLoginWaitParams.errors)}`, - ), - ); - break; - } - try { - const result = await waitForWebLogin({ - timeoutMs: - typeof (params as { timeoutMs?: unknown }).timeoutMs === - "number" - ? (params as { timeoutMs?: number }).timeoutMs - : undefined, - }); - if (result.connected) { - await startWhatsAppProvider(); - } - respond(true, result, undefined); - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), - ); - } - break; - } - case "web.logout": { - try { - await stopWhatsAppProvider(); - const cleared = await logoutWeb(defaultRuntime); - markWhatsAppLoggedOut(cleared); - respond(true, { cleared }, undefined); - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), - ); - } - break; - } - case "telegram.logout": { - try { - await stopTelegramProvider(); - const snapshot = await readConfigFileSnapshot(); - if (!snapshot.valid) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "config invalid; fix it before logging out", - ), - ); - break; - } - const cfg = snapshot.config ?? {}; - const envToken = process.env.TELEGRAM_BOT_TOKEN?.trim() ?? ""; - const hadToken = Boolean(cfg.telegram?.botToken); - const nextTelegram = cfg.telegram - ? { ...cfg.telegram } - : undefined; - if (nextTelegram) { - delete nextTelegram.botToken; - } - const nextCfg = { ...cfg } as ClawdisConfig; - if (nextTelegram && Object.keys(nextTelegram).length > 0) { - nextCfg.telegram = nextTelegram; - } else { - delete nextCfg.telegram; - } - await writeConfigFile(nextCfg); - respond( - true, - { cleared: hadToken, envToken: Boolean(envToken) }, - undefined, - ); - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), - ); - } - break; - } - case "models.list": { - const params = (req.params ?? {}) as Record; - if (!validateModelsListParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid models.list params: ${formatValidationErrors(validateModelsListParams.errors)}`, - ), - ); - break; - } - try { - const models = await loadGatewayModelCatalog(); - respond(true, { models }, undefined); - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, String(err)), - ); - } - break; - } - case "config.get": { - const params = (req.params ?? {}) as Record; - if (!validateConfigGetParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid config.get params: ${formatValidationErrors(validateConfigGetParams.errors)}`, - ), - ); - break; - } - const snapshot = await readConfigFileSnapshot(); - respond(true, snapshot, undefined); - break; - } - case "config.schema": { - const params = (req.params ?? {}) as Record; - if (!validateConfigSchemaParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid config.schema params: ${formatValidationErrors(validateConfigSchemaParams.errors)}`, - ), - ); - break; - } - const schema = buildConfigSchema(); - respond(true, schema, undefined); - break; - } - case "config.set": { - const params = (req.params ?? {}) as Record; - if (!validateConfigSetParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid config.set params: ${formatValidationErrors(validateConfigSetParams.errors)}`, - ), - ); - break; - } - const rawValue = (params as { raw?: unknown }).raw; - if (typeof rawValue !== "string") { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "invalid config.set params: raw (string) required", - ), - ); - break; - } - const parsedRes = parseConfigJson5(rawValue); - if (!parsedRes.ok) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, parsedRes.error), - ); - break; - } - const validated = validateConfigObject(parsedRes.parsed); - if (!validated.ok) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "invalid config", { - details: { issues: validated.issues }, - }), - ); - break; - } - await writeConfigFile(validated.config); - respond( - true, - { - ok: true, - path: CONFIG_PATH_CLAWDIS, - config: validated.config, - }, - undefined, - ); - break; - } - case "wizard.start": { - const params = (req.params ?? {}) as Record; - if (!validateWizardStartParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid wizard.start params: ${formatValidationErrors(validateWizardStartParams.errors)}`, - ), - ); - break; - } - const running = findRunningWizard(); - if (running) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, "wizard already running"), - ); - break; - } - const sessionId = randomUUID(); - const opts = { - mode: params.mode as "local" | "remote" | undefined, - workspace: - typeof params.workspace === "string" - ? params.workspace - : undefined, - }; - const session = new WizardSession((prompter) => - wizardRunner(opts, defaultRuntime, prompter), - ); - wizardSessions.set(sessionId, session); - const result = await session.next(); - if (result.done) { - purgeWizardSession(sessionId); - } - respond(true, { sessionId, ...result }, undefined); - break; - } - case "wizard.next": { - const params = (req.params ?? {}) as Record; - if (!validateWizardNextParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid wizard.next params: ${formatValidationErrors(validateWizardNextParams.errors)}`, - ), - ); - break; - } - const sessionId = params.sessionId as string; - const session = wizardSessions.get(sessionId); - if (!session) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "wizard not found"), - ); - break; - } - const answer = params.answer as - | { stepId?: string; value?: unknown } - | undefined; - if (answer) { - if (session.getStatus() !== "running") { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "wizard not running", - ), - ); - break; - } - try { - await session.answer( - String(answer.stepId ?? ""), - answer.value, - ); - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, formatForLog(err)), - ); - break; - } - } - const result = await session.next(); - if (result.done) { - purgeWizardSession(sessionId); - } - respond(true, result, undefined); - break; - } - case "wizard.cancel": { - const params = (req.params ?? {}) as Record; - if (!validateWizardCancelParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid wizard.cancel params: ${formatValidationErrors(validateWizardCancelParams.errors)}`, - ), - ); - break; - } - const sessionId = params.sessionId as string; - const session = wizardSessions.get(sessionId); - if (!session) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "wizard not found"), - ); - break; - } - session.cancel(); - const status = { - status: session.getStatus(), - error: session.getError(), - }; - wizardSessions.delete(sessionId); - respond(true, status, undefined); - break; - } - case "wizard.status": { - const params = (req.params ?? {}) as Record; - if (!validateWizardStatusParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid wizard.status params: ${formatValidationErrors(validateWizardStatusParams.errors)}`, - ), - ); - break; - } - const sessionId = params.sessionId as string; - const session = wizardSessions.get(sessionId); - if (!session) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "wizard not found"), - ); - break; - } - const status = { - status: session.getStatus(), - error: session.getError(), - }; - if (status.status !== "running") { - wizardSessions.delete(sessionId); - } - respond(true, status, undefined); - break; - } - case "talk.mode": { - if ( - client && - isWebchatConnect(client.connect) && - !hasConnectedMobileNode() - ) { - respond( - false, - undefined, - errorShape( - ErrorCodes.UNAVAILABLE, - "talk disabled: no connected iOS/Android nodes", - ), - ); - break; - } - const params = (req.params ?? {}) as Record; - if (!validateTalkModeParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid talk.mode params: ${formatValidationErrors(validateTalkModeParams.errors)}`, - ), - ); - break; - } - const payload = { - enabled: (params as { enabled: boolean }).enabled, - phase: (params as { phase?: string }).phase ?? null, - ts: Date.now(), - }; - broadcast("talk.mode", payload, { dropIfSlow: true }); - respond(true, payload, undefined); - break; - } - case "skills.status": { - const params = (req.params ?? {}) as Record; - if (!validateSkillsStatusParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid skills.status params: ${formatValidationErrors(validateSkillsStatusParams.errors)}`, - ), - ); - break; - } - const cfg = loadConfig(); - const workspaceDirRaw = - cfg.agent?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; - const workspaceDir = resolveUserPath(workspaceDirRaw); - const report = buildWorkspaceSkillStatus(workspaceDir, { - config: cfg, - }); - respond(true, report, undefined); - break; - } - case "skills.install": { - const params = (req.params ?? {}) as Record; - if (!validateSkillsInstallParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid skills.install params: ${formatValidationErrors(validateSkillsInstallParams.errors)}`, - ), - ); - break; - } - const p = params as { - name: string; - installId: string; - timeoutMs?: number; - }; - const cfg = loadConfig(); - const workspaceDirRaw = - cfg.agent?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; - const result = await installSkill({ - workspaceDir: workspaceDirRaw, - skillName: p.name, - installId: p.installId, - timeoutMs: p.timeoutMs, - config: cfg, - }); - respond( - result.ok, - result, - result.ok - ? undefined - : errorShape(ErrorCodes.UNAVAILABLE, result.message), - ); - break; - } - case "skills.update": { - const params = (req.params ?? {}) as Record; - if (!validateSkillsUpdateParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid skills.update params: ${formatValidationErrors(validateSkillsUpdateParams.errors)}`, - ), - ); - break; - } - const p = params as { - skillKey: string; - enabled?: boolean; - apiKey?: string; - env?: Record; - }; - const cfg = loadConfig(); - const skills = cfg.skills ? { ...cfg.skills } : {}; - const entries = skills.entries ? { ...skills.entries } : {}; - const current = entries[p.skillKey] - ? { ...entries[p.skillKey] } - : {}; - if (typeof p.enabled === "boolean") { - current.enabled = p.enabled; - } - if (typeof p.apiKey === "string") { - const trimmed = p.apiKey.trim(); - if (trimmed) current.apiKey = trimmed; - else delete current.apiKey; - } - if (p.env && typeof p.env === "object") { - const nextEnv = current.env ? { ...current.env } : {}; - for (const [key, value] of Object.entries(p.env)) { - const trimmedKey = key.trim(); - if (!trimmedKey) continue; - const trimmedVal = value.trim(); - if (!trimmedVal) delete nextEnv[trimmedKey]; - else nextEnv[trimmedKey] = trimmedVal; - } - current.env = nextEnv; - } - entries[p.skillKey] = current; - skills.entries = entries; - const nextConfig: ClawdisConfig = { - ...cfg, - skills, - }; - await writeConfigFile(nextConfig); - respond( - true, - { ok: true, skillKey: p.skillKey, config: current }, - undefined, - ); - break; - } - case "sessions.list": { - const params = (req.params ?? {}) as Record; - if (!validateSessionsListParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid sessions.list params: ${formatValidationErrors(validateSessionsListParams.errors)}`, - ), - ); - break; - } - const p = params as SessionsListParams; - const cfg = loadConfig(); - const storePath = resolveStorePath(cfg.session?.store); - const store = loadSessionStore(storePath); - const result = listSessionsFromStore({ - cfg, - storePath, - store, - opts: p, - }); - respond(true, result, undefined); - break; - } - case "sessions.patch": { - const params = (req.params ?? {}) as Record; - if (!validateSessionsPatchParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid sessions.patch params: ${formatValidationErrors(validateSessionsPatchParams.errors)}`, - ), - ); - break; - } - const p = params as SessionsPatchParams; - const key = String(p.key ?? "").trim(); - if (!key) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "key required"), - ); - break; - } - - const cfg = loadConfig(); - const storePath = resolveStorePath(cfg.session?.store); - const store = loadSessionStore(storePath); - const now = Date.now(); - - const existing = store[key]; - const next: SessionEntry = existing - ? { - ...existing, - updatedAt: Math.max(existing.updatedAt ?? 0, now), - } - : { sessionId: randomUUID(), updatedAt: now }; - - if ("thinkingLevel" in p) { - const raw = p.thinkingLevel; - if (raw === null) { - delete next.thinkingLevel; - } else if (raw !== undefined) { - const normalized = normalizeThinkLevel(String(raw)); - if (!normalized) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "invalid thinkingLevel (use off|minimal|low|medium|high)", - ), - ); - break; - } - if (normalized === "off") delete next.thinkingLevel; - else next.thinkingLevel = normalized; - } - } - - if ("verboseLevel" in p) { - const raw = p.verboseLevel; - if (raw === null) { - delete next.verboseLevel; - } else if (raw !== undefined) { - const normalized = normalizeVerboseLevel(String(raw)); - if (!normalized) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - 'invalid verboseLevel (use "on"|"off")', - ), - ); - break; - } - if (normalized === "off") delete next.verboseLevel; - else next.verboseLevel = normalized; - } - } - - if ("model" in p) { - const raw = p.model; - if (raw === null) { - delete next.providerOverride; - delete next.modelOverride; - } else if (raw !== undefined) { - const trimmed = String(raw).trim(); - if (!trimmed) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "invalid model: empty", - ), - ); - break; - } - const resolvedDefault = resolveConfiguredModelRef({ - cfg, - defaultProvider: DEFAULT_PROVIDER, - defaultModel: DEFAULT_MODEL, - }); - const aliasIndex = buildModelAliasIndex({ - cfg, - defaultProvider: resolvedDefault.provider, - }); - const resolved = resolveModelRefFromString({ - raw: trimmed, - defaultProvider: resolvedDefault.provider, - aliasIndex, - }); - if (!resolved) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid model: ${trimmed}`, - ), - ); - break; - } - const catalog = await loadGatewayModelCatalog(); - const allowed = buildAllowedModelSet({ - cfg, - catalog, - defaultProvider: resolvedDefault.provider, - }); - const key = modelKey( - resolved.ref.provider, - resolved.ref.model, - ); - if (!allowed.allowAny && !allowed.allowedKeys.has(key)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `model not allowed: ${key}`, - ), - ); - break; - } - if ( - resolved.ref.provider === resolvedDefault.provider && - resolved.ref.model === resolvedDefault.model - ) { - delete next.providerOverride; - delete next.modelOverride; - } else { - next.providerOverride = resolved.ref.provider; - next.modelOverride = resolved.ref.model; - } - } - } - - if ("groupActivation" in p) { - const raw = p.groupActivation; - if (raw === null) { - delete next.groupActivation; - } else if (raw !== undefined) { - const normalized = normalizeGroupActivation(String(raw)); - if (!normalized) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - 'invalid groupActivation (use "mention"|"always")', - ), - ); - break; - } - next.groupActivation = normalized; - } - } - - store[key] = next; - await saveSessionStore(storePath, store); - const result: SessionsPatchResult = { - ok: true, - path: storePath, - key, - entry: next, - }; - respond(true, result, undefined); - break; - } - case "sessions.reset": { - const params = (req.params ?? {}) as Record; - if (!validateSessionsResetParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid sessions.reset params: ${formatValidationErrors(validateSessionsResetParams.errors)}`, - ), - ); - break; - } - const p = params as SessionsResetParams; - const key = String(p.key ?? "").trim(); - if (!key) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "key required"), - ); - break; - } - - const { storePath, store, entry } = loadSessionEntry(key); - const now = Date.now(); - const next: SessionEntry = { - sessionId: randomUUID(), - updatedAt: now, - systemSent: false, - abortedLastRun: false, - thinkingLevel: entry?.thinkingLevel, - verboseLevel: entry?.verboseLevel, - model: entry?.model, - contextTokens: entry?.contextTokens, - lastChannel: entry?.lastChannel, - lastTo: entry?.lastTo, - skillsSnapshot: entry?.skillsSnapshot, - }; - store[key] = next; - await saveSessionStore(storePath, store); - respond(true, { ok: true, key, entry: next }, undefined); - break; - } - case "sessions.delete": { - const params = (req.params ?? {}) as Record; - if (!validateSessionsDeleteParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid sessions.delete params: ${formatValidationErrors(validateSessionsDeleteParams.errors)}`, - ), - ); - break; - } - const p = params as SessionsDeleteParams; - const key = String(p.key ?? "").trim(); - if (!key) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "key required"), - ); - break; - } - - const deleteTranscript = - typeof p.deleteTranscript === "boolean" - ? p.deleteTranscript - : true; - - const { storePath, store, entry } = loadSessionEntry(key); - const sessionId = entry?.sessionId; - const existed = Boolean(store[key]); - if (existed) delete store[key]; - await saveSessionStore(storePath, store); - - const archived: string[] = []; - if (deleteTranscript && sessionId) { - for (const candidate of resolveSessionTranscriptCandidates( - sessionId, - storePath, - )) { - if (!fs.existsSync(candidate)) continue; - try { - archived.push(archiveFileOnDisk(candidate, "deleted")); - } catch { - // Best-effort. - } - } - } - - respond( - true, - { ok: true, key, deleted: existed, archived }, - undefined, - ); - break; - } - case "sessions.compact": { - const params = (req.params ?? {}) as Record; - if (!validateSessionsCompactParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid sessions.compact params: ${formatValidationErrors(validateSessionsCompactParams.errors)}`, - ), - ); - break; - } - const p = params as SessionsCompactParams; - const key = String(p.key ?? "").trim(); - if (!key) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "key required"), - ); - break; - } - - const maxLines = - typeof p.maxLines === "number" && Number.isFinite(p.maxLines) - ? Math.max(1, Math.floor(p.maxLines)) - : 400; - - const { storePath, store, entry } = loadSessionEntry(key); - const sessionId = entry?.sessionId; - if (!sessionId) { - respond( - true, - { ok: true, key, compacted: false, reason: "no sessionId" }, - undefined, - ); - break; - } - - const filePath = resolveSessionTranscriptCandidates( - sessionId, - storePath, - ).find((candidate) => fs.existsSync(candidate)); - if (!filePath) { - respond( - true, - { ok: true, key, compacted: false, reason: "no transcript" }, - undefined, - ); - break; - } - - const raw = fs.readFileSync(filePath, "utf-8"); - const lines = raw - .split(/\r?\n/) - .filter((l) => l.trim().length > 0); - if (lines.length <= maxLines) { - respond( - true, - { ok: true, key, compacted: false, kept: lines.length }, - undefined, - ); - break; - } - - const archived = archiveFileOnDisk(filePath, "bak"); - const keptLines = lines.slice(-maxLines); - fs.writeFileSync(filePath, `${keptLines.join("\n")}\n`, "utf-8"); - - if (store[key]) { - delete store[key].inputTokens; - delete store[key].outputTokens; - delete store[key].totalTokens; - store[key].updatedAt = Date.now(); - await saveSessionStore(storePath, store); - } - - respond( - true, - { - ok: true, - key, - compacted: true, - archived, - kept: keptLines.length, - }, - undefined, - ); - break; - } - case "last-heartbeat": { - respond(true, getLastHeartbeatEvent(), undefined); - break; - } - case "set-heartbeats": { - const params = (req.params ?? {}) as Record; - const enabled = params.enabled; - if (typeof enabled !== "boolean") { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "invalid set-heartbeats params: enabled (boolean) required", - ), - ); - break; - } - setHeartbeatsEnabled(enabled); - respond(true, { ok: true, enabled }, undefined); - break; - } - case "system-presence": { - const presence = listSystemPresence(); - respond(true, presence, undefined); - break; - } - case "system-event": { - const params = (req.params ?? {}) as Record; - const text = - typeof params.text === "string" ? params.text.trim() : ""; - if (!text) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "text required"), - ); - break; - } - const instanceId = - typeof params.instanceId === "string" - ? params.instanceId - : undefined; - const host = - typeof params.host === "string" ? params.host : undefined; - const ip = typeof params.ip === "string" ? params.ip : undefined; - const mode = - typeof params.mode === "string" ? params.mode : undefined; - const version = - typeof params.version === "string" ? params.version : undefined; - const platform = - typeof params.platform === "string" - ? params.platform - : undefined; - const deviceFamily = - typeof params.deviceFamily === "string" - ? params.deviceFamily - : undefined; - const modelIdentifier = - typeof params.modelIdentifier === "string" - ? params.modelIdentifier - : undefined; - const lastInputSeconds = - typeof params.lastInputSeconds === "number" && - Number.isFinite(params.lastInputSeconds) - ? params.lastInputSeconds - : undefined; - const reason = - typeof params.reason === "string" ? params.reason : undefined; - const tags = - Array.isArray(params.tags) && - params.tags.every((t) => typeof t === "string") - ? (params.tags as string[]) - : undefined; - const presenceUpdate = updateSystemPresence({ - text, - instanceId, - host, - ip, - mode, - version, - platform, - deviceFamily, - modelIdentifier, - lastInputSeconds, - reason, - tags, - }); - const isNodePresenceLine = text.startsWith("Node:"); - if (isNodePresenceLine) { - const next = presenceUpdate.next; - const changed = new Set(presenceUpdate.changedKeys); - const reasonValue = next.reason ?? reason; - const normalizedReason = (reasonValue ?? "").toLowerCase(); - const ignoreReason = - normalizedReason.startsWith("periodic") || - normalizedReason === "heartbeat"; - const hostChanged = changed.has("host"); - const ipChanged = changed.has("ip"); - const versionChanged = changed.has("version"); - const modeChanged = changed.has("mode"); - const reasonChanged = changed.has("reason") && !ignoreReason; - const hasChanges = - hostChanged || - ipChanged || - versionChanged || - modeChanged || - reasonChanged; - if (hasChanges) { - const contextChanged = isSystemEventContextChanged( - presenceUpdate.key, - ); - const parts: string[] = []; - if (contextChanged || hostChanged || ipChanged) { - const hostLabel = next.host?.trim() || "Unknown"; - const ipLabel = next.ip?.trim(); - parts.push( - `Node: ${hostLabel}${ipLabel ? ` (${ipLabel})` : ""}`, - ); - } - if (versionChanged) { - parts.push(`app ${next.version?.trim() || "unknown"}`); - } - if (modeChanged) { - parts.push(`mode ${next.mode?.trim() || "unknown"}`); - } - if (reasonChanged) { - parts.push(`reason ${reasonValue?.trim() || "event"}`); - } - const deltaText = parts.join(" ยท "); - if (deltaText) { - enqueueSystemEvent(deltaText, { - contextKey: presenceUpdate.key, - }); - } - } - } else { - enqueueSystemEvent(text); - } - presenceVersion += 1; - broadcast( - "presence", - { presence: listSystemPresence() }, - { - dropIfSlow: true, - stateVersion: { - presence: presenceVersion, - health: healthVersion, - }, - }, - ); - respond(true, { ok: true }, undefined); - break; - } - case "node.pair.request": { - const params = (req.params ?? {}) as Record; - if (!validateNodePairRequestParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid node.pair.request params: ${formatValidationErrors(validateNodePairRequestParams.errors)}`, - ), - ); - break; - } - const p = params as { - nodeId: string; - displayName?: string; - platform?: string; - version?: string; - deviceFamily?: string; - modelIdentifier?: string; - caps?: string[]; - commands?: string[]; - remoteIp?: string; - silent?: boolean; - }; - try { - const result = await requestNodePairing({ - nodeId: p.nodeId, - displayName: p.displayName, - platform: p.platform, - version: p.version, - deviceFamily: p.deviceFamily, - modelIdentifier: p.modelIdentifier, - caps: p.caps, - commands: p.commands, - remoteIp: p.remoteIp, - silent: p.silent, - }); - if (result.status === "pending" && result.created) { - broadcast("node.pair.requested", result.request, { - dropIfSlow: true, - }); - } - respond(true, result, undefined); - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), - ); - } - break; - } - case "node.pair.list": { - const params = (req.params ?? {}) as Record; - if (!validateNodePairListParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid node.pair.list params: ${formatValidationErrors(validateNodePairListParams.errors)}`, - ), - ); - break; - } - try { - const list = await listNodePairing(); - respond(true, list, undefined); - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), - ); - } - break; - } - case "node.pair.approve": { - const params = (req.params ?? {}) as Record; - if (!validateNodePairApproveParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid node.pair.approve params: ${formatValidationErrors(validateNodePairApproveParams.errors)}`, - ), - ); - break; - } - const { requestId } = params as { requestId: string }; - try { - const approved = await approveNodePairing(requestId); - if (!approved) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"), - ); - break; - } - broadcast( - "node.pair.resolved", - { - requestId, - nodeId: approved.node.nodeId, - decision: "approved", - ts: Date.now(), - }, - { dropIfSlow: true }, - ); - respond(true, approved, undefined); - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), - ); - } - break; - } - case "node.pair.reject": { - const params = (req.params ?? {}) as Record; - if (!validateNodePairRejectParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid node.pair.reject params: ${formatValidationErrors(validateNodePairRejectParams.errors)}`, - ), - ); - break; - } - const { requestId } = params as { requestId: string }; - try { - const rejected = await rejectNodePairing(requestId); - if (!rejected) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"), - ); - break; - } - broadcast( - "node.pair.resolved", - { - requestId, - nodeId: rejected.nodeId, - decision: "rejected", - ts: Date.now(), - }, - { dropIfSlow: true }, - ); - respond(true, rejected, undefined); - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), - ); - } - break; - } - case "node.pair.verify": { - const params = (req.params ?? {}) as Record; - if (!validateNodePairVerifyParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid node.pair.verify params: ${formatValidationErrors(validateNodePairVerifyParams.errors)}`, - ), - ); - break; - } - const { nodeId, token } = params as { - nodeId: string; - token: string; - }; - try { - const result = await verifyNodeToken(nodeId, token); - respond(true, result, undefined); - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), - ); - } - break; - } - case "node.rename": { - const params = (req.params ?? {}) as Record; - if (!validateNodeRenameParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid node.rename params: ${formatValidationErrors(validateNodeRenameParams.errors)}`, - ), - ); - break; - } - const { nodeId, displayName } = params as { - nodeId: string; - displayName: string; - }; - try { - const trimmed = displayName.trim(); - if (!trimmed) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "displayName required", - ), - ); - break; - } - const updated = await renamePairedNode(nodeId, trimmed); - if (!updated) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "unknown nodeId"), - ); - break; - } - respond( - true, - { nodeId: updated.nodeId, displayName: updated.displayName }, - undefined, - ); - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), - ); - } - break; - } - case "node.list": { - const params = (req.params ?? {}) as Record; - if (!validateNodeListParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid node.list params: ${formatValidationErrors(validateNodeListParams.errors)}`, - ), - ); - break; - } - - try { - const list = await listNodePairing(); - const pairedById = new Map( - list.paired.map((n) => [n.nodeId, n]), - ); - - const connected = bridge?.listConnected?.() ?? []; - const connectedById = new Map( - connected.map((n) => [n.nodeId, n]), - ); - - const nodeIds = new Set([ - ...pairedById.keys(), - ...connectedById.keys(), - ]); - - const nodes = [...nodeIds].map((nodeId) => { - const paired = pairedById.get(nodeId); - const live = connectedById.get(nodeId); - - const caps = [ - ...new Set( - (live?.caps ?? paired?.caps ?? []) - .map((c) => String(c).trim()) - .filter(Boolean), - ), - ].sort(); - - const commands = [ - ...new Set( - (live?.commands ?? paired?.commands ?? []) - .map((c) => String(c).trim()) - .filter(Boolean), - ), - ].sort(); - - return { - nodeId, - displayName: live?.displayName ?? paired?.displayName, - platform: live?.platform ?? paired?.platform, - version: live?.version ?? paired?.version, - deviceFamily: live?.deviceFamily ?? paired?.deviceFamily, - modelIdentifier: - live?.modelIdentifier ?? paired?.modelIdentifier, - remoteIp: live?.remoteIp ?? paired?.remoteIp, - caps, - commands, - permissions: live?.permissions ?? paired?.permissions, - paired: Boolean(paired), - connected: Boolean(live), - }; - }); - - nodes.sort((a, b) => { - if (a.connected !== b.connected) return a.connected ? -1 : 1; - const an = (a.displayName ?? a.nodeId).toLowerCase(); - const bn = (b.displayName ?? b.nodeId).toLowerCase(); - if (an < bn) return -1; - if (an > bn) return 1; - return a.nodeId.localeCompare(b.nodeId); - }); - - respond(true, { ts: Date.now(), nodes }, undefined); - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), - ); - } - break; - } - case "node.describe": { - const params = (req.params ?? {}) as Record; - if (!validateNodeDescribeParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid node.describe params: ${formatValidationErrors(validateNodeDescribeParams.errors)}`, - ), - ); - break; - } - const { nodeId } = params as { nodeId: string }; - const id = String(nodeId ?? "").trim(); - if (!id) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required"), - ); - break; - } - - try { - const list = await listNodePairing(); - const paired = list.paired.find((n) => n.nodeId === id); - const connected = bridge?.listConnected?.() ?? []; - const live = connected.find((n) => n.nodeId === id); - - if (!paired && !live) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "unknown nodeId"), - ); - break; - } - - const caps = [ - ...new Set( - (live?.caps ?? paired?.caps ?? []) - .map((c) => String(c).trim()) - .filter(Boolean), - ), - ].sort(); - - const commands = [ - ...new Set( - (live?.commands ?? paired?.commands ?? []) - .map((c) => String(c).trim()) - .filter(Boolean), - ), - ].sort(); - - respond( - true, - { - ts: Date.now(), - nodeId: id, - displayName: live?.displayName ?? paired?.displayName, - platform: live?.platform ?? paired?.platform, - version: live?.version ?? paired?.version, - deviceFamily: live?.deviceFamily ?? paired?.deviceFamily, - modelIdentifier: - live?.modelIdentifier ?? paired?.modelIdentifier, - remoteIp: live?.remoteIp ?? paired?.remoteIp, - caps, - commands, - permissions: live?.permissions ?? paired?.permissions, - paired: Boolean(paired), - connected: Boolean(live), - }, - undefined, - ); - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), - ); - } - break; - } - case "node.invoke": { - const params = (req.params ?? {}) as Record; - if (!validateNodeInvokeParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid node.invoke params: ${formatValidationErrors(validateNodeInvokeParams.errors)}`, - ), - ); - break; - } - if (!bridge) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, "bridge not running"), - ); - break; - } - const p = params as { - nodeId: string; - command: string; - params?: unknown; - timeoutMs?: number; - idempotencyKey: string; - }; - const nodeId = String(p.nodeId ?? "").trim(); - const command = String(p.command ?? "").trim(); - if (!nodeId || !command) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "nodeId and command required", - ), - ); - break; - } - - try { - const paramsJSON = - "params" in p && p.params !== undefined - ? JSON.stringify(p.params) - : null; - const res = await bridge.invoke({ - nodeId, - command, - paramsJSON, - timeoutMs: p.timeoutMs, - }); - if (!res.ok) { - respond( - false, - undefined, - errorShape( - ErrorCodes.UNAVAILABLE, - res.error?.message ?? "node invoke failed", - { details: { nodeError: res.error ?? null } }, - ), - ); - break; - } - const payload = - typeof res.payloadJSON === "string" && res.payloadJSON.trim() - ? (() => { - try { - return JSON.parse(res.payloadJSON) as unknown; - } catch { - return { payloadJSON: res.payloadJSON }; - } - })() - : undefined; - respond( - true, - { - ok: true, - nodeId, - command, - payload, - payloadJSON: res.payloadJSON ?? null, - }, - undefined, - ); - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), - ); - } - break; - } - case "send": { - const p = (req.params ?? {}) as Record; - if (!validateSendParams(p)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid send params: ${formatValidationErrors(validateSendParams.errors)}`, - ), - ); - break; - } - const params = p as { - to: string; - message: string; - mediaUrl?: string; - provider?: string; - idempotencyKey: string; - }; - const idem = params.idempotencyKey; - const cached = dedupe.get(`send:${idem}`); - if (cached) { - respond(cached.ok, cached.payload, cached.error, { - cached: true, - }); - break; - } - const to = params.to.trim(); - const message = params.message.trim(); - const providerRaw = (params.provider ?? "whatsapp").toLowerCase(); - const provider = - providerRaw === "imsg" ? "imessage" : providerRaw; - try { - if (provider === "telegram") { - const cfg = loadConfig(); - const { token } = resolveTelegramToken(cfg); - const result = await sendMessageTelegram(to, message, { - mediaUrl: params.mediaUrl, - verbose: shouldLogVerbose(), - token: token || undefined, - }); - const payload = { - runId: idem, - messageId: result.messageId, - chatId: result.chatId, - provider, - }; - dedupe.set(`send:${idem}`, { - ts: Date.now(), - ok: true, - payload, - }); - respond(true, payload, undefined, { provider }); - } else if (provider === "discord") { - const result = await sendMessageDiscord(to, message, { - mediaUrl: params.mediaUrl, - token: process.env.DISCORD_BOT_TOKEN, - }); - const payload = { - runId: idem, - messageId: result.messageId, - channelId: result.channelId, - provider, - }; - dedupe.set(`send:${idem}`, { - ts: Date.now(), - ok: true, - payload, - }); - respond(true, payload, undefined, { provider }); - } else if (provider === "signal") { - const cfg = loadConfig(); - const host = cfg.signal?.httpHost?.trim() || "127.0.0.1"; - const port = cfg.signal?.httpPort ?? 8080; - const baseUrl = - cfg.signal?.httpUrl?.trim() || `http://${host}:${port}`; - const result = await sendMessageSignal(to, message, { - mediaUrl: params.mediaUrl, - baseUrl, - account: cfg.signal?.account, - }); - const payload = { - runId: idem, - messageId: result.messageId, - provider, - }; - dedupe.set(`send:${idem}`, { - ts: Date.now(), - ok: true, - payload, - }); - respond(true, payload, undefined, { provider }); - } else if (provider === "imessage") { - const cfg = loadConfig(); - const result = await sendMessageIMessage(to, message, { - mediaUrl: params.mediaUrl, - cliPath: cfg.imessage?.cliPath, - dbPath: cfg.imessage?.dbPath, - maxBytes: cfg.imessage?.mediaMaxMb - ? cfg.imessage.mediaMaxMb * 1024 * 1024 - : undefined, - }); - const payload = { - runId: idem, - messageId: result.messageId, - provider, - }; - dedupe.set(`send:${idem}`, { - ts: Date.now(), - ok: true, - payload, - }); - respond(true, payload, undefined, { provider }); - } else { - const result = await sendMessageWhatsApp(to, message, { - mediaUrl: params.mediaUrl, - verbose: shouldLogVerbose(), - }); - const payload = { - runId: idem, - messageId: result.messageId, - toJid: result.toJid ?? `${to}@s.whatsapp.net`, - provider, - }; - dedupe.set(`send:${idem}`, { - ts: Date.now(), - ok: true, - payload, - }); - respond(true, payload, undefined, { provider }); - } - } catch (err) { - const error = errorShape(ErrorCodes.UNAVAILABLE, String(err)); - dedupe.set(`send:${idem}`, { - ts: Date.now(), - ok: false, - error, - }); - respond(false, undefined, error, { - provider, - error: formatForLog(err), - }); - } - break; - } - case "agent": { - const p = (req.params ?? {}) as Record; - if (!validateAgentParams(p)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid agent params: ${formatValidationErrors(validateAgentParams.errors)}`, - ), - ); - break; - } - const params = p as { - message: string; - to?: string; - sessionId?: string; - sessionKey?: string; - thinking?: string; - deliver?: boolean; - channel?: string; - idempotencyKey: string; - timeout?: number; - }; - const idem = params.idempotencyKey; - const cached = dedupe.get(`agent:${idem}`); - if (cached) { - respond(cached.ok, cached.payload, cached.error, { - cached: true, - }); - break; - } - const message = params.message.trim(); - - const requestedSessionKey = - typeof params.sessionKey === "string" && - params.sessionKey.trim() - ? params.sessionKey.trim() - : undefined; - let resolvedSessionId = params.sessionId?.trim() || undefined; - let sessionEntry: SessionEntry | undefined; - let bestEffortDeliver = false; - let cfgForAgent: ReturnType | undefined; - - if (requestedSessionKey) { - const { cfg, storePath, store, entry } = - loadSessionEntry(requestedSessionKey); - cfgForAgent = cfg; - const now = Date.now(); - const sessionId = entry?.sessionId ?? randomUUID(); - sessionEntry = { - sessionId, - updatedAt: now, - thinkingLevel: entry?.thinkingLevel, - verboseLevel: entry?.verboseLevel, - systemSent: entry?.systemSent, - skillsSnapshot: entry?.skillsSnapshot, - lastChannel: entry?.lastChannel, - lastTo: entry?.lastTo, - }; - if (store) { - store[requestedSessionKey] = sessionEntry; - if (storePath) { - await saveSessionStore(storePath, store); - } - } - resolvedSessionId = sessionId; - const mainKey = - (cfg.session?.mainKey ?? "main").trim() || "main"; - if (requestedSessionKey === mainKey) { - addChatRun(sessionId, { - sessionKey: requestedSessionKey, - clientRunId: idem, - }); - bestEffortDeliver = true; - } - } - - const runId = resolvedSessionId || randomUUID(); - - const requestedChannelRaw = - typeof params.channel === "string" ? params.channel.trim() : ""; - const requestedChannelNormalized = requestedChannelRaw - ? requestedChannelRaw.toLowerCase() - : "last"; - const requestedChannel = - requestedChannelNormalized === "imsg" - ? "imessage" - : requestedChannelNormalized; - - const lastChannel = sessionEntry?.lastChannel; - const lastTo = - typeof sessionEntry?.lastTo === "string" - ? sessionEntry.lastTo.trim() - : ""; - - const resolvedChannel = (() => { - if (requestedChannel === "last") { - // WebChat is not a deliverable surface. Treat it as "unset" for routing, - // so VoiceWake and CLI callers don't get stuck with deliver=false. - return lastChannel && lastChannel !== "webchat" - ? lastChannel - : "whatsapp"; - } - if ( - requestedChannel === "whatsapp" || - requestedChannel === "telegram" || - requestedChannel === "discord" || - requestedChannel === "signal" || - requestedChannel === "imessage" || - requestedChannel === "webchat" - ) { - return requestedChannel; - } - return lastChannel && lastChannel !== "webchat" - ? lastChannel - : "whatsapp"; - })(); - - const resolvedTo = (() => { - const explicit = - typeof params.to === "string" && params.to.trim() - ? params.to.trim() - : undefined; - if (explicit) return explicit; - if ( - resolvedChannel === "whatsapp" || - resolvedChannel === "telegram" || - resolvedChannel === "discord" || - resolvedChannel === "signal" || - resolvedChannel === "imessage" - ) { - return lastTo || undefined; - } - return undefined; - })(); - - const sanitizedTo = (() => { - // If we derived a WhatsApp recipient from session "lastTo", ensure it is still valid - // for the configured allowlist. Otherwise, fall back to the first allowed number so - // voice wake doesn't silently route to stale/test recipients. - if (resolvedChannel !== "whatsapp") return resolvedTo; - const explicit = - typeof params.to === "string" && params.to.trim() - ? params.to.trim() - : undefined; - if (explicit) return resolvedTo; - - const cfg = cfgForAgent ?? loadConfig(); - const rawAllow = cfg.whatsapp?.allowFrom ?? []; - if (rawAllow.includes("*")) return resolvedTo; - const allowFrom = rawAllow - .map((val) => normalizeE164(val)) - .filter((val) => val.length > 1); - if (allowFrom.length === 0) return resolvedTo; - - const normalizedLast = - typeof resolvedTo === "string" && resolvedTo.trim() - ? normalizeE164(resolvedTo) - : undefined; - if (normalizedLast && allowFrom.includes(normalizedLast)) { - return normalizedLast; - } - return allowFrom[0]; - })(); - - const deliver = - params.deliver === true && resolvedChannel !== "webchat"; - - const accepted = { runId, status: "accepted" as const }; - // Store an in-flight ack so retries do not spawn a second run. - dedupe.set(`agent:${idem}`, { - ts: Date.now(), - ok: true, - payload: accepted, - }); - respond(true, accepted, undefined, { runId }); - - void agentCommand( - { - message, - to: sanitizedTo, - sessionId: resolvedSessionId, - thinking: params.thinking, - deliver, - provider: resolvedChannel, - timeout: params.timeout?.toString(), - bestEffortDeliver, - surface: "VoiceWake", - }, - defaultRuntime, - deps, - ) - .then(() => { - const payload = { - runId, - status: "ok" as const, - summary: "completed", - }; - dedupe.set(`agent:${idem}`, { - ts: Date.now(), - ok: true, - payload, - }); - // Send a second res frame (same id) so TS clients with expectFinal can wait. - // Swift clients will typically treat the first res as the result and ignore this. - respond(true, payload, undefined, { runId }); - }) - .catch((err) => { - const error = errorShape(ErrorCodes.UNAVAILABLE, String(err)); - const payload = { - runId, - status: "error" as const, - summary: String(err), - }; - dedupe.set(`agent:${idem}`, { - ts: Date.now(), - ok: false, - payload, - error, - }); - respond(false, payload, error, { - runId, - error: formatForLog(err), - }); - }); - break; - } - default: { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `unknown method: ${req.method}`, - ), - ); - break; - } - } + await handleGatewayRequest({ + req, + respond, + client, + isWebchatConnect, + context: { + deps, + cron, + cronStorePath, + loadGatewayModelCatalog, + getHealthCache: () => healthCache, + refreshHealthSnapshot, + logHealth, + incrementPresenceVersion: () => { + presenceVersion += 1; + return presenceVersion; + }, + getHealthVersion: () => healthVersion, + broadcast, + bridge, + bridgeSendToSession, + hasConnectedMobileNode, + agentRunSeq, + chatAbortControllers, + chatRunBuffers, + chatDeltaSentAt, + addChatRun, + removeChatRun, + dedupe, + wizardSessions, + findRunningWizard, + purgeWizardSession, + getRuntimeSnapshot, + startWhatsAppProvider, + stopWhatsAppProvider, + stopTelegramProvider, + markWhatsAppLoggedOut, + wizardRunner, + broadcastVoiceWakeChanged, + }, + }); })().catch((err) => { log.error(`request handler failed: ${formatForLog(err)}`); respond( @@ -5796,11 +3041,6 @@ export async function startGatewayServer( if (stopBrowserControlServerIfStarted) { await stopBrowserControlServerIfStarted().catch(() => {}); } - await Promise.allSettled( - [whatsappTask, telegramTask, signalTask, imessageTask].filter( - Boolean, - ) as Array>, - ); await new Promise((resolve) => wss.close(() => resolve())); await new Promise((resolve, reject) => httpServer.close((err) => (err ? reject(err) : resolve())), diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index 8f2ee1d87..13a1a27a3 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -504,7 +504,7 @@ export async function runOnboardingWizard( const links = resolveControlUiLinks({ bind, port, - basePath: config.gateway?.controlUi?.basePath, + basePath: baseConfig.gateway?.controlUi?.basePath, }); const tokenParam = authMode === "token" && gatewayToken @@ -530,7 +530,7 @@ export async function runOnboardingWizard( const links = resolveControlUiLinks({ bind, port, - basePath: config.gateway?.controlUi?.basePath, + basePath: baseConfig.gateway?.controlUi?.basePath, }); const tokenParam = authMode === "token" && gatewayToken