From 5ce1eb791e0ad7ca2e9fdd6e92bde51039ba3113 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 4 Jan 2026 14:34:23 +0100 Subject: [PATCH] chore: align rebase with main --- src/agents/clawdis-tools.sessions.test.ts | 167 +- src/agents/clawdis-tools.ts | 3093 +------------------ src/gateway/server-methods.ts | 3270 +-------------------- 3 files changed, 217 insertions(+), 6313 deletions(-) diff --git a/src/agents/clawdis-tools.sessions.test.ts b/src/agents/clawdis-tools.sessions.test.ts index e45846b14..94376b95d 100644 --- a/src/agents/clawdis-tools.sessions.test.ts +++ b/src/agents/clawdis-tools.sessions.test.ts @@ -7,7 +7,11 @@ vi.mock("../gateway/call.js", () => ({ vi.mock("../config/config.js", () => ({ loadConfig: () => ({ - session: { mainKey: "main", scope: "per-sender" }, + session: { + mainKey: "main", + scope: "per-sender", + agentToAgent: { maxPingPongTurns: 2 }, + }, }), resolveGatewayPort: () => 18789, })); @@ -127,18 +131,28 @@ describe("sessions tools", () => { let agentCallCount = 0; let _historyCallCount = 0; let sendCallCount = 0; - let waitRunId: string | undefined; - let nextHistoryIsWaitReply = false; + let lastWaitedRunId: string | undefined; + const replyByRunId = new Map(); + const requesterKey = "discord:group:req"; callGatewayMock.mockImplementation(async (opts: unknown) => { const request = opts as { method?: string; params?: unknown }; calls.push(request); if (request.method === "agent") { agentCallCount += 1; const runId = `run-${agentCallCount}`; - const params = request.params as { message?: string } | undefined; - if (params?.message === "wait") { - waitRunId = runId; + const params = request.params as + | { message?: string; sessionKey?: string } + | undefined; + const message = params?.message ?? ""; + let reply = "REPLY_SKIP"; + if (message === "ping" || message === "wait") { + reply = "done"; + } else if (message === "Agent-to-agent announce step.") { + reply = "ANNOUNCE_SKIP"; + } else if (params?.sessionKey === requesterKey) { + reply = "pong"; } + replyByRunId.set(runId, reply); return { runId, status: "accepted", @@ -147,15 +161,13 @@ describe("sessions tools", () => { } if (request.method === "agent.wait") { const params = request.params as { runId?: string } | undefined; - if (params?.runId && params.runId === waitRunId) { - nextHistoryIsWaitReply = true; - } + lastWaitedRunId = params?.runId; return { runId: params?.runId ?? "run-1", status: "ok" }; } if (request.method === "chat.history") { _historyCallCount += 1; - const text = nextHistoryIsWaitReply ? "done" : "ANNOUNCE_SKIP"; - nextHistoryIsWaitReply = false; + const text = + (lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? ""; return { messages: [ { @@ -178,9 +190,10 @@ describe("sessions tools", () => { return {}; }); - const tool = createClawdisTools().find( - (candidate) => candidate.name === "sessions_send", - ); + const tool = createClawdisTools({ + agentSessionKey: requesterKey, + agentSurface: "discord", + }).find((candidate) => candidate.name === "sessions_send"); expect(tool).toBeDefined(); if (!tool) throw new Error("missing sessions_send tool"); @@ -191,6 +204,7 @@ describe("sessions tools", () => { }); expect(fire.details).toMatchObject({ status: "accepted", runId: "run-1" }); await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); const waitPromise = tool.execute("call6", { sessionKey: "main", @@ -204,13 +218,14 @@ describe("sessions tools", () => { }); expect(typeof (waited.details as { runId?: string }).runId).toBe("string"); await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); const agentCalls = calls.filter((call) => call.method === "agent"); const waitCalls = calls.filter((call) => call.method === "agent.wait"); const historyOnlyCalls = calls.filter( (call) => call.method === "chat.history", ); - expect(agentCalls).toHaveLength(4); + expect(agentCalls).toHaveLength(8); for (const call of agentCalls) { expect(call.params).toMatchObject({ lane: "nested" }); } @@ -231,11 +246,21 @@ describe("sessions tools", () => { ?.extraSystemPrompt === "string" && ( call.params as { extraSystemPrompt?: string } - )?.extraSystemPrompt?.includes("Agent-to-agent post step"), + )?.extraSystemPrompt?.includes("Agent-to-agent reply step"), ), ).toBe(true); - expect(waitCalls).toHaveLength(3); - expect(historyOnlyCalls).toHaveLength(3); + expect( + agentCalls.some( + (call) => + typeof (call.params as { extraSystemPrompt?: string }) + ?.extraSystemPrompt === "string" && + ( + call.params as { extraSystemPrompt?: string } + )?.extraSystemPrompt?.includes("Agent-to-agent announce step"), + ), + ).toBe(true); + expect(waitCalls).toHaveLength(8); + expect(historyOnlyCalls).toHaveLength(8); expect( waitCalls.some( (call) => @@ -244,4 +269,110 @@ describe("sessions tools", () => { ).toBe(true); expect(sendCallCount).toBe(0); }); + + it("sessions_send runs ping-pong then announces", async () => { + callGatewayMock.mockReset(); + const calls: Array<{ method?: string; params?: unknown }> = []; + let agentCallCount = 0; + let lastWaitedRunId: string | undefined; + const replyByRunId = new Map(); + const requesterKey = "discord:group:req"; + const targetKey = "discord:group:target"; + let sendParams: { to?: string; provider?: string; message?: string } = {}; + callGatewayMock.mockImplementation(async (opts: unknown) => { + const request = opts as { method?: string; params?: unknown }; + calls.push(request); + if (request.method === "agent") { + agentCallCount += 1; + const runId = `run-${agentCallCount}`; + const params = request.params as + | { + message?: string; + sessionKey?: string; + extraSystemPrompt?: string; + } + | undefined; + let reply = "initial"; + if (params?.extraSystemPrompt?.includes("Agent-to-agent reply step")) { + reply = params.sessionKey === requesterKey ? "pong-1" : "pong-2"; + } + if ( + params?.extraSystemPrompt?.includes("Agent-to-agent announce step") + ) { + reply = "announce now"; + } + replyByRunId.set(runId, reply); + return { + runId, + status: "accepted", + acceptedAt: 2000 + agentCallCount, + }; + } + if (request.method === "agent.wait") { + const params = request.params as { runId?: string } | undefined; + lastWaitedRunId = params?.runId; + return { runId: params?.runId ?? "run-1", status: "ok" }; + } + if (request.method === "chat.history") { + const text = + (lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? ""; + return { + messages: [ + { + role: "assistant", + content: [{ type: "text", text }], + timestamp: 20, + }, + ], + }; + } + if (request.method === "send") { + const params = request.params as + | { to?: string; provider?: string; message?: string } + | undefined; + sendParams = { + to: params?.to, + provider: params?.provider, + message: params?.message, + }; + return { messageId: "m-announce" }; + } + return {}; + }); + + const tool = createClawdisTools({ + agentSessionKey: requesterKey, + agentSurface: "discord", + }).find((candidate) => candidate.name === "sessions_send"); + expect(tool).toBeDefined(); + if (!tool) throw new Error("missing sessions_send tool"); + + const waited = await tool.execute("call7", { + sessionKey: targetKey, + message: "ping", + timeoutSeconds: 1, + }); + expect(waited.details).toMatchObject({ + status: "ok", + reply: "initial", + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const replySteps = calls.filter( + (call) => + call.method === "agent" && + typeof (call.params as { extraSystemPrompt?: string }) + ?.extraSystemPrompt === "string" && + ( + call.params as { extraSystemPrompt?: string } + )?.extraSystemPrompt?.includes("Agent-to-agent reply step"), + ); + expect(replySteps).toHaveLength(2); + expect(sendParams).toMatchObject({ + to: "channel:target", + provider: "discord", + message: "announce now", + }); + }); }); diff --git a/src/agents/clawdis-tools.ts b/src/agents/clawdis-tools.ts index 5b0d1e2ed..05fb3b1c6 100644 --- a/src/agents/clawdis-tools.ts +++ b/src/agents/clawdis-tools.ts @@ -1,3084 +1,14 @@ -import crypto from "node:crypto"; -import fs from "node:fs/promises"; -import path from "node:path"; - -import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; -import { Type } from "@sinclair/typebox"; -import { - browserCloseTab, - browserFocusTab, - browserOpenTab, - browserSnapshot, - browserStart, - browserStatus, - browserStop, - browserTabs, -} from "../browser/client.js"; -import { - browserAct, - browserArmDialog, - browserArmFileChooser, - browserConsoleMessages, - browserNavigate, - browserPdfSave, - browserScreenshotAction, -} from "../browser/client-actions.js"; -import { resolveBrowserConfig } from "../browser/config.js"; -import { - type CameraFacing, - cameraTempPath, - parseCameraClipPayload, - parseCameraSnapPayload, - writeBase64ToFile, -} from "../cli/nodes-camera.js"; -import { - canvasSnapshotTempPath, - parseCanvasSnapshotPayload, -} from "../cli/nodes-canvas.js"; -import { - parseScreenRecordPayload, - screenRecordTempPath, - writeScreenRecordToFile, -} from "../cli/nodes-screen.js"; -import { parseDurationMs } from "../cli/parse-duration.js"; -import { - type ClawdisConfig, - type DiscordActionConfig, - loadConfig, -} from "../config/config.js"; -import { - addRoleDiscord, - banMemberDiscord, - createScheduledEventDiscord, - createThreadDiscord, - deleteMessageDiscord, - editMessageDiscord, - fetchChannelInfoDiscord, - fetchChannelPermissionsDiscord, - fetchMemberInfoDiscord, - fetchReactionsDiscord, - fetchRoleInfoDiscord, - fetchVoiceStatusDiscord, - kickMemberDiscord, - listGuildChannelsDiscord, - listGuildEmojisDiscord, - listPinsDiscord, - listScheduledEventsDiscord, - listThreadsDiscord, - pinMessageDiscord, - reactMessageDiscord, - readMessagesDiscord, - removeRoleDiscord, - searchMessagesDiscord, - sendMessageDiscord, - sendPollDiscord, - sendStickerDiscord, - timeoutMemberDiscord, - unpinMessageDiscord, -} from "../discord/send.js"; -import { callGateway } from "../gateway/call.js"; -import { detectMime, imageMimeFromFormat } from "../media/mime.js"; -import { sanitizeToolResultImages } from "./tool-images.js"; - -// biome-ignore lint/suspicious/noExplicitAny: TypeBox schema type from pi-agent-core uses a different module instance. -type AnyAgentTool = AgentTool; - -const DEFAULT_GATEWAY_URL = "ws://127.0.0.1:18789"; - -type GatewayCallOptions = { - gatewayUrl?: string; - gatewayToken?: string; - timeoutMs?: number; -}; - -function resolveGatewayOptions(opts?: GatewayCallOptions) { - const url = - typeof opts?.gatewayUrl === "string" && opts.gatewayUrl.trim() - ? opts.gatewayUrl.trim() - : DEFAULT_GATEWAY_URL; - const token = - typeof opts?.gatewayToken === "string" && opts.gatewayToken.trim() - ? opts.gatewayToken.trim() - : undefined; - const timeoutMs = - typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs) - ? Math.max(1, Math.floor(opts.timeoutMs)) - : 10_000; - return { url, token, timeoutMs }; -} - -type StringParamOptions = { - required?: boolean; - trim?: boolean; - label?: string; -}; - -function readStringParam( - params: Record, - key: string, - options: StringParamOptions & { required: true }, -): string; -function readStringParam( - params: Record, - key: string, - options?: StringParamOptions, -): string | undefined; -function readStringParam( - params: Record, - key: string, - options: StringParamOptions = {}, -) { - const { required = false, trim = true, label = key } = options; - const raw = params[key]; - if (typeof raw !== "string") { - if (required) throw new Error(`${label} required`); - return undefined; - } - const value = trim ? raw.trim() : raw; - if (!value) { - if (required) throw new Error(`${label} required`); - return undefined; - } - return value; -} - -function readStringArrayParam( - params: Record, - key: string, - options: StringParamOptions & { required: true }, -): string[]; -function readStringArrayParam( - params: Record, - key: string, - options?: StringParamOptions, -): string[] | undefined; -function readStringArrayParam( - params: Record, - key: string, - options: StringParamOptions = {}, -) { - const { required = false, label = key } = options; - const raw = params[key]; - if (Array.isArray(raw)) { - const values = raw - .filter((entry) => typeof entry === "string") - .map((entry) => entry.trim()) - .filter(Boolean); - if (values.length === 0) { - if (required) throw new Error(`${label} required`); - return undefined; - } - return values; - } - if (typeof raw === "string") { - const value = raw.trim(); - if (!value) { - if (required) throw new Error(`${label} required`); - return undefined; - } - return [value]; - } - if (required) throw new Error(`${label} required`); - return undefined; -} - -async function callGatewayTool( - method: string, - opts: GatewayCallOptions, - params?: unknown, - extra?: { expectFinal?: boolean }, -) { - const gateway = resolveGatewayOptions(opts); - return await callGateway({ - url: gateway.url, - token: gateway.token, - method, - params, - timeoutMs: gateway.timeoutMs, - expectFinal: extra?.expectFinal, - clientName: "agent", - mode: "agent", - }); -} - -function jsonResult(payload: unknown): AgentToolResult { - return { - content: [ - { - type: "text", - text: JSON.stringify(payload, null, 2), - }, - ], - details: payload, - }; -} - -type SessionKind = "main" | "group" | "cron" | "hook" | "node" | "other"; -type SessionListRow = { - key: string; - kind: SessionKind; - provider: string; - displayName?: string; - updatedAt?: number | null; - sessionId?: string; - model?: string; - contextTokens?: number | null; - totalTokens?: number | null; - thinkingLevel?: string; - verboseLevel?: string; - systemSent?: boolean; - abortedLastRun?: boolean; - sendPolicy?: string; - lastChannel?: string; - lastTo?: string; - transcriptPath?: string; - messages?: unknown[]; -}; - -function normalizeKey(value?: string) { - const trimmed = value?.trim(); - return trimmed ? trimmed : undefined; -} - -function resolveMainSessionAlias(cfg: ClawdisConfig) { - const mainKey = normalizeKey(cfg.session?.mainKey) ?? "main"; - const scope = cfg.session?.scope ?? "per-sender"; - const alias = scope === "global" ? "global" : mainKey; - return { mainKey, alias, scope }; -} - -function resolveDisplaySessionKey(params: { - key: string; - alias: string; - mainKey: string; -}) { - if (params.key === params.alias) return "main"; - if (params.key === params.mainKey) return "main"; - return params.key; -} - -function resolveInternalSessionKey(params: { - key: string; - alias: string; - mainKey: string; -}) { - if (params.key === "main") return params.alias; - return params.key; -} - -function classifySessionKind(params: { - key: string; - gatewayKind?: string | null; - alias: string; - mainKey: string; -}): SessionKind { - const key = params.key; - if (key === params.alias || key === params.mainKey) return "main"; - if (key.startsWith("cron:")) return "cron"; - if (key.startsWith("hook:")) return "hook"; - if (key.startsWith("node-") || key.startsWith("node:")) return "node"; - if (params.gatewayKind === "group") return "group"; - if ( - key.startsWith("group:") || - key.includes(":group:") || - key.includes(":channel:") - ) { - return "group"; - } - return "other"; -} - -function deriveProvider(params: { - key: string; - kind: SessionKind; - surface?: string | null; - lastChannel?: string | null; -}): string { - if ( - params.kind === "cron" || - params.kind === "hook" || - params.kind === "node" - ) - return "internal"; - const surface = normalizeKey(params.surface ?? undefined); - if (surface) return surface; - const lastChannel = normalizeKey(params.lastChannel ?? undefined); - if (lastChannel) return lastChannel; - const parts = params.key.split(":").filter(Boolean); - if (parts.length >= 3 && (parts[1] === "group" || parts[1] === "channel")) { - return parts[0]; - } - return "unknown"; -} - -function stripToolMessages(messages: unknown[]): unknown[] { - return messages.filter((msg) => { - if (!msg || typeof msg !== "object") return true; - const role = (msg as { role?: unknown }).role; - return role !== "toolResult"; - }); -} - -function extractAssistantText(message: unknown): string | undefined { - if (!message || typeof message !== "object") return undefined; - if ((message as { role?: unknown }).role !== "assistant") return undefined; - const content = (message as { content?: unknown }).content; - if (!Array.isArray(content)) return undefined; - const chunks: string[] = []; - for (const block of content) { - if (!block || typeof block !== "object") continue; - if ((block as { type?: unknown }).type !== "text") continue; - const text = (block as { text?: unknown }).text; - if (typeof text === "string" && text.trim()) { - chunks.push(text); - } - } - const joined = chunks.join("").trim(); - return joined ? joined : undefined; -} - -async function imageResult(params: { - label: string; - path: string; - base64: string; - mimeType: string; - extraText?: string; - details?: Record; -}): Promise> { - const content: AgentToolResult["content"] = [ - { - type: "text", - text: params.extraText ?? `MEDIA:${params.path}`, - }, - { - type: "image", - data: params.base64, - mimeType: params.mimeType, - }, - ]; - const result: AgentToolResult = { - content, - details: { path: params.path, ...params.details }, - }; - return await sanitizeToolResultImages(result, params.label); -} - -async function imageResultFromFile(params: { - label: string; - path: string; - extraText?: string; - details?: Record; -}): Promise> { - const buf = await fs.readFile(params.path); - const mimeType = - (await detectMime({ buffer: buf.slice(0, 256) })) ?? "image/png"; - return await imageResult({ - label: params.label, - path: params.path, - base64: buf.toString("base64"), - mimeType, - extraText: params.extraText, - details: params.details, - }); -} - -function resolveBrowserBaseUrl(controlUrl?: string) { - const cfg = loadConfig(); - const resolved = resolveBrowserConfig(cfg.browser); - if (!resolved.enabled && !controlUrl?.trim()) { - throw new Error( - "Browser control is disabled. Set browser.enabled=true in ~/.clawdis/clawdis.json.", - ); - } - const url = controlUrl?.trim() ? controlUrl.trim() : resolved.controlUrl; - return url.replace(/\/$/, ""); -} - -type NodeListNode = { - nodeId: string; - displayName?: string; - platform?: string; - remoteIp?: string; - deviceFamily?: string; - modelIdentifier?: string; - caps?: string[]; - commands?: string[]; - permissions?: Record; - paired?: boolean; - connected?: boolean; -}; - -type PendingRequest = { - requestId: string; - nodeId: string; - displayName?: string; - platform?: string; - version?: string; - remoteIp?: string; - isRepair?: boolean; - ts: number; -}; - -type PairedNode = { - nodeId: string; - token?: string; - displayName?: string; - platform?: string; - version?: string; - remoteIp?: string; - permissions?: Record; - createdAtMs?: number; - approvedAtMs?: number; -}; - -type PairingList = { - pending: PendingRequest[]; - paired: PairedNode[]; -}; - -function parseNodeList(value: unknown): NodeListNode[] { - const obj = - typeof value === "object" && value !== null - ? (value as Record) - : {}; - return Array.isArray(obj.nodes) ? (obj.nodes as NodeListNode[]) : []; -} - -function parsePairingList(value: unknown): PairingList { - const obj = - typeof value === "object" && value !== null - ? (value as Record) - : {}; - const pending = Array.isArray(obj.pending) - ? (obj.pending as PendingRequest[]) - : []; - const paired = Array.isArray(obj.paired) ? (obj.paired as PairedNode[]) : []; - return { pending, paired }; -} - -function normalizeNodeKey(value: string) { - return value - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+/, "") - .replace(/-+$/, ""); -} - -async function loadNodes(opts: GatewayCallOptions): Promise { - try { - const res = (await callGatewayTool("node.list", opts, {})) as unknown; - return parseNodeList(res); - } catch { - const res = (await callGatewayTool("node.pair.list", opts, {})) as unknown; - const { paired } = parsePairingList(res); - return paired.map((n) => ({ - nodeId: n.nodeId, - displayName: n.displayName, - platform: n.platform, - remoteIp: n.remoteIp, - })); - } -} - -function pickDefaultNode(nodes: NodeListNode[]): NodeListNode | null { - const withCanvas = nodes.filter((n) => - Array.isArray(n.caps) ? n.caps.includes("canvas") : true, - ); - if (withCanvas.length === 0) return null; - - const connected = withCanvas.filter((n) => n.connected); - const candidates = connected.length > 0 ? connected : withCanvas; - if (candidates.length === 1) return candidates[0]; - - const local = candidates.filter( - (n) => - n.platform?.toLowerCase().startsWith("mac") && - typeof n.nodeId === "string" && - n.nodeId.startsWith("mac-"), - ); - if (local.length === 1) return local[0]; - - return null; -} - -async function resolveNodeId( - opts: GatewayCallOptions, - query?: string, - allowDefault = false, -) { - const nodes = await loadNodes(opts); - const q = String(query ?? "").trim(); - if (!q) { - if (allowDefault) { - const picked = pickDefaultNode(nodes); - if (picked) return picked.nodeId; - } - throw new Error("node required"); - } - - const qNorm = normalizeNodeKey(q); - const matches = nodes.filter((n) => { - if (n.nodeId === q) return true; - if (typeof n.remoteIp === "string" && n.remoteIp === q) return true; - const name = typeof n.displayName === "string" ? n.displayName : ""; - if (name && normalizeNodeKey(name) === qNorm) return true; - if (q.length >= 6 && n.nodeId.startsWith(q)) return true; - return false; - }); - - if (matches.length === 1) return matches[0].nodeId; - if (matches.length === 0) { - const known = nodes - .map((n) => n.displayName || n.remoteIp || n.nodeId) - .filter(Boolean) - .join(", "); - throw new Error(`unknown node: ${q}${known ? ` (known: ${known})` : ""}`); - } - throw new Error( - `ambiguous node: ${q} (matches: ${matches - .map((n) => n.displayName || n.remoteIp || n.nodeId) - .join(", ")})`, - ); -} - -const BrowserActSchema = Type.Union([ - Type.Object({ - kind: Type.Literal("click"), - ref: Type.String(), - targetId: Type.Optional(Type.String()), - doubleClick: Type.Optional(Type.Boolean()), - button: Type.Optional(Type.String()), - modifiers: Type.Optional(Type.Array(Type.String())), - }), - Type.Object({ - kind: Type.Literal("type"), - ref: Type.String(), - text: Type.String(), - targetId: Type.Optional(Type.String()), - submit: Type.Optional(Type.Boolean()), - slowly: Type.Optional(Type.Boolean()), - }), - Type.Object({ - kind: Type.Literal("press"), - key: Type.String(), - targetId: Type.Optional(Type.String()), - }), - Type.Object({ - kind: Type.Literal("hover"), - ref: Type.String(), - targetId: Type.Optional(Type.String()), - }), - Type.Object({ - kind: Type.Literal("drag"), - startRef: Type.String(), - endRef: Type.String(), - targetId: Type.Optional(Type.String()), - }), - Type.Object({ - kind: Type.Literal("select"), - ref: Type.String(), - values: Type.Array(Type.String()), - targetId: Type.Optional(Type.String()), - }), - Type.Object({ - kind: Type.Literal("fill"), - fields: Type.Array(Type.Record(Type.String(), Type.Unknown())), - targetId: Type.Optional(Type.String()), - }), - Type.Object({ - kind: Type.Literal("resize"), - width: Type.Number(), - height: Type.Number(), - targetId: Type.Optional(Type.String()), - }), - Type.Object({ - kind: Type.Literal("wait"), - timeMs: Type.Optional(Type.Number()), - text: Type.Optional(Type.String()), - textGone: Type.Optional(Type.String()), - targetId: Type.Optional(Type.String()), - }), - Type.Object({ - kind: Type.Literal("evaluate"), - fn: Type.String(), - ref: Type.Optional(Type.String()), - targetId: Type.Optional(Type.String()), - }), - Type.Object({ - kind: Type.Literal("close"), - targetId: Type.Optional(Type.String()), - }), -]); - -const BrowserToolSchema = Type.Union([ - Type.Object({ - action: Type.Literal("status"), - controlUrl: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("start"), - controlUrl: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("stop"), - controlUrl: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("tabs"), - controlUrl: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("open"), - controlUrl: Type.Optional(Type.String()), - targetUrl: Type.String(), - }), - Type.Object({ - action: Type.Literal("focus"), - controlUrl: Type.Optional(Type.String()), - targetId: Type.String(), - }), - Type.Object({ - action: Type.Literal("close"), - controlUrl: Type.Optional(Type.String()), - targetId: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("snapshot"), - controlUrl: Type.Optional(Type.String()), - format: Type.Optional( - Type.Union([Type.Literal("aria"), Type.Literal("ai")]), - ), - targetId: Type.Optional(Type.String()), - limit: Type.Optional(Type.Number()), - }), - Type.Object({ - action: Type.Literal("screenshot"), - controlUrl: Type.Optional(Type.String()), - targetId: Type.Optional(Type.String()), - fullPage: Type.Optional(Type.Boolean()), - ref: Type.Optional(Type.String()), - element: Type.Optional(Type.String()), - type: Type.Optional( - Type.Union([Type.Literal("png"), Type.Literal("jpeg")]), - ), - }), - Type.Object({ - action: Type.Literal("navigate"), - controlUrl: Type.Optional(Type.String()), - targetUrl: Type.String(), - targetId: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("console"), - controlUrl: Type.Optional(Type.String()), - level: Type.Optional(Type.String()), - targetId: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("pdf"), - controlUrl: Type.Optional(Type.String()), - targetId: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("upload"), - controlUrl: Type.Optional(Type.String()), - paths: Type.Array(Type.String()), - ref: Type.Optional(Type.String()), - inputRef: Type.Optional(Type.String()), - element: Type.Optional(Type.String()), - targetId: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - }), - Type.Object({ - action: Type.Literal("dialog"), - controlUrl: Type.Optional(Type.String()), - accept: Type.Boolean(), - promptText: Type.Optional(Type.String()), - targetId: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - }), - Type.Object({ - action: Type.Literal("act"), - controlUrl: Type.Optional(Type.String()), - request: BrowserActSchema, - }), -]); - -function createBrowserTool(opts?: { - defaultControlUrl?: string; -}): AnyAgentTool { - return { - label: "Browser", - name: "browser", - description: - "Control clawd's dedicated browser (status/start/stop/tabs/open/snapshot/screenshot/actions). Use snapshot+act for UI automation. Avoid act:wait by default; use only in exceptional cases when no reliable UI state exists.", - parameters: BrowserToolSchema, - execute: async (_toolCallId, args) => { - const params = args as Record; - const action = readStringParam(params, "action", { required: true }); - const controlUrl = readStringParam(params, "controlUrl"); - const baseUrl = resolveBrowserBaseUrl( - controlUrl ?? opts?.defaultControlUrl, - ); - - switch (action) { - case "status": - return jsonResult(await browserStatus(baseUrl)); - case "start": - await browserStart(baseUrl); - return jsonResult(await browserStatus(baseUrl)); - case "stop": - await browserStop(baseUrl); - return jsonResult(await browserStatus(baseUrl)); - case "tabs": - return jsonResult({ tabs: await browserTabs(baseUrl) }); - case "open": { - const targetUrl = readStringParam(params, "targetUrl", { - required: true, - }); - return jsonResult(await browserOpenTab(baseUrl, targetUrl)); - } - case "focus": { - const targetId = readStringParam(params, "targetId", { - required: true, - }); - await browserFocusTab(baseUrl, targetId); - return jsonResult({ ok: true }); - } - case "close": { - const targetId = readStringParam(params, "targetId"); - if (targetId) await browserCloseTab(baseUrl, targetId); - else await browserAct(baseUrl, { kind: "close" }); - return jsonResult({ ok: true }); - } - case "snapshot": { - const format = - params.format === "ai" || params.format === "aria" - ? (params.format as "ai" | "aria") - : "ai"; - const targetId = - typeof params.targetId === "string" - ? params.targetId.trim() - : undefined; - const limit = - typeof params.limit === "number" && Number.isFinite(params.limit) - ? params.limit - : undefined; - const snapshot = await browserSnapshot(baseUrl, { - format, - targetId, - limit, - }); - if (snapshot.format === "ai") { - return { - content: [{ type: "text", text: snapshot.snapshot }], - details: snapshot, - }; - } - return jsonResult(snapshot); - } - case "screenshot": { - const targetId = readStringParam(params, "targetId"); - const fullPage = Boolean(params.fullPage); - const ref = readStringParam(params, "ref"); - const element = readStringParam(params, "element"); - const type = params.type === "jpeg" ? "jpeg" : "png"; - const result = await browserScreenshotAction(baseUrl, { - targetId, - fullPage, - ref, - element, - type, - }); - return await imageResultFromFile({ - label: "browser:screenshot", - path: result.path, - details: result, - }); - } - case "navigate": { - const targetUrl = readStringParam(params, "targetUrl", { - required: true, - }); - const targetId = readStringParam(params, "targetId"); - return jsonResult( - await browserNavigate(baseUrl, { url: targetUrl, targetId }), - ); - } - case "console": { - const level = - typeof params.level === "string" ? params.level.trim() : undefined; - const targetId = - typeof params.targetId === "string" - ? params.targetId.trim() - : undefined; - return jsonResult( - await browserConsoleMessages(baseUrl, { level, targetId }), - ); - } - case "pdf": { - const targetId = - typeof params.targetId === "string" - ? params.targetId.trim() - : undefined; - const result = await browserPdfSave(baseUrl, { targetId }); - return { - content: [{ type: "text", text: `FILE:${result.path}` }], - details: result, - }; - } - case "upload": { - const paths = Array.isArray(params.paths) - ? params.paths.map((p) => String(p)) - : []; - if (paths.length === 0) throw new Error("paths required"); - const ref = readStringParam(params, "ref"); - const inputRef = readStringParam(params, "inputRef"); - const element = readStringParam(params, "element"); - const targetId = - typeof params.targetId === "string" - ? params.targetId.trim() - : undefined; - const timeoutMs = - typeof params.timeoutMs === "number" && - Number.isFinite(params.timeoutMs) - ? params.timeoutMs - : undefined; - return jsonResult( - await browserArmFileChooser(baseUrl, { - paths, - ref, - inputRef, - element, - targetId, - timeoutMs, - }), - ); - } - case "dialog": { - const accept = Boolean(params.accept); - const promptText = - typeof params.promptText === "string" - ? params.promptText - : undefined; - const targetId = - typeof params.targetId === "string" - ? params.targetId.trim() - : undefined; - const timeoutMs = - typeof params.timeoutMs === "number" && - Number.isFinite(params.timeoutMs) - ? params.timeoutMs - : undefined; - return jsonResult( - await browserArmDialog(baseUrl, { - accept, - promptText, - targetId, - timeoutMs, - }), - ); - } - case "act": { - const request = params.request as Record | undefined; - if (!request || typeof request !== "object") { - throw new Error("request required"); - } - const result = await browserAct( - baseUrl, - request as Parameters[1], - ); - return jsonResult(result); - } - default: - throw new Error(`Unknown action: ${action}`); - } - }, - }; -} - -const CanvasToolSchema = Type.Union([ - Type.Object({ - action: Type.Literal("present"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - node: Type.Optional(Type.String()), - target: Type.Optional(Type.String()), - x: Type.Optional(Type.Number()), - y: Type.Optional(Type.Number()), - width: Type.Optional(Type.Number()), - height: Type.Optional(Type.Number()), - }), - Type.Object({ - action: Type.Literal("hide"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - node: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("navigate"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - node: Type.Optional(Type.String()), - url: Type.String(), - }), - Type.Object({ - action: Type.Literal("eval"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - node: Type.Optional(Type.String()), - javaScript: Type.String(), - }), - Type.Object({ - action: Type.Literal("snapshot"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - node: Type.Optional(Type.String()), - format: Type.Optional( - Type.Union([ - Type.Literal("png"), - Type.Literal("jpg"), - Type.Literal("jpeg"), - ]), - ), - maxWidth: Type.Optional(Type.Number()), - quality: Type.Optional(Type.Number()), - delayMs: Type.Optional(Type.Number()), - }), - Type.Object({ - action: Type.Literal("a2ui_push"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - node: Type.Optional(Type.String()), - jsonl: Type.Optional(Type.String()), - jsonlPath: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("a2ui_reset"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - node: Type.Optional(Type.String()), - }), -]); - -function createCanvasTool(): AnyAgentTool { - return { - label: "Canvas", - name: "canvas", - description: - "Control node canvases (present/hide/navigate/eval/snapshot/A2UI). Use snapshot to capture the rendered UI.", - parameters: CanvasToolSchema, - execute: async (_toolCallId, args) => { - const params = args as Record; - const action = readStringParam(params, "action", { required: true }); - const gatewayOpts: GatewayCallOptions = { - gatewayUrl: readStringParam(params, "gatewayUrl", { trim: false }), - gatewayToken: readStringParam(params, "gatewayToken", { trim: false }), - timeoutMs: - typeof params.timeoutMs === "number" ? params.timeoutMs : undefined, - }; - - const nodeId = await resolveNodeId( - gatewayOpts, - readStringParam(params, "node", { trim: true }), - true, - ); - - const invoke = async ( - command: string, - invokeParams?: Record, - ) => - await callGatewayTool("node.invoke", gatewayOpts, { - nodeId, - command, - params: invokeParams, - idempotencyKey: crypto.randomUUID(), - }); - - switch (action) { - case "present": { - const placement = { - x: typeof params.x === "number" ? params.x : undefined, - y: typeof params.y === "number" ? params.y : undefined, - width: typeof params.width === "number" ? params.width : undefined, - height: - typeof params.height === "number" ? params.height : undefined, - }; - const invokeParams: Record = {}; - if (typeof params.target === "string" && params.target.trim()) { - invokeParams.url = params.target.trim(); - } - if ( - Number.isFinite(placement.x) || - Number.isFinite(placement.y) || - Number.isFinite(placement.width) || - Number.isFinite(placement.height) - ) { - invokeParams.placement = placement; - } - await invoke("canvas.present", invokeParams); - return jsonResult({ ok: true }); - } - case "hide": - await invoke("canvas.hide", undefined); - return jsonResult({ ok: true }); - case "navigate": { - const url = readStringParam(params, "url", { required: true }); - await invoke("canvas.navigate", { url }); - return jsonResult({ ok: true }); - } - case "eval": { - const javaScript = readStringParam(params, "javaScript", { - required: true, - }); - const raw = (await invoke("canvas.eval", { javaScript })) as { - payload?: { result?: string }; - }; - const result = raw?.payload?.result; - if (result) { - return { - content: [{ type: "text", text: result }], - details: { result }, - }; - } - return jsonResult({ ok: true }); - } - case "snapshot": { - const formatRaw = - typeof params.format === "string" - ? params.format.toLowerCase() - : "png"; - const format = - formatRaw === "jpg" || formatRaw === "jpeg" ? "jpeg" : "png"; - const maxWidth = - typeof params.maxWidth === "number" && - Number.isFinite(params.maxWidth) - ? params.maxWidth - : undefined; - const quality = - typeof params.quality === "number" && - Number.isFinite(params.quality) - ? params.quality - : undefined; - const raw = (await invoke("canvas.snapshot", { - format, - maxWidth, - quality, - })) as { payload?: unknown }; - const payload = parseCanvasSnapshotPayload(raw?.payload); - const filePath = canvasSnapshotTempPath({ - ext: payload.format === "jpeg" ? "jpg" : payload.format, - }); - await writeBase64ToFile(filePath, payload.base64); - const mimeType = imageMimeFromFormat(payload.format) ?? "image/png"; - return await imageResult({ - label: "canvas:snapshot", - path: filePath, - base64: payload.base64, - mimeType, - details: { format: payload.format }, - }); - } - case "a2ui_push": { - const jsonl = - typeof params.jsonl === "string" && params.jsonl.trim() - ? params.jsonl - : typeof params.jsonlPath === "string" && params.jsonlPath.trim() - ? await fs.readFile(params.jsonlPath.trim(), "utf8") - : ""; - if (!jsonl.trim()) throw new Error("jsonl or jsonlPath required"); - await invoke("canvas.a2ui.pushJSONL", { jsonl }); - return jsonResult({ ok: true }); - } - case "a2ui_reset": - await invoke("canvas.a2ui.reset", undefined); - return jsonResult({ ok: true }); - default: - throw new Error(`Unknown action: ${action}`); - } - }, - }; -} - -const NodesToolSchema = Type.Union([ - Type.Object({ - action: Type.Literal("status"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - }), - Type.Object({ - action: Type.Literal("describe"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - node: Type.String(), - }), - Type.Object({ - action: Type.Literal("pending"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - }), - Type.Object({ - action: Type.Literal("approve"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - requestId: Type.String(), - }), - Type.Object({ - action: Type.Literal("reject"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - requestId: Type.String(), - }), - Type.Object({ - action: Type.Literal("notify"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - node: Type.String(), - title: Type.Optional(Type.String()), - body: Type.Optional(Type.String()), - sound: Type.Optional(Type.String()), - priority: Type.Optional( - Type.Union([ - Type.Literal("passive"), - Type.Literal("active"), - Type.Literal("timeSensitive"), - ]), - ), - delivery: Type.Optional( - Type.Union([ - Type.Literal("system"), - Type.Literal("overlay"), - Type.Literal("auto"), - ]), - ), - }), - Type.Object({ - action: Type.Literal("camera_snap"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - node: Type.String(), - facing: Type.Optional( - Type.Union([ - Type.Literal("front"), - Type.Literal("back"), - Type.Literal("both"), - ]), - ), - maxWidth: Type.Optional(Type.Number()), - quality: Type.Optional(Type.Number()), - delayMs: Type.Optional(Type.Number()), - deviceId: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("camera_list"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - node: Type.String(), - }), - Type.Object({ - action: Type.Literal("camera_clip"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - node: Type.String(), - facing: Type.Optional( - Type.Union([Type.Literal("front"), Type.Literal("back")]), - ), - duration: Type.Optional(Type.String()), - durationMs: Type.Optional(Type.Number()), - includeAudio: Type.Optional(Type.Boolean()), - deviceId: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("screen_record"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - node: Type.String(), - duration: Type.Optional(Type.String()), - durationMs: Type.Optional(Type.Number()), - fps: Type.Optional(Type.Number()), - screenIndex: Type.Optional(Type.Number()), - includeAudio: Type.Optional(Type.Boolean()), - outPath: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("location_get"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - node: Type.String(), - maxAgeMs: Type.Optional(Type.Number()), - locationTimeoutMs: Type.Optional(Type.Number()), - desiredAccuracy: Type.Optional( - Type.Union([ - Type.Literal("coarse"), - Type.Literal("balanced"), - Type.Literal("precise"), - ]), - ), - }), -]); - -function createNodesTool(): AnyAgentTool { - return { - label: "Nodes", - name: "nodes", - description: - "Discover and control paired nodes (status/describe/pairing/notify/camera/screen/location).", - parameters: NodesToolSchema, - execute: async (_toolCallId, args) => { - const params = args as Record; - const action = readStringParam(params, "action", { required: true }); - const gatewayOpts: GatewayCallOptions = { - gatewayUrl: readStringParam(params, "gatewayUrl", { trim: false }), - gatewayToken: readStringParam(params, "gatewayToken", { trim: false }), - timeoutMs: - typeof params.timeoutMs === "number" ? params.timeoutMs : undefined, - }; - - switch (action) { - case "status": - return jsonResult( - await callGatewayTool("node.list", gatewayOpts, {}), - ); - case "describe": { - const node = readStringParam(params, "node", { required: true }); - const nodeId = await resolveNodeId(gatewayOpts, node); - return jsonResult( - await callGatewayTool("node.describe", gatewayOpts, { nodeId }), - ); - } - case "pending": - return jsonResult( - await callGatewayTool("node.pair.list", gatewayOpts, {}), - ); - case "approve": { - const requestId = readStringParam(params, "requestId", { - required: true, - }); - return jsonResult( - await callGatewayTool("node.pair.approve", gatewayOpts, { - requestId, - }), - ); - } - case "reject": { - const requestId = readStringParam(params, "requestId", { - required: true, - }); - return jsonResult( - await callGatewayTool("node.pair.reject", gatewayOpts, { - requestId, - }), - ); - } - case "notify": { - const node = readStringParam(params, "node", { required: true }); - const title = typeof params.title === "string" ? params.title : ""; - const body = typeof params.body === "string" ? params.body : ""; - if (!title.trim() && !body.trim()) { - throw new Error("title or body required"); - } - const nodeId = await resolveNodeId(gatewayOpts, node); - await callGatewayTool("node.invoke", gatewayOpts, { - nodeId, - command: "system.notify", - params: { - title: title.trim() || undefined, - body: body.trim() || undefined, - sound: - typeof params.sound === "string" ? params.sound : undefined, - priority: - typeof params.priority === "string" - ? params.priority - : undefined, - delivery: - typeof params.delivery === "string" - ? params.delivery - : undefined, - }, - idempotencyKey: crypto.randomUUID(), - }); - return jsonResult({ ok: true }); - } - case "camera_snap": { - const node = readStringParam(params, "node", { required: true }); - const nodeId = await resolveNodeId(gatewayOpts, node); - const facingRaw = - typeof params.facing === "string" - ? params.facing.toLowerCase() - : "both"; - const facings: CameraFacing[] = - facingRaw === "both" - ? ["front", "back"] - : facingRaw === "front" || facingRaw === "back" - ? [facingRaw] - : (() => { - throw new Error("invalid facing (front|back|both)"); - })(); - const maxWidth = - typeof params.maxWidth === "number" && - Number.isFinite(params.maxWidth) - ? params.maxWidth - : undefined; - const quality = - typeof params.quality === "number" && - Number.isFinite(params.quality) - ? params.quality - : undefined; - const delayMs = - typeof params.delayMs === "number" && - Number.isFinite(params.delayMs) - ? params.delayMs - : undefined; - const deviceId = - typeof params.deviceId === "string" && params.deviceId.trim() - ? params.deviceId.trim() - : undefined; - - const content: AgentToolResult["content"] = []; - const details: Array> = []; - - for (const facing of facings) { - const raw = (await callGatewayTool("node.invoke", gatewayOpts, { - nodeId, - command: "camera.snap", - params: { - facing, - maxWidth, - quality, - format: "jpg", - delayMs, - deviceId, - }, - idempotencyKey: crypto.randomUUID(), - })) as { payload?: unknown }; - const payload = parseCameraSnapPayload(raw?.payload); - const normalizedFormat = payload.format.toLowerCase(); - if ( - normalizedFormat !== "jpg" && - normalizedFormat !== "jpeg" && - normalizedFormat !== "png" - ) { - throw new Error( - `unsupported camera.snap format: ${payload.format}`, - ); - } - - const isJpeg = - normalizedFormat === "jpg" || normalizedFormat === "jpeg"; - const filePath = cameraTempPath({ - kind: "snap", - facing, - ext: isJpeg ? "jpg" : "png", - }); - await writeBase64ToFile(filePath, payload.base64); - content.push({ type: "text", text: `MEDIA:${filePath}` }); - content.push({ - type: "image", - data: payload.base64, - mimeType: - imageMimeFromFormat(payload.format) ?? - (isJpeg ? "image/jpeg" : "image/png"), - }); - details.push({ - facing, - path: filePath, - width: payload.width, - height: payload.height, - }); - } - - const result: AgentToolResult = { content, details }; - return await sanitizeToolResultImages(result, "nodes:camera_snap"); - } - case "camera_list": { - const node = readStringParam(params, "node", { required: true }); - const nodeId = await resolveNodeId(gatewayOpts, node); - const raw = (await callGatewayTool("node.invoke", gatewayOpts, { - nodeId, - command: "camera.list", - params: {}, - idempotencyKey: crypto.randomUUID(), - })) as { payload?: unknown }; - const payload = - raw && typeof raw.payload === "object" && raw.payload !== null - ? raw.payload - : {}; - return jsonResult(payload); - } - case "camera_clip": { - const node = readStringParam(params, "node", { required: true }); - const nodeId = await resolveNodeId(gatewayOpts, node); - const facing = - typeof params.facing === "string" - ? params.facing.toLowerCase() - : "front"; - if (facing !== "front" && facing !== "back") { - throw new Error("invalid facing (front|back)"); - } - const durationMs = - typeof params.durationMs === "number" && - Number.isFinite(params.durationMs) - ? params.durationMs - : typeof params.duration === "string" - ? parseDurationMs(params.duration) - : 3000; - const includeAudio = - typeof params.includeAudio === "boolean" - ? params.includeAudio - : true; - const deviceId = - typeof params.deviceId === "string" && params.deviceId.trim() - ? params.deviceId.trim() - : undefined; - const raw = (await callGatewayTool("node.invoke", gatewayOpts, { - nodeId, - command: "camera.clip", - params: { - facing, - durationMs, - includeAudio, - format: "mp4", - deviceId, - }, - idempotencyKey: crypto.randomUUID(), - })) as { payload?: unknown }; - const payload = parseCameraClipPayload(raw?.payload); - const filePath = cameraTempPath({ - kind: "clip", - facing, - ext: payload.format, - }); - await writeBase64ToFile(filePath, payload.base64); - return { - content: [{ type: "text", text: `FILE:${filePath}` }], - details: { - facing, - path: filePath, - durationMs: payload.durationMs, - hasAudio: payload.hasAudio, - }, - }; - } - case "screen_record": { - const node = readStringParam(params, "node", { required: true }); - const nodeId = await resolveNodeId(gatewayOpts, node); - const durationMs = - typeof params.durationMs === "number" && - Number.isFinite(params.durationMs) - ? params.durationMs - : typeof params.duration === "string" - ? parseDurationMs(params.duration) - : 10_000; - const fps = - typeof params.fps === "number" && Number.isFinite(params.fps) - ? params.fps - : 10; - const screenIndex = - typeof params.screenIndex === "number" && - Number.isFinite(params.screenIndex) - ? params.screenIndex - : 0; - const includeAudio = - typeof params.includeAudio === "boolean" - ? params.includeAudio - : true; - const raw = (await callGatewayTool("node.invoke", gatewayOpts, { - nodeId, - command: "screen.record", - params: { - durationMs, - screenIndex, - fps, - format: "mp4", - includeAudio, - }, - idempotencyKey: crypto.randomUUID(), - })) as { payload?: unknown }; - const payload = parseScreenRecordPayload(raw?.payload); - const filePath = - typeof params.outPath === "string" && params.outPath.trim() - ? params.outPath.trim() - : screenRecordTempPath({ ext: payload.format || "mp4" }); - const written = await writeScreenRecordToFile( - filePath, - payload.base64, - ); - return { - content: [{ type: "text", text: `FILE:${written.path}` }], - details: { - path: written.path, - durationMs: payload.durationMs, - fps: payload.fps, - screenIndex: payload.screenIndex, - hasAudio: payload.hasAudio, - }, - }; - } - case "location_get": { - const node = readStringParam(params, "node", { required: true }); - const nodeId = await resolveNodeId(gatewayOpts, node); - const maxAgeMs = - typeof params.maxAgeMs === "number" && - Number.isFinite(params.maxAgeMs) - ? params.maxAgeMs - : undefined; - const desiredAccuracy = - params.desiredAccuracy === "coarse" || - params.desiredAccuracy === "balanced" || - params.desiredAccuracy === "precise" - ? params.desiredAccuracy - : undefined; - const locationTimeoutMs = - typeof params.locationTimeoutMs === "number" && - Number.isFinite(params.locationTimeoutMs) - ? params.locationTimeoutMs - : undefined; - const raw = (await callGatewayTool("node.invoke", gatewayOpts, { - nodeId, - command: "location.get", - params: { - maxAgeMs, - desiredAccuracy, - timeoutMs: locationTimeoutMs, - }, - idempotencyKey: crypto.randomUUID(), - })) as { payload?: unknown }; - return jsonResult(raw?.payload ?? {}); - } - default: - throw new Error(`Unknown action: ${action}`); - } - }, - }; -} - -const CronToolSchema = Type.Union([ - Type.Object({ - action: Type.Literal("status"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - }), - Type.Object({ - action: Type.Literal("list"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - includeDisabled: Type.Optional(Type.Boolean()), - }), - Type.Object({ - action: Type.Literal("add"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - job: Type.Object({}, { additionalProperties: true }), - }), - Type.Object({ - action: Type.Literal("update"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - jobId: Type.String(), - patch: Type.Object({}, { additionalProperties: true }), - }), - Type.Object({ - action: Type.Literal("remove"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - jobId: Type.String(), - }), - Type.Object({ - action: Type.Literal("run"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - jobId: Type.String(), - }), - Type.Object({ - action: Type.Literal("runs"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - jobId: Type.String(), - }), - Type.Object({ - action: Type.Literal("wake"), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - text: Type.String(), - mode: Type.Optional( - Type.Union([Type.Literal("now"), Type.Literal("next-heartbeat")]), - ), - }), -]); - -function createCronTool(): AnyAgentTool { - return { - label: "Cron", - name: "cron", - description: - "Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events.", - parameters: CronToolSchema, - execute: async (_toolCallId, args) => { - const params = args as Record; - const action = readStringParam(params, "action", { required: true }); - const gatewayOpts: GatewayCallOptions = { - gatewayUrl: readStringParam(params, "gatewayUrl", { trim: false }), - gatewayToken: readStringParam(params, "gatewayToken", { trim: false }), - timeoutMs: - typeof params.timeoutMs === "number" ? params.timeoutMs : undefined, - }; - - switch (action) { - case "status": - return jsonResult( - await callGatewayTool("cron.status", gatewayOpts, {}), - ); - case "list": - return jsonResult( - await callGatewayTool("cron.list", gatewayOpts, { - includeDisabled: Boolean(params.includeDisabled), - }), - ); - case "add": { - if (!params.job || typeof params.job !== "object") { - throw new Error("job required"); - } - return jsonResult( - await callGatewayTool("cron.add", gatewayOpts, params.job), - ); - } - case "update": { - const jobId = readStringParam(params, "jobId", { required: true }); - if (!params.patch || typeof params.patch !== "object") { - throw new Error("patch required"); - } - return jsonResult( - await callGatewayTool("cron.update", gatewayOpts, { - jobId, - patch: params.patch, - }), - ); - } - case "remove": { - const jobId = readStringParam(params, "jobId", { required: true }); - return jsonResult( - await callGatewayTool("cron.remove", gatewayOpts, { jobId }), - ); - } - case "run": { - const jobId = readStringParam(params, "jobId", { required: true }); - return jsonResult( - await callGatewayTool("cron.run", gatewayOpts, { jobId }), - ); - } - case "runs": { - const jobId = readStringParam(params, "jobId", { required: true }); - return jsonResult( - await callGatewayTool("cron.runs", gatewayOpts, { jobId }), - ); - } - case "wake": { - const text = readStringParam(params, "text", { required: true }); - const mode = - params.mode === "now" || params.mode === "next-heartbeat" - ? params.mode - : "next-heartbeat"; - return jsonResult( - await callGatewayTool( - "wake", - gatewayOpts, - { mode, text }, - { expectFinal: false }, - ), - ); - } - default: - throw new Error(`Unknown action: ${action}`); - } - }, - }; -} - -const GatewayToolSchema = Type.Union([ - Type.Object({ - action: Type.Literal("restart"), - delayMs: Type.Optional(Type.Number()), - reason: Type.Optional(Type.String()), - }), -]); - -const DiscordToolSchema = Type.Union([ - Type.Object({ - action: Type.Literal("react"), - channelId: Type.String(), - messageId: Type.String(), - emoji: Type.String(), - }), - Type.Object({ - action: Type.Literal("reactions"), - channelId: Type.String(), - messageId: Type.String(), - limit: Type.Optional(Type.Number()), - }), - Type.Object({ - action: Type.Literal("sticker"), - to: Type.String(), - stickerIds: Type.Array(Type.String()), - content: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("poll"), - to: Type.String(), - question: Type.String(), - answers: Type.Array(Type.String()), - allowMultiselect: Type.Optional(Type.Boolean()), - durationHours: Type.Optional(Type.Number()), - content: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("permissions"), - channelId: Type.String(), - }), - Type.Object({ - action: Type.Literal("readMessages"), - channelId: Type.String(), - limit: Type.Optional(Type.Number()), - before: Type.Optional(Type.String()), - after: Type.Optional(Type.String()), - around: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("sendMessage"), - to: Type.String(), - content: Type.String(), - mediaUrl: Type.Optional(Type.String()), - replyTo: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("editMessage"), - channelId: Type.String(), - messageId: Type.String(), - content: Type.String(), - }), - Type.Object({ - action: Type.Literal("deleteMessage"), - channelId: Type.String(), - messageId: Type.String(), - }), - Type.Object({ - action: Type.Literal("threadCreate"), - channelId: Type.String(), - name: Type.String(), - messageId: Type.Optional(Type.String()), - autoArchiveMinutes: Type.Optional(Type.Number()), - }), - Type.Object({ - action: Type.Literal("threadList"), - guildId: Type.String(), - channelId: Type.Optional(Type.String()), - includeArchived: Type.Optional(Type.Boolean()), - before: Type.Optional(Type.String()), - limit: Type.Optional(Type.Number()), - }), - Type.Object({ - action: Type.Literal("threadReply"), - channelId: Type.String(), - content: Type.String(), - mediaUrl: Type.Optional(Type.String()), - replyTo: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("pinMessage"), - channelId: Type.String(), - messageId: Type.String(), - }), - Type.Object({ - action: Type.Literal("unpinMessage"), - channelId: Type.String(), - messageId: Type.String(), - }), - Type.Object({ - action: Type.Literal("listPins"), - channelId: Type.String(), - }), - Type.Object({ - action: Type.Literal("searchMessages"), - guildId: Type.String(), - content: Type.String(), - channelId: Type.Optional(Type.String()), - channelIds: Type.Optional(Type.Array(Type.String())), - authorId: Type.Optional(Type.String()), - authorIds: Type.Optional(Type.Array(Type.String())), - limit: Type.Optional(Type.Number()), - }), - Type.Object({ - action: Type.Literal("memberInfo"), - guildId: Type.String(), - userId: Type.String(), - }), - Type.Object({ - action: Type.Literal("roleInfo"), - guildId: Type.String(), - }), - Type.Object({ - action: Type.Literal("emojiList"), - guildId: Type.String(), - }), - Type.Object({ - action: Type.Literal("roleAdd"), - guildId: Type.String(), - userId: Type.String(), - roleId: Type.String(), - }), - Type.Object({ - action: Type.Literal("roleRemove"), - guildId: Type.String(), - userId: Type.String(), - roleId: Type.String(), - }), - Type.Object({ - action: Type.Literal("channelInfo"), - channelId: Type.String(), - }), - Type.Object({ - action: Type.Literal("channelList"), - guildId: Type.String(), - }), - Type.Object({ - action: Type.Literal("voiceStatus"), - guildId: Type.String(), - userId: Type.String(), - }), - Type.Object({ - action: Type.Literal("eventList"), - guildId: Type.String(), - }), - Type.Object({ - action: Type.Literal("eventCreate"), - guildId: Type.String(), - name: Type.String(), - startTime: Type.String(), - endTime: Type.Optional(Type.String()), - description: Type.Optional(Type.String()), - channelId: Type.Optional(Type.String()), - entityType: Type.Optional( - Type.Union([ - Type.Literal("voice"), - Type.Literal("stage"), - Type.Literal("external"), - ]), - ), - location: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("timeout"), - guildId: Type.String(), - userId: Type.String(), - durationMinutes: Type.Optional(Type.Number()), - until: Type.Optional(Type.String()), - reason: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("kick"), - guildId: Type.String(), - userId: Type.String(), - reason: Type.Optional(Type.String()), - }), - Type.Object({ - action: Type.Literal("ban"), - guildId: Type.String(), - userId: Type.String(), - reason: Type.Optional(Type.String()), - deleteMessageDays: Type.Optional(Type.Number()), - }), -]); - -function createDiscordTool(): AnyAgentTool { - return { - label: "Discord", - name: "discord", - description: "Manage Discord messages, reactions, and moderation.", - parameters: DiscordToolSchema, - execute: async (_toolCallId, args) => { - const params = args as Record; - const action = readStringParam(params, "action", { required: true }); - const cfg = loadConfig(); - const isActionEnabled = ( - key: keyof DiscordActionConfig, - defaultValue = true, - ) => { - const value = cfg.discord?.actions?.[key]; - if (value === undefined) return defaultValue; - return value !== false; - }; - - switch (action) { - case "react": { - if (!isActionEnabled("reactions")) { - throw new Error("Discord reactions are disabled."); - } - const channelId = readStringParam(params, "channelId", { - required: true, - }); - const messageId = readStringParam(params, "messageId", { - required: true, - }); - const emoji = readStringParam(params, "emoji", { required: true }); - await reactMessageDiscord(channelId, messageId, emoji); - return jsonResult({ ok: true }); - } - case "reactions": { - if (!isActionEnabled("reactions")) { - throw new Error("Discord reactions are disabled."); - } - const channelId = readStringParam(params, "channelId", { - required: true, - }); - const messageId = readStringParam(params, "messageId", { - required: true, - }); - const limitRaw = params.limit; - const limit = - typeof limitRaw === "number" && Number.isFinite(limitRaw) - ? limitRaw - : undefined; - const reactions = await fetchReactionsDiscord(channelId, messageId, { - limit, - }); - return jsonResult({ ok: true, reactions }); - } - case "sticker": { - if (!isActionEnabled("stickers")) { - throw new Error("Discord stickers are disabled."); - } - const to = readStringParam(params, "to", { required: true }); - const content = readStringParam(params, "content"); - const stickerIds = readStringArrayParam(params, "stickerIds", { - required: true, - label: "stickerIds", - }); - await sendStickerDiscord(to, stickerIds, { content }); - return jsonResult({ ok: true }); - } - case "poll": { - if (!isActionEnabled("polls")) { - throw new Error("Discord polls are disabled."); - } - const to = readStringParam(params, "to", { required: true }); - const content = readStringParam(params, "content"); - const question = readStringParam(params, "question", { - required: true, - }); - const answers = readStringArrayParam(params, "answers", { - required: true, - label: "answers", - }); - const allowMultiselectRaw = params.allowMultiselect; - const allowMultiselect = - typeof allowMultiselectRaw === "boolean" - ? allowMultiselectRaw - : undefined; - const durationRaw = params.durationHours; - const durationHours = - typeof durationRaw === "number" && Number.isFinite(durationRaw) - ? durationRaw - : undefined; - await sendPollDiscord( - to, - { question, answers, allowMultiselect, durationHours }, - { content }, - ); - return jsonResult({ ok: true }); - } - case "permissions": { - if (!isActionEnabled("permissions")) { - throw new Error("Discord permissions are disabled."); - } - const channelId = readStringParam(params, "channelId", { - required: true, - }); - const permissions = await fetchChannelPermissionsDiscord(channelId); - return jsonResult({ ok: true, permissions }); - } - case "readMessages": { - if (!isActionEnabled("messages")) { - throw new Error("Discord message reads are disabled."); - } - const channelId = readStringParam(params, "channelId", { - required: true, - }); - const messages = await readMessagesDiscord(channelId, { - limit: - typeof params.limit === "number" && Number.isFinite(params.limit) - ? params.limit - : undefined, - before: readStringParam(params, "before"), - after: readStringParam(params, "after"), - around: readStringParam(params, "around"), - }); - return jsonResult({ ok: true, messages }); - } - case "sendMessage": { - if (!isActionEnabled("messages")) { - throw new Error("Discord message sends are disabled."); - } - const to = readStringParam(params, "to", { required: true }); - const content = readStringParam(params, "content", { - required: true, - }); - const mediaUrl = readStringParam(params, "mediaUrl"); - const replyTo = readStringParam(params, "replyTo"); - const result = await sendMessageDiscord(to, content, { - mediaUrl, - replyTo, - }); - return jsonResult({ ok: true, result }); - } - case "editMessage": { - if (!isActionEnabled("messages")) { - throw new Error("Discord message edits are disabled."); - } - const channelId = readStringParam(params, "channelId", { - required: true, - }); - const messageId = readStringParam(params, "messageId", { - required: true, - }); - const content = readStringParam(params, "content", { - required: true, - }); - const message = await editMessageDiscord(channelId, messageId, { - content, - }); - return jsonResult({ ok: true, message }); - } - case "deleteMessage": { - if (!isActionEnabled("messages")) { - throw new Error("Discord message deletes are disabled."); - } - const channelId = readStringParam(params, "channelId", { - required: true, - }); - const messageId = readStringParam(params, "messageId", { - required: true, - }); - await deleteMessageDiscord(channelId, messageId); - return jsonResult({ ok: true }); - } - case "threadCreate": { - if (!isActionEnabled("threads")) { - throw new Error("Discord threads are disabled."); - } - const channelId = readStringParam(params, "channelId", { - required: true, - }); - const name = readStringParam(params, "name", { required: true }); - const messageId = readStringParam(params, "messageId"); - const autoArchiveMinutesRaw = params.autoArchiveMinutes; - const autoArchiveMinutes = - typeof autoArchiveMinutesRaw === "number" && - Number.isFinite(autoArchiveMinutesRaw) - ? autoArchiveMinutesRaw - : undefined; - const thread = await createThreadDiscord(channelId, { - name, - messageId, - autoArchiveMinutes, - }); - return jsonResult({ ok: true, thread }); - } - case "threadList": { - if (!isActionEnabled("threads")) { - throw new Error("Discord threads are disabled."); - } - const guildId = readStringParam(params, "guildId", { - required: true, - }); - const channelId = readStringParam(params, "channelId"); - const includeArchived = - typeof params.includeArchived === "boolean" - ? params.includeArchived - : undefined; - const before = readStringParam(params, "before"); - const limit = - typeof params.limit === "number" && Number.isFinite(params.limit) - ? params.limit - : undefined; - const threads = await listThreadsDiscord({ - guildId, - channelId, - includeArchived, - before, - limit, - }); - return jsonResult({ ok: true, threads }); - } - case "threadReply": { - if (!isActionEnabled("threads")) { - throw new Error("Discord threads are disabled."); - } - const channelId = readStringParam(params, "channelId", { - required: true, - }); - const content = readStringParam(params, "content", { - required: true, - }); - const mediaUrl = readStringParam(params, "mediaUrl"); - const replyTo = readStringParam(params, "replyTo"); - const result = await sendMessageDiscord( - `channel:${channelId}`, - content, - { - mediaUrl, - replyTo, - }, - ); - return jsonResult({ ok: true, result }); - } - case "pinMessage": { - if (!isActionEnabled("pins")) { - throw new Error("Discord pins are disabled."); - } - const channelId = readStringParam(params, "channelId", { - required: true, - }); - const messageId = readStringParam(params, "messageId", { - required: true, - }); - await pinMessageDiscord(channelId, messageId); - return jsonResult({ ok: true }); - } - case "unpinMessage": { - if (!isActionEnabled("pins")) { - throw new Error("Discord pins are disabled."); - } - const channelId = readStringParam(params, "channelId", { - required: true, - }); - const messageId = readStringParam(params, "messageId", { - required: true, - }); - await unpinMessageDiscord(channelId, messageId); - return jsonResult({ ok: true }); - } - case "listPins": { - if (!isActionEnabled("pins")) { - throw new Error("Discord pins are disabled."); - } - const channelId = readStringParam(params, "channelId", { - required: true, - }); - const pins = await listPinsDiscord(channelId); - return jsonResult({ ok: true, pins }); - } - case "searchMessages": { - if (!isActionEnabled("search")) { - throw new Error("Discord search is disabled."); - } - const guildId = readStringParam(params, "guildId", { - required: true, - }); - const content = readStringParam(params, "content", { - required: true, - }); - const channelId = readStringParam(params, "channelId"); - const channelIds = readStringArrayParam(params, "channelIds"); - const authorId = readStringParam(params, "authorId"); - const authorIds = readStringArrayParam(params, "authorIds"); - const limit = - typeof params.limit === "number" && Number.isFinite(params.limit) - ? params.limit - : undefined; - const channelIdList = [ - ...(channelIds ?? []), - ...(channelId ? [channelId] : []), - ]; - const authorIdList = [ - ...(authorIds ?? []), - ...(authorId ? [authorId] : []), - ]; - const results = await searchMessagesDiscord({ - guildId, - content, - channelIds: channelIdList.length ? channelIdList : undefined, - authorIds: authorIdList.length ? authorIdList : undefined, - limit, - }); - return jsonResult({ ok: true, results }); - } - case "memberInfo": { - if (!isActionEnabled("memberInfo")) { - throw new Error("Discord member info is disabled."); - } - const guildId = readStringParam(params, "guildId", { - required: true, - }); - const userId = readStringParam(params, "userId", { - required: true, - }); - const member = await fetchMemberInfoDiscord(guildId, userId); - return jsonResult({ ok: true, member }); - } - case "roleInfo": { - if (!isActionEnabled("roleInfo")) { - throw new Error("Discord role info is disabled."); - } - const guildId = readStringParam(params, "guildId", { - required: true, - }); - const roles = await fetchRoleInfoDiscord(guildId); - return jsonResult({ ok: true, roles }); - } - case "emojiList": { - if (!isActionEnabled("reactions")) { - throw new Error("Discord reactions are disabled."); - } - const guildId = readStringParam(params, "guildId", { - required: true, - }); - const emojis = await listGuildEmojisDiscord(guildId); - return jsonResult({ ok: true, emojis }); - } - case "roleAdd": { - if (!isActionEnabled("roles", false)) { - throw new Error("Discord role changes are disabled."); - } - const guildId = readStringParam(params, "guildId", { - required: true, - }); - const userId = readStringParam(params, "userId", { - required: true, - }); - const roleId = readStringParam(params, "roleId", { - required: true, - }); - await addRoleDiscord({ guildId, userId, roleId }); - return jsonResult({ ok: true }); - } - case "roleRemove": { - if (!isActionEnabled("roles", false)) { - throw new Error("Discord role changes are disabled."); - } - const guildId = readStringParam(params, "guildId", { - required: true, - }); - const userId = readStringParam(params, "userId", { - required: true, - }); - const roleId = readStringParam(params, "roleId", { - required: true, - }); - await removeRoleDiscord({ guildId, userId, roleId }); - return jsonResult({ ok: true }); - } - case "channelInfo": { - if (!isActionEnabled("channelInfo")) { - throw new Error("Discord channel info is disabled."); - } - const channelId = readStringParam(params, "channelId", { - required: true, - }); - const channel = await fetchChannelInfoDiscord(channelId); - return jsonResult({ ok: true, channel }); - } - case "channelList": { - if (!isActionEnabled("channelInfo")) { - throw new Error("Discord channel info is disabled."); - } - const guildId = readStringParam(params, "guildId", { - required: true, - }); - const channels = await listGuildChannelsDiscord(guildId); - return jsonResult({ ok: true, channels }); - } - case "voiceStatus": { - if (!isActionEnabled("voiceStatus")) { - throw new Error("Discord voice status is disabled."); - } - const guildId = readStringParam(params, "guildId", { - required: true, - }); - const userId = readStringParam(params, "userId", { - required: true, - }); - const voice = await fetchVoiceStatusDiscord(guildId, userId); - return jsonResult({ ok: true, voice }); - } - case "eventList": { - if (!isActionEnabled("events")) { - throw new Error("Discord events are disabled."); - } - const guildId = readStringParam(params, "guildId", { - required: true, - }); - const events = await listScheduledEventsDiscord(guildId); - return jsonResult({ ok: true, events }); - } - case "eventCreate": { - if (!isActionEnabled("events")) { - throw new Error("Discord events are disabled."); - } - const guildId = readStringParam(params, "guildId", { - required: true, - }); - const name = readStringParam(params, "name", { required: true }); - const startTime = readStringParam(params, "startTime", { - required: true, - }); - const endTime = readStringParam(params, "endTime"); - const description = readStringParam(params, "description"); - const channelId = readStringParam(params, "channelId"); - const location = readStringParam(params, "location"); - const entityTypeRaw = readStringParam(params, "entityType"); - const entityType = - entityTypeRaw === "stage" - ? 1 - : entityTypeRaw === "external" - ? 3 - : 2; - const payload = { - name, - description, - scheduled_start_time: startTime, - scheduled_end_time: endTime, - entity_type: entityType, - channel_id: channelId, - entity_metadata: - entityType === 3 && location ? { location } : undefined, - privacy_level: 2, - }; - const event = await createScheduledEventDiscord(guildId, payload); - return jsonResult({ ok: true, event }); - } - case "timeout": { - if (!isActionEnabled("moderation", false)) { - throw new Error("Discord moderation is disabled."); - } - const guildId = readStringParam(params, "guildId", { - required: true, - }); - const userId = readStringParam(params, "userId", { - required: true, - }); - const durationMinutes = - typeof params.durationMinutes === "number" && - Number.isFinite(params.durationMinutes) - ? params.durationMinutes - : undefined; - const until = readStringParam(params, "until"); - const reason = readStringParam(params, "reason"); - const member = await timeoutMemberDiscord({ - guildId, - userId, - durationMinutes, - until, - reason, - }); - return jsonResult({ ok: true, member }); - } - case "kick": { - if (!isActionEnabled("moderation", false)) { - throw new Error("Discord moderation is disabled."); - } - const guildId = readStringParam(params, "guildId", { - required: true, - }); - const userId = readStringParam(params, "userId", { - required: true, - }); - const reason = readStringParam(params, "reason"); - await kickMemberDiscord({ guildId, userId, reason }); - return jsonResult({ ok: true }); - } - case "ban": { - if (!isActionEnabled("moderation", false)) { - throw new Error("Discord moderation is disabled."); - } - const guildId = readStringParam(params, "guildId", { - required: true, - }); - const userId = readStringParam(params, "userId", { - required: true, - }); - const reason = readStringParam(params, "reason"); - const deleteMessageDays = - typeof params.deleteMessageDays === "number" && - Number.isFinite(params.deleteMessageDays) - ? params.deleteMessageDays - : undefined; - await banMemberDiscord({ - guildId, - userId, - reason, - deleteMessageDays, - }); - return jsonResult({ ok: true }); - } - default: - throw new Error(`Unknown action: ${action}`); - } - }, - }; -} - -function createGatewayTool(): AnyAgentTool { - return { - label: "Gateway", - name: "gateway", - description: - "Restart the running gateway process in-place (SIGUSR1) without needing an external supervisor. Use delayMs to avoid interrupting an in-flight reply.", - parameters: GatewayToolSchema, - execute: async (_toolCallId, args) => { - const params = args as Record; - const action = readStringParam(params, "action", { required: true }); - if (action !== "restart") throw new Error(`Unknown action: ${action}`); - - const delayMsRaw = - typeof params.delayMs === "number" && Number.isFinite(params.delayMs) - ? Math.floor(params.delayMs) - : 2000; - const delayMs = Math.min(Math.max(delayMsRaw, 0), 60_000); - const reason = - typeof params.reason === "string" && params.reason.trim() - ? params.reason.trim().slice(0, 200) - : undefined; - - const pid = process.pid; - setTimeout(() => { - try { - process.kill(pid, "SIGUSR1"); - } catch { - /* ignore */ - } - }, delayMs); - - return jsonResult({ - ok: true, - pid, - signal: "SIGUSR1", - delayMs, - reason: reason ?? null, - }); - }, - }; -} - -const SessionsListToolSchema = Type.Object({ - kinds: Type.Optional(Type.Array(Type.String())), - limit: Type.Optional(Type.Integer({ minimum: 1 })), - activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })), - messageLimit: Type.Optional(Type.Integer({ minimum: 0 })), -}); - -const SessionsHistoryToolSchema = Type.Object({ - sessionKey: Type.String(), - limit: Type.Optional(Type.Integer({ minimum: 1 })), - includeTools: Type.Optional(Type.Boolean()), -}); - -const SessionsSendToolSchema = Type.Object({ - sessionKey: Type.String(), - message: Type.String(), - timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })), -}); - -function createSessionsListTool(): AnyAgentTool { - return { - label: "Sessions", - name: "sessions_list", - description: "List sessions with optional filters and last messages.", - parameters: SessionsListToolSchema, - execute: async (_toolCallId, args) => { - const params = args as Record; - const cfg = loadConfig(); - const { mainKey, alias } = resolveMainSessionAlias(cfg); - - const kindsRaw = readStringArrayParam(params, "kinds")?.map((value) => - value.trim().toLowerCase(), - ); - const allowedKindsList = (kindsRaw ?? []).filter((value) => - ["main", "group", "cron", "hook", "node", "other"].includes(value), - ); - const allowedKinds = allowedKindsList.length - ? new Set(allowedKindsList) - : undefined; - - const limit = - typeof params.limit === "number" && Number.isFinite(params.limit) - ? Math.max(1, Math.floor(params.limit)) - : undefined; - const activeMinutes = - typeof params.activeMinutes === "number" && - Number.isFinite(params.activeMinutes) - ? Math.max(1, Math.floor(params.activeMinutes)) - : undefined; - const messageLimitRaw = - typeof params.messageLimit === "number" && - Number.isFinite(params.messageLimit) - ? Math.max(0, Math.floor(params.messageLimit)) - : 0; - const messageLimit = Math.min(messageLimitRaw, 20); - - const list = (await callGateway({ - method: "sessions.list", - params: { - limit, - activeMinutes, - includeGlobal: true, - includeUnknown: true, - }, - })) as { - path?: string; - sessions?: Array>; - }; - - const sessions = Array.isArray(list?.sessions) ? list.sessions : []; - const storePath = typeof list?.path === "string" ? list.path : undefined; - const rows: SessionListRow[] = []; - - for (const entry of sessions) { - if (!entry || typeof entry !== "object") continue; - const key = typeof entry.key === "string" ? entry.key : ""; - if (!key) continue; - if (key === "unknown") continue; - if (key === "global" && alias !== "global") continue; - - const gatewayKind = - typeof entry.kind === "string" ? entry.kind : undefined; - const kind = classifySessionKind({ key, gatewayKind, alias, mainKey }); - if (allowedKinds && !allowedKinds.has(kind)) continue; - - const displayKey = resolveDisplaySessionKey({ - key, - alias, - mainKey, - }); - - const surface = - typeof entry.surface === "string" ? entry.surface : undefined; - const lastChannel = - typeof entry.lastChannel === "string" ? entry.lastChannel : undefined; - const provider = deriveProvider({ - key, - kind, - surface, - lastChannel, - }); - - const sessionId = - typeof entry.sessionId === "string" ? entry.sessionId : undefined; - const transcriptPath = - sessionId && storePath - ? path.join(path.dirname(storePath), `${sessionId}.jsonl`) - : undefined; - - const row: SessionListRow = { - key: displayKey, - kind, - provider, - displayName: - typeof entry.displayName === "string" - ? entry.displayName - : undefined, - updatedAt: - typeof entry.updatedAt === "number" ? entry.updatedAt : undefined, - sessionId, - model: typeof entry.model === "string" ? entry.model : undefined, - contextTokens: - typeof entry.contextTokens === "number" - ? entry.contextTokens - : undefined, - totalTokens: - typeof entry.totalTokens === "number" - ? entry.totalTokens - : undefined, - thinkingLevel: - typeof entry.thinkingLevel === "string" - ? entry.thinkingLevel - : undefined, - verboseLevel: - typeof entry.verboseLevel === "string" - ? entry.verboseLevel - : undefined, - systemSent: - typeof entry.systemSent === "boolean" - ? entry.systemSent - : undefined, - abortedLastRun: - typeof entry.abortedLastRun === "boolean" - ? entry.abortedLastRun - : undefined, - sendPolicy: - typeof entry.sendPolicy === "string" ? entry.sendPolicy : undefined, - lastChannel, - lastTo: typeof entry.lastTo === "string" ? entry.lastTo : undefined, - transcriptPath, - }; - - if (messageLimit > 0) { - const resolvedKey = resolveInternalSessionKey({ - key: displayKey, - alias, - mainKey, - }); - const history = (await callGateway({ - method: "chat.history", - params: { sessionKey: resolvedKey, limit: messageLimit }, - })) as { messages?: unknown[] }; - const rawMessages = Array.isArray(history?.messages) - ? history.messages - : []; - const filtered = stripToolMessages(rawMessages); - row.messages = - filtered.length > messageLimit - ? filtered.slice(-messageLimit) - : filtered; - } - - rows.push(row); - } - - return jsonResult({ - count: rows.length, - sessions: rows, - }); - }, - }; -} - -function createSessionsHistoryTool(): AnyAgentTool { - return { - label: "Session History", - name: "sessions_history", - description: "Fetch message history for a session.", - parameters: SessionsHistoryToolSchema, - execute: async (_toolCallId, args) => { - const params = args as Record; - const sessionKey = readStringParam(params, "sessionKey", { - required: true, - }); - const cfg = loadConfig(); - const { mainKey, alias } = resolveMainSessionAlias(cfg); - const resolvedKey = resolveInternalSessionKey({ - key: sessionKey, - alias, - mainKey, - }); - const limit = - typeof params.limit === "number" && Number.isFinite(params.limit) - ? Math.max(1, Math.floor(params.limit)) - : undefined; - const includeTools = Boolean(params.includeTools); - const result = (await callGateway({ - method: "chat.history", - params: { sessionKey: resolvedKey, limit }, - })) as { messages?: unknown[] }; - const rawMessages = Array.isArray(result?.messages) - ? result.messages - : []; - const messages = includeTools - ? rawMessages - : stripToolMessages(rawMessages); - return jsonResult({ - sessionKey: resolveDisplaySessionKey({ - key: sessionKey, - alias, - mainKey, - }), - messages, - }); - }, - }; -} - -const ANNOUNCE_SKIP_TOKEN = "ANNOUNCE_SKIP"; - -type AnnounceTarget = { - channel: string; - to: string; -}; - -function resolveAnnounceTargetFromKey( - sessionKey: string, -): AnnounceTarget | null { - const parts = sessionKey.split(":").filter(Boolean); - if (parts.length < 3) return null; - const [surface, kind, ...rest] = parts; - if (kind !== "group" && kind !== "channel") return null; - const id = rest.join(":").trim(); - if (!id) return null; - if (!surface) return null; - const channel = surface.toLowerCase(); - if (channel === "discord") { - return { channel, to: `channel:${id}` }; - } - if (channel === "signal") { - return { channel, to: `group:${id}` }; - } - return { channel, to: id }; -} - -function buildAgentToAgentMessageContext(params: { - requesterSessionKey?: string; - requesterSurface?: string; - targetSessionKey: string; -}) { - const lines = [ - "Agent-to-agent message context:", - params.requesterSessionKey - ? `Requester session: ${params.requesterSessionKey}.` - : undefined, - params.requesterSurface - ? `Requester surface: ${params.requesterSurface}.` - : undefined, - `Target session: ${params.targetSessionKey}.`, - ].filter(Boolean); - return lines.join("\n"); -} - -function buildAgentToAgentPostContext(params: { - requesterSessionKey?: string; - requesterSurface?: string; - targetSessionKey: string; - targetChannel?: string; - originalMessage: string; - roundOneReply?: string; -}) { - const lines = [ - "Agent-to-agent post step:", - params.requesterSessionKey - ? `Requester session: ${params.requesterSessionKey}.` - : undefined, - params.requesterSurface - ? `Requester surface: ${params.requesterSurface}.` - : undefined, - `Target session: ${params.targetSessionKey}.`, - params.targetChannel - ? `Target surface: ${params.targetChannel}.` - : undefined, - `Original request: ${params.originalMessage}`, - params.roundOneReply - ? `Round 1 reply: ${params.roundOneReply}` - : "Round 1 reply: (not available).", - `If you want to remain silent, reply exactly "${ANNOUNCE_SKIP_TOKEN}".`, - "Any other reply will be posted to the target channel.", - "After this reply, the agent-to-agent conversation is over.", - ].filter(Boolean); - return lines.join("\n"); -} - -function isAnnounceSkip(text?: string) { - return (text ?? "").trim() === ANNOUNCE_SKIP_TOKEN; -} - -function createSessionsSendTool(opts?: { - agentSessionKey?: string; - agentSurface?: string; -}): AnyAgentTool { - return { - label: "Session Send", - name: "sessions_send", - description: "Send a message into another session.", - parameters: SessionsSendToolSchema, - execute: async (_toolCallId, args) => { - const params = args as Record; - const sessionKey = readStringParam(params, "sessionKey", { - required: true, - }); - const message = readStringParam(params, "message", { required: true }); - const cfg = loadConfig(); - const { mainKey, alias } = resolveMainSessionAlias(cfg); - const resolvedKey = resolveInternalSessionKey({ - key: sessionKey, - alias, - mainKey, - }); - const timeoutSeconds = - typeof params.timeoutSeconds === "number" && - Number.isFinite(params.timeoutSeconds) - ? Math.max(0, Math.floor(params.timeoutSeconds)) - : 30; - const timeoutMs = timeoutSeconds * 1000; - const announceTimeoutMs = timeoutSeconds === 0 ? 30_000 : timeoutMs; - const idempotencyKey = crypto.randomUUID(); - let runId: string = idempotencyKey; - const displayKey = resolveDisplaySessionKey({ - key: sessionKey, - alias, - mainKey, - }); - const agentMessageContext = buildAgentToAgentMessageContext({ - requesterSessionKey: opts?.agentSessionKey, - requesterSurface: opts?.agentSurface, - targetSessionKey: displayKey, - }); - const sendParams = { - message, - sessionKey: resolvedKey, - idempotencyKey, - deliver: false, - lane: "nested", - extraSystemPrompt: agentMessageContext, - }; - - const resolveAnnounceTarget = - async (): Promise => { - const parsed = resolveAnnounceTargetFromKey(resolvedKey); - if (parsed) return parsed; - try { - const list = (await callGateway({ - method: "sessions.list", - params: { - includeGlobal: true, - includeUnknown: true, - limit: 200, - }, - })) as { sessions?: Array> }; - const sessions = Array.isArray(list?.sessions) ? list.sessions : []; - const match = - sessions.find((entry) => entry?.key === resolvedKey) ?? - sessions.find((entry) => entry?.key === displayKey); - const channel = - typeof match?.lastChannel === "string" - ? match.lastChannel - : undefined; - const to = - typeof match?.lastTo === "string" ? match.lastTo : undefined; - if (channel && to) return { channel, to }; - } catch { - // ignore; fall through to null - } - return null; - }; - - const runAgentToAgentPost = async (roundOneReply?: string) => { - const announceTarget = await resolveAnnounceTarget(); - try { - const postPrompt = buildAgentToAgentPostContext({ - requesterSessionKey: opts?.agentSessionKey, - requesterSurface: opts?.agentSurface, - targetSessionKey: displayKey, - targetChannel: announceTarget?.channel ?? "unknown", - originalMessage: message, - roundOneReply, - }); - const postIdem = crypto.randomUUID(); - const postResponse = (await callGateway({ - method: "agent", - params: { - message: "Agent-to-agent post step.", - sessionKey: resolvedKey, - idempotencyKey: postIdem, - deliver: false, - lane: "nested", - extraSystemPrompt: postPrompt, - }, - timeoutMs: 10_000, - })) as { runId?: string; acceptedAt?: number }; - const postRunId = - typeof postResponse?.runId === "string" && postResponse.runId - ? postResponse.runId - : postIdem; - const postAcceptedAt = - typeof postResponse?.acceptedAt === "number" - ? postResponse.acceptedAt - : undefined; - const postWaitMs = Math.min(announceTimeoutMs, 60_000); - const postWait = (await callGateway({ - method: "agent.wait", - params: { - runId: postRunId, - afterMs: postAcceptedAt, - timeoutMs: postWaitMs, - }, - timeoutMs: postWaitMs + 2000, - })) as { status?: string }; - if (postWait?.status === "ok") { - const postHistory = (await callGateway({ - method: "chat.history", - params: { sessionKey: resolvedKey, limit: 50 }, - })) as { messages?: unknown[] }; - const postFiltered = stripToolMessages( - Array.isArray(postHistory?.messages) ? postHistory.messages : [], - ); - const postLast = - postFiltered.length > 0 - ? postFiltered[postFiltered.length - 1] - : undefined; - const postReply = postLast - ? extractAssistantText(postLast) - : undefined; - if ( - announceTarget && - postReply && - postReply.trim() && - !isAnnounceSkip(postReply) - ) { - await callGateway({ - method: "send", - params: { - to: announceTarget.to, - message: postReply.trim(), - provider: announceTarget.channel, - idempotencyKey: crypto.randomUUID(), - }, - timeoutMs: 10_000, - }); - } - } - } catch { - // Best-effort announce; ignore failures to avoid breaking the caller response. - } - }; - - if (timeoutSeconds === 0) { - try { - const response = (await callGateway({ - method: "agent", - params: sendParams, - timeoutMs: 10_000, - })) as { runId?: string }; - if (typeof response?.runId === "string" && response.runId) { - runId = response.runId; - } - void runAgentToAgentPost(); - return jsonResult({ - runId, - status: "accepted", - sessionKey: displayKey, - }); - } catch (err) { - const message = - err instanceof Error - ? err.message - : typeof err === "string" - ? err - : "error"; - return jsonResult({ - runId, - status: "error", - error: message, - sessionKey: displayKey, - }); - } - } - - let acceptedAt: number | undefined; - try { - const response = (await callGateway({ - method: "agent", - params: sendParams, - timeoutMs: 10_000, - })) as { runId?: string; acceptedAt?: number }; - if (typeof response?.runId === "string" && response.runId) { - runId = response.runId; - } - if (typeof response?.acceptedAt === "number") { - acceptedAt = response.acceptedAt; - } - } catch (err) { - const message = - err instanceof Error - ? err.message - : typeof err === "string" - ? err - : "error"; - return jsonResult({ - runId, - status: "error", - error: message, - sessionKey: displayKey, - }); - } - - let waitStatus: string | undefined; - let waitError: string | undefined; - try { - const wait = (await callGateway({ - method: "agent.wait", - params: { - runId, - afterMs: acceptedAt, - timeoutMs, - }, - timeoutMs: timeoutMs + 2000, - })) as { status?: string; error?: string }; - waitStatus = typeof wait?.status === "string" ? wait.status : undefined; - waitError = typeof wait?.error === "string" ? wait.error : undefined; - } catch (err) { - const message = - err instanceof Error - ? err.message - : typeof err === "string" - ? err - : "error"; - return jsonResult({ - runId, - status: message.includes("gateway timeout") ? "timeout" : "error", - error: message, - sessionKey: displayKey, - }); - } - - if (waitStatus === "timeout") { - return jsonResult({ - runId, - status: "timeout", - error: waitError, - sessionKey: displayKey, - }); - } - if (waitStatus === "error") { - return jsonResult({ - runId, - status: "error", - error: waitError ?? "agent error", - sessionKey: displayKey, - }); - } - - const history = (await callGateway({ - method: "chat.history", - params: { sessionKey: resolvedKey, limit: 50 }, - })) as { messages?: unknown[] }; - const filtered = stripToolMessages( - Array.isArray(history?.messages) ? history.messages : [], - ); - const last = - filtered.length > 0 ? filtered[filtered.length - 1] : undefined; - const reply = last ? extractAssistantText(last) : undefined; - void runAgentToAgentPost(reply ?? undefined); - - return jsonResult({ - runId, - status: "ok", - reply, - sessionKey: displayKey, - }); - }, - }; -} +import { createBrowserTool } from "./tools/browser-tool.js"; +import { createCanvasTool } from "./tools/canvas-tool.js"; +import type { AnyAgentTool } from "./tools/common.js"; +import { createCronTool } from "./tools/cron-tool.js"; +import { createDiscordTool } from "./tools/discord-tool.js"; +import { createGatewayTool } from "./tools/gateway-tool.js"; +import { createNodesTool } from "./tools/nodes-tool.js"; +import { createSessionsHistoryTool } from "./tools/sessions-history-tool.js"; +import { createSessionsListTool } from "./tools/sessions-list-tool.js"; +import { createSessionsSendTool } from "./tools/sessions-send-tool.js"; +import { createSlackTool } from "./tools/slack-tool.js"; export function createClawdisTools(options?: { browserControlUrl?: string; @@ -3091,6 +21,7 @@ export function createClawdisTools(options?: { createNodesTool(), createCronTool(), createDiscordTool(), + createSlackTool(), createGatewayTool(), createSessionsListTool(), createSessionsHistoryTool(), diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index df6e5a5e4..16b9fe364 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -1,3223 +1,65 @@ -import { randomUUID } from "node:crypto"; -import fs from "node:fs"; +import { ErrorCodes, errorShape } from "./protocol/index.js"; +import { agentHandlers } from "./server-methods/agent.js"; +import { chatHandlers } from "./server-methods/chat.js"; +import { configHandlers } from "./server-methods/config.js"; +import { connectHandlers } from "./server-methods/connect.js"; +import { cronHandlers } from "./server-methods/cron.js"; +import { healthHandlers } from "./server-methods/health.js"; +import { modelsHandlers } from "./server-methods/models.js"; +import { nodeHandlers } from "./server-methods/nodes.js"; +import { providersHandlers } from "./server-methods/providers.js"; +import { sendHandlers } from "./server-methods/send.js"; +import { sessionsHandlers } from "./server-methods/sessions.js"; +import { skillsHandlers } from "./server-methods/skills.js"; +import { systemHandlers } from "./server-methods/system.js"; +import { talkHandlers } from "./server-methods/talk.js"; +import type { + GatewayRequestHandlers, + GatewayRequestOptions, +} from "./server-methods/types.js"; +import { voicewakeHandlers } from "./server-methods/voicewake.js"; +import { webHandlers } from "./server-methods/web.js"; +import { wizardHandlers } from "./server-methods/wizard.js"; -import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; -import type { ModelCatalogEntry } from "../agents/model-catalog.js"; -import { - buildAllowedModelSet, - buildModelAliasIndex, - modelKey, - resolveConfiguredModelRef, - resolveModelRefFromString, - resolveThinkingDefault, -} from "../agents/model-selection.js"; -import { - abortEmbeddedPiRun, - isEmbeddedPiRunActive, - resolveEmbeddedSessionLane, - waitForEmbeddedPiRunEnd, -} from "../agents/pi-embedded.js"; -import { installSkill } from "../agents/skills-install.js"; -import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; -import { DEFAULT_AGENT_WORKSPACE_DIR } from "../agents/workspace.js"; -import { normalizeGroupActivation } from "../auto-reply/group-activation.js"; -import { - normalizeThinkLevel, - normalizeVerboseLevel, -} from "../auto-reply/thinking.js"; -import type { createDefaultDeps } from "../cli/deps.js"; -import { agentCommand } from "../commands/agent.js"; -import type { HealthSummary } from "../commands/health.js"; -import { getStatusSummary } from "../commands/status.js"; -import type { ClawdisConfig } from "../config/config.js"; -import { - CONFIG_PATH_CLAWDIS, - loadConfig, - parseConfigJson5, - readConfigFileSnapshot, - validateConfigObject, - writeConfigFile, -} from "../config/config.js"; -import { buildConfigSchema } from "../config/schema.js"; -import { - loadSessionStore, - resolveMainSessionKey, - resolveStorePath, - type SessionEntry, - saveSessionStore, -} from "../config/sessions.js"; -import { - readCronRunLogEntries, - resolveCronRunLogPath, -} from "../cron/run-log.js"; -import type { CronService } from "../cron/service.js"; -import type { CronJobCreate, CronJobPatch } from "../cron/types.js"; -import { sendMessageDiscord } from "../discord/index.js"; -import { type DiscordProbe, probeDiscord } from "../discord/probe.js"; -import { shouldLogVerbose } from "../globals.js"; -import { sendMessageIMessage } from "../imessage/index.js"; -import { type IMessageProbe, probeIMessage } from "../imessage/probe.js"; -import { - onAgentEvent, - registerAgentRunContext, -} from "../infra/agent-events.js"; -import type { startNodeBridgeServer } from "../infra/bridge/server.js"; -import { getLastHeartbeatEvent } from "../infra/heartbeat-events.js"; -import { setHeartbeatsEnabled } from "../infra/heartbeat-runner.js"; -import { - approveNodePairing, - listNodePairing, - rejectNodePairing, - renamePairedNode, - requestNodePairing, - verifyNodeToken, -} from "../infra/node-pairing.js"; -import { - enqueueSystemEvent, - isSystemEventContextChanged, -} from "../infra/system-events.js"; -import { - listSystemPresence, - updateSystemPresence, -} from "../infra/system-presence.js"; -import { - loadVoiceWakeConfig, - setVoiceWakeTriggers, -} from "../infra/voicewake.js"; -import { clearCommandLane } from "../process/command-queue.js"; -import { webAuthExists } from "../providers/web/index.js"; -import { defaultRuntime } from "../runtime.js"; -import { - normalizeSendPolicy, - resolveSendPolicy, -} from "../sessions/send-policy.js"; -import { sendMessageSignal } from "../signal/index.js"; -import { probeSignal, type SignalProbe } from "../signal/probe.js"; -import { probeTelegram, type TelegramProbe } from "../telegram/probe.js"; -import { sendMessageTelegram } from "../telegram/send.js"; -import { resolveTelegramToken } from "../telegram/token.js"; -import { normalizeE164, resolveUserPath } from "../utils.js"; -import { startWebLoginWithQr, waitForWebLogin } from "../web/login-qr.js"; -import { sendMessageWhatsApp } from "../web/outbound.js"; -import { getWebAuthAgeMs, logoutWeb, readWebSelfId } from "../web/session.js"; -import { WizardSession } from "../wizard/session.js"; -import { buildMessageWithAttachments } from "./chat-attachments.js"; -import { - type AgentWaitParams, - type ConnectParams, - ErrorCodes, - type ErrorShape, - errorShape, - formatValidationErrors, - type RequestFrame, - type SessionsCompactParams, - type SessionsDeleteParams, - type SessionsListParams, - type SessionsPatchParams, - type SessionsResetParams, - validateAgentParams, - validateAgentWaitParams, - validateChatAbortParams, - validateChatHistoryParams, - validateChatSendParams, - validateConfigGetParams, - validateConfigSchemaParams, - validateConfigSetParams, - validateCronAddParams, - validateCronListParams, - validateCronRemoveParams, - validateCronRunParams, - validateCronRunsParams, - validateCronStatusParams, - validateCronUpdateParams, - validateModelsListParams, - validateNodeDescribeParams, - validateNodeInvokeParams, - validateNodeListParams, - validateNodePairApproveParams, - validateNodePairListParams, - validateNodePairRejectParams, - validateNodePairRequestParams, - validateNodePairVerifyParams, - validateNodeRenameParams, - validateProvidersStatusParams, - validateSendParams, - validateSessionsCompactParams, - validateSessionsDeleteParams, - validateSessionsListParams, - validateSessionsPatchParams, - validateSessionsResetParams, - validateSkillsInstallParams, - validateSkillsStatusParams, - validateSkillsUpdateParams, - validateTalkModeParams, - validateWakeParams, - validateWebLoginStartParams, - validateWebLoginWaitParams, - validateWizardCancelParams, - validateWizardNextParams, - validateWizardStartParams, - validateWizardStatusParams, -} from "./protocol/index.js"; -import { - HEALTH_REFRESH_INTERVAL_MS, - MAX_CHAT_HISTORY_MESSAGES_BYTES, -} from "./server-constants.js"; -import type { ProviderRuntimeSnapshot } from "./server-providers.js"; -import { formatError, normalizeVoiceWakeTriggers } from "./server-utils.js"; -import { - archiveFileOnDisk, - capArrayByJsonBytes, - listSessionsFromStore, - loadSessionEntry, - readSessionMessages, - resolveSessionModelRef, - resolveSessionTranscriptCandidates, - type SessionsPatchResult, -} from "./session-utils.js"; -import { formatForLog } from "./ws-log.js"; - -export type GatewayClient = { - connect: ConnectParams; -}; - -export type RespondFn = ( - ok: boolean, - payload?: unknown, - error?: ErrorShape, - meta?: Record, -) => void; - -type DedupeEntry = { - ts: number; - ok: boolean; - payload?: unknown; - error?: ErrorShape; -}; - -type AgentJobSnapshot = { - runId: string; - state: "done" | "error"; - startedAt?: number; - endedAt?: number; - error?: string; - ts: number; -}; - -const AGENT_JOB_CACHE_TTL_MS = 10 * 60_000; -const agentJobCache = new Map(); -const agentRunStarts = new Map(); -let agentJobListenerStarted = false; - -function pruneAgentJobCache(now = Date.now()) { - for (const [runId, entry] of agentJobCache) { - if (now - entry.ts > AGENT_JOB_CACHE_TTL_MS) { - agentJobCache.delete(runId); - } - } -} - -function recordAgentJobSnapshot(entry: AgentJobSnapshot) { - pruneAgentJobCache(entry.ts); - agentJobCache.set(entry.runId, entry); -} - -function ensureAgentJobListener() { - if (agentJobListenerStarted) return; - agentJobListenerStarted = true; - onAgentEvent((evt) => { - if (!evt || evt.stream !== "job") return; - const state = evt.data?.state; - if (state === "started") { - const startedAt = - typeof evt.data?.startedAt === "number" - ? (evt.data.startedAt as number) - : undefined; - if (startedAt !== undefined) { - agentRunStarts.set(evt.runId, startedAt); - } - return; - } - if (state !== "done" && state !== "error") return; - const startedAt = - typeof evt.data?.startedAt === "number" - ? (evt.data.startedAt as number) - : agentRunStarts.get(evt.runId); - const endedAt = - typeof evt.data?.endedAt === "number" - ? (evt.data.endedAt as number) - : undefined; - const error = - typeof evt.data?.error === "string" - ? (evt.data.error as string) - : undefined; - agentRunStarts.delete(evt.runId); - recordAgentJobSnapshot({ - runId: evt.runId, - state: state === "error" ? "error" : "done", - startedAt, - endedAt, - error, - ts: Date.now(), - }); - }); -} - -function matchesAfterMs(entry: AgentJobSnapshot, afterMs?: number) { - if (afterMs === undefined) return true; - if (typeof entry.startedAt === "number") return entry.startedAt >= afterMs; - if (typeof entry.endedAt === "number") return entry.endedAt >= afterMs; - return false; -} - -function getCachedAgentJob(runId: string, afterMs?: number) { - pruneAgentJobCache(); - const cached = agentJobCache.get(runId); - if (!cached) return undefined; - return matchesAfterMs(cached, afterMs) ? cached : undefined; -} - -async function waitForAgentJob(params: { - runId: string; - afterMs?: number; - timeoutMs: number; -}): Promise { - const { runId, afterMs, timeoutMs } = params; - ensureAgentJobListener(); - const cached = getCachedAgentJob(runId, afterMs); - if (cached) return cached; - if (timeoutMs <= 0) return null; - - return await new Promise((resolve) => { - let settled = false; - const finish = (entry: AgentJobSnapshot | null) => { - if (settled) return; - settled = true; - clearTimeout(timer); - unsubscribe(); - resolve(entry); - }; - const unsubscribe = onAgentEvent((evt) => { - if (!evt || evt.stream !== "job") return; - if (evt.runId !== runId) return; - const state = evt.data?.state; - if (state !== "done" && state !== "error") return; - const startedAt = - typeof evt.data?.startedAt === "number" - ? (evt.data.startedAt as number) - : agentRunStarts.get(evt.runId); - const endedAt = - typeof evt.data?.endedAt === "number" - ? (evt.data.endedAt as number) - : undefined; - const error = - typeof evt.data?.error === "string" - ? (evt.data.error as string) - : undefined; - const snapshot: AgentJobSnapshot = { - runId: evt.runId, - state: state === "error" ? "error" : "done", - startedAt, - endedAt, - error, - ts: Date.now(), - }; - recordAgentJobSnapshot(snapshot); - if (!matchesAfterMs(snapshot, afterMs)) return; - finish(snapshot); - }); - const timer = setTimeout(() => finish(null), Math.max(1, timeoutMs)); - }); -} - -ensureAgentJobListener(); - -export type GatewayRequestContext = { - deps: ReturnType; - cron: CronService; - cronStorePath: string; - loadGatewayModelCatalog: () => Promise; - getHealthCache: () => HealthSummary | null; - refreshHealthSnapshot: (opts?: { probe?: boolean }) => Promise; - logHealth: { error: (message: string) => void }; - incrementPresenceVersion: () => number; - getHealthVersion: () => number; - broadcast: ( - event: string, - payload: unknown, - opts?: { - dropIfSlow?: boolean; - stateVersion?: { presence?: number; health?: number }; - }, - ) => void; - bridge: Awaited> | null; - bridgeSendToSession: ( - sessionKey: string, - event: string, - payload: unknown, - ) => void; - hasConnectedMobileNode: () => boolean; - agentRunSeq: Map; - chatAbortControllers: Map< - string, - { controller: AbortController; sessionId: string; sessionKey: string } - >; - chatRunBuffers: Map; - chatDeltaSentAt: Map; - addChatRun: ( - sessionId: string, - entry: { sessionKey: string; clientRunId: string }, - ) => void; - removeChatRun: ( - sessionId: string, - clientRunId: string, - sessionKey?: string, - ) => { sessionKey: string; clientRunId: string } | undefined; - dedupe: Map; - wizardSessions: Map; - findRunningWizard: () => string | null; - purgeWizardSession: (id: string) => void; - getRuntimeSnapshot: () => ProviderRuntimeSnapshot; - startWhatsAppProvider: () => Promise; - stopWhatsAppProvider: () => Promise; - stopTelegramProvider: () => Promise; - markWhatsAppLoggedOut: (cleared: boolean) => void; - wizardRunner: ( - opts: import("../commands/onboard-types.js").OnboardOptions, - runtime: import("../runtime.js").RuntimeEnv, - prompter: import("../wizard/prompts.js").WizardPrompter, - ) => Promise; - broadcastVoiceWakeChanged: (triggers: string[]) => void; -}; - -export type GatewayRequestOptions = { - req: RequestFrame; - client: GatewayClient | null; - isWebchatConnect: (params: ConnectParams | null | undefined) => boolean; - respond: RespondFn; - context: GatewayRequestContext; +const handlers: GatewayRequestHandlers = { + ...connectHandlers, + ...voicewakeHandlers, + ...healthHandlers, + ...providersHandlers, + ...chatHandlers, + ...cronHandlers, + ...webHandlers, + ...modelsHandlers, + ...configHandlers, + ...wizardHandlers, + ...talkHandlers, + ...skillsHandlers, + ...sessionsHandlers, + ...systemHandlers, + ...nodeHandlers, + ...sendHandlers, + ...agentHandlers, }; export async function handleGatewayRequest( opts: GatewayRequestOptions, ): Promise { const { req, respond, client, isWebchatConnect, context } = opts; - const { - deps, - cron, - cronStorePath, - loadGatewayModelCatalog, - getHealthCache, - refreshHealthSnapshot, - logHealth, - incrementPresenceVersion, - getHealthVersion, - broadcast, - bridge, - bridgeSendToSession, - hasConnectedMobileNode, - agentRunSeq, - chatAbortControllers, - chatRunBuffers, - chatDeltaSentAt, - addChatRun, - removeChatRun, - dedupe, - wizardSessions, - findRunningWizard, - purgeWizardSession, - getRuntimeSnapshot, - startWhatsAppProvider, - stopWhatsAppProvider, - stopTelegramProvider, - markWhatsAppLoggedOut, - wizardRunner, - broadcastVoiceWakeChanged, - } = context; - - switch (req.method) { - case "connect": { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "connect is only valid as the first request", - ), - ); - break; - } - case "voicewake.get": { - try { - const cfg = await loadVoiceWakeConfig(); - respond(true, { triggers: cfg.triggers }); - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), - ); - } - break; - } - case "voicewake.set": { - const params = (req.params ?? {}) as Record; - if (!Array.isArray(params.triggers)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "voicewake.set requires triggers: string[]", - ), - ); - break; - } - try { - const triggers = normalizeVoiceWakeTriggers(params.triggers); - const cfg = await setVoiceWakeTriggers(triggers); - broadcastVoiceWakeChanged(cfg.triggers); - respond(true, { triggers: cfg.triggers }); - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), - ); - } - break; - } - case "health": { - const now = Date.now(); - const cached = getHealthCache(); - if (cached && now - cached.ts < HEALTH_REFRESH_INTERVAL_MS) { - respond(true, cached, undefined, { cached: true }); - void refreshHealthSnapshot({ probe: false }).catch((err) => - logHealth.error( - `background health refresh failed: ${formatError(err)}`, - ), - ); - break; - } - try { - const snap = await refreshHealthSnapshot({ probe: false }); - respond(true, snap, undefined); - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), - ); - } - break; - } - case "providers.status": { - const params = (req.params ?? {}) as Record; - if (!validateProvidersStatusParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid providers.status params: ${formatValidationErrors(validateProvidersStatusParams.errors)}`, - ), - ); - break; - } - const probe = (params as { probe?: boolean }).probe === true; - const timeoutMsRaw = (params as { timeoutMs?: unknown }).timeoutMs; - const timeoutMs = - typeof timeoutMsRaw === "number" - ? Math.max(1000, timeoutMsRaw) - : 10_000; - const cfg = loadConfig(); - const telegramCfg = cfg.telegram; - const telegramEnabled = - Boolean(telegramCfg) && telegramCfg?.enabled !== false; - const { token: telegramToken, source: tokenSource } = telegramEnabled - ? resolveTelegramToken(cfg) - : { token: "", source: "none" as const }; - let telegramProbe: TelegramProbe | undefined; - let lastProbeAt: number | null = null; - if (probe && telegramToken && telegramEnabled) { - telegramProbe = await probeTelegram( - telegramToken, - timeoutMs, - telegramCfg?.proxy, - ); - lastProbeAt = Date.now(); - } - - const discordCfg = cfg.discord; - const discordEnabled = - Boolean(discordCfg) && discordCfg?.enabled !== false; - const discordEnvToken = discordEnabled - ? process.env.DISCORD_BOT_TOKEN?.trim() - : ""; - const discordConfigToken = discordEnabled - ? discordCfg?.token?.trim() - : ""; - const discordToken = discordEnvToken || discordConfigToken || ""; - const discordTokenSource = discordEnvToken - ? "env" - : discordConfigToken - ? "config" - : "none"; - let discordProbe: DiscordProbe | undefined; - let discordLastProbeAt: number | null = null; - if (probe && discordToken && discordEnabled) { - discordProbe = await probeDiscord(discordToken, timeoutMs); - discordLastProbeAt = Date.now(); - } - - const signalCfg = cfg.signal; - const signalEnabled = signalCfg?.enabled !== false; - const signalHost = signalCfg?.httpHost?.trim() || "127.0.0.1"; - const signalPort = signalCfg?.httpPort ?? 8080; - const signalBaseUrl = - signalCfg?.httpUrl?.trim() || `http://${signalHost}:${signalPort}`; - const signalConfigured = - Boolean(signalCfg) && - signalEnabled && - Boolean( - signalCfg?.account?.trim() || - signalCfg?.httpUrl?.trim() || - signalCfg?.cliPath?.trim() || - signalCfg?.httpHost?.trim() || - typeof signalCfg?.httpPort === "number" || - typeof signalCfg?.autoStart === "boolean", - ); - let signalProbe: SignalProbe | undefined; - let signalLastProbeAt: number | null = null; - if (probe && signalConfigured) { - signalProbe = await probeSignal(signalBaseUrl, timeoutMs); - signalLastProbeAt = Date.now(); - } - - const imessageCfg = cfg.imessage; - const imessageEnabled = imessageCfg?.enabled !== false; - const imessageConfigured = Boolean(imessageCfg) && imessageEnabled; - let imessageProbe: IMessageProbe | undefined; - let imessageLastProbeAt: number | null = null; - if (probe && imessageConfigured) { - imessageProbe = await probeIMessage(timeoutMs); - imessageLastProbeAt = Date.now(); - } - - const linked = await webAuthExists(); - const authAgeMs = getWebAuthAgeMs(); - const self = readWebSelfId(); - const runtime = getRuntimeSnapshot(); - - respond( - true, - { - ts: Date.now(), - whatsapp: { - configured: linked, - linked, - authAgeMs, - self, - running: runtime.whatsapp.running, - connected: runtime.whatsapp.connected, - lastConnectedAt: runtime.whatsapp.lastConnectedAt ?? null, - lastDisconnect: runtime.whatsapp.lastDisconnect ?? null, - reconnectAttempts: runtime.whatsapp.reconnectAttempts, - lastMessageAt: runtime.whatsapp.lastMessageAt ?? null, - lastEventAt: runtime.whatsapp.lastEventAt ?? null, - lastError: runtime.whatsapp.lastError ?? null, - }, - telegram: { - configured: telegramEnabled && Boolean(telegramToken), - tokenSource, - running: runtime.telegram.running, - mode: runtime.telegram.mode ?? null, - lastStartAt: runtime.telegram.lastStartAt ?? null, - lastStopAt: runtime.telegram.lastStopAt ?? null, - lastError: runtime.telegram.lastError ?? null, - probe: telegramProbe, - lastProbeAt, - }, - discord: { - configured: discordEnabled && Boolean(discordToken), - tokenSource: discordTokenSource, - running: runtime.discord.running, - lastStartAt: runtime.discord.lastStartAt ?? null, - lastStopAt: runtime.discord.lastStopAt ?? null, - lastError: runtime.discord.lastError ?? null, - probe: discordProbe, - lastProbeAt: discordLastProbeAt, - }, - signal: { - configured: signalConfigured, - baseUrl: signalBaseUrl, - running: runtime.signal.running, - lastStartAt: runtime.signal.lastStartAt ?? null, - lastStopAt: runtime.signal.lastStopAt ?? null, - lastError: runtime.signal.lastError ?? null, - probe: signalProbe, - lastProbeAt: signalLastProbeAt, - }, - imessage: { - configured: imessageConfigured, - running: runtime.imessage.running, - lastStartAt: runtime.imessage.lastStartAt ?? null, - lastStopAt: runtime.imessage.lastStopAt ?? null, - lastError: runtime.imessage.lastError ?? null, - cliPath: runtime.imessage.cliPath ?? null, - dbPath: runtime.imessage.dbPath ?? null, - probe: imessageProbe, - lastProbeAt: imessageLastProbeAt, - }, - }, - undefined, - ); - break; - } - case "chat.history": { - const params = (req.params ?? {}) as Record; - if (!validateChatHistoryParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid chat.history params: ${formatValidationErrors(validateChatHistoryParams.errors)}`, - ), - ); - break; - } - const { sessionKey, limit } = params as { - sessionKey: string; - limit?: number; - }; - const { cfg, storePath, entry } = loadSessionEntry(sessionKey); - const sessionId = entry?.sessionId; - const rawMessages = - sessionId && storePath ? readSessionMessages(sessionId, storePath) : []; - const hardMax = 1000; - const defaultLimit = 200; - const requested = typeof limit === "number" ? limit : defaultLimit; - const max = Math.min(hardMax, requested); - const sliced = - rawMessages.length > max ? rawMessages.slice(-max) : rawMessages; - const capped = capArrayByJsonBytes( - sliced, - MAX_CHAT_HISTORY_MESSAGES_BYTES, - ).items; - let thinkingLevel = entry?.thinkingLevel; - if (!thinkingLevel) { - const configured = cfg.agent?.thinkingDefault; - if (configured) { - thinkingLevel = configured; - } else { - const { provider, model } = resolveSessionModelRef(cfg, entry); - const catalog = await loadGatewayModelCatalog(); - thinkingLevel = resolveThinkingDefault({ - cfg, - provider, - model, - catalog, - }); - } - } - respond(true, { - sessionKey, - sessionId, - messages: capped, - thinkingLevel, - }); - break; - } - case "chat.abort": { - const params = (req.params ?? {}) as Record; - if (!validateChatAbortParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid chat.abort params: ${formatValidationErrors(validateChatAbortParams.errors)}`, - ), - ); - break; - } - const { sessionKey, runId } = params as { - sessionKey: string; - runId: string; - }; - const active = chatAbortControllers.get(runId); - if (!active) { - respond(true, { ok: true, aborted: false }); - break; - } - if (active.sessionKey !== sessionKey) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "runId does not match sessionKey", - ), - ); - break; - } - - active.controller.abort(); - chatAbortControllers.delete(runId); - chatRunBuffers.delete(runId); - chatDeltaSentAt.delete(runId); - removeChatRun(active.sessionId, runId, sessionKey); - - const payload = { - runId, - sessionKey, - seq: (agentRunSeq.get(active.sessionId) ?? 0) + 1, - state: "aborted" as const, - }; - broadcast("chat", payload); - bridgeSendToSession(sessionKey, "chat", payload); - respond(true, { ok: true, aborted: true }); - break; - } - case "chat.send": { - if ( - client && - isWebchatConnect(client.connect) && - !hasConnectedMobileNode() - ) { - respond( - false, - undefined, - errorShape( - ErrorCodes.UNAVAILABLE, - "web chat disabled: no connected iOS/Android nodes", - ), - ); - break; - } - const params = (req.params ?? {}) as Record; - if (!validateChatSendParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid chat.send params: ${formatValidationErrors(validateChatSendParams.errors)}`, - ), - ); - break; - } - const p = params as { - sessionKey: string; - message: string; - thinking?: string; - deliver?: boolean; - attachments?: Array<{ - type?: string; - mimeType?: string; - fileName?: string; - content?: unknown; - }>; - timeoutMs?: number; - idempotencyKey: string; - }; - const timeoutMs = Math.min(Math.max(p.timeoutMs ?? 30_000, 0), 30_000); - const normalizedAttachments = - p.attachments?.map((a) => ({ - type: typeof a?.type === "string" ? a.type : undefined, - mimeType: typeof a?.mimeType === "string" ? a.mimeType : undefined, - fileName: typeof a?.fileName === "string" ? a.fileName : undefined, - content: - typeof a?.content === "string" - ? a.content - : ArrayBuffer.isView(a?.content) - ? Buffer.from( - a.content.buffer, - a.content.byteOffset, - a.content.byteLength, - ).toString("base64") - : undefined, - })) ?? []; - let messageWithAttachments = p.message; - if (normalizedAttachments.length > 0) { - try { - messageWithAttachments = buildMessageWithAttachments( - p.message, - normalizedAttachments, - { maxBytes: 5_000_000 }, - ); - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, String(err)), - ); - break; - } - } - const { cfg, storePath, store, entry } = loadSessionEntry(p.sessionKey); - const now = Date.now(); - const sessionId = entry?.sessionId ?? randomUUID(); - const sessionEntry: SessionEntry = { - sessionId, - updatedAt: now, - thinkingLevel: entry?.thinkingLevel, - verboseLevel: entry?.verboseLevel, - systemSent: entry?.systemSent, - sendPolicy: entry?.sendPolicy, - lastChannel: entry?.lastChannel, - lastTo: entry?.lastTo, - }; - const clientRunId = p.idempotencyKey; - - const sendPolicy = resolveSendPolicy({ - cfg, - entry, - sessionKey: p.sessionKey, - surface: entry?.surface, - chatType: entry?.chatType, - }); - if (sendPolicy === "deny") { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "send blocked by session policy", - ), - ); - break; - } - - const cached = dedupe.get(`chat:${clientRunId}`); - if (cached) { - respond(cached.ok, cached.payload, cached.error, { - cached: true, - }); - break; - } - - try { - const abortController = new AbortController(); - chatAbortControllers.set(clientRunId, { - controller: abortController, - sessionId, - sessionKey: p.sessionKey, - }); - addChatRun(sessionId, { - sessionKey: p.sessionKey, - clientRunId, - }); - - if (store) { - store[p.sessionKey] = sessionEntry; - if (storePath) { - await saveSessionStore(storePath, store); - } - } - - await agentCommand( - { - message: messageWithAttachments, - sessionId, - thinking: p.thinking, - deliver: p.deliver, - timeout: Math.ceil(timeoutMs / 1000).toString(), - surface: "WebChat", - abortSignal: abortController.signal, - }, - defaultRuntime, - deps, - ); - const payload = { - runId: clientRunId, - status: "ok" as const, - }; - dedupe.set(`chat:${clientRunId}`, { - ts: Date.now(), - ok: true, - payload, - }); - respond(true, payload, undefined, { runId: clientRunId }); - } catch (err) { - const error = errorShape(ErrorCodes.UNAVAILABLE, String(err)); - const payload = { - runId: clientRunId, - status: "error" as const, - summary: String(err), - }; - dedupe.set(`chat:${clientRunId}`, { - ts: Date.now(), - ok: false, - payload, - error, - }); - respond(false, payload, error, { - runId: clientRunId, - error: formatForLog(err), - }); - } finally { - chatAbortControllers.delete(clientRunId); - } - break; - } - case "wake": { - const params = (req.params ?? {}) as Record; - if (!validateWakeParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid wake params: ${formatValidationErrors(validateWakeParams.errors)}`, - ), - ); - break; - } - const p = params as { - mode: "now" | "next-heartbeat"; - text: string; - }; - const result = cron.wake({ mode: p.mode, text: p.text }); - respond(true, result, undefined); - break; - } - case "cron.list": { - const params = (req.params ?? {}) as Record; - if (!validateCronListParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid cron.list params: ${formatValidationErrors(validateCronListParams.errors)}`, - ), - ); - break; - } - const p = params as { includeDisabled?: boolean }; - const jobs = await cron.list({ - includeDisabled: p.includeDisabled, - }); - respond(true, { jobs }, undefined); - break; - } - case "cron.status": { - const params = (req.params ?? {}) as Record; - if (!validateCronStatusParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid cron.status params: ${formatValidationErrors(validateCronStatusParams.errors)}`, - ), - ); - break; - } - const status = await cron.status(); - respond(true, status, undefined); - break; - } - case "cron.add": { - const params = (req.params ?? {}) as Record; - if (!validateCronAddParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid cron.add params: ${formatValidationErrors(validateCronAddParams.errors)}`, - ), - ); - break; - } - const job = await cron.add(params as unknown as CronJobCreate); - respond(true, job, undefined); - break; - } - case "cron.update": { - const params = (req.params ?? {}) as Record; - if (!validateCronUpdateParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid cron.update params: ${formatValidationErrors(validateCronUpdateParams.errors)}`, - ), - ); - break; - } - const p = params as { - id: string; - patch: Record; - }; - const job = await cron.update(p.id, p.patch as unknown as CronJobPatch); - respond(true, job, undefined); - break; - } - case "cron.remove": { - const params = (req.params ?? {}) as Record; - if (!validateCronRemoveParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid cron.remove params: ${formatValidationErrors(validateCronRemoveParams.errors)}`, - ), - ); - break; - } - const p = params as { id: string }; - const result = await cron.remove(p.id); - respond(true, result, undefined); - break; - } - case "cron.run": { - const params = (req.params ?? {}) as Record; - if (!validateCronRunParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid cron.run params: ${formatValidationErrors(validateCronRunParams.errors)}`, - ), - ); - break; - } - const p = params as { id: string; mode?: "due" | "force" }; - const result = await cron.run(p.id, p.mode); - respond(true, result, undefined); - break; - } - case "cron.runs": { - const params = (req.params ?? {}) as Record; - if (!validateCronRunsParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid cron.runs params: ${formatValidationErrors(validateCronRunsParams.errors)}`, - ), - ); - break; - } - const p = params as { id: string; limit?: number }; - const logPath = resolveCronRunLogPath({ - storePath: cronStorePath, - jobId: p.id, - }); - const entries = await readCronRunLogEntries(logPath, { - limit: p.limit, - jobId: p.id, - }); - respond(true, { entries }, undefined); - break; - } - case "status": { - const status = await getStatusSummary(); - respond(true, status, undefined); - break; - } - case "web.login.start": { - const params = (req.params ?? {}) as Record; - if (!validateWebLoginStartParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid web.login.start params: ${formatValidationErrors(validateWebLoginStartParams.errors)}`, - ), - ); - break; - } - try { - await stopWhatsAppProvider(); - const result = await startWebLoginWithQr({ - force: Boolean((params as { force?: boolean }).force), - timeoutMs: - typeof (params as { timeoutMs?: unknown }).timeoutMs === "number" - ? (params as { timeoutMs?: number }).timeoutMs - : undefined, - verbose: Boolean((params as { verbose?: boolean }).verbose), - }); - respond(true, result, undefined); - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), - ); - } - break; - } - case "web.login.wait": { - const params = (req.params ?? {}) as Record; - if (!validateWebLoginWaitParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid web.login.wait params: ${formatValidationErrors(validateWebLoginWaitParams.errors)}`, - ), - ); - break; - } - try { - const result = await waitForWebLogin({ - timeoutMs: - typeof (params as { timeoutMs?: unknown }).timeoutMs === "number" - ? (params as { timeoutMs?: number }).timeoutMs - : undefined, - }); - if (result.connected) { - await startWhatsAppProvider(); - } - respond(true, result, undefined); - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), - ); - } - break; - } - case "web.logout": { - try { - await stopWhatsAppProvider(); - const cleared = await logoutWeb(defaultRuntime); - markWhatsAppLoggedOut(cleared); - respond(true, { cleared }, undefined); - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), - ); - } - break; - } - case "telegram.logout": { - try { - await stopTelegramProvider(); - const snapshot = await readConfigFileSnapshot(); - if (!snapshot.valid) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "config invalid; fix it before logging out", - ), - ); - break; - } - const cfg = snapshot.config ?? {}; - const envToken = process.env.TELEGRAM_BOT_TOKEN?.trim() ?? ""; - const hadToken = Boolean(cfg.telegram?.botToken); - const nextTelegram = cfg.telegram ? { ...cfg.telegram } : undefined; - if (nextTelegram) { - delete nextTelegram.botToken; - } - const nextCfg = { ...cfg } as ClawdisConfig; - if (nextTelegram && Object.keys(nextTelegram).length > 0) { - nextCfg.telegram = nextTelegram; - } else { - delete nextCfg.telegram; - } - await writeConfigFile(nextCfg); - respond( - true, - { cleared: hadToken, envToken: Boolean(envToken) }, - undefined, - ); - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), - ); - } - break; - } - case "models.list": { - const params = (req.params ?? {}) as Record; - if (!validateModelsListParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid models.list params: ${formatValidationErrors(validateModelsListParams.errors)}`, - ), - ); - break; - } - try { - const models = await loadGatewayModelCatalog(); - respond(true, { models }, undefined); - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, String(err)), - ); - } - break; - } - case "config.get": { - const params = (req.params ?? {}) as Record; - if (!validateConfigGetParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid config.get params: ${formatValidationErrors(validateConfigGetParams.errors)}`, - ), - ); - break; - } - const snapshot = await readConfigFileSnapshot(); - respond(true, snapshot, undefined); - break; - } - case "config.schema": { - const params = (req.params ?? {}) as Record; - if (!validateConfigSchemaParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid config.schema params: ${formatValidationErrors(validateConfigSchemaParams.errors)}`, - ), - ); - break; - } - const schema = buildConfigSchema(); - respond(true, schema, undefined); - break; - } - case "config.set": { - const params = (req.params ?? {}) as Record; - if (!validateConfigSetParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid config.set params: ${formatValidationErrors(validateConfigSetParams.errors)}`, - ), - ); - break; - } - const rawValue = (params as { raw?: unknown }).raw; - if (typeof rawValue !== "string") { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "invalid config.set params: raw (string) required", - ), - ); - break; - } - const parsedRes = parseConfigJson5(rawValue); - if (!parsedRes.ok) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, parsedRes.error), - ); - break; - } - const validated = validateConfigObject(parsedRes.parsed); - if (!validated.ok) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "invalid config", { - details: { issues: validated.issues }, - }), - ); - break; - } - await writeConfigFile(validated.config); - respond( - true, - { - ok: true, - path: CONFIG_PATH_CLAWDIS, - config: validated.config, - }, - undefined, - ); - break; - } - case "wizard.start": { - const params = (req.params ?? {}) as Record; - if (!validateWizardStartParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid wizard.start params: ${formatValidationErrors(validateWizardStartParams.errors)}`, - ), - ); - break; - } - const running = findRunningWizard(); - if (running) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, "wizard already running"), - ); - break; - } - const sessionId = randomUUID(); - const opts = { - mode: params.mode as "local" | "remote" | undefined, - workspace: - typeof params.workspace === "string" ? params.workspace : undefined, - }; - const session = new WizardSession((prompter) => - wizardRunner(opts, defaultRuntime, prompter), - ); - wizardSessions.set(sessionId, session); - const result = await session.next(); - if (result.done) { - purgeWizardSession(sessionId); - } - respond(true, { sessionId, ...result }, undefined); - break; - } - case "wizard.next": { - const params = (req.params ?? {}) as Record; - if (!validateWizardNextParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid wizard.next params: ${formatValidationErrors(validateWizardNextParams.errors)}`, - ), - ); - break; - } - const sessionId = params.sessionId as string; - const session = wizardSessions.get(sessionId); - if (!session) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "wizard not found"), - ); - break; - } - const answer = params.answer as - | { stepId?: string; value?: unknown } - | undefined; - if (answer) { - if (session.getStatus() !== "running") { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "wizard not running"), - ); - break; - } - try { - await session.answer(String(answer.stepId ?? ""), answer.value); - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, formatForLog(err)), - ); - break; - } - } - const result = await session.next(); - if (result.done) { - purgeWizardSession(sessionId); - } - respond(true, result, undefined); - break; - } - case "wizard.cancel": { - const params = (req.params ?? {}) as Record; - if (!validateWizardCancelParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid wizard.cancel params: ${formatValidationErrors(validateWizardCancelParams.errors)}`, - ), - ); - break; - } - const sessionId = params.sessionId as string; - const session = wizardSessions.get(sessionId); - if (!session) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "wizard not found"), - ); - break; - } - session.cancel(); - const status = { - status: session.getStatus(), - error: session.getError(), - }; - wizardSessions.delete(sessionId); - respond(true, status, undefined); - break; - } - case "wizard.status": { - const params = (req.params ?? {}) as Record; - if (!validateWizardStatusParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid wizard.status params: ${formatValidationErrors(validateWizardStatusParams.errors)}`, - ), - ); - break; - } - const sessionId = params.sessionId as string; - const session = wizardSessions.get(sessionId); - if (!session) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "wizard not found"), - ); - break; - } - const status = { - status: session.getStatus(), - error: session.getError(), - }; - if (status.status !== "running") { - wizardSessions.delete(sessionId); - } - respond(true, status, undefined); - break; - } - case "talk.mode": { - if ( - client && - isWebchatConnect(client.connect) && - !hasConnectedMobileNode() - ) { - respond( - false, - undefined, - errorShape( - ErrorCodes.UNAVAILABLE, - "talk disabled: no connected iOS/Android nodes", - ), - ); - break; - } - const params = (req.params ?? {}) as Record; - if (!validateTalkModeParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid talk.mode params: ${formatValidationErrors(validateTalkModeParams.errors)}`, - ), - ); - break; - } - const payload = { - enabled: (params as { enabled: boolean }).enabled, - phase: (params as { phase?: string }).phase ?? null, - ts: Date.now(), - }; - broadcast("talk.mode", payload, { dropIfSlow: true }); - respond(true, payload, undefined); - break; - } - case "skills.status": { - const params = (req.params ?? {}) as Record; - if (!validateSkillsStatusParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid skills.status params: ${formatValidationErrors(validateSkillsStatusParams.errors)}`, - ), - ); - break; - } - const cfg = loadConfig(); - const workspaceDirRaw = - cfg.agent?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; - const workspaceDir = resolveUserPath(workspaceDirRaw); - const report = buildWorkspaceSkillStatus(workspaceDir, { - config: cfg, - }); - respond(true, report, undefined); - break; - } - case "skills.install": { - const params = (req.params ?? {}) as Record; - if (!validateSkillsInstallParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid skills.install params: ${formatValidationErrors(validateSkillsInstallParams.errors)}`, - ), - ); - break; - } - const p = params as { - name: string; - installId: string; - timeoutMs?: number; - }; - const cfg = loadConfig(); - const workspaceDirRaw = - cfg.agent?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; - const result = await installSkill({ - workspaceDir: workspaceDirRaw, - skillName: p.name, - installId: p.installId, - timeoutMs: p.timeoutMs, - config: cfg, - }); - respond( - result.ok, - result, - result.ok - ? undefined - : errorShape(ErrorCodes.UNAVAILABLE, result.message), - ); - break; - } - case "skills.update": { - const params = (req.params ?? {}) as Record; - if (!validateSkillsUpdateParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid skills.update params: ${formatValidationErrors(validateSkillsUpdateParams.errors)}`, - ), - ); - break; - } - const p = params as { - skillKey: string; - enabled?: boolean; - apiKey?: string; - env?: Record; - }; - const cfg = loadConfig(); - const skills = cfg.skills ? { ...cfg.skills } : {}; - const entries = skills.entries ? { ...skills.entries } : {}; - const current = entries[p.skillKey] ? { ...entries[p.skillKey] } : {}; - if (typeof p.enabled === "boolean") { - current.enabled = p.enabled; - } - if (typeof p.apiKey === "string") { - const trimmed = p.apiKey.trim(); - if (trimmed) current.apiKey = trimmed; - else delete current.apiKey; - } - if (p.env && typeof p.env === "object") { - const nextEnv = current.env ? { ...current.env } : {}; - for (const [key, value] of Object.entries(p.env)) { - const trimmedKey = key.trim(); - if (!trimmedKey) continue; - const trimmedVal = value.trim(); - if (!trimmedVal) delete nextEnv[trimmedKey]; - else nextEnv[trimmedKey] = trimmedVal; - } - current.env = nextEnv; - } - entries[p.skillKey] = current; - skills.entries = entries; - const nextConfig: ClawdisConfig = { - ...cfg, - skills, - }; - await writeConfigFile(nextConfig); - respond( - true, - { ok: true, skillKey: p.skillKey, config: current }, - undefined, - ); - break; - } - case "sessions.list": { - const params = (req.params ?? {}) as Record; - if (!validateSessionsListParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid sessions.list params: ${formatValidationErrors(validateSessionsListParams.errors)}`, - ), - ); - break; - } - const p = params as SessionsListParams; - const cfg = loadConfig(); - const storePath = resolveStorePath(cfg.session?.store); - const store = loadSessionStore(storePath); - const result = listSessionsFromStore({ - cfg, - storePath, - store, - opts: p, - }); - respond(true, result, undefined); - break; - } - case "sessions.patch": { - const params = (req.params ?? {}) as Record; - if (!validateSessionsPatchParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid sessions.patch params: ${formatValidationErrors(validateSessionsPatchParams.errors)}`, - ), - ); - break; - } - const p = params as SessionsPatchParams; - const key = String(p.key ?? "").trim(); - if (!key) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "key required"), - ); - break; - } - - const cfg = loadConfig(); - const storePath = resolveStorePath(cfg.session?.store); - const store = loadSessionStore(storePath); - const now = Date.now(); - - const existing = store[key]; - const next: SessionEntry = existing - ? { - ...existing, - updatedAt: Math.max(existing.updatedAt ?? 0, now), - } - : { sessionId: randomUUID(), updatedAt: now }; - - if ("thinkingLevel" in p) { - const raw = p.thinkingLevel; - if (raw === null) { - delete next.thinkingLevel; - } else if (raw !== undefined) { - const normalized = normalizeThinkLevel(String(raw)); - if (!normalized) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "invalid thinkingLevel (use off|minimal|low|medium|high)", - ), - ); - break; - } - if (normalized === "off") delete next.thinkingLevel; - else next.thinkingLevel = normalized; - } - } - - if ("verboseLevel" in p) { - const raw = p.verboseLevel; - if (raw === null) { - delete next.verboseLevel; - } else if (raw !== undefined) { - const normalized = normalizeVerboseLevel(String(raw)); - if (!normalized) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - 'invalid verboseLevel (use "on"|"off")', - ), - ); - break; - } - if (normalized === "off") delete next.verboseLevel; - else next.verboseLevel = normalized; - } - } - - if ("model" in p) { - const raw = p.model; - if (raw === null) { - delete next.providerOverride; - delete next.modelOverride; - } else if (raw !== undefined) { - const trimmed = String(raw).trim(); - if (!trimmed) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "invalid model: empty"), - ); - break; - } - const resolvedDefault = resolveConfiguredModelRef({ - cfg, - defaultProvider: DEFAULT_PROVIDER, - defaultModel: DEFAULT_MODEL, - }); - const aliasIndex = buildModelAliasIndex({ - cfg, - defaultProvider: resolvedDefault.provider, - }); - const resolved = resolveModelRefFromString({ - raw: trimmed, - defaultProvider: resolvedDefault.provider, - aliasIndex, - }); - if (!resolved) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid model: ${trimmed}`, - ), - ); - break; - } - const catalog = await loadGatewayModelCatalog(); - const allowed = buildAllowedModelSet({ - cfg, - catalog, - defaultProvider: resolvedDefault.provider, - }); - const key = modelKey(resolved.ref.provider, resolved.ref.model); - if (!allowed.allowAny && !allowed.allowedKeys.has(key)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `model not allowed: ${key}`, - ), - ); - break; - } - if ( - resolved.ref.provider === resolvedDefault.provider && - resolved.ref.model === resolvedDefault.model - ) { - delete next.providerOverride; - delete next.modelOverride; - } else { - next.providerOverride = resolved.ref.provider; - next.modelOverride = resolved.ref.model; - } - } - } - - if ("sendPolicy" in p) { - const raw = p.sendPolicy; - if (raw === null) { - delete next.sendPolicy; - } else if (raw !== undefined) { - const normalized = normalizeSendPolicy(String(raw)); - if (!normalized) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - 'invalid sendPolicy (use "allow"|"deny")', - ), - ); - break; - } - next.sendPolicy = normalized; - } - } - - if ("groupActivation" in p) { - const raw = p.groupActivation; - if (raw === null) { - delete next.groupActivation; - } else if (raw !== undefined) { - const normalized = normalizeGroupActivation(String(raw)); - if (!normalized) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - 'invalid groupActivation (use "mention"|"always")', - ), - ); - break; - } - next.groupActivation = normalized; - } - } - - store[key] = next; - await saveSessionStore(storePath, store); - const result: SessionsPatchResult = { - ok: true, - path: storePath, - key, - entry: next, - }; - respond(true, result, undefined); - break; - } - case "sessions.reset": { - const params = (req.params ?? {}) as Record; - if (!validateSessionsResetParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid sessions.reset params: ${formatValidationErrors(validateSessionsResetParams.errors)}`, - ), - ); - break; - } - const p = params as SessionsResetParams; - const key = String(p.key ?? "").trim(); - if (!key) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "key required"), - ); - break; - } - - const { storePath, store, entry } = loadSessionEntry(key); - const now = Date.now(); - const next: SessionEntry = { - sessionId: randomUUID(), - updatedAt: now, - systemSent: false, - abortedLastRun: false, - thinkingLevel: entry?.thinkingLevel, - verboseLevel: entry?.verboseLevel, - model: entry?.model, - contextTokens: entry?.contextTokens, - sendPolicy: entry?.sendPolicy, - lastChannel: entry?.lastChannel, - lastTo: entry?.lastTo, - skillsSnapshot: entry?.skillsSnapshot, - }; - store[key] = next; - await saveSessionStore(storePath, store); - respond(true, { ok: true, key, entry: next }, undefined); - break; - } - case "sessions.delete": { - const params = (req.params ?? {}) as Record; - if (!validateSessionsDeleteParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid sessions.delete params: ${formatValidationErrors(validateSessionsDeleteParams.errors)}`, - ), - ); - break; - } - const p = params as SessionsDeleteParams; - const key = String(p.key ?? "").trim(); - if (!key) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "key required"), - ); - break; - } - - const mainKey = resolveMainSessionKey(loadConfig()); - if (key === mainKey) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `Cannot delete the main session (${mainKey}).`, - ), - ); - break; - } - - const deleteTranscript = - typeof p.deleteTranscript === "boolean" ? p.deleteTranscript : true; - - const { storePath, store, entry } = loadSessionEntry(key); - const sessionId = entry?.sessionId; - const existed = Boolean(store[key]); - clearCommandLane(resolveEmbeddedSessionLane(key)); - if (sessionId && isEmbeddedPiRunActive(sessionId)) { - abortEmbeddedPiRun(sessionId); - const ended = await waitForEmbeddedPiRunEnd(sessionId, 15_000); - if (!ended) { - respond( - false, - undefined, - errorShape( - ErrorCodes.UNAVAILABLE, - `Session ${key} is still active; try again in a moment.`, - ), - ); - break; - } - } - if (existed) delete store[key]; - await saveSessionStore(storePath, store); - - const archived: string[] = []; - if (deleteTranscript && sessionId) { - for (const candidate of resolveSessionTranscriptCandidates( - sessionId, - storePath, - )) { - if (!fs.existsSync(candidate)) continue; - try { - archived.push(archiveFileOnDisk(candidate, "deleted")); - } catch { - // Best-effort. - } - } - } - - respond(true, { ok: true, key, deleted: existed, archived }, undefined); - break; - } - case "sessions.compact": { - const params = (req.params ?? {}) as Record; - if (!validateSessionsCompactParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid sessions.compact params: ${formatValidationErrors(validateSessionsCompactParams.errors)}`, - ), - ); - break; - } - const p = params as SessionsCompactParams; - const key = String(p.key ?? "").trim(); - if (!key) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "key required"), - ); - break; - } - - const maxLines = - typeof p.maxLines === "number" && Number.isFinite(p.maxLines) - ? Math.max(1, Math.floor(p.maxLines)) - : 400; - - const { storePath, store, entry } = loadSessionEntry(key); - const sessionId = entry?.sessionId; - if (!sessionId) { - respond( - true, - { ok: true, key, compacted: false, reason: "no sessionId" }, - undefined, - ); - break; - } - - const filePath = resolveSessionTranscriptCandidates( - sessionId, - storePath, - ).find((candidate) => fs.existsSync(candidate)); - if (!filePath) { - respond( - true, - { ok: true, key, compacted: false, reason: "no transcript" }, - undefined, - ); - break; - } - - const raw = fs.readFileSync(filePath, "utf-8"); - const lines = raw.split(/\r?\n/).filter((l) => l.trim().length > 0); - if (lines.length <= maxLines) { - respond( - true, - { ok: true, key, compacted: false, kept: lines.length }, - undefined, - ); - break; - } - - const archived = archiveFileOnDisk(filePath, "bak"); - const keptLines = lines.slice(-maxLines); - fs.writeFileSync(filePath, `${keptLines.join("\n")}\n`, "utf-8"); - - if (store[key]) { - delete store[key].inputTokens; - delete store[key].outputTokens; - delete store[key].totalTokens; - store[key].updatedAt = Date.now(); - await saveSessionStore(storePath, store); - } - - respond( - true, - { - ok: true, - key, - compacted: true, - archived, - kept: keptLines.length, - }, - undefined, - ); - break; - } - case "last-heartbeat": { - respond(true, getLastHeartbeatEvent(), undefined); - break; - } - case "set-heartbeats": { - const params = (req.params ?? {}) as Record; - const enabled = params.enabled; - if (typeof enabled !== "boolean") { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "invalid set-heartbeats params: enabled (boolean) required", - ), - ); - break; - } - setHeartbeatsEnabled(enabled); - respond(true, { ok: true, enabled }, undefined); - break; - } - case "system-presence": { - const presence = listSystemPresence(); - respond(true, presence, undefined); - break; - } - case "system-event": { - const params = (req.params ?? {}) as Record; - const text = typeof params.text === "string" ? params.text.trim() : ""; - if (!text) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "text required"), - ); - break; - } - const instanceId = - typeof params.instanceId === "string" ? params.instanceId : undefined; - const host = typeof params.host === "string" ? params.host : undefined; - const ip = typeof params.ip === "string" ? params.ip : undefined; - const mode = typeof params.mode === "string" ? params.mode : undefined; - const version = - typeof params.version === "string" ? params.version : undefined; - const platform = - typeof params.platform === "string" ? params.platform : undefined; - const deviceFamily = - typeof params.deviceFamily === "string" - ? params.deviceFamily - : undefined; - const modelIdentifier = - typeof params.modelIdentifier === "string" - ? params.modelIdentifier - : undefined; - const lastInputSeconds = - typeof params.lastInputSeconds === "number" && - Number.isFinite(params.lastInputSeconds) - ? params.lastInputSeconds - : undefined; - const reason = - typeof params.reason === "string" ? params.reason : undefined; - const tags = - Array.isArray(params.tags) && - params.tags.every((t) => typeof t === "string") - ? (params.tags as string[]) - : undefined; - const presenceUpdate = updateSystemPresence({ - text, - instanceId, - host, - ip, - mode, - version, - platform, - deviceFamily, - modelIdentifier, - lastInputSeconds, - reason, - tags, - }); - const isNodePresenceLine = text.startsWith("Node:"); - if (isNodePresenceLine) { - const next = presenceUpdate.next; - const changed = new Set(presenceUpdate.changedKeys); - const reasonValue = next.reason ?? reason; - const normalizedReason = (reasonValue ?? "").toLowerCase(); - const ignoreReason = - normalizedReason.startsWith("periodic") || - normalizedReason === "heartbeat"; - const hostChanged = changed.has("host"); - const ipChanged = changed.has("ip"); - const versionChanged = changed.has("version"); - const modeChanged = changed.has("mode"); - const reasonChanged = changed.has("reason") && !ignoreReason; - const hasChanges = - hostChanged || - ipChanged || - versionChanged || - modeChanged || - reasonChanged; - if (hasChanges) { - const contextChanged = isSystemEventContextChanged( - presenceUpdate.key, - ); - const parts: string[] = []; - if (contextChanged || hostChanged || ipChanged) { - const hostLabel = next.host?.trim() || "Unknown"; - const ipLabel = next.ip?.trim(); - parts.push(`Node: ${hostLabel}${ipLabel ? ` (${ipLabel})` : ""}`); - } - if (versionChanged) { - parts.push(`app ${next.version?.trim() || "unknown"}`); - } - if (modeChanged) { - parts.push(`mode ${next.mode?.trim() || "unknown"}`); - } - if (reasonChanged) { - parts.push(`reason ${reasonValue?.trim() || "event"}`); - } - const deltaText = parts.join(" ยท "); - if (deltaText) { - enqueueSystemEvent(deltaText, { - contextKey: presenceUpdate.key, - }); - } - } - } else { - enqueueSystemEvent(text); - } - const nextPresenceVersion = incrementPresenceVersion(); - broadcast( - "presence", - { presence: listSystemPresence() }, - { - dropIfSlow: true, - stateVersion: { - presence: nextPresenceVersion, - health: getHealthVersion(), - }, - }, - ); - respond(true, { ok: true }, undefined); - break; - } - case "node.pair.request": { - const params = (req.params ?? {}) as Record; - if (!validateNodePairRequestParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid node.pair.request params: ${formatValidationErrors(validateNodePairRequestParams.errors)}`, - ), - ); - break; - } - const p = params as { - nodeId: string; - displayName?: string; - platform?: string; - version?: string; - deviceFamily?: string; - modelIdentifier?: string; - caps?: string[]; - commands?: string[]; - remoteIp?: string; - silent?: boolean; - }; - try { - const result = await requestNodePairing({ - nodeId: p.nodeId, - displayName: p.displayName, - platform: p.platform, - version: p.version, - deviceFamily: p.deviceFamily, - modelIdentifier: p.modelIdentifier, - caps: p.caps, - commands: p.commands, - remoteIp: p.remoteIp, - silent: p.silent, - }); - if (result.status === "pending" && result.created) { - broadcast("node.pair.requested", result.request, { - dropIfSlow: true, - }); - } - respond(true, result, undefined); - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), - ); - } - break; - } - case "node.pair.list": { - const params = (req.params ?? {}) as Record; - if (!validateNodePairListParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid node.pair.list params: ${formatValidationErrors(validateNodePairListParams.errors)}`, - ), - ); - break; - } - try { - const list = await listNodePairing(); - respond(true, list, undefined); - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), - ); - } - break; - } - case "node.pair.approve": { - const params = (req.params ?? {}) as Record; - if (!validateNodePairApproveParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid node.pair.approve params: ${formatValidationErrors(validateNodePairApproveParams.errors)}`, - ), - ); - break; - } - const { requestId } = params as { requestId: string }; - try { - const approved = await approveNodePairing(requestId); - if (!approved) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"), - ); - break; - } - broadcast( - "node.pair.resolved", - { - requestId, - nodeId: approved.node.nodeId, - decision: "approved", - ts: Date.now(), - }, - { dropIfSlow: true }, - ); - respond(true, approved, undefined); - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), - ); - } - break; - } - case "node.pair.reject": { - const params = (req.params ?? {}) as Record; - if (!validateNodePairRejectParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid node.pair.reject params: ${formatValidationErrors(validateNodePairRejectParams.errors)}`, - ), - ); - break; - } - const { requestId } = params as { requestId: string }; - try { - const rejected = await rejectNodePairing(requestId); - if (!rejected) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"), - ); - break; - } - broadcast( - "node.pair.resolved", - { - requestId, - nodeId: rejected.nodeId, - decision: "rejected", - ts: Date.now(), - }, - { dropIfSlow: true }, - ); - respond(true, rejected, undefined); - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), - ); - } - break; - } - case "node.pair.verify": { - const params = (req.params ?? {}) as Record; - if (!validateNodePairVerifyParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid node.pair.verify params: ${formatValidationErrors(validateNodePairVerifyParams.errors)}`, - ), - ); - break; - } - const { nodeId, token } = params as { - nodeId: string; - token: string; - }; - try { - const result = await verifyNodeToken(nodeId, token); - respond(true, result, undefined); - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), - ); - } - break; - } - case "node.rename": { - const params = (req.params ?? {}) as Record; - if (!validateNodeRenameParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid node.rename params: ${formatValidationErrors(validateNodeRenameParams.errors)}`, - ), - ); - break; - } - const { nodeId, displayName } = params as { - nodeId: string; - displayName: string; - }; - try { - const trimmed = displayName.trim(); - if (!trimmed) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "displayName required"), - ); - break; - } - const updated = await renamePairedNode(nodeId, trimmed); - if (!updated) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "unknown nodeId"), - ); - break; - } - respond( - true, - { nodeId: updated.nodeId, displayName: updated.displayName }, - undefined, - ); - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), - ); - } - break; - } - case "node.list": { - const params = (req.params ?? {}) as Record; - if (!validateNodeListParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid node.list params: ${formatValidationErrors(validateNodeListParams.errors)}`, - ), - ); - break; - } - - try { - const list = await listNodePairing(); - const pairedById = new Map(list.paired.map((n) => [n.nodeId, n])); - - const connected = bridge?.listConnected?.() ?? []; - const connectedById = new Map(connected.map((n) => [n.nodeId, n])); - - const nodeIds = new Set([ - ...pairedById.keys(), - ...connectedById.keys(), - ]); - - const nodes = [...nodeIds].map((nodeId) => { - const paired = pairedById.get(nodeId); - const live = connectedById.get(nodeId); - - const caps = [ - ...new Set( - (live?.caps ?? paired?.caps ?? []) - .map((c) => String(c).trim()) - .filter(Boolean), - ), - ].sort(); - - const commands = [ - ...new Set( - (live?.commands ?? paired?.commands ?? []) - .map((c) => String(c).trim()) - .filter(Boolean), - ), - ].sort(); - - return { - nodeId, - displayName: live?.displayName ?? paired?.displayName, - platform: live?.platform ?? paired?.platform, - version: live?.version ?? paired?.version, - deviceFamily: live?.deviceFamily ?? paired?.deviceFamily, - modelIdentifier: live?.modelIdentifier ?? paired?.modelIdentifier, - remoteIp: live?.remoteIp ?? paired?.remoteIp, - caps, - commands, - permissions: live?.permissions ?? paired?.permissions, - paired: Boolean(paired), - connected: Boolean(live), - }; - }); - - nodes.sort((a, b) => { - if (a.connected !== b.connected) return a.connected ? -1 : 1; - const an = (a.displayName ?? a.nodeId).toLowerCase(); - const bn = (b.displayName ?? b.nodeId).toLowerCase(); - if (an < bn) return -1; - if (an > bn) return 1; - return a.nodeId.localeCompare(b.nodeId); - }); - - respond(true, { ts: Date.now(), nodes }, undefined); - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), - ); - } - break; - } - case "node.describe": { - const params = (req.params ?? {}) as Record; - if (!validateNodeDescribeParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid node.describe params: ${formatValidationErrors(validateNodeDescribeParams.errors)}`, - ), - ); - break; - } - const { nodeId } = params as { nodeId: string }; - const id = String(nodeId ?? "").trim(); - if (!id) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required"), - ); - break; - } - - try { - const list = await listNodePairing(); - const paired = list.paired.find((n) => n.nodeId === id); - const connected = bridge?.listConnected?.() ?? []; - const live = connected.find((n) => n.nodeId === id); - - if (!paired && !live) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "unknown nodeId"), - ); - break; - } - - const caps = [ - ...new Set( - (live?.caps ?? paired?.caps ?? []) - .map((c) => String(c).trim()) - .filter(Boolean), - ), - ].sort(); - - const commands = [ - ...new Set( - (live?.commands ?? paired?.commands ?? []) - .map((c) => String(c).trim()) - .filter(Boolean), - ), - ].sort(); - - respond( - true, - { - ts: Date.now(), - nodeId: id, - displayName: live?.displayName ?? paired?.displayName, - platform: live?.platform ?? paired?.platform, - version: live?.version ?? paired?.version, - deviceFamily: live?.deviceFamily ?? paired?.deviceFamily, - modelIdentifier: live?.modelIdentifier ?? paired?.modelIdentifier, - remoteIp: live?.remoteIp ?? paired?.remoteIp, - caps, - commands, - permissions: live?.permissions ?? paired?.permissions, - paired: Boolean(paired), - connected: Boolean(live), - }, - undefined, - ); - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), - ); - } - break; - } - case "node.invoke": { - const params = (req.params ?? {}) as Record; - if (!validateNodeInvokeParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid node.invoke params: ${formatValidationErrors(validateNodeInvokeParams.errors)}`, - ), - ); - break; - } - if (!bridge) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, "bridge not running"), - ); - break; - } - const p = params as { - nodeId: string; - command: string; - params?: unknown; - timeoutMs?: number; - idempotencyKey: string; - }; - const nodeId = String(p.nodeId ?? "").trim(); - const command = String(p.command ?? "").trim(); - if (!nodeId || !command) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "nodeId and command required"), - ); - break; - } - - try { - const paramsJSON = - "params" in p && p.params !== undefined - ? JSON.stringify(p.params) - : null; - const res = await bridge.invoke({ - nodeId, - command, - paramsJSON, - timeoutMs: p.timeoutMs, - }); - if (!res.ok) { - respond( - false, - undefined, - errorShape( - ErrorCodes.UNAVAILABLE, - res.error?.message ?? "node invoke failed", - { details: { nodeError: res.error ?? null } }, - ), - ); - break; - } - const payload = - typeof res.payloadJSON === "string" && res.payloadJSON.trim() - ? (() => { - try { - return JSON.parse(res.payloadJSON) as unknown; - } catch { - return { payloadJSON: res.payloadJSON }; - } - })() - : undefined; - respond( - true, - { - ok: true, - nodeId, - command, - payload, - payloadJSON: res.payloadJSON ?? null, - }, - undefined, - ); - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), - ); - } - break; - } - case "send": { - const p = (req.params ?? {}) as Record; - if (!validateSendParams(p)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid send params: ${formatValidationErrors(validateSendParams.errors)}`, - ), - ); - break; - } - const params = p as { - to: string; - message: string; - mediaUrl?: string; - gifPlayback?: boolean; - provider?: string; - idempotencyKey: string; - }; - const idem = params.idempotencyKey; - const cached = dedupe.get(`send:${idem}`); - if (cached) { - respond(cached.ok, cached.payload, cached.error, { - cached: true, - }); - break; - } - const to = params.to.trim(); - const message = params.message.trim(); - const providerRaw = (params.provider ?? "whatsapp").toLowerCase(); - const provider = providerRaw === "imsg" ? "imessage" : providerRaw; - try { - if (provider === "telegram") { - const cfg = loadConfig(); - const { token } = resolveTelegramToken(cfg); - const result = await sendMessageTelegram(to, message, { - mediaUrl: params.mediaUrl, - verbose: shouldLogVerbose(), - token: token || undefined, - }); - const payload = { - runId: idem, - messageId: result.messageId, - chatId: result.chatId, - provider, - }; - dedupe.set(`send:${idem}`, { - ts: Date.now(), - ok: true, - payload, - }); - respond(true, payload, undefined, { provider }); - } else if (provider === "discord") { - const result = await sendMessageDiscord(to, message, { - mediaUrl: params.mediaUrl, - token: process.env.DISCORD_BOT_TOKEN, - }); - const payload = { - runId: idem, - messageId: result.messageId, - channelId: result.channelId, - provider, - }; - dedupe.set(`send:${idem}`, { - ts: Date.now(), - ok: true, - payload, - }); - respond(true, payload, undefined, { provider }); - } else if (provider === "signal") { - const cfg = loadConfig(); - const host = cfg.signal?.httpHost?.trim() || "127.0.0.1"; - const port = cfg.signal?.httpPort ?? 8080; - const baseUrl = - cfg.signal?.httpUrl?.trim() || `http://${host}:${port}`; - const result = await sendMessageSignal(to, message, { - mediaUrl: params.mediaUrl, - baseUrl, - account: cfg.signal?.account, - }); - const payload = { - runId: idem, - messageId: result.messageId, - provider, - }; - dedupe.set(`send:${idem}`, { - ts: Date.now(), - ok: true, - payload, - }); - respond(true, payload, undefined, { provider }); - } else if (provider === "imessage") { - const cfg = loadConfig(); - const result = await sendMessageIMessage(to, message, { - mediaUrl: params.mediaUrl, - cliPath: cfg.imessage?.cliPath, - dbPath: cfg.imessage?.dbPath, - maxBytes: cfg.imessage?.mediaMaxMb - ? cfg.imessage.mediaMaxMb * 1024 * 1024 - : undefined, - }); - const payload = { - runId: idem, - messageId: result.messageId, - provider, - }; - dedupe.set(`send:${idem}`, { - ts: Date.now(), - ok: true, - payload, - }); - respond(true, payload, undefined, { provider }); - } else { - const result = await sendMessageWhatsApp(to, message, { - mediaUrl: params.mediaUrl, - verbose: shouldLogVerbose(), - gifPlayback: params.gifPlayback, - }); - const payload = { - runId: idem, - messageId: result.messageId, - toJid: result.toJid ?? `${to}@s.whatsapp.net`, - provider, - }; - dedupe.set(`send:${idem}`, { - ts: Date.now(), - ok: true, - payload, - }); - respond(true, payload, undefined, { provider }); - } - } catch (err) { - const error = errorShape(ErrorCodes.UNAVAILABLE, String(err)); - dedupe.set(`send:${idem}`, { - ts: Date.now(), - ok: false, - error, - }); - respond(false, undefined, error, { - provider, - error: formatForLog(err), - }); - } - break; - } - case "agent": { - const p = (req.params ?? {}) as Record; - if (!validateAgentParams(p)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid agent params: ${formatValidationErrors(validateAgentParams.errors)}`, - ), - ); - break; - } - const params = p as { - message: string; - to?: string; - sessionId?: string; - sessionKey?: string; - thinking?: string; - deliver?: boolean; - channel?: string; - lane?: string; - extraSystemPrompt?: string; - idempotencyKey: string; - timeout?: number; - }; - const idem = params.idempotencyKey; - const cached = dedupe.get(`agent:${idem}`); - if (cached) { - respond(cached.ok, cached.payload, cached.error, { - cached: true, - }); - break; - } - const message = params.message.trim(); - - const requestedSessionKey = - typeof params.sessionKey === "string" && params.sessionKey.trim() - ? params.sessionKey.trim() - : undefined; - let resolvedSessionId = params.sessionId?.trim() || undefined; - let sessionEntry: SessionEntry | undefined; - let bestEffortDeliver = false; - let cfgForAgent: ReturnType | undefined; - - if (requestedSessionKey) { - const { cfg, storePath, store, entry } = - loadSessionEntry(requestedSessionKey); - cfgForAgent = cfg; - const now = Date.now(); - const sessionId = entry?.sessionId ?? randomUUID(); - sessionEntry = { - sessionId, - updatedAt: now, - thinkingLevel: entry?.thinkingLevel, - verboseLevel: entry?.verboseLevel, - systemSent: entry?.systemSent, - sendPolicy: entry?.sendPolicy, - skillsSnapshot: entry?.skillsSnapshot, - lastChannel: entry?.lastChannel, - lastTo: entry?.lastTo, - }; - const sendPolicy = resolveSendPolicy({ - cfg, - entry, - sessionKey: requestedSessionKey, - surface: entry?.surface, - chatType: entry?.chatType, - }); - if (sendPolicy === "deny") { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "send blocked by session policy", - ), - ); - break; - } - if (store) { - store[requestedSessionKey] = sessionEntry; - if (storePath) { - await saveSessionStore(storePath, store); - } - } - resolvedSessionId = sessionId; - const mainKey = (cfg.session?.mainKey ?? "main").trim() || "main"; - if (requestedSessionKey === mainKey) { - addChatRun(idem, { - sessionKey: requestedSessionKey, - clientRunId: idem, - }); - bestEffortDeliver = true; - } - registerAgentRunContext(idem, { sessionKey: requestedSessionKey }); - } - - const runId = idem; - - const requestedChannelRaw = - typeof params.channel === "string" ? params.channel.trim() : ""; - const requestedChannelNormalized = requestedChannelRaw - ? requestedChannelRaw.toLowerCase() - : "last"; - const requestedChannel = - requestedChannelNormalized === "imsg" - ? "imessage" - : requestedChannelNormalized; - - const lastChannel = sessionEntry?.lastChannel; - const lastTo = - typeof sessionEntry?.lastTo === "string" - ? sessionEntry.lastTo.trim() - : ""; - - const resolvedChannel = (() => { - if (requestedChannel === "last") { - // WebChat is not a deliverable surface. Treat it as "unset" for routing, - // so VoiceWake and CLI callers don't get stuck with deliver=false. - return lastChannel && lastChannel !== "webchat" - ? lastChannel - : "whatsapp"; - } - if ( - requestedChannel === "whatsapp" || - requestedChannel === "telegram" || - requestedChannel === "discord" || - requestedChannel === "signal" || - requestedChannel === "imessage" || - requestedChannel === "webchat" - ) { - return requestedChannel; - } - return lastChannel && lastChannel !== "webchat" - ? lastChannel - : "whatsapp"; - })(); - - const resolvedTo = (() => { - const explicit = - typeof params.to === "string" && params.to.trim() - ? params.to.trim() - : undefined; - if (explicit) return explicit; - if ( - resolvedChannel === "whatsapp" || - resolvedChannel === "telegram" || - resolvedChannel === "discord" || - resolvedChannel === "signal" || - resolvedChannel === "imessage" - ) { - return lastTo || undefined; - } - return undefined; - })(); - - const sanitizedTo = (() => { - // If we derived a WhatsApp recipient from session "lastTo", ensure it is still valid - // for the configured allowlist. Otherwise, fall back to the first allowed number so - // voice wake doesn't silently route to stale/test recipients. - if (resolvedChannel !== "whatsapp") return resolvedTo; - const explicit = - typeof params.to === "string" && params.to.trim() - ? params.to.trim() - : undefined; - if (explicit) return resolvedTo; - - const cfg = cfgForAgent ?? loadConfig(); - const rawAllow = cfg.whatsapp?.allowFrom ?? []; - if (rawAllow.includes("*")) return resolvedTo; - const allowFrom = rawAllow - .map((val) => normalizeE164(val)) - .filter((val) => val.length > 1); - if (allowFrom.length === 0) return resolvedTo; - - const normalizedLast = - typeof resolvedTo === "string" && resolvedTo.trim() - ? normalizeE164(resolvedTo) - : undefined; - if (normalizedLast && allowFrom.includes(normalizedLast)) { - return normalizedLast; - } - return allowFrom[0]; - })(); - - const deliver = params.deliver === true && resolvedChannel !== "webchat"; - - const accepted = { - runId, - status: "accepted" as const, - acceptedAt: Date.now(), - }; - // Store an in-flight ack so retries do not spawn a second run. - dedupe.set(`agent:${idem}`, { - ts: Date.now(), - ok: true, - payload: accepted, - }); - respond(true, accepted, undefined, { runId }); - - void agentCommand( - { - message, - to: sanitizedTo, - sessionId: resolvedSessionId, - thinking: params.thinking, - deliver, - provider: resolvedChannel, - timeout: params.timeout?.toString(), - bestEffortDeliver, - surface: "VoiceWake", - runId, - lane: params.lane, - extraSystemPrompt: params.extraSystemPrompt, - }, - defaultRuntime, - deps, - ) - .then(() => { - const payload = { - runId, - status: "ok" as const, - summary: "completed", - }; - dedupe.set(`agent:${idem}`, { - ts: Date.now(), - ok: true, - payload, - }); - // Send a second res frame (same id) so TS clients with expectFinal can wait. - // Swift clients will typically treat the first res as the result and ignore this. - respond(true, payload, undefined, { runId }); - }) - .catch((err) => { - const error = errorShape(ErrorCodes.UNAVAILABLE, String(err)); - const payload = { - runId, - status: "error" as const, - summary: String(err), - }; - dedupe.set(`agent:${idem}`, { - ts: Date.now(), - ok: false, - payload, - error, - }); - respond(false, payload, error, { - runId, - error: formatForLog(err), - }); - }); - break; - } - case "agent.wait": { - const params = (req.params ?? {}) as Record; - if (!validateAgentWaitParams(params)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `invalid agent.wait params: ${formatValidationErrors(validateAgentWaitParams.errors)}`, - ), - ); - break; - } - const p = params as AgentWaitParams; - const runId = p.runId.trim(); - const afterMs = - typeof p.afterMs === "number" && Number.isFinite(p.afterMs) - ? Math.max(0, Math.floor(p.afterMs)) - : undefined; - const timeoutMs = - typeof p.timeoutMs === "number" && Number.isFinite(p.timeoutMs) - ? Math.max(0, Math.floor(p.timeoutMs)) - : 30_000; - - const snapshot = await waitForAgentJob({ - runId, - afterMs, - timeoutMs, - }); - if (!snapshot) { - respond(true, { - runId, - status: "timeout", - }); - break; - } - respond(true, { - runId, - status: snapshot.state === "done" ? "ok" : "error", - startedAt: snapshot.startedAt, - endedAt: snapshot.endedAt, - error: snapshot.error, - }); - break; - } - default: { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, `unknown method: ${req.method}`), - ); - break; - } + const handler = handlers[req.method]; + if (!handler) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `unknown method: ${req.method}`), + ); + return; } + await handler({ + req, + params: (req.params ?? {}) as Record, + client, + isWebchatConnect, + respond, + context, + }); }