diff --git a/src/gateway/server-bridge-events.ts b/src/gateway/server-bridge-events.ts new file mode 100644 index 000000000..d7f666523 --- /dev/null +++ b/src/gateway/server-bridge-events.ts @@ -0,0 +1,197 @@ +import { randomUUID } from "node:crypto"; +import { normalizeChannelId } from "../channels/plugins/index.js"; +import { agentCommand } from "../commands/agent.js"; +import { loadConfig } from "../config/config.js"; +import { saveSessionStore } from "../config/sessions.js"; +import { normalizeMainKey } from "../routing/session-key.js"; +import { defaultRuntime } from "../runtime.js"; +import type { + BridgeEvent, + BridgeHandlersContext, +} from "./server-bridge-types.js"; +import { loadSessionEntry } from "./session-utils.js"; +import { formatForLog } from "./ws-log.js"; + +export const handleBridgeEvent = async ( + ctx: BridgeHandlersContext, + nodeId: string, + evt: BridgeEvent, +) => { + switch (evt.event) { + case "voice.transcript": { + if (!evt.payloadJSON) return; + let payload: unknown; + try { + payload = JSON.parse(evt.payloadJSON) as unknown; + } catch { + return; + } + const obj = + typeof payload === "object" && payload !== null + ? (payload as Record) + : {}; + const text = typeof obj.text === "string" ? obj.text.trim() : ""; + if (!text) return; + if (text.length > 20_000) return; + const sessionKeyRaw = + typeof obj.sessionKey === "string" ? obj.sessionKey.trim() : ""; + const cfg = loadConfig(); + const rawMainKey = normalizeMainKey(cfg.session?.mainKey); + const sessionKey = sessionKeyRaw.length > 0 ? sessionKeyRaw : rawMainKey; + const { storePath, store, entry, canonicalKey } = + loadSessionEntry(sessionKey); + const now = Date.now(); + const sessionId = entry?.sessionId ?? randomUUID(); + store[canonicalKey] = { + sessionId, + updatedAt: now, + thinkingLevel: entry?.thinkingLevel, + verboseLevel: entry?.verboseLevel, + reasoningLevel: entry?.reasoningLevel, + systemSent: entry?.systemSent, + sendPolicy: entry?.sendPolicy, + lastChannel: entry?.lastChannel, + lastTo: entry?.lastTo, + }; + if (storePath) { + await saveSessionStore(storePath, store); + } + + // Ensure chat UI clients refresh when this run completes (even though it wasn't started via chat.send). + // This maps agent bus events (keyed by sessionId) to chat events (keyed by clientRunId). + ctx.addChatRun(sessionId, { + sessionKey, + clientRunId: `voice-${randomUUID()}`, + }); + + void agentCommand( + { + message: text, + sessionId, + sessionKey, + thinking: "low", + deliver: false, + messageChannel: "node", + }, + defaultRuntime, + ctx.deps, + ).catch((err) => { + ctx.logBridge.warn(`agent failed node=${nodeId}: ${formatForLog(err)}`); + }); + return; + } + case "agent.request": { + if (!evt.payloadJSON) return; + type AgentDeepLink = { + message?: string; + sessionKey?: string | null; + thinking?: string | null; + deliver?: boolean; + to?: string | null; + channel?: string | null; + timeoutSeconds?: number | null; + key?: string | null; + }; + let link: AgentDeepLink | null = null; + try { + link = JSON.parse(evt.payloadJSON) as AgentDeepLink; + } catch { + return; + } + const message = (link?.message ?? "").trim(); + if (!message) return; + if (message.length > 20_000) return; + + const channelRaw = + typeof link?.channel === "string" ? link.channel.trim() : ""; + const channel = normalizeChannelId(channelRaw) ?? undefined; + const to = + typeof link?.to === "string" && link.to.trim() + ? link.to.trim() + : undefined; + const deliver = Boolean(link?.deliver) && Boolean(channel); + + const sessionKeyRaw = (link?.sessionKey ?? "").trim(); + const sessionKey = + sessionKeyRaw.length > 0 ? sessionKeyRaw : `node-${nodeId}`; + const { storePath, store, entry, canonicalKey } = + loadSessionEntry(sessionKey); + const now = Date.now(); + const sessionId = entry?.sessionId ?? randomUUID(); + store[canonicalKey] = { + sessionId, + updatedAt: now, + thinkingLevel: entry?.thinkingLevel, + verboseLevel: entry?.verboseLevel, + reasoningLevel: entry?.reasoningLevel, + systemSent: entry?.systemSent, + sendPolicy: entry?.sendPolicy, + lastChannel: entry?.lastChannel, + lastTo: entry?.lastTo, + }; + if (storePath) { + await saveSessionStore(storePath, store); + } + + void agentCommand( + { + message, + sessionId, + sessionKey, + thinking: link?.thinking ?? undefined, + deliver, + to, + channel, + timeout: + typeof link?.timeoutSeconds === "number" + ? link.timeoutSeconds.toString() + : undefined, + messageChannel: "node", + }, + defaultRuntime, + ctx.deps, + ).catch((err) => { + ctx.logBridge.warn(`agent failed node=${nodeId}: ${formatForLog(err)}`); + }); + return; + } + case "chat.subscribe": { + if (!evt.payloadJSON) return; + let payload: unknown; + try { + payload = JSON.parse(evt.payloadJSON) as unknown; + } catch { + return; + } + const obj = + typeof payload === "object" && payload !== null + ? (payload as Record) + : {}; + const sessionKey = + typeof obj.sessionKey === "string" ? obj.sessionKey.trim() : ""; + if (!sessionKey) return; + ctx.bridgeSubscribe(nodeId, sessionKey); + return; + } + case "chat.unsubscribe": { + if (!evt.payloadJSON) return; + let payload: unknown; + try { + payload = JSON.parse(evt.payloadJSON) as unknown; + } catch { + return; + } + const obj = + typeof payload === "object" && payload !== null + ? (payload as Record) + : {}; + const sessionKey = + typeof obj.sessionKey === "string" ? obj.sessionKey.trim() : ""; + if (!sessionKey) return; + ctx.bridgeUnsubscribe(nodeId, sessionKey); + return; + } + default: + return; + } +}; diff --git a/src/gateway/server-bridge-methods-chat.ts b/src/gateway/server-bridge-methods-chat.ts new file mode 100644 index 000000000..87a89c109 --- /dev/null +++ b/src/gateway/server-bridge-methods-chat.ts @@ -0,0 +1,391 @@ +import { randomUUID } from "node:crypto"; +import { resolveThinkingDefault } from "../agents/model-selection.js"; +import { resolveAgentTimeoutMs } from "../agents/timeout.js"; +import { agentCommand } from "../commands/agent.js"; +import { mergeSessionEntry, saveSessionStore } from "../config/sessions.js"; +import { registerAgentRunContext } from "../infra/agent-events.js"; +import { defaultRuntime } from "../runtime.js"; +import { + abortChatRunById, + abortChatRunsForSessionKey, + isChatStopCommandText, + resolveChatRunExpiresAtMs, +} from "./chat-abort.js"; +import { + type ChatImageContent, + parseMessageWithAttachments, +} from "./chat-attachments.js"; +import { + ErrorCodes, + errorShape, + formatValidationErrors, + validateChatAbortParams, + validateChatHistoryParams, + validateChatSendParams, +} from "./protocol/index.js"; +import type { BridgeMethodHandler } from "./server-bridge-types.js"; +import { MAX_CHAT_HISTORY_MESSAGES_BYTES } from "./server-constants.js"; +import { + capArrayByJsonBytes, + loadSessionEntry, + readSessionMessages, + resolveSessionModelRef, +} from "./session-utils.js"; + +export const handleChatBridgeMethods: BridgeMethodHandler = async ( + ctx, + nodeId, + method, + params, +) => { + switch (method) { + case "chat.history": { + if (!validateChatHistoryParams(params)) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: `invalid chat.history params: ${formatValidationErrors(validateChatHistoryParams.errors)}`, + }, + }; + } + 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, entry?.sessionFile) + : []; + const max = typeof limit === "number" ? limit : 200; + 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.agents?.defaults?.thinkingDefault; + if (configured) { + thinkingLevel = configured; + } else { + const { provider, model } = resolveSessionModelRef(cfg, entry); + const catalog = await ctx.loadGatewayModelCatalog(); + thinkingLevel = resolveThinkingDefault({ + cfg, + provider, + model, + catalog, + }); + } + } + return { + ok: true, + payloadJSON: JSON.stringify({ + sessionKey, + sessionId, + messages: capped, + thinkingLevel, + }), + }; + } + case "chat.abort": { + if (!validateChatAbortParams(params)) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: `invalid chat.abort params: ${formatValidationErrors(validateChatAbortParams.errors)}`, + }, + }; + } + + const { sessionKey, runId } = params as { + sessionKey: string; + runId?: string; + }; + const ops = { + chatAbortControllers: ctx.chatAbortControllers, + chatRunBuffers: ctx.chatRunBuffers, + chatDeltaSentAt: ctx.chatDeltaSentAt, + chatAbortedRuns: ctx.chatAbortedRuns, + removeChatRun: ctx.removeChatRun, + agentRunSeq: ctx.agentRunSeq, + broadcast: ctx.broadcast, + bridgeSendToSession: ctx.bridgeSendToSession, + }; + if (!runId) { + const res = abortChatRunsForSessionKey(ops, { + sessionKey, + stopReason: "rpc", + }); + return { + ok: true, + payloadJSON: JSON.stringify({ + ok: true, + aborted: res.aborted, + runIds: res.runIds, + }), + }; + } + const active = ctx.chatAbortControllers.get(runId); + if (!active) { + return { + ok: true, + payloadJSON: JSON.stringify({ + ok: true, + aborted: false, + runIds: [], + }), + }; + } + if (active.sessionKey !== sessionKey) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: "runId does not match sessionKey", + }, + }; + } + const res = abortChatRunById(ops, { + runId, + sessionKey, + stopReason: "rpc", + }); + return { + ok: true, + payloadJSON: JSON.stringify({ + ok: true, + aborted: res.aborted, + runIds: res.aborted ? [runId] : [], + }), + }; + } + case "chat.send": { + if (!validateChatSendParams(params)) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: `invalid chat.send params: ${formatValidationErrors(validateChatSendParams.errors)}`, + }, + }; + } + + 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 stopCommand = isChatStopCommandText(p.message); + 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, + })) + .filter((a) => a.content) ?? []; + + let parsedMessage = p.message; + let parsedImages: ChatImageContent[] = []; + if (normalizedAttachments.length > 0) { + try { + const parsed = await parseMessageWithAttachments( + p.message, + normalizedAttachments, + { maxBytes: 5_000_000, log: ctx.logBridge }, + ); + parsedMessage = parsed.message; + parsedImages = parsed.images; + } catch (err) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: String(err), + }, + }; + } + } + + const { cfg, storePath, store, entry, canonicalKey } = loadSessionEntry( + p.sessionKey, + ); + const timeoutMs = resolveAgentTimeoutMs({ + cfg, + overrideMs: p.timeoutMs, + }); + const now = Date.now(); + const sessionId = entry?.sessionId ?? randomUUID(); + const sessionEntry = mergeSessionEntry(entry, { + sessionId, + updatedAt: now, + }); + const clientRunId = p.idempotencyKey; + registerAgentRunContext(clientRunId, { sessionKey: p.sessionKey }); + + if (stopCommand) { + const res = abortChatRunsForSessionKey( + { + chatAbortControllers: ctx.chatAbortControllers, + chatRunBuffers: ctx.chatRunBuffers, + chatDeltaSentAt: ctx.chatDeltaSentAt, + chatAbortedRuns: ctx.chatAbortedRuns, + removeChatRun: ctx.removeChatRun, + agentRunSeq: ctx.agentRunSeq, + broadcast: ctx.broadcast, + bridgeSendToSession: ctx.bridgeSendToSession, + }, + { sessionKey: p.sessionKey, stopReason: "stop" }, + ); + return { + ok: true, + payloadJSON: JSON.stringify({ + ok: true, + aborted: res.aborted, + runIds: res.runIds, + }), + }; + } + + const cached = ctx.dedupe.get(`chat:${clientRunId}`); + if (cached) { + if (cached.ok) { + return { ok: true, payloadJSON: JSON.stringify(cached.payload) }; + } + return { + ok: false, + error: cached.error ?? { + code: ErrorCodes.UNAVAILABLE, + message: "request failed", + }, + }; + } + + const activeExisting = ctx.chatAbortControllers.get(clientRunId); + if (activeExisting) { + return { + ok: true, + payloadJSON: JSON.stringify({ + runId: clientRunId, + status: "in_flight", + }), + }; + } + + try { + const abortController = new AbortController(); + ctx.chatAbortControllers.set(clientRunId, { + controller: abortController, + sessionId, + sessionKey: p.sessionKey, + startedAtMs: now, + expiresAtMs: resolveChatRunExpiresAtMs({ now, timeoutMs }), + }); + ctx.addChatRun(clientRunId, { + sessionKey: p.sessionKey, + clientRunId, + }); + + if (store) { + store[canonicalKey] = sessionEntry; + if (storePath) { + await saveSessionStore(storePath, store); + } + } + + const ackPayload = { + runId: clientRunId, + status: "started" as const, + }; + void agentCommand( + { + message: parsedMessage, + images: parsedImages.length > 0 ? parsedImages : undefined, + sessionId, + sessionKey: p.sessionKey, + runId: clientRunId, + thinking: p.thinking, + deliver: p.deliver, + timeout: Math.ceil(timeoutMs / 1000).toString(), + messageChannel: `node(${nodeId})`, + abortSignal: abortController.signal, + }, + defaultRuntime, + ctx.deps, + ) + .then(() => { + ctx.dedupe.set(`chat:${clientRunId}`, { + ts: Date.now(), + ok: true, + payload: { runId: clientRunId, status: "ok" as const }, + }); + }) + .catch((err) => { + const error = errorShape(ErrorCodes.UNAVAILABLE, String(err)); + ctx.dedupe.set(`chat:${clientRunId}`, { + ts: Date.now(), + ok: false, + payload: { + runId: clientRunId, + status: "error" as const, + summary: String(err), + }, + error, + }); + }) + .finally(() => { + ctx.chatAbortControllers.delete(clientRunId); + }); + + return { ok: true, payloadJSON: JSON.stringify(ackPayload) }; + } catch (err) { + const error = errorShape(ErrorCodes.UNAVAILABLE, String(err)); + const payload = { + runId: clientRunId, + status: "error" as const, + summary: String(err), + }; + ctx.dedupe.set(`chat:${clientRunId}`, { + ts: Date.now(), + ok: false, + payload, + error, + }); + return { + ok: false, + error: error ?? { + code: ErrorCodes.UNAVAILABLE, + message: String(err), + }, + }; + } + } + default: + return null; + } +}; diff --git a/src/gateway/server-bridge-methods-config.ts b/src/gateway/server-bridge-methods-config.ts new file mode 100644 index 000000000..8acc08df3 --- /dev/null +++ b/src/gateway/server-bridge-methods-config.ts @@ -0,0 +1,133 @@ +import { + resolveAgentWorkspaceDir, + resolveDefaultAgentId, +} from "../agents/agent-scope.js"; +import { + CONFIG_PATH_CLAWDBOT, + loadConfig, + parseConfigJson5, + readConfigFileSnapshot, + validateConfigObject, + writeConfigFile, +} from "../config/config.js"; +import { buildConfigSchema } from "../config/schema.js"; +import { loadClawdbotPlugins } from "../plugins/loader.js"; +import { + ErrorCodes, + formatValidationErrors, + validateConfigGetParams, + validateConfigSchemaParams, + validateConfigSetParams, +} from "./protocol/index.js"; +import type { BridgeMethodHandler } from "./server-bridge-types.js"; + +export const handleConfigBridgeMethods: BridgeMethodHandler = async ( + _ctx, + _nodeId, + method, + params, +) => { + switch (method) { + case "config.get": { + if (!validateConfigGetParams(params)) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: `invalid config.get params: ${formatValidationErrors(validateConfigGetParams.errors)}`, + }, + }; + } + const snapshot = await readConfigFileSnapshot(); + return { ok: true, payloadJSON: JSON.stringify(snapshot) }; + } + case "config.schema": { + if (!validateConfigSchemaParams(params)) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: `invalid config.schema params: ${formatValidationErrors(validateConfigSchemaParams.errors)}`, + }, + }; + } + const cfg = loadConfig(); + const workspaceDir = resolveAgentWorkspaceDir( + cfg, + resolveDefaultAgentId(cfg), + ); + const pluginRegistry = loadClawdbotPlugins({ + config: cfg, + workspaceDir, + logger: { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + }, + }); + const schema = buildConfigSchema({ + plugins: pluginRegistry.plugins.map((plugin) => ({ + id: plugin.id, + name: plugin.name, + description: plugin.description, + configUiHints: plugin.configUiHints, + })), + }); + return { ok: true, payloadJSON: JSON.stringify(schema) }; + } + case "config.set": { + if (!validateConfigSetParams(params)) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: `invalid config.set params: ${formatValidationErrors(validateConfigSetParams.errors)}`, + }, + }; + } + const rawValue = (params as { raw?: unknown }).raw; + if (typeof rawValue !== "string") { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: "invalid config.set params: raw (string) required", + }, + }; + } + const parsedRes = parseConfigJson5(rawValue); + if (!parsedRes.ok) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: parsedRes.error, + }, + }; + } + const validated = validateConfigObject(parsedRes.parsed); + if (!validated.ok) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: "invalid config", + details: { issues: validated.issues }, + }, + }; + } + await writeConfigFile(validated.config); + return { + ok: true, + payloadJSON: JSON.stringify({ + ok: true, + path: CONFIG_PATH_CLAWDBOT, + config: validated.config, + }), + }; + } + default: + return null; + } +}; diff --git a/src/gateway/server-bridge-methods-sessions.ts b/src/gateway/server-bridge-methods-sessions.ts new file mode 100644 index 000000000..bcd407772 --- /dev/null +++ b/src/gateway/server-bridge-methods-sessions.ts @@ -0,0 +1,401 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import { + abortEmbeddedPiRun, + isEmbeddedPiRunActive, + resolveEmbeddedSessionLane, + waitForEmbeddedPiRunEnd, +} from "../agents/pi-embedded.js"; +import { loadConfig } from "../config/config.js"; +import { + loadSessionStore, + resolveMainSessionKeyFromConfig, + type SessionEntry, + saveSessionStore, +} from "../config/sessions.js"; +import { clearCommandLane } from "../process/command-queue.js"; +import { + ErrorCodes, + formatValidationErrors, + type SessionsCompactParams, + type SessionsDeleteParams, + type SessionsListParams, + type SessionsPatchParams, + type SessionsResetParams, + type SessionsResolveParams, + validateSessionsCompactParams, + validateSessionsDeleteParams, + validateSessionsListParams, + validateSessionsPatchParams, + validateSessionsResetParams, + validateSessionsResolveParams, +} from "./protocol/index.js"; +import type { BridgeMethodHandler } from "./server-bridge-types.js"; +import { + archiveFileOnDisk, + listSessionsFromStore, + loadCombinedSessionStoreForGateway, + loadSessionEntry, + resolveGatewaySessionStoreTarget, + resolveSessionTranscriptCandidates, + type SessionsPatchResult, +} from "./session-utils.js"; +import { applySessionsPatchToStore } from "./sessions-patch.js"; +import { resolveSessionKeyFromResolveParams } from "./sessions-resolve.js"; + +export const handleSessionsBridgeMethods: BridgeMethodHandler = async ( + ctx, + _nodeId, + method, + params, +) => { + switch (method) { + case "sessions.list": { + if (!validateSessionsListParams(params)) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: `invalid sessions.list params: ${formatValidationErrors(validateSessionsListParams.errors)}`, + }, + }; + } + const p = params as SessionsListParams; + const cfg = loadConfig(); + const { storePath, store } = loadCombinedSessionStoreForGateway(cfg); + const result = listSessionsFromStore({ + cfg, + storePath, + store, + opts: p, + }); + return { ok: true, payloadJSON: JSON.stringify(result) }; + } + case "sessions.resolve": { + if (!validateSessionsResolveParams(params)) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: `invalid sessions.resolve params: ${formatValidationErrors(validateSessionsResolveParams.errors)}`, + }, + }; + } + + const p = params as SessionsResolveParams; + const cfg = loadConfig(); + const resolved = resolveSessionKeyFromResolveParams({ cfg, p }); + if (!resolved.ok) { + return { + ok: false, + error: { + code: resolved.error.code, + message: resolved.error.message, + details: resolved.error.details, + }, + }; + } + return { + ok: true, + payloadJSON: JSON.stringify({ ok: true, key: resolved.key }), + }; + } + case "sessions.patch": { + if (!validateSessionsPatchParams(params)) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: `invalid sessions.patch params: ${formatValidationErrors(validateSessionsPatchParams.errors)}`, + }, + }; + } + + const p = params as SessionsPatchParams; + const key = String(p.key ?? "").trim(); + if (!key) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: "key required", + }, + }; + } + + const cfg = loadConfig(); + const target = resolveGatewaySessionStoreTarget({ cfg, key }); + const storePath = target.storePath; + const store = loadSessionStore(storePath); + const primaryKey = target.storeKeys[0] ?? key; + const existingKey = target.storeKeys.find( + (candidate) => store[candidate], + ); + if (existingKey && existingKey !== primaryKey && !store[primaryKey]) { + store[primaryKey] = store[existingKey]; + delete store[existingKey]; + } + const applied = await applySessionsPatchToStore({ + cfg, + store, + storeKey: primaryKey, + patch: p, + loadGatewayModelCatalog: ctx.loadGatewayModelCatalog, + }); + if (!applied.ok) { + return { + ok: false, + error: { + code: applied.error.code, + message: applied.error.message, + details: applied.error.details, + }, + }; + } + await saveSessionStore(storePath, store); + const payload: SessionsPatchResult = { + ok: true, + path: storePath, + key: target.canonicalKey, + entry: applied.entry, + }; + return { ok: true, payloadJSON: JSON.stringify(payload) }; + } + case "sessions.reset": { + if (!validateSessionsResetParams(params)) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: `invalid sessions.reset params: ${formatValidationErrors(validateSessionsResetParams.errors)}`, + }, + }; + } + + const p = params as SessionsResetParams; + const key = String(p.key ?? "").trim(); + if (!key) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: "key required", + }, + }; + } + + 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, + reasoningLevel: entry?.reasoningLevel, + model: entry?.model, + contextTokens: entry?.contextTokens, + sendPolicy: entry?.sendPolicy, + label: entry?.label, + displayName: entry?.displayName, + chatType: entry?.chatType, + channel: entry?.channel, + subject: entry?.subject, + room: entry?.room, + space: entry?.space, + lastChannel: entry?.lastChannel, + lastTo: entry?.lastTo, + skillsSnapshot: entry?.skillsSnapshot, + }; + store[key] = next; + await saveSessionStore(storePath, store); + return { + ok: true, + payloadJSON: JSON.stringify({ ok: true, key, entry: next }), + }; + } + case "sessions.delete": { + if (!validateSessionsDeleteParams(params)) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: `invalid sessions.delete params: ${formatValidationErrors(validateSessionsDeleteParams.errors)}`, + }, + }; + } + + const p = params as SessionsDeleteParams; + const key = String(p.key ?? "").trim(); + if (!key) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: "key required", + }, + }; + } + + const mainKey = resolveMainSessionKeyFromConfig(); + if (key === mainKey) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: `Cannot delete the main session (${mainKey}).`, + }, + }; + } + + const deleteTranscript = + typeof p.deleteTranscript === "boolean" ? p.deleteTranscript : true; + + const { storePath, store, entry } = loadSessionEntry(key); + const sessionId = entry?.sessionId; + const existed = Boolean(store[key]); + clearCommandLane(resolveEmbeddedSessionLane(key)); + if (sessionId && isEmbeddedPiRunActive(sessionId)) { + abortEmbeddedPiRun(sessionId); + const ended = await waitForEmbeddedPiRunEnd(sessionId, 15_000); + if (!ended) { + return { + ok: false, + error: { + code: ErrorCodes.UNAVAILABLE, + message: `Session ${key} is still active; try again in a moment.`, + }, + }; + } + } + if (existed) delete store[key]; + await saveSessionStore(storePath, store); + + const archived: string[] = []; + if (deleteTranscript && sessionId) { + for (const candidate of resolveSessionTranscriptCandidates( + sessionId, + storePath, + entry?.sessionFile, + )) { + if (!fs.existsSync(candidate)) continue; + try { + archived.push(archiveFileOnDisk(candidate, "deleted")); + } catch { + // Best-effort; deleting the store entry is the main operation. + } + } + } + + return { + ok: true, + payloadJSON: JSON.stringify({ + ok: true, + key, + deleted: existed, + archived, + }), + }; + } + case "sessions.compact": { + if (!validateSessionsCompactParams(params)) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: `invalid sessions.compact params: ${formatValidationErrors(validateSessionsCompactParams.errors)}`, + }, + }; + } + + const p = params as SessionsCompactParams; + const key = String(p.key ?? "").trim(); + if (!key) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: "key required", + }, + }; + } + + 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) { + return { + ok: true, + payloadJSON: JSON.stringify({ + ok: true, + key, + compacted: false, + reason: "no sessionId", + }), + }; + } + + const filePath = resolveSessionTranscriptCandidates( + sessionId, + storePath, + entry?.sessionFile, + ).find((candidate) => fs.existsSync(candidate)); + if (!filePath) { + return { + ok: true, + payloadJSON: JSON.stringify({ + ok: true, + key, + compacted: false, + reason: "no transcript", + }), + }; + } + + const raw = fs.readFileSync(filePath, "utf-8"); + const lines = raw.split(/\r?\n/).filter((l) => l.trim().length > 0); + if (lines.length <= maxLines) { + return { + ok: true, + payloadJSON: JSON.stringify({ + ok: true, + key, + compacted: false, + kept: lines.length, + }), + }; + } + + const archived = archiveFileOnDisk(filePath, "bak"); + const keptLines = lines.slice(-maxLines); + fs.writeFileSync(filePath, `${keptLines.join("\n")}\n`, "utf-8"); + + // Token counts no longer match; clear so status + UI reflect reality after the next turn. + if (store[key]) { + delete store[key].inputTokens; + delete store[key].outputTokens; + delete store[key].totalTokens; + store[key].updatedAt = Date.now(); + await saveSessionStore(storePath, store); + } + + return { + ok: true, + payloadJSON: JSON.stringify({ + ok: true, + key, + compacted: true, + archived, + kept: keptLines.length, + }), + }; + } + default: + return null; + } +}; diff --git a/src/gateway/server-bridge-methods-system.ts b/src/gateway/server-bridge-methods-system.ts new file mode 100644 index 000000000..d406d0110 --- /dev/null +++ b/src/gateway/server-bridge-methods-system.ts @@ -0,0 +1,81 @@ +import { + loadVoiceWakeConfig, + setVoiceWakeTriggers, +} from "../infra/voicewake.js"; +import { + ErrorCodes, + formatValidationErrors, + validateModelsListParams, + validateTalkModeParams, +} from "./protocol/index.js"; +import type { BridgeMethodHandler } from "./server-bridge-types.js"; +import { HEALTH_REFRESH_INTERVAL_MS } from "./server-constants.js"; +import { normalizeVoiceWakeTriggers } from "./server-utils.js"; + +export const handleSystemBridgeMethods: BridgeMethodHandler = async ( + ctx, + _nodeId, + method, + params, +) => { + switch (method) { + case "voicewake.get": { + const cfg = await loadVoiceWakeConfig(); + return { + ok: true, + payloadJSON: JSON.stringify({ triggers: cfg.triggers }), + }; + } + case "voicewake.set": { + const triggers = normalizeVoiceWakeTriggers(params.triggers); + const cfg = await setVoiceWakeTriggers(triggers); + ctx.broadcastVoiceWakeChanged(cfg.triggers); + return { + ok: true, + payloadJSON: JSON.stringify({ triggers: cfg.triggers }), + }; + } + case "health": { + const now = Date.now(); + const cached = ctx.getHealthCache(); + if (cached && now - cached.ts < HEALTH_REFRESH_INTERVAL_MS) { + return { ok: true, payloadJSON: JSON.stringify(cached) }; + } + const snap = await ctx.refreshHealthSnapshot({ probe: false }); + return { ok: true, payloadJSON: JSON.stringify(snap) }; + } + case "talk.mode": { + if (!validateTalkModeParams(params)) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: `invalid talk.mode params: ${formatValidationErrors(validateTalkModeParams.errors)}`, + }, + }; + } + const payload = { + enabled: (params as { enabled: boolean }).enabled, + phase: (params as { phase?: string }).phase ?? null, + ts: Date.now(), + }; + ctx.broadcast("talk.mode", payload, { dropIfSlow: true }); + return { ok: true, payloadJSON: JSON.stringify(payload) }; + } + case "models.list": { + if (!validateModelsListParams(params)) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: `invalid models.list params: ${formatValidationErrors(validateModelsListParams.errors)}`, + }, + }; + } + const models = await ctx.loadGatewayModelCatalog(); + return { ok: true, payloadJSON: JSON.stringify({ models }) }; + } + default: + return null; + } +}; diff --git a/src/gateway/server-bridge-runtime.ts b/src/gateway/server-bridge-runtime.ts new file mode 100644 index 000000000..865472b2b --- /dev/null +++ b/src/gateway/server-bridge-runtime.ts @@ -0,0 +1,281 @@ +import type { ModelCatalogEntry } from "../agents/model-catalog.js"; +import type { + CanvasHostHandler, + CanvasHostServer, +} from "../canvas-host/server.js"; +import { startCanvasHost } from "../canvas-host/server.js"; +import type { CliDeps } from "../cli/deps.js"; +import type { HealthSummary } from "../commands/health.js"; +import { + deriveDefaultBridgePort, + deriveDefaultCanvasHostPort, +} from "../config/port-defaults.js"; +import type { NodeBridgeServer } from "../infra/bridge/server.js"; +import { + pickPrimaryTailnetIPv4, + pickPrimaryTailnetIPv6, +} from "../infra/tailnet.js"; +import type { RuntimeEnv } from "../runtime.js"; +import type { ChatAbortControllerEntry } from "./chat-abort.js"; +import { createBridgeHandlers } from "./server-bridge.js"; +import { + type BridgeListConnectedFn, + type BridgeSendEventFn, + createBridgeSubscriptionManager, +} from "./server-bridge-subscriptions.js"; +import type { ChatRunEntry } from "./server-chat.js"; +import { startGatewayDiscovery } from "./server-discovery-runtime.js"; +import { loadGatewayModelCatalog } from "./server-model-catalog.js"; +import { startGatewayNodeBridge } from "./server-node-bridge.js"; +import type { DedupeEntry } from "./server-shared.js"; + +export type GatewayBridgeRuntime = { + bridge: import("../infra/bridge/server.js").NodeBridgeServer | null; + bridgeHost: string | null; + bridgePort: number; + canvasHostServer: CanvasHostServer | null; + nodePresenceTimers: Map>; + bonjourStop: (() => Promise) | null; + bridgeSendToSession: ( + sessionKey: string, + event: string, + payload: unknown, + ) => void; + bridgeSendToAllSubscribed: (event: string, payload: unknown) => void; + broadcastVoiceWakeChanged: (triggers: string[]) => void; +}; + +export async function startGatewayBridgeRuntime(params: { + cfg: { + bridge?: { + enabled?: boolean; + port?: number; + bind?: "loopback" | "lan" | "auto" | "custom"; + }; + canvasHost?: { port?: number; root?: string; liveReload?: boolean }; + discovery?: { wideArea?: { enabled?: boolean } }; + }; + port: number; + canvasHostEnabled: boolean; + canvasHost: CanvasHostHandler | null; + canvasRuntime: RuntimeEnv; + allowCanvasHostInTests?: boolean; + machineDisplayName: string; + deps: CliDeps; + broadcast: ( + event: string, + payload: unknown, + opts?: { + dropIfSlow?: boolean; + stateVersion?: { presence?: number; health?: number }; + }, + ) => void; + dedupe: Map; + agentRunSeq: Map; + chatRunState: { abortedRuns: Map }; + chatRunBuffers: Map; + chatDeltaSentAt: Map; + addChatRun: (sessionId: string, entry: ChatRunEntry) => void; + removeChatRun: ( + sessionId: string, + clientRunId: string, + sessionKey?: string, + ) => ChatRunEntry | undefined; + chatAbortControllers: Map; + getHealthCache: () => HealthSummary | null; + refreshGatewayHealthSnapshot: (opts?: { + probe?: boolean; + }) => Promise; + loadGatewayModelCatalog?: () => Promise; + logBridge: { info: (msg: string) => void; warn: (msg: string) => void }; + logCanvas: { warn: (msg: string) => void }; + logDiscovery: { info: (msg: string) => void; warn: (msg: string) => void }; +}): Promise { + const wideAreaDiscoveryEnabled = + params.cfg.discovery?.wideArea?.enabled === true; + + const bridgeEnabled = (() => { + if (params.cfg.bridge?.enabled !== undefined) + return params.cfg.bridge.enabled === true; + return process.env.CLAWDBOT_BRIDGE_ENABLED !== "0"; + })(); + + const bridgePort = (() => { + if ( + typeof params.cfg.bridge?.port === "number" && + params.cfg.bridge.port > 0 + ) { + return params.cfg.bridge.port; + } + if (process.env.CLAWDBOT_BRIDGE_PORT !== undefined) { + const parsed = Number.parseInt(process.env.CLAWDBOT_BRIDGE_PORT, 10); + return Number.isFinite(parsed) && parsed > 0 + ? parsed + : deriveDefaultBridgePort(params.port); + } + return deriveDefaultBridgePort(params.port); + })(); + + const bridgeHost = (() => { + // Back-compat: allow an env var override when no bind policy is configured. + if (params.cfg.bridge?.bind === undefined) { + const env = process.env.CLAWDBOT_BRIDGE_HOST?.trim(); + if (env) return env; + } + + const bind = + params.cfg.bridge?.bind ?? (wideAreaDiscoveryEnabled ? "auto" : "lan"); + if (bind === "loopback") return "127.0.0.1"; + if (bind === "lan") return "0.0.0.0"; + + const tailnetIPv4 = pickPrimaryTailnetIPv4(); + const tailnetIPv6 = pickPrimaryTailnetIPv6(); + if (bind === "auto") { + return tailnetIPv4 ?? tailnetIPv6 ?? "0.0.0.0"; + } + if (bind === "custom") { + // For bridge, customBindHost is not currently supported on GatewayConfig. + // This will fall back to "0.0.0.0" until we add customBindHost to BridgeConfig. + return "0.0.0.0"; + } + return "0.0.0.0"; + })(); + + const canvasHostPort = (() => { + if (process.env.CLAWDBOT_CANVAS_HOST_PORT !== undefined) { + const parsed = Number.parseInt(process.env.CLAWDBOT_CANVAS_HOST_PORT, 10); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + return deriveDefaultCanvasHostPort(params.port); + } + const configured = params.cfg.canvasHost?.port; + if (typeof configured === "number" && configured > 0) return configured; + return deriveDefaultCanvasHostPort(params.port); + })(); + + let canvasHostServer: CanvasHostServer | null = null; + if (params.canvasHostEnabled && bridgeEnabled && bridgeHost) { + try { + const started = await startCanvasHost({ + runtime: params.canvasRuntime, + rootDir: params.cfg.canvasHost?.root, + port: canvasHostPort, + listenHost: bridgeHost, + allowInTests: params.allowCanvasHostInTests, + liveReload: params.cfg.canvasHost?.liveReload, + handler: params.canvasHost ?? undefined, + ownsHandler: params.canvasHost ? false : undefined, + }); + if (started.port > 0) { + canvasHostServer = started; + } + } catch (err) { + params.logCanvas.warn( + `failed to start on ${bridgeHost}:${canvasHostPort}: ${String(err)}`, + ); + } + } + + let bridge: NodeBridgeServer | null = null; + const bridgeSubscriptions = createBridgeSubscriptionManager(); + const bridgeSubscribe = bridgeSubscriptions.subscribe; + const bridgeUnsubscribe = bridgeSubscriptions.unsubscribe; + const bridgeUnsubscribeAll = bridgeSubscriptions.unsubscribeAll; + const bridgeSendEvent: BridgeSendEventFn = (opts) => { + bridge?.sendEvent(opts); + }; + const bridgeListConnected: BridgeListConnectedFn = () => + bridge?.listConnected() ?? []; + const bridgeSendToSession = ( + sessionKey: string, + event: string, + payload: unknown, + ) => + bridgeSubscriptions.sendToSession( + sessionKey, + event, + payload, + bridgeSendEvent, + ); + const bridgeSendToAllSubscribed = (event: string, payload: unknown) => + bridgeSubscriptions.sendToAllSubscribed(event, payload, bridgeSendEvent); + const bridgeSendToAllConnected = (event: string, payload: unknown) => + bridgeSubscriptions.sendToAllConnected( + event, + payload, + bridgeListConnected, + bridgeSendEvent, + ); + + const broadcastVoiceWakeChanged = (triggers: string[]) => { + const payload = { triggers }; + params.broadcast("voicewake.changed", payload, { dropIfSlow: true }); + bridgeSendToAllConnected("voicewake.changed", payload); + }; + + const { handleBridgeRequest, handleBridgeEvent } = createBridgeHandlers({ + deps: params.deps, + broadcast: params.broadcast, + bridgeSendToSession, + bridgeSubscribe, + bridgeUnsubscribe, + broadcastVoiceWakeChanged, + addChatRun: params.addChatRun, + removeChatRun: params.removeChatRun, + chatAbortControllers: params.chatAbortControllers, + chatAbortedRuns: params.chatRunState.abortedRuns, + chatRunBuffers: params.chatRunBuffers, + chatDeltaSentAt: params.chatDeltaSentAt, + dedupe: params.dedupe, + agentRunSeq: params.agentRunSeq, + getHealthCache: params.getHealthCache, + refreshHealthSnapshot: params.refreshGatewayHealthSnapshot, + loadGatewayModelCatalog: + params.loadGatewayModelCatalog ?? loadGatewayModelCatalog, + logBridge: params.logBridge, + }); + + const canvasHostPortForBridge = canvasHostServer?.port; + const canvasHostHostForBridge = + canvasHostServer && + bridgeHost && + bridgeHost !== "0.0.0.0" && + bridgeHost !== "::" + ? bridgeHost + : undefined; + + const bridgeRuntime = await startGatewayNodeBridge({ + bridgeEnabled, + bridgePort, + bridgeHost, + machineDisplayName: params.machineDisplayName, + canvasHostPort: canvasHostPortForBridge, + canvasHostHost: canvasHostHostForBridge, + broadcast: params.broadcast, + bridgeUnsubscribeAll, + handleBridgeRequest, + handleBridgeEvent, + logBridge: params.logBridge, + }); + bridge = bridgeRuntime.bridge; + + const discovery = await startGatewayDiscovery({ + machineDisplayName: params.machineDisplayName, + port: params.port, + bridgePort: bridge?.port, + canvasPort: canvasHostPortForBridge, + wideAreaDiscoveryEnabled, + logDiscovery: params.logDiscovery, + }); + + return { + bridge, + bridgeHost, + bridgePort, + canvasHostServer, + nodePresenceTimers: bridgeRuntime.nodePresenceTimers, + bonjourStop: discovery.bonjourStop, + bridgeSendToSession, + bridgeSendToAllSubscribed, + broadcastVoiceWakeChanged, + }; +} diff --git a/src/gateway/server-bridge-types.ts b/src/gateway/server-bridge-types.ts new file mode 100644 index 000000000..b52e13d6a --- /dev/null +++ b/src/gateway/server-bridge-types.ts @@ -0,0 +1,66 @@ +import type { ModelCatalogEntry } from "../agents/model-catalog.js"; +import type { CliDeps } from "../cli/deps.js"; +import type { HealthSummary } from "../commands/health.js"; +import type { ChatAbortControllerEntry } from "./chat-abort.js"; +import type { ChatRunEntry } from "./server-chat.js"; +import type { DedupeEntry } from "./server-shared.js"; + +export type BridgeHandlersContext = { + deps: CliDeps; + broadcast: ( + event: string, + payload: unknown, + opts?: { dropIfSlow?: boolean }, + ) => void; + bridgeSendToSession: ( + sessionKey: string, + event: string, + payload: unknown, + ) => void; + bridgeSubscribe: (nodeId: string, sessionKey: string) => void; + bridgeUnsubscribe: (nodeId: string, sessionKey: string) => void; + broadcastVoiceWakeChanged: (triggers: string[]) => void; + addChatRun: (sessionId: string, entry: ChatRunEntry) => void; + removeChatRun: ( + sessionId: string, + clientRunId: string, + sessionKey?: string, + ) => ChatRunEntry | undefined; + chatAbortControllers: Map; + chatAbortedRuns: Map; + chatRunBuffers: Map; + chatDeltaSentAt: Map; + dedupe: Map; + agentRunSeq: Map; + getHealthCache: () => HealthSummary | null; + refreshHealthSnapshot: (opts?: { probe?: boolean }) => Promise; + loadGatewayModelCatalog: () => Promise; + logBridge: { warn: (msg: string) => void }; +}; + +export type BridgeRequest = { + id: string; + method: string; + paramsJSON?: string | null; +}; + +export type BridgeEvent = { + event: string; + payloadJSON?: string | null; +}; + +export type BridgeResponse = + | { ok: true; payloadJSON?: string | null } + | { + ok: false; + error: { code: string; message: string; details?: unknown }; + }; + +export type BridgeRequestParams = Record; + +export type BridgeMethodHandler = ( + ctx: BridgeHandlersContext, + nodeId: string, + method: string, + params: BridgeRequestParams, +) => Promise; diff --git a/src/gateway/server-bridge.ts b/src/gateway/server-bridge.ts index c0bbf32a0..36ebc1066 100644 --- a/src/gateway/server-bridge.ts +++ b/src/gateway/server-bridge.ts @@ -1,147 +1,23 @@ -import { randomUUID } from "node:crypto"; -import fs from "node:fs"; -import { - resolveAgentWorkspaceDir, - resolveDefaultAgentId, -} from "../agents/agent-scope.js"; -import type { ModelCatalogEntry } from "../agents/model-catalog.js"; -import { resolveThinkingDefault } from "../agents/model-selection.js"; -import { - abortEmbeddedPiRun, - isEmbeddedPiRunActive, - resolveEmbeddedSessionLane, - waitForEmbeddedPiRunEnd, -} from "../agents/pi-embedded.js"; -import { resolveAgentTimeoutMs } from "../agents/timeout.js"; -import { normalizeChannelId } from "../channels/plugins/index.js"; -import type { CliDeps } from "../cli/deps.js"; -import { agentCommand } from "../commands/agent.js"; -import type { HealthSummary } from "../commands/health.js"; -import { - CONFIG_PATH_CLAWDBOT, - loadConfig, - parseConfigJson5, - readConfigFileSnapshot, - validateConfigObject, - writeConfigFile, -} from "../config/config.js"; -import { buildConfigSchema } from "../config/schema.js"; -import { - loadSessionStore, - mergeSessionEntry, - resolveMainSessionKeyFromConfig, - type SessionEntry, - saveSessionStore, -} from "../config/sessions.js"; -import { registerAgentRunContext } from "../infra/agent-events.js"; -import { - loadVoiceWakeConfig, - setVoiceWakeTriggers, -} from "../infra/voicewake.js"; -import { loadClawdbotPlugins } from "../plugins/loader.js"; -import { clearCommandLane } from "../process/command-queue.js"; -import { normalizeMainKey } from "../routing/session-key.js"; -import { defaultRuntime } from "../runtime.js"; -import { - abortChatRunById, - abortChatRunsForSessionKey, - type ChatAbortControllerEntry, - isChatStopCommandText, - resolveChatRunExpiresAtMs, -} from "./chat-abort.js"; -import { - type ChatImageContent, - parseMessageWithAttachments, -} from "./chat-attachments.js"; -import { - ErrorCodes, - errorShape, - formatValidationErrors, - type SessionsCompactParams, - type SessionsDeleteParams, - type SessionsListParams, - type SessionsPatchParams, - type SessionsResetParams, - type SessionsResolveParams, - validateChatAbortParams, - validateChatHistoryParams, - validateChatSendParams, - validateConfigGetParams, - validateConfigSchemaParams, - validateConfigSetParams, - validateModelsListParams, - validateSessionsCompactParams, - validateSessionsDeleteParams, - validateSessionsListParams, - validateSessionsPatchParams, - validateSessionsResetParams, - validateSessionsResolveParams, - validateTalkModeParams, -} from "./protocol/index.js"; -import type { ChatRunEntry } from "./server-chat.js"; -import { - HEALTH_REFRESH_INTERVAL_MS, - MAX_CHAT_HISTORY_MESSAGES_BYTES, -} from "./server-constants.js"; -import type { DedupeEntry } from "./server-shared.js"; -import { normalizeVoiceWakeTriggers } from "./server-utils.js"; -import { - archiveFileOnDisk, - capArrayByJsonBytes, - listSessionsFromStore, - loadCombinedSessionStoreForGateway, - loadSessionEntry, - readSessionMessages, - resolveGatewaySessionStoreTarget, - resolveSessionModelRef, - resolveSessionTranscriptCandidates, - type SessionsPatchResult, -} from "./session-utils.js"; -import { applySessionsPatchToStore } from "./sessions-patch.js"; -import { resolveSessionKeyFromResolveParams } from "./sessions-resolve.js"; -import { formatForLog } from "./ws-log.js"; +import { ErrorCodes } from "./protocol/index.js"; +import { handleBridgeEvent as handleBridgeEventImpl } from "./server-bridge-events.js"; +import { handleChatBridgeMethods } from "./server-bridge-methods-chat.js"; +import { handleConfigBridgeMethods } from "./server-bridge-methods-config.js"; +import { handleSessionsBridgeMethods } from "./server-bridge-methods-sessions.js"; +import { handleSystemBridgeMethods } from "./server-bridge-methods-system.js"; +import type { + BridgeEvent, + BridgeHandlersContext, + BridgeRequest, + BridgeResponse, +} from "./server-bridge-types.js"; -export type BridgeHandlersContext = { - deps: CliDeps; - broadcast: ( - event: string, - payload: unknown, - opts?: { dropIfSlow?: boolean }, - ) => void; - bridgeSendToSession: ( - sessionKey: string, - event: string, - payload: unknown, - ) => void; - bridgeSubscribe: (nodeId: string, sessionKey: string) => void; - bridgeUnsubscribe: (nodeId: string, sessionKey: string) => void; - broadcastVoiceWakeChanged: (triggers: string[]) => void; - addChatRun: (sessionId: string, entry: ChatRunEntry) => void; - removeChatRun: ( - sessionId: string, - clientRunId: string, - sessionKey?: string, - ) => ChatRunEntry | undefined; - chatAbortControllers: Map; - chatAbortedRuns: Map; - chatRunBuffers: Map; - chatDeltaSentAt: Map; - dedupe: Map; - agentRunSeq: Map; - getHealthCache: () => HealthSummary | null; - refreshHealthSnapshot: (opts?: { probe?: boolean }) => Promise; - loadGatewayModelCatalog: () => Promise; - logBridge: { warn: (msg: string) => void }; -}; +export type { BridgeHandlersContext } from "./server-bridge-types.js"; export function createBridgeHandlers(ctx: BridgeHandlersContext) { const handleBridgeRequest = async ( nodeId: string, - req: { id: string; method: string; paramsJSON?: string | null }, - ): Promise< - | { ok: true; payloadJSON?: string | null } - | { ok: false; error: { code: string; message: string; details?: unknown } } - > => { + req: BridgeRequest, + ): Promise => { const method = req.method.trim(); const parseParams = (): Record => { @@ -155,879 +31,21 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { }; try { - switch (method) { - case "voicewake.get": { - const cfg = await loadVoiceWakeConfig(); - return { - ok: true, - payloadJSON: JSON.stringify({ triggers: cfg.triggers }), - }; - } - case "voicewake.set": { - const params = parseParams(); - const triggers = normalizeVoiceWakeTriggers(params.triggers); - const cfg = await setVoiceWakeTriggers(triggers); - ctx.broadcastVoiceWakeChanged(cfg.triggers); - return { - ok: true, - payloadJSON: JSON.stringify({ triggers: cfg.triggers }), - }; - } - case "health": { - const now = Date.now(); - const cached = ctx.getHealthCache(); - if (cached && now - cached.ts < HEALTH_REFRESH_INTERVAL_MS) { - return { ok: true, payloadJSON: JSON.stringify(cached) }; - } - const snap = await ctx.refreshHealthSnapshot({ probe: false }); - return { ok: true, payloadJSON: JSON.stringify(snap) }; - } - case "config.get": { - const params = parseParams(); - if (!validateConfigGetParams(params)) { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: `invalid config.get params: ${formatValidationErrors(validateConfigGetParams.errors)}`, - }, - }; - } - const snapshot = await readConfigFileSnapshot(); - return { ok: true, payloadJSON: JSON.stringify(snapshot) }; - } - case "config.schema": { - const params = parseParams(); - if (!validateConfigSchemaParams(params)) { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: `invalid config.schema params: ${formatValidationErrors(validateConfigSchemaParams.errors)}`, - }, - }; - } - const cfg = loadConfig(); - const workspaceDir = resolveAgentWorkspaceDir( - cfg, - resolveDefaultAgentId(cfg), - ); - const pluginRegistry = loadClawdbotPlugins({ - config: cfg, - workspaceDir, - logger: { - info: () => {}, - warn: () => {}, - error: () => {}, - debug: () => {}, - }, - }); - const schema = buildConfigSchema({ - plugins: pluginRegistry.plugins.map((plugin) => ({ - id: plugin.id, - name: plugin.name, - description: plugin.description, - configUiHints: plugin.configUiHints, - })), - }); - return { ok: true, payloadJSON: JSON.stringify(schema) }; - } - case "config.set": { - const params = parseParams(); - if (!validateConfigSetParams(params)) { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: `invalid config.set params: ${formatValidationErrors(validateConfigSetParams.errors)}`, - }, - }; - } - const rawValue = (params as { raw?: unknown }).raw; - if (typeof rawValue !== "string") { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: "invalid config.set params: raw (string) required", - }, - }; - } - const parsedRes = parseConfigJson5(rawValue); - if (!parsedRes.ok) { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: parsedRes.error, - }, - }; - } - const validated = validateConfigObject(parsedRes.parsed); - if (!validated.ok) { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: "invalid config", - details: { issues: validated.issues }, - }, - }; - } - await writeConfigFile(validated.config); - return { - ok: true, - payloadJSON: JSON.stringify({ - ok: true, - path: CONFIG_PATH_CLAWDBOT, - config: validated.config, - }), - }; - } - case "talk.mode": { - const params = parseParams(); - if (!validateTalkModeParams(params)) { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: `invalid talk.mode params: ${formatValidationErrors(validateTalkModeParams.errors)}`, - }, - }; - } - const payload = { - enabled: (params as { enabled: boolean }).enabled, - phase: (params as { phase?: string }).phase ?? null, - ts: Date.now(), - }; - ctx.broadcast("talk.mode", payload, { dropIfSlow: true }); - return { ok: true, payloadJSON: JSON.stringify(payload) }; - } - case "models.list": { - const params = parseParams(); - if (!validateModelsListParams(params)) { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: `invalid models.list params: ${formatValidationErrors(validateModelsListParams.errors)}`, - }, - }; - } - const models = await ctx.loadGatewayModelCatalog(); - return { ok: true, payloadJSON: JSON.stringify({ models }) }; - } - case "sessions.list": { - const params = parseParams(); - if (!validateSessionsListParams(params)) { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: `invalid sessions.list params: ${formatValidationErrors(validateSessionsListParams.errors)}`, - }, - }; - } - const p = params as SessionsListParams; - const cfg = loadConfig(); - const { storePath, store } = loadCombinedSessionStoreForGateway(cfg); - const result = listSessionsFromStore({ - cfg, - storePath, - store, - opts: p, - }); - return { ok: true, payloadJSON: JSON.stringify(result) }; - } - case "sessions.resolve": { - const params = parseParams(); - if (!validateSessionsResolveParams(params)) { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: `invalid sessions.resolve params: ${formatValidationErrors(validateSessionsResolveParams.errors)}`, - }, - }; - } - - const p = params as SessionsResolveParams; - const cfg = loadConfig(); - const resolved = resolveSessionKeyFromResolveParams({ cfg, p }); - if (!resolved.ok) { - return { - ok: false, - error: { - code: resolved.error.code, - message: resolved.error.message, - details: resolved.error.details, - }, - }; - } - return { - ok: true, - payloadJSON: JSON.stringify({ ok: true, key: resolved.key }), - }; - } - case "sessions.patch": { - const params = parseParams(); - if (!validateSessionsPatchParams(params)) { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: `invalid sessions.patch params: ${formatValidationErrors(validateSessionsPatchParams.errors)}`, - }, - }; - } - - const p = params as SessionsPatchParams; - const key = String(p.key ?? "").trim(); - if (!key) { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: "key required", - }, - }; - } - - const cfg = loadConfig(); - const target = resolveGatewaySessionStoreTarget({ cfg, key }); - const storePath = target.storePath; - const store = loadSessionStore(storePath); - const primaryKey = target.storeKeys[0] ?? key; - const existingKey = target.storeKeys.find( - (candidate) => store[candidate], - ); - if (existingKey && existingKey !== primaryKey && !store[primaryKey]) { - store[primaryKey] = store[existingKey]; - delete store[existingKey]; - } - const applied = await applySessionsPatchToStore({ - cfg, - store, - storeKey: primaryKey, - patch: p, - loadGatewayModelCatalog: ctx.loadGatewayModelCatalog, - }); - if (!applied.ok) { - return { - ok: false, - error: { - code: applied.error.code, - message: applied.error.message, - details: applied.error.details, - }, - }; - } - await saveSessionStore(storePath, store); - const payload: SessionsPatchResult = { - ok: true, - path: storePath, - key: target.canonicalKey, - entry: applied.entry, - }; - return { ok: true, payloadJSON: JSON.stringify(payload) }; - } - case "sessions.reset": { - const params = parseParams(); - if (!validateSessionsResetParams(params)) { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: `invalid sessions.reset params: ${formatValidationErrors(validateSessionsResetParams.errors)}`, - }, - }; - } - - const p = params as SessionsResetParams; - const key = String(p.key ?? "").trim(); - if (!key) { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: "key required", - }, - }; - } - - 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, - reasoningLevel: entry?.reasoningLevel, - model: entry?.model, - contextTokens: entry?.contextTokens, - sendPolicy: entry?.sendPolicy, - label: entry?.label, - displayName: entry?.displayName, - chatType: entry?.chatType, - channel: entry?.channel, - subject: entry?.subject, - room: entry?.room, - space: entry?.space, - lastChannel: entry?.lastChannel, - lastTo: entry?.lastTo, - skillsSnapshot: entry?.skillsSnapshot, - }; - store[key] = next; - await saveSessionStore(storePath, store); - return { - ok: true, - payloadJSON: JSON.stringify({ ok: true, key, entry: next }), - }; - } - case "sessions.delete": { - const params = parseParams(); - if (!validateSessionsDeleteParams(params)) { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: `invalid sessions.delete params: ${formatValidationErrors(validateSessionsDeleteParams.errors)}`, - }, - }; - } - - const p = params as SessionsDeleteParams; - const key = String(p.key ?? "").trim(); - if (!key) { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: "key required", - }, - }; - } - - const mainKey = resolveMainSessionKeyFromConfig(); - if (key === mainKey) { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: `Cannot delete the main session (${mainKey}).`, - }, - }; - } - - const deleteTranscript = - typeof p.deleteTranscript === "boolean" ? p.deleteTranscript : true; - - const { storePath, store, entry } = loadSessionEntry(key); - const sessionId = entry?.sessionId; - const existed = Boolean(store[key]); - clearCommandLane(resolveEmbeddedSessionLane(key)); - if (sessionId && isEmbeddedPiRunActive(sessionId)) { - abortEmbeddedPiRun(sessionId); - const ended = await waitForEmbeddedPiRunEnd(sessionId, 15_000); - if (!ended) { - return { - ok: false, - error: { - code: ErrorCodes.UNAVAILABLE, - message: `Session ${key} is still active; try again in a moment.`, - }, - }; - } - } - if (existed) delete store[key]; - await saveSessionStore(storePath, store); - - const archived: string[] = []; - if (deleteTranscript && sessionId) { - for (const candidate of resolveSessionTranscriptCandidates( - sessionId, - storePath, - entry?.sessionFile, - )) { - if (!fs.existsSync(candidate)) continue; - try { - archived.push(archiveFileOnDisk(candidate, "deleted")); - } catch { - // Best-effort; deleting the store entry is the main operation. - } - } - } - - return { - ok: true, - payloadJSON: JSON.stringify({ - ok: true, - key, - deleted: existed, - archived, - }), - }; - } - case "sessions.compact": { - const params = parseParams(); - if (!validateSessionsCompactParams(params)) { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: `invalid sessions.compact params: ${formatValidationErrors(validateSessionsCompactParams.errors)}`, - }, - }; - } - - const p = params as SessionsCompactParams; - const key = String(p.key ?? "").trim(); - if (!key) { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: "key required", - }, - }; - } - - 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) { - return { - ok: true, - payloadJSON: JSON.stringify({ - ok: true, - key, - compacted: false, - reason: "no sessionId", - }), - }; - } - - const filePath = resolveSessionTranscriptCandidates( - sessionId, - storePath, - entry?.sessionFile, - ).find((candidate) => fs.existsSync(candidate)); - if (!filePath) { - return { - ok: true, - payloadJSON: JSON.stringify({ - ok: true, - key, - compacted: false, - reason: "no transcript", - }), - }; - } - - const raw = fs.readFileSync(filePath, "utf-8"); - const lines = raw.split(/\r?\n/).filter((l) => l.trim().length > 0); - if (lines.length <= maxLines) { - return { - ok: true, - payloadJSON: JSON.stringify({ - ok: true, - key, - compacted: false, - kept: lines.length, - }), - }; - } - - const archived = archiveFileOnDisk(filePath, "bak"); - const keptLines = lines.slice(-maxLines); - fs.writeFileSync(filePath, `${keptLines.join("\n")}\n`, "utf-8"); - - // Token counts no longer match; clear so status + UI reflect reality after the next turn. - if (store[key]) { - delete store[key].inputTokens; - delete store[key].outputTokens; - delete store[key].totalTokens; - store[key].updatedAt = Date.now(); - await saveSessionStore(storePath, store); - } - - return { - ok: true, - payloadJSON: JSON.stringify({ - ok: true, - key, - compacted: true, - archived, - kept: keptLines.length, - }), - }; - } - case "chat.history": { - const params = parseParams(); - if (!validateChatHistoryParams(params)) { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: `invalid chat.history params: ${formatValidationErrors(validateChatHistoryParams.errors)}`, - }, - }; - } - 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, entry?.sessionFile) - : []; - const max = typeof limit === "number" ? limit : 200; - 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.agents?.defaults?.thinkingDefault; - if (configured) { - thinkingLevel = configured; - } else { - const { provider, model } = resolveSessionModelRef(cfg, entry); - const catalog = await ctx.loadGatewayModelCatalog(); - thinkingLevel = resolveThinkingDefault({ - cfg, - provider, - model, - catalog, - }); - } - } - return { - ok: true, - payloadJSON: JSON.stringify({ - sessionKey, - sessionId, - messages: capped, - thinkingLevel, - }), - }; - } - case "chat.abort": { - const params = parseParams(); - if (!validateChatAbortParams(params)) { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: `invalid chat.abort params: ${formatValidationErrors(validateChatAbortParams.errors)}`, - }, - }; - } - - const { sessionKey, runId } = params as { - sessionKey: string; - runId?: string; - }; - const ops = { - chatAbortControllers: ctx.chatAbortControllers, - chatRunBuffers: ctx.chatRunBuffers, - chatDeltaSentAt: ctx.chatDeltaSentAt, - chatAbortedRuns: ctx.chatAbortedRuns, - removeChatRun: ctx.removeChatRun, - agentRunSeq: ctx.agentRunSeq, - broadcast: ctx.broadcast, - bridgeSendToSession: ctx.bridgeSendToSession, - }; - if (!runId) { - const res = abortChatRunsForSessionKey(ops, { - sessionKey, - stopReason: "rpc", - }); - return { - ok: true, - payloadJSON: JSON.stringify({ - ok: true, - aborted: res.aborted, - runIds: res.runIds, - }), - }; - } - const active = ctx.chatAbortControllers.get(runId); - if (!active) { - return { - ok: true, - payloadJSON: JSON.stringify({ - ok: true, - aborted: false, - runIds: [], - }), - }; - } - if (active.sessionKey !== sessionKey) { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: "runId does not match sessionKey", - }, - }; - } - const res = abortChatRunById(ops, { - runId, - sessionKey, - stopReason: "rpc", - }); - return { - ok: true, - payloadJSON: JSON.stringify({ - ok: true, - aborted: res.aborted, - runIds: res.aborted ? [runId] : [], - }), - }; - } - case "chat.send": { - const params = parseParams(); - if (!validateChatSendParams(params)) { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: `invalid chat.send params: ${formatValidationErrors(validateChatSendParams.errors)}`, - }, - }; - } - - 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 stopCommand = isChatStopCommandText(p.message); - 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, - })) - .filter((a) => a.content) ?? []; - - let parsedMessage = p.message; - let parsedImages: ChatImageContent[] = []; - if (normalizedAttachments.length > 0) { - try { - const parsed = await parseMessageWithAttachments( - p.message, - normalizedAttachments, - { maxBytes: 5_000_000, log: ctx.logBridge }, - ); - parsedMessage = parsed.message; - parsedImages = parsed.images; - } catch (err) { - return { - ok: false, - error: { - code: ErrorCodes.INVALID_REQUEST, - message: String(err), - }, - }; - } - } - - const { cfg, storePath, store, entry, canonicalKey } = - loadSessionEntry(p.sessionKey); - const timeoutMs = resolveAgentTimeoutMs({ - cfg, - overrideMs: p.timeoutMs, - }); - const now = Date.now(); - const sessionId = entry?.sessionId ?? randomUUID(); - const sessionEntry = mergeSessionEntry(entry, { - sessionId, - updatedAt: now, - }); - const clientRunId = p.idempotencyKey; - registerAgentRunContext(clientRunId, { sessionKey: p.sessionKey }); - - if (stopCommand) { - const res = abortChatRunsForSessionKey( - { - chatAbortControllers: ctx.chatAbortControllers, - chatRunBuffers: ctx.chatRunBuffers, - chatDeltaSentAt: ctx.chatDeltaSentAt, - chatAbortedRuns: ctx.chatAbortedRuns, - removeChatRun: ctx.removeChatRun, - agentRunSeq: ctx.agentRunSeq, - broadcast: ctx.broadcast, - bridgeSendToSession: ctx.bridgeSendToSession, - }, - { sessionKey: p.sessionKey, stopReason: "stop" }, - ); - return { - ok: true, - payloadJSON: JSON.stringify({ - ok: true, - aborted: res.aborted, - runIds: res.runIds, - }), - }; - } - - const cached = ctx.dedupe.get(`chat:${clientRunId}`); - if (cached) { - if (cached.ok) { - return { ok: true, payloadJSON: JSON.stringify(cached.payload) }; - } - return { - ok: false, - error: cached.error ?? { - code: ErrorCodes.UNAVAILABLE, - message: "request failed", - }, - }; - } - - const activeExisting = ctx.chatAbortControllers.get(clientRunId); - if (activeExisting) { - return { - ok: true, - payloadJSON: JSON.stringify({ - runId: clientRunId, - status: "in_flight", - }), - }; - } - - try { - const abortController = new AbortController(); - ctx.chatAbortControllers.set(clientRunId, { - controller: abortController, - sessionId, - sessionKey: p.sessionKey, - startedAtMs: now, - expiresAtMs: resolveChatRunExpiresAtMs({ now, timeoutMs }), - }); - ctx.addChatRun(clientRunId, { - sessionKey: p.sessionKey, - clientRunId, - }); - - if (store) { - store[canonicalKey] = sessionEntry; - if (storePath) { - await saveSessionStore(storePath, store); - } - } - - const ackPayload = { - runId: clientRunId, - status: "started" as const, - }; - void agentCommand( - { - message: parsedMessage, - images: parsedImages.length > 0 ? parsedImages : undefined, - sessionId, - sessionKey: p.sessionKey, - runId: clientRunId, - thinking: p.thinking, - deliver: p.deliver, - timeout: Math.ceil(timeoutMs / 1000).toString(), - messageChannel: `node(${nodeId})`, - abortSignal: abortController.signal, - }, - defaultRuntime, - ctx.deps, - ) - .then(() => { - ctx.dedupe.set(`chat:${clientRunId}`, { - ts: Date.now(), - ok: true, - payload: { runId: clientRunId, status: "ok" as const }, - }); - }) - .catch((err) => { - const error = errorShape(ErrorCodes.UNAVAILABLE, String(err)); - ctx.dedupe.set(`chat:${clientRunId}`, { - ts: Date.now(), - ok: false, - payload: { - runId: clientRunId, - status: "error" as const, - summary: String(err), - }, - error, - }); - }) - .finally(() => { - ctx.chatAbortControllers.delete(clientRunId); - }); - - return { ok: true, payloadJSON: JSON.stringify(ackPayload) }; - } catch (err) { - const error = errorShape(ErrorCodes.UNAVAILABLE, String(err)); - const payload = { - runId: clientRunId, - status: "error" as const, - summary: String(err), - }; - ctx.dedupe.set(`chat:${clientRunId}`, { - ts: Date.now(), - ok: false, - payload, - error, - }); - return { - ok: false, - error: error ?? { - code: ErrorCodes.UNAVAILABLE, - message: String(err), - }, - }; - } - } - default: - return { - ok: false, - error: { - code: "FORBIDDEN", - message: "Method not allowed", - details: { method }, - }, - }; - } + const params = parseParams(); + const response = + (await handleSystemBridgeMethods(ctx, nodeId, method, params)) ?? + (await handleConfigBridgeMethods(ctx, nodeId, method, params)) ?? + (await handleSessionsBridgeMethods(ctx, nodeId, method, params)) ?? + (await handleChatBridgeMethods(ctx, nodeId, method, params)); + if (response) return response; + return { + ok: false, + error: { + code: "FORBIDDEN", + message: "Method not allowed", + details: { method }, + }, + }; } catch (err) { return { ok: false, @@ -1036,192 +54,8 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { } }; - const handleBridgeEvent = async ( - nodeId: string, - evt: { event: string; payloadJSON?: string | null }, - ) => { - switch (evt.event) { - case "voice.transcript": { - if (!evt.payloadJSON) return; - let payload: unknown; - try { - payload = JSON.parse(evt.payloadJSON) as unknown; - } catch { - return; - } - const obj = - typeof payload === "object" && payload !== null - ? (payload as Record) - : {}; - const text = typeof obj.text === "string" ? obj.text.trim() : ""; - if (!text) return; - if (text.length > 20_000) return; - const sessionKeyRaw = - typeof obj.sessionKey === "string" ? obj.sessionKey.trim() : ""; - const cfg = loadConfig(); - const rawMainKey = normalizeMainKey(cfg.session?.mainKey); - const sessionKey = - sessionKeyRaw.length > 0 ? sessionKeyRaw : rawMainKey; - const { storePath, store, entry, canonicalKey } = - loadSessionEntry(sessionKey); - const now = Date.now(); - const sessionId = entry?.sessionId ?? randomUUID(); - store[canonicalKey] = { - sessionId, - updatedAt: now, - thinkingLevel: entry?.thinkingLevel, - verboseLevel: entry?.verboseLevel, - reasoningLevel: entry?.reasoningLevel, - systemSent: entry?.systemSent, - sendPolicy: entry?.sendPolicy, - lastChannel: entry?.lastChannel, - lastTo: entry?.lastTo, - }; - if (storePath) { - await saveSessionStore(storePath, store); - } - - // Ensure chat UI clients refresh when this run completes (even though it wasn't started via chat.send). - // This maps agent bus events (keyed by sessionId) to chat events (keyed by clientRunId). - ctx.addChatRun(sessionId, { - sessionKey, - clientRunId: `voice-${randomUUID()}`, - }); - - void agentCommand( - { - message: text, - sessionId, - sessionKey, - thinking: "low", - deliver: false, - messageChannel: "node", - }, - defaultRuntime, - ctx.deps, - ).catch((err) => { - ctx.logBridge.warn( - `agent failed node=${nodeId}: ${formatForLog(err)}`, - ); - }); - return; - } - case "agent.request": { - if (!evt.payloadJSON) return; - type AgentDeepLink = { - message?: string; - sessionKey?: string | null; - thinking?: string | null; - deliver?: boolean; - to?: string | null; - channel?: string | null; - timeoutSeconds?: number | null; - key?: string | null; - }; - let link: AgentDeepLink | null = null; - try { - link = JSON.parse(evt.payloadJSON) as AgentDeepLink; - } catch { - return; - } - const message = (link?.message ?? "").trim(); - if (!message) return; - if (message.length > 20_000) return; - - const channelRaw = - typeof link?.channel === "string" ? link.channel.trim() : ""; - const channel = normalizeChannelId(channelRaw) ?? undefined; - const to = - typeof link?.to === "string" && link.to.trim() - ? link.to.trim() - : undefined; - const deliver = Boolean(link?.deliver) && Boolean(channel); - - const sessionKeyRaw = (link?.sessionKey ?? "").trim(); - const sessionKey = - sessionKeyRaw.length > 0 ? sessionKeyRaw : `node-${nodeId}`; - const { storePath, store, entry, canonicalKey } = - loadSessionEntry(sessionKey); - const now = Date.now(); - const sessionId = entry?.sessionId ?? randomUUID(); - store[canonicalKey] = { - sessionId, - updatedAt: now, - thinkingLevel: entry?.thinkingLevel, - verboseLevel: entry?.verboseLevel, - reasoningLevel: entry?.reasoningLevel, - systemSent: entry?.systemSent, - sendPolicy: entry?.sendPolicy, - lastChannel: entry?.lastChannel, - lastTo: entry?.lastTo, - }; - if (storePath) { - await saveSessionStore(storePath, store); - } - - void agentCommand( - { - message, - sessionId, - sessionKey, - thinking: link?.thinking ?? undefined, - deliver, - to, - channel, - timeout: - typeof link?.timeoutSeconds === "number" - ? link.timeoutSeconds.toString() - : undefined, - messageChannel: "node", - }, - defaultRuntime, - ctx.deps, - ).catch((err) => { - ctx.logBridge.warn( - `agent failed node=${nodeId}: ${formatForLog(err)}`, - ); - }); - return; - } - case "chat.subscribe": { - if (!evt.payloadJSON) return; - let payload: unknown; - try { - payload = JSON.parse(evt.payloadJSON) as unknown; - } catch { - return; - } - const obj = - typeof payload === "object" && payload !== null - ? (payload as Record) - : {}; - const sessionKey = - typeof obj.sessionKey === "string" ? obj.sessionKey.trim() : ""; - if (!sessionKey) return; - ctx.bridgeSubscribe(nodeId, sessionKey); - return; - } - case "chat.unsubscribe": { - if (!evt.payloadJSON) return; - let payload: unknown; - try { - payload = JSON.parse(evt.payloadJSON) as unknown; - } catch { - return; - } - const obj = - typeof payload === "object" && payload !== null - ? (payload as Record) - : {}; - const sessionKey = - typeof obj.sessionKey === "string" ? obj.sessionKey.trim() : ""; - if (!sessionKey) return; - ctx.bridgeUnsubscribe(nodeId, sessionKey); - return; - } - default: - return; - } + const handleBridgeEvent = async (nodeId: string, evt: BridgeEvent) => { + await handleBridgeEventImpl(ctx, nodeId, evt); }; return { handleBridgeRequest, handleBridgeEvent }; diff --git a/src/gateway/server-broadcast.ts b/src/gateway/server-broadcast.ts new file mode 100644 index 000000000..96f4a8ee8 --- /dev/null +++ b/src/gateway/server-broadcast.ts @@ -0,0 +1,56 @@ +import type { GatewayWsClient } from "./server/ws-types.js"; +import { MAX_BUFFERED_BYTES } from "./server-constants.js"; +import { logWs, summarizeAgentEventForWsLog } from "./ws-log.js"; + +export function createGatewayBroadcaster(params: { + clients: Set; +}) { + let seq = 0; + const broadcast = ( + event: string, + payload: unknown, + opts?: { + dropIfSlow?: boolean; + stateVersion?: { presence?: number; health?: number }; + }, + ) => { + const eventSeq = ++seq; + const frame = JSON.stringify({ + type: "event", + event, + payload, + seq: eventSeq, + stateVersion: opts?.stateVersion, + }); + const logMeta: Record = { + event, + seq: eventSeq, + clients: params.clients.size, + dropIfSlow: opts?.dropIfSlow, + presenceVersion: opts?.stateVersion?.presence, + healthVersion: opts?.stateVersion?.health, + }; + if (event === "agent") { + Object.assign(logMeta, summarizeAgentEventForWsLog(payload)); + } + logWs("out", "event", logMeta); + for (const c of params.clients) { + const slow = c.socket.bufferedAmount > MAX_BUFFERED_BYTES; + if (slow && opts?.dropIfSlow) continue; + if (slow) { + try { + c.socket.close(1008, "slow consumer"); + } catch { + /* ignore */ + } + continue; + } + try { + c.socket.send(frame); + } catch { + /* ignore */ + } + } + }; + return { broadcast }; +} diff --git a/src/gateway/server-close.ts b/src/gateway/server-close.ts new file mode 100644 index 000000000..c76000749 --- /dev/null +++ b/src/gateway/server-close.ts @@ -0,0 +1,138 @@ +import type { Server as HttpServer } from "node:http"; +import type { WebSocketServer } from "ws"; +import type { + CanvasHostHandler, + CanvasHostServer, +} from "../canvas-host/server.js"; +import { + type ChannelId, + listChannelPlugins, +} from "../channels/plugins/index.js"; +import { stopGmailWatcher } from "../hooks/gmail-watcher.js"; +import type { NodeBridgeServer } from "../infra/bridge/server.js"; +import type { PluginServicesHandle } from "../plugins/services.js"; + +export function createGatewayCloseHandler(params: { + bonjourStop: (() => Promise) | null; + tailscaleCleanup: (() => Promise) | null; + canvasHost: CanvasHostHandler | null; + canvasHostServer: CanvasHostServer | null; + bridge: NodeBridgeServer | null; + stopChannel: (name: ChannelId, accountId?: string) => Promise; + pluginServices: PluginServicesHandle | null; + cron: { stop: () => void }; + heartbeatRunner: { stop: () => void }; + nodePresenceTimers: Map>; + broadcast: ( + event: string, + payload: unknown, + opts?: { dropIfSlow?: boolean }, + ) => void; + tickInterval: ReturnType; + healthInterval: ReturnType; + dedupeCleanup: ReturnType; + agentUnsub: (() => void) | null; + heartbeatUnsub: (() => void) | null; + chatRunState: { clear: () => void }; + clients: Set<{ socket: { close: (code: number, reason: string) => void } }>; + configReloader: { stop: () => Promise }; + browserControl: { stop: () => Promise } | null; + wss: WebSocketServer; + httpServer: HttpServer; +}) { + return async (opts?: { + reason?: string; + restartExpectedMs?: number | null; + }) => { + const reasonRaw = + typeof opts?.reason === "string" ? opts.reason.trim() : ""; + const reason = reasonRaw || "gateway stopping"; + const restartExpectedMs = + typeof opts?.restartExpectedMs === "number" && + Number.isFinite(opts.restartExpectedMs) + ? Math.max(0, Math.floor(opts.restartExpectedMs)) + : null; + if (params.bonjourStop) { + try { + await params.bonjourStop(); + } catch { + /* ignore */ + } + } + if (params.tailscaleCleanup) { + await params.tailscaleCleanup(); + } + if (params.canvasHost) { + try { + await params.canvasHost.close(); + } catch { + /* ignore */ + } + } + if (params.canvasHostServer) { + try { + await params.canvasHostServer.close(); + } catch { + /* ignore */ + } + } + if (params.bridge) { + try { + await params.bridge.close(); + } catch { + /* ignore */ + } + } + for (const plugin of listChannelPlugins()) { + await params.stopChannel(plugin.id); + } + if (params.pluginServices) { + await params.pluginServices.stop().catch(() => {}); + } + await stopGmailWatcher(); + params.cron.stop(); + params.heartbeatRunner.stop(); + for (const timer of params.nodePresenceTimers.values()) { + clearInterval(timer); + } + params.nodePresenceTimers.clear(); + params.broadcast("shutdown", { + reason, + restartExpectedMs, + }); + clearInterval(params.tickInterval); + clearInterval(params.healthInterval); + clearInterval(params.dedupeCleanup); + if (params.agentUnsub) { + try { + params.agentUnsub(); + } catch { + /* ignore */ + } + } + if (params.heartbeatUnsub) { + try { + params.heartbeatUnsub(); + } catch { + /* ignore */ + } + } + params.chatRunState.clear(); + for (const c of params.clients) { + try { + c.socket.close(1012, "service restart"); + } catch { + /* ignore */ + } + } + params.clients.clear(); + await params.configReloader.stop().catch(() => {}); + if (params.browserControl) { + await params.browserControl.stop().catch(() => {}); + } + await new Promise((resolve) => params.wss.close(() => resolve())); + await new Promise((resolve, reject) => + params.httpServer.close((err) => (err ? reject(err) : resolve())), + ); + }; +} diff --git a/src/gateway/server-cron.ts b/src/gateway/server-cron.ts new file mode 100644 index 000000000..2860edcd9 --- /dev/null +++ b/src/gateway/server-cron.ts @@ -0,0 +1,119 @@ +import { resolveDefaultAgentId } from "../agents/agent-scope.js"; +import type { CliDeps } from "../cli/deps.js"; +import { loadConfig } from "../config/config.js"; +import { resolveAgentMainSessionKey } from "../config/sessions.js"; +import { runCronIsolatedAgentTurn } from "../cron/isolated-agent.js"; +import { appendCronRunLog, resolveCronRunLogPath } from "../cron/run-log.js"; +import { CronService } from "../cron/service.js"; +import { resolveCronStorePath } from "../cron/store.js"; +import { runHeartbeatOnce } from "../infra/heartbeat-runner.js"; +import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; +import { enqueueSystemEvent } from "../infra/system-events.js"; +import { getChildLogger } from "../logging.js"; +import { normalizeAgentId } from "../routing/session-key.js"; +import { defaultRuntime } from "../runtime.js"; + +export type GatewayCronState = { + cron: CronService; + storePath: string; + cronEnabled: boolean; +}; + +export function buildGatewayCronService(params: { + cfg: ReturnType; + deps: CliDeps; + broadcast: ( + event: string, + payload: unknown, + opts?: { dropIfSlow?: boolean }, + ) => void; +}): GatewayCronState { + const cronLogger = getChildLogger({ module: "cron" }); + const storePath = resolveCronStorePath(params.cfg.cron?.store); + const cronEnabled = + process.env.CLAWDBOT_SKIP_CRON !== "1" && + params.cfg.cron?.enabled !== false; + + const resolveCronAgent = (requested?: string | null) => { + const runtimeConfig = loadConfig(); + const normalized = + typeof requested === "string" && requested.trim() + ? normalizeAgentId(requested) + : undefined; + const hasAgent = + normalized !== undefined && + Array.isArray(runtimeConfig.agents?.list) && + runtimeConfig.agents.list.some( + (entry) => + entry && + typeof entry.id === "string" && + normalizeAgentId(entry.id) === normalized, + ); + const agentId = hasAgent + ? normalized + : resolveDefaultAgentId(runtimeConfig); + return { agentId, cfg: runtimeConfig }; + }; + + const cron = new CronService({ + storePath, + cronEnabled, + enqueueSystemEvent: (text, opts) => { + const { agentId, cfg: runtimeConfig } = resolveCronAgent(opts?.agentId); + const sessionKey = resolveAgentMainSessionKey({ + cfg: runtimeConfig, + agentId, + }); + enqueueSystemEvent(text, { sessionKey }); + }, + requestHeartbeatNow, + runHeartbeatOnce: async (opts) => { + const runtimeConfig = loadConfig(); + return await runHeartbeatOnce({ + cfg: runtimeConfig, + reason: opts?.reason, + deps: { ...params.deps, runtime: defaultRuntime }, + }); + }, + runIsolatedAgentJob: async ({ job, message }) => { + const { agentId, cfg: runtimeConfig } = resolveCronAgent(job.agentId); + return await runCronIsolatedAgentTurn({ + cfg: runtimeConfig, + deps: params.deps, + job, + message, + agentId, + sessionKey: `cron:${job.id}`, + lane: "cron", + }); + }, + log: getChildLogger({ module: "cron", storePath }), + onEvent: (evt) => { + params.broadcast("cron", evt, { dropIfSlow: true }); + if (evt.action === "finished") { + const logPath = resolveCronRunLogPath({ + storePath, + jobId: evt.jobId, + }); + void appendCronRunLog(logPath, { + ts: Date.now(), + jobId: evt.jobId, + action: "finished", + status: evt.status, + error: evt.error, + summary: evt.summary, + runAtMs: evt.runAtMs, + durationMs: evt.durationMs, + nextRunAtMs: evt.nextRunAtMs, + }).catch((err) => { + cronLogger.warn( + { err: String(err), logPath }, + "cron: run log append failed", + ); + }); + } + }, + }); + + return { cron, storePath, cronEnabled }; +} diff --git a/src/gateway/server-discovery-runtime.ts b/src/gateway/server-discovery-runtime.ts new file mode 100644 index 000000000..79662c7f0 --- /dev/null +++ b/src/gateway/server-discovery-runtime.ts @@ -0,0 +1,79 @@ +import { startGatewayBonjourAdvertiser } from "../infra/bonjour.js"; +import { + pickPrimaryTailnetIPv4, + pickPrimaryTailnetIPv6, +} from "../infra/tailnet.js"; +import { + WIDE_AREA_DISCOVERY_DOMAIN, + writeWideAreaBridgeZone, +} from "../infra/widearea-dns.js"; +import { + formatBonjourInstanceName, + resolveBonjourCliPath, + resolveTailnetDnsHint, +} from "./server-discovery.js"; + +export async function startGatewayDiscovery(params: { + machineDisplayName: string; + port: number; + bridgePort?: number; + canvasPort?: number; + wideAreaDiscoveryEnabled: boolean; + logDiscovery: { info: (msg: string) => void; warn: (msg: string) => void }; +}) { + let bonjourStop: (() => Promise) | null = null; + const tailnetDns = await resolveTailnetDnsHint(); + const sshPortEnv = process.env.CLAWDBOT_SSH_PORT?.trim(); + const sshPortParsed = sshPortEnv ? Number.parseInt(sshPortEnv, 10) : NaN; + const sshPort = + Number.isFinite(sshPortParsed) && sshPortParsed > 0 + ? sshPortParsed + : undefined; + + try { + const bonjour = await startGatewayBonjourAdvertiser({ + instanceName: formatBonjourInstanceName(params.machineDisplayName), + gatewayPort: params.port, + bridgePort: params.bridgePort, + canvasPort: params.canvasPort, + sshPort, + tailnetDns, + cliPath: resolveBonjourCliPath(), + }); + bonjourStop = bonjour.stop; + } catch (err) { + params.logDiscovery.warn(`bonjour advertising failed: ${String(err)}`); + } + + if (params.wideAreaDiscoveryEnabled && params.bridgePort) { + const tailnetIPv4 = pickPrimaryTailnetIPv4(); + if (!tailnetIPv4) { + params.logDiscovery.warn( + "discovery.wideArea.enabled is true, but no Tailscale IPv4 address was found; skipping unicast DNS-SD zone update", + ); + } else { + try { + const tailnetIPv6 = pickPrimaryTailnetIPv6(); + const result = await writeWideAreaBridgeZone({ + bridgePort: params.bridgePort, + gatewayPort: params.port, + displayName: formatBonjourInstanceName(params.machineDisplayName), + tailnetIPv4, + tailnetIPv6: tailnetIPv6 ?? undefined, + tailnetDns, + sshPort, + cliPath: resolveBonjourCliPath(), + }); + params.logDiscovery.info( + `wide-area DNS-SD ${result.changed ? "updated" : "unchanged"} (${WIDE_AREA_DISCOVERY_DOMAIN} → ${result.zonePath})`, + ); + } catch (err) { + params.logDiscovery.warn( + `wide-area discovery update failed: ${String(err)}`, + ); + } + } + } + + return { bonjourStop }; +} diff --git a/src/gateway/server-lanes.ts b/src/gateway/server-lanes.ts new file mode 100644 index 000000000..1e887fa89 --- /dev/null +++ b/src/gateway/server-lanes.ts @@ -0,0 +1,13 @@ +import type { loadConfig } from "../config/config.js"; +import { setCommandLaneConcurrency } from "../process/command-queue.js"; + +export function applyGatewayLaneConcurrency( + cfg: ReturnType, +) { + setCommandLaneConcurrency("cron", cfg.cron?.maxConcurrentRuns ?? 1); + setCommandLaneConcurrency("main", cfg.agents?.defaults?.maxConcurrent ?? 1); + setCommandLaneConcurrency( + "subagent", + cfg.agents?.defaults?.subagents?.maxConcurrent ?? 1, + ); +} diff --git a/src/gateway/server-maintenance.ts b/src/gateway/server-maintenance.ts new file mode 100644 index 000000000..90c3f6507 --- /dev/null +++ b/src/gateway/server-maintenance.ts @@ -0,0 +1,129 @@ +import type { HealthSummary } from "../commands/health.js"; +import { + abortChatRunById, + type ChatAbortControllerEntry, +} from "./chat-abort.js"; +import { setBroadcastHealthUpdate } from "./server/health-state.js"; +import type { ChatRunEntry } from "./server-chat.js"; +import { + DEDUPE_MAX, + DEDUPE_TTL_MS, + HEALTH_REFRESH_INTERVAL_MS, + TICK_INTERVAL_MS, +} from "./server-constants.js"; +import type { DedupeEntry } from "./server-shared.js"; +import { formatError } from "./server-utils.js"; + +export function startGatewayMaintenanceTimers(params: { + broadcast: ( + event: string, + payload: unknown, + opts?: { + dropIfSlow?: boolean; + stateVersion?: { presence?: number; health?: number }; + }, + ) => void; + bridgeSendToAllSubscribed: (event: string, payload: unknown) => void; + getPresenceVersion: () => number; + getHealthVersion: () => number; + refreshGatewayHealthSnapshot: (opts?: { + probe?: boolean; + }) => Promise; + logHealth: { error: (msg: string) => void }; + dedupe: Map; + chatAbortControllers: Map; + chatRunState: { abortedRuns: Map }; + chatRunBuffers: Map; + chatDeltaSentAt: Map; + removeChatRun: ( + sessionId: string, + clientRunId: string, + sessionKey?: string, + ) => ChatRunEntry | undefined; + agentRunSeq: Map; + bridgeSendToSession: ( + sessionKey: string, + event: string, + payload: unknown, + ) => void; +}): { + tickInterval: ReturnType; + healthInterval: ReturnType; + dedupeCleanup: ReturnType; +} { + setBroadcastHealthUpdate((snap: HealthSummary) => { + params.broadcast("health", snap, { + stateVersion: { + presence: params.getPresenceVersion(), + health: params.getHealthVersion(), + }, + }); + params.bridgeSendToAllSubscribed("health", snap); + }); + + // periodic keepalive + const tickInterval = setInterval(() => { + const payload = { ts: Date.now() }; + params.broadcast("tick", payload, { dropIfSlow: true }); + params.bridgeSendToAllSubscribed("tick", payload); + }, TICK_INTERVAL_MS); + + // periodic health refresh to keep cached snapshot warm + const healthInterval = setInterval(() => { + void params + .refreshGatewayHealthSnapshot({ probe: true }) + .catch((err) => + params.logHealth.error(`refresh failed: ${formatError(err)}`), + ); + }, HEALTH_REFRESH_INTERVAL_MS); + + // Prime cache so first client gets a snapshot without waiting. + void params + .refreshGatewayHealthSnapshot({ probe: true }) + .catch((err) => + params.logHealth.error(`initial refresh failed: ${formatError(err)}`), + ); + + // dedupe cache cleanup + const dedupeCleanup = setInterval(() => { + const now = Date.now(); + for (const [k, v] of params.dedupe) { + if (now - v.ts > DEDUPE_TTL_MS) params.dedupe.delete(k); + } + if (params.dedupe.size > DEDUPE_MAX) { + const entries = [...params.dedupe.entries()].sort( + (a, b) => a[1].ts - b[1].ts, + ); + for (let i = 0; i < params.dedupe.size - DEDUPE_MAX; i++) { + params.dedupe.delete(entries[i][0]); + } + } + + for (const [runId, entry] of params.chatAbortControllers) { + if (now <= entry.expiresAtMs) continue; + abortChatRunById( + { + chatAbortControllers: params.chatAbortControllers, + chatRunBuffers: params.chatRunBuffers, + chatDeltaSentAt: params.chatDeltaSentAt, + chatAbortedRuns: params.chatRunState.abortedRuns, + removeChatRun: params.removeChatRun, + agentRunSeq: params.agentRunSeq, + broadcast: params.broadcast, + bridgeSendToSession: params.bridgeSendToSession, + }, + { runId, sessionKey: entry.sessionKey, stopReason: "timeout" }, + ); + } + + const ABORTED_RUN_TTL_MS = 60 * 60_000; + for (const [runId, abortedAt] of params.chatRunState.abortedRuns) { + if (now - abortedAt <= ABORTED_RUN_TTL_MS) continue; + params.chatRunState.abortedRuns.delete(runId); + params.chatRunBuffers.delete(runId); + params.chatDeltaSentAt.delete(runId); + } + }, 60_000); + + return { tickInterval, healthInterval, dedupeCleanup }; +} diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts new file mode 100644 index 000000000..b818dcd88 --- /dev/null +++ b/src/gateway/server-methods-list.ts @@ -0,0 +1,83 @@ +import { listChannelPlugins } from "../channels/plugins/index.js"; + +const BASE_METHODS = [ + "health", + "logs.tail", + "channels.status", + "channels.logout", + "status", + "usage.status", + "config.get", + "config.set", + "config.apply", + "config.schema", + "wizard.start", + "wizard.next", + "wizard.cancel", + "wizard.status", + "talk.mode", + "models.list", + "agents.list", + "skills.status", + "skills.install", + "skills.update", + "update.run", + "voicewake.get", + "voicewake.set", + "sessions.list", + "sessions.patch", + "sessions.reset", + "sessions.delete", + "sessions.compact", + "last-heartbeat", + "set-heartbeats", + "wake", + "node.pair.request", + "node.pair.list", + "node.pair.approve", + "node.pair.reject", + "node.pair.verify", + "node.rename", + "node.list", + "node.describe", + "node.invoke", + "cron.list", + "cron.status", + "cron.add", + "cron.update", + "cron.remove", + "cron.run", + "cron.runs", + "system-presence", + "system-event", + "send", + "agent", + "agent.wait", + // WebChat WebSocket-native chat methods + "chat.history", + "chat.abort", + "chat.send", +]; + +const CHANNEL_METHODS = listChannelPlugins().flatMap( + (plugin) => plugin.gatewayMethods ?? [], +); + +export const GATEWAY_METHODS = Array.from( + new Set([...BASE_METHODS, ...CHANNEL_METHODS]), +); + +export const GATEWAY_EVENTS = [ + "agent", + "chat", + "presence", + "tick", + "talk.mode", + "shutdown", + "health", + "heartbeat", + "cron", + "node.pair.requested", + "node.pair.resolved", + "voicewake.changed", +]; diff --git a/src/gateway/server-mobile-nodes.ts b/src/gateway/server-mobile-nodes.ts new file mode 100644 index 000000000..b96e8921f --- /dev/null +++ b/src/gateway/server-mobile-nodes.ts @@ -0,0 +1,16 @@ +type BridgeLike = { + listConnected?: () => Array<{ platform?: string | null }>; +}; + +const isMobilePlatform = (platform: unknown): boolean => { + const p = typeof platform === "string" ? platform.trim().toLowerCase() : ""; + if (!p) return false; + return ( + p.startsWith("ios") || p.startsWith("ipados") || p.startsWith("android") + ); +}; + +export function hasConnectedMobileNode(bridge: BridgeLike | null): boolean { + const connected = bridge?.listConnected?.() ?? []; + return connected.some((n) => isMobilePlatform(n.platform)); +} diff --git a/src/gateway/server-model-catalog.ts b/src/gateway/server-model-catalog.ts new file mode 100644 index 000000000..7f72fbc4e --- /dev/null +++ b/src/gateway/server-model-catalog.ts @@ -0,0 +1,19 @@ +import { + loadModelCatalog, + type ModelCatalogEntry, + resetModelCatalogCacheForTest, +} from "../agents/model-catalog.js"; +import { loadConfig } from "../config/config.js"; + +export type GatewayModelChoice = ModelCatalogEntry; + +// Test-only escape hatch: model catalog is cached at module scope for the +// process lifetime, which is fine for the real gateway daemon, but makes +// isolated unit tests harder. Keep this intentionally obscure. +export function __resetModelCatalogCacheForTest() { + resetModelCatalogCacheForTest(); +} + +export async function loadGatewayModelCatalog(): Promise { + return await loadModelCatalog({ config: loadConfig() }); +} diff --git a/src/gateway/server-node-bridge.ts b/src/gateway/server-node-bridge.ts new file mode 100644 index 000000000..55bfb004f --- /dev/null +++ b/src/gateway/server-node-bridge.ts @@ -0,0 +1,171 @@ +import type { NodeBridgeServer } from "../infra/bridge/server.js"; +import { startNodeBridgeServer } from "../infra/bridge/server.js"; +import { + listSystemPresence, + upsertPresence, +} from "../infra/system-presence.js"; +import { loadVoiceWakeConfig } from "../infra/voicewake.js"; +import { isLoopbackAddress } from "./net.js"; +import { + getHealthVersion, + getPresenceVersion, + incrementPresenceVersion, +} from "./server/health-state.js"; +import type { + BridgeEvent, + BridgeRequest, + BridgeResponse, +} from "./server-bridge-types.js"; + +export type GatewayNodeBridgeRuntime = { + bridge: NodeBridgeServer | null; + nodePresenceTimers: Map>; +}; + +export async function startGatewayNodeBridge(params: { + bridgeEnabled: boolean; + bridgePort: number; + bridgeHost: string | null; + machineDisplayName: string; + canvasHostPort?: number; + canvasHostHost?: string; + broadcast: ( + event: string, + payload: unknown, + opts?: { + dropIfSlow?: boolean; + stateVersion?: { presence?: number; health?: number }; + }, + ) => void; + bridgeUnsubscribeAll: (nodeId: string) => void; + handleBridgeRequest: ( + nodeId: string, + req: BridgeRequest, + ) => Promise; + handleBridgeEvent: (nodeId: string, evt: BridgeEvent) => Promise | void; + logBridge: { info: (msg: string) => void; warn: (msg: string) => void }; +}): Promise { + const nodePresenceTimers = new Map>(); + + const stopNodePresenceTimer = (nodeId: string) => { + const timer = nodePresenceTimers.get(nodeId); + if (timer) { + clearInterval(timer); + } + nodePresenceTimers.delete(nodeId); + }; + + const beaconNodePresence = ( + node: { + nodeId: string; + displayName?: string; + remoteIp?: string; + version?: string; + platform?: string; + deviceFamily?: string; + modelIdentifier?: string; + }, + reason: string, + ) => { + const host = node.displayName?.trim() || node.nodeId; + const rawIp = node.remoteIp?.trim(); + const ip = rawIp && !isLoopbackAddress(rawIp) ? rawIp : undefined; + const version = node.version?.trim() || "unknown"; + const platform = node.platform?.trim() || undefined; + const deviceFamily = node.deviceFamily?.trim() || undefined; + const modelIdentifier = node.modelIdentifier?.trim() || undefined; + const text = `Node: ${host}${ip ? ` (${ip})` : ""} · app ${version} · last input 0s ago · mode remote · reason ${reason}`; + upsertPresence(node.nodeId, { + host, + ip, + version, + platform, + deviceFamily, + modelIdentifier, + mode: "remote", + reason, + lastInputSeconds: 0, + instanceId: node.nodeId, + text, + }); + incrementPresenceVersion(); + params.broadcast( + "presence", + { presence: listSystemPresence() }, + { + dropIfSlow: true, + stateVersion: { + presence: getPresenceVersion(), + health: getHealthVersion(), + }, + }, + ); + }; + + const startNodePresenceTimer = (node: { nodeId: string }) => { + stopNodePresenceTimer(node.nodeId); + nodePresenceTimers.set( + node.nodeId, + setInterval(() => { + beaconNodePresence(node, "periodic"); + }, 180_000), + ); + }; + + if (params.bridgeEnabled && params.bridgePort > 0 && params.bridgeHost) { + try { + const started = await startNodeBridgeServer({ + host: params.bridgeHost, + port: params.bridgePort, + serverName: params.machineDisplayName, + canvasHostPort: params.canvasHostPort, + canvasHostHost: params.canvasHostHost, + onRequest: (nodeId, req) => params.handleBridgeRequest(nodeId, req), + onAuthenticated: async (node) => { + beaconNodePresence(node, "node-connected"); + startNodePresenceTimer(node); + + try { + const cfg = await loadVoiceWakeConfig(); + started.sendEvent({ + nodeId: node.nodeId, + event: "voicewake.changed", + payloadJSON: JSON.stringify({ triggers: cfg.triggers }), + }); + } catch { + // Best-effort only. + } + }, + onDisconnected: (node) => { + params.bridgeUnsubscribeAll(node.nodeId); + stopNodePresenceTimer(node.nodeId); + beaconNodePresence(node, "node-disconnected"); + }, + onEvent: params.handleBridgeEvent, + onPairRequested: (request) => { + params.broadcast("node.pair.requested", request, { + dropIfSlow: true, + }); + }, + }); + if (started.port > 0) { + params.logBridge.info( + `listening on tcp://${params.bridgeHost}:${started.port} (node)`, + ); + return { bridge: started, nodePresenceTimers }; + } + } catch (err) { + params.logBridge.warn(`failed to start: ${String(err)}`); + } + } else if ( + params.bridgeEnabled && + params.bridgePort > 0 && + !params.bridgeHost + ) { + params.logBridge.warn( + "bind policy requested tailnet IP, but no tailnet interface was found; refusing to start bridge", + ); + } + + return { bridge: null, nodePresenceTimers }; +} diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts new file mode 100644 index 000000000..037a54807 --- /dev/null +++ b/src/gateway/server-plugins.ts @@ -0,0 +1,42 @@ +import type { loadConfig } from "../config/config.js"; +import { loadClawdbotPlugins } from "../plugins/loader.js"; +import type { GatewayRequestHandler } from "./server-methods/types.js"; + +export function loadGatewayPlugins(params: { + cfg: ReturnType; + workspaceDir: string; + log: { + info: (msg: string) => void; + warn: (msg: string) => void; + error: (msg: string) => void; + debug: (msg: string) => void; + }; + coreGatewayHandlers: Record; + baseMethods: string[]; +}) { + const pluginRegistry = loadClawdbotPlugins({ + config: params.cfg, + workspaceDir: params.workspaceDir, + logger: { + info: (msg) => params.log.info(msg), + warn: (msg) => params.log.warn(msg), + error: (msg) => params.log.error(msg), + debug: (msg) => params.log.debug(msg), + }, + coreGatewayHandlers: params.coreGatewayHandlers, + }); + const pluginMethods = Object.keys(pluginRegistry.gatewayHandlers); + const gatewayMethods = Array.from( + new Set([...params.baseMethods, ...pluginMethods]), + ); + if (pluginRegistry.diagnostics.length > 0) { + for (const diag of pluginRegistry.diagnostics) { + if (diag.level === "error") { + params.log.warn(`[plugins] ${diag.message}`); + } else { + params.log.info(`[plugins] ${diag.message}`); + } + } + } + return { pluginRegistry, gatewayMethods }; +} diff --git a/src/gateway/server-reload-handlers.ts b/src/gateway/server-reload-handlers.ts new file mode 100644 index 000000000..569b3e8c3 --- /dev/null +++ b/src/gateway/server-reload-handlers.ts @@ -0,0 +1,178 @@ +import type { CliDeps } from "../cli/deps.js"; +import type { loadConfig } from "../config/config.js"; +import { startGmailWatcher, stopGmailWatcher } from "../hooks/gmail-watcher.js"; +import { startHeartbeatRunner } from "../infra/heartbeat-runner.js"; +import { setCommandLaneConcurrency } from "../process/command-queue.js"; +import type { ChannelKind, GatewayReloadPlan } from "./config-reload.js"; +import { resolveHooksConfig } from "./hooks.js"; +import { startBrowserControlServerIfEnabled } from "./server-browser.js"; +import { + buildGatewayCronService, + type GatewayCronState, +} from "./server-cron.js"; + +type GatewayHotReloadState = { + hooksConfig: ReturnType; + heartbeatRunner: { stop: () => void }; + cronState: GatewayCronState; + browserControl: Awaited< + ReturnType + > | null; +}; + +export function createGatewayReloadHandlers(params: { + deps: CliDeps; + broadcast: ( + event: string, + payload: unknown, + opts?: { dropIfSlow?: boolean }, + ) => void; + getState: () => GatewayHotReloadState; + setState: (state: GatewayHotReloadState) => void; + startChannel: (name: ChannelKind) => Promise; + stopChannel: (name: ChannelKind) => Promise; + logHooks: { + info: (msg: string) => void; + warn: (msg: string) => void; + error: (msg: string) => void; + }; + logBrowser: { error: (msg: string) => void }; + logChannels: { info: (msg: string) => void; error: (msg: string) => void }; + logCron: { error: (msg: string) => void }; + logReload: { info: (msg: string) => void; warn: (msg: string) => void }; +}) { + const applyHotReload = async ( + plan: GatewayReloadPlan, + nextConfig: ReturnType, + ) => { + const state = params.getState(); + const nextState = { ...state }; + + if (plan.reloadHooks) { + try { + nextState.hooksConfig = resolveHooksConfig(nextConfig); + } catch (err) { + params.logHooks.warn(`hooks config reload failed: ${String(err)}`); + } + } + + if (plan.restartHeartbeat) { + state.heartbeatRunner.stop(); + nextState.heartbeatRunner = startHeartbeatRunner({ cfg: nextConfig }); + } + + if (plan.restartCron) { + state.cronState.cron.stop(); + nextState.cronState = buildGatewayCronService({ + cfg: nextConfig, + deps: params.deps, + broadcast: params.broadcast, + }); + void nextState.cronState.cron + .start() + .catch((err) => + params.logCron.error(`failed to start: ${String(err)}`), + ); + } + + if (plan.restartBrowserControl) { + if (state.browserControl) { + await state.browserControl.stop().catch(() => {}); + } + try { + nextState.browserControl = await startBrowserControlServerIfEnabled(); + } catch (err) { + params.logBrowser.error(`server failed to start: ${String(err)}`); + } + } + + if (plan.restartGmailWatcher) { + await stopGmailWatcher().catch(() => {}); + if (process.env.CLAWDBOT_SKIP_GMAIL_WATCHER !== "1") { + try { + const gmailResult = await startGmailWatcher(nextConfig); + if (gmailResult.started) { + params.logHooks.info("gmail watcher started"); + } else if ( + gmailResult.reason && + gmailResult.reason !== "hooks not enabled" && + gmailResult.reason !== "no gmail account configured" + ) { + params.logHooks.warn( + `gmail watcher not started: ${gmailResult.reason}`, + ); + } + } catch (err) { + params.logHooks.error( + `gmail watcher failed to start: ${String(err)}`, + ); + } + } else { + params.logHooks.info( + "skipping gmail watcher restart (CLAWDBOT_SKIP_GMAIL_WATCHER=1)", + ); + } + } + + if (plan.restartChannels.size > 0) { + if ( + process.env.CLAWDBOT_SKIP_CHANNELS === "1" || + process.env.CLAWDBOT_SKIP_PROVIDERS === "1" + ) { + params.logChannels.info( + "skipping channel reload (CLAWDBOT_SKIP_CHANNELS=1 or CLAWDBOT_SKIP_PROVIDERS=1)", + ); + } else { + const restartChannel = async (name: ChannelKind) => { + params.logChannels.info(`restarting ${name} channel`); + await params.stopChannel(name); + await params.startChannel(name); + }; + for (const channel of plan.restartChannels) { + await restartChannel(channel); + } + } + } + + setCommandLaneConcurrency("cron", nextConfig.cron?.maxConcurrentRuns ?? 1); + setCommandLaneConcurrency( + "main", + nextConfig.agents?.defaults?.maxConcurrent ?? 1, + ); + setCommandLaneConcurrency( + "subagent", + nextConfig.agents?.defaults?.subagents?.maxConcurrent ?? 1, + ); + + if (plan.hotReasons.length > 0) { + params.logReload.info( + `config hot reload applied (${plan.hotReasons.join(", ")})`, + ); + } else if (plan.noopPaths.length > 0) { + params.logReload.info( + `config change applied (dynamic reads: ${plan.noopPaths.join(", ")})`, + ); + } + + params.setState(nextState); + }; + + const requestGatewayRestart = ( + plan: GatewayReloadPlan, + _nextConfig: ReturnType, + ) => { + const reasons = plan.restartReasons.length + ? plan.restartReasons.join(", ") + : plan.changedPaths.join(", "); + params.logReload.warn( + `config change requires gateway restart (${reasons})`, + ); + if (process.listenerCount("SIGUSR1") === 0) { + params.logReload.warn("no SIGUSR1 listener found; restart skipped"); + return; + } + process.emit("SIGUSR1"); + }; + + return { applyHotReload, requestGatewayRestart }; +} diff --git a/src/gateway/server-restart-sentinel.ts b/src/gateway/server-restart-sentinel.ts new file mode 100644 index 000000000..723cad067 --- /dev/null +++ b/src/gateway/server-restart-sentinel.ts @@ -0,0 +1,75 @@ +import { resolveAnnounceTargetFromKey } from "../agents/tools/sessions-send-helpers.js"; +import { normalizeChannelId } from "../channels/plugins/index.js"; +import type { CliDeps } from "../cli/deps.js"; +import { agentCommand } from "../commands/agent.js"; +import { resolveMainSessionKeyFromConfig } from "../config/sessions.js"; +import { resolveOutboundTarget } from "../infra/outbound/targets.js"; +import { + consumeRestartSentinel, + formatRestartSentinelMessage, + summarizeRestartSentinel, +} from "../infra/restart-sentinel.js"; +import { enqueueSystemEvent } from "../infra/system-events.js"; +import { defaultRuntime } from "../runtime.js"; +import { loadSessionEntry } from "./session-utils.js"; + +export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) { + const sentinel = await consumeRestartSentinel(); + if (!sentinel) return; + const payload = sentinel.payload; + const sessionKey = payload.sessionKey?.trim(); + const message = formatRestartSentinelMessage(payload); + const summary = summarizeRestartSentinel(payload); + + if (!sessionKey) { + const mainSessionKey = resolveMainSessionKeyFromConfig(); + enqueueSystemEvent(message, { sessionKey: mainSessionKey }); + return; + } + + const { cfg, entry } = loadSessionEntry(sessionKey); + const lastChannel = entry?.lastChannel; + const lastTo = entry?.lastTo?.trim(); + const parsedTarget = resolveAnnounceTargetFromKey(sessionKey); + const channelRaw = lastChannel ?? parsedTarget?.channel; + const channel = channelRaw ? normalizeChannelId(channelRaw) : null; + const to = lastTo || parsedTarget?.to; + if (!channel || !to) { + enqueueSystemEvent(message, { sessionKey }); + return; + } + + const resolved = resolveOutboundTarget({ + channel, + to, + cfg, + accountId: parsedTarget?.accountId ?? entry?.lastAccountId, + mode: "implicit", + }); + if (!resolved.ok) { + enqueueSystemEvent(message, { sessionKey }); + return; + } + + try { + await agentCommand( + { + message, + sessionKey, + to: resolved.to, + channel, + deliver: true, + bestEffortDeliver: true, + messageChannel: channel, + }, + defaultRuntime, + params.deps, + ); + } catch (err) { + enqueueSystemEvent(`${summary}\n${String(err)}`, { sessionKey }); + } +} + +export function shouldWakeFromRestartSentinel() { + return !process.env.VITEST && process.env.NODE_ENV !== "test"; +} diff --git a/src/gateway/server-runtime-config.ts b/src/gateway/server-runtime-config.ts new file mode 100644 index 000000000..671b57a1b --- /dev/null +++ b/src/gateway/server-runtime-config.ts @@ -0,0 +1,105 @@ +import type { + BridgeBindMode, + GatewayAuthConfig, + GatewayTailscaleConfig, + loadConfig, +} from "../config/config.js"; +import { + assertGatewayAuthConfigured, + type ResolvedGatewayAuth, + resolveGatewayAuth, +} from "./auth.js"; +import { normalizeControlUiBasePath } from "./control-ui.js"; +import { resolveHooksConfig } from "./hooks.js"; +import { isLoopbackHost, resolveGatewayBindHost } from "./net.js"; + +export type GatewayRuntimeConfig = { + bindHost: string; + controlUiEnabled: boolean; + openAiChatCompletionsEnabled: boolean; + controlUiBasePath: string; + resolvedAuth: ResolvedGatewayAuth; + authMode: ResolvedGatewayAuth["mode"]; + tailscaleConfig: GatewayTailscaleConfig; + tailscaleMode: "off" | "serve" | "funnel"; + hooksConfig: ReturnType; + canvasHostEnabled: boolean; +}; + +export async function resolveGatewayRuntimeConfig(params: { + cfg: ReturnType; + port: number; + bind?: BridgeBindMode; + host?: string; + controlUiEnabled?: boolean; + openAiChatCompletionsEnabled?: boolean; + auth?: GatewayAuthConfig; + tailscale?: GatewayTailscaleConfig; +}): Promise { + const bindMode = params.bind ?? params.cfg.gateway?.bind ?? "loopback"; + const customBindHost = params.cfg.gateway?.customBindHost; + const bindHost = + params.host ?? (await resolveGatewayBindHost(bindMode, customBindHost)); + const controlUiEnabled = + params.controlUiEnabled ?? params.cfg.gateway?.controlUi?.enabled ?? true; + const openAiChatCompletionsEnabled = + params.openAiChatCompletionsEnabled ?? + params.cfg.gateway?.http?.endpoints?.chatCompletions?.enabled ?? + false; + const controlUiBasePath = normalizeControlUiBasePath( + params.cfg.gateway?.controlUi?.basePath, + ); + const authBase = params.cfg.gateway?.auth ?? {}; + const authOverrides = params.auth ?? {}; + const authConfig = { + ...authBase, + ...authOverrides, + }; + const tailscaleBase = params.cfg.gateway?.tailscale ?? {}; + const tailscaleOverrides = params.tailscale ?? {}; + const tailscaleConfig = { + ...tailscaleBase, + ...tailscaleOverrides, + }; + const tailscaleMode = tailscaleConfig.mode ?? "off"; + const resolvedAuth = resolveGatewayAuth({ + authConfig, + env: process.env, + tailscaleMode, + }); + const authMode: ResolvedGatewayAuth["mode"] = resolvedAuth.mode; + const hooksConfig = resolveHooksConfig(params.cfg); + const canvasHostEnabled = + process.env.CLAWDBOT_SKIP_CANVAS_HOST !== "1" && + params.cfg.canvasHost?.enabled !== false; + + assertGatewayAuthConfigured(resolvedAuth); + if (tailscaleMode === "funnel" && authMode !== "password") { + throw new Error( + "tailscale funnel requires gateway auth mode=password (set gateway.auth.password or CLAWDBOT_GATEWAY_PASSWORD)", + ); + } + if (tailscaleMode !== "off" && !isLoopbackHost(bindHost)) { + throw new Error( + "tailscale serve/funnel requires gateway bind=loopback (127.0.0.1)", + ); + } + if (!isLoopbackHost(bindHost) && authMode === "none") { + throw new Error( + `refusing to bind gateway to ${bindHost}:${params.port} without auth (set gateway.auth.token or CLAWDBOT_GATEWAY_TOKEN, or pass --token)`, + ); + } + + return { + bindHost, + controlUiEnabled, + openAiChatCompletionsEnabled, + controlUiBasePath, + resolvedAuth, + authMode, + tailscaleConfig, + tailscaleMode, + hooksConfig, + canvasHostEnabled, + }; +} diff --git a/src/gateway/server-runtime-state.ts b/src/gateway/server-runtime-state.ts new file mode 100644 index 000000000..50e11e88c --- /dev/null +++ b/src/gateway/server-runtime-state.ts @@ -0,0 +1,146 @@ +import type { Server as HttpServer } from "node:http"; +import { WebSocketServer } from "ws"; +import { CANVAS_HOST_PATH } from "../canvas-host/a2ui.js"; +import { + type CanvasHostHandler, + createCanvasHostHandler, +} from "../canvas-host/server.js"; +import type { CliDeps } from "../cli/deps.js"; +import type { createSubsystemLogger } from "../logging.js"; +import type { RuntimeEnv } from "../runtime.js"; +import type { ResolvedGatewayAuth } from "./auth.js"; +import type { ChatAbortControllerEntry } from "./chat-abort.js"; +import type { HooksConfigResolved } from "./hooks.js"; +import { createGatewayHooksRequestHandler } from "./server/hooks.js"; +import { listenGatewayHttpServer } from "./server/http-listen.js"; +import type { GatewayWsClient } from "./server/ws-types.js"; +import { createGatewayBroadcaster } from "./server-broadcast.js"; +import { type ChatRunEntry, createChatRunState } from "./server-chat.js"; +import { MAX_PAYLOAD_BYTES } from "./server-constants.js"; +import { + attachGatewayUpgradeHandler, + createGatewayHttpServer, +} from "./server-http.js"; +import type { DedupeEntry } from "./server-shared.js"; + +export async function createGatewayRuntimeState(params: { + cfg: { + canvasHost?: { root?: string; enabled?: boolean; liveReload?: boolean }; + }; + bindHost: string; + port: number; + controlUiEnabled: boolean; + controlUiBasePath: string; + openAiChatCompletionsEnabled: boolean; + resolvedAuth: ResolvedGatewayAuth; + hooksConfig: () => HooksConfigResolved | null; + deps: CliDeps; + canvasRuntime: RuntimeEnv; + canvasHostEnabled: boolean; + allowCanvasHostInTests?: boolean; + logCanvas: { info: (msg: string) => void; warn: (msg: string) => void }; + logHooks: ReturnType; +}): Promise<{ + canvasHost: CanvasHostHandler | null; + httpServer: HttpServer; + wss: WebSocketServer; + clients: Set; + broadcast: ( + event: string, + payload: unknown, + opts?: { + dropIfSlow?: boolean; + stateVersion?: { presence?: number; health?: number }; + }, + ) => void; + agentRunSeq: Map; + dedupe: Map; + chatRunState: ReturnType; + chatRunBuffers: Map; + chatDeltaSentAt: Map; + addChatRun: (sessionId: string, entry: ChatRunEntry) => void; + removeChatRun: ( + sessionId: string, + clientRunId: string, + sessionKey?: string, + ) => ChatRunEntry | undefined; + chatAbortControllers: Map; +}> { + let canvasHost: CanvasHostHandler | null = null; + if (params.canvasHostEnabled) { + try { + const handler = await createCanvasHostHandler({ + runtime: params.canvasRuntime, + rootDir: params.cfg.canvasHost?.root, + basePath: CANVAS_HOST_PATH, + allowInTests: params.allowCanvasHostInTests, + liveReload: params.cfg.canvasHost?.liveReload, + }); + if (handler.rootDir) { + canvasHost = handler; + params.logCanvas.info( + `canvas host mounted at http://${params.bindHost}:${params.port}${CANVAS_HOST_PATH}/ (root ${handler.rootDir})`, + ); + } + } catch (err) { + params.logCanvas.warn(`canvas host failed to start: ${String(err)}`); + } + } + + const handleHooksRequest = createGatewayHooksRequestHandler({ + deps: params.deps, + getHooksConfig: params.hooksConfig, + bindHost: params.bindHost, + port: params.port, + logHooks: params.logHooks, + }); + + const httpServer = createGatewayHttpServer({ + canvasHost, + controlUiEnabled: params.controlUiEnabled, + controlUiBasePath: params.controlUiBasePath, + openAiChatCompletionsEnabled: params.openAiChatCompletionsEnabled, + handleHooksRequest, + resolvedAuth: params.resolvedAuth, + }); + + await listenGatewayHttpServer({ + httpServer, + bindHost: params.bindHost, + port: params.port, + }); + + const wss = new WebSocketServer({ + noServer: true, + maxPayload: MAX_PAYLOAD_BYTES, + }); + attachGatewayUpgradeHandler({ httpServer, wss, canvasHost }); + + const clients = new Set(); + const { broadcast } = createGatewayBroadcaster({ clients }); + const agentRunSeq = new Map(); + const dedupe = new Map(); + const chatRunState = createChatRunState(); + const chatRunRegistry = chatRunState.registry; + const chatRunBuffers = chatRunState.buffers; + const chatDeltaSentAt = chatRunState.deltaSentAt; + const addChatRun = chatRunRegistry.add; + const removeChatRun = chatRunRegistry.remove; + const chatAbortControllers = new Map(); + + return { + canvasHost, + httpServer, + wss, + clients, + broadcast, + agentRunSeq, + dedupe, + chatRunState, + chatRunBuffers, + chatDeltaSentAt, + addChatRun, + removeChatRun, + chatAbortControllers, + }; +} diff --git a/src/gateway/server-session-key.ts b/src/gateway/server-session-key.ts new file mode 100644 index 000000000..d167d2dca --- /dev/null +++ b/src/gateway/server-session-key.ts @@ -0,0 +1,22 @@ +import { loadConfig } from "../config/config.js"; +import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; +import { + getAgentRunContext, + registerAgentRunContext, +} from "../infra/agent-events.js"; + +export function resolveSessionKeyForRun(runId: string) { + const cached = getAgentRunContext(runId)?.sessionKey; + if (cached) return cached; + const cfg = loadConfig(); + const storePath = resolveStorePath(cfg.session?.store); + const store = loadSessionStore(storePath); + const found = Object.entries(store).find( + ([, entry]) => entry?.sessionId === runId, + ); + const sessionKey = found?.[0]; + if (sessionKey) { + registerAgentRunContext(runId, { sessionKey }); + } + return sessionKey; +} diff --git a/src/gateway/server-startup-log.ts b/src/gateway/server-startup-log.ts new file mode 100644 index 000000000..62ab95c7f --- /dev/null +++ b/src/gateway/server-startup-log.ts @@ -0,0 +1,31 @@ +import chalk from "chalk"; +import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; +import { resolveConfiguredModelRef } from "../agents/model-selection.js"; +import type { loadConfig } from "../config/config.js"; +import { getResolvedLoggerSettings } from "../logging.js"; + +export function logGatewayStartup(params: { + cfg: ReturnType; + bindHost: string; + port: number; + log: { info: (msg: string, meta?: Record) => void }; + isNixMode: boolean; +}) { + const { provider: agentProvider, model: agentModel } = + resolveConfiguredModelRef({ + cfg: params.cfg, + defaultProvider: DEFAULT_PROVIDER, + defaultModel: DEFAULT_MODEL, + }); + const modelRef = `${agentProvider}/${agentModel}`; + params.log.info(`agent model: ${modelRef}`, { + consoleMessage: `agent model: ${chalk.whiteBright(modelRef)}`, + }); + params.log.info( + `listening on ws://${params.bindHost}:${params.port} (PID ${process.pid})`, + ); + params.log.info(`log file: ${getResolvedLoggerSettings().file}`); + if (params.isNixMode) { + params.log.info("gateway: running in Nix mode (config managed externally)"); + } +} diff --git a/src/gateway/server-startup.ts b/src/gateway/server-startup.ts new file mode 100644 index 000000000..edca85cab --- /dev/null +++ b/src/gateway/server-startup.ts @@ -0,0 +1,136 @@ +import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; +import { loadModelCatalog } from "../agents/model-catalog.js"; +import { + getModelRefStatus, + resolveConfiguredModelRef, + resolveHooksGmailModel, +} from "../agents/model-selection.js"; +import type { CliDeps } from "../cli/deps.js"; +import type { loadConfig } from "../config/config.js"; +import { startGmailWatcher } from "../hooks/gmail-watcher.js"; +import type { loadClawdbotPlugins } from "../plugins/loader.js"; +import { + type PluginServicesHandle, + startPluginServices, +} from "../plugins/services.js"; +import { startBrowserControlServerIfEnabled } from "./server-browser.js"; +import { + scheduleRestartSentinelWake, + shouldWakeFromRestartSentinel, +} from "./server-restart-sentinel.js"; + +export async function startGatewaySidecars(params: { + cfg: ReturnType; + pluginRegistry: ReturnType; + defaultWorkspaceDir: string; + deps: CliDeps; + startChannels: () => Promise; + log: { warn: (msg: string) => void }; + logHooks: { + info: (msg: string) => void; + warn: (msg: string) => void; + error: (msg: string) => void; + }; + logChannels: { info: (msg: string) => void; error: (msg: string) => void }; + logBrowser: { error: (msg: string) => void }; +}) { + // Start clawd browser control server (unless disabled via config). + let browserControl: Awaited< + ReturnType + > = null; + try { + browserControl = await startBrowserControlServerIfEnabled(); + } catch (err) { + params.logBrowser.error(`server failed to start: ${String(err)}`); + } + + // Start Gmail watcher if configured (hooks.gmail.account). + if (process.env.CLAWDBOT_SKIP_GMAIL_WATCHER !== "1") { + try { + const gmailResult = await startGmailWatcher(params.cfg); + if (gmailResult.started) { + params.logHooks.info("gmail watcher started"); + } else if ( + gmailResult.reason && + gmailResult.reason !== "hooks not enabled" && + gmailResult.reason !== "no gmail account configured" + ) { + params.logHooks.warn( + `gmail watcher not started: ${gmailResult.reason}`, + ); + } + } catch (err) { + params.logHooks.error(`gmail watcher failed to start: ${String(err)}`); + } + } + + // Validate hooks.gmail.model if configured. + if (params.cfg.hooks?.gmail?.model) { + const hooksModelRef = resolveHooksGmailModel({ + cfg: params.cfg, + defaultProvider: DEFAULT_PROVIDER, + }); + if (hooksModelRef) { + const { provider: defaultProvider, model: defaultModel } = + resolveConfiguredModelRef({ + cfg: params.cfg, + defaultProvider: DEFAULT_PROVIDER, + defaultModel: DEFAULT_MODEL, + }); + const catalog = await loadModelCatalog({ config: params.cfg }); + const status = getModelRefStatus({ + cfg: params.cfg, + catalog, + ref: hooksModelRef, + defaultProvider, + defaultModel, + }); + if (!status.allowed) { + params.logHooks.warn( + `hooks.gmail.model "${status.key}" not in agents.defaults.models allowlist (will use primary instead)`, + ); + } + if (!status.inCatalog) { + params.logHooks.warn( + `hooks.gmail.model "${status.key}" not in the model catalog (may fail at runtime)`, + ); + } + } + } + + // Launch configured channels so gateway replies via the surface the message came from. + // Tests can opt out via CLAWDBOT_SKIP_CHANNELS (or legacy CLAWDBOT_SKIP_PROVIDERS). + const skipChannels = + process.env.CLAWDBOT_SKIP_CHANNELS === "1" || + process.env.CLAWDBOT_SKIP_PROVIDERS === "1"; + if (!skipChannels) { + try { + await params.startChannels(); + } catch (err) { + params.logChannels.error(`channel startup failed: ${String(err)}`); + } + } else { + params.logChannels.info( + "skipping channel start (CLAWDBOT_SKIP_CHANNELS=1 or CLAWDBOT_SKIP_PROVIDERS=1)", + ); + } + + let pluginServices: PluginServicesHandle | null = null; + try { + pluginServices = await startPluginServices({ + registry: params.pluginRegistry, + config: params.cfg, + workspaceDir: params.defaultWorkspaceDir, + }); + } catch (err) { + params.log.warn(`plugin services failed to start: ${String(err)}`); + } + + if (shouldWakeFromRestartSentinel()) { + setTimeout(() => { + void scheduleRestartSentinelWake({ deps: params.deps }); + }, 750); + } + + return { browserControl, pluginServices }; +} diff --git a/src/gateway/server-tailscale.ts b/src/gateway/server-tailscale.ts new file mode 100644 index 000000000..7ff93b708 --- /dev/null +++ b/src/gateway/server-tailscale.ts @@ -0,0 +1,60 @@ +import { + disableTailscaleFunnel, + disableTailscaleServe, + enableTailscaleFunnel, + enableTailscaleServe, + getTailnetHostname, +} from "../infra/tailscale.js"; + +export async function startGatewayTailscaleExposure(params: { + tailscaleMode: "off" | "serve" | "funnel"; + resetOnExit?: boolean; + port: number; + controlUiBasePath?: string; + logTailscale: { info: (msg: string) => void; warn: (msg: string) => void }; +}): Promise<(() => Promise) | null> { + if (params.tailscaleMode === "off") { + return null; + } + + try { + if (params.tailscaleMode === "serve") { + await enableTailscaleServe(params.port); + } else { + await enableTailscaleFunnel(params.port); + } + const host = await getTailnetHostname().catch(() => null); + if (host) { + const uiPath = params.controlUiBasePath + ? `${params.controlUiBasePath}/` + : "/"; + params.logTailscale.info( + `${params.tailscaleMode} enabled: https://${host}${uiPath} (WS via wss://${host})`, + ); + } else { + params.logTailscale.info(`${params.tailscaleMode} enabled`); + } + } catch (err) { + params.logTailscale.warn( + `${params.tailscaleMode} failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + if (!params.resetOnExit) { + return null; + } + + return async () => { + try { + if (params.tailscaleMode === "serve") { + await disableTailscaleServe(); + } else { + await disableTailscaleFunnel(); + } + } catch (err) { + params.logTailscale.warn( + `${params.tailscaleMode} cleanup failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + }; +} diff --git a/src/gateway/server-wizard-sessions.ts b/src/gateway/server-wizard-sessions.ts new file mode 100644 index 000000000..bfa858874 --- /dev/null +++ b/src/gateway/server-wizard-sessions.ts @@ -0,0 +1,21 @@ +import type { WizardSession } from "../wizard/session.js"; + +export function createWizardSessionTracker() { + const wizardSessions = new Map(); + + const findRunningWizard = (): string | null => { + for (const [id, session] of wizardSessions) { + if (session.getStatus() === "running") return id; + } + return null; + }; + + const purgeWizardSession = (id: string) => { + const session = wizardSessions.get(id); + if (!session) return; + if (session.getStatus() === "running") return; + wizardSessions.delete(id); + }; + + return { wizardSessions, findRunningWizard, purgeWizardSession }; +} diff --git a/src/gateway/server-ws-runtime.ts b/src/gateway/server-ws-runtime.ts new file mode 100644 index 000000000..0dec2c3f0 --- /dev/null +++ b/src/gateway/server-ws-runtime.ts @@ -0,0 +1,52 @@ +import type { WebSocketServer } from "ws"; +import type { createSubsystemLogger } from "../logging.js"; +import type { ResolvedGatewayAuth } from "./auth.js"; +import { attachGatewayWsConnectionHandler } from "./server/ws-connection.js"; +import type { GatewayWsClient } from "./server/ws-types.js"; +import type { + GatewayRequestContext, + GatewayRequestHandlers, +} from "./server-methods/types.js"; + +export function attachGatewayWsHandlers(params: { + wss: WebSocketServer; + clients: Set; + port: number; + bridgeHost?: string; + canvasHostEnabled: boolean; + canvasHostServerPort?: number; + resolvedAuth: ResolvedGatewayAuth; + gatewayMethods: string[]; + events: string[]; + logGateway: ReturnType; + logHealth: ReturnType; + logWsControl: ReturnType; + extraHandlers: GatewayRequestHandlers; + broadcast: ( + event: string, + payload: unknown, + opts?: { + dropIfSlow?: boolean; + stateVersion?: { presence?: number; health?: number }; + }, + ) => void; + context: GatewayRequestContext; +}) { + attachGatewayWsConnectionHandler({ + wss: params.wss, + clients: params.clients, + port: params.port, + bridgeHost: params.bridgeHost, + canvasHostEnabled: params.canvasHostEnabled, + canvasHostServerPort: params.canvasHostServerPort, + resolvedAuth: params.resolvedAuth, + gatewayMethods: params.gatewayMethods, + events: params.events, + logGateway: params.logGateway, + logHealth: params.logHealth, + logWsControl: params.logWsControl, + extraHandlers: params.extraHandlers, + broadcast: params.broadcast, + buildRequestContext: () => params.context, + }); +} diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index badbb6632..cb1076556 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -1,38 +1,14 @@ -import type { Server as HttpServer } from "node:http"; -import chalk from "chalk"; -import { WebSocketServer } from "ws"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId, } from "../agents/agent-scope.js"; -import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; -import { - loadModelCatalog, - type ModelCatalogEntry, - resetModelCatalogCacheForTest, -} from "../agents/model-catalog.js"; -import { - getModelRefStatus, - resolveConfiguredModelRef, - resolveHooksGmailModel, -} from "../agents/model-selection.js"; import { initSubagentRegistry } from "../agents/subagent-registry.js"; -import { resolveAnnounceTargetFromKey } from "../agents/tools/sessions-send-helpers.js"; -import { CANVAS_HOST_PATH } from "../canvas-host/a2ui.js"; -import { - type CanvasHostHandler, - type CanvasHostServer, - createCanvasHostHandler, - startCanvasHost, -} from "../canvas-host/server.js"; +import type { CanvasHostServer } from "../canvas-host/server.js"; import { type ChannelId, listChannelPlugins, - normalizeChannelId, } from "../channels/plugins/index.js"; import { createDefaultDeps } from "../cli/deps.js"; -import { agentCommand } from "../commands/agent.js"; -import type { HealthSummary } from "../commands/health.js"; import { CONFIG_PATH_CLAWDBOT, isNixMode, @@ -41,145 +17,48 @@ import { readConfigFileSnapshot, writeConfigFile, } from "../config/config.js"; -import { - deriveDefaultBridgePort, - deriveDefaultCanvasHostPort, -} from "../config/port-defaults.js"; -import { - loadSessionStore, - resolveAgentMainSessionKey, - resolveMainSessionKeyFromConfig, - resolveStorePath, -} from "../config/sessions.js"; -import { runCronIsolatedAgentTurn } from "../cron/isolated-agent.js"; -import { appendCronRunLog, resolveCronRunLogPath } from "../cron/run-log.js"; -import { CronService } from "../cron/service.js"; -import { resolveCronStorePath } from "../cron/store.js"; -import { startGmailWatcher, stopGmailWatcher } from "../hooks/gmail-watcher.js"; -import { - clearAgentRunContext, - getAgentRunContext, - onAgentEvent, - registerAgentRunContext, -} from "../infra/agent-events.js"; -import { startGatewayBonjourAdvertiser } from "../infra/bonjour.js"; -import { startNodeBridgeServer } from "../infra/bridge/server.js"; +import { clearAgentRunContext, onAgentEvent } from "../infra/agent-events.js"; import { onHeartbeatEvent } from "../infra/heartbeat-events.js"; -import { - runHeartbeatOnce, - startHeartbeatRunner, -} from "../infra/heartbeat-runner.js"; -import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; +import { startHeartbeatRunner } from "../infra/heartbeat-runner.js"; import { getMachineDisplayName } from "../infra/machine-name.js"; -import { resolveOutboundTarget } from "../infra/outbound/targets.js"; import { ensureClawdbotCliOnPath } from "../infra/path-env.js"; -import { - consumeRestartSentinel, - formatRestartSentinelMessage, - summarizeRestartSentinel, -} from "../infra/restart-sentinel.js"; import { autoMigrateLegacyState } from "../infra/state-migrations.js"; -import { enqueueSystemEvent } from "../infra/system-events.js"; -import { - listSystemPresence, - upsertPresence, -} from "../infra/system-presence.js"; -import { - pickPrimaryTailnetIPv4, - pickPrimaryTailnetIPv6, -} from "../infra/tailnet.js"; -import { - disableTailscaleFunnel, - disableTailscaleServe, - enableTailscaleFunnel, - enableTailscaleServe, - getTailnetHostname, -} from "../infra/tailscale.js"; -import { loadVoiceWakeConfig } from "../infra/voicewake.js"; -import { - WIDE_AREA_DISCOVERY_DOMAIN, - writeWideAreaBridgeZone, -} from "../infra/widearea-dns.js"; -import { - createSubsystemLogger, - getChildLogger, - getResolvedLoggerSettings, - runtimeForLogger, -} from "../logging.js"; -import { loadClawdbotPlugins } from "../plugins/loader.js"; -import { - type PluginServicesHandle, - startPluginServices, -} from "../plugins/services.js"; -import { setCommandLaneConcurrency } from "../process/command-queue.js"; -import { normalizeAgentId } from "../routing/session-key.js"; -import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; +import { createSubsystemLogger, runtimeForLogger } from "../logging.js"; +import type { PluginServicesHandle } from "../plugins/services.js"; +import type { RuntimeEnv } from "../runtime.js"; import { runOnboardingWizard } from "../wizard/onboarding.js"; -import type { WizardSession } from "../wizard/session.js"; -import { - assertGatewayAuthConfigured, - type ResolvedGatewayAuth, - resolveGatewayAuth, -} from "./auth.js"; -import { - abortChatRunById, - type ChatAbortControllerEntry, -} from "./chat-abort.js"; -import { - type ChannelKind, - type GatewayReloadPlan, - startGatewayConfigReloader, -} from "./config-reload.js"; -import { normalizeControlUiBasePath } from "./control-ui.js"; -import { resolveHooksConfig } from "./hooks.js"; -import { - isLoopbackAddress, - isLoopbackHost, - resolveGatewayBindHost, -} from "./net.js"; +import { startGatewayConfigReloader } from "./config-reload.js"; import { getHealthCache, getHealthVersion, getPresenceVersion, incrementPresenceVersion, refreshGatewayHealthSnapshot, - setBroadcastHealthUpdate, } from "./server/health-state.js"; -import { createGatewayHooksRequestHandler } from "./server/hooks.js"; -import { listenGatewayHttpServer } from "./server/http-listen.js"; -import { attachGatewayWsConnectionHandler } from "./server/ws-connection.js"; -import type { GatewayWsClient } from "./server/ws-types.js"; -import { createBridgeHandlers } from "./server-bridge.js"; -import { - type BridgeListConnectedFn, - type BridgeSendEventFn, - createBridgeSubscriptionManager, -} from "./server-bridge-subscriptions.js"; -import { startBrowserControlServerIfEnabled } from "./server-browser.js"; +import { startGatewayBridgeRuntime } from "./server-bridge-runtime.js"; +import type { startBrowserControlServerIfEnabled } from "./server-browser.js"; import { createChannelManager } from "./server-channels.js"; -import { createAgentEventHandler, createChatRunState } from "./server-chat.js"; -import { - DEDUPE_MAX, - DEDUPE_TTL_MS, - HEALTH_REFRESH_INTERVAL_MS, - MAX_BUFFERED_BYTES, - MAX_PAYLOAD_BYTES, - TICK_INTERVAL_MS, -} from "./server-constants.js"; -import { - formatBonjourInstanceName, - resolveBonjourCliPath, - resolveTailnetDnsHint, -} from "./server-discovery.js"; -import { - attachGatewayUpgradeHandler, - createGatewayHttpServer, -} from "./server-http.js"; +import { createAgentEventHandler } from "./server-chat.js"; +import { createGatewayCloseHandler } from "./server-close.js"; +import { buildGatewayCronService } from "./server-cron.js"; +import { applyGatewayLaneConcurrency } from "./server-lanes.js"; +import { startGatewayMaintenanceTimers } from "./server-maintenance.js"; import { coreGatewayHandlers } from "./server-methods.js"; -import type { DedupeEntry } from "./server-shared.js"; -import { formatError } from "./server-utils.js"; -import { loadSessionEntry } from "./session-utils.js"; -import { logWs, summarizeAgentEventForWsLog } from "./ws-log.js"; +import { GATEWAY_EVENTS, GATEWAY_METHODS } from "./server-methods-list.js"; +import { hasConnectedMobileNode as hasConnectedMobileNodeFromBridge } from "./server-mobile-nodes.js"; +import { loadGatewayModelCatalog } from "./server-model-catalog.js"; +import { loadGatewayPlugins } from "./server-plugins.js"; +import { createGatewayReloadHandlers } from "./server-reload-handlers.js"; +import { resolveGatewayRuntimeConfig } from "./server-runtime-config.js"; +import { createGatewayRuntimeState } from "./server-runtime-state.js"; +import { resolveSessionKeyForRun } from "./server-session-key.js"; +import { startGatewaySidecars } from "./server-startup.js"; +import { logGatewayStartup } from "./server-startup-log.js"; +import { startGatewayTailscaleExposure } from "./server-tailscale.js"; +import { createWizardSessionTracker } from "./server-wizard-sessions.js"; +import { attachGatewayWsHandlers } from "./server-ws-runtime.js"; + +export { __resetModelCatalogCacheForTest } from "./server-model-catalog.js"; ensureClawdbotCliOnPath(); @@ -209,100 +88,7 @@ const channelRuntimeEnvs = Object.fromEntries( ]), ) as Record; -type GatewayModelChoice = ModelCatalogEntry; - -// Test-only escape hatch: model catalog is cached at module scope for the -// process lifetime, which is fine for the real gateway daemon, but makes -// isolated unit tests harder. Keep this intentionally obscure. -export function __resetModelCatalogCacheForTest() { - resetModelCatalogCacheForTest(); -} - -async function loadGatewayModelCatalog(): Promise { - return await loadModelCatalog({ config: loadConfig() }); -} - -type Client = GatewayWsClient; - -const BASE_METHODS = [ - "health", - "logs.tail", - "channels.status", - "channels.logout", - "status", - "usage.status", - "config.get", - "config.set", - "config.apply", - "config.schema", - "wizard.start", - "wizard.next", - "wizard.cancel", - "wizard.status", - "talk.mode", - "models.list", - "agents.list", - "skills.status", - "skills.install", - "skills.update", - "update.run", - "voicewake.get", - "voicewake.set", - "sessions.list", - "sessions.patch", - "sessions.reset", - "sessions.delete", - "sessions.compact", - "last-heartbeat", - "set-heartbeats", - "wake", - "node.pair.request", - "node.pair.list", - "node.pair.approve", - "node.pair.reject", - "node.pair.verify", - "node.rename", - "node.list", - "node.describe", - "node.invoke", - "cron.list", - "cron.status", - "cron.add", - "cron.update", - "cron.remove", - "cron.run", - "cron.runs", - "system-presence", - "system-event", - "send", - "agent", - "agent.wait", - // WebChat WebSocket-native chat methods - "chat.history", - "chat.abort", - "chat.send", -]; - -const CHANNEL_METHODS = listChannelPlugins().flatMap( - (plugin) => plugin.gatewayMethods ?? [], -); - -const METHODS = Array.from(new Set([...BASE_METHODS, ...CHANNEL_METHODS])); - -const EVENTS = [ - "agent", - "chat", - "presence", - "tick", - "talk.mode", - "shutdown", - "health", - "heartbeat", - "cron", - "node.pair.requested", - "node.pair.resolved", - "voicewake.changed", -]; +const METHODS = GATEWAY_METHODS; export type GatewayServer = { close: (opts?: { @@ -397,288 +183,85 @@ export async function startGatewayServer( cfgAtStart, defaultAgentId, ); - const pluginRegistry = loadClawdbotPlugins({ - config: cfgAtStart, + const { pluginRegistry, gatewayMethods } = loadGatewayPlugins({ + cfg: cfgAtStart, workspaceDir: defaultWorkspaceDir, - logger: { - info: (msg) => log.info(msg), - warn: (msg) => log.warn(msg), - error: (msg) => log.error(msg), - debug: (msg) => log.debug(msg), - }, + log, coreGatewayHandlers, + baseMethods: METHODS, }); - const pluginMethods = Object.keys(pluginRegistry.gatewayHandlers); - const gatewayMethods = Array.from(new Set([...METHODS, ...pluginMethods])); - if (pluginRegistry.diagnostics.length > 0) { - for (const diag of pluginRegistry.diagnostics) { - if (diag.level === "error") { - log.warn(`[plugins] ${diag.message}`); - } else { - log.info(`[plugins] ${diag.message}`); - } - } - } let pluginServices: PluginServicesHandle | null = null; - const bindMode = opts.bind ?? cfgAtStart.gateway?.bind ?? "loopback"; - const customBindHost = cfgAtStart.gateway?.customBindHost; - const bindHost = - opts.host ?? (await resolveGatewayBindHost(bindMode, customBindHost)); - const controlUiEnabled = - opts.controlUiEnabled ?? cfgAtStart.gateway?.controlUi?.enabled ?? true; - const openAiChatCompletionsEnabled = - opts.openAiChatCompletionsEnabled ?? - cfgAtStart.gateway?.http?.endpoints?.chatCompletions?.enabled ?? - false; - const controlUiBasePath = normalizeControlUiBasePath( - cfgAtStart.gateway?.controlUi?.basePath, - ); - const authBase = cfgAtStart.gateway?.auth ?? {}; - const authOverrides = opts.auth ?? {}; - const authConfig = { - ...authBase, - ...authOverrides, - }; - const tailscaleBase = cfgAtStart.gateway?.tailscale ?? {}; - const tailscaleOverrides = opts.tailscale ?? {}; - const tailscaleConfig = { - ...tailscaleBase, - ...tailscaleOverrides, - }; - const tailscaleMode = tailscaleConfig.mode ?? "off"; - const resolvedAuth = resolveGatewayAuth({ - authConfig, - env: process.env, - tailscaleMode, + const runtimeConfig = await resolveGatewayRuntimeConfig({ + cfg: cfgAtStart, + port, + bind: opts.bind, + host: opts.host, + controlUiEnabled: opts.controlUiEnabled, + openAiChatCompletionsEnabled: opts.openAiChatCompletionsEnabled, + auth: opts.auth, + tailscale: opts.tailscale, }); - const authMode: ResolvedGatewayAuth["mode"] = resolvedAuth.mode; - let hooksConfig = resolveHooksConfig(cfgAtStart); - const canvasHostEnabled = - process.env.CLAWDBOT_SKIP_CANVAS_HOST !== "1" && - cfgAtStart.canvasHost?.enabled !== false; - assertGatewayAuthConfigured(resolvedAuth); - if (tailscaleMode === "funnel" && authMode !== "password") { - throw new Error( - "tailscale funnel requires gateway auth mode=password (set gateway.auth.password or CLAWDBOT_GATEWAY_PASSWORD)", - ); - } - if (tailscaleMode !== "off" && !isLoopbackHost(bindHost)) { - throw new Error( - "tailscale serve/funnel requires gateway bind=loopback (127.0.0.1)", - ); - } - if (!isLoopbackHost(bindHost) && authMode === "none") { - throw new Error( - `refusing to bind gateway to ${bindHost}:${port} without auth (set gateway.auth.token or CLAWDBOT_GATEWAY_TOKEN, or pass --token)`, - ); - } + const { + bindHost, + controlUiEnabled, + openAiChatCompletionsEnabled, + controlUiBasePath, + resolvedAuth, + tailscaleConfig, + tailscaleMode, + } = runtimeConfig; + let hooksConfig = runtimeConfig.hooksConfig; + const canvasHostEnabled = runtimeConfig.canvasHostEnabled; const wizardRunner = opts.wizardRunner ?? runOnboardingWizard; - const wizardSessions = new Map(); - - const findRunningWizard = (): string | null => { - for (const [id, session] of wizardSessions) { - if (session.getStatus() === "running") return id; - } - return null; - }; - - const purgeWizardSession = (id: string) => { - const session = wizardSessions.get(id); - if (!session) return; - if (session.getStatus() === "running") return; - wizardSessions.delete(id); - }; + const { wizardSessions, findRunningWizard, purgeWizardSession } = + createWizardSessionTracker(); const deps = createDefaultDeps(); - let canvasHost: CanvasHostHandler | null = null; let canvasHostServer: CanvasHostServer | null = null; - if (canvasHostEnabled) { - try { - const handler = await createCanvasHostHandler({ - runtime: canvasRuntime, - rootDir: cfgAtStart.canvasHost?.root, - basePath: CANVAS_HOST_PATH, - allowInTests: opts.allowCanvasHostInTests, - liveReload: cfgAtStart.canvasHost?.liveReload, - }); - if (handler.rootDir) { - canvasHost = handler; - logCanvas.info( - `canvas host mounted at http://${bindHost}:${port}${CANVAS_HOST_PATH}/ (root ${handler.rootDir})`, - ); - } - } catch (err) { - logCanvas.warn(`canvas host failed to start: ${String(err)}`); - } - } - - const handleHooksRequest = createGatewayHooksRequestHandler({ - deps, - getHooksConfig: () => hooksConfig, + const { + canvasHost, + httpServer, + wss, + clients, + broadcast, + agentRunSeq, + dedupe, + chatRunState, + chatRunBuffers, + chatDeltaSentAt, + addChatRun, + removeChatRun, + chatAbortControllers, + } = await createGatewayRuntimeState({ + cfg: cfgAtStart, bindHost, port, - logHooks, - }); - - const httpServer: HttpServer = createGatewayHttpServer({ - canvasHost, controlUiEnabled, controlUiBasePath, openAiChatCompletionsEnabled, - handleHooksRequest, resolvedAuth, + hooksConfig: () => hooksConfig, + deps, + canvasRuntime, + canvasHostEnabled, + allowCanvasHostInTests: opts.allowCanvasHostInTests, + logCanvas, + logHooks, }); let bonjourStop: (() => Promise) | null = null; - let bridge: Awaited> | null = null; - const bridgeSubscriptions = createBridgeSubscriptionManager(); + let bridge: import("../infra/bridge/server.js").NodeBridgeServer | null = + null; - const isMobilePlatform = (platform: unknown): boolean => { - const p = typeof platform === "string" ? platform.trim().toLowerCase() : ""; - if (!p) return false; - return ( - p.startsWith("ios") || p.startsWith("ipados") || p.startsWith("android") - ); - }; + const hasConnectedMobileNode = () => hasConnectedMobileNodeFromBridge(bridge); + applyGatewayLaneConcurrency(cfgAtStart); - const hasConnectedMobileNode = (): boolean => { - const connected = bridge?.listConnected?.() ?? []; - return connected.some((n) => isMobilePlatform(n.platform)); - }; - await listenGatewayHttpServer({ httpServer, bindHost, port }); - - const wss = new WebSocketServer({ - noServer: true, - maxPayload: MAX_PAYLOAD_BYTES, + let cronState = buildGatewayCronService({ + cfg: cfgAtStart, + deps, + broadcast, }); - attachGatewayUpgradeHandler({ httpServer, wss, canvasHost }); - const clients = new Set(); - let seq = 0; - // Track per-run sequence to detect out-of-order/lost agent events. - const agentRunSeq = new Map(); - const dedupe = new Map(); - const chatRunState = createChatRunState(); - const chatRunRegistry = chatRunState.registry; - const chatRunBuffers = chatRunState.buffers; - const chatDeltaSentAt = chatRunState.deltaSentAt; - const addChatRun = chatRunRegistry.add; - const removeChatRun = chatRunRegistry.remove; - const resolveSessionKeyForRun = (runId: string) => { - const cached = getAgentRunContext(runId)?.sessionKey; - if (cached) return cached; - const cfg = loadConfig(); - const storePath = resolveStorePath(cfg.session?.store); - const store = loadSessionStore(storePath); - const found = Object.entries(store).find( - ([, entry]) => entry?.sessionId === runId, - ); - const sessionKey = found?.[0]; - if (sessionKey) { - registerAgentRunContext(runId, { sessionKey }); - } - return sessionKey; - }; - const chatAbortControllers = new Map(); - setCommandLaneConcurrency("cron", cfgAtStart.cron?.maxConcurrentRuns ?? 1); - setCommandLaneConcurrency( - "main", - cfgAtStart.agents?.defaults?.maxConcurrent ?? 1, - ); - setCommandLaneConcurrency( - "subagent", - cfgAtStart.agents?.defaults?.subagents?.maxConcurrent ?? 1, - ); - - const cronLogger = getChildLogger({ - module: "cron", - }); - const buildCronService = (cfg: ReturnType) => { - const storePath = resolveCronStorePath(cfg.cron?.store); - const cronEnabled = - process.env.CLAWDBOT_SKIP_CRON !== "1" && cfg.cron?.enabled !== false; - const resolveCronAgent = (requested?: string | null) => { - const runtimeConfig = loadConfig(); - const normalized = - typeof requested === "string" && requested.trim() - ? normalizeAgentId(requested) - : undefined; - const hasAgent = - normalized !== undefined && - Array.isArray(runtimeConfig.agents?.list) && - runtimeConfig.agents.list.some( - (entry) => - entry && - typeof entry.id === "string" && - normalizeAgentId(entry.id) === normalized, - ); - const agentId = hasAgent - ? normalized - : resolveDefaultAgentId(runtimeConfig); - return { agentId, cfg: runtimeConfig }; - }; - const cron = new CronService({ - storePath, - cronEnabled, - enqueueSystemEvent: (text, opts) => { - const { agentId, cfg: runtimeConfig } = resolveCronAgent(opts?.agentId); - const sessionKey = resolveAgentMainSessionKey({ - cfg: runtimeConfig, - agentId, - }); - enqueueSystemEvent(text, { sessionKey }); - }, - requestHeartbeatNow, - runHeartbeatOnce: async (opts) => { - const runtimeConfig = loadConfig(); - return await runHeartbeatOnce({ - cfg: runtimeConfig, - reason: opts?.reason, - deps: { ...deps, runtime: defaultRuntime }, - }); - }, - runIsolatedAgentJob: async ({ job, message }) => { - const { agentId, cfg: runtimeConfig } = resolveCronAgent(job.agentId); - return await runCronIsolatedAgentTurn({ - cfg: runtimeConfig, - deps, - job, - message, - agentId, - sessionKey: `cron:${job.id}`, - lane: "cron", - }); - }, - log: getChildLogger({ module: "cron", storePath }), - onEvent: (evt) => { - broadcast("cron", evt, { dropIfSlow: true }); - if (evt.action === "finished") { - const logPath = resolveCronRunLogPath({ - storePath, - jobId: evt.jobId, - }); - void appendCronRunLog(logPath, { - ts: Date.now(), - jobId: evt.jobId, - action: "finished", - status: evt.status, - error: evt.error, - summary: evt.summary, - runAtMs: evt.runAtMs, - durationMs: evt.durationMs, - nextRunAtMs: evt.nextRunAtMs, - }).catch((err) => { - cronLogger.warn( - { err: String(err), logPath }, - "cron: run log append failed", - ); - }); - } - }, - }); - return { cron, storePath, cronEnabled }; - }; - - let { cron, storePath: cronStorePath } = buildCronService(cfgAtStart); + let { cron, storePath: cronStorePath } = cronState; const channelManager = createChannelManager({ loadConfig, @@ -693,436 +276,58 @@ export async function startGatewayServer( markChannelLoggedOut, } = channelManager; - const broadcast = ( - event: string, - payload: unknown, - opts?: { - dropIfSlow?: boolean; - stateVersion?: { presence?: number; health?: number }; - }, - ) => { - const eventSeq = ++seq; - const frame = JSON.stringify({ - type: "event", - event, - payload, - seq: eventSeq, - stateVersion: opts?.stateVersion, - }); - const logMeta: Record = { - event, - seq: eventSeq, - clients: clients.size, - dropIfSlow: opts?.dropIfSlow, - presenceVersion: opts?.stateVersion?.presence, - healthVersion: opts?.stateVersion?.health, - }; - if (event === "agent") { - Object.assign(logMeta, summarizeAgentEventForWsLog(payload)); - } - logWs("out", "event", logMeta); - for (const c of clients) { - const slow = c.socket.bufferedAmount > MAX_BUFFERED_BYTES; - if (slow && opts?.dropIfSlow) continue; - if (slow) { - try { - c.socket.close(1008, "slow consumer"); - } catch { - /* ignore */ - } - continue; - } - try { - c.socket.send(frame); - } catch { - /* ignore */ - } - } - }; - - const wideAreaDiscoveryEnabled = - cfgAtStart.discovery?.wideArea?.enabled === true; - - const bridgeEnabled = (() => { - if (cfgAtStart.bridge?.enabled !== undefined) - return cfgAtStart.bridge.enabled === true; - return process.env.CLAWDBOT_BRIDGE_ENABLED !== "0"; - })(); - - const bridgePort = (() => { - if ( - typeof cfgAtStart.bridge?.port === "number" && - cfgAtStart.bridge.port > 0 - ) { - return cfgAtStart.bridge.port; - } - if (process.env.CLAWDBOT_BRIDGE_PORT !== undefined) { - const parsed = Number.parseInt(process.env.CLAWDBOT_BRIDGE_PORT, 10); - return Number.isFinite(parsed) && parsed > 0 - ? parsed - : deriveDefaultBridgePort(port); - } - return deriveDefaultBridgePort(port); - })(); - - const bridgeHost = (() => { - // Back-compat: allow an env var override when no bind policy is configured. - if (cfgAtStart.bridge?.bind === undefined) { - const env = process.env.CLAWDBOT_BRIDGE_HOST?.trim(); - if (env) return env; - } - - const bind = - cfgAtStart.bridge?.bind ?? (wideAreaDiscoveryEnabled ? "auto" : "lan"); - if (bind === "loopback") return "127.0.0.1"; - if (bind === "lan") return "0.0.0.0"; - - const tailnetIPv4 = pickPrimaryTailnetIPv4(); - const tailnetIPv6 = pickPrimaryTailnetIPv6(); - if (bind === "auto") { - return tailnetIPv4 ?? tailnetIPv6 ?? "0.0.0.0"; - } - if (bind === "custom") { - // For bridge, customBindHost is not currently supported on GatewayConfig. - // This will fall back to "0.0.0.0" until we add customBindHost to BridgeConfig. - return "0.0.0.0"; - } - return "0.0.0.0"; - })(); - - const canvasHostPort = (() => { - if (process.env.CLAWDBOT_CANVAS_HOST_PORT !== undefined) { - const parsed = Number.parseInt(process.env.CLAWDBOT_CANVAS_HOST_PORT, 10); - if (Number.isFinite(parsed) && parsed > 0) return parsed; - return deriveDefaultCanvasHostPort(port); - } - const configured = cfgAtStart.canvasHost?.port; - if (typeof configured === "number" && configured > 0) return configured; - return deriveDefaultCanvasHostPort(port); - })(); - - if (canvasHostEnabled && bridgeEnabled && bridgeHost) { - try { - const started = await startCanvasHost({ - runtime: canvasRuntime, - rootDir: cfgAtStart.canvasHost?.root, - port: canvasHostPort, - listenHost: bridgeHost, - allowInTests: opts.allowCanvasHostInTests, - liveReload: cfgAtStart.canvasHost?.liveReload, - handler: canvasHost ?? undefined, - ownsHandler: canvasHost ? false : undefined, - }); - if (started.port > 0) { - canvasHostServer = started; - } - } catch (err) { - logCanvas.warn( - `failed to start on ${bridgeHost}:${canvasHostPort}: ${String(err)}`, - ); - } - } - - const bridgeSubscribe = bridgeSubscriptions.subscribe; - const bridgeUnsubscribe = bridgeSubscriptions.unsubscribe; - const bridgeUnsubscribeAll = bridgeSubscriptions.unsubscribeAll; - const bridgeSendEvent: BridgeSendEventFn = (opts) => { - bridge?.sendEvent(opts); - }; - const bridgeListConnected: BridgeListConnectedFn = () => - bridge?.listConnected() ?? []; - const bridgeSendToSession = ( - sessionKey: string, - event: string, - payload: unknown, - ) => - bridgeSubscriptions.sendToSession( - sessionKey, - event, - payload, - bridgeSendEvent, - ); - const bridgeSendToAllSubscribed = (event: string, payload: unknown) => - bridgeSubscriptions.sendToAllSubscribed(event, payload, bridgeSendEvent); - const bridgeSendToAllConnected = (event: string, payload: unknown) => - bridgeSubscriptions.sendToAllConnected( - event, - payload, - bridgeListConnected, - bridgeSendEvent, - ); - - const broadcastVoiceWakeChanged = (triggers: string[]) => { - const payload = { triggers }; - broadcast("voicewake.changed", payload, { dropIfSlow: true }); - bridgeSendToAllConnected("voicewake.changed", payload); - }; - - const { handleBridgeRequest, handleBridgeEvent } = createBridgeHandlers({ + const machineDisplayName = await getMachineDisplayName(); + const bridgeRuntime = await startGatewayBridgeRuntime({ + cfg: cfgAtStart, + port, + canvasHostEnabled, + canvasHost, + canvasRuntime, + allowCanvasHostInTests: opts.allowCanvasHostInTests, + machineDisplayName, deps, broadcast, - bridgeSendToSession, - bridgeSubscribe, - bridgeUnsubscribe, - broadcastVoiceWakeChanged, + dedupe, + agentRunSeq, + chatRunState, + chatRunBuffers, + chatDeltaSentAt, addChatRun, removeChatRun, chatAbortControllers, - chatAbortedRuns: chatRunState.abortedRuns, - chatRunBuffers, - chatDeltaSentAt, - dedupe, - agentRunSeq, getHealthCache, - refreshHealthSnapshot: refreshGatewayHealthSnapshot, + refreshGatewayHealthSnapshot, loadGatewayModelCatalog, logBridge, + logCanvas, + logDiscovery, }); + bridge = bridgeRuntime.bridge; + const bridgeHost = bridgeRuntime.bridgeHost; + canvasHostServer = bridgeRuntime.canvasHostServer; + const nodePresenceTimers = bridgeRuntime.nodePresenceTimers; + bonjourStop = bridgeRuntime.bonjourStop; + const bridgeSendToSession = bridgeRuntime.bridgeSendToSession; + const bridgeSendToAllSubscribed = bridgeRuntime.bridgeSendToAllSubscribed; + const broadcastVoiceWakeChanged = bridgeRuntime.broadcastVoiceWakeChanged; - const machineDisplayName = await getMachineDisplayName(); - const canvasHostPortForBridge = canvasHostServer?.port; - const canvasHostHostForBridge = - canvasHostServer && - bridgeHost && - bridgeHost !== "0.0.0.0" && - bridgeHost !== "::" - ? bridgeHost - : undefined; - - const nodePresenceTimers = new Map>(); - - const stopNodePresenceTimer = (nodeId: string) => { - const timer = nodePresenceTimers.get(nodeId); - if (timer) { - clearInterval(timer); - } - nodePresenceTimers.delete(nodeId); - }; - - const beaconNodePresence = ( - node: { - nodeId: string; - displayName?: string; - remoteIp?: string; - version?: string; - platform?: string; - deviceFamily?: string; - modelIdentifier?: string; - }, - reason: string, - ) => { - const host = node.displayName?.trim() || node.nodeId; - const rawIp = node.remoteIp?.trim(); - const ip = rawIp && !isLoopbackAddress(rawIp) ? rawIp : undefined; - const version = node.version?.trim() || "unknown"; - const platform = node.platform?.trim() || undefined; - const deviceFamily = node.deviceFamily?.trim() || undefined; - const modelIdentifier = node.modelIdentifier?.trim() || undefined; - const text = `Node: ${host}${ip ? ` (${ip})` : ""} · app ${version} · last input 0s ago · mode remote · reason ${reason}`; - upsertPresence(node.nodeId, { - host, - ip, - version, - platform, - deviceFamily, - modelIdentifier, - mode: "remote", - reason, - lastInputSeconds: 0, - instanceId: node.nodeId, - text, + const { tickInterval, healthInterval, dedupeCleanup } = + startGatewayMaintenanceTimers({ + broadcast, + bridgeSendToAllSubscribed, + getPresenceVersion, + getHealthVersion, + refreshGatewayHealthSnapshot, + logHealth, + dedupe, + chatAbortControllers, + chatRunState, + chatRunBuffers, + chatDeltaSentAt, + removeChatRun, + agentRunSeq, + bridgeSendToSession, }); - incrementPresenceVersion(); - broadcast( - "presence", - { presence: listSystemPresence() }, - { - dropIfSlow: true, - stateVersion: { - presence: getPresenceVersion(), - health: getHealthVersion(), - }, - }, - ); - }; - - const startNodePresenceTimer = (node: { nodeId: string }) => { - stopNodePresenceTimer(node.nodeId); - nodePresenceTimers.set( - node.nodeId, - setInterval(() => { - beaconNodePresence(node, "periodic"); - }, 180_000), - ); - }; - - if (bridgeEnabled && bridgePort > 0 && bridgeHost) { - try { - const started = await startNodeBridgeServer({ - host: bridgeHost, - port: bridgePort, - serverName: machineDisplayName, - canvasHostPort: canvasHostPortForBridge, - canvasHostHost: canvasHostHostForBridge, - onRequest: (nodeId, req) => handleBridgeRequest(nodeId, req), - onAuthenticated: async (node) => { - beaconNodePresence(node, "node-connected"); - startNodePresenceTimer(node); - - try { - const cfg = await loadVoiceWakeConfig(); - started.sendEvent({ - nodeId: node.nodeId, - event: "voicewake.changed", - payloadJSON: JSON.stringify({ triggers: cfg.triggers }), - }); - } catch { - // Best-effort only. - } - }, - onDisconnected: (node) => { - bridgeUnsubscribeAll(node.nodeId); - stopNodePresenceTimer(node.nodeId); - beaconNodePresence(node, "node-disconnected"); - }, - onEvent: handleBridgeEvent, - onPairRequested: (request) => { - broadcast("node.pair.requested", request, { dropIfSlow: true }); - }, - }); - if (started.port > 0) { - bridge = started; - logBridge.info( - `listening on tcp://${bridgeHost}:${bridge.port} (node)`, - ); - } - } catch (err) { - logBridge.warn(`failed to start: ${String(err)}`); - } - } else if (bridgeEnabled && bridgePort > 0 && !bridgeHost) { - logBridge.warn( - "bind policy requested tailnet IP, but no tailnet interface was found; refusing to start bridge", - ); - } - - const tailnetDns = await resolveTailnetDnsHint(); - const sshPortEnv = process.env.CLAWDBOT_SSH_PORT?.trim(); - const sshPortParsed = sshPortEnv ? Number.parseInt(sshPortEnv, 10) : NaN; - const sshPort = - Number.isFinite(sshPortParsed) && sshPortParsed > 0 - ? sshPortParsed - : undefined; - - try { - const bonjour = await startGatewayBonjourAdvertiser({ - instanceName: formatBonjourInstanceName(machineDisplayName), - gatewayPort: port, - bridgePort: bridge?.port, - canvasPort: canvasHostPortForBridge, - sshPort, - tailnetDns, - cliPath: resolveBonjourCliPath(), - }); - bonjourStop = bonjour.stop; - } catch (err) { - logDiscovery.warn(`bonjour advertising failed: ${String(err)}`); - } - - if (wideAreaDiscoveryEnabled && bridge?.port) { - const tailnetIPv4 = pickPrimaryTailnetIPv4(); - if (!tailnetIPv4) { - logDiscovery.warn( - "discovery.wideArea.enabled is true, but no Tailscale IPv4 address was found; skipping unicast DNS-SD zone update", - ); - } else { - try { - const tailnetIPv6 = pickPrimaryTailnetIPv6(); - const result = await writeWideAreaBridgeZone({ - bridgePort: bridge.port, - gatewayPort: port, - displayName: formatBonjourInstanceName(machineDisplayName), - tailnetIPv4, - tailnetIPv6: tailnetIPv6 ?? undefined, - tailnetDns, - sshPort, - cliPath: resolveBonjourCliPath(), - }); - logDiscovery.info( - `wide-area DNS-SD ${result.changed ? "updated" : "unchanged"} (${WIDE_AREA_DISCOVERY_DOMAIN} → ${result.zonePath})`, - ); - } catch (err) { - logDiscovery.warn(`wide-area discovery update failed: ${String(err)}`); - } - } - } - - setBroadcastHealthUpdate((snap: HealthSummary) => { - broadcast("health", snap, { - stateVersion: { - presence: getPresenceVersion(), - health: getHealthVersion(), - }, - }); - bridgeSendToAllSubscribed("health", snap); - }); - - // periodic keepalive - const tickInterval = setInterval(() => { - const payload = { ts: Date.now() }; - broadcast("tick", payload, { dropIfSlow: true }); - bridgeSendToAllSubscribed("tick", payload); - }, TICK_INTERVAL_MS); - - // periodic health refresh to keep cached snapshot warm - const healthInterval = setInterval(() => { - void refreshGatewayHealthSnapshot({ probe: true }).catch((err) => - logHealth.error(`refresh failed: ${formatError(err)}`), - ); - }, HEALTH_REFRESH_INTERVAL_MS); - - // Prime cache so first client gets a snapshot without waiting. - void refreshGatewayHealthSnapshot({ probe: true }).catch((err) => - logHealth.error(`initial refresh failed: ${formatError(err)}`), - ); - - // dedupe cache cleanup - const dedupeCleanup = setInterval(() => { - const now = Date.now(); - for (const [k, v] of dedupe) { - if (now - v.ts > DEDUPE_TTL_MS) dedupe.delete(k); - } - if (dedupe.size > DEDUPE_MAX) { - const entries = [...dedupe.entries()].sort((a, b) => a[1].ts - b[1].ts); - for (let i = 0; i < dedupe.size - DEDUPE_MAX; i++) { - dedupe.delete(entries[i][0]); - } - } - - for (const [runId, entry] of chatAbortControllers) { - if (now <= entry.expiresAtMs) continue; - abortChatRunById( - { - chatAbortControllers, - chatRunBuffers, - chatDeltaSentAt, - chatAbortedRuns: chatRunState.abortedRuns, - removeChatRun, - agentRunSeq, - broadcast, - bridgeSendToSession, - }, - { runId, sessionKey: entry.sessionKey, stopReason: "timeout" }, - ); - } - - const ABORTED_RUN_TTL_MS = 60 * 60_000; - for (const [runId, abortedAt] of chatRunState.abortedRuns) { - if (now - abortedAt <= ABORTED_RUN_TTL_MS) continue; - chatRunState.abortedRuns.delete(runId); - chatRunBuffers.delete(runId); - chatDeltaSentAt.delete(runId); - } - }, 60_000); const agentUnsub = onAgentEvent( createAgentEventHandler({ @@ -1145,22 +350,22 @@ export async function startGatewayServer( .start() .catch((err) => logCron.error(`failed to start: ${String(err)}`)); - attachGatewayWsConnectionHandler({ + attachGatewayWsHandlers({ wss, clients, port, - bridgeHost, + bridgeHost: bridgeHost ?? undefined, canvasHostEnabled: Boolean(canvasHost), canvasHostServerPort: canvasHostServer?.port ?? undefined, resolvedAuth, gatewayMethods, - events: EVENTS, + events: GATEWAY_EVENTS, logGateway: log, logHealth, logWsControl, extraHandlers: pluginRegistry.gatewayHandlers, broadcast, - buildRequestContext: () => ({ + context: { deps, cron, cronStorePath, @@ -1178,8 +383,8 @@ export async function startGatewayServer( agentRunSeq, chatAbortControllers, chatAbortedRuns: chatRunState.abortedRuns, - chatRunBuffers, - chatDeltaSentAt, + chatRunBuffers: chatRunState.buffers, + chatDeltaSentAt: chatRunState.deltaSentAt, addChatRun, removeChatRun, dedupe, @@ -1192,333 +397,65 @@ export async function startGatewayServer( markChannelLoggedOut, wizardRunner, broadcastVoiceWakeChanged, - }), + }, }); - const { provider: agentProvider, model: agentModel } = - resolveConfiguredModelRef({ - cfg: cfgAtStart, - defaultProvider: DEFAULT_PROVIDER, - defaultModel: DEFAULT_MODEL, - }); - const modelRef = `${agentProvider}/${agentModel}`; - log.info(`agent model: ${modelRef}`, { - consoleMessage: `agent model: ${chalk.whiteBright(modelRef)}`, + logGatewayStartup({ + cfg: cfgAtStart, + bindHost, + port, + log, + isNixMode, + }); + const tailscaleCleanup = await startGatewayTailscaleExposure({ + tailscaleMode, + resetOnExit: tailscaleConfig.resetOnExit, + port, + controlUiBasePath, + logTailscale, }); - log.info(`listening on ws://${bindHost}:${port} (PID ${process.pid})`); - log.info(`log file: ${getResolvedLoggerSettings().file}`); - if (isNixMode) { - log.info("gateway: running in Nix mode (config managed externally)"); - } - let tailscaleCleanup: (() => Promise) | null = null; - if (tailscaleMode !== "off") { - try { - if (tailscaleMode === "serve") { - await enableTailscaleServe(port); - } else { - await enableTailscaleFunnel(port); - } - const host = await getTailnetHostname().catch(() => null); - if (host) { - const uiPath = controlUiBasePath ? `${controlUiBasePath}/` : "/"; - logTailscale.info( - `${tailscaleMode} enabled: https://${host}${uiPath} (WS via wss://${host})`, - ); - } else { - logTailscale.info(`${tailscaleMode} enabled`); - } - } catch (err) { - logTailscale.warn( - `${tailscaleMode} failed: ${err instanceof Error ? err.message : String(err)}`, - ); - } - if (tailscaleConfig.resetOnExit) { - tailscaleCleanup = async () => { - try { - if (tailscaleMode === "serve") { - await disableTailscaleServe(); - } else { - await disableTailscaleFunnel(); - } - } catch (err) { - logTailscale.warn( - `${tailscaleMode} cleanup failed: ${err instanceof Error ? err.message : String(err)}`, - ); - } - }; - } - } - // Start clawd browser control server (unless disabled via config). let browserControl: Awaited< ReturnType > = null; - try { - browserControl = await startBrowserControlServerIfEnabled(); - } catch (err) { - logBrowser.error(`server failed to start: ${String(err)}`); - } + ({ browserControl, pluginServices } = await startGatewaySidecars({ + cfg: cfgAtStart, + pluginRegistry, + defaultWorkspaceDir, + deps, + startChannels, + log, + logHooks, + logChannels, + logBrowser, + })); - // Start Gmail watcher if configured (hooks.gmail.account). - if (process.env.CLAWDBOT_SKIP_GMAIL_WATCHER !== "1") { - try { - const gmailResult = await startGmailWatcher(cfgAtStart); - if (gmailResult.started) { - logHooks.info("gmail watcher started"); - } else if ( - gmailResult.reason && - gmailResult.reason !== "hooks not enabled" && - gmailResult.reason !== "no gmail account configured" - ) { - logHooks.warn(`gmail watcher not started: ${gmailResult.reason}`); - } - } catch (err) { - logHooks.error(`gmail watcher failed to start: ${String(err)}`); - } - } - - // Validate hooks.gmail.model if configured. - if (cfgAtStart.hooks?.gmail?.model) { - const hooksModelRef = resolveHooksGmailModel({ - cfg: cfgAtStart, - defaultProvider: DEFAULT_PROVIDER, - }); - if (hooksModelRef) { - const { provider: defaultProvider, model: defaultModel } = - resolveConfiguredModelRef({ - cfg: cfgAtStart, - defaultProvider: DEFAULT_PROVIDER, - defaultModel: DEFAULT_MODEL, - }); - const catalog = await loadModelCatalog({ config: cfgAtStart }); - const status = getModelRefStatus({ - cfg: cfgAtStart, - catalog, - ref: hooksModelRef, - defaultProvider, - defaultModel, - }); - if (!status.allowed) { - logHooks.warn( - `hooks.gmail.model "${status.key}" not in agents.defaults.models allowlist (will use primary instead)`, - ); - } - if (!status.inCatalog) { - logHooks.warn( - `hooks.gmail.model "${status.key}" not in the model catalog (may fail at runtime)`, - ); - } - } - } - - // Launch configured channels so gateway replies via the surface the message came from. - // Tests can opt out via CLAWDBOT_SKIP_CHANNELS (or legacy CLAWDBOT_SKIP_PROVIDERS). - const skipChannels = - process.env.CLAWDBOT_SKIP_CHANNELS === "1" || - process.env.CLAWDBOT_SKIP_PROVIDERS === "1"; - if (!skipChannels) { - try { - await startChannels(); - } catch (err) { - logChannels.error(`channel startup failed: ${String(err)}`); - } - } else { - logChannels.info( - "skipping channel start (CLAWDBOT_SKIP_CHANNELS=1 or CLAWDBOT_SKIP_PROVIDERS=1)", - ); - } - - try { - pluginServices = await startPluginServices({ - registry: pluginRegistry, - config: cfgAtStart, - workspaceDir: defaultWorkspaceDir, - }); - } catch (err) { - log.warn(`plugin services failed to start: ${String(err)}`); - } - - const scheduleRestartSentinelWake = async () => { - const sentinel = await consumeRestartSentinel(); - if (!sentinel) return; - const payload = sentinel.payload; - const sessionKey = payload.sessionKey?.trim(); - const message = formatRestartSentinelMessage(payload); - const summary = summarizeRestartSentinel(payload); - - if (!sessionKey) { - const mainSessionKey = resolveMainSessionKeyFromConfig(); - enqueueSystemEvent(message, { sessionKey: mainSessionKey }); - return; - } - - const { cfg, entry } = loadSessionEntry(sessionKey); - const lastChannel = entry?.lastChannel; - const lastTo = entry?.lastTo?.trim(); - const parsedTarget = resolveAnnounceTargetFromKey(sessionKey); - const channelRaw = lastChannel ?? parsedTarget?.channel; - const channel = channelRaw ? normalizeChannelId(channelRaw) : null; - const to = lastTo || parsedTarget?.to; - if (!channel || !to) { - enqueueSystemEvent(message, { sessionKey }); - return; - } - - const resolved = resolveOutboundTarget({ - channel, - to, - cfg, - accountId: parsedTarget?.accountId ?? entry?.lastAccountId, - mode: "implicit", - }); - if (!resolved.ok) { - enqueueSystemEvent(message, { sessionKey }); - return; - } - - try { - await agentCommand( - { - message, - sessionKey, - to: resolved.to, - channel, - deliver: true, - bestEffortDeliver: true, - messageChannel: channel, - }, - defaultRuntime, - deps, - ); - } catch (err) { - enqueueSystemEvent(`${summary}\n${String(err)}`, { sessionKey }); - } - }; - - const shouldWakeFromSentinel = - !process.env.VITEST && process.env.NODE_ENV !== "test"; - if (shouldWakeFromSentinel) { - setTimeout(() => { - void scheduleRestartSentinelWake(); - }, 750); - } - - const applyHotReload = async ( - plan: GatewayReloadPlan, - nextConfig: ReturnType, - ) => { - if (plan.reloadHooks) { - try { - hooksConfig = resolveHooksConfig(nextConfig); - } catch (err) { - logHooks.warn(`hooks config reload failed: ${String(err)}`); - } - } - - if (plan.restartHeartbeat) { - heartbeatRunner.stop(); - heartbeatRunner = startHeartbeatRunner({ cfg: nextConfig }); - } - - if (plan.restartCron) { - cron.stop(); - const next = buildCronService(nextConfig); - cron = next.cron; - cronStorePath = next.storePath; - void cron - .start() - .catch((err) => logCron.error(`failed to start: ${String(err)}`)); - } - - if (plan.restartBrowserControl) { - if (browserControl) { - await browserControl.stop().catch(() => {}); - } - try { - browserControl = await startBrowserControlServerIfEnabled(); - } catch (err) { - logBrowser.error(`server failed to start: ${String(err)}`); - } - } - - if (plan.restartGmailWatcher) { - await stopGmailWatcher().catch(() => {}); - if (process.env.CLAWDBOT_SKIP_GMAIL_WATCHER !== "1") { - try { - const gmailResult = await startGmailWatcher(nextConfig); - if (gmailResult.started) { - logHooks.info("gmail watcher started"); - } else if ( - gmailResult.reason && - gmailResult.reason !== "hooks not enabled" && - gmailResult.reason !== "no gmail account configured" - ) { - logHooks.warn(`gmail watcher not started: ${gmailResult.reason}`); - } - } catch (err) { - logHooks.error(`gmail watcher failed to start: ${String(err)}`); - } - } else { - logHooks.info( - "skipping gmail watcher restart (CLAWDBOT_SKIP_GMAIL_WATCHER=1)", - ); - } - } - - if (plan.restartChannels.size > 0) { - if ( - process.env.CLAWDBOT_SKIP_CHANNELS === "1" || - process.env.CLAWDBOT_SKIP_PROVIDERS === "1" - ) { - logChannels.info( - "skipping channel reload (CLAWDBOT_SKIP_CHANNELS=1 or CLAWDBOT_SKIP_PROVIDERS=1)", - ); - } else { - const restartChannel = async (name: ChannelKind) => { - logChannels.info(`restarting ${name} channel`); - await stopChannel(name); - await startChannel(name); - }; - for (const channel of plan.restartChannels) { - await restartChannel(channel); - } - } - } - - setCommandLaneConcurrency("cron", nextConfig.cron?.maxConcurrentRuns ?? 1); - setCommandLaneConcurrency( - "main", - nextConfig.agents?.defaults?.maxConcurrent ?? 1, - ); - setCommandLaneConcurrency( - "subagent", - nextConfig.agents?.defaults?.subagents?.maxConcurrent ?? 1, - ); - - if (plan.hotReasons.length > 0) { - logReload.info( - `config hot reload applied (${plan.hotReasons.join(", ")})`, - ); - } else if (plan.noopPaths.length > 0) { - logReload.info( - `config change applied (dynamic reads: ${plan.noopPaths.join(", ")})`, - ); - } - }; - - const requestGatewayRestart = ( - plan: GatewayReloadPlan, - _nextConfig: ReturnType, - ) => { - const reasons = plan.restartReasons.length - ? plan.restartReasons.join(", ") - : plan.changedPaths.join(", "); - logReload.warn(`config change requires gateway restart (${reasons})`); - if (process.listenerCount("SIGUSR1") === 0) { - logReload.warn("no SIGUSR1 listener found; restart skipped"); - return; - } - process.emit("SIGUSR1"); - }; + const { applyHotReload, requestGatewayRestart } = createGatewayReloadHandlers( + { + deps, + broadcast, + getState: () => ({ + hooksConfig, + heartbeatRunner, + cronState, + browserControl, + }), + setState: (nextState) => { + hooksConfig = nextState.hooksConfig; + heartbeatRunner = nextState.heartbeatRunner; + cronState = nextState.cronState; + cron = cronState.cron; + cronStorePath = cronState.storePath; + browserControl = nextState.browserControl; + }, + startChannel, + stopChannel, + logHooks, + logBrowser, + logChannels, + logCron, + logReload, + }, + ); const configReloader = startGatewayConfigReloader({ initialConfig: cfgAtStart, @@ -1533,98 +470,30 @@ export async function startGatewayServer( watchPath: CONFIG_PATH_CLAWDBOT, }); - return { - close: async (opts) => { - const reasonRaw = - typeof opts?.reason === "string" ? opts.reason.trim() : ""; - const reason = reasonRaw || "gateway stopping"; - const restartExpectedMs = - typeof opts?.restartExpectedMs === "number" && - Number.isFinite(opts.restartExpectedMs) - ? Math.max(0, Math.floor(opts.restartExpectedMs)) - : null; - if (bonjourStop) { - try { - await bonjourStop(); - } catch { - /* ignore */ - } - } - if (tailscaleCleanup) { - await tailscaleCleanup(); - } - if (canvasHost) { - try { - await canvasHost.close(); - } catch { - /* ignore */ - } - } - if (canvasHostServer) { - try { - await canvasHostServer.close(); - } catch { - /* ignore */ - } - } - if (bridge) { - try { - await bridge.close(); - } catch { - /* ignore */ - } - } - for (const plugin of listChannelPlugins()) { - await stopChannel(plugin.id); - } - if (pluginServices) { - await pluginServices.stop().catch(() => {}); - } - await stopGmailWatcher(); - cron.stop(); - heartbeatRunner.stop(); - for (const timer of nodePresenceTimers.values()) { - clearInterval(timer); - } - nodePresenceTimers.clear(); - broadcast("shutdown", { - reason, - restartExpectedMs, - }); - clearInterval(tickInterval); - clearInterval(healthInterval); - clearInterval(dedupeCleanup); - if (agentUnsub) { - try { - agentUnsub(); - } catch { - /* ignore */ - } - } - if (heartbeatUnsub) { - try { - heartbeatUnsub(); - } catch { - /* ignore */ - } - } - chatRunState.clear(); - for (const c of clients) { - try { - c.socket.close(1012, "service restart"); - } catch { - /* ignore */ - } - } - clients.clear(); - await configReloader.stop().catch(() => {}); - if (browserControl) { - await browserControl.stop().catch(() => {}); - } - await new Promise((resolve) => wss.close(() => resolve())); - await new Promise((resolve, reject) => - httpServer.close((err) => (err ? reject(err) : resolve())), - ); - }, - }; + const close = createGatewayCloseHandler({ + bonjourStop, + tailscaleCleanup, + canvasHost, + canvasHostServer, + bridge, + stopChannel, + pluginServices, + cron, + heartbeatRunner, + nodePresenceTimers, + broadcast, + tickInterval, + healthInterval, + dedupeCleanup, + agentUnsub, + heartbeatUnsub, + chatRunState, + clients, + configReloader, + browserControl, + wss, + httpServer, + }); + + return { close }; }