diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c9a516d3..31153806f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,29 +9,24 @@ - Sessions: primary session key is fixed to `main` (or `global` for global scope); `session.mainKey` is ignored. ### Features -- Highlight: agent-to-agent ping-pong (reply-back loop) with `REPLY_SKIP` plus target announce step with `ANNOUNCE_SKIP` (max turns configurable, 0–5). - Gateway: support `gateway.port` + `CLAWDIS_GATEWAY_PORT` across CLI, TUI, and macOS app. - Gateway: add config hot reload with hybrid restart strategy (`gateway.reload`) and per-section reload handling. - UI: centralize tool display metadata and show action/detail summaries across Web Chat, SwiftUI, Android, and the TUI. - Control UI: support configurable base paths (`gateway.controlUi.basePath`, default unchanged) for hosting under URL prefixes. - Onboarding: shared wizard engine powering CLI + macOS via gateway wizard RPC. - Config: expose schema + UI hints for generic config forms (Web UI + future clients). -- Browser: add multi-profile browser control with per-profile remote CDP URLs — thanks @jamesgroat. - Skills: add blogwatcher skill for RSS/Atom monitoring — thanks @Hyaxia. -- Skills: add Notion API skill — thanks @scald. - Discord: emit system events for reaction add/remove with per-guild reaction notifications (off|own|all|allowlist) (#140) — thanks @thewilloftheshadow. -- Slack: add socket-mode connector, tools, and UI/docs updates (#170) — thanks @thewilloftheshadow. - Agent: add optional per-session Docker sandbox for tool execution (`agent.sandbox`) with allow/deny policy and auto-pruning. - Agent: add sandboxed Chromium browser (CDP + optional noVNC observer) for sandboxed sessions. - Nodes: add `location.get` with Always/Precise settings on macOS/iOS/Android plus CLI/tool support. -- Android nodes: add `sms.send` with permission-gated capability refresh (#172) — thanks @vsabavat. +- Sessions: add agent‑to‑agent post step with `ANNOUNCE_SKIP` to suppress channel announcements. ### Fixes -- macOS: improve Swift 6 strict concurrency compatibility (#166) — thanks @Nachx639. +- Gateway/macOS: keep node presence fresh with periodic beacons + show presence status in Instances (#168) — thanks @mbelinky. - CI: fix lint ordering after merge cleanup (#156) — thanks @steipete. - CI: consolidate checks to avoid redundant installs (#144) — thanks @thewilloftheshadow. - WhatsApp: support `gifPlayback` for MP4 GIF sends via CLI/gateway. -- Gateway: log config hot reloads for dynamic-read changes without restarts. - Sessions: prevent `sessions_send` timeouts by running nested agent turns on a separate lane. - Sessions: use per-send run IDs for gateway agent calls to avoid wait collisions. - Auto-reply: drop final payloads when block streaming to avoid duplicate Discord sends. @@ -64,7 +59,6 @@ - Build: drop stale ClawdisCLI product from macOS build-and-run script. - Auto-reply: add run-level telemetry + typing TTL guardrails to diagnose stuck replies. - WhatsApp: honor per-group mention gating overrides when group ids are stored as session keys. -- Slack: add missing deps and wire Slack into cron/heartbeat/hook delivery. - Dependencies: bump pi-mono packages to 0.32.3. ### Docs @@ -79,8 +73,7 @@ - Queue: clarify steer-backlog behavior with inline commands and update examples for streaming surfaces. - Sandbox: document per-session agent sandbox setup, browser image, and Docker build. - macOS: clarify menu bar uses sessionKey from agent events. -- Sessions: document agent-to-agent reply loop (`REPLY_SKIP`) and announce step (`ANNOUNCE_SKIP`). -- Skills: clarify wacli third-party messaging scope and JID format examples. +- Sessions: document agent-to-agent post step and `ANNOUNCE_SKIP`. ## 2.0.0-beta5 — 2026-01-03 diff --git a/apps/macos/Sources/Clawdis/InstancesSettings.swift b/apps/macos/Sources/Clawdis/InstancesSettings.swift index aa748ad28..2b8e8fcac 100644 --- a/apps/macos/Sources/Clawdis/InstancesSettings.swift +++ b/apps/macos/Sources/Clawdis/InstancesSettings.swift @@ -73,6 +73,7 @@ struct InstancesSettings: View { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 8) { Text(inst.host ?? "unknown host").font(.subheadline.bold()) + self.presenceIndicator(inst) if let ip = inst.ip { Text("(") + Text(ip).monospaced() + Text(")") } } @@ -146,6 +147,29 @@ struct InstancesSettings: View { .font(.footnote) } + private func presenceIndicator(_ inst: InstanceInfo) -> some View { + let status = self.presenceStatus(for: inst) + return HStack(spacing: 4) { + Circle() + .fill(status.color) + .frame(width: 6, height: 6) + .accessibilityHidden(true) + Text(status.label) + .foregroundStyle(.secondary) + } + .font(.caption) + .help("Presence updated \(inst.ageDescription).") + .accessibilityLabel("\(status.label) presence") + } + + private func presenceStatus(for inst: InstanceInfo) -> (label: String, color: Color) { + let nowMs = Date().timeIntervalSince1970 * 1000 + let ageSeconds = max(0, Int((nowMs - inst.ts) / 1000)) + if ageSeconds <= 120 { return ("Active", .green) } + if ageSeconds <= 300 { return ("Idle", .yellow) } + return ("Stale", .gray) + } + @ViewBuilder private func leadingDeviceIcon(_ inst: InstanceInfo, device: DevicePresentation?) -> some View { let symbol = self.leadingDeviceSymbol(inst, device: device) @@ -307,6 +331,10 @@ struct InstancesSettings: View { return "Connect" case "disconnect": return "Disconnect" + case "node-connected": + return "Node connect" + case "node-disconnected": + return "Node disconnect" case "launch": return "Launch" case "periodic": diff --git a/docs/ios/connect.md b/docs/ios/connect.md index 308da0a63..5f8008af9 100644 --- a/docs/ios/connect.md +++ b/docs/ios/connect.md @@ -100,7 +100,7 @@ Pairing details: `docs/gateway/pairing.md`. ## 5) Verify the node is connected -- In the macOS app: **Instances** tab should show something like `iOS Node (...)`. +- In the macOS app: **Instances** tab should show something like `iOS Node (...)` with a green “Active” presence dot shortly after connect. - Via nodes status (paired + connected): ```bash clawdis nodes status diff --git a/docs/presence.md b/docs/presence.md index 01081387b..31946ccad 100644 --- a/docs/presence.md +++ b/docs/presence.md @@ -24,7 +24,7 @@ Presence entries are structured objects with (some) fields: - `modelIdentifier` (optional): hardware model identifier like `iPad16,6` or `Mac16,6` - `mode`: e.g. `gateway`, `app`, `webchat`, `cli` - `lastInputSeconds` (optional): “seconds since last user input” for that client machine -- `reason`: a short marker like `self`, `connect`, `periodic`, `instances-refresh` +- `reason`: a short marker like `self`, `connect`, `node-connected`, `node-disconnected`, `periodic`, `instances-refresh` - `text`: legacy/debug summary string (kept for backwards compatibility and UI display) - `ts`: last update timestamp (ms since epoch) @@ -61,6 +61,16 @@ Implementation: - Gateway: `src/gateway/server.ts` handles method `system-event` by calling `updateSystemPresence(...)`. - mac app beaconing: `apps/macos/Sources/Clawdis/PresenceReporter.swift`. +### 4) Node bridge beacons (gateway-owned presence) + +When a node bridge connection authenticates, the Gateway emits a presence entry +for that node and starts periodic refresh beacons so it does not expire. + +- Connect/disconnect markers: `node-connected`, `node-disconnected` +- Periodic heartbeat: every 3 minutes (`reason: periodic`) + +Implementation: `src/gateway/server.ts` (node bridge handlers + timer beacons). + ## Merge + dedupe rules (why `instanceId` matters) All producers write into a single in-memory presence map. @@ -109,6 +119,9 @@ Implementation: - View: `apps/macos/Sources/Clawdis/InstancesSettings.swift` - Store: `apps/macos/Sources/Clawdis/InstancesStore.swift` +The Instances rows show a small presence indicator (Active/Idle/Stale) based on +the last beacon age. The label is derived from the entry timestamp (`ts`). + The store refreshes periodically and also applies `presence` WS events. ## Debugging tips diff --git a/skills/coding-agent/SKILL.md b/skills/coding-agent/SKILL.md index 46bd20d39..9edb46691 100644 --- a/skills/coding-agent/SKILL.md +++ b/skills/coding-agent/SKILL.md @@ -212,7 +212,7 @@ git worktree remove /tmp/issue-99 When submitting PRs to external repos, use this format for quality & maintainer-friendliness: -```markdown +````markdown ## Original Prompt [Exact request/problem statement] @@ -228,7 +228,7 @@ When submitting PRs to external repos, use this format for quality & maintainer- # Example command example ``` -``` +```` ## Feature intent (maintainer-friendly) [Why useful, how it fits, workflows it enables] diff --git a/src/agents/clawdis-tools.sessions.test.ts b/src/agents/clawdis-tools.sessions.test.ts index 94376b95d..e45846b14 100644 --- a/src/agents/clawdis-tools.sessions.test.ts +++ b/src/agents/clawdis-tools.sessions.test.ts @@ -7,11 +7,7 @@ vi.mock("../gateway/call.js", () => ({ vi.mock("../config/config.js", () => ({ loadConfig: () => ({ - session: { - mainKey: "main", - scope: "per-sender", - agentToAgent: { maxPingPongTurns: 2 }, - }, + session: { mainKey: "main", scope: "per-sender" }, }), resolveGatewayPort: () => 18789, })); @@ -131,28 +127,18 @@ describe("sessions tools", () => { let agentCallCount = 0; let _historyCallCount = 0; let sendCallCount = 0; - let lastWaitedRunId: string | undefined; - const replyByRunId = new Map(); - const requesterKey = "discord:group:req"; + let waitRunId: string | undefined; + let nextHistoryIsWaitReply = false; 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 } - | 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"; + const params = request.params as { message?: string } | undefined; + if (params?.message === "wait") { + waitRunId = runId; } - replyByRunId.set(runId, reply); return { runId, status: "accepted", @@ -161,13 +147,15 @@ describe("sessions tools", () => { } if (request.method === "agent.wait") { const params = request.params as { runId?: string } | undefined; - lastWaitedRunId = params?.runId; + if (params?.runId && params.runId === waitRunId) { + nextHistoryIsWaitReply = true; + } return { runId: params?.runId ?? "run-1", status: "ok" }; } if (request.method === "chat.history") { _historyCallCount += 1; - const text = - (lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? ""; + const text = nextHistoryIsWaitReply ? "done" : "ANNOUNCE_SKIP"; + nextHistoryIsWaitReply = false; return { messages: [ { @@ -190,10 +178,9 @@ describe("sessions tools", () => { return {}; }); - const tool = createClawdisTools({ - agentSessionKey: requesterKey, - agentSurface: "discord", - }).find((candidate) => candidate.name === "sessions_send"); + const tool = createClawdisTools().find( + (candidate) => candidate.name === "sessions_send", + ); expect(tool).toBeDefined(); if (!tool) throw new Error("missing sessions_send tool"); @@ -204,7 +191,6 @@ 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", @@ -218,14 +204,13 @@ 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(8); + expect(agentCalls).toHaveLength(4); for (const call of agentCalls) { expect(call.params).toMatchObject({ lane: "nested" }); } @@ -246,21 +231,11 @@ describe("sessions tools", () => { ?.extraSystemPrompt === "string" && ( call.params as { extraSystemPrompt?: string } - )?.extraSystemPrompt?.includes("Agent-to-agent reply step"), + )?.extraSystemPrompt?.includes("Agent-to-agent post step"), ), ).toBe(true); - 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).toHaveLength(3); + expect(historyOnlyCalls).toHaveLength(3); expect( waitCalls.some( (call) => @@ -269,110 +244,4 @@ 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 05fb3b1c6..5b0d1e2ed 100644 --- a/src/agents/clawdis-tools.ts +++ b/src/agents/clawdis-tools.ts @@ -1,14 +1,3084 @@ -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"; +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, + }); + }, + }; +} export function createClawdisTools(options?: { browserControlUrl?: string; @@ -21,7 +3091,6 @@ 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 16b9fe364..df6e5a5e4 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -1,65 +1,3223 @@ -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 { randomUUID } from "node:crypto"; +import fs from "node:fs"; -const handlers: GatewayRequestHandlers = { - ...connectHandlers, - ...voicewakeHandlers, - ...healthHandlers, - ...providersHandlers, - ...chatHandlers, - ...cronHandlers, - ...webHandlers, - ...modelsHandlers, - ...configHandlers, - ...wizardHandlers, - ...talkHandlers, - ...skillsHandlers, - ...sessionsHandlers, - ...systemHandlers, - ...nodeHandlers, - ...sendHandlers, - ...agentHandlers, +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; }; export async function handleGatewayRequest( opts: GatewayRequestOptions, ): Promise { const { req, respond, client, isWebchatConnect, context } = opts; - const handler = handlers[req.method]; - if (!handler) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, `unknown method: ${req.method}`), - ); - return; + 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; + } } - await handler({ - req, - params: (req.params ?? {}) as Record, - client, - isWebchatConnect, - respond, - context, - }); } diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 4f0b75d3b..0d50a055b 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -946,15 +946,18 @@ export async function startGatewayServer( nodePresenceTimers.delete(nodeId); }; - const beaconNodePresence = (node: { - nodeId: string; - displayName?: string; - remoteIp?: string; - version?: string; - platform?: string; - deviceFamily?: string; - modelIdentifier?: string; - }, reason: string) => { + const beaconNodePresence = ( + node: { + nodeId: string; + displayName?: string; + remoteIp?: string; + version?: string; + platform?: string; + deviceFamily?: string; + modelIdentifier?: string; + }, + reason: string, + ) => { const host = node.displayName?.trim() || node.nodeId; const rawIp = node.remoteIp?.trim(); const ip = rawIp && !isLoopbackAddress(rawIp) ? rawIp : undefined; @@ -1826,6 +1829,10 @@ export async function startGatewayServer( await stopGmailWatcher(); cron.stop(); heartbeatRunner.stop(); + for (const timer of nodePresenceTimers.values()) { + clearInterval(timer); + } + nodePresenceTimers.clear(); broadcast("shutdown", { reason, restartExpectedMs,