import { B as normalizeThinkLevel, F as formatXHighModelHint, H as normalizeVerboseLevel, P as formatThinkingLevels, R as normalizeElevatedLevel, V as normalizeUsageDisplay, W as supportsXHighThinking, z as normalizeReasoningLevel } from "./pi-embedded-helpers-s_r2sMSQ.js"; import { $n as CommandLane, $t as registerUnhandledRejectionHandler, At as recordRemoteNodeInfo, B as isGatewaySigusr1RestartExternallyAllowed, Bn as getTtsProvider, Bt as verifyNodeToken, C as readSessionPreviewItemsFromTranscript, Cn as registerAgentRunContext, Cr as DEFAULT_INPUT_PDF_MAX_PAGES, D as ensureOutboundSessionEntry, Dr as extractFileContentFromSource, Dt as resolveAssistantAvatarUrl, Er as DEFAULT_INPUT_TIMEOUT_MS, Et as normalizeControlUiBasePath, Fn as createInternalHookEvent, Ft as listNodePairing, G as migrateLegacyCronPayload, Gn as resolveTtsConfig, Gt as getHookType, H as setGatewaySigusr1RestartPolicy, Hn as isTtsProviderConfigured, Ht as registerSkillsChangeListener, I as runWithModelFallback, In as registerInternalHook, It as rejectNodePairing, J as requestHeartbeatNow, Jn as setTtsEnabled, Kn as resolveTtsPrefsPath, Kt as isExternalHookSession, Ln as triggerInternalHook, Lt as renamePairedNode, M as resolveSessionDeliveryTarget, Mn as resolveUserTimeFormat, Mt as refreshRemoteNodeBins, Nn as resolveUserTimezone, Nt as setSkillsRemoteRegistry, O as resolveOutboundSessionRoute, Or as extractImageContentFromSource, Ot as getRemoteSkillEligibility, P as resetDirectoryCache, Pn as clearInternalHooks, Pt as approveNodePairing, Q as loadProviderUsageSummary, Qn as setCommandLaneConcurrency, R as authorizeGatewaySigusr1Restart, Rn as OPENAI_TTS_MODELS, Rt as requestNodePairing, S as readSessionMessages, Sn as onAgentEvent, Sr as DEFAULT_INPUT_MAX_REDIRECTS, T as stripEnvelopeFromMessages, Tn as normalizePollInput, Tr as DEFAULT_INPUT_PDF_MIN_TEXT_CHARS, Tt as buildControlUiAvatarUrl, U as normalizeCronJobCreate, Un as resolveTtsApiKey, Ut as buildSafeExternalPrompt, V as scheduleGatewaySigusr1Restart, Vn as isTtsEnabled, Vt as getSkillsSnapshotVersion, W as normalizeCronJobPatch, Wn as resolveTtsAutoMode, Wt as detectSuspiciousPatterns, X as getPluginToolMeta, Xn as textToSpeech, Xt as createReplyPrefixOptions, Yn as setTtsProvider, Z as loadOpenClawPlugins, Zt as normalizeGroupActivation, _ as loadSessionEntry, _n as initSubagentRegistry, _r as DEFAULT_INPUT_FILE_MAX_BYTES, a as loadCostUsageSummary, an as onHeartbeatEvent, b as archiveFileOnDisk, bn as emitAgentEvent, br as DEFAULT_INPUT_IMAGE_MAX_BYTES, c as clearSessionQueues, cr as lookupContextTokens, d as waitForEmbeddedPiRunEnd, dr as applyVerboseOverride, er as startDiagnosticHeartbeat, f as createOpenClawTools, fn as dispatchInboundMessage, fr as parseVerboseOverride, g as loadCombinedSessionStoreForGateway, gn as stopSubagentsForRequester, gr as loadModelCatalog, h as listSessionsFromStore, hn as isAbortTrigger, i as runCliAgent, in as getLastHeartbeatEvent, it as handleSlackHttpRequest, j as resolveOutboundTarget, jn as formatUserTime, jr as resolveAgentTimeoutMs, jt as refreshRemoteBinsForConnectedNodes, kn as resolveAgentIdentity, kr as normalizeMimeList, kt as primeRemoteSkillsCache, l as runEmbeddedPiAgent, ln as getChannelActivity, m as listAgentsForGateway, mn as formatZonedTimestamp, mr as isSystemEventContextChanged, n as getCliSessionId, nn as resolveHeartbeatVisibility, nr as isDiagnosticsEnabled, o as normalizeSendPolicy, p as resolveAnnounceTargetFromKey, pn as createReplyDispatcher, pr as enqueueSystemEvent, pt as handleReset, qn as resolveTtsProviderOrder, r as setCliSessionId, rr as DEFAULT_HEARTBEAT_ACK_MAX_CHARS, rt as hasNonzeroUsage, s as resolveSendPolicy, sr as stripHeartbeatToken, tn as buildHistoryContextFromEntries, tr as stopDiagnosticHeartbeat, u as abortEmbeddedPiRun, ur as applyModelOverrideToSessionEntry, v as resolveGatewaySessionStoreTarget, vr as DEFAULT_INPUT_FILE_MAX_CHARS, w as resolveSessionTranscriptCandidates, wr as DEFAULT_INPUT_PDF_MAX_PIXELS, wt as CONTROL_UI_AVATAR_PREFIX, x as capArrayByJsonBytes, xn as getAgentRunContext, xr as DEFAULT_INPUT_IMAGE_MIMES, y as resolveSessionModelRef, yn as clearAgentRunContext, yr as DEFAULT_INPUT_FILE_MIMES, z as consumeGatewaySigusr1RestartAuthorization, zn as OPENAI_TTS_VOICES, zt as updatePairedNodeMetadata } from "./reply-5K4M-ATj.js"; import { g as resolveStateDir, i as isNixMode, l as resolveGatewayLockDir, o as resolveConfigPath, r as STATE_DIR, t as CONFIG_PATH, u as resolveGatewayPort } from "./paths-BDd7_JUB.js"; import { E as DEFAULT_ACCOUNT_ID, F as resolveAgentIdFromSessionKey, H as parseAgentSessionKey, N as normalizeAgentId, P as normalizeMainKey, R as toAgentRequestSessionKey, T as resolveWorkspaceTemplateDir, V as isSubagentSessionKey, _ as DEFAULT_MEMORY_FILENAME, b as DEFAULT_USER_FILENAME, c as resolveDefaultAgentId, d as DEFAULT_AGENTS_FILENAME, g as DEFAULT_MEMORY_ALT_FILENAME, h as DEFAULT_IDENTITY_FILENAME, i as resolveAgentModelFallbacksOverride, k as buildAgentMainSessionKey, l as resolveSessionAgentId, m as DEFAULT_HEARTBEAT_FILENAME, n as resolveAgentConfig, p as DEFAULT_BOOTSTRAP_FILENAME, r as resolveAgentDir, s as resolveAgentWorkspaceDir, t as listAgentIds, v as DEFAULT_SOUL_FILENAME, w as resolveDefaultAgentWorkspaceDir, x as ensureAgentWorkspace, y as DEFAULT_TOOLS_FILENAME } from "./agent-scope-CrgUOY3f.js"; import { B as getLogger, C as getActivePluginRegistry, I as colorize, L as isRich, M as setVerbose, R as theme, V as getResolvedLoggerSettings, a as setConsoleTimestampPrefix, c as defaultRuntime, h as DEFAULT_CHAT_CHANNEL, i as setConsoleSubsystemFilter, n as runtimeForLogger, p as CHANNEL_IDS, t as createSubsystemLogger, z as getChildLogger } from "./subsystem-46MXi6Ip.js"; import { _ as shortenHomePath, h as resolveUserPath, s as ensureDir, t as CONFIG_DIR, x as truncateUtf16Safe } from "./utils-Dg0Xbl6w.js"; import { a as logDebug, c as logWarn, n as runExec, t as runCommandWithTimeout } from "./exec-CTo4hK94.js"; import { t as resolveOpenClawPackageRoot } from "./openclaw-root-CJKDUIBP.js"; import { bt as DEFAULT_MODEL, c as resolveAllowedModelRef, d as resolveHooksGmailModel, i as isCliProvider, l as resolveConfiguredModelRef, p as resolveThinkingDefault, r as getModelRefStatus, u as resolveDefaultModelForAgent, xt as DEFAULT_PROVIDER, yt as DEFAULT_CONTEXT_TOKENS } from "./model-selection-Cp1maz7B.js"; import "./github-copilot-token-VZsS4013.js"; import { t as formatCliCommand } from "./command-format-BQK1OIvH.js"; import "./boolean-CE7i9tBR.js"; import { n as logAcceptedEnvOption, t as isTruthyEnvValue } from "./env-DOcCob95.js"; import { A as resolveAgentMaxConcurrent, E as applyLegacyMigrations, M as VERSION, a as readConfigFileSnapshot, i as parseConfigJson5, j as resolveSubagentMaxConcurrent, l as validateConfigObjectWithPlugins, n as createConfigIO, o as resolveConfigSnapshotHash, r as loadConfig, s as writeConfigFile, t as migrateLegacyConfig, u as OpenClawSchema } from "./config-BAN8QfW5.js"; import { o as isTestDefaultMemorySlotDisabled } from "./manifest-registry-BFpLJJDB.js"; import { n as listChannelPlugins, r as normalizeChannelId, t as getChannelPlugin } from "./plugins-D1CxUobm.js"; import { B as snapshotSessionOrigin, F as resolveAgentMainSessionKey, I as resolveExplicitAgentSessionKey, L as resolveMainSessionKey, R as resolveMainSessionKeyFromConfig, S as mergeDeliveryContext, at as normalizeToolName, b as deliveryContextFromSession, d as loadSessionStore, g as updateSessionStore, nt as collectExplicitAllowlist, ot as resolveToolProfilePolicy, rt as expandPolicyWithPluginGroups, st as stripPluginOnlyAllowlist, tt as buildPluginToolGroups, w as normalizeSessionDeliveryFields } from "./sandbox-CRRd7UQ-.js"; import "./image-Bx6WKexe.js"; import "./pi-model-discovery-DjGamP_B.js"; import { T as formatErrorMessage, d as inspectPortUsage, l as ensurePortAvailable, p as formatPortDiagnostics } from "./chrome-D2LUApAY.js"; import { d as hasBinary, i as loadWorkspaceSkillEntries, r as buildWorkspaceSkillSnapshot } from "./skills-C4b1FA1e.js"; import { c as saveMediaBuffer, l as detectMime } from "./routes-CB4ih7ZH.js"; import "./server-context-BjC2Yruc.js"; import { t as rawDataToString } from "./ws-Cxo2p9fH.js"; import { f as GATEWAY_CLIENT_CAPS, g as hasGatewayClientCap, h as GATEWAY_CLIENT_NAMES, i as isGatewayMessageChannel, l as normalizeMessageChannel, m as GATEWAY_CLIENT_MODES, n as isDeliverableMessageChannel, p as GATEWAY_CLIENT_IDS, r as isGatewayCliClient, s as isWebchatClient, t as INTERNAL_MESSAGE_CHANNEL } from "./message-channel-BD3kuDks.js"; import "./logging-BdnOSVPD.js"; import "./accounts-BlHoTziG.js"; import { o as resolveStorePath, r as resolveSessionTranscriptPath } from "./paths-50eo6DV6.js"; import "./redact-BKh-zp-c.js"; import "./tool-display-rIUh61kT.js"; import { a as normalizeReplyPayloadsForDelivery, t as deliverOutboundPayloads } from "./deliver-Do5KTPQ4.js"; import { i as getMachineDisplayName, t as createBrowserRouteDispatcher } from "./dispatcher-DLhjSne5.js"; import "./manager-BArueSTR.js"; import "./sqlite-Dz6S6ijV.js"; import { m as formatUsd, p as formatTokenCount } from "./channel-summary-DvcMwADq.js"; import { $ as validatePollParams, A as validateDevicePairListParams, B as validateLogsTailParams, C as validateCronListParams, Ct as ErrorCodes, D as validateCronStatusParams, Dt as buildDeviceAuthPayload, E as validateCronRunsParams, Et as parseSessionLabel, F as validateExecApprovalResolveParams, G as validateNodeInvokeResultParams, H as validateNodeDescribeParams, I as validateExecApprovalsGetParams, J as validateNodePairListParams, K as validateNodeListParams, L as validateExecApprovalsNodeGetParams, M as validateDeviceTokenRevokeParams, Mt as verifyDeviceSignature, N as validateDeviceTokenRotateParams, O as validateCronUpdateParams, P as validateExecApprovalRequestParams, Q as validateNodeRenameParams, R as validateExecApprovalsNodeSetParams, S as validateCronAddParams, St as PROTOCOL_VERSION, T as validateCronRunParams, U as validateNodeEventParams, V as validateModelsListParams, W as validateNodeInvokeParams, X as validateNodePairRequestParams, Y as validateNodePairRejectParams, Z as validateNodePairVerifyParams, _ as validateConfigGetParams, _t as validateWebLoginWaitParams, a as validateAgentWaitParams, at as validateSessionsPatchParams, b as validateConfigSetParams, bt as validateWizardStartParams, c as validateAgentsFilesSetParams, ct as validateSessionsResolveParams, d as validateChannelsStatusParams, dt as validateSkillsStatusParams, et as validateRequestFrame, f as validateChatAbortParams, ft as validateSkillsUpdateParams, g as validateConfigApplyParams, gt as validateWebLoginStartParams, h as validateChatSendParams, ht as validateWakeParams, i as validateAgentParams, it as validateSessionsListParams, j as validateDevicePairRejectParams, jt as normalizeDevicePublicKeyBase64Url, k as validateDevicePairApproveParams, kt as deriveDeviceIdFromPublicKey, l as validateAgentsListParams, lt as validateSkillsBinsParams, m as validateChatInjectParams, mt as validateUpdateRunParams, n as formatValidationErrors, nt as validateSessionsCompactParams, o as validateAgentsFilesGetParams, ot as validateSessionsPreviewParams, p as validateChatHistoryParams, pt as validateTalkModeParams, q as validateNodePairApproveParams, r as validateAgentIdentityParams, rt as validateSessionsDeleteParams, s as validateAgentsFilesListParams, st as validateSessionsResetParams, tt as validateSendParams, u as validateChannelsLogoutParams, ut as validateSkillsInstallParams, v as validateConfigPatchParams, vt as validateWizardCancelParams, w as validateCronRemoveParams, wt as errorShape, x as validateConnectParams, xt as validateWizardStatusParams, y as validateConfigSchemaParams, yt as validateWizardNextParams, z as validateExecApprovalsSetParams } from "./client-i9GLCujO.js"; import { n as pickPrimaryTailnetIPv6, t as pickPrimaryTailnetIPv4 } from "./tailnet-Bk5cGmwE.js"; import { n as callGateway, o as loadGatewayTlsRuntime$1 } from "./call-BxBOsbXG.js"; import "./login-qr-f0O41dc6.js"; import "./pairing-store-BnMngoWQ.js"; import { t as formatDocsLink } from "./links-C9fyAH-V.js"; import { r as runCommandWithRuntime } from "./cli-utils-CukoNm8O.js"; import { n as withProgress } from "./progress-uNDQDtGB.js"; import { a as resolveSubagentToolPolicy, i as resolveGroupToolPolicy, r as resolveEffectiveToolPolicy, t as filterToolsByPolicy } from "./pi-tools.policy-B0NP2v9o.js"; import "./prompt-style-gfROyHgB.js"; import "./pairing-labels-DK2aLSd2.js"; import { c as normalizeExecApprovals, g as saveExecApprovals, l as readExecApprovalsSnapshot, m as resolveExecApprovalsSocketPath, r as ensureExecApprovals } from "./exec-approvals-CK-Umdr3.js"; import { r as startBrowserControlServiceFromConfig, t as createBrowserControlContext } from "./control-service-yGq0G92l.js"; import { t as parseAbsoluteTimeMs } from "./parse-DLFPuO7w.js"; import { a as summarizeRestartSentinel, n as formatDoctorNonInteractiveHint, r as formatRestartSentinelMessage, s as writeRestartSentinel, t as consumeRestartSentinel } from "./restart-sentinel-CdcBcziq.js"; import { n as resolveMessageChannelSelection } from "./channel-selection-Cmg7RpCn.js"; import { n as createOutboundSendDeps, t as createDefaultDeps } from "./deps-DhwPoDgU.js"; import { i as enableTailscaleServe, n as disableTailscaleServe, o as getTailnetHostname, r as enableTailscaleFunnel, t as disableTailscaleFunnel } from "./tailscale-Cvk3mQDZ.js"; import { t as ensureOpenClawCliOnPath } from "./path-env-CuGC6r1F.js"; import { c as loadAgentIdentity, d as loadAgentIdentityFromWorkspace, p as forceFreePortAndWait, r as getStatusSummary, t as runOnboardingWizard } from "./onboarding-BO2BFcUM.js"; import { t as resolveChannelDefaultAccountId } from "./helpers-Cw9kFCkw.js"; import "./logging-CLCWl7Iu.js"; import "./note-BFpD-42H.js"; import { t as WizardCancelledError } from "./prompts-CIDznuZR.js"; import { d as resolveControlUiRootOverrideSync, f as resolveControlUiRootSync, i as discoverGatewayBeacons, n as installSkill, u as ensureControlUiAssetsBuilt } from "./onboard-skills--CKZf9Qg.js"; import "./github-copilot-auth-DujNgq_8.js"; import "./onboard-channels-CMSy3YWX.js"; import { r as buildChannelUiCatalog, t as applyPluginAutoEnable } from "./plugin-auto-enable-JQ63k0Ce.js"; import "./archive-BrH5WBhI.js"; import "./installs-W4Pc1LJz.js"; import { c as startHeartbeatRunner, n as formatHealthChannelLines, o as runHeartbeatOnce, r as getHealthSnapshot, s as setHeartbeatsEnabled } from "./health-format-CJ1RDAfc.js"; import { d as compareSemverStrings, h as DEFAULT_PACKAGE_CHANNEL, p as resolveNpmChannelTag, t as runGatewayUpdate, u as checkUpdateStatus, v as normalizeUpdateChannel } from "./update-runner-CyWVdiRE.js"; import { a as resolveGatewayBindHost, n as isLoopbackHost$2, o as resolveGatewayClientIp, r as isTrustedProxyAddress, s as resolveGatewayListenHosts, t as isLoopbackAddress } from "./net-DeiCIMU6.js"; import { i as resolveGatewayAuth, n as authorizeGatewayConnect, r as isLocalDirectRequest, t as assertGatewayAuthConfigured } from "./auth-DK3l201_.js"; import { i as probeGateway } from "./audit-BfZkU1Yk.js"; import "./table-DuNe7Qes.js"; import { t as buildWorkspaceSkillStatus } from "./skills-status-CSCMqNZP.js"; import "./service-CAxAjHNB.js"; import "./systemd-CNYEkRek.js"; import "./service-audit-JMhmQoYj.js"; import "./node-service-CjtBRyjp.js"; import "./channels-status-issues-C9vMMPG0.js"; import "./status.update-DVbgaKOH.js"; import "./completion-cli-D2_ctQqF.js"; import { a as createOutboundSendDeps$1, i as resolveAgentOutboundTarget, r as resolveAgentDeliveryPlan, t as agentCommand } from "./agent-vIBVyXVW.js"; import "./daemon-runtime-B9eMPdZi.js"; import { n as resolveWideAreaDiscoveryDomain, r as writeWideAreaGatewayZone } from "./widearea-dns-BIhjVRG1.js"; import { i as shouldIncludeHook, n as loadWorkspaceHookEntries, r as resolveHookConfig } from "./hooks-status-a34fqgtQ.js"; import "./tui-D1lUmmX_.js"; import { t as buildChannelAccountSnapshot } from "./status-CCHBIZnm.js"; import "./shared-ChNOqAzp.js"; import { a as runDaemonStop, i as runDaemonStart, n as runDaemonStatus, o as runDaemonUninstall, r as runDaemonRestart, s as runDaemonInstall } from "./daemon-cli-Clo849bM.js"; import { a as toOptionString, i as parsePort$1, n as extractGatewayMiskeys, r as maybeExplainGatewayServiceStop, t as describeUnknownError } from "./shared-xZjRQa6y.js"; import { i as setGatewayWsLogStyle, n as logWs, r as summarizeAgentEventForWsLog, t as formatForLog } from "./ws-log-DQ6nkLys.js"; import { T as resolveGmailHookRuntimeConfig, _ as buildGogWatchServeArgs, i as ensureTailscaleEndpoint, v as buildGogWatchStartArgs } from "./gmail-setup-utils-B1CTmT2V.js"; import { fileURLToPath, pathToFileURL } from "node:url"; import os from "node:os"; import path from "node:path"; import * as fsSync from "node:fs"; import fs, { constants } from "node:fs"; import JSON5 from "json5"; import chalk from "chalk"; import fs$1 from "node:fs/promises"; import { spawn, spawnSync } from "node:child_process"; import crypto, { createHash, randomUUID } from "node:crypto"; import { z } from "zod"; import { CURRENT_SESSION_VERSION } from "@mariozechner/pi-coding-agent"; import { createServer } from "node:http"; import { createServer as createServer$1 } from "node:https"; import { WebSocketServer } from "ws"; import { Buffer as Buffer$1 } from "node:buffer"; import net from "node:net"; import chokidar from "chokidar"; import { Cron } from "croner"; //#region src/infra/ssh-config.ts function parsePort(value) { if (!value) return; const parsed = Number.parseInt(value, 10); if (!Number.isFinite(parsed) || parsed <= 0) return; return parsed; } function parseSshConfigOutput(output) { const result = { identityFiles: [] }; const lines = output.split("\n"); for (const raw of lines) { const line = raw.trim(); if (!line) continue; const [key, ...rest] = line.split(/\s+/); const value = rest.join(" ").trim(); if (!key || !value) continue; switch (key) { case "user": result.user = value; break; case "hostname": result.host = value; break; case "port": result.port = parsePort(value); break; case "identityfile": if (value !== "none") result.identityFiles.push(value); break; default: break; } } return result; } async function resolveSshConfig(target, opts = {}) { const sshPath = "/usr/bin/ssh"; const args = ["-G"]; if (target.port > 0 && target.port !== 22) args.push("-p", String(target.port)); if (opts.identity?.trim()) args.push("-i", opts.identity.trim()); const userHost = target.user ? `${target.user}@${target.host}` : target.host; args.push("--", userHost); return await new Promise((resolve) => { const child = spawn(sshPath, args, { stdio: [ "ignore", "pipe", "ignore" ] }); let stdout = ""; child.stdout?.setEncoding("utf8"); child.stdout?.on("data", (chunk) => { stdout += String(chunk); }); const timeoutMs = Math.max(200, opts.timeoutMs ?? 800); const timer = setTimeout(() => { try { child.kill("SIGKILL"); } finally { resolve(null); } }, timeoutMs); child.once("error", () => { clearTimeout(timer); resolve(null); }); child.once("exit", (code) => { clearTimeout(timer); if (code !== 0 || !stdout.trim()) { resolve(null); return; } resolve(parseSshConfigOutput(stdout)); }); }); } //#endregion //#region src/infra/ssh-tunnel.ts function isErrno(err) { return Boolean(err && typeof err === "object" && "code" in err); } function parseSshTarget(raw) { const trimmed = raw.trim().replace(/^ssh\s+/, ""); if (!trimmed) return null; const [userPart, hostPart] = trimmed.includes("@") ? (() => { const idx = trimmed.indexOf("@"); const user = trimmed.slice(0, idx).trim(); const host = trimmed.slice(idx + 1).trim(); return [user || void 0, host]; })() : [void 0, trimmed]; const colonIdx = hostPart.lastIndexOf(":"); if (colonIdx > 0 && colonIdx < hostPart.length - 1) { const host = hostPart.slice(0, colonIdx).trim(); const portRaw = hostPart.slice(colonIdx + 1).trim(); const port = Number.parseInt(portRaw, 10); if (!host || !Number.isFinite(port) || port <= 0) return null; if (host.startsWith("-")) return null; return { user: userPart, host, port }; } if (!hostPart) return null; if (hostPart.startsWith("-")) return null; return { user: userPart, host: hostPart, port: 22 }; } async function pickEphemeralPort() { return await new Promise((resolve, reject) => { const server = net.createServer(); server.once("error", reject); server.listen(0, "127.0.0.1", () => { const addr = server.address(); server.close(() => { if (!addr || typeof addr === "string") { reject(/* @__PURE__ */ new Error("failed to allocate a local port")); return; } resolve(addr.port); }); }); }); } async function canConnectLocal(port) { return await new Promise((resolve) => { const socket = net.connect({ host: "127.0.0.1", port }); const done = (ok) => { socket.removeAllListeners(); socket.destroy(); resolve(ok); }; socket.once("connect", () => done(true)); socket.once("error", () => done(false)); socket.setTimeout(250, () => done(false)); }); } async function waitForLocalListener(port, timeoutMs) { const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { if (await canConnectLocal(port)) return; await new Promise((r) => setTimeout(r, 50)); } throw new Error(`ssh tunnel did not start listening on localhost:${port}`); } async function startSshPortForward(opts) { const parsed = parseSshTarget(opts.target); if (!parsed) throw new Error(`invalid SSH target: ${opts.target}`); let localPort = opts.localPortPreferred; try { await ensurePortAvailable(localPort); } catch (err) { if (isErrno(err) && err.code === "EADDRINUSE") localPort = await pickEphemeralPort(); else throw err; } const userHost = parsed.user ? `${parsed.user}@${parsed.host}` : parsed.host; const args = [ "-N", "-L", `${localPort}:127.0.0.1:${opts.remotePort}`, "-p", String(parsed.port), "-o", "ExitOnForwardFailure=yes", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-o", "UpdateHostKeys=yes", "-o", "ConnectTimeout=5", "-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=3" ]; if (opts.identity?.trim()) args.push("-i", opts.identity.trim()); args.push("--", userHost); const stderr = []; const child = spawn("/usr/bin/ssh", args, { stdio: [ "ignore", "ignore", "pipe" ] }); child.stderr?.setEncoding("utf8"); child.stderr?.on("data", (chunk) => { const lines = String(chunk).split("\n").map((l) => l.trim()).filter(Boolean); stderr.push(...lines); }); const stop = async () => { if (child.killed) return; child.kill("SIGTERM"); await new Promise((resolve) => { const t = setTimeout(() => { try { child.kill("SIGKILL"); } finally { resolve(); } }, 1500); child.once("exit", () => { clearTimeout(t); resolve(); }); }); }; try { await Promise.race([waitForLocalListener(localPort, Math.max(250, opts.timeoutMs)), new Promise((_, reject) => { child.once("exit", (code, signal) => { reject(/* @__PURE__ */ new Error(`ssh exited (${code ?? "null"}${signal ? `/${signal}` : ""})`)); }); })]); } catch (err) { await stop(); const suffix = stderr.length > 0 ? `\n${stderr.join("\n")}` : ""; throw new Error(`${err instanceof Error ? err.message : String(err)}${suffix}`, { cause: err }); } return { parsedTarget: parsed, localPort, remotePort: opts.remotePort, pid: typeof child.pid === "number" ? child.pid : null, stderr, stop }; } //#endregion //#region src/commands/gateway-status/helpers.ts function parseIntOrNull(value) { const s = typeof value === "string" ? value.trim() : typeof value === "number" || typeof value === "bigint" ? String(value) : ""; if (!s) return null; const n = Number.parseInt(s, 10); return Number.isFinite(n) ? n : null; } function parseTimeoutMs(raw, fallbackMs) { const value = typeof raw === "string" ? raw.trim() : typeof raw === "number" || typeof raw === "bigint" ? String(raw) : ""; if (!value) return fallbackMs; const parsed = Number.parseInt(value, 10); if (!Number.isFinite(parsed) || parsed <= 0) throw new Error(`invalid --timeout: ${value}`); return parsed; } function normalizeWsUrl(value) { const trimmed = value.trim(); if (!trimmed) return null; if (!trimmed.startsWith("ws://") && !trimmed.startsWith("wss://")) return null; return trimmed; } function resolveTargets(cfg, explicitUrl) { const targets = []; const add = (t) => { if (!targets.some((x) => x.url === t.url)) targets.push(t); }; const explicit = typeof explicitUrl === "string" ? normalizeWsUrl(explicitUrl) : null; if (explicit) add({ id: "explicit", kind: "explicit", url: explicit, active: true }); const remoteUrl = typeof cfg.gateway?.remote?.url === "string" ? normalizeWsUrl(cfg.gateway.remote.url) : null; if (remoteUrl) add({ id: "configRemote", kind: "configRemote", url: remoteUrl, active: cfg.gateway?.mode === "remote" }); add({ id: "localLoopback", kind: "localLoopback", url: `ws://127.0.0.1:${resolveGatewayPort(cfg)}`, active: cfg.gateway?.mode !== "remote" }); return targets; } function resolveProbeBudgetMs(overallMs, kind) { if (kind === "localLoopback") return Math.min(800, overallMs); if (kind === "sshTunnel") return Math.min(2e3, overallMs); return Math.min(1500, overallMs); } function sanitizeSshTarget(value) { if (typeof value !== "string") return null; const trimmed = value.trim(); if (!trimmed) return null; return trimmed.replace(/^ssh\\s+/, ""); } function resolveAuthForTarget(cfg, target, overrides) { const tokenOverride = overrides.token?.trim() ? overrides.token.trim() : void 0; const passwordOverride = overrides.password?.trim() ? overrides.password.trim() : void 0; if (tokenOverride || passwordOverride) return { token: tokenOverride, password: passwordOverride }; if (target.kind === "configRemote" || target.kind === "sshTunnel") { const token = typeof cfg.gateway?.remote?.token === "string" ? cfg.gateway.remote.token.trim() : ""; const remotePassword = (cfg.gateway?.remote)?.password; const password = typeof remotePassword === "string" ? remotePassword.trim() : ""; return { token: token.length > 0 ? token : void 0, password: password.length > 0 ? password : void 0 }; } const envToken = process.env.OPENCLAW_GATEWAY_TOKEN?.trim() || ""; const envPassword = process.env.OPENCLAW_GATEWAY_PASSWORD?.trim() || ""; const cfgToken = typeof cfg.gateway?.auth?.token === "string" ? cfg.gateway.auth.token.trim() : ""; const cfgPassword = typeof cfg.gateway?.auth?.password === "string" ? cfg.gateway.auth.password.trim() : ""; return { token: envToken || cfgToken || void 0, password: envPassword || cfgPassword || void 0 }; } function pickGatewaySelfPresence(presence) { if (!Array.isArray(presence)) return null; const entries = presence; const self = entries.find((e) => e.mode === "gateway" && e.reason === "self") ?? entries.find((e) => typeof e.text === "string" && String(e.text).startsWith("Gateway:")) ?? null; if (!self) return null; return { host: typeof self.host === "string" ? self.host : void 0, ip: typeof self.ip === "string" ? self.ip : void 0, version: typeof self.version === "string" ? self.version : void 0, platform: typeof self.platform === "string" ? self.platform : void 0 }; } function extractConfigSummary(snapshotUnknown) { const snap = snapshotUnknown; const path = typeof snap?.path === "string" ? snap.path : null; const exists = Boolean(snap?.exists); const valid = Boolean(snap?.valid); const issuesRaw = Array.isArray(snap?.issues) ? snap.issues : []; const legacyRaw = Array.isArray(snap?.legacyIssues) ? snap.legacyIssues : []; const cfg = snap?.config ?? {}; const gateway = cfg.gateway ?? {}; const wideArea = (cfg.discovery ?? {}).wideArea ?? {}; const remote = gateway.remote ?? {}; const auth = gateway.auth ?? {}; const controlUi = gateway.controlUi ?? {}; const tailscale = gateway.tailscale ?? {}; const authMode = typeof auth.mode === "string" ? auth.mode : null; const authTokenConfigured = typeof auth.token === "string" ? auth.token.trim().length > 0 : false; const authPasswordConfigured = typeof auth.password === "string" ? auth.password.trim().length > 0 : false; const remoteUrl = typeof remote.url === "string" ? normalizeWsUrl(remote.url) : null; const remoteTokenConfigured = typeof remote.token === "string" ? remote.token.trim().length > 0 : false; const remotePasswordConfigured = typeof remote.password === "string" ? String(remote.password).trim().length > 0 : false; const wideAreaEnabled = typeof wideArea.enabled === "boolean" ? wideArea.enabled : null; return { path, exists, valid, issues: issuesRaw.filter((i) => Boolean(i && typeof i.path === "string" && typeof i.message === "string")).map((i) => ({ path: i.path, message: i.message })), legacyIssues: legacyRaw.filter((i) => Boolean(i && typeof i.path === "string" && typeof i.message === "string")).map((i) => ({ path: i.path, message: i.message })), gateway: { mode: typeof gateway.mode === "string" ? gateway.mode : null, bind: typeof gateway.bind === "string" ? gateway.bind : null, port: parseIntOrNull(gateway.port), controlUiEnabled: typeof controlUi.enabled === "boolean" ? controlUi.enabled : null, controlUiBasePath: typeof controlUi.basePath === "string" ? controlUi.basePath : null, authMode, authTokenConfigured, authPasswordConfigured, remoteUrl, remoteTokenConfigured, remotePasswordConfigured, tailscaleMode: typeof tailscale.mode === "string" ? tailscale.mode : null }, discovery: { wideAreaEnabled } }; } function buildNetworkHints(cfg) { const tailnetIPv4 = pickPrimaryTailnetIPv4(); const port = resolveGatewayPort(cfg); return { localLoopbackUrl: `ws://127.0.0.1:${port}`, localTailnetUrl: tailnetIPv4 ? `ws://${tailnetIPv4}:${port}` : null, tailnetIPv4: tailnetIPv4 ?? null }; } function renderTargetHeader(target, rich) { const kindLabel = target.kind === "localLoopback" ? "Local loopback" : target.kind === "sshTunnel" ? "Remote over SSH" : target.kind === "configRemote" ? target.active ? "Remote (configured)" : "Remote (configured, inactive)" : "URL (explicit)"; return `${colorize(rich, theme.heading, kindLabel)} ${colorize(rich, theme.muted, target.url)}`; } function renderProbeSummaryLine(probe, rich) { if (probe.ok) { const latency = typeof probe.connectLatencyMs === "number" ? `${probe.connectLatencyMs}ms` : "unknown"; return `${colorize(rich, theme.success, "Connect: ok")} (${latency}) · ${colorize(rich, theme.success, "RPC: ok")}`; } const detail = probe.error ? ` - ${probe.error}` : ""; if (probe.connectLatencyMs != null) { const latency = typeof probe.connectLatencyMs === "number" ? `${probe.connectLatencyMs}ms` : "unknown"; return `${colorize(rich, theme.success, "Connect: ok")} (${latency}) · ${colorize(rich, theme.error, "RPC: failed")}${detail}`; } return `${colorize(rich, theme.error, "Connect: failed")}${detail}`; } //#endregion //#region src/commands/gateway-status.ts async function gatewayStatusCommand(opts, runtime) { const startedAt = Date.now(); const cfg = loadConfig(); const rich = isRich() && opts.json !== true; const overallTimeoutMs = parseTimeoutMs(opts.timeout, 3e3); const wideAreaDomain = resolveWideAreaDiscoveryDomain({ configDomain: cfg.discovery?.wideArea?.domain }); const baseTargets = resolveTargets(cfg, opts.url); const network = buildNetworkHints(cfg); const discoveryTimeoutMs = Math.min(1200, overallTimeoutMs); const discoveryPromise = discoverGatewayBeacons({ timeoutMs: discoveryTimeoutMs, wideAreaDomain }); let sshTarget = sanitizeSshTarget(opts.ssh) ?? sanitizeSshTarget(cfg.gateway?.remote?.sshTarget); let sshIdentity = sanitizeSshTarget(opts.sshIdentity) ?? sanitizeSshTarget(cfg.gateway?.remote?.sshIdentity); const remotePort = resolveGatewayPort(cfg); let sshTunnelError = null; let sshTunnelStarted = false; if (!sshTarget) sshTarget = inferSshTargetFromRemoteUrl(cfg.gateway?.remote?.url); if (sshTarget) { const resolved = await resolveSshTarget(sshTarget, sshIdentity, overallTimeoutMs); if (resolved) { sshTarget = resolved.target; if (!sshIdentity && resolved.identity) sshIdentity = resolved.identity; } } const { discovery, probed } = await withProgress({ label: "Inspecting gateways…", indeterminate: true, enabled: opts.json !== true }, async () => { const tryStartTunnel = async () => { if (!sshTarget) return null; try { const tunnel = await startSshPortForward({ target: sshTarget, identity: sshIdentity ?? void 0, localPortPreferred: remotePort, remotePort, timeoutMs: Math.min(1500, overallTimeoutMs) }); sshTunnelStarted = true; return tunnel; } catch (err) { sshTunnelError = err instanceof Error ? err.message : String(err); return null; } }; const discoveryTask = discoveryPromise.catch(() => []); const tunnelTask = sshTarget ? tryStartTunnel() : Promise.resolve(null); const [discovery, tunnelFirst] = await Promise.all([discoveryTask, tunnelTask]); if (!sshTarget && opts.sshAuto) { const user = process.env.USER?.trim() || ""; const candidates = discovery.map((b) => { const host = b.tailnetDns || b.lanHost || b.host; if (!host?.trim()) return null; const sshPort = typeof b.sshPort === "number" && b.sshPort > 0 ? b.sshPort : 22; const base = user ? `${user}@${host.trim()}` : host.trim(); return sshPort !== 22 ? `${base}:${sshPort}` : base; }).filter((candidate) => Boolean(candidate && parseSshTarget(candidate))); if (candidates.length > 0) sshTarget = candidates[0] ?? null; } const tunnel = tunnelFirst || (sshTarget && !sshTunnelStarted && !sshTunnelError ? await tryStartTunnel() : null); const tunnelTarget = tunnel ? { id: "sshTunnel", kind: "sshTunnel", url: `ws://127.0.0.1:${tunnel.localPort}`, active: true, tunnel: { kind: "ssh", target: sshTarget ?? "", localPort: tunnel.localPort, remotePort, pid: tunnel.pid } } : null; const targets = tunnelTarget ? [tunnelTarget, ...baseTargets.filter((t) => t.url !== tunnelTarget.url)] : baseTargets; try { return { discovery, probed: await Promise.all(targets.map(async (target) => { const auth = resolveAuthForTarget(cfg, target, { token: typeof opts.token === "string" ? opts.token : void 0, password: typeof opts.password === "string" ? opts.password : void 0 }); const timeoutMs = resolveProbeBudgetMs(overallTimeoutMs, target.kind); const probe = await probeGateway({ url: target.url, auth, timeoutMs }); return { target, probe, configSummary: probe.configSnapshot ? extractConfigSummary(probe.configSnapshot) : null, self: pickGatewaySelfPresence(probe.presence) }; })) }; } finally { if (tunnel) try { await tunnel.stop(); } catch {} } }); const reachable = probed.filter((p) => p.probe.ok); const ok = reachable.length > 0; const multipleGateways = reachable.length > 1; const primary = reachable.find((p) => p.target.kind === "explicit") ?? reachable.find((p) => p.target.kind === "sshTunnel") ?? reachable.find((p) => p.target.kind === "configRemote") ?? reachable.find((p) => p.target.kind === "localLoopback") ?? null; const warnings = []; if (sshTarget && !sshTunnelStarted) warnings.push({ code: "ssh_tunnel_failed", message: sshTunnelError ? `SSH tunnel failed: ${String(sshTunnelError)}` : "SSH tunnel failed to start; falling back to direct probes." }); if (multipleGateways) warnings.push({ code: "multiple_gateways", message: "Unconventional setup: multiple reachable gateways detected. Usually one gateway per network is recommended unless you intentionally run isolated profiles, like a rescue bot (see docs: /gateway#multiple-gateways-same-host).", targetIds: reachable.map((p) => p.target.id) }); if (opts.json) { runtime.log(JSON.stringify({ ok, ts: Date.now(), durationMs: Date.now() - startedAt, timeoutMs: overallTimeoutMs, primaryTargetId: primary?.target.id ?? null, warnings, network, discovery: { timeoutMs: discoveryTimeoutMs, count: discovery.length, beacons: discovery.map((b) => ({ instanceName: b.instanceName, displayName: b.displayName ?? null, domain: b.domain ?? null, host: b.host ?? null, lanHost: b.lanHost ?? null, tailnetDns: b.tailnetDns ?? null, gatewayPort: b.gatewayPort ?? null, sshPort: b.sshPort ?? null, wsUrl: (() => { const host = b.tailnetDns || b.lanHost || b.host; const port = b.gatewayPort ?? 18789; return host ? `ws://${host}:${port}` : null; })() })) }, targets: probed.map((p) => ({ id: p.target.id, kind: p.target.kind, url: p.target.url, active: p.target.active, tunnel: p.target.tunnel ?? null, connect: { ok: p.probe.ok, latencyMs: p.probe.connectLatencyMs, error: p.probe.error, close: p.probe.close }, self: p.self, config: p.configSummary, health: p.probe.health, summary: p.probe.status, presence: p.probe.presence })) }, null, 2)); if (!ok) runtime.exit(1); return; } runtime.log(colorize(rich, theme.heading, "Gateway Status")); runtime.log(ok ? `${colorize(rich, theme.success, "Reachable")}: yes` : `${colorize(rich, theme.error, "Reachable")}: no`); runtime.log(colorize(rich, theme.muted, `Probe budget: ${overallTimeoutMs}ms`)); if (warnings.length > 0) { runtime.log(""); runtime.log(colorize(rich, theme.warn, "Warning:")); for (const w of warnings) runtime.log(`- ${w.message}`); } runtime.log(""); runtime.log(colorize(rich, theme.heading, "Discovery (this machine)")); const discoveryDomains = wideAreaDomain ? `local. + ${wideAreaDomain}` : "local."; runtime.log(discovery.length > 0 ? `Found ${discovery.length} gateway(s) via Bonjour (${discoveryDomains})` : `Found 0 gateways via Bonjour (${discoveryDomains})`); if (discovery.length === 0) runtime.log(colorize(rich, theme.muted, "Tip: if the gateway is remote, mDNS won’t cross networks; use Wide-Area Bonjour (split DNS) or SSH tunnels.")); runtime.log(""); runtime.log(colorize(rich, theme.heading, "Targets")); for (const p of probed) { runtime.log(renderTargetHeader(p.target, rich)); runtime.log(` ${renderProbeSummaryLine(p.probe, rich)}`); if (p.target.tunnel?.kind === "ssh") runtime.log(` ${colorize(rich, theme.muted, "ssh")}: ${colorize(rich, theme.command, p.target.tunnel.target)}`); if (p.probe.ok && p.self) { const host = p.self.host ?? "unknown"; const ip = p.self.ip ? ` (${p.self.ip})` : ""; const platform = p.self.platform ? ` · ${p.self.platform}` : ""; const version = p.self.version ? ` · app ${p.self.version}` : ""; runtime.log(` ${colorize(rich, theme.info, "Gateway")}: ${host}${ip}${platform}${version}`); } if (p.configSummary) { const c = p.configSummary; const wideArea = c.discovery.wideAreaEnabled === true ? "enabled" : c.discovery.wideAreaEnabled === false ? "disabled" : "unknown"; runtime.log(` ${colorize(rich, theme.info, "Wide-area discovery")}: ${wideArea}`); } runtime.log(""); } if (!ok) runtime.exit(1); } function inferSshTargetFromRemoteUrl(rawUrl) { if (typeof rawUrl !== "string") return null; const trimmed = rawUrl.trim(); if (!trimmed) return null; let host = null; try { host = new URL(trimmed).hostname || null; } catch { return null; } if (!host) return null; const user = process.env.USER?.trim() || ""; return user ? `${user}@${host}` : host; } function buildSshTarget(input) { const host = input.host?.trim() ?? ""; if (!host) return null; const user = input.user?.trim() ?? ""; const base = user ? `${user}@${host}` : host; const port = input.port ?? 22; if (port && port !== 22) return `${base}:${port}`; return base; } async function resolveSshTarget(rawTarget, identity, overallTimeoutMs) { const parsed = parseSshTarget(rawTarget); if (!parsed) return null; const config = await resolveSshConfig(parsed, { identity: identity ?? void 0, timeoutMs: Math.min(800, overallTimeoutMs) }); if (!config) return { target: rawTarget, identity: identity ?? void 0 }; const target = buildSshTarget({ user: config.user ?? parsed.user, host: config.host ?? parsed.host, port: config.port ?? parsed.port }); if (!target) return { target: rawTarget, identity: identity ?? void 0 }; return { target, identity: identity ?? config.identityFiles.find((entry) => entry.trim().length > 0)?.trim() ?? void 0 }; } //#endregion //#region src/cli/gateway-cli/call.ts const gatewayCallOpts = (cmd) => cmd.option("--url ", "Gateway WebSocket URL (defaults to gateway.remote.url when configured)").option("--token ", "Gateway token (if required)").option("--password ", "Gateway password (password auth)").option("--timeout ", "Timeout in ms", "10000").option("--expect-final", "Wait for final response (agent)", false).option("--json", "Output JSON", false); const callGatewayCli = async (method, opts, params) => withProgress({ label: `Gateway ${method}`, indeterminate: true, enabled: opts.json !== true }, async () => await callGateway({ url: opts.url, token: opts.token, password: opts.password, method, params, expectFinal: Boolean(opts.expectFinal), timeoutMs: Number(opts.timeout ?? 1e4), clientName: GATEWAY_CLIENT_NAMES.CLI, mode: GATEWAY_CLIENT_MODES.CLI })); //#endregion //#region src/cli/gateway-cli/discover.ts function parseDiscoverTimeoutMs(raw, fallbackMs) { if (raw === void 0 || raw === null) return fallbackMs; const value = typeof raw === "string" ? raw.trim() : typeof raw === "number" || typeof raw === "bigint" ? String(raw) : null; if (value === null) throw new Error("invalid --timeout"); if (!value) return fallbackMs; const parsed = Number.parseInt(value, 10); if (!Number.isFinite(parsed) || parsed <= 0) throw new Error(`invalid --timeout: ${value}`); return parsed; } function pickBeaconHost(beacon) { const host = beacon.tailnetDns || beacon.lanHost || beacon.host; return host?.trim() ? host.trim() : null; } function pickGatewayPort(beacon) { const port = beacon.gatewayPort ?? 18789; return port > 0 ? port : 18789; } function dedupeBeacons(beacons) { const out = []; const seen = /* @__PURE__ */ new Set(); for (const b of beacons) { const host = pickBeaconHost(b) ?? ""; const key = [ b.domain ?? "", b.instanceName ?? "", b.displayName ?? "", host, String(b.port ?? ""), String(b.gatewayPort ?? "") ].join("|"); if (seen.has(key)) continue; seen.add(key); out.push(b); } return out; } function renderBeaconLines(beacon, rich) { const nameRaw = (beacon.displayName || beacon.instanceName || "Gateway").trim(); const domainRaw = (beacon.domain || "local.").trim(); const title = colorize(rich, theme.accentBright, nameRaw); const domain = colorize(rich, theme.muted, domainRaw); const host = pickBeaconHost(beacon); const gatewayPort = pickGatewayPort(beacon); const scheme = beacon.gatewayTls ? "wss" : "ws"; const wsUrl = host ? `${scheme}://${host}:${gatewayPort}` : null; const lines = [`- ${title} ${domain}`]; if (beacon.tailnetDns) lines.push(` ${colorize(rich, theme.info, "tailnet")}: ${beacon.tailnetDns}`); if (beacon.lanHost) lines.push(` ${colorize(rich, theme.info, "lan")}: ${beacon.lanHost}`); if (beacon.host) lines.push(` ${colorize(rich, theme.info, "host")}: ${beacon.host}`); if (wsUrl) lines.push(` ${colorize(rich, theme.muted, "ws")}: ${colorize(rich, theme.command, wsUrl)}`); if (beacon.role) lines.push(` ${colorize(rich, theme.muted, "role")}: ${beacon.role}`); if (beacon.transport) lines.push(` ${colorize(rich, theme.muted, "transport")}: ${beacon.transport}`); if (beacon.gatewayTls) { const fingerprint = beacon.gatewayTlsFingerprintSha256 ? `sha256 ${beacon.gatewayTlsFingerprintSha256}` : "enabled"; lines.push(` ${colorize(rich, theme.muted, "tls")}: ${fingerprint}`); } if (typeof beacon.sshPort === "number" && beacon.sshPort > 0 && host) { const ssh = `ssh -N -L 18789:127.0.0.1:18789 @${host} -p ${beacon.sshPort}`; lines.push(` ${colorize(rich, theme.muted, "ssh")}: ${colorize(rich, theme.command, ssh)}`); } return lines; } //#endregion //#region src/gateway/server/close-reason.ts const CLOSE_REASON_MAX_BYTES = 120; function truncateCloseReason(reason, maxBytes = CLOSE_REASON_MAX_BYTES) { if (!reason) return "invalid handshake"; const buf = Buffer$1.from(reason); if (buf.length <= maxBytes) return reason; return buf.subarray(0, maxBytes).toString(); } //#endregion //#region src/infra/exec-approval-forwarder.ts const log$3 = createSubsystemLogger("gateway/exec-approvals"); const DEFAULT_MODE = "session"; function normalizeMode(mode) { return mode ?? DEFAULT_MODE; } function matchSessionFilter(sessionKey, patterns) { return patterns.some((pattern) => { try { return sessionKey.includes(pattern) || new RegExp(pattern).test(sessionKey); } catch { return sessionKey.includes(pattern); } }); } function shouldForward(params) { const config = params.config; if (!config?.enabled) return false; if (config.agentFilter?.length) { const agentId = params.request.request.agentId ?? parseAgentSessionKey(params.request.request.sessionKey)?.agentId; if (!agentId) return false; if (!config.agentFilter.includes(agentId)) return false; } if (config.sessionFilter?.length) { const sessionKey = params.request.request.sessionKey; if (!sessionKey) return false; if (!matchSessionFilter(sessionKey, config.sessionFilter)) return false; } return true; } function buildTargetKey(target) { const channel = normalizeMessageChannel(target.channel) ?? target.channel; const accountId = target.accountId ?? ""; const threadId = target.threadId ?? ""; return [ channel, target.to, accountId, threadId ].join(":"); } function buildRequestMessage(request, nowMs) { const lines = ["🔒 Exec approval required", `ID: ${request.id}`]; lines.push(`Command: ${request.request.command}`); if (request.request.cwd) lines.push(`CWD: ${request.request.cwd}`); if (request.request.host) lines.push(`Host: ${request.request.host}`); if (request.request.agentId) lines.push(`Agent: ${request.request.agentId}`); if (request.request.security) lines.push(`Security: ${request.request.security}`); if (request.request.ask) lines.push(`Ask: ${request.request.ask}`); const expiresIn = Math.max(0, Math.round((request.expiresAtMs - nowMs) / 1e3)); lines.push(`Expires in: ${expiresIn}s`); lines.push("Reply with: /approve allow-once|allow-always|deny"); return lines.join("\n"); } function decisionLabel(decision) { if (decision === "allow-once") return "allowed once"; if (decision === "allow-always") return "allowed always"; return "denied"; } function buildResolvedMessage(resolved) { return `${`✅ Exec approval ${decisionLabel(resolved.decision)}.`}${resolved.resolvedBy ? ` Resolved by ${resolved.resolvedBy}.` : ""} ID: ${resolved.id}`; } function buildExpiredMessage(request) { return `⏱️ Exec approval expired. ID: ${request.id}`; } function defaultResolveSessionTarget(params) { const sessionKey = params.request.request.sessionKey?.trim(); if (!sessionKey) return null; const agentId = parseAgentSessionKey(sessionKey)?.agentId ?? params.request.request.agentId ?? "main"; const entry = loadSessionStore(resolveStorePath(params.cfg.session?.store, { agentId }))[sessionKey]; if (!entry) return null; const target = resolveSessionDeliveryTarget({ entry, requestedChannel: "last" }); if (!target.channel || !target.to) return null; if (!isDeliverableMessageChannel(target.channel)) return null; return { channel: target.channel, to: target.to, accountId: target.accountId, threadId: target.threadId }; } async function deliverToTargets(params) { const deliveries = params.targets.map(async (target) => { if (params.shouldSend && !params.shouldSend()) return; const channel = normalizeMessageChannel(target.channel) ?? target.channel; if (!isDeliverableMessageChannel(channel)) return; try { await params.deliver({ cfg: params.cfg, channel, to: target.to, accountId: target.accountId, threadId: target.threadId, payloads: [{ text: params.text }] }); } catch (err) { log$3.error(`exec approvals: failed to deliver to ${channel}:${target.to}: ${String(err)}`); } }); await Promise.allSettled(deliveries); } function createExecApprovalForwarder(deps = {}) { const getConfig = deps.getConfig ?? loadConfig; const deliver = deps.deliver ?? deliverOutboundPayloads; const nowMs = deps.nowMs ?? Date.now; const resolveSessionTarget = deps.resolveSessionTarget ?? defaultResolveSessionTarget; const pending = /* @__PURE__ */ new Map(); const handleRequested = async (request) => { const cfg = getConfig(); const config = cfg.approvals?.exec; if (!shouldForward({ config, request })) return; const mode = normalizeMode(config?.mode); const targets = []; const seen = /* @__PURE__ */ new Set(); if (mode === "session" || mode === "both") { const sessionTarget = resolveSessionTarget({ cfg, request }); if (sessionTarget) { const key = buildTargetKey(sessionTarget); if (!seen.has(key)) { seen.add(key); targets.push({ ...sessionTarget, source: "session" }); } } } if (mode === "targets" || mode === "both") { const explicitTargets = config?.targets ?? []; for (const target of explicitTargets) { const key = buildTargetKey(target); if (seen.has(key)) continue; seen.add(key); targets.push({ ...target, source: "target" }); } } if (targets.length === 0) return; const expiresInMs = Math.max(0, request.expiresAtMs - nowMs()); const timeoutId = setTimeout(() => { (async () => { const entry = pending.get(request.id); if (!entry) return; pending.delete(request.id); const expiredText = buildExpiredMessage(request); await deliverToTargets({ cfg, targets: entry.targets, text: expiredText, deliver }); })(); }, expiresInMs); timeoutId.unref?.(); const pendingEntry = { request, targets, timeoutId }; pending.set(request.id, pendingEntry); if (pending.get(request.id) !== pendingEntry) return; await deliverToTargets({ cfg, targets, text: buildRequestMessage(request, nowMs()), deliver, shouldSend: () => pending.get(request.id) === pendingEntry }); }; const handleResolved = async (resolved) => { const entry = pending.get(resolved.id); if (!entry) return; if (entry.timeoutId) clearTimeout(entry.timeoutId); pending.delete(resolved.id); const cfg = getConfig(); const text = buildResolvedMessage(resolved); await deliverToTargets({ cfg, targets: entry.targets, text, deliver }); }; const stop = () => { for (const entry of pending.values()) if (entry.timeoutId) clearTimeout(entry.timeoutId); pending.clear(); }; return { handleRequested, handleResolved, stop }; } //#endregion //#region src/infra/update-startup.ts const UPDATE_CHECK_FILENAME = "update-check.json"; const UPDATE_CHECK_INTERVAL_MS = 1440 * 60 * 1e3; function shouldSkipCheck(allowInTests) { if (allowInTests) return false; if (process.env.VITEST || false) return true; return false; } async function readState(statePath) { try { const raw = await fs$1.readFile(statePath, "utf-8"); const parsed = JSON.parse(raw); return parsed && typeof parsed === "object" ? parsed : {}; } catch { return {}; } } async function writeState(statePath, state) { await fs$1.mkdir(path.dirname(statePath), { recursive: true }); await fs$1.writeFile(statePath, JSON.stringify(state, null, 2), "utf-8"); } async function runGatewayUpdateCheck(params) { if (shouldSkipCheck(Boolean(params.allowInTests))) return; if (params.isNixMode) return; if (params.cfg.update?.checkOnStart === false) return; const statePath = path.join(resolveStateDir(), UPDATE_CHECK_FILENAME); const state = await readState(statePath); const now = Date.now(); const lastCheckedAt = state.lastCheckedAt ? Date.parse(state.lastCheckedAt) : null; if (lastCheckedAt && Number.isFinite(lastCheckedAt)) { if (now - lastCheckedAt < UPDATE_CHECK_INTERVAL_MS) return; } const status = await checkUpdateStatus({ root: await resolveOpenClawPackageRoot({ moduleUrl: import.meta.url, argv1: process.argv[1], cwd: process.cwd() }), timeoutMs: 2500, fetchGit: false, includeRegistry: false }); const nextState = { ...state, lastCheckedAt: new Date(now).toISOString() }; if (status.installKind !== "package") { await writeState(statePath, nextState); return; } const resolved = await resolveNpmChannelTag({ channel: normalizeUpdateChannel(params.cfg.update?.channel) ?? DEFAULT_PACKAGE_CHANNEL, timeoutMs: 2500 }); const tag = resolved.tag; if (!resolved.version) { await writeState(statePath, nextState); return; } const cmp = compareSemverStrings(VERSION, resolved.version); if (cmp != null && cmp < 0) { if (state.lastNotifiedVersion !== resolved.version || state.lastNotifiedTag !== tag) { params.log.info(`update available (${tag}): v${resolved.version} (current v${VERSION}). Run: ${formatCliCommand("openclaw update")}`); nextState.lastNotifiedVersion = resolved.version; nextState.lastNotifiedTag = tag; } } await writeState(statePath, nextState); } function scheduleGatewayUpdateCheck(params) { runGatewayUpdateCheck(params).catch(() => {}); } //#endregion //#region src/gateway/config-reload.ts const DEFAULT_RELOAD_SETTINGS = { mode: "hybrid", debounceMs: 300 }; const BASE_RELOAD_RULES = [ { prefix: "gateway.remote", kind: "none" }, { prefix: "gateway.reload", kind: "none" }, { prefix: "hooks.gmail", kind: "hot", actions: ["restart-gmail-watcher"] }, { prefix: "hooks", kind: "hot", actions: ["reload-hooks"] }, { prefix: "agents.defaults.heartbeat", kind: "hot", actions: ["restart-heartbeat"] }, { prefix: "agent.heartbeat", kind: "hot", actions: ["restart-heartbeat"] }, { prefix: "cron", kind: "hot", actions: ["restart-cron"] }, { prefix: "browser", kind: "hot", actions: ["restart-browser-control"] } ]; const BASE_RELOAD_RULES_TAIL = [ { prefix: "identity", kind: "none" }, { prefix: "wizard", kind: "none" }, { prefix: "logging", kind: "none" }, { prefix: "models", kind: "none" }, { prefix: "agents", kind: "none" }, { prefix: "tools", kind: "none" }, { prefix: "bindings", kind: "none" }, { prefix: "audio", kind: "none" }, { prefix: "agent", kind: "none" }, { prefix: "routing", kind: "none" }, { prefix: "messages", kind: "none" }, { prefix: "session", kind: "none" }, { prefix: "talk", kind: "none" }, { prefix: "skills", kind: "none" }, { prefix: "plugins", kind: "restart" }, { prefix: "ui", kind: "none" }, { prefix: "gateway", kind: "restart" }, { prefix: "discovery", kind: "restart" }, { prefix: "canvasHost", kind: "restart" } ]; let cachedReloadRules = null; let cachedRegistry = null; function listReloadRules() { const registry = getActivePluginRegistry(); if (registry !== cachedRegistry) { cachedReloadRules = null; cachedRegistry = registry; } if (cachedReloadRules) return cachedReloadRules; const channelReloadRules = listChannelPlugins().flatMap((plugin) => [...(plugin.reload?.configPrefixes ?? []).map((prefix) => ({ prefix, kind: "hot", actions: [`restart-channel:${plugin.id}`] })), ...(plugin.reload?.noopPrefixes ?? []).map((prefix) => ({ prefix, kind: "none" }))]); const rules = [ ...BASE_RELOAD_RULES, ...channelReloadRules, ...BASE_RELOAD_RULES_TAIL ]; cachedReloadRules = rules; return rules; } function matchRule(path) { for (const rule of listReloadRules()) if (path === rule.prefix || path.startsWith(`${rule.prefix}.`)) return rule; return null; } function isPlainObject$1(value) { return Boolean(value && typeof value === "object" && !Array.isArray(value) && Object.prototype.toString.call(value) === "[object Object]"); } function diffConfigPaths(prev, next, prefix = "") { if (prev === next) return []; if (isPlainObject$1(prev) && isPlainObject$1(next)) { const keys = new Set([...Object.keys(prev), ...Object.keys(next)]); const paths = []; for (const key of keys) { const prevValue = prev[key]; const nextValue = next[key]; if (prevValue === void 0 && nextValue === void 0) continue; const childPaths = diffConfigPaths(prevValue, nextValue, prefix ? `${prefix}.${key}` : key); if (childPaths.length > 0) paths.push(...childPaths); } return paths; } if (Array.isArray(prev) && Array.isArray(next)) { if (prev.length === next.length && prev.every((val, idx) => val === next[idx])) return []; } return [prefix || ""]; } function resolveGatewayReloadSettings(cfg) { const rawMode = cfg.gateway?.reload?.mode; const mode = rawMode === "off" || rawMode === "restart" || rawMode === "hot" || rawMode === "hybrid" ? rawMode : DEFAULT_RELOAD_SETTINGS.mode; const debounceRaw = cfg.gateway?.reload?.debounceMs; return { mode, debounceMs: typeof debounceRaw === "number" && Number.isFinite(debounceRaw) ? Math.max(0, Math.floor(debounceRaw)) : DEFAULT_RELOAD_SETTINGS.debounceMs }; } function buildGatewayReloadPlan(changedPaths) { const plan = { changedPaths, restartGateway: false, restartReasons: [], hotReasons: [], reloadHooks: false, restartGmailWatcher: false, restartBrowserControl: false, restartCron: false, restartHeartbeat: false, restartChannels: /* @__PURE__ */ new Set(), noopPaths: [] }; const applyAction = (action) => { if (action.startsWith("restart-channel:")) { const channel = action.slice(16); plan.restartChannels.add(channel); return; } switch (action) { case "reload-hooks": plan.reloadHooks = true; break; case "restart-gmail-watcher": plan.restartGmailWatcher = true; break; case "restart-browser-control": plan.restartBrowserControl = true; break; case "restart-cron": plan.restartCron = true; break; case "restart-heartbeat": plan.restartHeartbeat = true; break; default: break; } }; for (const path of changedPaths) { const rule = matchRule(path); if (!rule) { plan.restartGateway = true; plan.restartReasons.push(path); continue; } if (rule.kind === "restart") { plan.restartGateway = true; plan.restartReasons.push(path); continue; } if (rule.kind === "none") { plan.noopPaths.push(path); continue; } plan.hotReasons.push(path); for (const action of rule.actions ?? []) applyAction(action); } if (plan.restartGmailWatcher) plan.reloadHooks = true; return plan; } function startGatewayConfigReloader(opts) { let currentConfig = opts.initialConfig; let settings = resolveGatewayReloadSettings(currentConfig); let debounceTimer = null; let pending = false; let running = false; let stopped = false; let restartQueued = false; const schedule = () => { if (stopped) return; if (debounceTimer) clearTimeout(debounceTimer); const wait = settings.debounceMs; debounceTimer = setTimeout(() => { runReload(); }, wait); }; const runReload = async () => { if (stopped) return; if (running) { pending = true; return; } running = true; if (debounceTimer) { clearTimeout(debounceTimer); debounceTimer = null; } try { const snapshot = await opts.readSnapshot(); if (!snapshot.valid) { const issues = snapshot.issues.map((issue) => `${issue.path}: ${issue.message}`).join(", "); opts.log.warn(`config reload skipped (invalid config): ${issues}`); return; } const nextConfig = snapshot.config; const changedPaths = diffConfigPaths(currentConfig, nextConfig); currentConfig = nextConfig; settings = resolveGatewayReloadSettings(nextConfig); if (changedPaths.length === 0) return; opts.log.info(`config change detected; evaluating reload (${changedPaths.join(", ")})`); const plan = buildGatewayReloadPlan(changedPaths); if (settings.mode === "off") { opts.log.info("config reload disabled (gateway.reload.mode=off)"); return; } if (settings.mode === "restart") { if (!restartQueued) { restartQueued = true; opts.onRestart(plan, nextConfig); } return; } if (plan.restartGateway) { if (settings.mode === "hot") { opts.log.warn(`config reload requires gateway restart; hot mode ignoring (${plan.restartReasons.join(", ")})`); return; } if (!restartQueued) { restartQueued = true; opts.onRestart(plan, nextConfig); } return; } await opts.onHotReload(plan, nextConfig); } catch (err) { opts.log.error(`config reload failed: ${String(err)}`); } finally { running = false; if (pending) { pending = false; schedule(); } } }; const watcher = chokidar.watch(opts.watchPath, { ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 }, usePolling: Boolean(process.env.VITEST) }); watcher.on("add", schedule); watcher.on("change", schedule); watcher.on("unlink", schedule); let watcherClosed = false; watcher.on("error", (err) => { if (watcherClosed) return; watcherClosed = true; opts.log.warn(`config watcher error: ${String(err)}`); watcher.close().catch(() => {}); }); return { stop: async () => { stopped = true; if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = null; watcherClosed = true; await watcher.close().catch(() => {}); } }; } //#endregion //#region src/gateway/exec-approval-manager.ts var ExecApprovalManager = class { constructor() { this.pending = /* @__PURE__ */ new Map(); } create(request, timeoutMs, id) { const now = Date.now(); return { id: id && id.trim().length > 0 ? id.trim() : randomUUID(), request, createdAtMs: now, expiresAtMs: now + timeoutMs }; } async waitForDecision(record, timeoutMs) { return await new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pending.delete(record.id); resolve(null); }, timeoutMs); this.pending.set(record.id, { record, resolve, reject, timer }); }); } resolve(recordId, decision, resolvedBy) { const pending = this.pending.get(recordId); if (!pending) return false; clearTimeout(pending.timer); pending.record.resolvedAtMs = Date.now(); pending.record.decision = decision; pending.record.resolvedBy = resolvedBy ?? null; this.pending.delete(recordId); pending.resolve(decision); return true; } getSnapshot(recordId) { return this.pending.get(recordId)?.record ?? null; } }; //#endregion //#region src/gateway/node-registry.ts var NodeRegistry = class { constructor() { this.nodesById = /* @__PURE__ */ new Map(); this.nodesByConn = /* @__PURE__ */ new Map(); this.pendingInvokes = /* @__PURE__ */ new Map(); } register(client, opts) { const connect = client.connect; const nodeId = connect.device?.id ?? connect.client.id; const caps = Array.isArray(connect.caps) ? connect.caps : []; const commands = Array.isArray(connect.commands) ? connect.commands ?? [] : []; const permissions = typeof connect.permissions === "object" ? connect.permissions ?? void 0 : void 0; const pathEnv = typeof connect.pathEnv === "string" ? connect.pathEnv : void 0; const session = { nodeId, connId: client.connId, client, displayName: connect.client.displayName, platform: connect.client.platform, version: connect.client.version, coreVersion: connect.coreVersion, uiVersion: connect.uiVersion, deviceFamily: connect.client.deviceFamily, modelIdentifier: connect.client.modelIdentifier, remoteIp: opts.remoteIp, caps, commands, permissions, pathEnv, connectedAtMs: Date.now() }; this.nodesById.set(nodeId, session); this.nodesByConn.set(client.connId, nodeId); return session; } unregister(connId) { const nodeId = this.nodesByConn.get(connId); if (!nodeId) return null; this.nodesByConn.delete(connId); this.nodesById.delete(nodeId); for (const [id, pending] of this.pendingInvokes.entries()) { if (pending.nodeId !== nodeId) continue; clearTimeout(pending.timer); pending.reject(/* @__PURE__ */ new Error(`node disconnected (${pending.command})`)); this.pendingInvokes.delete(id); } return nodeId; } listConnected() { return [...this.nodesById.values()]; } get(nodeId) { return this.nodesById.get(nodeId); } async invoke(params) { const node = this.nodesById.get(params.nodeId); if (!node) return { ok: false, error: { code: "NOT_CONNECTED", message: "node not connected" } }; const requestId = randomUUID(); const payload = { id: requestId, nodeId: params.nodeId, command: params.command, paramsJSON: "params" in params && params.params !== void 0 ? JSON.stringify(params.params) : null, timeoutMs: params.timeoutMs, idempotencyKey: params.idempotencyKey }; if (!this.sendEventToSession(node, "node.invoke.request", payload)) return { ok: false, error: { code: "UNAVAILABLE", message: "failed to send invoke to node" } }; const timeoutMs = typeof params.timeoutMs === "number" ? params.timeoutMs : 3e4; return await new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pendingInvokes.delete(requestId); resolve({ ok: false, error: { code: "TIMEOUT", message: "node invoke timed out" } }); }, timeoutMs); this.pendingInvokes.set(requestId, { nodeId: params.nodeId, command: params.command, resolve, reject, timer }); }); } handleInvokeResult(params) { const pending = this.pendingInvokes.get(params.id); if (!pending) return false; if (pending.nodeId !== params.nodeId) return false; clearTimeout(pending.timer); this.pendingInvokes.delete(params.id); pending.resolve({ ok: params.ok, payload: params.payload, payloadJSON: params.payloadJSON ?? null, error: params.error ?? null }); return true; } sendEvent(nodeId, event, payload) { const node = this.nodesById.get(nodeId); if (!node) return false; return this.sendEventToSession(node, event, payload); } sendEventInternal(node, event, payload) { try { node.client.socket.send(JSON.stringify({ type: "event", event, payload })); return true; } catch { return false; } } sendEventToSession(node, event, payload) { return this.sendEventInternal(node, event, payload); } }; //#endregion //#region src/gateway/server-channels.ts function createRuntimeStore() { return { aborts: /* @__PURE__ */ new Map(), tasks: /* @__PURE__ */ new Map(), runtimes: /* @__PURE__ */ new Map() }; } function isAccountEnabled(account) { if (!account || typeof account !== "object") return true; return account.enabled !== false; } function resolveDefaultRuntime(channelId) { return getChannelPlugin(channelId)?.status?.defaultRuntime ?? { accountId: DEFAULT_ACCOUNT_ID }; } function cloneDefaultRuntime(channelId, accountId) { return { ...resolveDefaultRuntime(channelId), accountId }; } function createChannelManager(opts) { const { loadConfig, channelLogs, channelRuntimeEnvs } = opts; const channelStores = /* @__PURE__ */ new Map(); const getStore = (channelId) => { const existing = channelStores.get(channelId); if (existing) return existing; const next = createRuntimeStore(); channelStores.set(channelId, next); return next; }; const getRuntime = (channelId, accountId) => { return getStore(channelId).runtimes.get(accountId) ?? cloneDefaultRuntime(channelId, accountId); }; const setRuntime = (channelId, accountId, patch) => { const store = getStore(channelId); const next = { ...getRuntime(channelId, accountId), ...patch, accountId }; store.runtimes.set(accountId, next); return next; }; const startChannel = async (channelId, accountId) => { const plugin = getChannelPlugin(channelId); const startAccount = plugin?.gateway?.startAccount; if (!startAccount) return; const cfg = loadConfig(); resetDirectoryCache({ channel: channelId, accountId }); const store = getStore(channelId); const accountIds = accountId ? [accountId] : plugin.config.listAccountIds(cfg); if (accountIds.length === 0) return; await Promise.all(accountIds.map(async (id) => { if (store.tasks.has(id)) return; const account = plugin.config.resolveAccount(cfg, id); if (!(plugin.config.isEnabled ? plugin.config.isEnabled(account, cfg) : isAccountEnabled(account))) { setRuntime(channelId, id, { accountId: id, running: false, lastError: plugin.config.disabledReason?.(account, cfg) ?? "disabled" }); return; } let configured = true; if (plugin.config.isConfigured) configured = await plugin.config.isConfigured(account, cfg); if (!configured) { setRuntime(channelId, id, { accountId: id, running: false, lastError: plugin.config.unconfiguredReason?.(account, cfg) ?? "not configured" }); return; } const abort = new AbortController(); store.aborts.set(id, abort); setRuntime(channelId, id, { accountId: id, running: true, lastStartAt: Date.now(), lastError: null }); const log = channelLogs[channelId]; const task = startAccount({ cfg, accountId: id, account, runtime: channelRuntimeEnvs[channelId], abortSignal: abort.signal, log, getStatus: () => getRuntime(channelId, id), setStatus: (next) => setRuntime(channelId, id, next) }); const tracked = Promise.resolve(task).catch((err) => { const message = formatErrorMessage(err); setRuntime(channelId, id, { accountId: id, lastError: message }); log.error?.(`[${id}] channel exited: ${message}`); }).finally(() => { store.aborts.delete(id); store.tasks.delete(id); setRuntime(channelId, id, { accountId: id, running: false, lastStopAt: Date.now() }); }); store.tasks.set(id, tracked); })); }; const stopChannel = async (channelId, accountId) => { const plugin = getChannelPlugin(channelId); const cfg = loadConfig(); const store = getStore(channelId); const knownIds = new Set([ ...store.aborts.keys(), ...store.tasks.keys(), ...plugin ? plugin.config.listAccountIds(cfg) : [] ]); if (accountId) { knownIds.clear(); knownIds.add(accountId); } await Promise.all(Array.from(knownIds.values()).map(async (id) => { const abort = store.aborts.get(id); const task = store.tasks.get(id); if (!abort && !task && !plugin?.gateway?.stopAccount) return; abort?.abort(); if (plugin?.gateway?.stopAccount) { const account = plugin.config.resolveAccount(cfg, id); await plugin.gateway.stopAccount({ cfg, accountId: id, account, runtime: channelRuntimeEnvs[channelId], abortSignal: abort?.signal ?? new AbortController().signal, log: channelLogs[channelId], getStatus: () => getRuntime(channelId, id), setStatus: (next) => setRuntime(channelId, id, next) }); } try { await task; } catch {} store.aborts.delete(id); store.tasks.delete(id); setRuntime(channelId, id, { accountId: id, running: false, lastStopAt: Date.now() }); })); }; const startChannels = async () => { for (const plugin of listChannelPlugins()) await startChannel(plugin.id); }; const markChannelLoggedOut = (channelId, cleared, accountId) => { const plugin = getChannelPlugin(channelId); if (!plugin) return; const cfg = loadConfig(); const resolvedId = accountId ?? resolveChannelDefaultAccountId({ plugin, cfg }); const current = getRuntime(channelId, resolvedId); const next = { accountId: resolvedId, running: false, lastError: cleared ? "logged out" : current.lastError }; if (typeof current.connected === "boolean") next.connected = false; setRuntime(channelId, resolvedId, next); }; const getRuntimeSnapshot = () => { const cfg = loadConfig(); const channels = {}; const channelAccounts = {}; for (const plugin of listChannelPlugins()) { const store = getStore(plugin.id); const accountIds = plugin.config.listAccountIds(cfg); const defaultAccountId = resolveChannelDefaultAccountId({ plugin, cfg, accountIds }); const accounts = {}; for (const id of accountIds) { const account = plugin.config.resolveAccount(cfg, id); const enabled = plugin.config.isEnabled ? plugin.config.isEnabled(account, cfg) : isAccountEnabled(account); const configured = (plugin.config.describeAccount?.(account, cfg))?.configured; const next = { ...store.runtimes.get(id) ?? cloneDefaultRuntime(plugin.id, id), accountId: id }; if (!next.running) { if (!enabled) next.lastError ??= plugin.config.disabledReason?.(account, cfg) ?? "disabled"; else if (configured === false) next.lastError ??= plugin.config.unconfiguredReason?.(account, cfg) ?? "not configured"; } accounts[id] = next; } const defaultAccount = accounts[defaultAccountId] ?? cloneDefaultRuntime(plugin.id, defaultAccountId); channels[plugin.id] = defaultAccount; channelAccounts[plugin.id] = accounts; } return { channels, channelAccounts }; }; return { getRuntimeSnapshot, startChannels, startChannel, stopChannel, markChannelLoggedOut }; } //#endregion //#region src/gateway/server-chat.ts /** * Check if webchat broadcasts should be suppressed for heartbeat runs. * Returns true if the run is a heartbeat and showOk is false. */ function shouldSuppressHeartbeatBroadcast(runId) { if (!getAgentRunContext(runId)?.isHeartbeat) return false; try { return !resolveHeartbeatVisibility({ cfg: loadConfig(), channel: "webchat" }).showOk; } catch { return true; } } function createChatRunRegistry() { const chatRunSessions = /* @__PURE__ */ new Map(); const add = (sessionId, entry) => { const queue = chatRunSessions.get(sessionId); if (queue) queue.push(entry); else chatRunSessions.set(sessionId, [entry]); }; const peek = (sessionId) => chatRunSessions.get(sessionId)?.[0]; const shift = (sessionId) => { const queue = chatRunSessions.get(sessionId); if (!queue || queue.length === 0) return; const entry = queue.shift(); if (!queue.length) chatRunSessions.delete(sessionId); return entry; }; const remove = (sessionId, clientRunId, sessionKey) => { const queue = chatRunSessions.get(sessionId); if (!queue || queue.length === 0) return; const idx = queue.findIndex((entry) => entry.clientRunId === clientRunId && (sessionKey ? entry.sessionKey === sessionKey : true)); if (idx < 0) return; const [entry] = queue.splice(idx, 1); if (!queue.length) chatRunSessions.delete(sessionId); return entry; }; const clear = () => { chatRunSessions.clear(); }; return { add, peek, shift, remove, clear }; } function createChatRunState() { const registry = createChatRunRegistry(); const buffers = /* @__PURE__ */ new Map(); const deltaSentAt = /* @__PURE__ */ new Map(); const abortedRuns = /* @__PURE__ */ new Map(); const clear = () => { registry.clear(); buffers.clear(); deltaSentAt.clear(); abortedRuns.clear(); }; return { registry, buffers, deltaSentAt, abortedRuns, clear }; } const TOOL_EVENT_RECIPIENT_TTL_MS = 600 * 1e3; const TOOL_EVENT_RECIPIENT_FINAL_GRACE_MS = 30 * 1e3; function createToolEventRecipientRegistry() { const recipients = /* @__PURE__ */ new Map(); const prune = () => { if (recipients.size === 0) return; const now = Date.now(); for (const [runId, entry] of recipients) if (now >= (entry.finalizedAt ? entry.finalizedAt + TOOL_EVENT_RECIPIENT_FINAL_GRACE_MS : entry.updatedAt + TOOL_EVENT_RECIPIENT_TTL_MS)) recipients.delete(runId); }; const add = (runId, connId) => { if (!runId || !connId) return; const now = Date.now(); const existing = recipients.get(runId); if (existing) { existing.connIds.add(connId); existing.updatedAt = now; } else recipients.set(runId, { connIds: new Set([connId]), updatedAt: now }); prune(); }; const get = (runId) => { const entry = recipients.get(runId); if (!entry) return; entry.updatedAt = Date.now(); prune(); return entry.connIds; }; const markFinal = (runId) => { const entry = recipients.get(runId); if (!entry) return; entry.finalizedAt = Date.now(); prune(); }; return { add, get, markFinal }; } function createAgentEventHandler({ broadcast, broadcastToConnIds, nodeSendToSession, agentRunSeq, chatRunState, resolveSessionKeyForRun, clearAgentRunContext, toolEventRecipients }) { const emitChatDelta = (sessionKey, clientRunId, seq, text) => { chatRunState.buffers.set(clientRunId, text); const now = Date.now(); if (now - (chatRunState.deltaSentAt.get(clientRunId) ?? 0) < 150) return; chatRunState.deltaSentAt.set(clientRunId, now); const payload = { runId: clientRunId, sessionKey, seq, state: "delta", message: { role: "assistant", content: [{ type: "text", text }], timestamp: now } }; if (!shouldSuppressHeartbeatBroadcast(clientRunId)) broadcast("chat", payload, { dropIfSlow: true }); nodeSendToSession(sessionKey, "chat", payload); }; const emitChatFinal = (sessionKey, clientRunId, seq, jobState, error) => { const text = chatRunState.buffers.get(clientRunId)?.trim() ?? ""; chatRunState.buffers.delete(clientRunId); chatRunState.deltaSentAt.delete(clientRunId); if (jobState === "done") { const payload = { runId: clientRunId, sessionKey, seq, state: "final", message: text ? { role: "assistant", content: [{ type: "text", text }], timestamp: Date.now() } : void 0 }; if (!shouldSuppressHeartbeatBroadcast(clientRunId)) broadcast("chat", payload); nodeSendToSession(sessionKey, "chat", payload); return; } const payload = { runId: clientRunId, sessionKey, seq, state: "error", errorMessage: error ? formatForLog(error) : void 0 }; broadcast("chat", payload); nodeSendToSession(sessionKey, "chat", payload); }; const resolveToolVerboseLevel = (runId, sessionKey) => { const runVerbose = normalizeVerboseLevel(getAgentRunContext(runId)?.verboseLevel); if (runVerbose) return runVerbose; if (!sessionKey) return "off"; try { const { cfg, entry } = loadSessionEntry(sessionKey); const sessionVerbose = normalizeVerboseLevel(entry?.verboseLevel); if (sessionVerbose) return sessionVerbose; return normalizeVerboseLevel(cfg.agents?.defaults?.verboseDefault) ?? "off"; } catch { return "off"; } }; return (evt) => { const chatLink = chatRunState.registry.peek(evt.runId); const sessionKey = chatLink?.sessionKey ?? resolveSessionKeyForRun(evt.runId); const clientRunId = chatLink?.clientRunId ?? evt.runId; const isAborted = chatRunState.abortedRuns.has(clientRunId) || chatRunState.abortedRuns.has(evt.runId); const agentPayload = sessionKey ? { ...evt, sessionKey } : evt; const last = agentRunSeq.get(evt.runId) ?? 0; const isToolEvent = evt.stream === "tool"; const toolVerbose = isToolEvent ? resolveToolVerboseLevel(evt.runId, sessionKey) : "off"; if (isToolEvent && toolVerbose === "off") { agentRunSeq.set(evt.runId, evt.seq); return; } const toolPayload = isToolEvent && toolVerbose !== "full" ? (() => { const data = evt.data ? { ...evt.data } : {}; delete data.result; delete data.partialResult; return sessionKey ? { ...evt, sessionKey, data } : { ...evt, data }; })() : agentPayload; if (evt.seq !== last + 1) broadcast("agent", { runId: evt.runId, stream: "error", ts: Date.now(), sessionKey, data: { reason: "seq gap", expected: last + 1, received: evt.seq } }); agentRunSeq.set(evt.runId, evt.seq); if (isToolEvent) { const recipients = toolEventRecipients.get(evt.runId); if (recipients && recipients.size > 0) broadcastToConnIds("agent", toolPayload, recipients); } else broadcast("agent", agentPayload); const lifecyclePhase = evt.stream === "lifecycle" && typeof evt.data?.phase === "string" ? evt.data.phase : null; if (sessionKey) { nodeSendToSession(sessionKey, "agent", isToolEvent ? toolPayload : agentPayload); if (!isAborted && evt.stream === "assistant" && typeof evt.data?.text === "string") emitChatDelta(sessionKey, clientRunId, evt.seq, evt.data.text); else if (!isAborted && (lifecyclePhase === "end" || lifecyclePhase === "error")) if (chatLink) { const finished = chatRunState.registry.shift(evt.runId); if (!finished) { clearAgentRunContext(evt.runId); return; } emitChatFinal(finished.sessionKey, finished.clientRunId, evt.seq, lifecyclePhase === "error" ? "error" : "done", evt.data?.error); } else emitChatFinal(sessionKey, evt.runId, evt.seq, lifecyclePhase === "error" ? "error" : "done", evt.data?.error); else if (isAborted && (lifecyclePhase === "end" || lifecyclePhase === "error")) { chatRunState.abortedRuns.delete(clientRunId); chatRunState.abortedRuns.delete(evt.runId); chatRunState.buffers.delete(clientRunId); chatRunState.deltaSentAt.delete(clientRunId); if (chatLink) chatRunState.registry.remove(evt.runId, clientRunId, sessionKey); } } if (lifecyclePhase === "end" || lifecyclePhase === "error") { toolEventRecipients.markFinal(evt.runId); clearAgentRunContext(evt.runId); } }; } //#endregion //#region src/hooks/gmail-watcher.ts /** * Gmail Watcher Service * * Automatically starts `gog gmail watch serve` when the gateway starts, * if hooks.gmail is configured with an account. */ const log$2 = createSubsystemLogger("gmail-watcher"); const ADDRESS_IN_USE_RE = /address already in use|EADDRINUSE/i; function isAddressInUseError(line) { return ADDRESS_IN_USE_RE.test(line); } let watcherProcess = null; let renewInterval = null; let shuttingDown = false; let currentConfig = null; /** * Check if gog binary is available */ function isGogAvailable() { return hasBinary("gog"); } /** * Start the Gmail watch (registers with Gmail API) */ async function startGmailWatch(cfg) { const args = ["gog", ...buildGogWatchStartArgs(cfg)]; try { const result = await runCommandWithTimeout(args, { timeoutMs: 12e4 }); if (result.code !== 0) { const message = result.stderr || result.stdout || "gog watch start failed"; log$2.error(`watch start failed: ${message}`); return false; } log$2.info(`watch started for ${cfg.account}`); return true; } catch (err) { log$2.error(`watch start error: ${String(err)}`); return false; } } /** * Spawn the gog gmail watch serve process */ function spawnGogServe(cfg) { const args = buildGogWatchServeArgs(cfg); log$2.info(`starting gog ${args.join(" ")}`); let addressInUse = false; const child = spawn("gog", args, { stdio: [ "ignore", "pipe", "pipe" ], detached: false }); child.stdout?.on("data", (data) => { const line = data.toString().trim(); if (line) log$2.info(`[gog] ${line}`); }); child.stderr?.on("data", (data) => { const line = data.toString().trim(); if (!line) return; if (isAddressInUseError(line)) addressInUse = true; log$2.warn(`[gog] ${line}`); }); child.on("error", (err) => { log$2.error(`gog process error: ${String(err)}`); }); child.on("exit", (code, signal) => { if (shuttingDown) return; if (addressInUse) { log$2.warn("gog serve failed to bind (address already in use); stopping restarts. Another watcher is likely running. Set OPENCLAW_SKIP_GMAIL_WATCHER=1 or stop the other process."); watcherProcess = null; return; } log$2.warn(`gog exited (code=${code}, signal=${signal}); restarting in 5s`); watcherProcess = null; setTimeout(() => { if (shuttingDown || !currentConfig) return; watcherProcess = spawnGogServe(currentConfig); }, 5e3); }); return child; } /** * Start the Gmail watcher service. * Called automatically by the gateway if hooks.gmail is configured. */ async function startGmailWatcher(cfg) { if (!cfg.hooks?.enabled) return { started: false, reason: "hooks not enabled" }; if (!cfg.hooks?.gmail?.account) return { started: false, reason: "no gmail account configured" }; if (!isGogAvailable()) return { started: false, reason: "gog binary not found" }; const resolved = resolveGmailHookRuntimeConfig(cfg, {}); if (!resolved.ok) return { started: false, reason: resolved.error }; const runtimeConfig = resolved.value; currentConfig = runtimeConfig; if (runtimeConfig.tailscale.mode !== "off") try { await ensureTailscaleEndpoint({ mode: runtimeConfig.tailscale.mode, path: runtimeConfig.tailscale.path, port: runtimeConfig.serve.port, target: runtimeConfig.tailscale.target }); log$2.info(`tailscale ${runtimeConfig.tailscale.mode} configured for port ${runtimeConfig.serve.port}`); } catch (err) { log$2.error(`tailscale setup failed: ${String(err)}`); return { started: false, reason: `tailscale setup failed: ${String(err)}` }; } if (!await startGmailWatch(runtimeConfig)) log$2.warn("gmail watch start failed, but continuing with serve"); shuttingDown = false; watcherProcess = spawnGogServe(runtimeConfig); const renewMs = runtimeConfig.renewEveryMinutes * 6e4; renewInterval = setInterval(() => { if (shuttingDown) return; startGmailWatch(runtimeConfig); }, renewMs); log$2.info(`gmail watcher started for ${runtimeConfig.account} (renew every ${runtimeConfig.renewEveryMinutes}m)`); return { started: true }; } /** * Stop the Gmail watcher service. */ async function stopGmailWatcher() { shuttingDown = true; if (renewInterval) { clearInterval(renewInterval); renewInterval = null; } if (watcherProcess) { log$2.info("stopping gmail watcher"); watcherProcess.kill("SIGTERM"); await new Promise((resolve) => { const timeout = setTimeout(() => { if (watcherProcess) watcherProcess.kill("SIGKILL"); resolve(); }, 3e3); watcherProcess?.on("exit", () => { clearTimeout(timeout); resolve(); }); }); watcherProcess = null; } currentConfig = null; log$2.info("gmail watcher stopped"); } //#endregion //#region src/gateway/server-close.ts function createGatewayCloseHandler(params) { return async (opts) => { const reason = (typeof opts?.reason === "string" ? opts.reason.trim() : "") || "gateway stopping"; const restartExpectedMs = typeof opts?.restartExpectedMs === "number" && Number.isFinite(opts.restartExpectedMs) ? Math.max(0, Math.floor(opts.restartExpectedMs)) : null; if (params.bonjourStop) try { await params.bonjourStop(); } catch {} if (params.tailscaleCleanup) await params.tailscaleCleanup(); if (params.canvasHost) try { await params.canvasHost.close(); } catch {} if (params.canvasHostServer) try { await params.canvasHostServer.close(); } catch {} for (const plugin of listChannelPlugins()) await params.stopChannel(plugin.id); if (params.pluginServices) await params.pluginServices.stop().catch(() => {}); await stopGmailWatcher(); params.cron.stop(); params.heartbeatRunner.stop(); for (const timer of params.nodePresenceTimers.values()) clearInterval(timer); params.nodePresenceTimers.clear(); params.broadcast("shutdown", { reason, restartExpectedMs }); clearInterval(params.tickInterval); clearInterval(params.healthInterval); clearInterval(params.dedupeCleanup); if (params.agentUnsub) try { params.agentUnsub(); } catch {} if (params.heartbeatUnsub) try { params.heartbeatUnsub(); } catch {} params.chatRunState.clear(); for (const c of params.clients) try { c.socket.close(1012, "service restart"); } catch {} params.clients.clear(); await params.configReloader.stop().catch(() => {}); if (params.browserControl) await params.browserControl.stop().catch(() => {}); await new Promise((resolve) => params.wss.close(() => resolve())); const servers = params.httpServers && params.httpServers.length > 0 ? params.httpServers : [params.httpServer]; for (const server of servers) { const httpServer = server; if (typeof httpServer.closeIdleConnections === "function") httpServer.closeIdleConnections(); await new Promise((resolve, reject) => httpServer.close((err) => err ? reject(err) : resolve())); } }; } //#endregion //#region src/cron/delivery.ts function normalizeChannel(value) { if (typeof value !== "string") return; const trimmed = value.trim().toLowerCase(); if (!trimmed) return; return trimmed; } function normalizeTo(value) { if (typeof value !== "string") return; const trimmed = value.trim(); return trimmed ? trimmed : void 0; } function resolveCronDeliveryPlan(job) { const payload = job.payload.kind === "agentTurn" ? job.payload : null; const delivery = job.delivery; const hasDelivery = delivery && typeof delivery === "object"; const rawMode = hasDelivery ? delivery.mode : void 0; const mode = rawMode === "announce" ? "announce" : rawMode === "none" ? "none" : rawMode === "deliver" ? "announce" : void 0; const payloadChannel = normalizeChannel(payload?.channel); const payloadTo = normalizeTo(payload?.to); const deliveryChannel = normalizeChannel(delivery?.channel); const deliveryTo = normalizeTo(delivery?.to); const channel = deliveryChannel ?? payloadChannel ?? "last"; const to = deliveryTo ?? payloadTo; if (hasDelivery) { const resolvedMode = mode ?? "none"; return { mode: resolvedMode, channel, to, source: "delivery", requested: resolvedMode === "announce" }; } const legacyMode = payload?.deliver === true ? "explicit" : payload?.deliver === false ? "off" : "auto"; const requested = legacyMode === "explicit" || legacyMode === "auto" && Boolean(to); return { mode: requested ? "announce" : "none", channel, to, source: "payload", requested }; } //#endregion //#region src/cron/isolated-agent/delivery-target.ts async function resolveDeliveryTarget(cfg, agentId, jobPayload) { const requestedChannel = typeof jobPayload.channel === "string" ? jobPayload.channel : "last"; const explicitTo = typeof jobPayload.to === "string" ? jobPayload.to : void 0; const sessionCfg = cfg.session; const mainSessionKey = resolveAgentMainSessionKey({ cfg, agentId }); const main = loadSessionStore(resolveStorePath(sessionCfg?.store, { agentId }))[mainSessionKey]; const preliminary = resolveSessionDeliveryTarget({ entry: main, requestedChannel, explicitTo, allowMismatchedLastTo: true }); let fallbackChannel; if (!preliminary.channel) try { fallbackChannel = (await resolveMessageChannelSelection({ cfg })).channel; } catch { fallbackChannel = preliminary.lastChannel ?? DEFAULT_CHAT_CHANNEL; } const resolved = fallbackChannel ? resolveSessionDeliveryTarget({ entry: main, requestedChannel, explicitTo, fallbackChannel, allowMismatchedLastTo: true, mode: preliminary.mode }) : preliminary; const channel = resolved.channel ?? fallbackChannel ?? DEFAULT_CHAT_CHANNEL; const mode = resolved.mode; const toCandidate = resolved.to; if (!toCandidate) return { channel, to: void 0, accountId: resolved.accountId, threadId: resolved.threadId, mode }; const docked = resolveOutboundTarget({ channel, to: toCandidate, cfg, accountId: resolved.accountId, mode }); return { channel, to: docked.ok ? docked.to : void 0, accountId: resolved.accountId, threadId: resolved.threadId, mode, error: docked.ok ? void 0 : docked.error }; } //#endregion //#region src/cron/isolated-agent/helpers.ts function pickSummaryFromOutput(text) { const clean = (text ?? "").trim(); if (!clean) return; const limit = 2e3; return clean.length > limit ? `${truncateUtf16Safe(clean, limit)}…` : clean; } function pickSummaryFromPayloads(payloads) { for (let i = payloads.length - 1; i >= 0; i--) { const summary = pickSummaryFromOutput(payloads[i]?.text); if (summary) return summary; } } function pickLastNonEmptyTextFromPayloads(payloads) { for (let i = payloads.length - 1; i >= 0; i--) { const clean = (payloads[i]?.text ?? "").trim(); if (clean) return clean; } } /** * Check if all payloads are just heartbeat ack responses (HEARTBEAT_OK). * Returns true if delivery should be skipped because there's no real content. */ function isHeartbeatOnlyResponse(payloads, ackMaxChars) { if (payloads.length === 0) return true; return payloads.every((payload) => { if ((payload.mediaUrls?.length ?? 0) > 0 || Boolean(payload.mediaUrl)) return false; return stripHeartbeatToken(payload.text, { mode: "heartbeat", maxAckChars: ackMaxChars }).shouldSkip; }); } function resolveHeartbeatAckMaxChars(agentCfg) { const raw = agentCfg?.heartbeat?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS; return Math.max(0, raw); } //#endregion //#region src/cron/isolated-agent/session.ts function resolveCronSession(params) { const sessionCfg = params.cfg.session; const storePath = resolveStorePath(sessionCfg?.store, { agentId: params.agentId }); const store = loadSessionStore(storePath); const entry = store[params.sessionKey]; const sessionId = crypto.randomUUID(); const systemSent = false; return { storePath, store, sessionEntry: { sessionId, updatedAt: params.nowMs, systemSent, thinkingLevel: entry?.thinkingLevel, verboseLevel: entry?.verboseLevel, model: entry?.model, contextTokens: entry?.contextTokens, sendPolicy: entry?.sendPolicy, lastChannel: entry?.lastChannel, lastTo: entry?.lastTo, lastAccountId: entry?.lastAccountId, skillsSnapshot: entry?.skillsSnapshot }, systemSent, isNewSession: true }; } //#endregion //#region src/cron/isolated-agent/run.ts function matchesMessagingToolDeliveryTarget(target, delivery) { if (!delivery.to || !target.to) return false; const channel = delivery.channel.trim().toLowerCase(); const provider = target.provider?.trim().toLowerCase(); if (provider && provider !== "message" && provider !== channel) return false; if (target.accountId && delivery.accountId && target.accountId !== delivery.accountId) return false; return target.to === delivery.to; } function resolveCronDeliveryBestEffort(job) { if (typeof job.delivery?.bestEffort === "boolean") return job.delivery.bestEffort; if (job.payload.kind === "agentTurn" && typeof job.payload.bestEffortDeliver === "boolean") return job.payload.bestEffortDeliver; return false; } async function runCronIsolatedAgentTurn(params) { const defaultAgentId = resolveDefaultAgentId(params.cfg); const requestedAgentId = typeof params.agentId === "string" && params.agentId.trim() ? params.agentId : typeof params.job.agentId === "string" && params.job.agentId.trim() ? params.job.agentId : void 0; const normalizedRequested = requestedAgentId ? normalizeAgentId(requestedAgentId) : void 0; const agentConfigOverride = normalizedRequested ? resolveAgentConfig(params.cfg, normalizedRequested) : void 0; const { model: overrideModel, ...agentOverrideRest } = agentConfigOverride ?? {}; const agentId = agentConfigOverride ? normalizedRequested ?? defaultAgentId : defaultAgentId; const agentCfg = Object.assign({}, params.cfg.agents?.defaults, agentOverrideRest); if (typeof overrideModel === "string") agentCfg.model = { primary: overrideModel }; else if (overrideModel) agentCfg.model = overrideModel; const cfgWithAgentDefaults = { ...params.cfg, agents: Object.assign({}, params.cfg.agents, { defaults: agentCfg }) }; const baseSessionKey = (params.sessionKey?.trim() || `cron:${params.job.id}`).trim(); const agentSessionKey = buildAgentMainSessionKey({ agentId, mainKey: baseSessionKey }); const workspaceDirRaw = resolveAgentWorkspaceDir(params.cfg, agentId); const agentDir = resolveAgentDir(params.cfg, agentId); const workspaceDir = (await ensureAgentWorkspace({ dir: workspaceDirRaw, ensureBootstrapFiles: !agentCfg?.skipBootstrap })).dir; const resolvedDefault = resolveConfiguredModelRef({ cfg: cfgWithAgentDefaults, defaultProvider: DEFAULT_PROVIDER, defaultModel: DEFAULT_MODEL }); let provider = resolvedDefault.provider; let model = resolvedDefault.model; let catalog; const loadCatalog = async () => { if (!catalog) catalog = await loadModelCatalog({ config: cfgWithAgentDefaults }); return catalog; }; const isGmailHook = baseSessionKey.startsWith("hook:gmail:"); const hooksGmailModelRef = isGmailHook ? resolveHooksGmailModel({ cfg: params.cfg, defaultProvider: DEFAULT_PROVIDER }) : null; if (hooksGmailModelRef) { if (getModelRefStatus({ cfg: params.cfg, catalog: await loadCatalog(), ref: hooksGmailModelRef, defaultProvider: resolvedDefault.provider, defaultModel: resolvedDefault.model }).allowed) { provider = hooksGmailModelRef.provider; model = hooksGmailModelRef.model; } } const modelOverrideRaw = params.job.payload.kind === "agentTurn" ? params.job.payload.model : void 0; if (modelOverrideRaw !== void 0) { if (typeof modelOverrideRaw !== "string") return { status: "error", error: "invalid model: expected string" }; const resolvedOverride = resolveAllowedModelRef({ cfg: cfgWithAgentDefaults, catalog: await loadCatalog(), raw: modelOverrideRaw, defaultProvider: resolvedDefault.provider, defaultModel: resolvedDefault.model }); if ("error" in resolvedOverride) return { status: "error", error: resolvedOverride.error }; provider = resolvedOverride.ref.provider; model = resolvedOverride.ref.model; } const now = Date.now(); const cronSession = resolveCronSession({ cfg: params.cfg, sessionKey: agentSessionKey, agentId, nowMs: now }); const hooksGmailThinking = isGmailHook ? normalizeThinkLevel(params.cfg.hooks?.gmail?.thinking) : void 0; const thinkOverride = normalizeThinkLevel(agentCfg?.thinkingDefault); let thinkLevel = normalizeThinkLevel((params.job.payload.kind === "agentTurn" ? params.job.payload.thinking : void 0) ?? void 0) ?? hooksGmailThinking ?? thinkOverride; if (!thinkLevel) thinkLevel = resolveThinkingDefault({ cfg: cfgWithAgentDefaults, provider, model, catalog: await loadCatalog() }); if (thinkLevel === "xhigh" && !supportsXHighThinking(provider, model)) throw new Error(`Thinking level "xhigh" is only supported for ${formatXHighModelHint()}.`); const timeoutMs = resolveAgentTimeoutMs({ cfg: cfgWithAgentDefaults, overrideSeconds: params.job.payload.kind === "agentTurn" ? params.job.payload.timeoutSeconds : void 0 }); const agentPayload = params.job.payload.kind === "agentTurn" ? params.job.payload : null; const deliveryPlan = resolveCronDeliveryPlan(params.job); const deliveryRequested = deliveryPlan.requested; const resolvedDelivery = await resolveDeliveryTarget(cfgWithAgentDefaults, agentId, { channel: deliveryPlan.channel ?? "last", to: deliveryPlan.to }); const userTimezone = resolveUserTimezone(params.cfg.agents?.defaults?.userTimezone); const userTimeFormat = resolveUserTimeFormat(params.cfg.agents?.defaults?.timeFormat); const formattedTime = formatUserTime(new Date(now), userTimezone, userTimeFormat) ?? new Date(now).toISOString(); const timeLine = `Current time: ${formattedTime} (${userTimezone})`; const base = `[cron:${params.job.id} ${params.job.name}] ${params.message}`.trim(); const isExternalHook = isExternalHookSession(baseSessionKey); const allowUnsafeExternalContent = agentPayload?.allowUnsafeExternalContent === true || isGmailHook && params.cfg.hooks?.gmail?.allowUnsafeExternalContent === true; const shouldWrapExternal = isExternalHook && !allowUnsafeExternalContent; let commandBody; if (isExternalHook) { const suspiciousPatterns = detectSuspiciousPatterns(params.message); if (suspiciousPatterns.length > 0) logWarn(`[security] Suspicious patterns detected in external hook content (session=${baseSessionKey}, patterns=${suspiciousPatterns.length}): ${suspiciousPatterns.slice(0, 3).join(", ")}`); } if (shouldWrapExternal) { const hookType = getHookType(baseSessionKey); commandBody = `${buildSafeExternalPrompt({ content: params.message, source: hookType, jobName: params.job.name, jobId: params.job.id, timestamp: formattedTime })}\n\n${timeLine}`.trim(); } else commandBody = `${base}\n${timeLine}`.trim(); if (deliveryRequested) commandBody = `${commandBody}\n\nReturn your summary as plain text; it will be delivered automatically. If the task explicitly calls for messaging a specific external recipient, note who/where it should go instead of sending it yourself.`.trim(); const existingSnapshot = cronSession.sessionEntry.skillsSnapshot; const skillsSnapshotVersion = getSkillsSnapshotVersion(workspaceDir); const needsSkillsSnapshot = !existingSnapshot || existingSnapshot.version !== skillsSnapshotVersion; const skillsSnapshot = needsSkillsSnapshot ? buildWorkspaceSkillSnapshot(workspaceDir, { config: cfgWithAgentDefaults, eligibility: { remote: getRemoteSkillEligibility() }, snapshotVersion: skillsSnapshotVersion }) : cronSession.sessionEntry.skillsSnapshot; if (needsSkillsSnapshot && skillsSnapshot) { cronSession.sessionEntry = { ...cronSession.sessionEntry, updatedAt: Date.now(), skillsSnapshot }; cronSession.store[agentSessionKey] = cronSession.sessionEntry; await updateSessionStore(cronSession.storePath, (store) => { store[agentSessionKey] = cronSession.sessionEntry; }); } cronSession.sessionEntry.systemSent = true; cronSession.store[agentSessionKey] = cronSession.sessionEntry; await updateSessionStore(cronSession.storePath, (store) => { store[agentSessionKey] = cronSession.sessionEntry; }); let runResult; let fallbackProvider = provider; let fallbackModel = model; try { const sessionFile = resolveSessionTranscriptPath(cronSession.sessionEntry.sessionId, agentId); const resolvedVerboseLevel = normalizeVerboseLevel(cronSession.sessionEntry.verboseLevel) ?? normalizeVerboseLevel(agentCfg?.verboseDefault) ?? "off"; registerAgentRunContext(cronSession.sessionEntry.sessionId, { sessionKey: agentSessionKey, verboseLevel: resolvedVerboseLevel }); const messageChannel = resolvedDelivery.channel; const fallbackResult = await runWithModelFallback({ cfg: cfgWithAgentDefaults, provider, model, agentDir, fallbacksOverride: resolveAgentModelFallbacksOverride(params.cfg, agentId), run: (providerOverride, modelOverride) => { if (isCliProvider(providerOverride, cfgWithAgentDefaults)) { const cliSessionId = getCliSessionId(cronSession.sessionEntry, providerOverride); return runCliAgent({ sessionId: cronSession.sessionEntry.sessionId, sessionKey: agentSessionKey, sessionFile, workspaceDir, config: cfgWithAgentDefaults, prompt: commandBody, provider: providerOverride, model: modelOverride, thinkLevel, timeoutMs, runId: cronSession.sessionEntry.sessionId, cliSessionId }); } return runEmbeddedPiAgent({ sessionId: cronSession.sessionEntry.sessionId, sessionKey: agentSessionKey, messageChannel, agentAccountId: resolvedDelivery.accountId, sessionFile, workspaceDir, config: cfgWithAgentDefaults, skillsSnapshot, prompt: commandBody, lane: params.lane ?? "cron", provider: providerOverride, model: modelOverride, thinkLevel, verboseLevel: resolvedVerboseLevel, timeoutMs, runId: cronSession.sessionEntry.sessionId, requireExplicitMessageTarget: true, disableMessageTool: deliveryRequested }); } }); runResult = fallbackResult.result; fallbackProvider = fallbackResult.provider; fallbackModel = fallbackResult.model; } catch (err) { return { status: "error", error: String(err) }; } const payloads = runResult.payloads ?? []; { const usage = runResult.meta.agentMeta?.usage; const modelUsed = runResult.meta.agentMeta?.model ?? fallbackModel ?? model; const providerUsed = runResult.meta.agentMeta?.provider ?? fallbackProvider ?? provider; const contextTokens = agentCfg?.contextTokens ?? lookupContextTokens(modelUsed) ?? DEFAULT_CONTEXT_TOKENS; cronSession.sessionEntry.modelProvider = providerUsed; cronSession.sessionEntry.model = modelUsed; cronSession.sessionEntry.contextTokens = contextTokens; if (isCliProvider(providerUsed, cfgWithAgentDefaults)) { const cliSessionId = runResult.meta.agentMeta?.sessionId?.trim(); if (cliSessionId) setCliSessionId(cronSession.sessionEntry, providerUsed, cliSessionId); } if (hasNonzeroUsage(usage)) { const input = usage.input ?? 0; const output = usage.output ?? 0; const promptTokens = input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0); cronSession.sessionEntry.inputTokens = input; cronSession.sessionEntry.outputTokens = output; cronSession.sessionEntry.totalTokens = promptTokens > 0 ? promptTokens : usage.total ?? input; } cronSession.store[agentSessionKey] = cronSession.sessionEntry; await updateSessionStore(cronSession.storePath, (store) => { store[agentSessionKey] = cronSession.sessionEntry; }); } const firstText = payloads[0]?.text ?? ""; const summary = pickSummaryFromPayloads(payloads) ?? pickSummaryFromOutput(firstText); const outputText = pickLastNonEmptyTextFromPayloads(payloads); const deliveryBestEffort = resolveCronDeliveryBestEffort(params.job); const ackMaxChars = resolveHeartbeatAckMaxChars(agentCfg); const skipHeartbeatDelivery = deliveryRequested && isHeartbeatOnlyResponse(payloads, ackMaxChars); const skipMessagingToolDelivery = deliveryRequested && runResult.didSendViaMessagingTool === true && (runResult.messagingToolSentTargets ?? []).some((target) => matchesMessagingToolDeliveryTarget(target, { channel: resolvedDelivery.channel, to: resolvedDelivery.to, accountId: resolvedDelivery.accountId })); if (deliveryRequested && !skipHeartbeatDelivery && !skipMessagingToolDelivery) { if (resolvedDelivery.error) { if (!deliveryBestEffort) return { status: "error", error: resolvedDelivery.error.message, summary, outputText }; logWarn(`[cron:${params.job.id}] ${resolvedDelivery.error.message}`); return { status: "ok", summary, outputText }; } if (!resolvedDelivery.to) { const message = "cron delivery target is missing"; if (!deliveryBestEffort) return { status: "error", error: message, summary, outputText }; logWarn(`[cron:${params.job.id}] ${message}`); return { status: "ok", summary, outputText }; } try { await deliverOutboundPayloads({ cfg: cfgWithAgentDefaults, channel: resolvedDelivery.channel, to: resolvedDelivery.to, accountId: resolvedDelivery.accountId, threadId: resolvedDelivery.threadId, payloads, bestEffort: deliveryBestEffort, deps: createOutboundSendDeps$1(params.deps) }); } catch (err) { if (!deliveryBestEffort) return { status: "error", summary, outputText, error: String(err) }; } } return { status: "ok", summary, outputText }; } //#endregion //#region src/cron/run-log.ts function resolveCronRunLogPath(params) { const storePath = path.resolve(params.storePath); const dir = path.dirname(storePath); return path.join(dir, "runs", `${params.jobId}.jsonl`); } const writesByPath = /* @__PURE__ */ new Map(); async function pruneIfNeeded(filePath, opts) { const stat = await fs$1.stat(filePath).catch(() => null); if (!stat || stat.size <= opts.maxBytes) return; const lines = (await fs$1.readFile(filePath, "utf-8").catch(() => "")).split("\n").map((l) => l.trim()).filter(Boolean); const kept = lines.slice(Math.max(0, lines.length - opts.keepLines)); const tmp = `${filePath}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`; await fs$1.writeFile(tmp, `${kept.join("\n")}\n`, "utf-8"); await fs$1.rename(tmp, filePath); } async function appendCronRunLog(filePath, entry, opts) { const resolved = path.resolve(filePath); const next = (writesByPath.get(resolved) ?? Promise.resolve()).catch(() => void 0).then(async () => { await fs$1.mkdir(path.dirname(resolved), { recursive: true }); await fs$1.appendFile(resolved, `${JSON.stringify(entry)}\n`, "utf-8"); await pruneIfNeeded(resolved, { maxBytes: opts?.maxBytes ?? 2e6, keepLines: opts?.keepLines ?? 2e3 }); }); writesByPath.set(resolved, next); await next; } async function readCronRunLogEntries(filePath, opts) { const limit = Math.max(1, Math.min(5e3, Math.floor(opts?.limit ?? 200))); const jobId = opts?.jobId?.trim() || void 0; const raw = await fs$1.readFile(path.resolve(filePath), "utf-8").catch(() => ""); if (!raw.trim()) return []; const parsed = []; const lines = raw.split("\n"); for (let i = lines.length - 1; i >= 0 && parsed.length < limit; i--) { const line = lines[i]?.trim(); if (!line) continue; try { const obj = JSON.parse(line); if (!obj || typeof obj !== "object") continue; if (obj.action !== "finished") continue; if (typeof obj.jobId !== "string" || obj.jobId.trim().length === 0) continue; if (typeof obj.ts !== "number" || !Number.isFinite(obj.ts)) continue; if (jobId && obj.jobId !== jobId) continue; parsed.push(obj); } catch {} } return parsed.toReversed(); } //#endregion //#region src/cron/schedule.ts function computeNextRunAtMs(schedule, nowMs) { if (schedule.kind === "at") { const atMs = parseAbsoluteTimeMs(schedule.at); if (atMs === null) return; return atMs > nowMs ? atMs : void 0; } if (schedule.kind === "every") { const everyMs = Math.max(1, Math.floor(schedule.everyMs)); const anchor = Math.max(0, Math.floor(schedule.anchorMs ?? nowMs)); if (nowMs < anchor) return anchor; const elapsed = nowMs - anchor; return anchor + Math.max(1, Math.floor((elapsed + everyMs - 1) / everyMs)) * everyMs; } const expr = schedule.expr.trim(); if (!expr) return; const next = new Cron(expr, { timezone: schedule.tz?.trim() || void 0, catch: false }).nextRun(new Date(nowMs)); return next ? next.getTime() : void 0; } //#endregion //#region src/cron/service/normalize.ts function normalizeRequiredName(raw) { if (typeof raw !== "string") throw new Error("cron job name is required"); const name = raw.trim(); if (!name) throw new Error("cron job name is required"); return name; } function normalizeOptionalText(raw) { if (typeof raw !== "string") return; const trimmed = raw.trim(); return trimmed ? trimmed : void 0; } function truncateText(input, maxLen) { if (input.length <= maxLen) return input; return `${truncateUtf16Safe(input, Math.max(0, maxLen - 1)).trimEnd()}…`; } function normalizeOptionalAgentId(raw) { if (typeof raw !== "string") return; const trimmed = raw.trim(); if (!trimmed) return; return normalizeAgentId(trimmed); } function inferLegacyName(job) { const firstLine = (job?.payload?.kind === "systemEvent" && typeof job.payload.text === "string" ? job.payload.text : job?.payload?.kind === "agentTurn" && typeof job.payload.message === "string" ? job.payload.message : "").split("\n").map((l) => l.trim()).find(Boolean) ?? ""; if (firstLine) return truncateText(firstLine, 60); const kind = typeof job?.schedule?.kind === "string" ? job.schedule.kind : ""; if (kind === "cron" && typeof job?.schedule?.expr === "string") return `Cron: ${truncateText(job.schedule.expr, 52)}`; if (kind === "every" && typeof job?.schedule?.everyMs === "number") return `Every: ${job.schedule.everyMs}ms`; if (kind === "at") return "One-shot"; return "Cron job"; } function normalizePayloadToSystemText(payload) { if (payload.kind === "systemEvent") return payload.text.trim(); return payload.message.trim(); } //#endregion //#region src/cron/service/jobs.ts const STUCK_RUN_MS = 7200 * 1e3; function assertSupportedJobSpec(job) { if (job.sessionTarget === "main" && job.payload.kind !== "systemEvent") throw new Error("main cron jobs require payload.kind=\"systemEvent\""); if (job.sessionTarget === "isolated" && job.payload.kind !== "agentTurn") throw new Error("isolated cron jobs require payload.kind=\"agentTurn\""); } function assertDeliverySupport(job) { if (job.delivery && job.sessionTarget !== "isolated") throw new Error("cron delivery config is only supported for sessionTarget=\"isolated\""); } function findJobOrThrow(state, id) { const job = state.store?.jobs.find((j) => j.id === id); if (!job) throw new Error(`unknown cron job id: ${id}`); return job; } function computeJobNextRunAtMs(job, nowMs) { if (!job.enabled) return; if (job.schedule.kind === "at") { if (job.state.lastStatus === "ok" && job.state.lastRunAtMs) return; const atMs = parseAbsoluteTimeMs(job.schedule.at); return atMs !== null ? atMs : void 0; } return computeNextRunAtMs(job.schedule, nowMs); } function recomputeNextRuns(state) { if (!state.store) return; const now = state.deps.nowMs(); for (const job of state.store.jobs) { if (!job.state) job.state = {}; if (!job.enabled) { job.state.nextRunAtMs = void 0; job.state.runningAtMs = void 0; continue; } const runningAt = job.state.runningAtMs; if (typeof runningAt === "number" && now - runningAt > STUCK_RUN_MS) { state.deps.log.warn({ jobId: job.id, runningAtMs: runningAt }, "cron: clearing stuck running marker"); job.state.runningAtMs = void 0; } job.state.nextRunAtMs = computeJobNextRunAtMs(job, now); } } function nextWakeAtMs(state) { const enabled = (state.store?.jobs ?? []).filter((j) => j.enabled && typeof j.state.nextRunAtMs === "number"); if (enabled.length === 0) return; return enabled.reduce((min, j) => Math.min(min, j.state.nextRunAtMs), enabled[0].state.nextRunAtMs); } function createJob(state, input) { const now = state.deps.nowMs(); const id = crypto.randomUUID(); const deleteAfterRun = typeof input.deleteAfterRun === "boolean" ? input.deleteAfterRun : input.schedule.kind === "at" ? true : void 0; const enabled = typeof input.enabled === "boolean" ? input.enabled : true; const job = { id, agentId: normalizeOptionalAgentId(input.agentId), name: normalizeRequiredName(input.name), description: normalizeOptionalText(input.description), enabled, deleteAfterRun, createdAtMs: now, updatedAtMs: now, schedule: input.schedule, sessionTarget: input.sessionTarget, wakeMode: input.wakeMode, payload: input.payload, delivery: input.delivery, state: { ...input.state } }; assertSupportedJobSpec(job); assertDeliverySupport(job); job.state.nextRunAtMs = computeJobNextRunAtMs(job, now); return job; } function applyJobPatch(job, patch) { if ("name" in patch) job.name = normalizeRequiredName(patch.name); if ("description" in patch) job.description = normalizeOptionalText(patch.description); if (typeof patch.enabled === "boolean") job.enabled = patch.enabled; if (typeof patch.deleteAfterRun === "boolean") job.deleteAfterRun = patch.deleteAfterRun; if (patch.schedule) job.schedule = patch.schedule; if (patch.sessionTarget) job.sessionTarget = patch.sessionTarget; if (patch.wakeMode) job.wakeMode = patch.wakeMode; if (patch.payload) job.payload = mergeCronPayload(job.payload, patch.payload); if (!patch.delivery && patch.payload?.kind === "agentTurn") { const legacyDeliveryPatch = buildLegacyDeliveryPatch(patch.payload); if (legacyDeliveryPatch && job.sessionTarget === "isolated" && job.payload.kind === "agentTurn") job.delivery = mergeCronDelivery(job.delivery, legacyDeliveryPatch); } if (patch.delivery) job.delivery = mergeCronDelivery(job.delivery, patch.delivery); if (job.sessionTarget === "main" && job.delivery) job.delivery = void 0; if (patch.state) job.state = { ...job.state, ...patch.state }; if ("agentId" in patch) job.agentId = normalizeOptionalAgentId(patch.agentId); assertSupportedJobSpec(job); assertDeliverySupport(job); } function mergeCronPayload(existing, patch) { if (patch.kind !== existing.kind) return buildPayloadFromPatch(patch); if (patch.kind === "systemEvent") { if (existing.kind !== "systemEvent") return buildPayloadFromPatch(patch); return { kind: "systemEvent", text: typeof patch.text === "string" ? patch.text : existing.text }; } if (existing.kind !== "agentTurn") return buildPayloadFromPatch(patch); const next = { ...existing }; if (typeof patch.message === "string") next.message = patch.message; if (typeof patch.model === "string") next.model = patch.model; if (typeof patch.thinking === "string") next.thinking = patch.thinking; if (typeof patch.timeoutSeconds === "number") next.timeoutSeconds = patch.timeoutSeconds; if (typeof patch.deliver === "boolean") next.deliver = patch.deliver; if (typeof patch.channel === "string") next.channel = patch.channel; if (typeof patch.to === "string") next.to = patch.to; if (typeof patch.bestEffortDeliver === "boolean") next.bestEffortDeliver = patch.bestEffortDeliver; return next; } function buildLegacyDeliveryPatch(payload) { const deliver = payload.deliver; const toRaw = typeof payload.to === "string" ? payload.to.trim() : ""; if (!(typeof deliver === "boolean" || typeof payload.bestEffortDeliver === "boolean" || Boolean(toRaw))) return null; const patch = {}; let hasPatch = false; if (deliver === false) { patch.mode = "none"; hasPatch = true; } else if (deliver === true || toRaw) { patch.mode = "announce"; hasPatch = true; } if (typeof payload.channel === "string") { const channel = payload.channel.trim().toLowerCase(); patch.channel = channel ? channel : void 0; hasPatch = true; } if (typeof payload.to === "string") { patch.to = payload.to.trim(); hasPatch = true; } if (typeof payload.bestEffortDeliver === "boolean") { patch.bestEffort = payload.bestEffortDeliver; hasPatch = true; } return hasPatch ? patch : null; } function buildPayloadFromPatch(patch) { if (patch.kind === "systemEvent") { if (typeof patch.text !== "string" || patch.text.length === 0) throw new Error("cron.update payload.kind=\"systemEvent\" requires text"); return { kind: "systemEvent", text: patch.text }; } if (typeof patch.message !== "string" || patch.message.length === 0) throw new Error("cron.update payload.kind=\"agentTurn\" requires message"); return { kind: "agentTurn", message: patch.message, model: patch.model, thinking: patch.thinking, timeoutSeconds: patch.timeoutSeconds, deliver: patch.deliver, channel: patch.channel, to: patch.to, bestEffortDeliver: patch.bestEffortDeliver }; } function mergeCronDelivery(existing, patch) { const next = { mode: existing?.mode ?? "none", channel: existing?.channel, to: existing?.to, bestEffort: existing?.bestEffort }; if (typeof patch.mode === "string") next.mode = patch.mode === "deliver" ? "announce" : patch.mode; if ("channel" in patch) { const channel = typeof patch.channel === "string" ? patch.channel.trim() : ""; next.channel = channel ? channel : void 0; } if ("to" in patch) { const to = typeof patch.to === "string" ? patch.to.trim() : ""; next.to = to ? to : void 0; } if (typeof patch.bestEffort === "boolean") next.bestEffort = patch.bestEffort; return next; } function isJobDue(job, nowMs, opts) { if (opts.forced) return true; return job.enabled && typeof job.state.nextRunAtMs === "number" && nowMs >= job.state.nextRunAtMs; } function resolveJobPayloadTextForMain(job) { if (job.payload.kind !== "systemEvent") return; const text = normalizePayloadToSystemText(job.payload); return text.trim() ? text : void 0; } //#endregion //#region src/cron/service/locked.ts const storeLocks = /* @__PURE__ */ new Map(); const resolveChain = (promise) => promise.then(() => void 0, () => void 0); async function locked(state, fn) { const storePath = state.deps.storePath; const storeOp = storeLocks.get(storePath) ?? Promise.resolve(); const next = Promise.all([resolveChain(state.op), resolveChain(storeOp)]).then(fn); const keepAlive = resolveChain(next); state.op = keepAlive; storeLocks.set(storePath, keepAlive); return await next; } //#endregion //#region src/cron/store.ts const DEFAULT_CRON_DIR = path.join(CONFIG_DIR, "cron"); const DEFAULT_CRON_STORE_PATH = path.join(DEFAULT_CRON_DIR, "jobs.json"); function resolveCronStorePath(storePath) { if (storePath?.trim()) { const raw = storePath.trim(); if (raw.startsWith("~")) return path.resolve(raw.replace("~", os.homedir())); return path.resolve(raw); } return DEFAULT_CRON_STORE_PATH; } async function loadCronStore(storePath) { try { const raw = await fs.promises.readFile(storePath, "utf-8"); const parsed = JSON5.parse(raw); return { version: 1, jobs: (Array.isArray(parsed?.jobs) ? parsed?.jobs : []).filter(Boolean) }; } catch { return { version: 1, jobs: [] }; } } async function saveCronStore(storePath, store) { await fs.promises.mkdir(path.dirname(storePath), { recursive: true }); const tmp = `${storePath}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`; const json = JSON.stringify(store, null, 2); await fs.promises.writeFile(tmp, json, "utf-8"); await fs.promises.rename(tmp, storePath); try { await fs.promises.copyFile(storePath, `${storePath}.bak`); } catch {} } //#endregion //#region src/cron/service/store.ts function hasLegacyDeliveryHints(payload) { if (typeof payload.deliver === "boolean") return true; if (typeof payload.bestEffortDeliver === "boolean") return true; if (typeof payload.to === "string" && payload.to.trim()) return true; return false; } function buildDeliveryFromLegacyPayload(payload) { const mode = payload.deliver === false ? "none" : "announce"; const channelRaw = typeof payload.channel === "string" ? payload.channel.trim().toLowerCase() : ""; const toRaw = typeof payload.to === "string" ? payload.to.trim() : ""; const next = { mode }; if (channelRaw) next.channel = channelRaw; if (toRaw) next.to = toRaw; if (typeof payload.bestEffortDeliver === "boolean") next.bestEffort = payload.bestEffortDeliver; return next; } function buildDeliveryPatchFromLegacyPayload(payload) { const deliver = payload.deliver; const channelRaw = typeof payload.channel === "string" ? payload.channel.trim().toLowerCase() : ""; const toRaw = typeof payload.to === "string" ? payload.to.trim() : ""; const next = {}; let hasPatch = false; if (deliver === false) { next.mode = "none"; hasPatch = true; } else if (deliver === true || toRaw) { next.mode = "announce"; hasPatch = true; } if (channelRaw) { next.channel = channelRaw; hasPatch = true; } if (toRaw) { next.to = toRaw; hasPatch = true; } if (typeof payload.bestEffortDeliver === "boolean") { next.bestEffort = payload.bestEffortDeliver; hasPatch = true; } return hasPatch ? next : null; } function mergeLegacyDeliveryInto(delivery, payload) { const patch = buildDeliveryPatchFromLegacyPayload(payload); if (!patch) return { delivery, mutated: false }; const next = { ...delivery }; let mutated = false; if ("mode" in patch && patch.mode !== next.mode) { next.mode = patch.mode; mutated = true; } if ("channel" in patch && patch.channel !== next.channel) { next.channel = patch.channel; mutated = true; } if ("to" in patch && patch.to !== next.to) { next.to = patch.to; mutated = true; } if ("bestEffort" in patch && patch.bestEffort !== next.bestEffort) { next.bestEffort = patch.bestEffort; mutated = true; } return { delivery: next, mutated }; } function stripLegacyDeliveryFields(payload) { if ("deliver" in payload) delete payload.deliver; if ("channel" in payload) delete payload.channel; if ("to" in payload) delete payload.to; if ("bestEffortDeliver" in payload) delete payload.bestEffortDeliver; } async function getFileMtimeMs(path) { try { return (await fs.promises.stat(path)).mtimeMs; } catch { return null; } } async function ensureLoaded(state, opts) { if (state.store && !opts?.forceReload) return; const fileMtimeMs = await getFileMtimeMs(state.deps.storePath); const jobs = (await loadCronStore(state.deps.storePath)).jobs ?? []; let mutated = false; for (const raw of jobs) { const nameRaw = raw.name; if (typeof nameRaw !== "string" || nameRaw.trim().length === 0) { raw.name = inferLegacyName({ schedule: raw.schedule, payload: raw.payload }); mutated = true; } else raw.name = nameRaw.trim(); const desc = normalizeOptionalText(raw.description); if (raw.description !== desc) { raw.description = desc; mutated = true; } if (typeof raw.enabled !== "boolean") { raw.enabled = true; mutated = true; } const payload = raw.payload; if (payload && typeof payload === "object" && !Array.isArray(payload)) { if (migrateLegacyCronPayload(payload)) mutated = true; } const schedule = raw.schedule; if (schedule && typeof schedule === "object" && !Array.isArray(schedule)) { const sched = schedule; if (!(typeof sched.kind === "string" ? sched.kind.trim().toLowerCase() : "") && ("at" in sched || "atMs" in sched)) { sched.kind = "at"; mutated = true; } const atRaw = typeof sched.at === "string" ? sched.at.trim() : ""; const atMsRaw = sched.atMs; const parsedAtMs = typeof atMsRaw === "number" ? atMsRaw : typeof atMsRaw === "string" ? parseAbsoluteTimeMs(atMsRaw) : atRaw ? parseAbsoluteTimeMs(atRaw) : null; if (parsedAtMs !== null) { sched.at = new Date(parsedAtMs).toISOString(); if ("atMs" in sched) delete sched.atMs; mutated = true; } } const delivery = raw.delivery; if (delivery && typeof delivery === "object" && !Array.isArray(delivery)) { const modeRaw = delivery.mode; if (typeof modeRaw === "string") { if (modeRaw.trim().toLowerCase() === "deliver") { delivery.mode = "announce"; mutated = true; } } } const isolation = raw.isolation; if (isolation && typeof isolation === "object" && !Array.isArray(isolation)) { delete raw.isolation; mutated = true; } const payloadRecord = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : null; const payloadKind = payloadRecord && typeof payloadRecord.kind === "string" ? payloadRecord.kind : ""; const sessionTarget = typeof raw.sessionTarget === "string" ? raw.sessionTarget.trim().toLowerCase() : ""; const isIsolatedAgentTurn = sessionTarget === "isolated" || sessionTarget === "" && payloadKind === "agentTurn"; const hasDelivery = delivery && typeof delivery === "object" && !Array.isArray(delivery); const hasLegacyDelivery = payloadRecord ? hasLegacyDeliveryHints(payloadRecord) : false; if (isIsolatedAgentTurn && payloadKind === "agentTurn") { if (!hasDelivery) { raw.delivery = payloadRecord && hasLegacyDelivery ? buildDeliveryFromLegacyPayload(payloadRecord) : { mode: "announce" }; mutated = true; } if (payloadRecord && hasLegacyDelivery) { if (hasDelivery) { const merged = mergeLegacyDeliveryInto(delivery, payloadRecord); if (merged.mutated) { raw.delivery = merged.delivery; mutated = true; } } stripLegacyDeliveryFields(payloadRecord); mutated = true; } } } state.store = { version: 1, jobs }; state.storeLoadedAtMs = state.deps.nowMs(); state.storeFileMtimeMs = fileMtimeMs; recomputeNextRuns(state); if (mutated) await persist(state); } function warnIfDisabled(state, action) { if (state.deps.cronEnabled) return; if (state.warnedDisabled) return; state.warnedDisabled = true; state.deps.log.warn({ enabled: false, action, storePath: state.deps.storePath }, "cron: scheduler disabled; jobs will not run automatically"); } async function persist(state) { if (!state.store) return; await saveCronStore(state.deps.storePath, state.store); state.storeFileMtimeMs = await getFileMtimeMs(state.deps.storePath); } //#endregion //#region src/cron/service/timer.ts const MAX_TIMEOUT_MS = 2 ** 31 - 1; function armTimer(state) { if (state.timer) clearTimeout(state.timer); state.timer = null; if (!state.deps.cronEnabled) return; const nextAt = nextWakeAtMs(state); if (!nextAt) return; const delay = Math.max(nextAt - state.deps.nowMs(), 0); const clampedDelay = Math.min(delay, MAX_TIMEOUT_MS); state.timer = setTimeout(() => { onTimer(state).catch((err) => { state.deps.log.error({ err: String(err) }, "cron: timer tick failed"); }); }, clampedDelay); state.timer.unref?.(); } async function onTimer(state) { if (state.running) return; state.running = true; try { await locked(state, async () => { await ensureLoaded(state, { forceReload: true }); await runDueJobs(state); await persist(state); armTimer(state); }); } finally { state.running = false; } } async function runDueJobs(state) { if (!state.store) return; const now = state.deps.nowMs(); const due = state.store.jobs.filter((j) => { if (!j.enabled) return false; if (typeof j.state.runningAtMs === "number") return false; const next = j.state.nextRunAtMs; return typeof next === "number" && now >= next; }); for (const job of due) await executeJob(state, job, now, { forced: false }); } async function executeJob(state, job, nowMs, opts) { const startedAt = state.deps.nowMs(); job.state.runningAtMs = startedAt; job.state.lastError = void 0; emit(state, { jobId: job.id, action: "started", runAtMs: startedAt }); let deleted = false; const finish = async (status, err, summary) => { const endedAt = state.deps.nowMs(); job.state.runningAtMs = void 0; job.state.lastRunAtMs = startedAt; job.state.lastStatus = status; job.state.lastDurationMs = Math.max(0, endedAt - startedAt); job.state.lastError = err; const shouldDelete = job.schedule.kind === "at" && status === "ok" && job.deleteAfterRun === true; if (!shouldDelete) if (job.schedule.kind === "at" && status === "ok") { job.enabled = false; job.state.nextRunAtMs = void 0; } else if (job.enabled) job.state.nextRunAtMs = computeJobNextRunAtMs(job, endedAt); else job.state.nextRunAtMs = void 0; emit(state, { jobId: job.id, action: "finished", status, error: err, summary, runAtMs: startedAt, durationMs: job.state.lastDurationMs, nextRunAtMs: job.state.nextRunAtMs }); if (shouldDelete && state.store) { state.store.jobs = state.store.jobs.filter((j) => j.id !== job.id); deleted = true; emit(state, { jobId: job.id, action: "removed" }); } }; try { if (job.sessionTarget === "main") { const text = resolveJobPayloadTextForMain(job); if (!text) { const kind = job.payload.kind; await finish("skipped", kind === "systemEvent" ? "main job requires non-empty systemEvent text" : "main job requires payload.kind=\"systemEvent\""); return; } state.deps.enqueueSystemEvent(text, { agentId: job.agentId }); if (job.wakeMode === "now" && state.deps.runHeartbeatOnce) { const reason = `cron:${job.id}`; const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const maxWaitMs = 2 * 6e4; const waitStartedAt = state.deps.nowMs(); let heartbeatResult; for (;;) { heartbeatResult = await state.deps.runHeartbeatOnce({ reason }); if (heartbeatResult.status !== "skipped" || heartbeatResult.reason !== "requests-in-flight") break; if (state.deps.nowMs() - waitStartedAt > maxWaitMs) { heartbeatResult = { status: "skipped", reason: "timeout waiting for main lane to become idle" }; break; } await delay(250); } if (heartbeatResult.status === "ran") await finish("ok", void 0, text); else if (heartbeatResult.status === "skipped") await finish("skipped", heartbeatResult.reason, text); else await finish("error", heartbeatResult.reason, text); } else { state.deps.requestHeartbeatNow({ reason: `cron:${job.id}` }); await finish("ok", void 0, text); } return; } if (job.payload.kind !== "agentTurn") { await finish("skipped", "isolated job requires payload.kind=agentTurn"); return; } const res = await state.deps.runIsolatedAgentJob({ job, message: job.payload.message }); const summaryText = res.summary?.trim(); const deliveryMode = job.delivery?.mode ?? "announce"; if (summaryText && deliveryMode !== "none") { const prefix = "Cron"; const label = res.status === "error" ? `${prefix} (error): ${summaryText}` : `${prefix}: ${summaryText}`; state.deps.enqueueSystemEvent(label, { agentId: job.agentId }); if (job.wakeMode === "now") state.deps.requestHeartbeatNow({ reason: `cron:${job.id}` }); } if (res.status === "ok") await finish("ok", void 0, res.summary); else if (res.status === "skipped") await finish("skipped", void 0, res.summary); else await finish("error", res.error ?? "cron job failed", res.summary); } catch (err) { await finish("error", String(err)); } finally { job.updatedAtMs = nowMs; if (!opts.forced && job.enabled && !deleted) job.state.nextRunAtMs = computeJobNextRunAtMs(job, state.deps.nowMs()); } } function wake(state, opts) { const text = opts.text.trim(); if (!text) return { ok: false }; state.deps.enqueueSystemEvent(text); if (opts.mode === "now") state.deps.requestHeartbeatNow({ reason: "wake" }); return { ok: true }; } function stopTimer(state) { if (state.timer) clearTimeout(state.timer); state.timer = null; } function emit(state, evt) { try { state.deps.onEvent?.(evt); } catch {} } //#endregion //#region src/cron/service/ops.ts async function start(state) { await locked(state, async () => { if (!state.deps.cronEnabled) { state.deps.log.info({ enabled: false }, "cron: disabled"); return; } await ensureLoaded(state); recomputeNextRuns(state); await persist(state); armTimer(state); state.deps.log.info({ enabled: true, jobs: state.store?.jobs.length ?? 0, nextWakeAtMs: nextWakeAtMs(state) ?? null }, "cron: started"); }); } function stop(state) { stopTimer(state); } async function status(state) { return await locked(state, async () => { await ensureLoaded(state); return { enabled: state.deps.cronEnabled, storePath: state.deps.storePath, jobs: state.store?.jobs.length ?? 0, nextWakeAtMs: state.deps.cronEnabled ? nextWakeAtMs(state) ?? null : null }; }); } async function list(state, opts) { return await locked(state, async () => { await ensureLoaded(state); const includeDisabled = opts?.includeDisabled === true; return (state.store?.jobs ?? []).filter((j) => includeDisabled || j.enabled).toSorted((a, b) => (a.state.nextRunAtMs ?? 0) - (b.state.nextRunAtMs ?? 0)); }); } async function add(state, input) { return await locked(state, async () => { warnIfDisabled(state, "add"); await ensureLoaded(state); const job = createJob(state, input); state.store?.jobs.push(job); await persist(state); armTimer(state); emit(state, { jobId: job.id, action: "added", nextRunAtMs: job.state.nextRunAtMs }); return job; }); } async function update(state, id, patch) { return await locked(state, async () => { warnIfDisabled(state, "update"); await ensureLoaded(state); const job = findJobOrThrow(state, id); const now = state.deps.nowMs(); applyJobPatch(job, patch); job.updatedAtMs = now; if (job.enabled) job.state.nextRunAtMs = computeJobNextRunAtMs(job, now); else { job.state.nextRunAtMs = void 0; job.state.runningAtMs = void 0; } await persist(state); armTimer(state); emit(state, { jobId: id, action: "updated", nextRunAtMs: job.state.nextRunAtMs }); return job; }); } async function remove(state, id) { return await locked(state, async () => { warnIfDisabled(state, "remove"); await ensureLoaded(state); const before = state.store?.jobs.length ?? 0; if (!state.store) return { ok: false, removed: false }; state.store.jobs = state.store.jobs.filter((j) => j.id !== id); const removed = (state.store.jobs.length ?? 0) !== before; await persist(state); armTimer(state); if (removed) emit(state, { jobId: id, action: "removed" }); return { ok: true, removed }; }); } async function run(state, id, mode) { return await locked(state, async () => { warnIfDisabled(state, "run"); await ensureLoaded(state); const job = findJobOrThrow(state, id); const now = state.deps.nowMs(); if (!isJobDue(job, now, { forced: mode === "force" })) return { ok: true, ran: false, reason: "not-due" }; await executeJob(state, job, now, { forced: mode === "force" }); await persist(state); armTimer(state); return { ok: true, ran: true }; }); } function wakeNow(state, opts) { return wake(state, opts); } //#endregion //#region src/cron/service/state.ts function createCronServiceState(deps) { return { deps: { ...deps, nowMs: deps.nowMs ?? (() => Date.now()) }, store: null, timer: null, running: false, op: Promise.resolve(), warnedDisabled: false, storeLoadedAtMs: null, storeFileMtimeMs: null }; } //#endregion //#region src/cron/service.ts var CronService = class { constructor(deps) { this.state = createCronServiceState(deps); } async start() { await start(this.state); } stop() { stop(this.state); } async status() { return await status(this.state); } async list(opts) { return await list(this.state, opts); } async add(input) { return await add(this.state, input); } async update(id, patch) { return await update(this.state, id, patch); } async remove(id) { return await remove(this.state, id); } async run(id, mode) { return await run(this.state, id, mode); } wake(opts) { return wakeNow(this.state, opts); } }; //#endregion //#region src/gateway/server-cron.ts function buildGatewayCronService(params) { const cronLogger = getChildLogger({ module: "cron" }); const storePath = resolveCronStorePath(params.cfg.cron?.store); const cronEnabled = process.env.OPENCLAW_SKIP_CRON !== "1" && params.cfg.cron?.enabled !== false; const resolveCronAgent = (requested) => { const runtimeConfig = loadConfig(); const normalized = typeof requested === "string" && requested.trim() ? normalizeAgentId(requested) : void 0; return { agentId: normalized !== void 0 && Array.isArray(runtimeConfig.agents?.list) && runtimeConfig.agents.list.some((entry) => entry && typeof entry.id === "string" && normalizeAgentId(entry.id) === normalized) ? normalized : resolveDefaultAgentId(runtimeConfig), cfg: runtimeConfig }; }; return { cron: new CronService({ storePath, cronEnabled, enqueueSystemEvent: (text, opts) => { const { agentId, cfg: runtimeConfig } = resolveCronAgent(opts?.agentId); enqueueSystemEvent(text, { sessionKey: resolveAgentMainSessionKey({ cfg: runtimeConfig, agentId }) }); }, requestHeartbeatNow, runHeartbeatOnce: async (opts) => { return await runHeartbeatOnce({ cfg: loadConfig(), reason: opts?.reason, deps: { ...params.deps, runtime: defaultRuntime } }); }, runIsolatedAgentJob: async ({ job, message }) => { const { agentId, cfg: runtimeConfig } = resolveCronAgent(job.agentId); return await runCronIsolatedAgentTurn({ cfg: runtimeConfig, deps: params.deps, job, message, agentId, sessionKey: `cron:${job.id}`, lane: "cron" }); }, log: getChildLogger({ module: "cron", storePath }), onEvent: (evt) => { params.broadcast("cron", evt, { dropIfSlow: true }); if (evt.action === "finished") { const logPath = resolveCronRunLogPath({ storePath, jobId: evt.jobId }); appendCronRunLog(logPath, { ts: Date.now(), jobId: evt.jobId, action: "finished", status: evt.status, error: evt.error, summary: evt.summary, runAtMs: evt.runAtMs, durationMs: evt.durationMs, nextRunAtMs: evt.nextRunAtMs }).catch((err) => { cronLogger.warn({ err: String(err), logPath }, "cron: run log append failed"); }); } } }), storePath, cronEnabled }; } //#endregion //#region src/infra/bonjour-errors.ts function formatBonjourError(err) { if (err instanceof Error) { const msg = err.message || String(err); return err.name && err.name !== "Error" ? `${err.name}: ${msg}` : msg; } return String(err); } //#endregion //#region src/infra/bonjour-ciao.ts function ignoreCiaoCancellationRejection(reason) { if (!formatBonjourError(reason).toUpperCase().includes("CIAO ANNOUNCEMENT CANCELLED")) return false; logDebug(`bonjour: ignoring unhandled ciao rejection: ${formatBonjourError(reason)}`); return true; } //#endregion //#region src/infra/bonjour.ts function isDisabledByEnv$1() { if (isTruthyEnvValue(process.env.OPENCLAW_DISABLE_BONJOUR)) return true; if (process.env.VITEST) return true; return false; } function safeServiceName(name) { const trimmed = name.trim(); return trimmed.length > 0 ? trimmed : "OpenClaw"; } function prettifyInstanceName(name) { const normalized = name.trim().replace(/\s+/g, " "); return normalized.replace(/\s+\(OpenClaw\)\s*$/i, "").trim() || normalized; } function serviceSummary(label, svc) { let fqdn = "unknown"; let hostname = "unknown"; let port = -1; try { fqdn = svc.getFQDN(); } catch {} try { hostname = svc.getHostname(); } catch {} try { port = svc.getPort(); } catch {} const state = typeof svc.serviceState === "string" ? svc.serviceState : "unknown"; return `${label} fqdn=${fqdn} host=${hostname} port=${port} state=${state}`; } async function startGatewayBonjourAdvertiser(opts) { if (isDisabledByEnv$1()) return { stop: async () => {} }; const { getResponder, Protocol } = await import("@homebridge/ciao"); const responder = getResponder(); const hostname = (process.env.OPENCLAW_MDNS_HOSTNAME?.trim() || process.env.CLAWDBOT_MDNS_HOSTNAME?.trim() || "openclaw").replace(/\.local$/i, "").split(".")[0].trim() || "openclaw"; const instanceName = typeof opts.instanceName === "string" && opts.instanceName.trim() ? opts.instanceName.trim() : `${hostname} (OpenClaw)`; const displayName = prettifyInstanceName(instanceName); const txtBase = { role: "gateway", gatewayPort: String(opts.gatewayPort), lanHost: `${hostname}.local`, displayName }; if (opts.gatewayTlsEnabled) { txtBase.gatewayTls = "1"; if (opts.gatewayTlsFingerprintSha256) txtBase.gatewayTlsSha256 = opts.gatewayTlsFingerprintSha256; } if (typeof opts.canvasPort === "number" && opts.canvasPort > 0) txtBase.canvasPort = String(opts.canvasPort); if (typeof opts.tailnetDns === "string" && opts.tailnetDns.trim()) txtBase.tailnetDns = opts.tailnetDns.trim(); if (!opts.minimal && typeof opts.cliPath === "string" && opts.cliPath.trim()) txtBase.cliPath = opts.cliPath.trim(); const services = []; const gatewayTxt = { ...txtBase, transport: "gateway" }; if (!opts.minimal) gatewayTxt.sshPort = String(opts.sshPort ?? 22); const gateway = responder.createService({ name: safeServiceName(instanceName), type: "openclaw-gw", protocol: Protocol.TCP, port: opts.gatewayPort, domain: "local", hostname, txt: gatewayTxt }); services.push({ label: "gateway", svc: gateway }); let ciaoCancellationRejectionHandler; if (services.length > 0) ciaoCancellationRejectionHandler = registerUnhandledRejectionHandler(ignoreCiaoCancellationRejection); logDebug(`bonjour: starting (hostname=${hostname}, instance=${JSON.stringify(safeServiceName(instanceName))}, gatewayPort=${opts.gatewayPort}${opts.minimal ? ", minimal=true" : `, sshPort=${opts.sshPort ?? 22}`})`); for (const { label, svc } of services) try { svc.on("name-change", (name) => { const next = typeof name === "string" ? name : String(name); logWarn(`bonjour: ${label} name conflict resolved; newName=${JSON.stringify(next)}`); }); svc.on("hostname-change", (nextHostname) => { const next = typeof nextHostname === "string" ? nextHostname : String(nextHostname); logWarn(`bonjour: ${label} hostname conflict resolved; newHostname=${JSON.stringify(next)}`); }); } catch (err) { logDebug(`bonjour: failed to attach listeners for ${label}: ${String(err)}`); } for (const { label, svc } of services) try { svc.advertise().then(() => { getLogger().info(`bonjour: advertised ${serviceSummary(label, svc)}`); }).catch((err) => { logWarn(`bonjour: advertise failed (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`); }); } catch (err) { logWarn(`bonjour: advertise threw (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`); } const lastRepairAttempt = /* @__PURE__ */ new Map(); const watchdog = setInterval(() => { for (const { label, svc } of services) { const stateUnknown = svc.serviceState; if (typeof stateUnknown !== "string") continue; if (stateUnknown === "announced" || stateUnknown === "announcing") continue; let key = label; try { key = `${label}:${svc.getFQDN()}`; } catch {} const now = Date.now(); if (now - (lastRepairAttempt.get(key) ?? 0) < 3e4) continue; lastRepairAttempt.set(key, now); logWarn(`bonjour: watchdog detected non-announced service; attempting re-advertise (${serviceSummary(label, svc)})`); try { svc.advertise().catch((err) => { logWarn(`bonjour: watchdog advertise failed (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`); }); } catch (err) { logWarn(`bonjour: watchdog advertise threw (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`); } } }, 6e4); watchdog.unref?.(); return { stop: async () => { clearInterval(watchdog); for (const { svc } of services) try { await svc.destroy(); } catch {} try { await responder.shutdown(); } catch {} finally { ciaoCancellationRejectionHandler?.(); } } }; } //#endregion //#region src/gateway/server-discovery.ts function formatBonjourInstanceName(displayName) { const trimmed = displayName.trim(); if (!trimmed) return "OpenClaw"; if (/openclaw/i.test(trimmed)) return trimmed; return `${trimmed} (OpenClaw)`; } function resolveBonjourCliPath(opts = {}) { const envPath = (opts.env ?? process.env).OPENCLAW_CLI_PATH?.trim(); if (envPath) return envPath; const statSync = opts.statSync ?? fs.statSync; const isFile = (candidate) => { try { return statSync(candidate).isFile(); } catch { return false; } }; const execPath = opts.execPath ?? process.execPath; const execDir = path.dirname(execPath); const siblingCli = path.join(execDir, "openclaw"); if (isFile(siblingCli)) return siblingCli; const argvPath = (opts.argv ?? process.argv)[1]; if (argvPath && isFile(argvPath)) return argvPath; const cwd = opts.cwd ?? process.cwd(); const distCli = path.join(cwd, "dist", "index.js"); if (isFile(distCli)) return distCli; const binCli = path.join(cwd, "bin", "openclaw"); if (isFile(binCli)) return binCli; } async function resolveTailnetDnsHint(opts) { const envRaw = (opts?.env ?? process.env).OPENCLAW_TAILNET_DNS?.trim(); const envValue = envRaw && envRaw.length > 0 ? envRaw.replace(/\.$/, "") : ""; if (envValue) return envValue; if (opts?.enabled === false) return; const exec = opts?.exec ?? ((command, args) => runExec(command, args, { timeoutMs: 1500, maxBuffer: 2e5 })); try { return await getTailnetHostname(exec); } catch { return; } } //#endregion //#region src/gateway/server-discovery-runtime.ts async function startGatewayDiscovery(params) { let bonjourStop = null; const mdnsMode = params.mdnsMode ?? "minimal"; const bonjourEnabled = mdnsMode !== "off" && process.env.OPENCLAW_DISABLE_BONJOUR !== "1" && !process.env.VITEST; const mdnsMinimal = mdnsMode !== "full"; const tailscaleEnabled = params.tailscaleMode !== "off"; const tailnetDns = bonjourEnabled || params.wideAreaDiscoveryEnabled ? await resolveTailnetDnsHint({ enabled: tailscaleEnabled }) : void 0; const sshPortEnv = mdnsMinimal ? void 0 : process.env.OPENCLAW_SSH_PORT?.trim(); const sshPortParsed = sshPortEnv ? Number.parseInt(sshPortEnv, 10) : NaN; const sshPort = Number.isFinite(sshPortParsed) && sshPortParsed > 0 ? sshPortParsed : void 0; const cliPath = mdnsMinimal ? void 0 : resolveBonjourCliPath(); if (bonjourEnabled) try { bonjourStop = (await startGatewayBonjourAdvertiser({ instanceName: formatBonjourInstanceName(params.machineDisplayName), gatewayPort: params.port, gatewayTlsEnabled: params.gatewayTls?.enabled ?? false, gatewayTlsFingerprintSha256: params.gatewayTls?.fingerprintSha256, canvasPort: params.canvasPort, sshPort, tailnetDns, cliPath, minimal: mdnsMinimal })).stop; } catch (err) { params.logDiscovery.warn(`bonjour advertising failed: ${String(err)}`); } if (params.wideAreaDiscoveryEnabled) { const wideAreaDomain = resolveWideAreaDiscoveryDomain({ configDomain: params.wideAreaDiscoveryDomain ?? void 0 }); if (!wideAreaDomain) { params.logDiscovery.warn("discovery.wideArea.enabled is true, but no domain was configured; set discovery.wideArea.domain to enable unicast DNS-SD"); return { bonjourStop }; } const tailnetIPv4 = pickPrimaryTailnetIPv4(); if (!tailnetIPv4) params.logDiscovery.warn("discovery.wideArea.enabled is true, but no Tailscale IPv4 address was found; skipping unicast DNS-SD zone update"); else try { const tailnetIPv6 = pickPrimaryTailnetIPv6(); const result = await writeWideAreaGatewayZone({ domain: wideAreaDomain, gatewayPort: params.port, displayName: formatBonjourInstanceName(params.machineDisplayName), tailnetIPv4, tailnetIPv6: tailnetIPv6 ?? void 0, gatewayTlsEnabled: params.gatewayTls?.enabled ?? false, gatewayTlsFingerprintSha256: params.gatewayTls?.fingerprintSha256, tailnetDns, sshPort, cliPath: resolveBonjourCliPath() }); params.logDiscovery.info(`wide-area DNS-SD ${result.changed ? "updated" : "unchanged"} (${wideAreaDomain} → ${result.zonePath})`); } catch (err) { params.logDiscovery.warn(`wide-area discovery update failed: ${String(err)}`); } } return { bonjourStop }; } //#endregion //#region src/gateway/server-lanes.ts function applyGatewayLaneConcurrency(cfg) { setCommandLaneConcurrency(CommandLane.Cron, cfg.cron?.maxConcurrentRuns ?? 1); setCommandLaneConcurrency(CommandLane.Main, resolveAgentMaxConcurrent(cfg)); setCommandLaneConcurrency(CommandLane.Subagent, resolveSubagentMaxConcurrent(cfg)); } //#endregion //#region src/gateway/chat-abort.ts function isChatStopCommandText(text) { const trimmed = text.trim(); if (!trimmed) return false; return trimmed.toLowerCase() === "/stop" || isAbortTrigger(trimmed); } function resolveChatRunExpiresAtMs(params) { const { now, timeoutMs, graceMs = 6e4, minMs = 2 * 6e4, maxMs = 1440 * 6e4 } = params; const target = now + Math.max(0, timeoutMs) + graceMs; const min = now + minMs; const max = now + maxMs; return Math.min(max, Math.max(min, target)); } function broadcastChatAborted(ops, params) { const { runId, sessionKey, stopReason } = params; const payload = { runId, sessionKey, seq: (ops.agentRunSeq.get(runId) ?? 0) + 1, state: "aborted", stopReason }; ops.broadcast("chat", payload); ops.nodeSendToSession(sessionKey, "chat", payload); } function abortChatRunById(ops, params) { const { runId, sessionKey, stopReason } = params; const active = ops.chatAbortControllers.get(runId); if (!active) return { aborted: false }; if (active.sessionKey !== sessionKey) return { aborted: false }; ops.chatAbortedRuns.set(runId, Date.now()); active.controller.abort(); ops.chatAbortControllers.delete(runId); ops.chatRunBuffers.delete(runId); ops.chatDeltaSentAt.delete(runId); ops.removeChatRun(runId, runId, sessionKey); broadcastChatAborted(ops, { runId, sessionKey, stopReason }); return { aborted: true }; } function abortChatRunsForSessionKey(ops, params) { const { sessionKey, stopReason } = params; const runIds = []; for (const [runId, active] of ops.chatAbortControllers) { if (active.sessionKey !== sessionKey) continue; if (abortChatRunById(ops, { runId, sessionKey, stopReason }).aborted) runIds.push(runId); } return { aborted: runIds.length > 0, runIds }; } //#endregion //#region src/gateway/server-constants.ts const MAX_PAYLOAD_BYTES = 512 * 1024; const MAX_BUFFERED_BYTES = 1.5 * 1024 * 1024; const DEFAULT_MAX_CHAT_HISTORY_MESSAGES_BYTES = 6 * 1024 * 1024; let maxChatHistoryMessagesBytes = DEFAULT_MAX_CHAT_HISTORY_MESSAGES_BYTES; const getMaxChatHistoryMessagesBytes = () => maxChatHistoryMessagesBytes; const DEFAULT_HANDSHAKE_TIMEOUT_MS = 1e4; const getHandshakeTimeoutMs = () => { if (process.env.VITEST && process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS) { const parsed = Number(process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS); if (Number.isFinite(parsed) && parsed > 0) return parsed; } return DEFAULT_HANDSHAKE_TIMEOUT_MS; }; const TICK_INTERVAL_MS = 3e4; const HEALTH_REFRESH_INTERVAL_MS = 6e4; const DEDUPE_TTL_MS = 5 * 6e4; const DEDUPE_MAX = 1e3; //#endregion //#region src/infra/voicewake.ts const DEFAULT_TRIGGERS = [ "openclaw", "claude", "computer" ]; function resolvePath$1(baseDir) { const root = baseDir ?? resolveStateDir(); return path.join(root, "settings", "voicewake.json"); } function sanitizeTriggers(triggers) { const cleaned = (triggers ?? []).map((w) => typeof w === "string" ? w.trim() : "").filter((w) => w.length > 0); return cleaned.length > 0 ? cleaned : DEFAULT_TRIGGERS; } async function readJSON$1(filePath) { try { const raw = await fs$1.readFile(filePath, "utf8"); return JSON.parse(raw); } catch { return null; } } async function writeJSONAtomic$1(filePath, value) { const dir = path.dirname(filePath); await fs$1.mkdir(dir, { recursive: true }); const tmp = `${filePath}.${randomUUID()}.tmp`; await fs$1.writeFile(tmp, JSON.stringify(value, null, 2), "utf8"); await fs$1.rename(tmp, filePath); } let lock$1 = Promise.resolve(); async function withLock$1(fn) { const prev = lock$1; let release; lock$1 = new Promise((resolve) => { release = resolve; }); await prev; try { return await fn(); } finally { release?.(); } } function defaultVoiceWakeTriggers() { return [...DEFAULT_TRIGGERS]; } async function loadVoiceWakeConfig(baseDir) { const existing = await readJSON$1(resolvePath$1(baseDir)); if (!existing) return { triggers: defaultVoiceWakeTriggers(), updatedAtMs: 0 }; return { triggers: sanitizeTriggers(existing.triggers), updatedAtMs: typeof existing.updatedAtMs === "number" && existing.updatedAtMs > 0 ? existing.updatedAtMs : 0 }; } async function setVoiceWakeTriggers(triggers, baseDir) { const sanitized = sanitizeTriggers(triggers); const filePath = resolvePath$1(baseDir); return await withLock$1(async () => { const next = { triggers: sanitized, updatedAtMs: Date.now() }; await writeJSONAtomic$1(filePath, next); return next; }); } //#endregion //#region src/gateway/server-utils.ts function normalizeVoiceWakeTriggers(input) { const cleaned = (Array.isArray(input) ? input : []).map((v) => typeof v === "string" ? v.trim() : "").filter((v) => v.length > 0).slice(0, 32).map((v) => v.slice(0, 64)); return cleaned.length > 0 ? cleaned : defaultVoiceWakeTriggers(); } function formatError(err) { if (err instanceof Error) return err.message; if (typeof err === "string") return err; const statusValue = err?.status; const codeValue = err?.code; if (statusValue !== void 0 || codeValue !== void 0) return `status=${typeof statusValue === "string" || typeof statusValue === "number" ? String(statusValue) : "unknown"} code=${typeof codeValue === "string" || typeof codeValue === "number" ? String(codeValue) : "unknown"}`; try { return JSON.stringify(err, null, 2); } catch { return String(err); } } //#endregion //#region src/infra/system-presence.ts const entries = /* @__PURE__ */ new Map(); const TTL_MS = 300 * 1e3; const MAX_ENTRIES = 200; function normalizePresenceKey(key) { if (!key) return; const trimmed = key.trim(); if (!trimmed) return; return trimmed.toLowerCase(); } function resolvePrimaryIPv4() { const nets = os.networkInterfaces(); const prefer = ["en0", "eth0"]; const pick = (names) => { for (const name of names) { const entry = nets[name]?.find((n) => n.family === "IPv4" && !n.internal); if (entry?.address) return entry.address; } for (const list of Object.values(nets)) { const entry = list?.find((n) => n.family === "IPv4" && !n.internal); if (entry?.address) return entry.address; } }; return pick(prefer) ?? os.hostname(); } function initSelfPresence() { const host = os.hostname(); const ip = resolvePrimaryIPv4() ?? void 0; const version = process.env.OPENCLAW_VERSION ?? process.env.npm_package_version ?? "unknown"; const modelIdentifier = (() => { if (os.platform() === "darwin") { const res = spawnSync("sysctl", ["-n", "hw.model"], { encoding: "utf-8" }); const out = typeof res.stdout === "string" ? res.stdout.trim() : ""; return out.length > 0 ? out : void 0; } return os.arch(); })(); const macOSVersion = () => { const res = spawnSync("sw_vers", ["-productVersion"], { encoding: "utf-8" }); const out = typeof res.stdout === "string" ? res.stdout.trim() : ""; return out.length > 0 ? out : os.release(); }; const selfEntry = { host, ip, version, platform: (() => { const p = os.platform(); const rel = os.release(); if (p === "darwin") return `macos ${macOSVersion()}`; if (p === "win32") return `windows ${rel}`; return `${p} ${rel}`; })(), deviceFamily: (() => { const p = os.platform(); if (p === "darwin") return "Mac"; if (p === "win32") return "Windows"; if (p === "linux") return "Linux"; return p; })(), modelIdentifier, mode: "gateway", reason: "self", text: `Gateway: ${host}${ip ? ` (${ip})` : ""} · app ${version} · mode gateway · reason self`, ts: Date.now() }; const key = host.toLowerCase(); entries.set(key, selfEntry); } function ensureSelfPresence() { if (entries.size === 0) initSelfPresence(); } function touchSelfPresence() { const key = os.hostname().toLowerCase(); const existing = entries.get(key); if (existing) entries.set(key, { ...existing, ts: Date.now() }); else initSelfPresence(); } initSelfPresence(); function parsePresence(text) { const trimmed = text.trim(); const match = trimmed.match(/Node:\s*([^ (]+)\s*\(([^)]+)\)\s*·\s*app\s*([^·]+?)\s*·\s*last input\s*([0-9]+)s ago\s*·\s*mode\s*([^·]+?)\s*·\s*reason\s*(.+)$/i); if (!match) return { text: trimmed, ts: Date.now() }; const [, host, ip, version, lastInputStr, mode, reasonRaw] = match; const lastInputSeconds = Number.parseInt(lastInputStr, 10); const reason = reasonRaw.trim(); return { host: host.trim(), ip: ip.trim(), version: version.trim(), lastInputSeconds: Number.isFinite(lastInputSeconds) ? lastInputSeconds : void 0, mode: mode.trim(), reason, text: trimmed, ts: Date.now() }; } function mergeStringList(...values) { const out = /* @__PURE__ */ new Set(); for (const list of values) { if (!Array.isArray(list)) continue; for (const item of list) { const trimmed = String(item).trim(); if (trimmed) out.add(trimmed); } } return out.size > 0 ? [...out] : void 0; } function updateSystemPresence(payload) { ensureSelfPresence(); const parsed = parsePresence(payload.text); const key = normalizePresenceKey(payload.deviceId) || normalizePresenceKey(payload.instanceId) || normalizePresenceKey(parsed.instanceId) || normalizePresenceKey(parsed.host) || parsed.ip || parsed.text.slice(0, 64) || os.hostname().toLowerCase(); const hadExisting = entries.has(key); const existing = entries.get(key) ?? {}; const merged = { ...existing, ...parsed, host: payload.host ?? parsed.host ?? existing.host, ip: payload.ip ?? parsed.ip ?? existing.ip, version: payload.version ?? parsed.version ?? existing.version, platform: payload.platform ?? existing.platform, deviceFamily: payload.deviceFamily ?? existing.deviceFamily, modelIdentifier: payload.modelIdentifier ?? existing.modelIdentifier, mode: payload.mode ?? parsed.mode ?? existing.mode, lastInputSeconds: payload.lastInputSeconds ?? parsed.lastInputSeconds ?? existing.lastInputSeconds, reason: payload.reason ?? parsed.reason ?? existing.reason, deviceId: payload.deviceId ?? existing.deviceId, roles: mergeStringList(existing.roles, payload.roles), scopes: mergeStringList(existing.scopes, payload.scopes), instanceId: payload.instanceId ?? parsed.instanceId ?? existing.instanceId, text: payload.text || parsed.text || existing.text, ts: Date.now() }; entries.set(key, merged); const trackKeys = [ "host", "ip", "version", "mode", "reason" ]; const changes = {}; const changedKeys = []; for (const k of trackKeys) { const prev = existing[k]; const next = merged[k]; if (prev !== next) { changes[k] = next; changedKeys.push(k); } } return { key, previous: hadExisting ? existing : void 0, next: merged, changes, changedKeys }; } function upsertPresence(key, presence) { ensureSelfPresence(); const normalizedKey = normalizePresenceKey(key) ?? os.hostname().toLowerCase(); const existing = entries.get(normalizedKey) ?? {}; const roles = mergeStringList(existing.roles, presence.roles); const scopes = mergeStringList(existing.scopes, presence.scopes); const merged = { ...existing, ...presence, roles, scopes, ts: Date.now(), text: presence.text || existing.text || `Node: ${presence.host ?? existing.host ?? "unknown"} · mode ${presence.mode ?? existing.mode ?? "unknown"}` }; entries.set(normalizedKey, merged); } function listSystemPresence() { ensureSelfPresence(); const now = Date.now(); for (const [k, v] of entries) if (now - v.ts > TTL_MS) entries.delete(k); if (entries.size > MAX_ENTRIES) { const sorted = [...entries.entries()].toSorted((a, b) => a[1].ts - b[1].ts); const toDrop = entries.size - MAX_ENTRIES; for (let i = 0; i < toDrop; i++) entries.delete(sorted[i][0]); } touchSelfPresence(); return [...entries.values()].toSorted((a, b) => b.ts - a.ts); } //#endregion //#region src/gateway/server/health-state.ts let presenceVersion = 1; let healthVersion = 1; let healthCache = null; let healthRefresh = null; let broadcastHealthUpdate = null; function buildGatewaySnapshot() { const cfg = loadConfig(); const defaultAgentId = resolveDefaultAgentId(cfg); const mainKey = normalizeMainKey(cfg.session?.mainKey); const mainSessionKey = resolveMainSessionKey(cfg); const scope = cfg.session?.scope ?? "per-sender"; const presence = listSystemPresence(); const uptimeMs = Math.round(process.uptime() * 1e3); return { presence, health: {}, stateVersion: { presence: presenceVersion, health: healthVersion }, uptimeMs, configPath: CONFIG_PATH, stateDir: STATE_DIR, sessionDefaults: { defaultAgentId, mainKey, mainSessionKey, scope } }; } function getHealthCache() { return healthCache; } function getHealthVersion() { return healthVersion; } function incrementPresenceVersion() { presenceVersion += 1; return presenceVersion; } function getPresenceVersion() { return presenceVersion; } function setBroadcastHealthUpdate(fn) { broadcastHealthUpdate = fn; } async function refreshGatewayHealthSnapshot(opts) { if (!healthRefresh) healthRefresh = (async () => { const snap = await getHealthSnapshot({ probe: opts?.probe }); healthCache = snap; healthVersion += 1; if (broadcastHealthUpdate) broadcastHealthUpdate(snap); return snap; })().finally(() => { healthRefresh = null; }); return healthRefresh; } //#endregion //#region src/gateway/server-maintenance.ts function startGatewayMaintenanceTimers(params) { setBroadcastHealthUpdate((snap) => { params.broadcast("health", snap, { stateVersion: { presence: params.getPresenceVersion(), health: params.getHealthVersion() } }); params.nodeSendToAllSubscribed("health", snap); }); const tickInterval = setInterval(() => { const payload = { ts: Date.now() }; params.broadcast("tick", payload, { dropIfSlow: true }); params.nodeSendToAllSubscribed("tick", payload); }, TICK_INTERVAL_MS); const healthInterval = setInterval(() => { params.refreshGatewayHealthSnapshot({ probe: true }).catch((err) => params.logHealth.error(`refresh failed: ${formatError(err)}`)); }, HEALTH_REFRESH_INTERVAL_MS); params.refreshGatewayHealthSnapshot({ probe: true }).catch((err) => params.logHealth.error(`initial refresh failed: ${formatError(err)}`)); return { tickInterval, healthInterval, dedupeCleanup: setInterval(() => { const now = Date.now(); for (const [k, v] of params.dedupe) if (now - v.ts > DEDUPE_TTL_MS) params.dedupe.delete(k); if (params.dedupe.size > DEDUPE_MAX) { const entries = [...params.dedupe.entries()].toSorted((a, b) => a[1].ts - b[1].ts); for (let i = 0; i < params.dedupe.size - DEDUPE_MAX; i++) params.dedupe.delete(entries[i][0]); } for (const [runId, entry] of params.chatAbortControllers) { if (now <= entry.expiresAtMs) continue; abortChatRunById({ chatAbortControllers: params.chatAbortControllers, chatRunBuffers: params.chatRunBuffers, chatDeltaSentAt: params.chatDeltaSentAt, chatAbortedRuns: params.chatRunState.abortedRuns, removeChatRun: params.removeChatRun, agentRunSeq: params.agentRunSeq, broadcast: params.broadcast, nodeSendToSession: params.nodeSendToSession }, { runId, sessionKey: entry.sessionKey, stopReason: "timeout" }); } const ABORTED_RUN_TTL_MS = 60 * 6e4; for (const [runId, abortedAt] of params.chatRunState.abortedRuns) { if (now - abortedAt <= ABORTED_RUN_TTL_MS) continue; params.chatRunState.abortedRuns.delete(runId); params.chatRunBuffers.delete(runId); params.chatDeltaSentAt.delete(runId); } }, 6e4) }; } //#endregion //#region src/gateway/server-methods-list.ts const BASE_METHODS = [ "health", "logs.tail", "channels.status", "channels.logout", "status", "usage.status", "usage.cost", "tts.status", "tts.providers", "tts.enable", "tts.disable", "tts.convert", "tts.setProvider", "config.get", "config.set", "config.apply", "config.patch", "config.schema", "exec.approvals.get", "exec.approvals.set", "exec.approvals.node.get", "exec.approvals.node.set", "exec.approval.request", "exec.approval.resolve", "wizard.start", "wizard.next", "wizard.cancel", "wizard.status", "talk.mode", "models.list", "agents.list", "agents.files.list", "agents.files.get", "agents.files.set", "skills.status", "skills.bins", "skills.install", "skills.update", "update.run", "voicewake.get", "voicewake.set", "sessions.list", "sessions.preview", "sessions.patch", "sessions.reset", "sessions.delete", "sessions.compact", "last-heartbeat", "set-heartbeats", "wake", "node.pair.request", "node.pair.list", "node.pair.approve", "node.pair.reject", "node.pair.verify", "device.pair.list", "device.pair.approve", "device.pair.reject", "device.token.rotate", "device.token.revoke", "node.rename", "node.list", "node.describe", "node.invoke", "node.invoke.result", "node.event", "cron.list", "cron.status", "cron.add", "cron.update", "cron.remove", "cron.run", "cron.runs", "system-presence", "system-event", "send", "agent", "agent.identity.get", "agent.wait", "browser.request", "chat.history", "chat.abort", "chat.send" ]; function listGatewayMethods() { const channelMethods = listChannelPlugins().flatMap((plugin) => plugin.gatewayMethods ?? []); return Array.from(new Set([...BASE_METHODS, ...channelMethods])); } const GATEWAY_EVENTS = [ "connect.challenge", "agent", "chat", "presence", "tick", "talk.mode", "shutdown", "health", "heartbeat", "cron", "node.pair.requested", "node.pair.resolved", "node.invoke.request", "device.pair.requested", "device.pair.resolved", "voicewake.changed", "exec.approval.requested", "exec.approval.resolved" ]; //#endregion //#region src/gateway/assistant-identity.ts const MAX_ASSISTANT_NAME = 50; const MAX_ASSISTANT_AVATAR = 200; const MAX_ASSISTANT_EMOJI = 16; const DEFAULT_ASSISTANT_IDENTITY = { agentId: "main", name: "Assistant", avatar: "A" }; function coerceIdentityValue(value, maxLength) { if (typeof value !== "string") return; const trimmed = value.trim(); if (!trimmed) return; if (trimmed.length <= maxLength) return trimmed; return trimmed.slice(0, maxLength); } function isAvatarUrl(value) { return /^https?:\/\//i.test(value) || /^data:image\//i.test(value); } function looksLikeAvatarPath(value) { if (/[\\/]/.test(value)) return true; return /\.(png|jpe?g|gif|webp|svg|ico)$/i.test(value); } function normalizeAvatarValue$1(value) { if (!value) return; const trimmed = value.trim(); if (!trimmed) return; if (isAvatarUrl(trimmed)) return trimmed; if (looksLikeAvatarPath(trimmed)) return trimmed; if (!/\s/.test(trimmed) && trimmed.length <= 4) return trimmed; } function normalizeEmojiValue(value) { if (!value) return; const trimmed = value.trim(); if (!trimmed) return; if (trimmed.length > MAX_ASSISTANT_EMOJI) return; let hasNonAscii = false; for (let i = 0; i < trimmed.length; i += 1) if (trimmed.charCodeAt(i) > 127) { hasNonAscii = true; break; } if (!hasNonAscii) return; if (isAvatarUrl(trimmed) || looksLikeAvatarPath(trimmed)) return; return trimmed; } function resolveAssistantIdentity(params) { const agentId = normalizeAgentId(params.agentId ?? resolveDefaultAgentId(params.cfg)); const workspaceDir = params.workspaceDir ?? resolveAgentWorkspaceDir(params.cfg, agentId); const configAssistant = params.cfg.ui?.assistant; const agentIdentity = resolveAgentIdentity(params.cfg, agentId); const fileIdentity = workspaceDir ? loadAgentIdentity(workspaceDir) : null; return { agentId, name: coerceIdentityValue(configAssistant?.name, MAX_ASSISTANT_NAME) ?? coerceIdentityValue(agentIdentity?.name, MAX_ASSISTANT_NAME) ?? coerceIdentityValue(fileIdentity?.name, MAX_ASSISTANT_NAME) ?? DEFAULT_ASSISTANT_IDENTITY.name, avatar: [ coerceIdentityValue(configAssistant?.avatar, MAX_ASSISTANT_AVATAR), coerceIdentityValue(agentIdentity?.avatar, MAX_ASSISTANT_AVATAR), coerceIdentityValue(agentIdentity?.emoji, MAX_ASSISTANT_AVATAR), coerceIdentityValue(fileIdentity?.avatar, MAX_ASSISTANT_AVATAR), coerceIdentityValue(fileIdentity?.emoji, MAX_ASSISTANT_AVATAR) ].map((candidate) => normalizeAvatarValue$1(candidate)).find(Boolean) ?? DEFAULT_ASSISTANT_IDENTITY.avatar, emoji: [ coerceIdentityValue(agentIdentity?.emoji, MAX_ASSISTANT_EMOJI), coerceIdentityValue(fileIdentity?.emoji, MAX_ASSISTANT_EMOJI), coerceIdentityValue(agentIdentity?.avatar, MAX_ASSISTANT_EMOJI), coerceIdentityValue(fileIdentity?.avatar, MAX_ASSISTANT_EMOJI) ].map((candidate) => normalizeEmojiValue(candidate)).find(Boolean) }; } //#endregion //#region src/gateway/chat-attachments.ts function normalizeMime(mime) { if (!mime) return; return mime.split(";")[0]?.trim().toLowerCase() || void 0; } async function sniffMimeFromBase64(base64) { const trimmed = base64.trim(); if (!trimmed) return; const take = Math.min(256, trimmed.length); const sliceLen = take - take % 4; if (sliceLen < 8) return; try { return await detectMime({ buffer: Buffer.from(trimmed.slice(0, sliceLen), "base64") }); } catch { return; } } function isImageMime(mime) { return typeof mime === "string" && mime.startsWith("image/"); } /** * Parse attachments and extract images as structured content blocks. * Returns the message text and an array of image content blocks * compatible with Claude API's image format. */ async function parseMessageWithAttachments(message, attachments, opts) { const maxBytes = opts?.maxBytes ?? 5e6; const log = opts?.log; if (!attachments || attachments.length === 0) return { message, images: [] }; const images = []; for (const [idx, att] of attachments.entries()) { if (!att) continue; const mime = att.mimeType ?? ""; const content = att.content; const label = att.fileName || att.type || `attachment-${idx + 1}`; if (typeof content !== "string") throw new Error(`attachment ${label}: content must be base64 string`); let sizeBytes = 0; let b64 = content.trim(); const dataUrlMatch = /^data:[^;]+;base64,(.*)$/.exec(b64); if (dataUrlMatch) b64 = dataUrlMatch[1]; if (b64.length % 4 !== 0 || /[^A-Za-z0-9+/=]/.test(b64)) throw new Error(`attachment ${label}: invalid base64 content`); try { sizeBytes = Buffer.from(b64, "base64").byteLength; } catch { throw new Error(`attachment ${label}: invalid base64 content`); } if (sizeBytes <= 0 || sizeBytes > maxBytes) throw new Error(`attachment ${label}: exceeds size limit (${sizeBytes} > ${maxBytes} bytes)`); const providedMime = normalizeMime(mime); const sniffedMime = normalizeMime(await sniffMimeFromBase64(b64)); if (sniffedMime && !isImageMime(sniffedMime)) { log?.warn(`attachment ${label}: detected non-image (${sniffedMime}), dropping`); continue; } if (!sniffedMime && !isImageMime(providedMime)) { log?.warn(`attachment ${label}: unable to detect image mime type, dropping`); continue; } if (sniffedMime && providedMime && sniffedMime !== providedMime) log?.warn(`attachment ${label}: mime mismatch (${providedMime} -> ${sniffedMime}), using sniffed`); images.push({ type: "image", data: b64, mimeType: sniffedMime ?? providedMime ?? mime }); } return { message, images }; } //#endregion //#region src/gateway/server-methods/agent-job.ts const AGENT_RUN_CACHE_TTL_MS = 10 * 6e4; const agentRunCache = /* @__PURE__ */ new Map(); const agentRunStarts = /* @__PURE__ */ new Map(); let agentRunListenerStarted = false; function pruneAgentRunCache(now = Date.now()) { for (const [runId, entry] of agentRunCache) if (now - entry.ts > AGENT_RUN_CACHE_TTL_MS) agentRunCache.delete(runId); } function recordAgentRunSnapshot(entry) { pruneAgentRunCache(entry.ts); agentRunCache.set(entry.runId, entry); } function ensureAgentRunListener() { if (agentRunListenerStarted) return; agentRunListenerStarted = true; onAgentEvent((evt) => { if (!evt) return; if (evt.stream !== "lifecycle") return; const phase = evt.data?.phase; if (phase === "start") { const startedAt = typeof evt.data?.startedAt === "number" ? evt.data.startedAt : void 0; agentRunStarts.set(evt.runId, startedAt ?? Date.now()); return; } if (phase !== "end" && phase !== "error") return; const startedAt = typeof evt.data?.startedAt === "number" ? evt.data.startedAt : agentRunStarts.get(evt.runId); const endedAt = typeof evt.data?.endedAt === "number" ? evt.data.endedAt : void 0; const error = typeof evt.data?.error === "string" ? evt.data.error : void 0; agentRunStarts.delete(evt.runId); recordAgentRunSnapshot({ runId: evt.runId, status: phase === "error" ? "error" : "ok", startedAt, endedAt, error, ts: Date.now() }); }); } function getCachedAgentRun(runId) { pruneAgentRunCache(); return agentRunCache.get(runId); } async function waitForAgentJob(params) { const { runId, timeoutMs } = params; ensureAgentRunListener(); const cached = getCachedAgentRun(runId); if (cached) return cached; if (timeoutMs <= 0) return null; return await new Promise((resolve) => { let settled = false; const finish = (entry) => { if (settled) return; settled = true; clearTimeout(timer); unsubscribe(); resolve(entry); }; const unsubscribe = onAgentEvent((evt) => { if (!evt || evt.stream !== "lifecycle") return; if (evt.runId !== runId) return; const phase = evt.data?.phase; if (phase !== "end" && phase !== "error") return; const cached = getCachedAgentRun(runId); if (cached) { finish(cached); return; } const startedAt = typeof evt.data?.startedAt === "number" ? evt.data.startedAt : agentRunStarts.get(evt.runId); const endedAt = typeof evt.data?.endedAt === "number" ? evt.data.endedAt : void 0; const error = typeof evt.data?.error === "string" ? evt.data.error : void 0; const snapshot = { runId: evt.runId, status: phase === "error" ? "error" : "ok", startedAt, endedAt, error, ts: Date.now() }; recordAgentRunSnapshot(snapshot); finish(snapshot); }); const timer = setTimeout(() => finish(null), Math.max(1, timeoutMs)); }); } ensureAgentRunListener(); //#endregion //#region src/gateway/server-methods/agent-timestamp.ts /** * Cron jobs inject "Current time: ..." into their messages. * Skip injection for those. */ const CRON_TIME_PATTERN = /Current time: /; /** * Matches a leading `[... YYYY-MM-DD HH:MM ...]` envelope — either from * channel plugins or from a previous injection. Uses the same YYYY-MM-DD * HH:MM format as {@link formatZonedTimestamp}, so detection stays in sync * with the formatting. */ const TIMESTAMP_ENVELOPE_PATTERN = /^\[.*\d{4}-\d{2}-\d{2} \d{2}:\d{2}/; /** * Injects a compact timestamp prefix into a message if one isn't already * present. Uses the same `YYYY-MM-DD HH:MM TZ` format as channel envelope * timestamps ({@link formatZonedTimestamp}), keeping token cost low (~7 * tokens) and format consistent across all agent contexts. * * Used by the gateway `agent` and `chat.send` handlers to give TUI, web, * spawned subagents, `sessions_send`, and heartbeat wake events date/time * awareness — without modifying the system prompt (which is cached). * * Channel messages (Discord, Telegram, etc.) already have timestamps via * envelope formatting and take a separate code path — they never reach * these handlers, so there is no double-stamping risk. The detection * pattern is a safety net for edge cases. * * @see https://github.com/moltbot/moltbot/issues/3658 */ function injectTimestamp(message, opts) { if (!message.trim()) return message; if (TIMESTAMP_ENVELOPE_PATTERN.test(message)) return message; if (CRON_TIME_PATTERN.test(message)) return message; const now = opts?.now ?? /* @__PURE__ */ new Date(); const timezone = opts?.timezone ?? "UTC"; const formatted = formatZonedTimestamp(now, timezone); if (!formatted) return message; return `[${new Intl.DateTimeFormat("en-US", { timeZone: timezone, weekday: "short" }).format(now)} ${formatted}] ${message}`; } /** * Build TimestampInjectionOptions from an OpenClawConfig. */ function timestampOptsFromConfig(cfg) { return { timezone: resolveUserTimezone(cfg.agents?.defaults?.userTimezone) }; } //#endregion //#region src/gateway/server-methods/agent.ts const agentHandlers = { agent: async ({ params, respond, context, client }) => { const p = params; if (!validateAgentParams(p)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid agent params: ${formatValidationErrors(validateAgentParams.errors)}`)); return; } const request = p; const cfg = loadConfig(); const idem = request.idempotencyKey; const groupIdRaw = typeof request.groupId === "string" ? request.groupId.trim() : ""; const groupChannelRaw = typeof request.groupChannel === "string" ? request.groupChannel.trim() : ""; const groupSpaceRaw = typeof request.groupSpace === "string" ? request.groupSpace.trim() : ""; let resolvedGroupId = groupIdRaw || void 0; let resolvedGroupChannel = groupChannelRaw || void 0; let resolvedGroupSpace = groupSpaceRaw || void 0; let spawnedByValue = typeof request.spawnedBy === "string" ? request.spawnedBy.trim() : void 0; const cached = context.dedupe.get(`agent:${idem}`); if (cached) { respond(cached.ok, cached.payload, cached.error, { cached: true }); return; } const normalizedAttachments = request.attachments?.map((a) => ({ type: typeof a?.type === "string" ? a.type : void 0, mimeType: typeof a?.mimeType === "string" ? a.mimeType : void 0, fileName: typeof a?.fileName === "string" ? a.fileName : void 0, content: typeof a?.content === "string" ? a.content : ArrayBuffer.isView(a?.content) ? Buffer.from(a.content.buffer, a.content.byteOffset, a.content.byteLength).toString("base64") : void 0 })).filter((a) => a.content) ?? []; let message = request.message.trim(); let images = []; if (normalizedAttachments.length > 0) try { const parsed = await parseMessageWithAttachments(message, normalizedAttachments, { maxBytes: 5e6, log: context.logGateway }); message = parsed.message.trim(); images = parsed.images; } catch (err) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, String(err))); return; } message = injectTimestamp(message, timestampOptsFromConfig(cfg)); const isKnownGatewayChannel = (value) => isGatewayMessageChannel(value); const channelHints = [request.channel, request.replyChannel].filter((value) => typeof value === "string").map((value) => value.trim()).filter(Boolean); for (const rawChannel of channelHints) { const normalized = normalizeMessageChannel(rawChannel); if (normalized && normalized !== "last" && !isKnownGatewayChannel(normalized)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid agent params: unknown channel: ${String(normalized)}`)); return; } } const agentIdRaw = typeof request.agentId === "string" ? request.agentId.trim() : ""; const agentId = agentIdRaw ? normalizeAgentId(agentIdRaw) : void 0; if (agentId) { if (!listAgentIds(cfg).includes(agentId)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid agent params: unknown agent id "${request.agentId}"`)); return; } } const requestedSessionKeyRaw = typeof request.sessionKey === "string" && request.sessionKey.trim() ? request.sessionKey.trim() : void 0; const requestedSessionKey = requestedSessionKeyRaw ?? resolveExplicitAgentSessionKey({ cfg, agentId }); if (agentId && requestedSessionKeyRaw) { const sessionAgentId = resolveAgentIdFromSessionKey(requestedSessionKeyRaw); if (sessionAgentId !== agentId) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid agent params: agent "${request.agentId}" does not match session key agent "${sessionAgentId}"`)); return; } } let resolvedSessionId = request.sessionId?.trim() || void 0; let sessionEntry; let bestEffortDeliver = false; let cfgForAgent; if (requestedSessionKey) { const { cfg, storePath, entry, canonicalKey } = loadSessionEntry(requestedSessionKey); cfgForAgent = cfg; const now = Date.now(); const sessionId = entry?.sessionId ?? randomUUID(); const labelValue = request.label?.trim() || entry?.label; spawnedByValue = spawnedByValue || entry?.spawnedBy; let inheritedGroup; if (spawnedByValue && (!resolvedGroupId || !resolvedGroupChannel || !resolvedGroupSpace)) try { const parentEntry = loadSessionEntry(spawnedByValue)?.entry; inheritedGroup = { groupId: parentEntry?.groupId, groupChannel: parentEntry?.groupChannel, groupSpace: parentEntry?.space }; } catch { inheritedGroup = void 0; } resolvedGroupId = resolvedGroupId || inheritedGroup?.groupId; resolvedGroupChannel = resolvedGroupChannel || inheritedGroup?.groupChannel; resolvedGroupSpace = resolvedGroupSpace || inheritedGroup?.groupSpace; const deliveryFields = normalizeSessionDeliveryFields(entry); const nextEntry = { sessionId, updatedAt: now, thinkingLevel: entry?.thinkingLevel, verboseLevel: entry?.verboseLevel, reasoningLevel: entry?.reasoningLevel, systemSent: entry?.systemSent, sendPolicy: entry?.sendPolicy, skillsSnapshot: entry?.skillsSnapshot, deliveryContext: deliveryFields.deliveryContext, lastChannel: deliveryFields.lastChannel ?? entry?.lastChannel, lastTo: deliveryFields.lastTo ?? entry?.lastTo, lastAccountId: deliveryFields.lastAccountId ?? entry?.lastAccountId, modelOverride: entry?.modelOverride, providerOverride: entry?.providerOverride, label: labelValue, spawnedBy: spawnedByValue, channel: entry?.channel ?? request.channel?.trim(), groupId: resolvedGroupId ?? entry?.groupId, groupChannel: resolvedGroupChannel ?? entry?.groupChannel, space: resolvedGroupSpace ?? entry?.space, cliSessionIds: entry?.cliSessionIds, claudeCliSessionId: entry?.claudeCliSessionId }; sessionEntry = nextEntry; if (resolveSendPolicy({ cfg, entry, sessionKey: requestedSessionKey, channel: entry?.channel, chatType: entry?.chatType }) === "deny") { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "send blocked by session policy")); return; } resolvedSessionId = sessionId; const canonicalSessionKey = canonicalKey; const mainSessionKey = resolveAgentMainSessionKey({ cfg, agentId: resolveAgentIdFromSessionKey(canonicalSessionKey) }); if (storePath) await updateSessionStore(storePath, (store) => { store[canonicalSessionKey] = nextEntry; }); if (canonicalSessionKey === mainSessionKey || canonicalSessionKey === "global") { context.addChatRun(idem, { sessionKey: requestedSessionKey, clientRunId: idem }); bestEffortDeliver = true; } registerAgentRunContext(idem, { sessionKey: requestedSessionKey }); } const runId = idem; const connId = typeof client?.connId === "string" ? client.connId : void 0; const wantsToolEvents = hasGatewayClientCap(client?.connect?.caps, GATEWAY_CLIENT_CAPS.TOOL_EVENTS); if (connId && wantsToolEvents) context.registerToolEventRecipient(runId, connId); const wantsDelivery = request.deliver === true; const explicitTo = typeof request.replyTo === "string" && request.replyTo.trim() ? request.replyTo.trim() : typeof request.to === "string" && request.to.trim() ? request.to.trim() : void 0; const explicitThreadId = typeof request.threadId === "string" && request.threadId.trim() ? request.threadId.trim() : void 0; const deliveryPlan = resolveAgentDeliveryPlan({ sessionEntry, requestedChannel: request.replyChannel ?? request.channel, explicitTo, explicitThreadId, accountId: request.replyAccountId ?? request.accountId, wantsDelivery }); const resolvedChannel = deliveryPlan.resolvedChannel; const deliveryTargetMode = deliveryPlan.deliveryTargetMode; const resolvedAccountId = deliveryPlan.resolvedAccountId; let resolvedTo = deliveryPlan.resolvedTo; if (!resolvedTo && isDeliverableMessageChannel(resolvedChannel)) { const fallback = resolveAgentOutboundTarget({ cfg: cfgForAgent ?? cfg, plan: deliveryPlan, targetMode: "implicit", validateExplicitTarget: false }); if (fallback.resolvedTarget?.ok) resolvedTo = fallback.resolvedTo; } const deliver = request.deliver === true && resolvedChannel !== INTERNAL_MESSAGE_CHANNEL; const accepted = { runId, status: "accepted", acceptedAt: Date.now() }; context.dedupe.set(`agent:${idem}`, { ts: Date.now(), ok: true, payload: accepted }); respond(true, accepted, void 0, { runId }); const resolvedThreadId = explicitThreadId ?? deliveryPlan.resolvedThreadId; agentCommand({ message, images, to: resolvedTo, sessionId: resolvedSessionId, sessionKey: requestedSessionKey, thinking: request.thinking, deliver, deliveryTargetMode, channel: resolvedChannel, accountId: resolvedAccountId, threadId: resolvedThreadId, runContext: { messageChannel: resolvedChannel, accountId: resolvedAccountId, groupId: resolvedGroupId, groupChannel: resolvedGroupChannel, groupSpace: resolvedGroupSpace, currentThreadTs: resolvedThreadId != null ? String(resolvedThreadId) : void 0 }, groupId: resolvedGroupId, groupChannel: resolvedGroupChannel, groupSpace: resolvedGroupSpace, spawnedBy: spawnedByValue, timeout: request.timeout?.toString(), bestEffortDeliver, messageChannel: resolvedChannel, runId, lane: request.lane, extraSystemPrompt: request.extraSystemPrompt }, defaultRuntime, context.deps).then((result) => { const payload = { runId, status: "ok", summary: "completed", result }; context.dedupe.set(`agent:${idem}`, { ts: Date.now(), ok: true, payload }); respond(true, payload, void 0, { runId }); }).catch((err) => { const error = errorShape(ErrorCodes.UNAVAILABLE, String(err)); const payload = { runId, status: "error", summary: String(err) }; context.dedupe.set(`agent:${idem}`, { ts: Date.now(), ok: false, payload, error }); respond(false, payload, error, { runId, error: formatForLog(err) }); }); }, "agent.identity.get": ({ params, respond }) => { if (!validateAgentIdentityParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid agent.identity.get params: ${formatValidationErrors(validateAgentIdentityParams.errors)}`)); return; } const p = params; const agentIdRaw = typeof p.agentId === "string" ? p.agentId.trim() : ""; const sessionKeyRaw = typeof p.sessionKey === "string" ? p.sessionKey.trim() : ""; let agentId = agentIdRaw ? normalizeAgentId(agentIdRaw) : void 0; if (sessionKeyRaw) { const resolved = resolveAgentIdFromSessionKey(sessionKeyRaw); if (agentId && resolved !== agentId) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid agent.identity.get params: agent "${agentIdRaw}" does not match session key agent "${resolved}"`)); return; } agentId = resolved; } const cfg = loadConfig(); const identity = resolveAssistantIdentity({ cfg, agentId }); const avatarValue = resolveAssistantAvatarUrl({ avatar: identity.avatar, agentId: identity.agentId, basePath: cfg.gateway?.controlUi?.basePath }) ?? identity.avatar; respond(true, { ...identity, avatar: avatarValue }, void 0); }, "agent.wait": async ({ params, respond }) => { if (!validateAgentWaitParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid agent.wait params: ${formatValidationErrors(validateAgentWaitParams.errors)}`)); return; } const p = params; const runId = p.runId.trim(); const snapshot = await waitForAgentJob({ runId, timeoutMs: typeof p.timeoutMs === "number" && Number.isFinite(p.timeoutMs) ? Math.max(0, Math.floor(p.timeoutMs)) : 3e4 }); if (!snapshot) { respond(true, { runId, status: "timeout" }); return; } respond(true, { runId, status: snapshot.status, startedAt: snapshot.startedAt, endedAt: snapshot.endedAt, error: snapshot.error }); } }; //#endregion //#region src/gateway/server-methods/agents.ts const BOOTSTRAP_FILE_NAMES = [ DEFAULT_AGENTS_FILENAME, DEFAULT_SOUL_FILENAME, DEFAULT_TOOLS_FILENAME, DEFAULT_IDENTITY_FILENAME, DEFAULT_USER_FILENAME, DEFAULT_HEARTBEAT_FILENAME, DEFAULT_BOOTSTRAP_FILENAME ]; const MEMORY_FILE_NAMES = [DEFAULT_MEMORY_FILENAME, DEFAULT_MEMORY_ALT_FILENAME]; const ALLOWED_FILE_NAMES = new Set([...BOOTSTRAP_FILE_NAMES, ...MEMORY_FILE_NAMES]); async function statFile(filePath) { try { const stat = await fs$1.stat(filePath); if (!stat.isFile()) return null; return { size: stat.size, updatedAtMs: Math.floor(stat.mtimeMs) }; } catch { return null; } } async function listAgentFiles(workspaceDir) { const files = []; for (const name of BOOTSTRAP_FILE_NAMES) { const filePath = path.join(workspaceDir, name); const meta = await statFile(filePath); if (meta) files.push({ name, path: filePath, missing: false, size: meta.size, updatedAtMs: meta.updatedAtMs }); else files.push({ name, path: filePath, missing: true }); } const primaryMemoryPath = path.join(workspaceDir, DEFAULT_MEMORY_FILENAME); const primaryMeta = await statFile(primaryMemoryPath); if (primaryMeta) files.push({ name: DEFAULT_MEMORY_FILENAME, path: primaryMemoryPath, missing: false, size: primaryMeta.size, updatedAtMs: primaryMeta.updatedAtMs }); else { const altMemoryPath = path.join(workspaceDir, DEFAULT_MEMORY_ALT_FILENAME); const altMeta = await statFile(altMemoryPath); if (altMeta) files.push({ name: DEFAULT_MEMORY_ALT_FILENAME, path: altMemoryPath, missing: false, size: altMeta.size, updatedAtMs: altMeta.updatedAtMs }); else files.push({ name: DEFAULT_MEMORY_FILENAME, path: primaryMemoryPath, missing: true }); } return files; } function resolveAgentIdOrError(agentIdRaw, cfg) { const agentId = normalizeAgentId(agentIdRaw); if (!new Set(listAgentIds(cfg)).has(agentId)) return null; return agentId; } const agentsHandlers = { "agents.list": ({ params, respond }) => { if (!validateAgentsListParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid agents.list params: ${formatValidationErrors(validateAgentsListParams.errors)}`)); return; } respond(true, listAgentsForGateway(loadConfig()), void 0); }, "agents.files.list": async ({ params, respond }) => { if (!validateAgentsFilesListParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid agents.files.list params: ${formatValidationErrors(validateAgentsFilesListParams.errors)}`)); return; } const cfg = loadConfig(); const agentId = resolveAgentIdOrError(String(params.agentId ?? ""), cfg); if (!agentId) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "unknown agent id")); return; } const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); respond(true, { agentId, workspace: workspaceDir, files: await listAgentFiles(workspaceDir) }, void 0); }, "agents.files.get": async ({ params, respond }) => { if (!validateAgentsFilesGetParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid agents.files.get params: ${formatValidationErrors(validateAgentsFilesGetParams.errors)}`)); return; } const cfg = loadConfig(); const agentId = resolveAgentIdOrError(String(params.agentId ?? ""), cfg); if (!agentId) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "unknown agent id")); return; } const name = String(params.name ?? "").trim(); if (!ALLOWED_FILE_NAMES.has(name)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `unsupported file "${name}"`)); return; } const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); const filePath = path.join(workspaceDir, name); const meta = await statFile(filePath); if (!meta) { respond(true, { agentId, workspace: workspaceDir, file: { name, path: filePath, missing: true } }, void 0); return; } const content = await fs$1.readFile(filePath, "utf-8"); respond(true, { agentId, workspace: workspaceDir, file: { name, path: filePath, missing: false, size: meta.size, updatedAtMs: meta.updatedAtMs, content } }, void 0); }, "agents.files.set": async ({ params, respond }) => { if (!validateAgentsFilesSetParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid agents.files.set params: ${formatValidationErrors(validateAgentsFilesSetParams.errors)}`)); return; } const cfg = loadConfig(); const agentId = resolveAgentIdOrError(String(params.agentId ?? ""), cfg); if (!agentId) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "unknown agent id")); return; } const name = String(params.name ?? "").trim(); if (!ALLOWED_FILE_NAMES.has(name)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `unsupported file "${name}"`)); return; } const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); await fs$1.mkdir(workspaceDir, { recursive: true }); const filePath = path.join(workspaceDir, name); const content = String(params.content ?? ""); await fs$1.writeFile(filePath, content, "utf-8"); const meta = await statFile(filePath); respond(true, { ok: true, agentId, workspace: workspaceDir, file: { name, path: filePath, missing: false, size: meta?.size, updatedAtMs: meta?.updatedAtMs, content } }, void 0); } }; //#endregion //#region src/gateway/node-command-policy.ts const CANVAS_COMMANDS = [ "canvas.present", "canvas.hide", "canvas.navigate", "canvas.eval", "canvas.snapshot", "canvas.a2ui.push", "canvas.a2ui.pushJSONL", "canvas.a2ui.reset" ]; const CAMERA_COMMANDS = [ "camera.list", "camera.snap", "camera.clip" ]; const SCREEN_COMMANDS = ["screen.record"]; const LOCATION_COMMANDS = ["location.get"]; const SMS_COMMANDS = ["sms.send"]; const SYSTEM_COMMANDS = [ "system.run", "system.which", "system.notify", "system.execApprovals.get", "system.execApprovals.set", "browser.proxy" ]; const PLATFORM_DEFAULTS = { ios: [ ...CANVAS_COMMANDS, ...CAMERA_COMMANDS, ...SCREEN_COMMANDS, ...LOCATION_COMMANDS ], android: [ ...CANVAS_COMMANDS, ...CAMERA_COMMANDS, ...SCREEN_COMMANDS, ...LOCATION_COMMANDS, ...SMS_COMMANDS ], macos: [ ...CANVAS_COMMANDS, ...CAMERA_COMMANDS, ...SCREEN_COMMANDS, ...LOCATION_COMMANDS, ...SYSTEM_COMMANDS ], linux: [...SYSTEM_COMMANDS], windows: [...SYSTEM_COMMANDS], unknown: [ ...CANVAS_COMMANDS, ...CAMERA_COMMANDS, ...SCREEN_COMMANDS, ...LOCATION_COMMANDS, ...SMS_COMMANDS, ...SYSTEM_COMMANDS ] }; function normalizePlatformId(platform, deviceFamily) { const raw = (platform ?? "").trim().toLowerCase(); if (raw.startsWith("ios")) return "ios"; if (raw.startsWith("android")) return "android"; if (raw.startsWith("mac")) return "macos"; if (raw.startsWith("darwin")) return "macos"; if (raw.startsWith("win")) return "windows"; if (raw.startsWith("linux")) return "linux"; const family = (deviceFamily ?? "").trim().toLowerCase(); if (family.includes("iphone") || family.includes("ipad") || family.includes("ios")) return "ios"; if (family.includes("android")) return "android"; if (family.includes("mac")) return "macos"; if (family.includes("windows")) return "windows"; if (family.includes("linux")) return "linux"; return "unknown"; } function resolveNodeCommandAllowlist(cfg, node) { const base = PLATFORM_DEFAULTS[normalizePlatformId(node?.platform, node?.deviceFamily)] ?? PLATFORM_DEFAULTS.unknown; const extra = cfg.gateway?.nodes?.allowCommands ?? []; const deny = new Set(cfg.gateway?.nodes?.denyCommands ?? []); const allow = new Set([...base, ...extra].map((cmd) => cmd.trim()).filter(Boolean)); for (const blocked of deny) { const trimmed = blocked.trim(); if (trimmed) allow.delete(trimmed); } return allow; } function isNodeCommandAllowed(params) { const command = params.command.trim(); if (!command) return { ok: false, reason: "command required" }; if (!params.allowlist.has(command)) return { ok: false, reason: "command not allowlisted" }; if (Array.isArray(params.declaredCommands) && params.declaredCommands.length > 0) { if (!params.declaredCommands.includes(command)) return { ok: false, reason: "command not declared by node" }; } else return { ok: false, reason: "node did not declare commands" }; return { ok: true }; } //#endregion //#region src/gateway/server-methods/nodes.helpers.ts function respondInvalidParams(params) { params.respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid ${params.method} params: ${formatValidationErrors(params.validator.errors)}`)); } async function respondUnavailableOnThrow(respond, fn) { try { await fn(); } catch (err) { respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err))); } } function uniqueSortedStrings(values) { return [...new Set(values.filter((v) => typeof v === "string"))].map((v) => v.trim()).filter(Boolean).toSorted(); } function safeParseJson(value) { if (typeof value !== "string") return; const trimmed = value.trim(); if (!trimmed) return; try { return JSON.parse(trimmed); } catch { return { payloadJSON: value }; } } //#endregion //#region src/gateway/server-methods/browser.ts function isBrowserNode(node) { const caps = Array.isArray(node.caps) ? node.caps : []; const commands = Array.isArray(node.commands) ? node.commands : []; return caps.includes("browser") || commands.includes("browser.proxy"); } function normalizeNodeKey(value) { return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, ""); } function resolveBrowserNode(nodes, query) { const q = query.trim(); if (!q) return null; const qNorm = normalizeNodeKey(q); const matches = nodes.filter((node) => { if (node.nodeId === q) return true; if (typeof node.remoteIp === "string" && node.remoteIp === q) return true; const name = typeof node.displayName === "string" ? node.displayName : ""; if (name && normalizeNodeKey(name) === qNorm) return true; if (q.length >= 6 && node.nodeId.startsWith(q)) return true; return false; }); if (matches.length === 1) return matches[0] ?? null; if (matches.length === 0) return null; throw new Error(`ambiguous node: ${q} (matches: ${matches.map((node) => node.displayName || node.remoteIp || node.nodeId).join(", ")})`); } function resolveBrowserNodeTarget(params) { const policy = params.cfg.gateway?.nodes?.browser; const mode = policy?.mode ?? "auto"; if (mode === "off") return null; const browserNodes = params.nodes.filter((node) => isBrowserNode(node)); if (browserNodes.length === 0) { if (policy?.node?.trim()) throw new Error("No connected browser-capable nodes."); return null; } const requested = policy?.node?.trim() || ""; if (requested) { const resolved = resolveBrowserNode(browserNodes, requested); if (!resolved) throw new Error(`Configured browser node not connected: ${requested}`); return resolved; } if (mode === "manual") return null; if (browserNodes.length === 1) return browserNodes[0] ?? null; return null; } async function persistProxyFiles(files) { if (!files || files.length === 0) return /* @__PURE__ */ new Map(); const mapping = /* @__PURE__ */ new Map(); for (const file of files) { const buffer = Buffer.from(file.base64, "base64"); const saved = await saveMediaBuffer(buffer, file.mimeType, "browser", buffer.byteLength); mapping.set(file.path, saved.path); } return mapping; } function applyProxyPaths(result, mapping) { if (!result || typeof result !== "object") return; const obj = result; if (typeof obj.path === "string" && mapping.has(obj.path)) obj.path = mapping.get(obj.path); if (typeof obj.imagePath === "string" && mapping.has(obj.imagePath)) obj.imagePath = mapping.get(obj.imagePath); const download = obj.download; if (download && typeof download === "object") { const d = download; if (typeof d.path === "string" && mapping.has(d.path)) d.path = mapping.get(d.path); } } const browserHandlers = { "browser.request": async ({ params, respond, context }) => { const typed = params; const methodRaw = typeof typed.method === "string" ? typed.method.trim().toUpperCase() : ""; const path = typeof typed.path === "string" ? typed.path.trim() : ""; const query = typed.query && typeof typed.query === "object" ? typed.query : void 0; const body = typed.body; const timeoutMs = typeof typed.timeoutMs === "number" && Number.isFinite(typed.timeoutMs) ? Math.max(1, Math.floor(typed.timeoutMs)) : void 0; if (!methodRaw || !path) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "method and path are required")); return; } if (methodRaw !== "GET" && methodRaw !== "POST" && methodRaw !== "DELETE") { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "method must be GET, POST, or DELETE")); return; } const cfg = loadConfig(); let nodeTarget = null; try { nodeTarget = resolveBrowserNodeTarget({ cfg, nodes: context.nodeRegistry.listConnected() }); } catch (err) { respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, String(err))); return; } if (nodeTarget) { const allowlist = resolveNodeCommandAllowlist(cfg, nodeTarget); const allowed = isNodeCommandAllowed({ command: "browser.proxy", declaredCommands: nodeTarget.commands, allowlist }); if (!allowed.ok) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "node command not allowed", { details: { reason: allowed.reason, command: "browser.proxy" } })); return; } const proxyParams = { method: methodRaw, path, query, body, timeoutMs, profile: typeof query?.profile === "string" ? query.profile : void 0 }; const res = await context.nodeRegistry.invoke({ nodeId: nodeTarget.nodeId, command: "browser.proxy", params: proxyParams, timeoutMs, idempotencyKey: crypto.randomUUID() }); if (!res.ok) { respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, res.error?.message ?? "node invoke failed", { details: { nodeError: res.error ?? null } })); return; } const payload = res.payloadJSON ? safeParseJson(res.payloadJSON) : res.payload; const proxy = payload && typeof payload === "object" ? payload : null; if (!proxy || !("result" in proxy)) { respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, "browser proxy failed")); return; } const mapping = await persistProxyFiles(proxy.files); applyProxyPaths(proxy.result, mapping); respond(true, proxy.result); return; } if (!await startBrowserControlServiceFromConfig()) { respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, "browser control is disabled")); return; } let dispatcher; try { dispatcher = createBrowserRouteDispatcher(createBrowserControlContext()); } catch (err) { respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, String(err))); return; } const result = await dispatcher.dispatch({ method: methodRaw, path, query, body }); if (result.status >= 400) { const message = result.body && typeof result.body === "object" && "error" in result.body ? String(result.body.error) : `browser request failed (${result.status})`; respond(false, void 0, errorShape(result.status >= 500 ? ErrorCodes.UNAVAILABLE : ErrorCodes.INVALID_REQUEST, message, { details: result.body })); return; } respond(true, result.body); } }; //#endregion //#region src/gateway/server-methods/channels.ts async function logoutChannelAccount(params) { const resolvedAccountId = params.accountId?.trim() || params.plugin.config.defaultAccountId?.(params.cfg) || params.plugin.config.listAccountIds(params.cfg)[0] || DEFAULT_ACCOUNT_ID; const account = params.plugin.config.resolveAccount(params.cfg, resolvedAccountId); await params.context.stopChannel(params.channelId, resolvedAccountId); const result = await params.plugin.gateway?.logoutAccount?.({ cfg: params.cfg, accountId: resolvedAccountId, account, runtime: defaultRuntime }); if (!result) throw new Error(`Channel ${params.channelId} does not support logout`); const cleared = Boolean(result.cleared); if (typeof result.loggedOut === "boolean" ? result.loggedOut : cleared) params.context.markChannelLoggedOut(params.channelId, true, resolvedAccountId); return { channel: params.channelId, accountId: resolvedAccountId, ...result, cleared }; } const channelsHandlers = { "channels.status": async ({ params, respond, context }) => { if (!validateChannelsStatusParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid channels.status params: ${formatValidationErrors(validateChannelsStatusParams.errors)}`)); return; } const probe = params.probe === true; const timeoutMsRaw = params.timeoutMs; const timeoutMs = typeof timeoutMsRaw === "number" ? Math.max(1e3, timeoutMsRaw) : 1e4; const cfg = loadConfig(); const runtime = context.getRuntimeSnapshot(); const plugins = listChannelPlugins(); const pluginMap = new Map(plugins.map((plugin) => [plugin.id, plugin])); const resolveRuntimeSnapshot = (channelId, accountId, defaultAccountId) => { const accounts = runtime.channelAccounts[channelId]; const defaultRuntime = runtime.channels[channelId]; const raw = accounts?.[accountId] ?? (accountId === defaultAccountId ? defaultRuntime : void 0); if (!raw) return; return raw; }; const isAccountEnabled = (plugin, account) => plugin.config.isEnabled ? plugin.config.isEnabled(account, cfg) : !account || typeof account !== "object" || account.enabled !== false; const buildChannelAccounts = async (channelId) => { const plugin = pluginMap.get(channelId); if (!plugin) return { accounts: [], defaultAccountId: DEFAULT_ACCOUNT_ID, defaultAccount: void 0, resolvedAccounts: {} }; const accountIds = plugin.config.listAccountIds(cfg); const defaultAccountId = resolveChannelDefaultAccountId({ plugin, cfg, accountIds }); const accounts = []; const resolvedAccounts = {}; for (const accountId of accountIds) { const account = plugin.config.resolveAccount(cfg, accountId); const enabled = isAccountEnabled(plugin, account); resolvedAccounts[accountId] = account; let probeResult; let lastProbeAt = null; if (probe && enabled && plugin.status?.probeAccount) { let configured = true; if (plugin.config.isConfigured) configured = await plugin.config.isConfigured(account, cfg); if (configured) { probeResult = await plugin.status.probeAccount({ account, timeoutMs, cfg }); lastProbeAt = Date.now(); } } let auditResult; if (probe && enabled && plugin.status?.auditAccount) { let configured = true; if (plugin.config.isConfigured) configured = await plugin.config.isConfigured(account, cfg); if (configured) auditResult = await plugin.status.auditAccount({ account, timeoutMs, cfg, probe: probeResult }); } const snapshot = await buildChannelAccountSnapshot({ plugin, cfg, accountId, runtime: resolveRuntimeSnapshot(channelId, accountId, defaultAccountId), probe: probeResult, audit: auditResult }); if (lastProbeAt) snapshot.lastProbeAt = lastProbeAt; const activity = getChannelActivity({ channel: channelId, accountId }); if (snapshot.lastInboundAt == null) snapshot.lastInboundAt = activity.inboundAt; if (snapshot.lastOutboundAt == null) snapshot.lastOutboundAt = activity.outboundAt; accounts.push(snapshot); } return { accounts, defaultAccountId, defaultAccount: accounts.find((entry) => entry.accountId === defaultAccountId) ?? accounts[0], resolvedAccounts }; }; const uiCatalog = buildChannelUiCatalog(plugins); const payload = { ts: Date.now(), channelOrder: uiCatalog.order, channelLabels: uiCatalog.labels, channelDetailLabels: uiCatalog.detailLabels, channelSystemImages: uiCatalog.systemImages, channelMeta: uiCatalog.entries, channels: {}, channelAccounts: {}, channelDefaultAccountId: {} }; const channelsMap = payload.channels; const accountsMap = payload.channelAccounts; const defaultAccountIdMap = payload.channelDefaultAccountId; for (const plugin of plugins) { const { accounts, defaultAccountId, defaultAccount, resolvedAccounts } = await buildChannelAccounts(plugin.id); const fallbackAccount = resolvedAccounts[defaultAccountId] ?? plugin.config.resolveAccount(cfg, defaultAccountId); const summary = plugin.status?.buildChannelSummary ? await plugin.status.buildChannelSummary({ account: fallbackAccount, cfg, defaultAccountId, snapshot: defaultAccount ?? { accountId: defaultAccountId } }) : { configured: defaultAccount?.configured ?? false }; channelsMap[plugin.id] = summary; accountsMap[plugin.id] = accounts; defaultAccountIdMap[plugin.id] = defaultAccountId; } respond(true, payload, void 0); }, "channels.logout": async ({ params, respond, context }) => { if (!validateChannelsLogoutParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid channels.logout params: ${formatValidationErrors(validateChannelsLogoutParams.errors)}`)); return; } const rawChannel = params.channel; const channelId = typeof rawChannel === "string" ? normalizeChannelId(rawChannel) : null; if (!channelId) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "invalid channels.logout channel")); return; } const plugin = getChannelPlugin(channelId); if (!plugin?.gateway?.logoutAccount) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `channel ${channelId} does not support logout`)); return; } const accountIdRaw = params.accountId; const accountId = typeof accountIdRaw === "string" ? accountIdRaw.trim() : void 0; const snapshot = await readConfigFileSnapshot(); if (!snapshot.valid) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "config invalid; fix it before logging out")); return; } try { respond(true, await logoutChannelAccount({ channelId, accountId, cfg: snapshot.config ?? {}, context, plugin }), void 0); } catch (err) { respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err))); } } }; //#endregion //#region src/gateway/server-methods/chat.ts function resolveTranscriptPath(params) { const { sessionId, storePath, sessionFile } = params; if (sessionFile) return sessionFile; if (!storePath) return null; return path.join(path.dirname(storePath), `${sessionId}.jsonl`); } function ensureTranscriptFile(params) { if (fs.existsSync(params.transcriptPath)) return { ok: true }; try { fs.mkdirSync(path.dirname(params.transcriptPath), { recursive: true }); const header = { type: "session", version: CURRENT_SESSION_VERSION, id: params.sessionId, timestamp: (/* @__PURE__ */ new Date()).toISOString(), cwd: process.cwd() }; fs.writeFileSync(params.transcriptPath, `${JSON.stringify(header)}\n`, "utf-8"); return { ok: true }; } catch (err) { return { ok: false, error: err instanceof Error ? err.message : String(err) }; } } function appendAssistantTranscriptMessage(params) { const transcriptPath = resolveTranscriptPath({ sessionId: params.sessionId, storePath: params.storePath, sessionFile: params.sessionFile }); if (!transcriptPath) return { ok: false, error: "transcript path not resolved" }; if (!fs.existsSync(transcriptPath)) { if (!params.createIfMissing) return { ok: false, error: "transcript file not found" }; const ensured = ensureTranscriptFile({ transcriptPath, sessionId: params.sessionId }); if (!ensured.ok) return { ok: false, error: ensured.error ?? "failed to create transcript file" }; } const now = Date.now(); const messageId = randomUUID().slice(0, 8); const messageBody = { role: "assistant", content: [{ type: "text", text: `${params.label ? `[${params.label}]\n\n` : ""}${params.message}` }], timestamp: now, stopReason: "injected", usage: { input: 0, output: 0, totalTokens: 0 } }; const transcriptEntry = { type: "message", id: messageId, timestamp: new Date(now).toISOString(), message: messageBody }; try { fs.appendFileSync(transcriptPath, `${JSON.stringify(transcriptEntry)}\n`, "utf-8"); } catch (err) { return { ok: false, error: err instanceof Error ? err.message : String(err) }; } return { ok: true, messageId, message: transcriptEntry.message }; } function nextChatSeq(context, runId) { const next = (context.agentRunSeq.get(runId) ?? 0) + 1; context.agentRunSeq.set(runId, next); return next; } function broadcastChatFinal(params) { const seq = nextChatSeq({ agentRunSeq: params.context.agentRunSeq }, params.runId); const payload = { runId: params.runId, sessionKey: params.sessionKey, seq, state: "final", message: params.message }; params.context.broadcast("chat", payload); params.context.nodeSendToSession(params.sessionKey, "chat", payload); } function broadcastChatError(params) { const seq = nextChatSeq({ agentRunSeq: params.context.agentRunSeq }, params.runId); const payload = { runId: params.runId, sessionKey: params.sessionKey, seq, state: "error", errorMessage: params.errorMessage }; params.context.broadcast("chat", payload); params.context.nodeSendToSession(params.sessionKey, "chat", payload); } const chatHandlers = { "chat.history": async ({ params, respond, context }) => { if (!validateChatHistoryParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid chat.history params: ${formatValidationErrors(validateChatHistoryParams.errors)}`)); return; } const { sessionKey, limit } = params; const { cfg, storePath, entry } = loadSessionEntry(sessionKey); const sessionId = entry?.sessionId; const rawMessages = sessionId && storePath ? readSessionMessages(sessionId, storePath, entry?.sessionFile) : []; const hardMax = 1e3; const requested = typeof limit === "number" ? limit : 200; const max = Math.min(hardMax, requested); const capped = capArrayByJsonBytes(stripEnvelopeFromMessages(rawMessages.length > max ? rawMessages.slice(-max) : rawMessages), getMaxChatHistoryMessagesBytes()).items; let thinkingLevel = entry?.thinkingLevel; if (!thinkingLevel) { const configured = cfg.agents?.defaults?.thinkingDefault; if (configured) thinkingLevel = configured; else { const { provider, model } = resolveSessionModelRef(cfg, entry, resolveSessionAgentId({ sessionKey, config: cfg })); thinkingLevel = resolveThinkingDefault({ cfg, provider, model, catalog: await context.loadGatewayModelCatalog() }); } } const verboseLevel = entry?.verboseLevel ?? cfg.agents?.defaults?.verboseDefault; respond(true, { sessionKey, sessionId, messages: capped, thinkingLevel, verboseLevel }); }, "chat.abort": ({ params, respond, context }) => { if (!validateChatAbortParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid chat.abort params: ${formatValidationErrors(validateChatAbortParams.errors)}`)); return; } const { sessionKey, runId } = params; const ops = { chatAbortControllers: context.chatAbortControllers, chatRunBuffers: context.chatRunBuffers, chatDeltaSentAt: context.chatDeltaSentAt, chatAbortedRuns: context.chatAbortedRuns, removeChatRun: context.removeChatRun, agentRunSeq: context.agentRunSeq, broadcast: context.broadcast, nodeSendToSession: context.nodeSendToSession }; if (!runId) { const res = abortChatRunsForSessionKey(ops, { sessionKey, stopReason: "rpc" }); respond(true, { ok: true, aborted: res.aborted, runIds: res.runIds }); return; } const active = context.chatAbortControllers.get(runId); if (!active) { respond(true, { ok: true, aborted: false, runIds: [] }); return; } if (active.sessionKey !== sessionKey) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "runId does not match sessionKey")); return; } const res = abortChatRunById(ops, { runId, sessionKey, stopReason: "rpc" }); respond(true, { ok: true, aborted: res.aborted, runIds: res.aborted ? [runId] : [] }); }, "chat.send": async ({ params, respond, context, client }) => { if (!validateChatSendParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid chat.send params: ${formatValidationErrors(validateChatSendParams.errors)}`)); return; } const p = params; const stopCommand = isChatStopCommandText(p.message); const normalizedAttachments = p.attachments?.map((a) => ({ type: typeof a?.type === "string" ? a.type : void 0, mimeType: typeof a?.mimeType === "string" ? a.mimeType : void 0, fileName: typeof a?.fileName === "string" ? a.fileName : void 0, content: typeof a?.content === "string" ? a.content : ArrayBuffer.isView(a?.content) ? Buffer.from(a.content.buffer, a.content.byteOffset, a.content.byteLength).toString("base64") : void 0 })).filter((a) => a.content) ?? []; if (!p.message.trim() && normalizedAttachments.length === 0) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "message or attachment required")); return; } let parsedMessage = p.message; let parsedImages = []; if (normalizedAttachments.length > 0) try { const parsed = await parseMessageWithAttachments(p.message, normalizedAttachments, { maxBytes: 5e6, log: context.logGateway }); parsedMessage = parsed.message; parsedImages = parsed.images; } catch (err) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, String(err))); return; } const { cfg, entry } = loadSessionEntry(p.sessionKey); const timeoutMs = resolveAgentTimeoutMs({ cfg, overrideMs: p.timeoutMs }); const now = Date.now(); const clientRunId = p.idempotencyKey; if (resolveSendPolicy({ cfg, entry, sessionKey: p.sessionKey, channel: entry?.channel, chatType: entry?.chatType }) === "deny") { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "send blocked by session policy")); return; } if (stopCommand) { const res = abortChatRunsForSessionKey({ chatAbortControllers: context.chatAbortControllers, chatRunBuffers: context.chatRunBuffers, chatDeltaSentAt: context.chatDeltaSentAt, chatAbortedRuns: context.chatAbortedRuns, removeChatRun: context.removeChatRun, agentRunSeq: context.agentRunSeq, broadcast: context.broadcast, nodeSendToSession: context.nodeSendToSession }, { sessionKey: p.sessionKey, stopReason: "stop" }); respond(true, { ok: true, aborted: res.aborted, runIds: res.runIds }); return; } const cached = context.dedupe.get(`chat:${clientRunId}`); if (cached) { respond(cached.ok, cached.payload, cached.error, { cached: true }); return; } if (context.chatAbortControllers.get(clientRunId)) { respond(true, { runId: clientRunId, status: "in_flight" }, void 0, { cached: true, runId: clientRunId }); return; } try { const abortController = new AbortController(); context.chatAbortControllers.set(clientRunId, { controller: abortController, sessionId: entry?.sessionId ?? clientRunId, sessionKey: p.sessionKey, startedAtMs: now, expiresAtMs: resolveChatRunExpiresAtMs({ now, timeoutMs }) }); respond(true, { runId: clientRunId, status: "started" }, void 0, { runId: clientRunId }); const trimmedMessage = parsedMessage.trim(); const commandBody = Boolean(p.thinking && trimmedMessage && !trimmedMessage.startsWith("/")) ? `/think ${p.thinking} ${parsedMessage}` : parsedMessage; const clientInfo = client?.connect?.client; const stampedMessage = injectTimestamp(parsedMessage, timestampOptsFromConfig(cfg)); const ctx = { Body: parsedMessage, BodyForAgent: stampedMessage, BodyForCommands: commandBody, RawBody: parsedMessage, CommandBody: commandBody, SessionKey: p.sessionKey, Provider: INTERNAL_MESSAGE_CHANNEL, Surface: INTERNAL_MESSAGE_CHANNEL, OriginatingChannel: INTERNAL_MESSAGE_CHANNEL, ChatType: "direct", CommandAuthorized: true, MessageSid: clientRunId, SenderId: clientInfo?.id, SenderName: clientInfo?.displayName, SenderUsername: clientInfo?.displayName, GatewayClientScopes: client?.connect?.scopes }; const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ cfg, agentId: resolveSessionAgentId({ sessionKey: p.sessionKey, config: cfg }), channel: INTERNAL_MESSAGE_CHANNEL }); const finalReplyParts = []; const dispatcher = createReplyDispatcher({ ...prefixOptions, onError: (err) => { context.logGateway.warn(`webchat dispatch failed: ${formatForLog(err)}`); }, deliver: async (payload, info) => { if (info.kind !== "final") return; const text = payload.text?.trim() ?? ""; if (!text) return; finalReplyParts.push(text); } }); let agentRunStarted = false; dispatchInboundMessage({ ctx, cfg, dispatcher, replyOptions: { runId: clientRunId, abortSignal: abortController.signal, images: parsedImages.length > 0 ? parsedImages : void 0, disableBlockStreaming: true, onAgentRunStart: (runId) => { agentRunStarted = true; const connId = typeof client?.connId === "string" ? client.connId : void 0; const wantsToolEvents = hasGatewayClientCap(client?.connect?.caps, GATEWAY_CLIENT_CAPS.TOOL_EVENTS); if (connId && wantsToolEvents) context.registerToolEventRecipient(runId, connId); }, onModelSelected } }).then(() => { if (!agentRunStarted) { const combinedReply = finalReplyParts.map((part) => part.trim()).filter(Boolean).join("\n\n").trim(); let message; if (combinedReply) { const { storePath: latestStorePath, entry: latestEntry } = loadSessionEntry(p.sessionKey); const appended = appendAssistantTranscriptMessage({ message: combinedReply, sessionId: latestEntry?.sessionId ?? entry?.sessionId ?? clientRunId, storePath: latestStorePath, sessionFile: latestEntry?.sessionFile, createIfMissing: true }); if (appended.ok) message = appended.message; else { context.logGateway.warn(`webchat transcript append failed: ${appended.error ?? "unknown error"}`); message = { role: "assistant", content: [{ type: "text", text: combinedReply }], timestamp: Date.now(), stopReason: "injected", usage: { input: 0, output: 0, totalTokens: 0 } }; } } broadcastChatFinal({ context, runId: clientRunId, sessionKey: p.sessionKey, message }); } context.dedupe.set(`chat:${clientRunId}`, { ts: Date.now(), ok: true, payload: { runId: clientRunId, status: "ok" } }); }).catch((err) => { const error = errorShape(ErrorCodes.UNAVAILABLE, String(err)); context.dedupe.set(`chat:${clientRunId}`, { ts: Date.now(), ok: false, payload: { runId: clientRunId, status: "error", summary: String(err) }, error }); broadcastChatError({ context, runId: clientRunId, sessionKey: p.sessionKey, errorMessage: String(err) }); }).finally(() => { context.chatAbortControllers.delete(clientRunId); }); } catch (err) { const error = errorShape(ErrorCodes.UNAVAILABLE, String(err)); const payload = { runId: clientRunId, status: "error", summary: String(err) }; context.dedupe.set(`chat:${clientRunId}`, { ts: Date.now(), ok: false, payload, error }); respond(false, payload, error, { runId: clientRunId, error: formatForLog(err) }); } }, "chat.inject": async ({ params, respond, context }) => { if (!validateChatInjectParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid chat.inject params: ${formatValidationErrors(validateChatInjectParams.errors)}`)); return; } const p = params; const { storePath, entry } = loadSessionEntry(p.sessionKey); const sessionId = entry?.sessionId; if (!sessionId || !storePath) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "session not found")); return; } const transcriptPath = entry?.sessionFile ? entry.sessionFile : path.join(path.dirname(storePath), `${sessionId}.jsonl`); if (!fs.existsSync(transcriptPath)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "transcript file not found")); return; } const now = Date.now(); const messageId = randomUUID().slice(0, 8); const messageBody = { role: "assistant", content: [{ type: "text", text: `${p.label ? `[${p.label}]\n\n` : ""}${p.message}` }], timestamp: now, stopReason: "injected", usage: { input: 0, output: 0, totalTokens: 0 } }; const transcriptEntry = { type: "message", id: messageId, timestamp: new Date(now).toISOString(), message: messageBody }; try { fs.appendFileSync(transcriptPath, `${JSON.stringify(transcriptEntry)}\n`, "utf-8"); } catch (err) { const errMessage = err instanceof Error ? err.message : String(err); respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, `failed to write transcript: ${errMessage}`)); return; } const chatPayload = { runId: `inject-${messageId}`, sessionKey: p.sessionKey, seq: 0, state: "final", message: transcriptEntry.message }; context.broadcast("chat", chatPayload); context.nodeSendToSession(p.sessionKey, "chat", chatPayload); respond(true, { ok: true, messageId }); } }; //#endregion //#region src/config/merge-patch.ts function isPlainObject(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } function applyMergePatch(base, patch) { if (!isPlainObject(patch)) return patch; const result = isPlainObject(base) ? { ...base } : {}; for (const [key, value] of Object.entries(patch)) { if (value === null) { delete result[key]; continue; } if (isPlainObject(value)) { const baseValue = result[key]; result[key] = applyMergePatch(isPlainObject(baseValue) ? baseValue : {}, value); continue; } result[key] = value; } return result; } //#endregion //#region src/config/schema.ts const GROUP_LABELS = { wizard: "Wizard", update: "Update", diagnostics: "Diagnostics", logging: "Logging", gateway: "Gateway", nodeHost: "Node Host", agents: "Agents", tools: "Tools", bindings: "Bindings", audio: "Audio", models: "Models", messages: "Messages", commands: "Commands", session: "Session", cron: "Cron", hooks: "Hooks", ui: "UI", browser: "Browser", talk: "Talk", channels: "Messaging Channels", skills: "Skills", plugins: "Plugins", discovery: "Discovery", presence: "Presence", voicewake: "Voice Wake" }; const GROUP_ORDER = { wizard: 20, update: 25, diagnostics: 27, gateway: 30, nodeHost: 35, agents: 40, tools: 50, bindings: 55, audio: 60, models: 70, messages: 80, commands: 85, session: 90, cron: 100, hooks: 110, ui: 120, browser: 130, talk: 140, channels: 150, skills: 200, plugins: 205, discovery: 210, presence: 220, voicewake: 230, logging: 900 }; const FIELD_LABELS = { "meta.lastTouchedVersion": "Config Last Touched Version", "meta.lastTouchedAt": "Config Last Touched At", "update.channel": "Update Channel", "update.checkOnStart": "Update Check on Start", "diagnostics.enabled": "Diagnostics Enabled", "diagnostics.flags": "Diagnostics Flags", "diagnostics.otel.enabled": "OpenTelemetry Enabled", "diagnostics.otel.endpoint": "OpenTelemetry Endpoint", "diagnostics.otel.protocol": "OpenTelemetry Protocol", "diagnostics.otel.headers": "OpenTelemetry Headers", "diagnostics.otel.serviceName": "OpenTelemetry Service Name", "diagnostics.otel.traces": "OpenTelemetry Traces Enabled", "diagnostics.otel.metrics": "OpenTelemetry Metrics Enabled", "diagnostics.otel.logs": "OpenTelemetry Logs Enabled", "diagnostics.otel.sampleRate": "OpenTelemetry Trace Sample Rate", "diagnostics.otel.flushIntervalMs": "OpenTelemetry Flush Interval (ms)", "diagnostics.cacheTrace.enabled": "Cache Trace Enabled", "diagnostics.cacheTrace.filePath": "Cache Trace File Path", "diagnostics.cacheTrace.includeMessages": "Cache Trace Include Messages", "diagnostics.cacheTrace.includePrompt": "Cache Trace Include Prompt", "diagnostics.cacheTrace.includeSystem": "Cache Trace Include System", "agents.list.*.identity.avatar": "Identity Avatar", "agents.list.*.skills": "Agent Skill Filter", "gateway.remote.url": "Remote Gateway URL", "gateway.remote.sshTarget": "Remote Gateway SSH Target", "gateway.remote.sshIdentity": "Remote Gateway SSH Identity", "gateway.remote.token": "Remote Gateway Token", "gateway.remote.password": "Remote Gateway Password", "gateway.remote.tlsFingerprint": "Remote Gateway TLS Fingerprint", "gateway.auth.token": "Gateway Token", "gateway.auth.password": "Gateway Password", "tools.media.image.enabled": "Enable Image Understanding", "tools.media.image.maxBytes": "Image Understanding Max Bytes", "tools.media.image.maxChars": "Image Understanding Max Chars", "tools.media.image.prompt": "Image Understanding Prompt", "tools.media.image.timeoutSeconds": "Image Understanding Timeout (sec)", "tools.media.image.attachments": "Image Understanding Attachment Policy", "tools.media.image.models": "Image Understanding Models", "tools.media.image.scope": "Image Understanding Scope", "tools.media.models": "Media Understanding Shared Models", "tools.media.concurrency": "Media Understanding Concurrency", "tools.media.audio.enabled": "Enable Audio Understanding", "tools.media.audio.maxBytes": "Audio Understanding Max Bytes", "tools.media.audio.maxChars": "Audio Understanding Max Chars", "tools.media.audio.prompt": "Audio Understanding Prompt", "tools.media.audio.timeoutSeconds": "Audio Understanding Timeout (sec)", "tools.media.audio.language": "Audio Understanding Language", "tools.media.audio.attachments": "Audio Understanding Attachment Policy", "tools.media.audio.models": "Audio Understanding Models", "tools.media.audio.scope": "Audio Understanding Scope", "tools.media.video.enabled": "Enable Video Understanding", "tools.media.video.maxBytes": "Video Understanding Max Bytes", "tools.media.video.maxChars": "Video Understanding Max Chars", "tools.media.video.prompt": "Video Understanding Prompt", "tools.media.video.timeoutSeconds": "Video Understanding Timeout (sec)", "tools.media.video.attachments": "Video Understanding Attachment Policy", "tools.media.video.models": "Video Understanding Models", "tools.media.video.scope": "Video Understanding Scope", "tools.links.enabled": "Enable Link Understanding", "tools.links.maxLinks": "Link Understanding Max Links", "tools.links.timeoutSeconds": "Link Understanding Timeout (sec)", "tools.links.models": "Link Understanding Models", "tools.links.scope": "Link Understanding Scope", "tools.profile": "Tool Profile", "tools.alsoAllow": "Tool Allowlist Additions", "agents.list[].tools.profile": "Agent Tool Profile", "agents.list[].tools.alsoAllow": "Agent Tool Allowlist Additions", "tools.byProvider": "Tool Policy by Provider", "agents.list[].tools.byProvider": "Agent Tool Policy by Provider", "tools.exec.applyPatch.enabled": "Enable apply_patch", "tools.exec.applyPatch.allowModels": "apply_patch Model Allowlist", "tools.exec.notifyOnExit": "Exec Notify On Exit", "tools.exec.approvalRunningNoticeMs": "Exec Approval Running Notice (ms)", "tools.exec.host": "Exec Host", "tools.exec.security": "Exec Security", "tools.exec.ask": "Exec Ask", "tools.exec.node": "Exec Node Binding", "tools.exec.pathPrepend": "Exec PATH Prepend", "tools.exec.safeBins": "Exec Safe Bins", "tools.message.allowCrossContextSend": "Allow Cross-Context Messaging", "tools.message.crossContext.allowWithinProvider": "Allow Cross-Context (Same Provider)", "tools.message.crossContext.allowAcrossProviders": "Allow Cross-Context (Across Providers)", "tools.message.crossContext.marker.enabled": "Cross-Context Marker", "tools.message.crossContext.marker.prefix": "Cross-Context Marker Prefix", "tools.message.crossContext.marker.suffix": "Cross-Context Marker Suffix", "tools.message.broadcast.enabled": "Enable Message Broadcast", "tools.web.search.enabled": "Enable Web Search Tool", "tools.web.search.provider": "Web Search Provider", "tools.web.search.apiKey": "Brave Search API Key", "tools.web.search.maxResults": "Web Search Max Results", "tools.web.search.timeoutSeconds": "Web Search Timeout (sec)", "tools.web.search.cacheTtlMinutes": "Web Search Cache TTL (min)", "tools.web.fetch.enabled": "Enable Web Fetch Tool", "tools.web.fetch.maxChars": "Web Fetch Max Chars", "tools.web.fetch.timeoutSeconds": "Web Fetch Timeout (sec)", "tools.web.fetch.cacheTtlMinutes": "Web Fetch Cache TTL (min)", "tools.web.fetch.maxRedirects": "Web Fetch Max Redirects", "tools.web.fetch.userAgent": "Web Fetch User-Agent", "gateway.controlUi.basePath": "Control UI Base Path", "gateway.controlUi.root": "Control UI Assets Root", "gateway.controlUi.allowedOrigins": "Control UI Allowed Origins", "gateway.controlUi.allowInsecureAuth": "Allow Insecure Control UI Auth", "gateway.controlUi.dangerouslyDisableDeviceAuth": "Dangerously Disable Control UI Device Auth", "gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint", "gateway.reload.mode": "Config Reload Mode", "gateway.reload.debounceMs": "Config Reload Debounce (ms)", "gateway.nodes.browser.mode": "Gateway Node Browser Mode", "gateway.nodes.browser.node": "Gateway Node Browser Pin", "gateway.nodes.allowCommands": "Gateway Node Allowlist (Extra Commands)", "gateway.nodes.denyCommands": "Gateway Node Denylist", "nodeHost.browserProxy.enabled": "Node Browser Proxy Enabled", "nodeHost.browserProxy.allowProfiles": "Node Browser Proxy Allowed Profiles", "skills.load.watch": "Watch Skills", "skills.load.watchDebounceMs": "Skills Watch Debounce (ms)", "agents.defaults.workspace": "Workspace", "agents.defaults.repoRoot": "Repo Root", "agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars", "agents.defaults.envelopeTimezone": "Envelope Timezone", "agents.defaults.envelopeTimestamp": "Envelope Timestamp", "agents.defaults.envelopeElapsed": "Envelope Elapsed", "agents.defaults.memorySearch": "Memory Search", "agents.defaults.memorySearch.enabled": "Enable Memory Search", "agents.defaults.memorySearch.sources": "Memory Search Sources", "agents.defaults.memorySearch.extraPaths": "Extra Memory Paths", "agents.defaults.memorySearch.experimental.sessionMemory": "Memory Search Session Index (Experimental)", "agents.defaults.memorySearch.provider": "Memory Search Provider", "agents.defaults.memorySearch.remote.baseUrl": "Remote Embedding Base URL", "agents.defaults.memorySearch.remote.apiKey": "Remote Embedding API Key", "agents.defaults.memorySearch.remote.headers": "Remote Embedding Headers", "agents.defaults.memorySearch.remote.batch.concurrency": "Remote Batch Concurrency", "agents.defaults.memorySearch.model": "Memory Search Model", "agents.defaults.memorySearch.fallback": "Memory Search Fallback", "agents.defaults.memorySearch.local.modelPath": "Local Embedding Model Path", "agents.defaults.memorySearch.store.path": "Memory Search Index Path", "agents.defaults.memorySearch.store.vector.enabled": "Memory Search Vector Index", "agents.defaults.memorySearch.store.vector.extensionPath": "Memory Search Vector Extension Path", "agents.defaults.memorySearch.chunking.tokens": "Memory Chunk Tokens", "agents.defaults.memorySearch.chunking.overlap": "Memory Chunk Overlap Tokens", "agents.defaults.memorySearch.sync.onSessionStart": "Index on Session Start", "agents.defaults.memorySearch.sync.onSearch": "Index on Search (Lazy)", "agents.defaults.memorySearch.sync.watch": "Watch Memory Files", "agents.defaults.memorySearch.sync.watchDebounceMs": "Memory Watch Debounce (ms)", "agents.defaults.memorySearch.sync.sessions.deltaBytes": "Session Delta Bytes", "agents.defaults.memorySearch.sync.sessions.deltaMessages": "Session Delta Messages", "agents.defaults.memorySearch.query.maxResults": "Memory Search Max Results", "agents.defaults.memorySearch.query.minScore": "Memory Search Min Score", "agents.defaults.memorySearch.query.hybrid.enabled": "Memory Search Hybrid", "agents.defaults.memorySearch.query.hybrid.vectorWeight": "Memory Search Vector Weight", "agents.defaults.memorySearch.query.hybrid.textWeight": "Memory Search Text Weight", "agents.defaults.memorySearch.query.hybrid.candidateMultiplier": "Memory Search Hybrid Candidate Multiplier", "agents.defaults.memorySearch.cache.enabled": "Memory Search Embedding Cache", "agents.defaults.memorySearch.cache.maxEntries": "Memory Search Embedding Cache Max Entries", memory: "Memory", "memory.backend": "Memory Backend", "memory.citations": "Memory Citations Mode", "memory.qmd.command": "QMD Binary", "memory.qmd.includeDefaultMemory": "QMD Include Default Memory", "memory.qmd.paths": "QMD Extra Paths", "memory.qmd.paths.path": "QMD Path", "memory.qmd.paths.pattern": "QMD Path Pattern", "memory.qmd.paths.name": "QMD Path Name", "memory.qmd.sessions.enabled": "QMD Session Indexing", "memory.qmd.sessions.exportDir": "QMD Session Export Directory", "memory.qmd.sessions.retentionDays": "QMD Session Retention (days)", "memory.qmd.update.interval": "QMD Update Interval", "memory.qmd.update.debounceMs": "QMD Update Debounce (ms)", "memory.qmd.update.onBoot": "QMD Update on Startup", "memory.qmd.update.embedInterval": "QMD Embed Interval", "memory.qmd.limits.maxResults": "QMD Max Results", "memory.qmd.limits.maxSnippetChars": "QMD Max Snippet Chars", "memory.qmd.limits.maxInjectedChars": "QMD Max Injected Chars", "memory.qmd.limits.timeoutMs": "QMD Search Timeout (ms)", "memory.qmd.scope": "QMD Surface Scope", "auth.profiles": "Auth Profiles", "auth.order": "Auth Profile Order", "auth.cooldowns.billingBackoffHours": "Billing Backoff (hours)", "auth.cooldowns.billingBackoffHoursByProvider": "Billing Backoff Overrides", "auth.cooldowns.billingMaxHours": "Billing Backoff Cap (hours)", "auth.cooldowns.failureWindowHours": "Failover Window (hours)", "agents.defaults.models": "Models", "agents.defaults.model.primary": "Primary Model", "agents.defaults.model.fallbacks": "Model Fallbacks", "agents.defaults.imageModel.primary": "Image Model", "agents.defaults.imageModel.fallbacks": "Image Model Fallbacks", "agents.defaults.humanDelay.mode": "Human Delay Mode", "agents.defaults.humanDelay.minMs": "Human Delay Min (ms)", "agents.defaults.humanDelay.maxMs": "Human Delay Max (ms)", "agents.defaults.cliBackends": "CLI Backends", "commands.native": "Native Commands", "commands.nativeSkills": "Native Skill Commands", "commands.text": "Text Commands", "commands.bash": "Allow Bash Chat Command", "commands.bashForegroundMs": "Bash Foreground Window (ms)", "commands.config": "Allow /config", "commands.debug": "Allow /debug", "commands.restart": "Allow Restart", "commands.useAccessGroups": "Use Access Groups", "commands.ownerAllowFrom": "Command Owners", "ui.seamColor": "Accent Color", "ui.assistant.name": "Assistant Name", "ui.assistant.avatar": "Assistant Avatar", "browser.evaluateEnabled": "Browser Evaluate Enabled", "browser.snapshotDefaults": "Browser Snapshot Defaults", "browser.snapshotDefaults.mode": "Browser Snapshot Mode", "browser.remoteCdpTimeoutMs": "Remote CDP Timeout (ms)", "browser.remoteCdpHandshakeTimeoutMs": "Remote CDP Handshake Timeout (ms)", "session.dmScope": "DM Session Scope", "session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns", "messages.ackReaction": "Ack Reaction Emoji", "messages.ackReactionScope": "Ack Reaction Scope", "messages.inbound.debounceMs": "Inbound Message Debounce (ms)", "talk.apiKey": "Talk API Key", "channels.whatsapp": "WhatsApp", "channels.telegram": "Telegram", "channels.telegram.customCommands": "Telegram Custom Commands", "channels.discord": "Discord", "channels.slack": "Slack", "channels.mattermost": "Mattermost", "channels.signal": "Signal", "channels.imessage": "iMessage", "channels.bluebubbles": "BlueBubbles", "channels.msteams": "MS Teams", "channels.telegram.botToken": "Telegram Bot Token", "channels.telegram.dmPolicy": "Telegram DM Policy", "channels.telegram.streamMode": "Telegram Draft Stream Mode", "channels.telegram.draftChunk.minChars": "Telegram Draft Chunk Min Chars", "channels.telegram.draftChunk.maxChars": "Telegram Draft Chunk Max Chars", "channels.telegram.draftChunk.breakPreference": "Telegram Draft Chunk Break Preference", "channels.telegram.retry.attempts": "Telegram Retry Attempts", "channels.telegram.retry.minDelayMs": "Telegram Retry Min Delay (ms)", "channels.telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)", "channels.telegram.retry.jitter": "Telegram Retry Jitter", "channels.telegram.network.autoSelectFamily": "Telegram autoSelectFamily", "channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)", "channels.telegram.capabilities.inlineButtons": "Telegram Inline Buttons", "channels.whatsapp.dmPolicy": "WhatsApp DM Policy", "channels.whatsapp.selfChatMode": "WhatsApp Self-Phone Mode", "channels.whatsapp.debounceMs": "WhatsApp Message Debounce (ms)", "channels.signal.dmPolicy": "Signal DM Policy", "channels.imessage.dmPolicy": "iMessage DM Policy", "channels.bluebubbles.dmPolicy": "BlueBubbles DM Policy", "channels.discord.dm.policy": "Discord DM Policy", "channels.discord.retry.attempts": "Discord Retry Attempts", "channels.discord.retry.minDelayMs": "Discord Retry Min Delay (ms)", "channels.discord.retry.maxDelayMs": "Discord Retry Max Delay (ms)", "channels.discord.retry.jitter": "Discord Retry Jitter", "channels.discord.maxLinesPerMessage": "Discord Max Lines Per Message", "channels.discord.intents.presence": "Discord Presence Intent", "channels.discord.intents.guildMembers": "Discord Guild Members Intent", "channels.discord.pluralkit.enabled": "Discord PluralKit Enabled", "channels.discord.pluralkit.token": "Discord PluralKit Token", "channels.slack.dm.policy": "Slack DM Policy", "channels.slack.allowBots": "Slack Allow Bot Messages", "channels.discord.token": "Discord Bot Token", "channels.slack.botToken": "Slack Bot Token", "channels.slack.appToken": "Slack App Token", "channels.slack.userToken": "Slack User Token", "channels.slack.userTokenReadOnly": "Slack User Token Read Only", "channels.slack.thread.historyScope": "Slack Thread History Scope", "channels.slack.thread.inheritParent": "Slack Thread Parent Inheritance", "channels.mattermost.botToken": "Mattermost Bot Token", "channels.mattermost.baseUrl": "Mattermost Base URL", "channels.mattermost.chatmode": "Mattermost Chat Mode", "channels.mattermost.oncharPrefixes": "Mattermost Onchar Prefixes", "channels.mattermost.requireMention": "Mattermost Require Mention", "channels.signal.account": "Signal Account", "channels.imessage.cliPath": "iMessage CLI Path", "agents.list[].skills": "Agent Skill Filter", "agents.list[].identity.avatar": "Agent Avatar", "discovery.mdns.mode": "mDNS Discovery Mode", "plugins.enabled": "Enable Plugins", "plugins.allow": "Plugin Allowlist", "plugins.deny": "Plugin Denylist", "plugins.load.paths": "Plugin Load Paths", "plugins.slots": "Plugin Slots", "plugins.slots.memory": "Memory Plugin", "plugins.entries": "Plugin Entries", "plugins.entries.*.enabled": "Plugin Enabled", "plugins.entries.*.config": "Plugin Config", "plugins.installs": "Plugin Install Records", "plugins.installs.*.source": "Plugin Install Source", "plugins.installs.*.spec": "Plugin Install Spec", "plugins.installs.*.sourcePath": "Plugin Install Source Path", "plugins.installs.*.installPath": "Plugin Install Path", "plugins.installs.*.version": "Plugin Install Version", "plugins.installs.*.installedAt": "Plugin Install Time" }; const FIELD_HELP = { "meta.lastTouchedVersion": "Auto-set when OpenClaw writes the config.", "meta.lastTouchedAt": "ISO timestamp of the last config write (auto-set).", "update.channel": "Update channel for git + npm installs (\"stable\", \"beta\", or \"dev\").", "update.checkOnStart": "Check for npm updates when the gateway starts (default: true).", "gateway.remote.url": "Remote Gateway WebSocket URL (ws:// or wss://).", "gateway.remote.tlsFingerprint": "Expected sha256 TLS fingerprint for the remote gateway (pin to avoid MITM).", "gateway.remote.sshTarget": "Remote gateway over SSH (tunnels the gateway port to localhost). Format: user@host or user@host:port.", "gateway.remote.sshIdentity": "Optional SSH identity file path (passed to ssh -i).", "agents.list.*.skills": "Optional allowlist of skills for this agent (omit = all skills; empty = no skills).", "agents.list[].skills": "Optional allowlist of skills for this agent (omit = all skills; empty = no skills).", "agents.list[].identity.avatar": "Avatar image path (relative to the agent workspace only) or a remote URL/data URL.", "discovery.mdns.mode": "mDNS broadcast mode (\"minimal\" default, \"full\" includes cliPath/sshPort, \"off\" disables mDNS).", "gateway.auth.token": "Required by default for gateway access (unless using Tailscale Serve identity); required for non-loopback binds.", "gateway.auth.password": "Required for Tailscale funnel.", "gateway.controlUi.basePath": "Optional URL prefix where the Control UI is served (e.g. /openclaw).", "gateway.controlUi.root": "Optional filesystem root for Control UI assets (defaults to dist/control-ui).", "gateway.controlUi.allowedOrigins": "Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com).", "gateway.controlUi.allowInsecureAuth": "Allow Control UI auth over insecure HTTP (token-only; not recommended).", "gateway.controlUi.dangerouslyDisableDeviceAuth": "DANGEROUS. Disable Control UI device identity checks (token/password only).", "gateway.http.endpoints.chatCompletions.enabled": "Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).", "gateway.reload.mode": "Hot reload strategy for config changes (\"hybrid\" recommended).", "gateway.reload.debounceMs": "Debounce window (ms) before applying config changes.", "gateway.nodes.browser.mode": "Node browser routing (\"auto\" = pick single connected browser node, \"manual\" = require node param, \"off\" = disable).", "gateway.nodes.browser.node": "Pin browser routing to a specific node id or name (optional).", "gateway.nodes.allowCommands": "Extra node.invoke commands to allow beyond the gateway defaults (array of command strings).", "gateway.nodes.denyCommands": "Commands to block even if present in node claims or default allowlist.", "nodeHost.browserProxy.enabled": "Expose the local browser control server via node proxy.", "nodeHost.browserProxy.allowProfiles": "Optional allowlist of browser profile names exposed via the node proxy.", "diagnostics.flags": "Enable targeted diagnostics logs by flag (e.g. [\"telegram.http\"]). Supports wildcards like \"telegram.*\" or \"*\".", "diagnostics.cacheTrace.enabled": "Log cache trace snapshots for embedded agent runs (default: false).", "diagnostics.cacheTrace.filePath": "JSONL output path for cache trace logs (default: $OPENCLAW_STATE_DIR/logs/cache-trace.jsonl).", "diagnostics.cacheTrace.includeMessages": "Include full message payloads in trace output (default: true).", "diagnostics.cacheTrace.includePrompt": "Include prompt text in trace output (default: true).", "diagnostics.cacheTrace.includeSystem": "Include system prompt in trace output (default: true).", "tools.exec.applyPatch.enabled": "Experimental. Enables apply_patch for OpenAI models when allowed by tool policy.", "tools.exec.applyPatch.allowModels": "Optional allowlist of model ids (e.g. \"gpt-5.2\" or \"openai/gpt-5.2\").", "tools.exec.notifyOnExit": "When true (default), backgrounded exec sessions enqueue a system event and request a heartbeat on exit.", "tools.exec.pathPrepend": "Directories to prepend to PATH for exec runs (gateway/sandbox).", "tools.exec.safeBins": "Allow stdin-only safe binaries to run without explicit allowlist entries.", "tools.message.allowCrossContextSend": "Legacy override: allow cross-context sends across all providers.", "tools.message.crossContext.allowWithinProvider": "Allow sends to other channels within the same provider (default: true).", "tools.message.crossContext.allowAcrossProviders": "Allow sends across different providers (default: false).", "tools.message.crossContext.marker.enabled": "Add a visible origin marker when sending cross-context (default: true).", "tools.message.crossContext.marker.prefix": "Text prefix for cross-context markers (supports \"{channel}\").", "tools.message.crossContext.marker.suffix": "Text suffix for cross-context markers (supports \"{channel}\").", "tools.message.broadcast.enabled": "Enable broadcast action (default: true).", "tools.web.search.enabled": "Enable the web_search tool (requires a provider API key).", "tools.web.search.provider": "Search provider (\"brave\" or \"perplexity\").", "tools.web.search.apiKey": "Brave Search API key (fallback: BRAVE_API_KEY env var).", "tools.web.search.maxResults": "Default number of results to return (1-10).", "tools.web.search.timeoutSeconds": "Timeout in seconds for web_search requests.", "tools.web.search.cacheTtlMinutes": "Cache TTL in minutes for web_search results.", "tools.web.search.perplexity.apiKey": "Perplexity or OpenRouter API key (fallback: PERPLEXITY_API_KEY or OPENROUTER_API_KEY env var).", "tools.web.search.perplexity.baseUrl": "Perplexity base URL override (default: https://openrouter.ai/api/v1 or https://api.perplexity.ai).", "tools.web.search.perplexity.model": "Perplexity model override (default: \"perplexity/sonar-pro\").", "tools.web.fetch.enabled": "Enable the web_fetch tool (lightweight HTTP fetch).", "tools.web.fetch.maxChars": "Max characters returned by web_fetch (truncated).", "tools.web.fetch.maxCharsCap": "Hard cap for web_fetch maxChars (applies to config and tool calls).", "tools.web.fetch.timeoutSeconds": "Timeout in seconds for web_fetch requests.", "tools.web.fetch.cacheTtlMinutes": "Cache TTL in minutes for web_fetch results.", "tools.web.fetch.maxRedirects": "Maximum redirects allowed for web_fetch (default: 3).", "tools.web.fetch.userAgent": "Override User-Agent header for web_fetch requests.", "tools.web.fetch.readability": "Use Readability to extract main content from HTML (fallbacks to basic HTML cleanup).", "tools.web.fetch.firecrawl.enabled": "Enable Firecrawl fallback for web_fetch (if configured).", "tools.web.fetch.firecrawl.apiKey": "Firecrawl API key (fallback: FIRECRAWL_API_KEY env var).", "tools.web.fetch.firecrawl.baseUrl": "Firecrawl base URL (e.g. https://api.firecrawl.dev or custom endpoint).", "tools.web.fetch.firecrawl.onlyMainContent": "When true, Firecrawl returns only the main content (default: true).", "tools.web.fetch.firecrawl.maxAgeMs": "Firecrawl maxAge (ms) for cached results when supported by the API.", "tools.web.fetch.firecrawl.timeoutSeconds": "Timeout in seconds for Firecrawl requests.", "channels.slack.allowBots": "Allow bot-authored messages to trigger Slack replies (default: false).", "channels.slack.thread.historyScope": "Scope for Slack thread history context (\"thread\" isolates per thread; \"channel\" reuses channel history).", "channels.slack.thread.inheritParent": "If true, Slack thread sessions inherit the parent channel transcript (default: false).", "channels.mattermost.botToken": "Bot token from Mattermost System Console -> Integrations -> Bot Accounts.", "channels.mattermost.baseUrl": "Base URL for your Mattermost server (e.g., https://chat.example.com).", "channels.mattermost.chatmode": "Reply to channel messages on mention (\"oncall\"), on trigger chars (\">\" or \"!\") (\"onchar\"), or on every message (\"onmessage\").", "channels.mattermost.oncharPrefixes": "Trigger prefixes for onchar mode (default: [\">\", \"!\"]).", "channels.mattermost.requireMention": "Require @mention in channels before responding (default: true).", "auth.profiles": "Named auth profiles (provider + mode + optional email).", "auth.order": "Ordered auth profile IDs per provider (used for automatic failover).", "auth.cooldowns.billingBackoffHours": "Base backoff (hours) when a profile fails due to billing/insufficient credits (default: 5).", "auth.cooldowns.billingBackoffHoursByProvider": "Optional per-provider overrides for billing backoff (hours).", "auth.cooldowns.billingMaxHours": "Cap (hours) for billing backoff (default: 24).", "auth.cooldowns.failureWindowHours": "Failure window (hours) for backoff counters (default: 24).", "agents.defaults.bootstrapMaxChars": "Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).", "agents.defaults.repoRoot": "Optional repository root shown in the system prompt runtime line (overrides auto-detect).", "agents.defaults.envelopeTimezone": "Timezone for message envelopes (\"utc\", \"local\", \"user\", or an IANA timezone string).", "agents.defaults.envelopeTimestamp": "Include absolute timestamps in message envelopes (\"on\" or \"off\").", "agents.defaults.envelopeElapsed": "Include elapsed time in message envelopes (\"on\" or \"off\").", "agents.defaults.models": "Configured model catalog (keys are full provider/model IDs).", "agents.defaults.memorySearch": "Vector search over MEMORY.md and memory/*.md (per-agent overrides supported).", "agents.defaults.memorySearch.sources": "Sources to index for memory search (default: [\"memory\"]; add \"sessions\" to include session transcripts).", "agents.defaults.memorySearch.extraPaths": "Extra paths to include in memory search (directories or .md files; relative paths resolved from workspace).", "agents.defaults.memorySearch.experimental.sessionMemory": "Enable experimental session transcript indexing for memory search (default: false).", "agents.defaults.memorySearch.provider": "Embedding provider (\"openai\", \"gemini\", or \"local\").", "agents.defaults.memorySearch.remote.baseUrl": "Custom base URL for remote embeddings (OpenAI-compatible proxies or Gemini overrides).", "agents.defaults.memorySearch.remote.apiKey": "Custom API key for the remote embedding provider.", "agents.defaults.memorySearch.remote.headers": "Extra headers for remote embeddings (merged; remote overrides OpenAI headers).", "agents.defaults.memorySearch.remote.batch.enabled": "Enable batch API for memory embeddings (OpenAI/Gemini; default: true).", "agents.defaults.memorySearch.remote.batch.wait": "Wait for batch completion when indexing (default: true).", "agents.defaults.memorySearch.remote.batch.concurrency": "Max concurrent embedding batch jobs for memory indexing (default: 2).", "agents.defaults.memorySearch.remote.batch.pollIntervalMs": "Polling interval in ms for batch status (default: 2000).", "agents.defaults.memorySearch.remote.batch.timeoutMinutes": "Timeout in minutes for batch indexing (default: 60).", "agents.defaults.memorySearch.local.modelPath": "Local GGUF model path or hf: URI (node-llama-cpp).", "agents.defaults.memorySearch.fallback": "Fallback provider when embeddings fail (\"openai\", \"gemini\", \"local\", or \"none\").", "agents.defaults.memorySearch.store.path": "SQLite index path (default: ~/.openclaw/memory/{agentId}.sqlite).", "agents.defaults.memorySearch.store.vector.enabled": "Enable sqlite-vec extension for vector search (default: true).", "agents.defaults.memorySearch.store.vector.extensionPath": "Optional override path to sqlite-vec extension library (.dylib/.so/.dll).", "agents.defaults.memorySearch.query.hybrid.enabled": "Enable hybrid BM25 + vector search for memory (default: true).", "agents.defaults.memorySearch.query.hybrid.vectorWeight": "Weight for vector similarity when merging results (0-1).", "agents.defaults.memorySearch.query.hybrid.textWeight": "Weight for BM25 text relevance when merging results (0-1).", "agents.defaults.memorySearch.query.hybrid.candidateMultiplier": "Multiplier for candidate pool size (default: 4).", "agents.defaults.memorySearch.cache.enabled": "Cache chunk embeddings in SQLite to speed up reindexing and frequent updates (default: true).", memory: "Memory backend configuration (global).", "memory.backend": "Memory backend (\"builtin\" for OpenClaw embeddings, \"qmd\" for QMD sidecar).", "memory.citations": "Default citation behavior (\"auto\", \"on\", or \"off\").", "memory.qmd.command": "Path to the qmd binary (default: resolves from PATH).", "memory.qmd.includeDefaultMemory": "Whether to automatically index MEMORY.md + memory/**/*.md (default: true).", "memory.qmd.paths": "Additional directories/files to index with QMD (path + optional glob pattern).", "memory.qmd.paths.path": "Absolute or ~-relative path to index via QMD.", "memory.qmd.paths.pattern": "Glob pattern relative to the path root (default: **/*.md).", "memory.qmd.paths.name": "Optional stable name for the QMD collection (default derived from path).", "memory.qmd.sessions.enabled": "Enable QMD session transcript indexing (experimental, default: false).", "memory.qmd.sessions.exportDir": "Override directory for sanitized session exports before indexing.", "memory.qmd.sessions.retentionDays": "Retention window for exported sessions before pruning (default: unlimited).", "memory.qmd.update.interval": "How often the QMD sidecar refreshes indexes (duration string, default: 5m).", "memory.qmd.update.debounceMs": "Minimum delay between successive QMD refresh runs (default: 15000).", "memory.qmd.update.onBoot": "Run QMD update once on gateway startup (default: true).", "memory.qmd.update.embedInterval": "How often QMD embeddings are refreshed (duration string, default: 60m). Set to 0 to disable periodic embed.", "memory.qmd.limits.maxResults": "Max QMD results returned to the agent loop (default: 6).", "memory.qmd.limits.maxSnippetChars": "Max characters per snippet pulled from QMD (default: 700).", "memory.qmd.limits.maxInjectedChars": "Max total characters injected from QMD hits per turn.", "memory.qmd.limits.timeoutMs": "Per-query timeout for QMD searches (default: 4000).", "memory.qmd.scope": "Session/channel scope for QMD recall (same syntax as session.sendPolicy; default: direct-only).", "agents.defaults.memorySearch.cache.maxEntries": "Optional cap on cached embeddings (best-effort).", "agents.defaults.memorySearch.sync.onSearch": "Lazy sync: schedule a reindex on search after changes.", "agents.defaults.memorySearch.sync.watch": "Watch memory files for changes (chokidar).", "agents.defaults.memorySearch.sync.sessions.deltaBytes": "Minimum appended bytes before session transcripts trigger reindex (default: 100000).", "agents.defaults.memorySearch.sync.sessions.deltaMessages": "Minimum appended JSONL lines before session transcripts trigger reindex (default: 50).", "plugins.enabled": "Enable plugin/extension loading (default: true).", "plugins.allow": "Optional allowlist of plugin ids; when set, only listed plugins load.", "plugins.deny": "Optional denylist of plugin ids; deny wins over allowlist.", "plugins.load.paths": "Additional plugin files or directories to load.", "plugins.slots": "Select which plugins own exclusive slots (memory, etc.).", "plugins.slots.memory": "Select the active memory plugin by id, or \"none\" to disable memory plugins.", "plugins.entries": "Per-plugin settings keyed by plugin id (enable/disable + config payloads).", "plugins.entries.*.enabled": "Overrides plugin enable/disable for this entry (restart required).", "plugins.entries.*.config": "Plugin-defined config payload (schema is provided by the plugin).", "plugins.installs": "CLI-managed install metadata (used by `openclaw plugins update` to locate install sources).", "plugins.installs.*.source": "Install source (\"npm\", \"archive\", or \"path\").", "plugins.installs.*.spec": "Original npm spec used for install (if source is npm).", "plugins.installs.*.sourcePath": "Original archive/path used for install (if any).", "plugins.installs.*.installPath": "Resolved install directory (usually ~/.openclaw/extensions/).", "plugins.installs.*.version": "Version recorded at install time (if available).", "plugins.installs.*.installedAt": "ISO timestamp of last install/update.", "agents.list.*.identity.avatar": "Agent avatar (workspace-relative path, http(s) URL, or data URI).", "agents.defaults.model.primary": "Primary model (provider/model).", "agents.defaults.model.fallbacks": "Ordered fallback models (provider/model). Used when the primary model fails.", "agents.defaults.imageModel.primary": "Optional image model (provider/model) used when the primary model lacks image input.", "agents.defaults.imageModel.fallbacks": "Ordered fallback image models (provider/model).", "agents.defaults.cliBackends": "Optional CLI backends for text-only fallback (claude-cli, etc.).", "agents.defaults.humanDelay.mode": "Delay style for block replies (\"off\", \"natural\", \"custom\").", "agents.defaults.humanDelay.minMs": "Minimum delay in ms for custom humanDelay (default: 800).", "agents.defaults.humanDelay.maxMs": "Maximum delay in ms for custom humanDelay (default: 2500).", "commands.native": "Register native commands with channels that support it (Discord/Slack/Telegram).", "commands.nativeSkills": "Register native skill commands (user-invocable skills) with channels that support it.", "commands.text": "Allow text command parsing (slash commands only).", "commands.bash": "Allow bash chat command (`!`; `/bash` alias) to run host shell commands (default: false; requires tools.elevated).", "commands.bashForegroundMs": "How long bash waits before backgrounding (default: 2000; 0 backgrounds immediately).", "commands.config": "Allow /config chat command to read/write config on disk (default: false).", "commands.debug": "Allow /debug chat command for runtime-only overrides (default: false).", "commands.restart": "Allow /restart and gateway restart tool actions (default: false).", "commands.useAccessGroups": "Enforce access-group allowlists/policies for commands.", "commands.ownerAllowFrom": "Explicit owner allowlist for owner-only tools/commands. Use channel-native IDs (optionally prefixed like \"whatsapp:+15551234567\"). '*' is ignored.", "session.dmScope": "DM session scoping: \"main\" keeps continuity; \"per-peer\", \"per-channel-peer\", or \"per-account-channel-peer\" isolates DM history (recommended for shared inboxes/multi-account).", "session.identityLinks": "Map canonical identities to provider-prefixed peer IDs for DM session linking (example: telegram:123456).", "channels.telegram.configWrites": "Allow Telegram to write config in response to channel events/commands (default: true).", "channels.slack.configWrites": "Allow Slack to write config in response to channel events/commands (default: true).", "channels.mattermost.configWrites": "Allow Mattermost to write config in response to channel events/commands (default: true).", "channels.discord.configWrites": "Allow Discord to write config in response to channel events/commands (default: true).", "channels.whatsapp.configWrites": "Allow WhatsApp to write config in response to channel events/commands (default: true).", "channels.signal.configWrites": "Allow Signal to write config in response to channel events/commands (default: true).", "channels.imessage.configWrites": "Allow iMessage to write config in response to channel events/commands (default: true).", "channels.msteams.configWrites": "Allow Microsoft Teams to write config in response to channel events/commands (default: true).", "channels.discord.commands.native": "Override native commands for Discord (bool or \"auto\").", "channels.discord.commands.nativeSkills": "Override native skill commands for Discord (bool or \"auto\").", "channels.telegram.commands.native": "Override native commands for Telegram (bool or \"auto\").", "channels.telegram.commands.nativeSkills": "Override native skill commands for Telegram (bool or \"auto\").", "channels.slack.commands.native": "Override native commands for Slack (bool or \"auto\").", "channels.slack.commands.nativeSkills": "Override native skill commands for Slack (bool or \"auto\").", "session.agentToAgent.maxPingPongTurns": "Max reply-back turns between requester and target (0–5).", "channels.telegram.customCommands": "Additional Telegram bot menu commands (merged with native; conflicts ignored).", "messages.ackReaction": "Emoji reaction used to acknowledge inbound messages (empty disables).", "messages.ackReactionScope": "When to send ack reactions (\"group-mentions\", \"group-all\", \"direct\", \"all\").", "messages.inbound.debounceMs": "Debounce window (ms) for batching rapid inbound messages from the same sender (0 to disable).", "channels.telegram.dmPolicy": "Direct message access control (\"pairing\" recommended). \"open\" requires channels.telegram.allowFrom=[\"*\"].", "channels.telegram.streamMode": "Draft streaming mode for Telegram replies (off | partial | block). Separate from block streaming; requires private topics + sendMessageDraft.", "channels.telegram.draftChunk.minChars": "Minimum chars before emitting a Telegram draft update when channels.telegram.streamMode=\"block\" (default: 200).", "channels.telegram.draftChunk.maxChars": "Target max size for a Telegram draft update chunk when channels.telegram.streamMode=\"block\" (default: 800; clamped to channels.telegram.textChunkLimit).", "channels.telegram.draftChunk.breakPreference": "Preferred breakpoints for Telegram draft chunks (paragraph | newline | sentence). Default: paragraph.", "channels.telegram.retry.attempts": "Max retry attempts for outbound Telegram API calls (default: 3).", "channels.telegram.retry.minDelayMs": "Minimum retry delay in ms for Telegram outbound calls.", "channels.telegram.retry.maxDelayMs": "Maximum retry delay cap in ms for Telegram outbound calls.", "channels.telegram.retry.jitter": "Jitter factor (0-1) applied to Telegram retry delays.", "channels.telegram.network.autoSelectFamily": "Override Node autoSelectFamily for Telegram (true=enable, false=disable).", "channels.telegram.timeoutSeconds": "Max seconds before Telegram API requests are aborted (default: 500 per grammY).", "channels.whatsapp.dmPolicy": "Direct message access control (\"pairing\" recommended). \"open\" requires channels.whatsapp.allowFrom=[\"*\"].", "channels.whatsapp.selfChatMode": "Same-phone setup (bot uses your personal WhatsApp number).", "channels.whatsapp.debounceMs": "Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable).", "channels.signal.dmPolicy": "Direct message access control (\"pairing\" recommended). \"open\" requires channels.signal.allowFrom=[\"*\"].", "channels.imessage.dmPolicy": "Direct message access control (\"pairing\" recommended). \"open\" requires channels.imessage.allowFrom=[\"*\"].", "channels.bluebubbles.dmPolicy": "Direct message access control (\"pairing\" recommended). \"open\" requires channels.bluebubbles.allowFrom=[\"*\"].", "channels.discord.dm.policy": "Direct message access control (\"pairing\" recommended). \"open\" requires channels.discord.dm.allowFrom=[\"*\"].", "channels.discord.retry.attempts": "Max retry attempts for outbound Discord API calls (default: 3).", "channels.discord.retry.minDelayMs": "Minimum retry delay in ms for Discord outbound calls.", "channels.discord.retry.maxDelayMs": "Maximum retry delay cap in ms for Discord outbound calls.", "channels.discord.retry.jitter": "Jitter factor (0-1) applied to Discord retry delays.", "channels.discord.maxLinesPerMessage": "Soft max line count per Discord message (default: 17).", "channels.discord.intents.presence": "Enable the Guild Presences privileged intent. Must also be enabled in the Discord Developer Portal. Allows tracking user activities (e.g. Spotify). Default: false.", "channels.discord.intents.guildMembers": "Enable the Guild Members privileged intent. Must also be enabled in the Discord Developer Portal. Default: false.", "channels.discord.pluralkit.enabled": "Resolve PluralKit proxied messages and treat system members as distinct senders.", "channels.discord.pluralkit.token": "Optional PluralKit token for resolving private systems or members.", "channels.slack.dm.policy": "Direct message access control (\"pairing\" recommended). \"open\" requires channels.slack.dm.allowFrom=[\"*\"]." }; const FIELD_PLACEHOLDERS = { "gateway.remote.url": "ws://host:18789", "gateway.remote.tlsFingerprint": "sha256:ab12cd34…", "gateway.remote.sshTarget": "user@host", "gateway.controlUi.basePath": "/openclaw", "gateway.controlUi.root": "dist/control-ui", "gateway.controlUi.allowedOrigins": "https://control.example.com", "channels.mattermost.baseUrl": "https://chat.example.com", "agents.list[].identity.avatar": "avatars/openclaw.png" }; const SENSITIVE_PATTERNS = [ /token/i, /password/i, /secret/i, /api.?key/i ]; function isSensitivePath(path) { return SENSITIVE_PATTERNS.some((pattern) => pattern.test(path)); } function cloneSchema(value) { if (typeof structuredClone === "function") return structuredClone(value); return JSON.parse(JSON.stringify(value)); } function asSchemaObject(value) { if (!value || typeof value !== "object" || Array.isArray(value)) return null; return value; } function isObjectSchema(schema) { const type = schema.type; if (type === "object") return true; if (Array.isArray(type) && type.includes("object")) return true; return Boolean(schema.properties || schema.additionalProperties); } function mergeObjectSchema(base, extension) { const mergedRequired = new Set([...base.required ?? [], ...extension.required ?? []]); const merged = { ...base, ...extension, properties: { ...base.properties, ...extension.properties } }; if (mergedRequired.size > 0) merged.required = Array.from(mergedRequired); const additional = extension.additionalProperties ?? base.additionalProperties; if (additional !== void 0) merged.additionalProperties = additional; return merged; } function buildBaseHints() { const hints = {}; for (const [group, label] of Object.entries(GROUP_LABELS)) hints[group] = { label, group: label, order: GROUP_ORDER[group] }; for (const [path, label] of Object.entries(FIELD_LABELS)) { const current = hints[path]; hints[path] = current ? { ...current, label } : { label }; } for (const [path, help] of Object.entries(FIELD_HELP)) { const current = hints[path]; hints[path] = current ? { ...current, help } : { help }; } for (const [path, placeholder] of Object.entries(FIELD_PLACEHOLDERS)) { const current = hints[path]; hints[path] = current ? { ...current, placeholder } : { placeholder }; } return hints; } function applySensitiveHints(hints) { const next = { ...hints }; for (const key of Object.keys(next)) if (isSensitivePath(key)) next[key] = { ...next[key], sensitive: true }; return next; } function applyPluginHints(hints, plugins) { const next = { ...hints }; for (const plugin of plugins) { const id = plugin.id.trim(); if (!id) continue; const name = (plugin.name ?? id).trim() || id; const basePath = `plugins.entries.${id}`; next[basePath] = { ...next[basePath], label: name, help: plugin.description ? `${plugin.description} (plugin: ${id})` : `Plugin entry for ${id}.` }; next[`${basePath}.enabled`] = { ...next[`${basePath}.enabled`], label: `Enable ${name}` }; next[`${basePath}.config`] = { ...next[`${basePath}.config`], label: `${name} Config`, help: `Plugin-defined config payload for ${id}.` }; const uiHints = plugin.configUiHints ?? {}; for (const [relPathRaw, hint] of Object.entries(uiHints)) { const relPath = relPathRaw.trim().replace(/^\./, ""); if (!relPath) continue; const key = `${basePath}.config.${relPath}`; next[key] = { ...next[key], ...hint }; } } return next; } function applyChannelHints(hints, channels) { const next = { ...hints }; for (const channel of channels) { const id = channel.id.trim(); if (!id) continue; const basePath = `channels.${id}`; const current = next[basePath] ?? {}; const label = channel.label?.trim(); const help = channel.description?.trim(); next[basePath] = { ...current, ...label ? { label } : {}, ...help ? { help } : {} }; const uiHints = channel.configUiHints ?? {}; for (const [relPathRaw, hint] of Object.entries(uiHints)) { const relPath = relPathRaw.trim().replace(/^\./, ""); if (!relPath) continue; const key = `${basePath}.${relPath}`; next[key] = { ...next[key], ...hint }; } } return next; } function listHeartbeatTargetChannels(channels) { const seen = /* @__PURE__ */ new Set(); const ordered = []; for (const id of CHANNEL_IDS) { const normalized = id.trim().toLowerCase(); if (!normalized || seen.has(normalized)) continue; seen.add(normalized); ordered.push(normalized); } for (const channel of channels) { const normalized = channel.id.trim().toLowerCase(); if (!normalized || seen.has(normalized)) continue; seen.add(normalized); ordered.push(normalized); } return ordered; } function applyHeartbeatTargetHints(hints, channels) { const next = { ...hints }; const channelList = listHeartbeatTargetChannels(channels); const help = `Delivery target ("last", "none", or a channel id).${channelList.length ? ` Known channels: ${channelList.join(", ")}.` : ""}`; for (const path of ["agents.defaults.heartbeat.target", "agents.list.*.heartbeat.target"]) { const current = next[path] ?? {}; next[path] = { ...current, help: current.help ?? help, placeholder: current.placeholder ?? "last" }; } return next; } function applyPluginSchemas(schema, plugins) { const next = cloneSchema(schema); const entriesNode = asSchemaObject(asSchemaObject(asSchemaObject(next)?.properties?.plugins)?.properties?.entries); if (!entriesNode) return next; const entryBase = asSchemaObject(entriesNode.additionalProperties); const entryProperties = entriesNode.properties ?? {}; entriesNode.properties = entryProperties; for (const plugin of plugins) { if (!plugin.configSchema) continue; const entryObject = asSchemaObject(entryBase ? cloneSchema(entryBase) : { type: "object" }) ?? { type: "object" }; const baseConfigSchema = asSchemaObject(entryObject.properties?.config); const pluginSchema = asSchemaObject(plugin.configSchema); const nextConfigSchema = baseConfigSchema && pluginSchema && isObjectSchema(baseConfigSchema) && isObjectSchema(pluginSchema) ? mergeObjectSchema(baseConfigSchema, pluginSchema) : cloneSchema(plugin.configSchema); entryObject.properties = { ...entryObject.properties, config: nextConfigSchema }; entryProperties[plugin.id] = entryObject; } return next; } function applyChannelSchemas(schema, channels) { const next = cloneSchema(schema); const channelsNode = asSchemaObject(asSchemaObject(next)?.properties?.channels); if (!channelsNode) return next; const channelProps = channelsNode.properties ?? {}; channelsNode.properties = channelProps; for (const channel of channels) { if (!channel.configSchema) continue; const existing = asSchemaObject(channelProps[channel.id]); const incoming = asSchemaObject(channel.configSchema); if (existing && incoming && isObjectSchema(existing) && isObjectSchema(incoming)) channelProps[channel.id] = mergeObjectSchema(existing, incoming); else channelProps[channel.id] = cloneSchema(channel.configSchema); } return next; } let cachedBase = null; function stripChannelSchema(schema) { const next = cloneSchema(schema); const root = asSchemaObject(next); if (!root || !root.properties) return next; const channelsNode = asSchemaObject(root.properties.channels); if (channelsNode) { channelsNode.properties = {}; channelsNode.required = []; channelsNode.additionalProperties = true; } return next; } function buildBaseConfigSchema() { if (cachedBase) return cachedBase; const schema = OpenClawSchema.toJSONSchema({ target: "draft-07", unrepresentable: "any" }); schema.title = "OpenClawConfig"; const hints = applySensitiveHints(buildBaseHints()); const next = { schema: stripChannelSchema(schema), uiHints: hints, version: VERSION, generatedAt: (/* @__PURE__ */ new Date()).toISOString() }; cachedBase = next; return next; } function buildConfigSchema(params) { const base = buildBaseConfigSchema(); const plugins = params?.plugins ?? []; const channels = params?.channels ?? []; if (plugins.length === 0 && channels.length === 0) return base; const mergedHints = applySensitiveHints(applyHeartbeatTargetHints(applyChannelHints(applyPluginHints(base.uiHints, plugins), channels), channels)); const mergedSchema = applyChannelSchemas(applyPluginSchemas(base.schema, plugins), channels); return { ...base, schema: mergedSchema, uiHints: mergedHints }; } //#endregion //#region src/gateway/server-methods/config.ts function resolveBaseHash$1(params) { const raw = params?.baseHash; if (typeof raw !== "string") return null; const trimmed = raw.trim(); return trimmed ? trimmed : null; } function requireConfigBaseHash(params, snapshot, respond) { if (!snapshot.exists) return true; const snapshotHash = resolveConfigSnapshotHash(snapshot); if (!snapshotHash) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "config base hash unavailable; re-run config.get and retry")); return false; } const baseHash = resolveBaseHash$1(params); if (!baseHash) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "config base hash required; re-run config.get and retry")); return false; } if (baseHash !== snapshotHash) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "config changed since last load; re-run config.get and retry")); return false; } return true; } const configHandlers = { "config.get": async ({ params, respond }) => { if (!validateConfigGetParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid config.get params: ${formatValidationErrors(validateConfigGetParams.errors)}`)); return; } respond(true, await readConfigFileSnapshot(), void 0); }, "config.schema": ({ params, respond }) => { if (!validateConfigSchemaParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid config.schema params: ${formatValidationErrors(validateConfigSchemaParams.errors)}`)); return; } const cfg = loadConfig(); respond(true, buildConfigSchema({ plugins: loadOpenClawPlugins({ config: cfg, workspaceDir: resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)), logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } }).plugins.map((plugin) => ({ id: plugin.id, name: plugin.name, description: plugin.description, configUiHints: plugin.configUiHints, configSchema: plugin.configJsonSchema })), channels: listChannelPlugins().map((entry) => ({ id: entry.id, label: entry.meta.label, description: entry.meta.blurb, configSchema: entry.configSchema?.schema, configUiHints: entry.configSchema?.uiHints })) }), void 0); }, "config.set": async ({ params, respond }) => { if (!validateConfigSetParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid config.set params: ${formatValidationErrors(validateConfigSetParams.errors)}`)); return; } if (!requireConfigBaseHash(params, await readConfigFileSnapshot(), respond)) return; const rawValue = params.raw; if (typeof rawValue !== "string") { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "invalid config.set params: raw (string) required")); return; } const parsedRes = parseConfigJson5(rawValue); if (!parsedRes.ok) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, parsedRes.error)); return; } const validated = validateConfigObjectWithPlugins(parsedRes.parsed); if (!validated.ok) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "invalid config", { details: { issues: validated.issues } })); return; } await writeConfigFile(validated.config); respond(true, { ok: true, path: CONFIG_PATH, config: validated.config }, void 0); }, "config.patch": async ({ params, respond }) => { if (!validateConfigPatchParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid config.patch params: ${formatValidationErrors(validateConfigPatchParams.errors)}`)); return; } const snapshot = await readConfigFileSnapshot(); if (!requireConfigBaseHash(params, snapshot, respond)) return; if (!snapshot.valid) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "invalid config; fix before patching")); return; } const rawValue = params.raw; if (typeof rawValue !== "string") { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "invalid config.patch params: raw (string) required")); return; } const parsedRes = parseConfigJson5(rawValue); if (!parsedRes.ok) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, parsedRes.error)); return; } if (!parsedRes.parsed || typeof parsedRes.parsed !== "object" || Array.isArray(parsedRes.parsed)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "config.patch raw must be an object")); return; } const merged = applyMergePatch(snapshot.config, parsedRes.parsed); const validated = validateConfigObjectWithPlugins(applyLegacyMigrations(merged).next ?? merged); if (!validated.ok) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "invalid config", { details: { issues: validated.issues } })); return; } await writeConfigFile(validated.config); const sessionKey = typeof params.sessionKey === "string" ? params.sessionKey?.trim() || void 0 : void 0; const note = typeof params.note === "string" ? params.note?.trim() || void 0 : void 0; const restartDelayMsRaw = params.restartDelayMs; const restartDelayMs = typeof restartDelayMsRaw === "number" && Number.isFinite(restartDelayMsRaw) ? Math.max(0, Math.floor(restartDelayMsRaw)) : void 0; const payload = { kind: "config-apply", status: "ok", ts: Date.now(), sessionKey, message: note ?? null, doctorHint: formatDoctorNonInteractiveHint(), stats: { mode: "config.patch", root: CONFIG_PATH } }; let sentinelPath = null; try { sentinelPath = await writeRestartSentinel(payload); } catch { sentinelPath = null; } const restart = scheduleGatewaySigusr1Restart({ delayMs: restartDelayMs, reason: "config.patch" }); respond(true, { ok: true, path: CONFIG_PATH, config: validated.config, restart, sentinel: { path: sentinelPath, payload } }, void 0); }, "config.apply": async ({ params, respond }) => { if (!validateConfigApplyParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid config.apply params: ${formatValidationErrors(validateConfigApplyParams.errors)}`)); return; } if (!requireConfigBaseHash(params, await readConfigFileSnapshot(), respond)) return; const rawValue = params.raw; if (typeof rawValue !== "string") { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "invalid config.apply params: raw (string) required")); return; } const parsedRes = parseConfigJson5(rawValue); if (!parsedRes.ok) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, parsedRes.error)); return; } const validated = validateConfigObjectWithPlugins(parsedRes.parsed); if (!validated.ok) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "invalid config", { details: { issues: validated.issues } })); return; } await writeConfigFile(validated.config); const sessionKey = typeof params.sessionKey === "string" ? params.sessionKey?.trim() || void 0 : void 0; const note = typeof params.note === "string" ? params.note?.trim() || void 0 : void 0; const restartDelayMsRaw = params.restartDelayMs; const restartDelayMs = typeof restartDelayMsRaw === "number" && Number.isFinite(restartDelayMsRaw) ? Math.max(0, Math.floor(restartDelayMsRaw)) : void 0; const payload = { kind: "config-apply", status: "ok", ts: Date.now(), sessionKey, message: note ?? null, doctorHint: formatDoctorNonInteractiveHint(), stats: { mode: "config.apply", root: CONFIG_PATH } }; let sentinelPath = null; try { sentinelPath = await writeRestartSentinel(payload); } catch { sentinelPath = null; } const restart = scheduleGatewaySigusr1Restart({ delayMs: restartDelayMs, reason: "config.apply" }); respond(true, { ok: true, path: CONFIG_PATH, config: validated.config, restart, sentinel: { path: sentinelPath, payload } }, void 0); } }; //#endregion //#region src/gateway/server-methods/connect.ts const connectHandlers = { connect: ({ respond }) => { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "connect is only valid as the first request")); } }; //#endregion //#region src/cron/validate-timestamp.ts const ONE_MINUTE_MS = 60 * 1e3; const TEN_YEARS_MS = 10 * 365.25 * 24 * 60 * 60 * 1e3; /** * Validates at timestamps in cron schedules. * Rejects timestamps that are: * - More than 1 minute in the past * - More than 10 years in the future */ function validateScheduleTimestamp(schedule, nowMs = Date.now()) { if (schedule.kind !== "at") return { ok: true }; const atRaw = typeof schedule.at === "string" ? schedule.at.trim() : ""; const atMs = atRaw ? parseAbsoluteTimeMs(atRaw) : null; if (atMs === null || !Number.isFinite(atMs)) return { ok: false, message: `Invalid schedule.at: expected ISO-8601 timestamp (got ${String(schedule.at)})` }; const diffMs = atMs - nowMs; if (diffMs < -ONE_MINUTE_MS) { const nowDate = new Date(nowMs).toISOString(); return { ok: false, message: `schedule.at is in the past: ${new Date(atMs).toISOString()} (${Math.floor(-diffMs / ONE_MINUTE_MS)} minutes ago). Current time: ${nowDate}` }; } if (diffMs > TEN_YEARS_MS) return { ok: false, message: `schedule.at is too far in the future: ${new Date(atMs).toISOString()} (${Math.floor(diffMs / (365.25 * 24 * 60 * 60 * 1e3))} years ahead). Maximum allowed: 10 years` }; return { ok: true }; } //#endregion //#region src/gateway/server-methods/cron.ts const cronHandlers = { wake: ({ params, respond, context }) => { if (!validateWakeParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid wake params: ${formatValidationErrors(validateWakeParams.errors)}`)); return; } const p = params; respond(true, context.cron.wake({ mode: p.mode, text: p.text }), void 0); }, "cron.list": async ({ params, respond, context }) => { if (!validateCronListParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid cron.list params: ${formatValidationErrors(validateCronListParams.errors)}`)); return; } const p = params; respond(true, { jobs: await context.cron.list({ includeDisabled: p.includeDisabled }) }, void 0); }, "cron.status": async ({ params, respond, context }) => { if (!validateCronStatusParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid cron.status params: ${formatValidationErrors(validateCronStatusParams.errors)}`)); return; } respond(true, await context.cron.status(), void 0); }, "cron.add": async ({ params, respond, context }) => { const normalized = normalizeCronJobCreate(params) ?? params; if (!validateCronAddParams(normalized)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid cron.add params: ${formatValidationErrors(validateCronAddParams.errors)}`)); return; } const jobCreate = normalized; const timestampValidation = validateScheduleTimestamp(jobCreate.schedule); if (!timestampValidation.ok) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, timestampValidation.message)); return; } respond(true, await context.cron.add(jobCreate), void 0); }, "cron.update": async ({ params, respond, context }) => { const normalizedPatch = normalizeCronJobPatch(params?.patch); const candidate = normalizedPatch && typeof params === "object" && params !== null ? { ...params, patch: normalizedPatch } : params; if (!validateCronUpdateParams(candidate)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid cron.update params: ${formatValidationErrors(validateCronUpdateParams.errors)}`)); return; } const p = candidate; const jobId = p.id ?? p.jobId; if (!jobId) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "invalid cron.update params: missing id")); return; } const patch = p.patch; if (patch.schedule) { const timestampValidation = validateScheduleTimestamp(patch.schedule); if (!timestampValidation.ok) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, timestampValidation.message)); return; } } respond(true, await context.cron.update(jobId, patch), void 0); }, "cron.remove": async ({ params, respond, context }) => { if (!validateCronRemoveParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid cron.remove params: ${formatValidationErrors(validateCronRemoveParams.errors)}`)); return; } const p = params; const jobId = p.id ?? p.jobId; if (!jobId) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "invalid cron.remove params: missing id")); return; } respond(true, await context.cron.remove(jobId), void 0); }, "cron.run": async ({ params, respond, context }) => { if (!validateCronRunParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid cron.run params: ${formatValidationErrors(validateCronRunParams.errors)}`)); return; } const p = params; const jobId = p.id ?? p.jobId; if (!jobId) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "invalid cron.run params: missing id")); return; } respond(true, await context.cron.run(jobId, p.mode), void 0); }, "cron.runs": async ({ params, respond, context }) => { if (!validateCronRunsParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid cron.runs params: ${formatValidationErrors(validateCronRunsParams.errors)}`)); return; } const p = params; const jobId = p.id ?? p.jobId; if (!jobId) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "invalid cron.runs params: missing id")); return; } respond(true, { entries: await readCronRunLogEntries(resolveCronRunLogPath({ storePath: context.cronStorePath, jobId }), { limit: p.limit, jobId }) }, void 0); } }; //#endregion //#region src/infra/device-pairing.ts const PENDING_TTL_MS = 300 * 1e3; function resolvePaths(baseDir) { const root = baseDir ?? resolveStateDir(); const dir = path.join(root, "devices"); return { dir, pendingPath: path.join(dir, "pending.json"), pairedPath: path.join(dir, "paired.json") }; } async function readJSON(filePath) { try { const raw = await fs$1.readFile(filePath, "utf8"); return JSON.parse(raw); } catch { return null; } } async function writeJSONAtomic(filePath, value) { const dir = path.dirname(filePath); await fs$1.mkdir(dir, { recursive: true }); const tmp = `${filePath}.${randomUUID()}.tmp`; await fs$1.writeFile(tmp, JSON.stringify(value, null, 2), "utf8"); try { await fs$1.chmod(tmp, 384); } catch {} await fs$1.rename(tmp, filePath); try { await fs$1.chmod(filePath, 384); } catch {} } function pruneExpiredPending(pendingById, nowMs) { for (const [id, req] of Object.entries(pendingById)) if (nowMs - req.ts > PENDING_TTL_MS) delete pendingById[id]; } let lock = Promise.resolve(); async function withLock(fn) { const prev = lock; let release; lock = new Promise((resolve) => { release = resolve; }); await prev; try { return await fn(); } finally { release?.(); } } async function loadState(baseDir) { const { pendingPath, pairedPath } = resolvePaths(baseDir); const [pending, paired] = await Promise.all([readJSON(pendingPath), readJSON(pairedPath)]); const state = { pendingById: pending ?? {}, pairedByDeviceId: paired ?? {} }; pruneExpiredPending(state.pendingById, Date.now()); return state; } async function persistState(state, baseDir) { const { pendingPath, pairedPath } = resolvePaths(baseDir); await Promise.all([writeJSONAtomic(pendingPath, state.pendingById), writeJSONAtomic(pairedPath, state.pairedByDeviceId)]); } function normalizeDeviceId(deviceId) { return deviceId.trim(); } function normalizeRole(role) { const trimmed = role?.trim(); return trimmed ? trimmed : null; } function mergeRoles(...items) { const roles = /* @__PURE__ */ new Set(); for (const item of items) { if (!item) continue; if (Array.isArray(item)) for (const role of item) { const trimmed = role.trim(); if (trimmed) roles.add(trimmed); } else { const trimmed = item.trim(); if (trimmed) roles.add(trimmed); } } if (roles.size === 0) return; return [...roles]; } function mergeScopes(...items) { const scopes = /* @__PURE__ */ new Set(); for (const item of items) { if (!item) continue; for (const scope of item) { const trimmed = scope.trim(); if (trimmed) scopes.add(trimmed); } } if (scopes.size === 0) return; return [...scopes]; } function normalizeScopes(scopes) { if (!Array.isArray(scopes)) return []; const out = /* @__PURE__ */ new Set(); for (const scope of scopes) { const trimmed = scope.trim(); if (trimmed) out.add(trimmed); } return [...out].toSorted(); } function scopesAllow(requested, allowed) { if (requested.length === 0) return true; if (allowed.length === 0) return false; const allowedSet = new Set(allowed); return requested.every((scope) => allowedSet.has(scope)); } function newToken() { return randomUUID().replaceAll("-", ""); } async function listDevicePairing(baseDir) { const state = await loadState(baseDir); return { pending: Object.values(state.pendingById).toSorted((a, b) => b.ts - a.ts), paired: Object.values(state.pairedByDeviceId).toSorted((a, b) => b.approvedAtMs - a.approvedAtMs) }; } async function getPairedDevice(deviceId, baseDir) { return (await loadState(baseDir)).pairedByDeviceId[normalizeDeviceId(deviceId)] ?? null; } async function requestDevicePairing(req, baseDir) { return await withLock(async () => { const state = await loadState(baseDir); const deviceId = normalizeDeviceId(req.deviceId); if (!deviceId) throw new Error("deviceId required"); const existing = Object.values(state.pendingById).find((p) => p.deviceId === deviceId); if (existing) return { status: "pending", request: existing, created: false }; const isRepair = Boolean(state.pairedByDeviceId[deviceId]); const request = { requestId: randomUUID(), deviceId, publicKey: req.publicKey, displayName: req.displayName, platform: req.platform, clientId: req.clientId, clientMode: req.clientMode, role: req.role, roles: req.role ? [req.role] : void 0, scopes: req.scopes, remoteIp: req.remoteIp, silent: req.silent, isRepair, ts: Date.now() }; state.pendingById[request.requestId] = request; await persistState(state, baseDir); return { status: "pending", request, created: true }; }); } async function approveDevicePairing(requestId, baseDir) { return await withLock(async () => { const state = await loadState(baseDir); const pending = state.pendingById[requestId]; if (!pending) return null; const now = Date.now(); const existing = state.pairedByDeviceId[pending.deviceId]; const roles = mergeRoles(existing?.roles, existing?.role, pending.roles, pending.role); const scopes = mergeScopes(existing?.scopes, pending.scopes); const tokens = existing?.tokens ? { ...existing.tokens } : {}; const roleForToken = normalizeRole(pending.role); if (roleForToken) { const nextScopes = normalizeScopes(pending.scopes); const existingToken = tokens[roleForToken]; const now = Date.now(); tokens[roleForToken] = { token: newToken(), role: roleForToken, scopes: nextScopes, createdAtMs: existingToken?.createdAtMs ?? now, rotatedAtMs: existingToken ? now : void 0, revokedAtMs: void 0, lastUsedAtMs: existingToken?.lastUsedAtMs }; } const device = { deviceId: pending.deviceId, publicKey: pending.publicKey, displayName: pending.displayName, platform: pending.platform, clientId: pending.clientId, clientMode: pending.clientMode, role: pending.role, roles, scopes, remoteIp: pending.remoteIp, tokens, createdAtMs: existing?.createdAtMs ?? now, approvedAtMs: now }; delete state.pendingById[requestId]; state.pairedByDeviceId[device.deviceId] = device; await persistState(state, baseDir); return { requestId, device }; }); } async function rejectDevicePairing(requestId, baseDir) { return await withLock(async () => { const state = await loadState(baseDir); const pending = state.pendingById[requestId]; if (!pending) return null; delete state.pendingById[requestId]; await persistState(state, baseDir); return { requestId, deviceId: pending.deviceId }; }); } async function updatePairedDeviceMetadata(deviceId, patch, baseDir) { return await withLock(async () => { const state = await loadState(baseDir); const existing = state.pairedByDeviceId[normalizeDeviceId(deviceId)]; if (!existing) return; const roles = mergeRoles(existing.roles, existing.role, patch.role); const scopes = mergeScopes(existing.scopes, patch.scopes); state.pairedByDeviceId[deviceId] = { ...existing, ...patch, deviceId: existing.deviceId, createdAtMs: existing.createdAtMs, approvedAtMs: existing.approvedAtMs, role: patch.role ?? existing.role, roles, scopes }; await persistState(state, baseDir); }); } function summarizeDeviceTokens(tokens) { if (!tokens) return; const summaries = Object.values(tokens).map((token) => ({ role: token.role, scopes: token.scopes, createdAtMs: token.createdAtMs, rotatedAtMs: token.rotatedAtMs, revokedAtMs: token.revokedAtMs, lastUsedAtMs: token.lastUsedAtMs })).toSorted((a, b) => a.role.localeCompare(b.role)); return summaries.length > 0 ? summaries : void 0; } async function verifyDeviceToken(params) { return await withLock(async () => { const state = await loadState(params.baseDir); const device = state.pairedByDeviceId[normalizeDeviceId(params.deviceId)]; if (!device) return { ok: false, reason: "device-not-paired" }; const role = normalizeRole(params.role); if (!role) return { ok: false, reason: "role-missing" }; const entry = device.tokens?.[role]; if (!entry) return { ok: false, reason: "token-missing" }; if (entry.revokedAtMs) return { ok: false, reason: "token-revoked" }; if (entry.token !== params.token) return { ok: false, reason: "token-mismatch" }; if (!scopesAllow(normalizeScopes(params.scopes), entry.scopes)) return { ok: false, reason: "scope-mismatch" }; entry.lastUsedAtMs = Date.now(); device.tokens ??= {}; device.tokens[role] = entry; state.pairedByDeviceId[device.deviceId] = device; await persistState(state, params.baseDir); return { ok: true }; }); } async function ensureDeviceToken(params) { return await withLock(async () => { const state = await loadState(params.baseDir); const device = state.pairedByDeviceId[normalizeDeviceId(params.deviceId)]; if (!device) return null; const role = normalizeRole(params.role); if (!role) return null; const requestedScopes = normalizeScopes(params.scopes); const tokens = device.tokens ? { ...device.tokens } : {}; const existing = tokens[role]; if (existing && !existing.revokedAtMs) { if (scopesAllow(requestedScopes, existing.scopes)) return existing; } const now = Date.now(); const next = { token: newToken(), role, scopes: requestedScopes, createdAtMs: existing?.createdAtMs ?? now, rotatedAtMs: existing ? now : void 0, revokedAtMs: void 0, lastUsedAtMs: existing?.lastUsedAtMs }; tokens[role] = next; device.tokens = tokens; state.pairedByDeviceId[device.deviceId] = device; await persistState(state, params.baseDir); return next; }); } async function rotateDeviceToken(params) { return await withLock(async () => { const state = await loadState(params.baseDir); const device = state.pairedByDeviceId[normalizeDeviceId(params.deviceId)]; if (!device) return null; const role = normalizeRole(params.role); if (!role) return null; const tokens = device.tokens ? { ...device.tokens } : {}; const existing = tokens[role]; const requestedScopes = normalizeScopes(params.scopes ?? existing?.scopes ?? device.scopes); const now = Date.now(); const next = { token: newToken(), role, scopes: requestedScopes, createdAtMs: existing?.createdAtMs ?? now, rotatedAtMs: now, revokedAtMs: void 0, lastUsedAtMs: existing?.lastUsedAtMs }; tokens[role] = next; device.tokens = tokens; if (params.scopes !== void 0) device.scopes = requestedScopes; state.pairedByDeviceId[device.deviceId] = device; await persistState(state, params.baseDir); return next; }); } async function revokeDeviceToken(params) { return await withLock(async () => { const state = await loadState(params.baseDir); const device = state.pairedByDeviceId[normalizeDeviceId(params.deviceId)]; if (!device) return null; const role = normalizeRole(params.role); if (!role) return null; if (!device.tokens?.[role]) return null; const tokens = { ...device.tokens }; const entry = { ...tokens[role], revokedAtMs: Date.now() }; tokens[role] = entry; device.tokens = tokens; state.pairedByDeviceId[device.deviceId] = device; await persistState(state, params.baseDir); return entry; }); } //#endregion //#region src/gateway/server-methods/devices.ts function redactPairedDevice(device) { const { tokens, ...rest } = device; return { ...rest, tokens: summarizeDeviceTokens(tokens) }; } const deviceHandlers = { "device.pair.list": async ({ params, respond }) => { if (!validateDevicePairListParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid device.pair.list params: ${formatValidationErrors(validateDevicePairListParams.errors)}`)); return; } const list = await listDevicePairing(); respond(true, { pending: list.pending, paired: list.paired.map((device) => redactPairedDevice(device)) }, void 0); }, "device.pair.approve": async ({ params, respond, context }) => { if (!validateDevicePairApproveParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid device.pair.approve params: ${formatValidationErrors(validateDevicePairApproveParams.errors)}`)); return; } const { requestId } = params; const approved = await approveDevicePairing(requestId); if (!approved) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId")); return; } context.logGateway.info(`device pairing approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`); context.broadcast("device.pair.resolved", { requestId, deviceId: approved.device.deviceId, decision: "approved", ts: Date.now() }, { dropIfSlow: true }); respond(true, { requestId, device: redactPairedDevice(approved.device) }, void 0); }, "device.pair.reject": async ({ params, respond, context }) => { if (!validateDevicePairRejectParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid device.pair.reject params: ${formatValidationErrors(validateDevicePairRejectParams.errors)}`)); return; } const { requestId } = params; const rejected = await rejectDevicePairing(requestId); if (!rejected) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId")); return; } context.broadcast("device.pair.resolved", { requestId, deviceId: rejected.deviceId, decision: "rejected", ts: Date.now() }, { dropIfSlow: true }); respond(true, rejected, void 0); }, "device.token.rotate": async ({ params, respond, context }) => { if (!validateDeviceTokenRotateParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid device.token.rotate params: ${formatValidationErrors(validateDeviceTokenRotateParams.errors)}`)); return; } const { deviceId, role, scopes } = params; const entry = await rotateDeviceToken({ deviceId, role, scopes }); if (!entry) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "unknown deviceId/role")); return; } context.logGateway.info(`device token rotated device=${deviceId} role=${entry.role} scopes=${entry.scopes.join(",")}`); respond(true, { deviceId, role: entry.role, token: entry.token, scopes: entry.scopes, rotatedAtMs: entry.rotatedAtMs ?? entry.createdAtMs }, void 0); }, "device.token.revoke": async ({ params, respond, context }) => { if (!validateDeviceTokenRevokeParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid device.token.revoke params: ${formatValidationErrors(validateDeviceTokenRevokeParams.errors)}`)); return; } const { deviceId, role } = params; const entry = await revokeDeviceToken({ deviceId, role }); if (!entry) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "unknown deviceId/role")); return; } context.logGateway.info(`device token revoked device=${deviceId} role=${entry.role}`); respond(true, { deviceId, role: entry.role, revokedAtMs: entry.revokedAtMs ?? Date.now() }, void 0); } }; //#endregion //#region src/gateway/server-methods/exec-approvals.ts function resolveBaseHash(params) { const raw = params?.baseHash; if (typeof raw !== "string") return null; const trimmed = raw.trim(); return trimmed ? trimmed : null; } function requireApprovalsBaseHash(params, snapshot, respond) { if (!snapshot.exists) return true; if (!snapshot.hash) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "exec approvals base hash unavailable; re-run exec.approvals.get and retry")); return false; } const baseHash = resolveBaseHash(params); if (!baseHash) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "exec approvals base hash required; re-run exec.approvals.get and retry")); return false; } if (baseHash !== snapshot.hash) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "exec approvals changed since last load; re-run exec.approvals.get and retry")); return false; } return true; } function redactExecApprovals(file) { const socketPath = file.socket?.path?.trim(); return { ...file, socket: socketPath ? { path: socketPath } : void 0 }; } const execApprovalsHandlers = { "exec.approvals.get": ({ params, respond }) => { if (!validateExecApprovalsGetParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid exec.approvals.get params: ${formatValidationErrors(validateExecApprovalsGetParams.errors)}`)); return; } ensureExecApprovals(); const snapshot = readExecApprovalsSnapshot(); respond(true, { path: snapshot.path, exists: snapshot.exists, hash: snapshot.hash, file: redactExecApprovals(snapshot.file) }, void 0); }, "exec.approvals.set": ({ params, respond }) => { if (!validateExecApprovalsSetParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid exec.approvals.set params: ${formatValidationErrors(validateExecApprovalsSetParams.errors)}`)); return; } ensureExecApprovals(); const snapshot = readExecApprovalsSnapshot(); if (!requireApprovalsBaseHash(params, snapshot, respond)) return; const incoming = params.file; if (!incoming || typeof incoming !== "object") { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "exec approvals file is required")); return; } const normalized = normalizeExecApprovals(incoming); const currentSocketPath = snapshot.file.socket?.path?.trim(); const currentToken = snapshot.file.socket?.token?.trim(); const socketPath = normalized.socket?.path?.trim() ?? currentSocketPath ?? resolveExecApprovalsSocketPath(); const token = normalized.socket?.token?.trim() ?? currentToken ?? ""; saveExecApprovals({ ...normalized, socket: { path: socketPath, token } }); const nextSnapshot = readExecApprovalsSnapshot(); respond(true, { path: nextSnapshot.path, exists: nextSnapshot.exists, hash: nextSnapshot.hash, file: redactExecApprovals(nextSnapshot.file) }, void 0); }, "exec.approvals.node.get": async ({ params, respond, context }) => { if (!validateExecApprovalsNodeGetParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid exec.approvals.node.get params: ${formatValidationErrors(validateExecApprovalsNodeGetParams.errors)}`)); return; } const { nodeId } = params; const id = nodeId.trim(); if (!id) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required")); return; } await respondUnavailableOnThrow(respond, async () => { const res = await context.nodeRegistry.invoke({ nodeId: id, command: "system.execApprovals.get", params: {} }); if (!res.ok) { respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, res.error?.message ?? "node invoke failed", { details: { nodeError: res.error ?? null } })); return; } respond(true, res.payloadJSON ? safeParseJson(res.payloadJSON) : res.payload, void 0); }); }, "exec.approvals.node.set": async ({ params, respond, context }) => { if (!validateExecApprovalsNodeSetParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid exec.approvals.node.set params: ${formatValidationErrors(validateExecApprovalsNodeSetParams.errors)}`)); return; } const { nodeId, file, baseHash } = params; const id = nodeId.trim(); if (!id) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required")); return; } await respondUnavailableOnThrow(respond, async () => { const res = await context.nodeRegistry.invoke({ nodeId: id, command: "system.execApprovals.set", params: { file, baseHash } }); if (!res.ok) { respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, res.error?.message ?? "node invoke failed", { details: { nodeError: res.error ?? null } })); return; } respond(true, safeParseJson(res.payloadJSON ?? null), void 0); }); } }; //#endregion //#region src/gateway/server-methods/health.ts const healthHandlers = { health: async ({ respond, context, params }) => { const { getHealthCache, refreshHealthSnapshot, logHealth } = context; const wantsProbe = params?.probe === true; const now = Date.now(); const cached = getHealthCache(); if (!wantsProbe && cached && now - cached.ts < HEALTH_REFRESH_INTERVAL_MS) { respond(true, cached, void 0, { cached: true }); refreshHealthSnapshot({ probe: false }).catch((err) => logHealth.error(`background health refresh failed: ${formatError(err)}`)); return; } try { respond(true, await refreshHealthSnapshot({ probe: wantsProbe }), void 0); } catch (err) { respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err))); } }, status: async ({ respond }) => { respond(true, await getStatusSummary(), void 0); } }; //#endregion //#region src/gateway/server-methods/logs.ts const DEFAULT_LIMIT = 500; const DEFAULT_MAX_BYTES = 25e4; const MAX_LIMIT = 5e3; const MAX_BYTES = 1e6; const ROLLING_LOG_RE = /^openclaw-\d{4}-\d{2}-\d{2}\.log$/; function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } function isRollingLogFile(file) { return ROLLING_LOG_RE.test(path.basename(file)); } async function resolveLogFile(file) { if (await fs$1.stat(file).catch(() => null)) return file; if (!isRollingLogFile(file)) return file; const dir = path.dirname(file); const entries = await fs$1.readdir(dir, { withFileTypes: true }).catch(() => null); if (!entries) return file; return (await Promise.all(entries.filter((entry) => entry.isFile() && ROLLING_LOG_RE.test(entry.name)).map(async (entry) => { const fullPath = path.join(dir, entry.name); const fileStat = await fs$1.stat(fullPath).catch(() => null); return fileStat ? { path: fullPath, mtimeMs: fileStat.mtimeMs } : null; }))).filter((entry) => Boolean(entry)).toSorted((a, b) => b.mtimeMs - a.mtimeMs)[0]?.path ?? file; } async function readLogSlice(params) { const stat = await fs$1.stat(params.file).catch(() => null); if (!stat) return { cursor: 0, size: 0, lines: [], truncated: false, reset: false }; const size = stat.size; const maxBytes = clamp(params.maxBytes, 1, MAX_BYTES); const limit = clamp(params.limit, 1, MAX_LIMIT); let cursor = typeof params.cursor === "number" && Number.isFinite(params.cursor) ? Math.max(0, Math.floor(params.cursor)) : void 0; let reset = false; let truncated = false; let start = 0; if (cursor != null) if (cursor > size) { reset = true; start = Math.max(0, size - maxBytes); truncated = start > 0; } else { start = cursor; if (size - start > maxBytes) { reset = true; truncated = true; start = Math.max(0, size - maxBytes); } } else { start = Math.max(0, size - maxBytes); truncated = start > 0; } if (size === 0 || size <= start) return { cursor: size, size, lines: [], truncated, reset }; const handle = await fs$1.open(params.file, "r"); try { let prefix = ""; if (start > 0) { const prefixBuf = Buffer.alloc(1); const prefixRead = await handle.read(prefixBuf, 0, 1, start - 1); prefix = prefixBuf.toString("utf8", 0, prefixRead.bytesRead); } const length = Math.max(0, size - start); const buffer = Buffer.alloc(length); const readResult = await handle.read(buffer, 0, length, start); let lines = buffer.toString("utf8", 0, readResult.bytesRead).split("\n"); if (start > 0 && prefix !== "\n") lines = lines.slice(1); if (lines.length > 0 && lines[lines.length - 1] === "") lines = lines.slice(0, -1); if (lines.length > limit) lines = lines.slice(lines.length - limit); cursor = size; return { cursor, size, lines, truncated, reset }; } finally { await handle.close(); } } const logsHandlers = { "logs.tail": async ({ params, respond }) => { if (!validateLogsTailParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid logs.tail params: ${formatValidationErrors(validateLogsTailParams.errors)}`)); return; } const p = params; const configuredFile = getResolvedLoggerSettings().file; try { const file = await resolveLogFile(configuredFile); respond(true, { file, ...await readLogSlice({ file, cursor: p.cursor, limit: p.limit ?? DEFAULT_LIMIT, maxBytes: p.maxBytes ?? DEFAULT_MAX_BYTES }) }, void 0); } catch (err) { respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, `log read failed: ${String(err)}`)); } } }; //#endregion //#region src/gateway/server-methods/models.ts const modelsHandlers = { "models.list": async ({ params, respond, context }) => { if (!validateModelsListParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid models.list params: ${formatValidationErrors(validateModelsListParams.errors)}`)); return; } try { respond(true, { models: await context.loadGatewayModelCatalog() }, void 0); } catch (err) { respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, String(err))); } } }; //#endregion //#region src/gateway/server-methods/nodes.ts function isNodeEntry(entry) { if (entry.role === "node") return true; if (Array.isArray(entry.roles) && entry.roles.includes("node")) return true; return false; } function normalizeNodeInvokeResultParams(params) { if (!params || typeof params !== "object") return params; const normalized = { ...params }; if (normalized.payloadJSON === null) delete normalized.payloadJSON; else if (normalized.payloadJSON !== void 0 && typeof normalized.payloadJSON !== "string") { if (normalized.payload === void 0) normalized.payload = normalized.payloadJSON; delete normalized.payloadJSON; } if (normalized.error === null) delete normalized.error; return normalized; } const nodeHandlers = { "node.pair.request": async ({ params, respond, context }) => { if (!validateNodePairRequestParams(params)) { respondInvalidParams({ respond, method: "node.pair.request", validator: validateNodePairRequestParams }); return; } const p = params; await respondUnavailableOnThrow(respond, async () => { const result = await requestNodePairing({ nodeId: p.nodeId, displayName: p.displayName, platform: p.platform, version: p.version, coreVersion: p.coreVersion, uiVersion: p.uiVersion, deviceFamily: p.deviceFamily, modelIdentifier: p.modelIdentifier, caps: p.caps, commands: p.commands, remoteIp: p.remoteIp, silent: p.silent }); if (result.status === "pending" && result.created) context.broadcast("node.pair.requested", result.request, { dropIfSlow: true }); respond(true, result, void 0); }); }, "node.pair.list": async ({ params, respond }) => { if (!validateNodePairListParams(params)) { respondInvalidParams({ respond, method: "node.pair.list", validator: validateNodePairListParams }); return; } await respondUnavailableOnThrow(respond, async () => { respond(true, await listNodePairing(), void 0); }); }, "node.pair.approve": async ({ params, respond, context }) => { if (!validateNodePairApproveParams(params)) { respondInvalidParams({ respond, method: "node.pair.approve", validator: validateNodePairApproveParams }); return; } const { requestId } = params; await respondUnavailableOnThrow(respond, async () => { const approved = await approveNodePairing(requestId); if (!approved) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId")); return; } context.broadcast("node.pair.resolved", { requestId, nodeId: approved.node.nodeId, decision: "approved", ts: Date.now() }, { dropIfSlow: true }); respond(true, approved, void 0); }); }, "node.pair.reject": async ({ params, respond, context }) => { if (!validateNodePairRejectParams(params)) { respondInvalidParams({ respond, method: "node.pair.reject", validator: validateNodePairRejectParams }); return; } const { requestId } = params; await respondUnavailableOnThrow(respond, async () => { const rejected = await rejectNodePairing(requestId); if (!rejected) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId")); return; } context.broadcast("node.pair.resolved", { requestId, nodeId: rejected.nodeId, decision: "rejected", ts: Date.now() }, { dropIfSlow: true }); respond(true, rejected, void 0); }); }, "node.pair.verify": async ({ params, respond }) => { if (!validateNodePairVerifyParams(params)) { respondInvalidParams({ respond, method: "node.pair.verify", validator: validateNodePairVerifyParams }); return; } const { nodeId, token } = params; await respondUnavailableOnThrow(respond, async () => { respond(true, await verifyNodeToken(nodeId, token), void 0); }); }, "node.rename": async ({ params, respond }) => { if (!validateNodeRenameParams(params)) { respondInvalidParams({ respond, method: "node.rename", validator: validateNodeRenameParams }); return; } const { nodeId, displayName } = params; await respondUnavailableOnThrow(respond, async () => { const trimmed = displayName.trim(); if (!trimmed) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "displayName required")); return; } const updated = await renamePairedNode(nodeId, trimmed); if (!updated) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "unknown nodeId")); return; } respond(true, { nodeId: updated.nodeId, displayName: updated.displayName }, void 0); }); }, "node.list": async ({ params, respond, context }) => { if (!validateNodeListParams(params)) { respondInvalidParams({ respond, method: "node.list", validator: validateNodeListParams }); return; } await respondUnavailableOnThrow(respond, async () => { const list = await listDevicePairing(); const pairedById = new Map(list.paired.filter((entry) => isNodeEntry(entry)).map((entry) => [entry.deviceId, { nodeId: entry.deviceId, displayName: entry.displayName, platform: entry.platform, version: void 0, coreVersion: void 0, uiVersion: void 0, deviceFamily: void 0, modelIdentifier: void 0, remoteIp: entry.remoteIp, caps: [], commands: [], permissions: void 0 }])); const connected = context.nodeRegistry.listConnected(); const connectedById = new Map(connected.map((n) => [n.nodeId, n])); const nodes = [...new Set([...pairedById.keys(), ...connectedById.keys()])].map((nodeId) => { const paired = pairedById.get(nodeId); const live = connectedById.get(nodeId); const caps = uniqueSortedStrings([...live?.caps ?? paired?.caps ?? []]); const commands = uniqueSortedStrings([...live?.commands ?? paired?.commands ?? []]); return { nodeId, displayName: live?.displayName ?? paired?.displayName, platform: live?.platform ?? paired?.platform, version: live?.version ?? paired?.version, coreVersion: live?.coreVersion ?? paired?.coreVersion, uiVersion: live?.uiVersion ?? paired?.uiVersion, deviceFamily: live?.deviceFamily ?? paired?.deviceFamily, modelIdentifier: live?.modelIdentifier ?? paired?.modelIdentifier, remoteIp: live?.remoteIp ?? paired?.remoteIp, caps, commands, pathEnv: live?.pathEnv, permissions: live?.permissions ?? paired?.permissions, connectedAtMs: live?.connectedAtMs, 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 }, void 0); }); }, "node.describe": async ({ params, respond, context }) => { if (!validateNodeDescribeParams(params)) { respondInvalidParams({ respond, method: "node.describe", validator: validateNodeDescribeParams }); return; } const { nodeId } = params; const id = String(nodeId ?? "").trim(); if (!id) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required")); return; } await respondUnavailableOnThrow(respond, async () => { const paired = (await listDevicePairing()).paired.find((n) => n.deviceId === id && isNodeEntry(n)); const live = context.nodeRegistry.listConnected().find((n) => n.nodeId === id); if (!paired && !live) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "unknown nodeId")); return; } const caps = uniqueSortedStrings([...live?.caps ?? []]); const commands = uniqueSortedStrings([...live?.commands ?? []]); respond(true, { ts: Date.now(), nodeId: id, displayName: live?.displayName ?? paired?.displayName, platform: live?.platform ?? paired?.platform, version: live?.version, coreVersion: live?.coreVersion, uiVersion: live?.uiVersion, deviceFamily: live?.deviceFamily, modelIdentifier: live?.modelIdentifier, remoteIp: live?.remoteIp ?? paired?.remoteIp, caps, commands, pathEnv: live?.pathEnv, permissions: live?.permissions, connectedAtMs: live?.connectedAtMs, paired: Boolean(paired), connected: Boolean(live) }, void 0); }); }, "node.invoke": async ({ params, respond, context }) => { if (!validateNodeInvokeParams(params)) { respondInvalidParams({ respond, method: "node.invoke", validator: validateNodeInvokeParams }); return; } const p = params; const nodeId = String(p.nodeId ?? "").trim(); const command = String(p.command ?? "").trim(); if (!nodeId || !command) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId and command required")); return; } await respondUnavailableOnThrow(respond, async () => { const nodeSession = context.nodeRegistry.get(nodeId); if (!nodeSession) { respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, "node not connected", { details: { code: "NOT_CONNECTED" } })); return; } const allowlist = resolveNodeCommandAllowlist(loadConfig(), nodeSession); const allowed = isNodeCommandAllowed({ command, declaredCommands: nodeSession.commands, allowlist }); if (!allowed.ok) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "node command not allowed", { details: { reason: allowed.reason, command } })); return; } const res = await context.nodeRegistry.invoke({ nodeId, command, params: p.params, timeoutMs: p.timeoutMs, idempotencyKey: p.idempotencyKey }); if (!res.ok) { respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, res.error?.message ?? "node invoke failed", { details: { nodeError: res.error ?? null } })); return; } respond(true, { ok: true, nodeId, command, payload: res.payloadJSON ? safeParseJson(res.payloadJSON) : res.payload, payloadJSON: res.payloadJSON ?? null }, void 0); }); }, "node.invoke.result": async ({ params, respond, context, client }) => { const normalizedParams = normalizeNodeInvokeResultParams(params); if (!validateNodeInvokeResultParams(normalizedParams)) { respondInvalidParams({ respond, method: "node.invoke.result", validator: validateNodeInvokeResultParams }); return; } const p = normalizedParams; const callerNodeId = client?.connect?.device?.id ?? client?.connect?.client?.id; if (callerNodeId && callerNodeId !== p.nodeId) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId mismatch")); return; } if (!context.nodeRegistry.handleInvokeResult({ id: p.id, nodeId: p.nodeId, ok: p.ok, payload: p.payload, payloadJSON: p.payloadJSON ?? null, error: p.error ?? null })) { context.logGateway.debug(`late invoke result ignored: id=${p.id} node=${p.nodeId}`); respond(true, { ok: true, ignored: true }, void 0); return; } respond(true, { ok: true }, void 0); }, "node.event": async ({ params, respond, context, client }) => { if (!validateNodeEventParams(params)) { respondInvalidParams({ respond, method: "node.event", validator: validateNodeEventParams }); return; } const p = params; const payloadJSON = typeof p.payloadJSON === "string" ? p.payloadJSON : p.payload !== void 0 ? JSON.stringify(p.payload) : null; await respondUnavailableOnThrow(respond, async () => { const { handleNodeEvent } = await import("./server-node-events-CZftsY4e.js"); const nodeId = client?.connect?.device?.id ?? client?.connect?.client?.id ?? "node"; await handleNodeEvent({ deps: context.deps, broadcast: context.broadcast, nodeSendToSession: context.nodeSendToSession, nodeSubscribe: context.nodeSubscribe, nodeUnsubscribe: context.nodeUnsubscribe, broadcastVoiceWakeChanged: context.broadcastVoiceWakeChanged, addChatRun: context.addChatRun, removeChatRun: context.removeChatRun, chatAbortControllers: context.chatAbortControllers, chatAbortedRuns: context.chatAbortedRuns, chatRunBuffers: context.chatRunBuffers, chatDeltaSentAt: context.chatDeltaSentAt, dedupe: context.dedupe, agentRunSeq: context.agentRunSeq, getHealthCache: context.getHealthCache, refreshHealthSnapshot: context.refreshHealthSnapshot, loadGatewayModelCatalog: context.loadGatewayModelCatalog, logGateway: { warn: context.logGateway.warn } }, nodeId, { event: p.event, payloadJSON }); respond(true, { ok: true }, void 0); }); } }; //#endregion //#region src/gateway/server-methods/send.ts const inflightByContext = /* @__PURE__ */ new WeakMap(); const getInflightMap = (context) => { let inflight = inflightByContext.get(context); if (!inflight) { inflight = /* @__PURE__ */ new Map(); inflightByContext.set(context, inflight); } return inflight; }; const sendHandlers = { send: async ({ params, respond, context }) => { const p = params; if (!validateSendParams(p)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid send params: ${formatValidationErrors(validateSendParams.errors)}`)); return; } const request = p; const idem = request.idempotencyKey; const dedupeKey = `send:${idem}`; const cached = context.dedupe.get(dedupeKey); if (cached) { respond(cached.ok, cached.payload, cached.error, { cached: true }); return; } const inflightMap = getInflightMap(context); const inflight = inflightMap.get(dedupeKey); if (inflight) { const result = await inflight; const meta = result.meta ? { ...result.meta, cached: true } : { cached: true }; respond(result.ok, result.payload, result.error, meta); return; } const to = request.to.trim(); const message = request.message.trim(); const mediaUrls = Array.isArray(request.mediaUrls) ? request.mediaUrls : void 0; const channelInput = typeof request.channel === "string" ? request.channel : void 0; const normalizedChannel = channelInput ? normalizeChannelId(channelInput) : null; if (channelInput && !normalizedChannel) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `unsupported channel: ${channelInput}`)); return; } const channel = normalizedChannel ?? DEFAULT_CHAT_CHANNEL; const accountId = typeof request.accountId === "string" && request.accountId.trim().length ? request.accountId.trim() : void 0; const outboundChannel = channel; if (!getChannelPlugin(channel)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `unsupported channel: ${channel}`)); return; } const work = (async () => { try { const cfg = loadConfig(); const resolved = resolveOutboundTarget({ channel: outboundChannel, to, cfg, accountId, mode: "explicit" }); if (!resolved.ok) return { ok: false, error: errorShape(ErrorCodes.INVALID_REQUEST, String(resolved.error)), meta: { channel } }; const outboundDeps = context.deps ? createOutboundSendDeps(context.deps) : void 0; const mirrorPayloads = normalizeReplyPayloadsForDelivery([{ text: message, mediaUrl: request.mediaUrl, mediaUrls }]); const mirrorText = mirrorPayloads.map((payload) => payload.text).filter(Boolean).join("\n"); const mirrorMediaUrls = mirrorPayloads.flatMap((payload) => payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : [])); const providedSessionKey = typeof request.sessionKey === "string" && request.sessionKey.trim() ? request.sessionKey.trim().toLowerCase() : void 0; const derivedAgentId = resolveSessionAgentId({ config: cfg }); const derivedRoute = !providedSessionKey ? await resolveOutboundSessionRoute({ cfg, channel, agentId: derivedAgentId, accountId, target: resolved.to }) : null; if (derivedRoute) await ensureOutboundSessionEntry({ cfg, agentId: derivedAgentId, channel, accountId, route: derivedRoute }); const result = (await deliverOutboundPayloads({ cfg, channel: outboundChannel, to: resolved.to, accountId, payloads: [{ text: message, mediaUrl: request.mediaUrl, mediaUrls }], gifPlayback: request.gifPlayback, deps: outboundDeps, mirror: providedSessionKey ? { sessionKey: providedSessionKey, agentId: resolveSessionAgentId({ sessionKey: providedSessionKey, config: cfg }), text: mirrorText || message, mediaUrls: mirrorMediaUrls.length > 0 ? mirrorMediaUrls : void 0 } : derivedRoute ? { sessionKey: derivedRoute.sessionKey, agentId: derivedAgentId, text: mirrorText || message, mediaUrls: mirrorMediaUrls.length > 0 ? mirrorMediaUrls : void 0 } : void 0 })).at(-1); if (!result) throw new Error("No delivery result"); const payload = { runId: idem, messageId: result.messageId, channel }; if ("chatId" in result) payload.chatId = result.chatId; if ("channelId" in result) payload.channelId = result.channelId; if ("toJid" in result) payload.toJid = result.toJid; if ("conversationId" in result) payload.conversationId = result.conversationId; context.dedupe.set(dedupeKey, { ts: Date.now(), ok: true, payload }); return { ok: true, payload, meta: { channel } }; } catch (err) { const error = errorShape(ErrorCodes.UNAVAILABLE, String(err)); context.dedupe.set(dedupeKey, { ts: Date.now(), ok: false, error }); return { ok: false, error, meta: { channel, error: formatForLog(err) } }; } })(); inflightMap.set(dedupeKey, work); try { const result = await work; respond(result.ok, result.payload, result.error, result.meta); } finally { inflightMap.delete(dedupeKey); } }, poll: async ({ params, respond, context }) => { const p = params; if (!validatePollParams(p)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid poll params: ${formatValidationErrors(validatePollParams.errors)}`)); return; } const request = p; const idem = request.idempotencyKey; const cached = context.dedupe.get(`poll:${idem}`); if (cached) { respond(cached.ok, cached.payload, cached.error, { cached: true }); return; } const to = request.to.trim(); const channelInput = typeof request.channel === "string" ? request.channel : void 0; const normalizedChannel = channelInput ? normalizeChannelId(channelInput) : null; if (channelInput && !normalizedChannel) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `unsupported poll channel: ${channelInput}`)); return; } const channel = normalizedChannel ?? DEFAULT_CHAT_CHANNEL; const poll = { question: request.question, options: request.options, maxSelections: request.maxSelections, durationHours: request.durationHours }; const accountId = typeof request.accountId === "string" && request.accountId.trim().length ? request.accountId.trim() : void 0; try { const outbound = getChannelPlugin(channel)?.outbound; if (!outbound?.sendPoll) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `unsupported poll channel: ${channel}`)); return; } const cfg = loadConfig(); const resolved = resolveOutboundTarget({ channel, to, cfg, accountId, mode: "explicit" }); if (!resolved.ok) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, String(resolved.error))); return; } const normalized = outbound.pollMaxOptions ? normalizePollInput(poll, { maxOptions: outbound.pollMaxOptions }) : normalizePollInput(poll); const result = await outbound.sendPoll({ cfg, to: resolved.to, poll: normalized, accountId }); const payload = { runId: idem, messageId: result.messageId, channel }; if (result.toJid) payload.toJid = result.toJid; if (result.channelId) payload.channelId = result.channelId; if (result.conversationId) payload.conversationId = result.conversationId; if (result.pollId) payload.pollId = result.pollId; context.dedupe.set(`poll:${idem}`, { ts: Date.now(), ok: true, payload }); respond(true, payload, void 0, { channel }); } catch (err) { const error = errorShape(ErrorCodes.UNAVAILABLE, String(err)); context.dedupe.set(`poll:${idem}`, { ts: Date.now(), ok: false, error }); respond(false, void 0, error, { channel, error: formatForLog(err) }); } } }; //#endregion //#region src/gateway/sessions-patch.ts function invalid(message) { return { ok: false, error: errorShape(ErrorCodes.INVALID_REQUEST, message) }; } function normalizeExecHost(raw) { const normalized = raw.trim().toLowerCase(); if (normalized === "sandbox" || normalized === "gateway" || normalized === "node") return normalized; } function normalizeExecSecurity(raw) { const normalized = raw.trim().toLowerCase(); if (normalized === "deny" || normalized === "allowlist" || normalized === "full") return normalized; } function normalizeExecAsk(raw) { const normalized = raw.trim().toLowerCase(); if (normalized === "off" || normalized === "on-miss" || normalized === "always") return normalized; } async function applySessionsPatchToStore(params) { const { cfg, store, storeKey, patch } = params; const now = Date.now(); const resolvedDefault = resolveDefaultModelForAgent({ cfg, agentId: normalizeAgentId(parseAgentSessionKey(storeKey)?.agentId ?? resolveDefaultAgentId(cfg)) }); const existing = store[storeKey]; const next = existing ? { ...existing, updatedAt: Math.max(existing.updatedAt ?? 0, now) } : { sessionId: randomUUID(), updatedAt: now }; if ("spawnedBy" in patch) { const raw = patch.spawnedBy; if (raw === null) { if (existing?.spawnedBy) return invalid("spawnedBy cannot be cleared once set"); } else if (raw !== void 0) { const trimmed = String(raw).trim(); if (!trimmed) return invalid("invalid spawnedBy: empty"); if (!isSubagentSessionKey(storeKey)) return invalid("spawnedBy is only supported for subagent:* sessions"); if (existing?.spawnedBy && existing.spawnedBy !== trimmed) return invalid("spawnedBy cannot be changed once set"); next.spawnedBy = trimmed; } } if ("label" in patch) { const raw = patch.label; if (raw === null) delete next.label; else if (raw !== void 0) { const parsed = parseSessionLabel(raw); if (!parsed.ok) return invalid(parsed.error); for (const [key, entry] of Object.entries(store)) { if (key === storeKey) continue; if (entry?.label === parsed.label) return invalid(`label already in use: ${parsed.label}`); } next.label = parsed.label; } } if ("thinkingLevel" in patch) { const raw = patch.thinkingLevel; if (raw === null) delete next.thinkingLevel; else if (raw !== void 0) { const normalized = normalizeThinkLevel(String(raw)); if (!normalized) return invalid(`invalid thinkingLevel (use ${formatThinkingLevels(existing?.providerOverride?.trim() || resolvedDefault.provider, existing?.modelOverride?.trim() || resolvedDefault.model, "|")})`); if (normalized === "off") delete next.thinkingLevel; else next.thinkingLevel = normalized; } } if ("verboseLevel" in patch) { const raw = patch.verboseLevel; const parsed = parseVerboseOverride(raw); if (!parsed.ok) return invalid(parsed.error); applyVerboseOverride(next, parsed.value); } if ("reasoningLevel" in patch) { const raw = patch.reasoningLevel; if (raw === null) delete next.reasoningLevel; else if (raw !== void 0) { const normalized = normalizeReasoningLevel(String(raw)); if (!normalized) return invalid("invalid reasoningLevel (use \"on\"|\"off\"|\"stream\")"); if (normalized === "off") delete next.reasoningLevel; else next.reasoningLevel = normalized; } } if ("responseUsage" in patch) { const raw = patch.responseUsage; if (raw === null) delete next.responseUsage; else if (raw !== void 0) { const normalized = normalizeUsageDisplay(String(raw)); if (!normalized) return invalid("invalid responseUsage (use \"off\"|\"tokens\"|\"full\")"); if (normalized === "off") delete next.responseUsage; else next.responseUsage = normalized; } } if ("elevatedLevel" in patch) { const raw = patch.elevatedLevel; if (raw === null) delete next.elevatedLevel; else if (raw !== void 0) { const normalized = normalizeElevatedLevel(String(raw)); if (!normalized) return invalid("invalid elevatedLevel (use \"on\"|\"off\"|\"ask\"|\"full\")"); next.elevatedLevel = normalized; } } if ("execHost" in patch) { const raw = patch.execHost; if (raw === null) delete next.execHost; else if (raw !== void 0) { const normalized = normalizeExecHost(String(raw)); if (!normalized) return invalid("invalid execHost (use \"sandbox\"|\"gateway\"|\"node\")"); next.execHost = normalized; } } if ("execSecurity" in patch) { const raw = patch.execSecurity; if (raw === null) delete next.execSecurity; else if (raw !== void 0) { const normalized = normalizeExecSecurity(String(raw)); if (!normalized) return invalid("invalid execSecurity (use \"deny\"|\"allowlist\"|\"full\")"); next.execSecurity = normalized; } } if ("execAsk" in patch) { const raw = patch.execAsk; if (raw === null) delete next.execAsk; else if (raw !== void 0) { const normalized = normalizeExecAsk(String(raw)); if (!normalized) return invalid("invalid execAsk (use \"off\"|\"on-miss\"|\"always\")"); next.execAsk = normalized; } } if ("execNode" in patch) { const raw = patch.execNode; if (raw === null) delete next.execNode; else if (raw !== void 0) { const trimmed = String(raw).trim(); if (!trimmed) return invalid("invalid execNode: empty"); next.execNode = trimmed; } } if ("model" in patch) { const raw = patch.model; if (raw === null) applyModelOverrideToSessionEntry({ entry: next, selection: { provider: resolvedDefault.provider, model: resolvedDefault.model, isDefault: true } }); else if (raw !== void 0) { const trimmed = String(raw).trim(); if (!trimmed) return invalid("invalid model: empty"); if (!params.loadGatewayModelCatalog) return { ok: false, error: errorShape(ErrorCodes.UNAVAILABLE, "model catalog unavailable") }; const resolved = resolveAllowedModelRef({ cfg, catalog: await params.loadGatewayModelCatalog(), raw: trimmed, defaultProvider: resolvedDefault.provider, defaultModel: resolvedDefault.model }); if ("error" in resolved) return invalid(resolved.error); const isDefault = resolved.ref.provider === resolvedDefault.provider && resolved.ref.model === resolvedDefault.model; applyModelOverrideToSessionEntry({ entry: next, selection: { provider: resolved.ref.provider, model: resolved.ref.model, isDefault } }); } } if (next.thinkingLevel === "xhigh") { if (!supportsXHighThinking(next.providerOverride ?? resolvedDefault.provider, next.modelOverride ?? resolvedDefault.model)) { if ("thinkingLevel" in patch) return invalid(`thinkingLevel "xhigh" is only supported for ${formatXHighModelHint()}`); next.thinkingLevel = "high"; } } if ("sendPolicy" in patch) { const raw = patch.sendPolicy; if (raw === null) delete next.sendPolicy; else if (raw !== void 0) { const normalized = normalizeSendPolicy(String(raw)); if (!normalized) return invalid("invalid sendPolicy (use \"allow\"|\"deny\")"); next.sendPolicy = normalized; } } if ("groupActivation" in patch) { const raw = patch.groupActivation; if (raw === null) delete next.groupActivation; else if (raw !== void 0) { const normalized = normalizeGroupActivation(String(raw)); if (!normalized) return invalid("invalid groupActivation (use \"mention\"|\"always\")"); next.groupActivation = normalized; } } store[storeKey] = next; return { ok: true, entry: next }; } //#endregion //#region src/gateway/sessions-resolve.ts function resolveSessionKeyFromResolveParams(params) { const { cfg, p } = params; const key = typeof p.key === "string" ? p.key.trim() : ""; const hasKey = key.length > 0; const sessionId = typeof p.sessionId === "string" ? p.sessionId.trim() : ""; const hasSessionId = sessionId.length > 0; const selectionCount = [ hasKey, hasSessionId, typeof p.label === "string" && p.label.trim().length > 0 ].filter(Boolean).length; if (selectionCount > 1) return { ok: false, error: errorShape(ErrorCodes.INVALID_REQUEST, "Provide either key, sessionId, or label (not multiple)") }; if (selectionCount === 0) return { ok: false, error: errorShape(ErrorCodes.INVALID_REQUEST, "Either key, sessionId, or label is required") }; if (hasKey) { const target = resolveGatewaySessionStoreTarget({ cfg, key }); const store = loadSessionStore(target.storePath); if (!target.storeKeys.find((candidate) => store[candidate])) return { ok: false, error: errorShape(ErrorCodes.INVALID_REQUEST, `No session found: ${key}`) }; return { ok: true, key: target.canonicalKey }; } if (hasSessionId) { const { storePath, store } = loadCombinedSessionStoreForGateway(cfg); const matches = listSessionsFromStore({ cfg, storePath, store, opts: { includeGlobal: p.includeGlobal === true, includeUnknown: p.includeUnknown === true, spawnedBy: p.spawnedBy, agentId: p.agentId, search: sessionId, limit: 8 } }).sessions.filter((session) => session.sessionId === sessionId || session.key === sessionId); if (matches.length === 0) return { ok: false, error: errorShape(ErrorCodes.INVALID_REQUEST, `No session found: ${sessionId}`) }; if (matches.length > 1) { const keys = matches.map((session) => session.key).join(", "); return { ok: false, error: errorShape(ErrorCodes.INVALID_REQUEST, `Multiple sessions found for sessionId: ${sessionId} (${keys})`) }; } return { ok: true, key: String(matches[0]?.key ?? "") }; } const parsedLabel = parseSessionLabel(p.label); if (!parsedLabel.ok) return { ok: false, error: errorShape(ErrorCodes.INVALID_REQUEST, parsedLabel.error) }; const { storePath, store } = loadCombinedSessionStoreForGateway(cfg); const list = listSessionsFromStore({ cfg, storePath, store, opts: { includeGlobal: p.includeGlobal === true, includeUnknown: p.includeUnknown === true, label: parsedLabel.label, agentId: p.agentId, spawnedBy: p.spawnedBy, limit: 2 } }); if (list.sessions.length === 0) return { ok: false, error: errorShape(ErrorCodes.INVALID_REQUEST, `No session found with label: ${parsedLabel.label}`) }; if (list.sessions.length > 1) { const keys = list.sessions.map((s) => s.key).join(", "); return { ok: false, error: errorShape(ErrorCodes.INVALID_REQUEST, `Multiple sessions found with label: ${parsedLabel.label} (${keys})`) }; } return { ok: true, key: String(list.sessions[0]?.key ?? "") }; } //#endregion //#region src/gateway/server-methods/sessions.ts const sessionsHandlers = { "sessions.list": ({ params, respond }) => { if (!validateSessionsListParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid sessions.list params: ${formatValidationErrors(validateSessionsListParams.errors)}`)); return; } const p = params; const cfg = loadConfig(); const { storePath, store } = loadCombinedSessionStoreForGateway(cfg); respond(true, listSessionsFromStore({ cfg, storePath, store, opts: p }), void 0); }, "sessions.preview": ({ params, respond }) => { if (!validateSessionsPreviewParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid sessions.preview params: ${formatValidationErrors(validateSessionsPreviewParams.errors)}`)); return; } const p = params; const keys = (Array.isArray(p.keys) ? p.keys : []).map((key) => String(key ?? "").trim()).filter(Boolean).slice(0, 64); const limit = typeof p.limit === "number" && Number.isFinite(p.limit) ? Math.max(1, p.limit) : 12; const maxChars = typeof p.maxChars === "number" && Number.isFinite(p.maxChars) ? Math.max(20, p.maxChars) : 240; if (keys.length === 0) { respond(true, { ts: Date.now(), previews: [] }, void 0); return; } const cfg = loadConfig(); const storeCache = /* @__PURE__ */ new Map(); const previews = []; for (const key of keys) try { const target = resolveGatewaySessionStoreTarget({ cfg, key }); const store = storeCache.get(target.storePath) ?? loadSessionStore(target.storePath); storeCache.set(target.storePath, store); const entry = target.storeKeys.map((candidate) => store[candidate]).find(Boolean) ?? store[target.canonicalKey]; if (!entry?.sessionId) { previews.push({ key, status: "missing", items: [] }); continue; } const items = readSessionPreviewItemsFromTranscript(entry.sessionId, target.storePath, entry.sessionFile, target.agentId, limit, maxChars); previews.push({ key, status: items.length > 0 ? "ok" : "empty", items }); } catch { previews.push({ key, status: "error", items: [] }); } respond(true, { ts: Date.now(), previews }, void 0); }, "sessions.resolve": ({ params, respond }) => { if (!validateSessionsResolveParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid sessions.resolve params: ${formatValidationErrors(validateSessionsResolveParams.errors)}`)); return; } const p = params; const resolved = resolveSessionKeyFromResolveParams({ cfg: loadConfig(), p }); if (!resolved.ok) { respond(false, void 0, resolved.error); return; } respond(true, { ok: true, key: resolved.key }, void 0); }, "sessions.patch": async ({ params, respond, context }) => { if (!validateSessionsPatchParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid sessions.patch params: ${formatValidationErrors(validateSessionsPatchParams.errors)}`)); return; } const p = params; const key = String(p.key ?? "").trim(); if (!key) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "key required")); return; } const cfg = loadConfig(); const target = resolveGatewaySessionStoreTarget({ cfg, key }); const storePath = target.storePath; const applied = await updateSessionStore(storePath, async (store) => { const primaryKey = target.storeKeys[0] ?? key; const existingKey = target.storeKeys.find((candidate) => store[candidate]); if (existingKey && existingKey !== primaryKey && !store[primaryKey]) { store[primaryKey] = store[existingKey]; delete store[existingKey]; } return await applySessionsPatchToStore({ cfg, store, storeKey: primaryKey, patch: p, loadGatewayModelCatalog: context.loadGatewayModelCatalog }); }); if (!applied.ok) { respond(false, void 0, applied.error); return; } const agentId = normalizeAgentId(parseAgentSessionKey(target.canonicalKey ?? key)?.agentId ?? resolveDefaultAgentId(cfg)); const resolved = resolveSessionModelRef(cfg, applied.entry, agentId); respond(true, { ok: true, path: storePath, key: target.canonicalKey, entry: applied.entry, resolved: { modelProvider: resolved.provider, model: resolved.model } }, void 0); }, "sessions.reset": async ({ params, respond }) => { if (!validateSessionsResetParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid sessions.reset params: ${formatValidationErrors(validateSessionsResetParams.errors)}`)); return; } const p = params; const key = String(p.key ?? "").trim(); if (!key) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "key required")); return; } const target = resolveGatewaySessionStoreTarget({ cfg: loadConfig(), key }); const storePath = target.storePath; const next = await updateSessionStore(storePath, (store) => { const primaryKey = target.storeKeys[0] ?? key; const existingKey = target.storeKeys.find((candidate) => store[candidate]); if (existingKey && existingKey !== primaryKey && !store[primaryKey]) { store[primaryKey] = store[existingKey]; delete store[existingKey]; } const entry = store[primaryKey]; const now = Date.now(); const nextEntry = { sessionId: randomUUID(), updatedAt: now, systemSent: false, abortedLastRun: false, thinkingLevel: entry?.thinkingLevel, verboseLevel: entry?.verboseLevel, reasoningLevel: entry?.reasoningLevel, responseUsage: entry?.responseUsage, model: entry?.model, contextTokens: entry?.contextTokens, sendPolicy: entry?.sendPolicy, label: entry?.label, origin: snapshotSessionOrigin(entry), lastChannel: entry?.lastChannel, lastTo: entry?.lastTo, skillsSnapshot: entry?.skillsSnapshot, inputTokens: 0, outputTokens: 0, totalTokens: 0 }; store[primaryKey] = nextEntry; return nextEntry; }); respond(true, { ok: true, key: target.canonicalKey, entry: next }, void 0); }, "sessions.delete": async ({ params, respond }) => { if (!validateSessionsDeleteParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid sessions.delete params: ${formatValidationErrors(validateSessionsDeleteParams.errors)}`)); return; } const p = params; const key = String(p.key ?? "").trim(); if (!key) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "key required")); return; } const cfg = loadConfig(); const mainKey = resolveMainSessionKey(cfg); const target = resolveGatewaySessionStoreTarget({ cfg, key }); if (target.canonicalKey === mainKey) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `Cannot delete the main session (${mainKey}).`)); return; } const deleteTranscript = typeof p.deleteTranscript === "boolean" ? p.deleteTranscript : true; const storePath = target.storePath; const { entry } = loadSessionEntry(key); const sessionId = entry?.sessionId; const existed = Boolean(entry); const queueKeys = new Set(target.storeKeys); queueKeys.add(target.canonicalKey); if (sessionId) queueKeys.add(sessionId); clearSessionQueues([...queueKeys]); stopSubagentsForRequester({ cfg, requesterSessionKey: target.canonicalKey }); if (sessionId) { abortEmbeddedPiRun(sessionId); if (!await waitForEmbeddedPiRunEnd(sessionId, 15e3)) { respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, `Session ${key} is still active; try again in a moment.`)); return; } } await updateSessionStore(storePath, (store) => { const primaryKey = target.storeKeys[0] ?? key; const existingKey = target.storeKeys.find((candidate) => store[candidate]); if (existingKey && existingKey !== primaryKey && !store[primaryKey]) { store[primaryKey] = store[existingKey]; delete store[existingKey]; } if (store[primaryKey]) delete store[primaryKey]; }); const archived = []; if (deleteTranscript && sessionId) for (const candidate of resolveSessionTranscriptCandidates(sessionId, storePath, entry?.sessionFile, target.agentId)) { if (!fs.existsSync(candidate)) continue; try { archived.push(archiveFileOnDisk(candidate, "deleted")); } catch {} } respond(true, { ok: true, key: target.canonicalKey, deleted: existed, archived }, void 0); }, "sessions.compact": async ({ params, respond }) => { if (!validateSessionsCompactParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid sessions.compact params: ${formatValidationErrors(validateSessionsCompactParams.errors)}`)); return; } const p = params; const key = String(p.key ?? "").trim(); if (!key) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "key required")); return; } const maxLines = typeof p.maxLines === "number" && Number.isFinite(p.maxLines) ? Math.max(1, Math.floor(p.maxLines)) : 400; const target = resolveGatewaySessionStoreTarget({ cfg: loadConfig(), key }); const storePath = target.storePath; const compactTarget = await updateSessionStore(storePath, (store) => { const primaryKey = target.storeKeys[0] ?? key; const existingKey = target.storeKeys.find((candidate) => store[candidate]); if (existingKey && existingKey !== primaryKey && !store[primaryKey]) { store[primaryKey] = store[existingKey]; delete store[existingKey]; } return { entry: store[primaryKey], primaryKey }; }); const entry = compactTarget.entry; const sessionId = entry?.sessionId; if (!sessionId) { respond(true, { ok: true, key: target.canonicalKey, compacted: false, reason: "no sessionId" }, void 0); return; } const filePath = resolveSessionTranscriptCandidates(sessionId, storePath, entry?.sessionFile, target.agentId).find((candidate) => fs.existsSync(candidate)); if (!filePath) { respond(true, { ok: true, key: target.canonicalKey, compacted: false, reason: "no transcript" }, void 0); return; } const lines = fs.readFileSync(filePath, "utf-8").split(/\r?\n/).filter((l) => l.trim().length > 0); if (lines.length <= maxLines) { respond(true, { ok: true, key: target.canonicalKey, compacted: false, kept: lines.length }, void 0); return; } const archived = archiveFileOnDisk(filePath, "bak"); const keptLines = lines.slice(-maxLines); fs.writeFileSync(filePath, `${keptLines.join("\n")}\n`, "utf-8"); await updateSessionStore(storePath, (store) => { const entryToUpdate = store[compactTarget.primaryKey]; if (!entryToUpdate) return; delete entryToUpdate.inputTokens; delete entryToUpdate.outputTokens; delete entryToUpdate.totalTokens; entryToUpdate.updatedAt = Date.now(); }); respond(true, { ok: true, key: target.canonicalKey, compacted: true, archived, kept: keptLines.length }, void 0); } }; //#endregion //#region src/gateway/server-methods/skills.ts function listWorkspaceDirs(cfg) { const dirs = /* @__PURE__ */ new Set(); const list = cfg.agents?.list; if (Array.isArray(list)) { for (const entry of list) if (entry && typeof entry === "object" && typeof entry.id === "string") dirs.add(resolveAgentWorkspaceDir(cfg, entry.id)); } dirs.add(resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg))); return [...dirs]; } function collectSkillBins(entries) { const bins = /* @__PURE__ */ new Set(); for (const entry of entries) { const required = entry.metadata?.requires?.bins ?? []; const anyBins = entry.metadata?.requires?.anyBins ?? []; const install = entry.metadata?.install ?? []; for (const bin of required) { const trimmed = bin.trim(); if (trimmed) bins.add(trimmed); } for (const bin of anyBins) { const trimmed = bin.trim(); if (trimmed) bins.add(trimmed); } for (const spec of install) { const specBins = spec?.bins ?? []; for (const bin of specBins) { const trimmed = String(bin).trim(); if (trimmed) bins.add(trimmed); } } } return [...bins].toSorted(); } const skillsHandlers = { "skills.status": ({ params, respond }) => { if (!validateSkillsStatusParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid skills.status params: ${formatValidationErrors(validateSkillsStatusParams.errors)}`)); return; } const cfg = loadConfig(); const agentIdRaw = typeof params?.agentId === "string" ? params.agentId.trim() : ""; const agentId = agentIdRaw ? normalizeAgentId(agentIdRaw) : resolveDefaultAgentId(cfg); if (agentIdRaw) { if (!listAgentIds(cfg).includes(agentId)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `unknown agent id "${agentIdRaw}"`)); return; } } respond(true, buildWorkspaceSkillStatus(resolveAgentWorkspaceDir(cfg, agentId), { config: cfg, eligibility: { remote: getRemoteSkillEligibility() } }), void 0); }, "skills.bins": ({ params, respond }) => { if (!validateSkillsBinsParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid skills.bins params: ${formatValidationErrors(validateSkillsBinsParams.errors)}`)); return; } const cfg = loadConfig(); const workspaceDirs = listWorkspaceDirs(cfg); const bins = /* @__PURE__ */ new Set(); for (const workspaceDir of workspaceDirs) { const entries = loadWorkspaceSkillEntries(workspaceDir, { config: cfg }); for (const bin of collectSkillBins(entries)) bins.add(bin); } respond(true, { bins: [...bins].toSorted() }, void 0); }, "skills.install": async ({ params, respond }) => { if (!validateSkillsInstallParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid skills.install params: ${formatValidationErrors(validateSkillsInstallParams.errors)}`)); return; } const p = params; const cfg = loadConfig(); const result = await installSkill({ workspaceDir: resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)), skillName: p.name, installId: p.installId, timeoutMs: p.timeoutMs, config: cfg }); respond(result.ok, result, result.ok ? void 0 : errorShape(ErrorCodes.UNAVAILABLE, result.message)); }, "skills.update": async ({ params, respond }) => { if (!validateSkillsUpdateParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid skills.update params: ${formatValidationErrors(validateSkillsUpdateParams.errors)}`)); return; } const p = params; 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; await writeConfigFile({ ...cfg, skills }); respond(true, { ok: true, skillKey: p.skillKey, config: current }, void 0); } }; //#endregion //#region src/gateway/server-methods/system.ts const systemHandlers = { "last-heartbeat": ({ respond }) => { respond(true, getLastHeartbeatEvent(), void 0); }, "set-heartbeats": ({ params, respond }) => { const enabled = params.enabled; if (typeof enabled !== "boolean") { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "invalid set-heartbeats params: enabled (boolean) required")); return; } setHeartbeatsEnabled(enabled); respond(true, { ok: true, enabled }, void 0); }, "system-presence": ({ respond }) => { respond(true, listSystemPresence(), void 0); }, "system-event": ({ params, respond, context }) => { const text = typeof params.text === "string" ? params.text.trim() : ""; if (!text) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "text required")); return; } const sessionKey = resolveMainSessionKeyFromConfig(); const deviceId = typeof params.deviceId === "string" ? params.deviceId : void 0; const instanceId = typeof params.instanceId === "string" ? params.instanceId : void 0; const host = typeof params.host === "string" ? params.host : void 0; const ip = typeof params.ip === "string" ? params.ip : void 0; const mode = typeof params.mode === "string" ? params.mode : void 0; const version = typeof params.version === "string" ? params.version : void 0; const platform = typeof params.platform === "string" ? params.platform : void 0; const deviceFamily = typeof params.deviceFamily === "string" ? params.deviceFamily : void 0; const modelIdentifier = typeof params.modelIdentifier === "string" ? params.modelIdentifier : void 0; const lastInputSeconds = typeof params.lastInputSeconds === "number" && Number.isFinite(params.lastInputSeconds) ? params.lastInputSeconds : void 0; const reason = typeof params.reason === "string" ? params.reason : void 0; const presenceUpdate = updateSystemPresence({ text, deviceId, instanceId, host, ip, mode, version, platform, deviceFamily, modelIdentifier, lastInputSeconds, reason, roles: Array.isArray(params.roles) && params.roles.every((t) => typeof t === "string") ? params.roles : void 0, scopes: Array.isArray(params.scopes) && params.scopes.every((t) => typeof t === "string") ? params.scopes : void 0, tags: Array.isArray(params.tags) && params.tags.every((t) => typeof t === "string") ? params.tags : void 0 }); if (text.startsWith("Node:")) { 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; if (hostChanged || ipChanged || versionChanged || modeChanged || reasonChanged) { const contextChanged = isSystemEventContextChanged(sessionKey, presenceUpdate.key); const parts = []; 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, { sessionKey, contextKey: presenceUpdate.key }); } } else enqueueSystemEvent(text, { sessionKey }); const nextPresenceVersion = context.incrementPresenceVersion(); context.broadcast("presence", { presence: listSystemPresence() }, { dropIfSlow: true, stateVersion: { presence: nextPresenceVersion, health: context.getHealthVersion() } }); respond(true, { ok: true }, void 0); } }; //#endregion //#region src/gateway/server-methods/talk.ts const talkHandlers = { "talk.mode": ({ params, respond, context, client, isWebchatConnect }) => { if (client && isWebchatConnect(client.connect) && !context.hasConnectedMobileNode()) { respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, "talk disabled: no connected iOS/Android nodes")); return; } if (!validateTalkModeParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid talk.mode params: ${formatValidationErrors(validateTalkModeParams.errors)}`)); return; } const payload = { enabled: params.enabled, phase: params.phase ?? null, ts: Date.now() }; context.broadcast("talk.mode", payload, { dropIfSlow: true }); respond(true, payload, void 0); } }; //#endregion //#region src/gateway/server-methods/tts.ts const ttsHandlers = { "tts.status": async ({ respond }) => { try { const config = resolveTtsConfig(loadConfig()); const prefsPath = resolveTtsPrefsPath(config); const provider = getTtsProvider(config, prefsPath); const autoMode = resolveTtsAutoMode({ config, prefsPath }); const fallbackProviders = resolveTtsProviderOrder(provider).slice(1).filter((candidate) => isTtsProviderConfigured(config, candidate)); respond(true, { enabled: isTtsEnabled(config, prefsPath), auto: autoMode, provider, fallbackProvider: fallbackProviders[0] ?? null, fallbackProviders, prefsPath, hasOpenAIKey: Boolean(resolveTtsApiKey(config, "openai")), hasElevenLabsKey: Boolean(resolveTtsApiKey(config, "elevenlabs")), edgeEnabled: isTtsProviderConfigured(config, "edge") }); } catch (err) { respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err))); } }, "tts.enable": async ({ respond }) => { try { setTtsEnabled(resolveTtsPrefsPath(resolveTtsConfig(loadConfig())), true); respond(true, { enabled: true }); } catch (err) { respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err))); } }, "tts.disable": async ({ respond }) => { try { setTtsEnabled(resolveTtsPrefsPath(resolveTtsConfig(loadConfig())), false); respond(true, { enabled: false }); } catch (err) { respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err))); } }, "tts.convert": async ({ params, respond }) => { const text = typeof params.text === "string" ? params.text.trim() : ""; if (!text) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "tts.convert requires text")); return; } try { const result = await textToSpeech({ text, cfg: loadConfig(), channel: typeof params.channel === "string" ? params.channel.trim() : void 0 }); if (result.success && result.audioPath) { respond(true, { audioPath: result.audioPath, provider: result.provider, outputFormat: result.outputFormat, voiceCompatible: result.voiceCompatible }); return; } respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, result.error ?? "TTS conversion failed")); } catch (err) { respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err))); } }, "tts.setProvider": async ({ params, respond }) => { const provider = typeof params.provider === "string" ? params.provider.trim() : ""; if (provider !== "openai" && provider !== "elevenlabs" && provider !== "edge") { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "Invalid provider. Use openai, elevenlabs, or edge.")); return; } try { setTtsProvider(resolveTtsPrefsPath(resolveTtsConfig(loadConfig())), provider); respond(true, { provider }); } catch (err) { respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err))); } }, "tts.providers": async ({ respond }) => { try { const config = resolveTtsConfig(loadConfig()); const prefsPath = resolveTtsPrefsPath(config); respond(true, { providers: [ { id: "openai", name: "OpenAI", configured: Boolean(resolveTtsApiKey(config, "openai")), models: [...OPENAI_TTS_MODELS], voices: [...OPENAI_TTS_VOICES] }, { id: "elevenlabs", name: "ElevenLabs", configured: Boolean(resolveTtsApiKey(config, "elevenlabs")), models: [ "eleven_multilingual_v2", "eleven_turbo_v2_5", "eleven_monolingual_v1" ] }, { id: "edge", name: "Edge TTS", configured: isTtsProviderConfigured(config, "edge"), models: [] } ], active: getTtsProvider(config, prefsPath) }); } catch (err) { respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err))); } } }; //#endregion //#region src/gateway/server-methods/update.ts const updateHandlers = { "update.run": async ({ params, respond }) => { if (!validateUpdateRunParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid update.run params: ${formatValidationErrors(validateUpdateRunParams.errors)}`)); return; } const sessionKey = typeof params.sessionKey === "string" ? params.sessionKey?.trim() || void 0 : void 0; const note = typeof params.note === "string" ? params.note?.trim() || void 0 : void 0; const restartDelayMsRaw = params.restartDelayMs; const restartDelayMs = typeof restartDelayMsRaw === "number" && Number.isFinite(restartDelayMsRaw) ? Math.max(0, Math.floor(restartDelayMsRaw)) : void 0; const timeoutMsRaw = params.timeoutMs; const timeoutMs = typeof timeoutMsRaw === "number" && Number.isFinite(timeoutMsRaw) ? Math.max(1e3, Math.floor(timeoutMsRaw)) : void 0; let result; try { const configChannel = normalizeUpdateChannel(loadConfig().update?.channel); result = await runGatewayUpdate({ timeoutMs, cwd: await resolveOpenClawPackageRoot({ moduleUrl: import.meta.url, argv1: process.argv[1], cwd: process.cwd() }) ?? process.cwd(), argv1: process.argv[1], channel: configChannel ?? void 0 }); } catch (err) { result = { status: "error", mode: "unknown", reason: String(err), steps: [], durationMs: 0 }; } const payload = { kind: "update", status: result.status, ts: Date.now(), sessionKey, message: note ?? null, doctorHint: formatDoctorNonInteractiveHint(), stats: { mode: result.mode, root: result.root ?? void 0, before: result.before ?? null, after: result.after ?? null, steps: result.steps.map((step) => ({ name: step.name, command: step.command, cwd: step.cwd, durationMs: step.durationMs, log: { stdoutTail: step.stdoutTail ?? null, stderrTail: step.stderrTail ?? null, exitCode: step.exitCode ?? null } })), reason: result.reason ?? null, durationMs: result.durationMs } }; let sentinelPath = null; try { sentinelPath = await writeRestartSentinel(payload); } catch { sentinelPath = null; } const restart = scheduleGatewaySigusr1Restart({ delayMs: restartDelayMs, reason: "update.run" }); respond(true, { ok: true, result, restart, sentinel: { path: sentinelPath, payload } }, void 0); } }; //#endregion //#region src/gateway/server-methods/usage.ts const COST_USAGE_CACHE_TTL_MS = 3e4; const costUsageCache = /* @__PURE__ */ new Map(); const parseDays = (raw) => { if (typeof raw === "number" && Number.isFinite(raw)) return Math.floor(raw); if (typeof raw === "string" && raw.trim() !== "") { const parsed = Number(raw); if (Number.isFinite(parsed)) return Math.floor(parsed); } return 30; }; async function loadCostUsageSummaryCached(params) { const days = Math.max(1, params.days); const now = Date.now(); const cached = costUsageCache.get(days); if (cached?.summary && cached.updatedAt && now - cached.updatedAt < COST_USAGE_CACHE_TTL_MS) return cached.summary; if (cached?.inFlight) { if (cached.summary) return cached.summary; return await cached.inFlight; } const entry = cached ?? {}; const inFlight = loadCostUsageSummary({ days, config: params.config }).then((summary) => { costUsageCache.set(days, { summary, updatedAt: Date.now() }); return summary; }).catch((err) => { if (entry.summary) return entry.summary; throw err; }).finally(() => { const current = costUsageCache.get(days); if (current?.inFlight === inFlight) { current.inFlight = void 0; costUsageCache.set(days, current); } }); entry.inFlight = inFlight; costUsageCache.set(days, entry); if (entry.summary) return entry.summary; return await inFlight; } const usageHandlers = { "usage.status": async ({ respond }) => { respond(true, await loadProviderUsageSummary(), void 0); }, "usage.cost": async ({ respond, params }) => { const config = loadConfig(); respond(true, await loadCostUsageSummaryCached({ days: parseDays(params?.days), config }), void 0); } }; //#endregion //#region src/gateway/server-methods/voicewake.ts const voicewakeHandlers = { "voicewake.get": async ({ respond }) => { try { respond(true, { triggers: (await loadVoiceWakeConfig()).triggers }); } catch (err) { respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err))); } }, "voicewake.set": async ({ params, respond, context }) => { if (!Array.isArray(params.triggers)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "voicewake.set requires triggers: string[]")); return; } try { const cfg = await setVoiceWakeTriggers(normalizeVoiceWakeTriggers(params.triggers)); context.broadcastVoiceWakeChanged(cfg.triggers); respond(true, { triggers: cfg.triggers }); } catch (err) { respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err))); } } }; //#endregion //#region src/gateway/server-methods/web.ts const WEB_LOGIN_METHODS = new Set(["web.login.start", "web.login.wait"]); const resolveWebLoginProvider = () => listChannelPlugins().find((plugin) => (plugin.gatewayMethods ?? []).some((method) => WEB_LOGIN_METHODS.has(method))) ?? null; const webHandlers = { "web.login.start": async ({ params, respond, context }) => { if (!validateWebLoginStartParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid web.login.start params: ${formatValidationErrors(validateWebLoginStartParams.errors)}`)); return; } try { const accountId = typeof params.accountId === "string" ? params.accountId : void 0; const provider = resolveWebLoginProvider(); if (!provider) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "web login provider is not available")); return; } await context.stopChannel(provider.id, accountId); if (!provider.gateway?.loginWithQrStart) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `web login is not supported by provider ${provider.id}`)); return; } respond(true, await provider.gateway.loginWithQrStart({ force: Boolean(params.force), timeoutMs: typeof params.timeoutMs === "number" ? params.timeoutMs : void 0, verbose: Boolean(params.verbose), accountId }), void 0); } catch (err) { respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err))); } }, "web.login.wait": async ({ params, respond, context }) => { if (!validateWebLoginWaitParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid web.login.wait params: ${formatValidationErrors(validateWebLoginWaitParams.errors)}`)); return; } try { const accountId = typeof params.accountId === "string" ? params.accountId : void 0; const provider = resolveWebLoginProvider(); if (!provider) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "web login provider is not available")); return; } if (!provider.gateway?.loginWithQrWait) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `web login is not supported by provider ${provider.id}`)); return; } const result = await provider.gateway.loginWithQrWait({ timeoutMs: typeof params.timeoutMs === "number" ? params.timeoutMs : void 0, accountId }); if (result.connected) await context.startChannel(provider.id, accountId); respond(true, result, void 0); } catch (err) { respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err))); } } }; //#endregion //#region src/wizard/session.ts function createDeferred() { let resolve; let reject; return { promise: new Promise((res, rej) => { resolve = res; reject = rej; }), resolve, reject }; } var WizardSessionPrompter = class { constructor(session) { this.session = session; } async intro(title) { await this.prompt({ type: "note", title, message: "", executor: "client" }); } async outro(message) { await this.prompt({ type: "note", title: "Done", message, executor: "client" }); } async note(message, title) { await this.prompt({ type: "note", title, message, executor: "client" }); } async select(params) { return await this.prompt({ type: "select", message: params.message, options: params.options.map((opt) => ({ value: opt.value, label: opt.label, hint: opt.hint })), initialValue: params.initialValue, executor: "client" }); } async multiselect(params) { const res = await this.prompt({ type: "multiselect", message: params.message, options: params.options.map((opt) => ({ value: opt.value, label: opt.label, hint: opt.hint })), initialValue: params.initialValues, executor: "client" }); return Array.isArray(res) ? res : []; } async text(params) { const res = await this.prompt({ type: "text", message: params.message, initialValue: params.initialValue, placeholder: params.placeholder, executor: "client" }); const value = res === null || res === void 0 ? "" : typeof res === "string" ? res : typeof res === "number" || typeof res === "boolean" || typeof res === "bigint" ? String(res) : ""; const error = params.validate?.(value); if (error) throw new Error(error); return value; } async confirm(params) { const res = await this.prompt({ type: "confirm", message: params.message, initialValue: params.initialValue, executor: "client" }); return Boolean(res); } progress(_label) { return { update: (_message) => {}, stop: (_message) => {} }; } async prompt(step) { return await this.session.awaitAnswer({ ...step, id: randomUUID() }); } }; var WizardSession = class { constructor(runner) { this.runner = runner; this.currentStep = null; this.stepDeferred = null; this.answerDeferred = /* @__PURE__ */ new Map(); this.status = "running"; const prompter = new WizardSessionPrompter(this); this.run(prompter); } async next() { if (this.currentStep) return { done: false, step: this.currentStep, status: this.status }; if (this.status !== "running") return { done: true, status: this.status, error: this.error }; if (!this.stepDeferred) this.stepDeferred = createDeferred(); const step = await this.stepDeferred.promise; if (step) return { done: false, step, status: this.status }; return { done: true, status: this.status, error: this.error }; } async answer(stepId, value) { const deferred = this.answerDeferred.get(stepId); if (!deferred) throw new Error("wizard: no pending step"); this.answerDeferred.delete(stepId); this.currentStep = null; deferred.resolve(value); } cancel() { if (this.status !== "running") return; this.status = "cancelled"; this.error = "cancelled"; this.currentStep = null; for (const [, deferred] of this.answerDeferred) deferred.reject(new WizardCancelledError()); this.answerDeferred.clear(); this.resolveStep(null); } pushStep(step) { this.currentStep = step; this.resolveStep(step); } async run(prompter) { try { await this.runner(prompter); this.status = "done"; } catch (err) { if (err instanceof WizardCancelledError) { this.status = "cancelled"; this.error = err.message; } else { this.status = "error"; this.error = String(err); } } finally { this.resolveStep(null); } } async awaitAnswer(step) { if (this.status !== "running") throw new Error("wizard: session not running"); this.pushStep(step); const deferred = createDeferred(); this.answerDeferred.set(step.id, deferred); return await deferred.promise; } resolveStep(step) { if (!this.stepDeferred) return; const deferred = this.stepDeferred; this.stepDeferred = null; deferred.resolve(step); } getStatus() { return this.status; } getError() { return this.error; } }; //#endregion //#region src/gateway/server-methods/wizard.ts const wizardHandlers = { "wizard.start": async ({ params, respond, context }) => { if (!validateWizardStartParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid wizard.start params: ${formatValidationErrors(validateWizardStartParams.errors)}`)); return; } if (context.findRunningWizard()) { respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, "wizard already running")); return; } const sessionId = randomUUID(); const opts = { mode: params.mode, workspace: typeof params.workspace === "string" ? params.workspace : void 0 }; const session = new WizardSession((prompter) => context.wizardRunner(opts, defaultRuntime, prompter)); context.wizardSessions.set(sessionId, session); const result = await session.next(); if (result.done) context.purgeWizardSession(sessionId); respond(true, { sessionId, ...result }, void 0); }, "wizard.next": async ({ params, respond, context }) => { if (!validateWizardNextParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid wizard.next params: ${formatValidationErrors(validateWizardNextParams.errors)}`)); return; } const sessionId = params.sessionId; const session = context.wizardSessions.get(sessionId); if (!session) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "wizard not found")); return; } const answer = params.answer; if (answer) { if (session.getStatus() !== "running") { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "wizard not running")); return; } try { await session.answer(String(answer.stepId ?? ""), answer.value); } catch (err) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, formatForLog(err))); return; } } const result = await session.next(); if (result.done) context.purgeWizardSession(sessionId); respond(true, result, void 0); }, "wizard.cancel": ({ params, respond, context }) => { if (!validateWizardCancelParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid wizard.cancel params: ${formatValidationErrors(validateWizardCancelParams.errors)}`)); return; } const sessionId = params.sessionId; const session = context.wizardSessions.get(sessionId); if (!session) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "wizard not found")); return; } session.cancel(); const status = { status: session.getStatus(), error: session.getError() }; context.wizardSessions.delete(sessionId); respond(true, status, void 0); }, "wizard.status": ({ params, respond, context }) => { if (!validateWizardStatusParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid wizard.status params: ${formatValidationErrors(validateWizardStatusParams.errors)}`)); return; } const sessionId = params.sessionId; const session = context.wizardSessions.get(sessionId); if (!session) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "wizard not found")); return; } const status = { status: session.getStatus(), error: session.getError() }; if (status.status !== "running") context.wizardSessions.delete(sessionId); respond(true, status, void 0); } }; //#endregion //#region src/gateway/server-methods.ts const ADMIN_SCOPE$1 = "operator.admin"; const READ_SCOPE = "operator.read"; const WRITE_SCOPE = "operator.write"; const APPROVALS_SCOPE$1 = "operator.approvals"; const PAIRING_SCOPE$1 = "operator.pairing"; const APPROVAL_METHODS = new Set(["exec.approval.request", "exec.approval.resolve"]); const NODE_ROLE_METHODS = new Set([ "node.invoke.result", "node.event", "skills.bins" ]); const PAIRING_METHODS = new Set([ "node.pair.request", "node.pair.list", "node.pair.approve", "node.pair.reject", "node.pair.verify", "device.pair.list", "device.pair.approve", "device.pair.reject", "device.token.rotate", "device.token.revoke", "node.rename" ]); const ADMIN_METHOD_PREFIXES = ["exec.approvals."]; const READ_METHODS = new Set([ "health", "logs.tail", "channels.status", "status", "usage.status", "usage.cost", "tts.status", "tts.providers", "models.list", "agents.list", "agent.identity.get", "skills.status", "voicewake.get", "sessions.list", "sessions.preview", "cron.list", "cron.status", "cron.runs", "system-presence", "last-heartbeat", "node.list", "node.describe", "chat.history" ]); const WRITE_METHODS = new Set([ "send", "agent", "agent.wait", "wake", "talk.mode", "tts.enable", "tts.disable", "tts.convert", "tts.setProvider", "voicewake.set", "node.invoke", "chat.send", "chat.abort", "browser.request" ]); function authorizeGatewayMethod(method, client) { if (!client?.connect) return null; const role = client.connect.role ?? "operator"; const scopes = client.connect.scopes ?? []; if (NODE_ROLE_METHODS.has(method)) { if (role === "node") return null; return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`); } if (role === "node") return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`); if (role !== "operator") return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`); if (scopes.includes(ADMIN_SCOPE$1)) return null; if (APPROVAL_METHODS.has(method) && !scopes.includes(APPROVALS_SCOPE$1)) return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.approvals"); if (PAIRING_METHODS.has(method) && !scopes.includes(PAIRING_SCOPE$1)) return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.pairing"); if (READ_METHODS.has(method) && !(scopes.includes(READ_SCOPE) || scopes.includes(WRITE_SCOPE))) return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.read"); if (WRITE_METHODS.has(method) && !scopes.includes(WRITE_SCOPE)) return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.write"); if (APPROVAL_METHODS.has(method)) return null; if (PAIRING_METHODS.has(method)) return null; if (READ_METHODS.has(method)) return null; if (WRITE_METHODS.has(method)) return null; if (ADMIN_METHOD_PREFIXES.some((prefix) => method.startsWith(prefix))) return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.admin"); if (method.startsWith("config.") || method.startsWith("wizard.") || method.startsWith("update.") || method === "channels.logout" || method === "skills.install" || method === "skills.update" || method === "cron.add" || method === "cron.update" || method === "cron.remove" || method === "cron.run" || method === "sessions.patch" || method === "sessions.reset" || method === "sessions.delete" || method === "sessions.compact") return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.admin"); return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.admin"); } const coreGatewayHandlers = { ...connectHandlers, ...logsHandlers, ...voicewakeHandlers, ...healthHandlers, ...channelsHandlers, ...chatHandlers, ...cronHandlers, ...deviceHandlers, ...execApprovalsHandlers, ...webHandlers, ...modelsHandlers, ...configHandlers, ...wizardHandlers, ...talkHandlers, ...ttsHandlers, ...skillsHandlers, ...sessionsHandlers, ...systemHandlers, ...updateHandlers, ...nodeHandlers, ...sendHandlers, ...usageHandlers, ...agentHandlers, ...agentsHandlers, ...browserHandlers }; async function handleGatewayRequest(opts) { const { req, respond, client, isWebchatConnect, context } = opts; const authError = authorizeGatewayMethod(req.method, client); if (authError) { respond(false, void 0, authError); return; } const handler = opts.extraHandlers?.[req.method] ?? coreGatewayHandlers[req.method]; if (!handler) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `unknown method: ${req.method}`)); return; } await handler({ req, params: req.params ?? {}, client, isWebchatConnect, respond, context }); } //#endregion //#region src/gateway/server-methods/exec-approval.ts function createExecApprovalHandlers(manager, opts) { return { "exec.approval.request": async ({ params, respond, context }) => { if (!validateExecApprovalRequestParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid exec.approval.request params: ${formatValidationErrors(validateExecApprovalRequestParams.errors)}`)); return; } const p = params; const timeoutMs = typeof p.timeoutMs === "number" ? p.timeoutMs : 12e4; const explicitId = typeof p.id === "string" && p.id.trim().length > 0 ? p.id.trim() : null; if (explicitId && manager.getSnapshot(explicitId)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "approval id already pending")); return; } const request = { command: p.command, cwd: p.cwd ?? null, host: p.host ?? null, security: p.security ?? null, ask: p.ask ?? null, agentId: p.agentId ?? null, resolvedPath: p.resolvedPath ?? null, sessionKey: p.sessionKey ?? null }; const record = manager.create(request, timeoutMs, explicitId); const decisionPromise = manager.waitForDecision(record, timeoutMs); context.broadcast("exec.approval.requested", { id: record.id, request: record.request, createdAtMs: record.createdAtMs, expiresAtMs: record.expiresAtMs }, { dropIfSlow: true }); opts?.forwarder?.handleRequested({ id: record.id, request: record.request, createdAtMs: record.createdAtMs, expiresAtMs: record.expiresAtMs }).catch((err) => { context.logGateway?.error?.(`exec approvals: forward request failed: ${String(err)}`); }); const decision = await decisionPromise; respond(true, { id: record.id, decision, createdAtMs: record.createdAtMs, expiresAtMs: record.expiresAtMs }, void 0); }, "exec.approval.resolve": async ({ params, respond, client, context }) => { if (!validateExecApprovalResolveParams(params)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `invalid exec.approval.resolve params: ${formatValidationErrors(validateExecApprovalResolveParams.errors)}`)); return; } const p = params; const decision = p.decision; if (decision !== "allow-once" && decision !== "allow-always" && decision !== "deny") { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "invalid decision")); return; } const resolvedBy = client?.connect?.client?.displayName ?? client?.connect?.client?.id; if (!manager.resolve(p.id, decision, resolvedBy ?? null)) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "unknown approval id")); return; } context.broadcast("exec.approval.resolved", { id: p.id, decision, resolvedBy, ts: Date.now() }, { dropIfSlow: true }); opts?.forwarder?.handleResolved({ id: p.id, decision, resolvedBy, ts: Date.now() }).catch((err) => { context.logGateway?.error?.(`exec approvals: forward resolve failed: ${String(err)}`); }); respond(true, { ok: true }, void 0); } }; } //#endregion //#region src/gateway/server-mobile-nodes.ts const isMobilePlatform = (platform) => { const p = typeof platform === "string" ? platform.trim().toLowerCase() : ""; if (!p) return false; return p.startsWith("ios") || p.startsWith("ipados") || p.startsWith("android"); }; function hasConnectedMobileNode(registry) { return registry.listConnected().some((n) => isMobilePlatform(n.platform)); } //#endregion //#region src/gateway/server-model-catalog.ts async function loadGatewayModelCatalog() { return await loadModelCatalog({ config: loadConfig() }); } //#endregion //#region src/gateway/server-node-subscriptions.ts function createNodeSubscriptionManager() { const nodeSubscriptions = /* @__PURE__ */ new Map(); const sessionSubscribers = /* @__PURE__ */ new Map(); const toPayloadJSON = (payload) => payload ? JSON.stringify(payload) : null; const subscribe = (nodeId, sessionKey) => { const normalizedNodeId = nodeId.trim(); const normalizedSessionKey = sessionKey.trim(); if (!normalizedNodeId || !normalizedSessionKey) return; let nodeSet = nodeSubscriptions.get(normalizedNodeId); if (!nodeSet) { nodeSet = /* @__PURE__ */ new Set(); nodeSubscriptions.set(normalizedNodeId, nodeSet); } if (nodeSet.has(normalizedSessionKey)) return; nodeSet.add(normalizedSessionKey); let sessionSet = sessionSubscribers.get(normalizedSessionKey); if (!sessionSet) { sessionSet = /* @__PURE__ */ new Set(); sessionSubscribers.set(normalizedSessionKey, sessionSet); } sessionSet.add(normalizedNodeId); }; const unsubscribe = (nodeId, sessionKey) => { const normalizedNodeId = nodeId.trim(); const normalizedSessionKey = sessionKey.trim(); if (!normalizedNodeId || !normalizedSessionKey) return; const nodeSet = nodeSubscriptions.get(normalizedNodeId); nodeSet?.delete(normalizedSessionKey); if (nodeSet?.size === 0) nodeSubscriptions.delete(normalizedNodeId); const sessionSet = sessionSubscribers.get(normalizedSessionKey); sessionSet?.delete(normalizedNodeId); if (sessionSet?.size === 0) sessionSubscribers.delete(normalizedSessionKey); }; const unsubscribeAll = (nodeId) => { const normalizedNodeId = nodeId.trim(); const nodeSet = nodeSubscriptions.get(normalizedNodeId); if (!nodeSet) return; for (const sessionKey of nodeSet) { const sessionSet = sessionSubscribers.get(sessionKey); sessionSet?.delete(normalizedNodeId); if (sessionSet?.size === 0) sessionSubscribers.delete(sessionKey); } nodeSubscriptions.delete(normalizedNodeId); }; const sendToSession = (sessionKey, event, payload, sendEvent) => { const normalizedSessionKey = sessionKey.trim(); if (!normalizedSessionKey || !sendEvent) return; const subs = sessionSubscribers.get(normalizedSessionKey); if (!subs || subs.size === 0) return; const payloadJSON = toPayloadJSON(payload); for (const nodeId of subs) sendEvent({ nodeId, event, payloadJSON }); }; const sendToAllSubscribed = (event, payload, sendEvent) => { if (!sendEvent) return; const payloadJSON = toPayloadJSON(payload); for (const nodeId of nodeSubscriptions.keys()) sendEvent({ nodeId, event, payloadJSON }); }; const sendToAllConnected = (event, payload, listConnected, sendEvent) => { if (!sendEvent || !listConnected) return; const payloadJSON = toPayloadJSON(payload); for (const node of listConnected()) sendEvent({ nodeId: node.nodeId, event, payloadJSON }); }; const clear = () => { nodeSubscriptions.clear(); sessionSubscribers.clear(); }; return { subscribe, unsubscribe, unsubscribeAll, sendToSession, sendToAllSubscribed, sendToAllConnected, clear }; } //#endregion //#region src/gateway/server-plugins.ts function loadGatewayPlugins(params) { const pluginRegistry = loadOpenClawPlugins({ config: params.cfg, workspaceDir: params.workspaceDir, logger: { info: (msg) => params.log.info(msg), warn: (msg) => params.log.warn(msg), error: (msg) => params.log.error(msg), debug: (msg) => params.log.debug(msg) }, coreGatewayHandlers: params.coreGatewayHandlers }); const pluginMethods = Object.keys(pluginRegistry.gatewayHandlers); const gatewayMethods = Array.from(new Set([...params.baseMethods, ...pluginMethods])); if (pluginRegistry.diagnostics.length > 0) for (const diag of pluginRegistry.diagnostics) { const details = [diag.pluginId ? `plugin=${diag.pluginId}` : null, diag.source ? `source=${diag.source}` : null].filter((entry) => Boolean(entry)).join(", "); const message = details ? `[plugins] ${diag.message} (${details})` : `[plugins] ${diag.message}`; if (diag.level === "error") params.log.error(message); else params.log.info(message); } return { pluginRegistry, gatewayMethods }; } //#endregion //#region src/gateway/hooks-mapping.ts const hookPresetMappings = { gmail: [{ id: "gmail", match: { path: "gmail" }, action: "agent", wakeMode: "now", name: "Gmail", sessionKey: "hook:gmail:{{messages[0].id}}", messageTemplate: "New email from {{messages[0].from}}\nSubject: {{messages[0].subject}}\n{{messages[0].snippet}}\n{{messages[0].body}}" }] }; const transformCache = /* @__PURE__ */ new Map(); function resolveHookMappings(hooks) { const presets = hooks?.presets ?? []; const gmailAllowUnsafe = hooks?.gmail?.allowUnsafeExternalContent; const mappings = []; if (hooks?.mappings) mappings.push(...hooks.mappings); for (const preset of presets) { const presetMappings = hookPresetMappings[preset]; if (!presetMappings) continue; if (preset === "gmail" && typeof gmailAllowUnsafe === "boolean") { mappings.push(...presetMappings.map((mapping) => ({ ...mapping, allowUnsafeExternalContent: gmailAllowUnsafe }))); continue; } mappings.push(...presetMappings); } if (mappings.length === 0) return []; const configDir = path.dirname(CONFIG_PATH); const transformsDir = hooks?.transformsDir ? resolvePath(configDir, hooks.transformsDir) : configDir; return mappings.map((mapping, index) => normalizeHookMapping(mapping, index, transformsDir)); } async function applyHookMappings(mappings, ctx) { if (mappings.length === 0) return null; for (const mapping of mappings) { if (!mappingMatches(mapping, ctx)) continue; const base = buildActionFromMapping(mapping, ctx); if (!base.ok) return base; let override = null; if (mapping.transform) { override = await (await loadTransform(mapping.transform))(ctx); if (override === null) return { ok: true, action: null, skipped: true }; } if (!base.action) return { ok: true, action: null, skipped: true }; const merged = mergeAction(base.action, override, mapping.action); if (!merged.ok) return merged; return merged; } return null; } function normalizeHookMapping(mapping, index, transformsDir) { const id = mapping.id?.trim() || `mapping-${index + 1}`; const matchPath = normalizeMatchPath(mapping.match?.path); const matchSource = mapping.match?.source?.trim(); const action = mapping.action ?? "agent"; const wakeMode = mapping.wakeMode ?? "now"; const transform = mapping.transform ? { modulePath: resolvePath(transformsDir, mapping.transform.module), exportName: mapping.transform.export?.trim() || void 0 } : void 0; return { id, matchPath, matchSource, action, wakeMode, name: mapping.name, sessionKey: mapping.sessionKey, messageTemplate: mapping.messageTemplate, textTemplate: mapping.textTemplate, deliver: mapping.deliver, allowUnsafeExternalContent: mapping.allowUnsafeExternalContent, channel: mapping.channel, to: mapping.to, model: mapping.model, thinking: mapping.thinking, timeoutSeconds: mapping.timeoutSeconds, transform }; } function mappingMatches(mapping, ctx) { if (mapping.matchPath) { if (mapping.matchPath !== normalizeMatchPath(ctx.path)) return false; } if (mapping.matchSource) { const source = typeof ctx.payload.source === "string" ? ctx.payload.source : void 0; if (!source || source !== mapping.matchSource) return false; } return true; } function buildActionFromMapping(mapping, ctx) { if (mapping.action === "wake") return { ok: true, action: { kind: "wake", text: renderTemplate(mapping.textTemplate ?? "", ctx), mode: mapping.wakeMode ?? "now" } }; return { ok: true, action: { kind: "agent", message: renderTemplate(mapping.messageTemplate ?? "", ctx), name: renderOptional(mapping.name, ctx), wakeMode: mapping.wakeMode ?? "now", sessionKey: renderOptional(mapping.sessionKey, ctx), deliver: mapping.deliver, allowUnsafeExternalContent: mapping.allowUnsafeExternalContent, channel: mapping.channel, to: renderOptional(mapping.to, ctx), model: renderOptional(mapping.model, ctx), thinking: renderOptional(mapping.thinking, ctx), timeoutSeconds: mapping.timeoutSeconds } }; } function mergeAction(base, override, defaultAction) { if (!override) return validateAction(base); if ((override.kind ?? base.kind ?? defaultAction) === "wake") { const baseWake = base.kind === "wake" ? base : void 0; return validateAction({ kind: "wake", text: typeof override.text === "string" ? override.text : baseWake?.text ?? "", mode: override.mode === "next-heartbeat" ? "next-heartbeat" : baseWake?.mode ?? "now" }); } const baseAgent = base.kind === "agent" ? base : void 0; return validateAction({ kind: "agent", message: typeof override.message === "string" ? override.message : baseAgent?.message ?? "", wakeMode: override.wakeMode === "next-heartbeat" ? "next-heartbeat" : baseAgent?.wakeMode ?? "now", name: override.name ?? baseAgent?.name, sessionKey: override.sessionKey ?? baseAgent?.sessionKey, deliver: typeof override.deliver === "boolean" ? override.deliver : baseAgent?.deliver, allowUnsafeExternalContent: typeof override.allowUnsafeExternalContent === "boolean" ? override.allowUnsafeExternalContent : baseAgent?.allowUnsafeExternalContent, channel: override.channel ?? baseAgent?.channel, to: override.to ?? baseAgent?.to, model: override.model ?? baseAgent?.model, thinking: override.thinking ?? baseAgent?.thinking, timeoutSeconds: override.timeoutSeconds ?? baseAgent?.timeoutSeconds }); } function validateAction(action) { if (action.kind === "wake") { if (!action.text?.trim()) return { ok: false, error: "hook mapping requires text" }; return { ok: true, action }; } if (!action.message?.trim()) return { ok: false, error: "hook mapping requires message" }; return { ok: true, action }; } async function loadTransform(transform) { const cached = transformCache.get(transform.modulePath); if (cached) return cached; const fn = resolveTransformFn(await import(pathToFileURL(transform.modulePath).href), transform.exportName); transformCache.set(transform.modulePath, fn); return fn; } function resolveTransformFn(mod, exportName) { const candidate = exportName ? mod[exportName] : mod.default ?? mod.transform; if (typeof candidate !== "function") throw new Error("hook transform module must export a function"); return candidate; } function resolvePath(baseDir, target) { if (!target) return baseDir; if (path.isAbsolute(target)) return target; return path.join(baseDir, target); } function normalizeMatchPath(raw) { if (!raw) return; const trimmed = raw.trim(); if (!trimmed) return; return trimmed.replace(/^\/+/, "").replace(/\/+$/, ""); } function renderOptional(value, ctx) { if (!value) return; const rendered = renderTemplate(value, ctx).trim(); return rendered ? rendered : void 0; } function renderTemplate(template, ctx) { if (!template) return ""; return template.replace(/\{\{\s*([^}]+)\s*\}\}/g, (_, expr) => { const value = resolveTemplateExpr(expr.trim(), ctx); if (value === void 0 || value === null) return ""; if (typeof value === "string") return value; if (typeof value === "number" || typeof value === "boolean") return String(value); return JSON.stringify(value); }); } function resolveTemplateExpr(expr, ctx) { if (expr === "path") return ctx.path; if (expr === "now") return (/* @__PURE__ */ new Date()).toISOString(); if (expr.startsWith("headers.")) return getByPath(ctx.headers, expr.slice(8)); if (expr.startsWith("query.")) return getByPath(Object.fromEntries(ctx.url.searchParams.entries()), expr.slice(6)); if (expr.startsWith("payload.")) return getByPath(ctx.payload, expr.slice(8)); return getByPath(ctx.payload, expr); } function getByPath(input, pathExpr) { if (!pathExpr) return; const parts = []; const re = /([^.[\]]+)|(\[(\d+)\])/g; let match = re.exec(pathExpr); while (match) { if (match[1]) parts.push(match[1]); else if (match[3]) parts.push(Number(match[3])); match = re.exec(pathExpr); } let current = input; for (const part of parts) { if (current === null || current === void 0) return; if (typeof part === "number") { if (!Array.isArray(current)) return; current = current[part]; continue; } if (typeof current !== "object") return; current = current[part]; } return current; } //#endregion //#region src/gateway/hooks.ts const DEFAULT_HOOKS_PATH = "/hooks"; const DEFAULT_HOOKS_MAX_BODY_BYTES = 256 * 1024; function resolveHooksConfig(cfg) { if (cfg.hooks?.enabled !== true) return null; const token = cfg.hooks?.token?.trim(); if (!token) throw new Error("hooks.enabled requires hooks.token"); const rawPath = cfg.hooks?.path?.trim() || DEFAULT_HOOKS_PATH; const withSlash = rawPath.startsWith("/") ? rawPath : `/${rawPath}`; const trimmed = withSlash.length > 1 ? withSlash.replace(/\/+$/, "") : withSlash; if (trimmed === "/") throw new Error("hooks.path may not be '/'"); return { basePath: trimmed, token, maxBodyBytes: cfg.hooks?.maxBodyBytes && cfg.hooks.maxBodyBytes > 0 ? cfg.hooks.maxBodyBytes : DEFAULT_HOOKS_MAX_BODY_BYTES, mappings: resolveHookMappings(cfg.hooks) }; } function extractHookToken(req, url) { const auth = typeof req.headers.authorization === "string" ? req.headers.authorization.trim() : ""; if (auth.toLowerCase().startsWith("bearer ")) { const token = auth.slice(7).trim(); if (token) return { token, fromQuery: false }; } const headerToken = typeof req.headers["x-openclaw-token"] === "string" ? req.headers["x-openclaw-token"].trim() : ""; if (headerToken) return { token: headerToken, fromQuery: false }; const queryToken = url.searchParams.get("token"); if (queryToken) return { token: queryToken.trim(), fromQuery: true }; return { token: void 0, fromQuery: false }; } async function readJsonBody(req, maxBytes) { return await new Promise((resolve) => { let done = false; let total = 0; const chunks = []; req.on("data", (chunk) => { if (done) return; total += chunk.length; if (total > maxBytes) { done = true; resolve({ ok: false, error: "payload too large" }); req.destroy(); return; } chunks.push(chunk); }); req.on("end", () => { if (done) return; done = true; const raw = Buffer.concat(chunks).toString("utf-8").trim(); if (!raw) { resolve({ ok: true, value: {} }); return; } try { resolve({ ok: true, value: JSON.parse(raw) }); } catch (err) { resolve({ ok: false, error: String(err) }); } }); req.on("error", (err) => { if (done) return; done = true; resolve({ ok: false, error: String(err) }); }); }); } function normalizeHookHeaders(req) { const headers = {}; for (const [key, value] of Object.entries(req.headers)) if (typeof value === "string") headers[key.toLowerCase()] = value; else if (Array.isArray(value) && value.length > 0) headers[key.toLowerCase()] = value.join(", "); return headers; } function normalizeWakePayload(payload) { const text = typeof payload.text === "string" ? payload.text.trim() : ""; if (!text) return { ok: false, error: "text required" }; return { ok: true, value: { text, mode: payload.mode === "next-heartbeat" ? "next-heartbeat" : "now" } }; } const listHookChannelValues = () => ["last", ...listChannelPlugins().map((plugin) => plugin.id)]; const getHookChannelSet = () => new Set(listHookChannelValues()); const getHookChannelError = () => `channel must be ${listHookChannelValues().join("|")}`; function resolveHookChannel(raw) { if (raw === void 0) return "last"; if (typeof raw !== "string") return null; const normalized = normalizeMessageChannel(raw); if (!normalized || !getHookChannelSet().has(normalized)) return null; return normalized; } function resolveHookDeliver(raw) { return raw !== false; } function normalizeAgentPayload(payload, opts) { const message = typeof payload.message === "string" ? payload.message.trim() : ""; if (!message) return { ok: false, error: "message required" }; const nameRaw = payload.name; const name = typeof nameRaw === "string" && nameRaw.trim() ? nameRaw.trim() : "Hook"; const wakeMode = payload.wakeMode === "next-heartbeat" ? "next-heartbeat" : "now"; const sessionKeyRaw = payload.sessionKey; const idFactory = opts?.idFactory ?? randomUUID; const sessionKey = typeof sessionKeyRaw === "string" && sessionKeyRaw.trim() ? sessionKeyRaw.trim() : `hook:${idFactory()}`; const channel = resolveHookChannel(payload.channel); if (!channel) return { ok: false, error: getHookChannelError() }; const toRaw = payload.to; const to = typeof toRaw === "string" && toRaw.trim() ? toRaw.trim() : void 0; const modelRaw = payload.model; const model = typeof modelRaw === "string" && modelRaw.trim() ? modelRaw.trim() : void 0; if (modelRaw !== void 0 && !model) return { ok: false, error: "model required" }; const deliver = resolveHookDeliver(payload.deliver); const thinkingRaw = payload.thinking; const thinking = typeof thinkingRaw === "string" && thinkingRaw.trim() ? thinkingRaw.trim() : void 0; const timeoutRaw = payload.timeoutSeconds; return { ok: true, value: { message, name, wakeMode, sessionKey, deliver, channel, to, model, thinking, timeoutSeconds: typeof timeoutRaw === "number" && Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? Math.floor(timeoutRaw) : void 0 } }; } //#endregion //#region src/gateway/server-browser.ts async function startBrowserControlServerIfEnabled() { if (isTruthyEnvValue(process.env.OPENCLAW_SKIP_BROWSER_CONTROL_SERVER)) return null; const override = process.env.OPENCLAW_BROWSER_CONTROL_MODULE?.trim(); const mod = override ? await import(override) : await import("./control-service-DvA7c6GM.js"); const start = typeof mod.startBrowserControlServiceFromConfig === "function" ? mod.startBrowserControlServiceFromConfig : mod.startBrowserControlServerFromConfig; const stop = typeof mod.stopBrowserControlService === "function" ? mod.stopBrowserControlService : mod.stopBrowserControlServer; if (!start) return null; await start(); return { stop: stop ?? (async () => {}) }; } //#endregion //#region src/gateway/server-reload-handlers.ts function createGatewayReloadHandlers(params) { const applyHotReload = async (plan, nextConfig) => { setGatewaySigusr1RestartPolicy({ allowExternal: nextConfig.commands?.restart === true }); const state = params.getState(); const nextState = { ...state }; if (plan.reloadHooks) try { nextState.hooksConfig = resolveHooksConfig(nextConfig); } catch (err) { params.logHooks.warn(`hooks config reload failed: ${String(err)}`); } if (plan.restartHeartbeat) nextState.heartbeatRunner.updateConfig(nextConfig); resetDirectoryCache(); if (plan.restartCron) { state.cronState.cron.stop(); nextState.cronState = buildGatewayCronService({ cfg: nextConfig, deps: params.deps, broadcast: params.broadcast }); nextState.cronState.cron.start().catch((err) => params.logCron.error(`failed to start: ${String(err)}`)); } if (plan.restartBrowserControl) { if (state.browserControl) await state.browserControl.stop().catch(() => {}); try { nextState.browserControl = await startBrowserControlServerIfEnabled(); } catch (err) { params.logBrowser.error(`server failed to start: ${String(err)}`); } } if (plan.restartGmailWatcher) { await stopGmailWatcher().catch(() => {}); if (!isTruthyEnvValue(process.env.OPENCLAW_SKIP_GMAIL_WATCHER)) try { const gmailResult = await startGmailWatcher(nextConfig); if (gmailResult.started) params.logHooks.info("gmail watcher started"); else if (gmailResult.reason && gmailResult.reason !== "hooks not enabled" && gmailResult.reason !== "no gmail account configured") params.logHooks.warn(`gmail watcher not started: ${gmailResult.reason}`); } catch (err) { params.logHooks.error(`gmail watcher failed to start: ${String(err)}`); } else params.logHooks.info("skipping gmail watcher restart (OPENCLAW_SKIP_GMAIL_WATCHER=1)"); } if (plan.restartChannels.size > 0) if (isTruthyEnvValue(process.env.OPENCLAW_SKIP_CHANNELS) || isTruthyEnvValue(process.env.OPENCLAW_SKIP_PROVIDERS)) params.logChannels.info("skipping channel reload (OPENCLAW_SKIP_CHANNELS=1 or OPENCLAW_SKIP_PROVIDERS=1)"); else { const restartChannel = async (name) => { params.logChannels.info(`restarting ${name} channel`); await params.stopChannel(name); await params.startChannel(name); }; for (const channel of plan.restartChannels) await restartChannel(channel); } setCommandLaneConcurrency(CommandLane.Cron, nextConfig.cron?.maxConcurrentRuns ?? 1); setCommandLaneConcurrency(CommandLane.Main, resolveAgentMaxConcurrent(nextConfig)); setCommandLaneConcurrency(CommandLane.Subagent, resolveSubagentMaxConcurrent(nextConfig)); if (plan.hotReasons.length > 0) params.logReload.info(`config hot reload applied (${plan.hotReasons.join(", ")})`); else if (plan.noopPaths.length > 0) params.logReload.info(`config change applied (dynamic reads: ${plan.noopPaths.join(", ")})`); params.setState(nextState); }; const requestGatewayRestart = (plan, nextConfig) => { setGatewaySigusr1RestartPolicy({ allowExternal: nextConfig.commands?.restart === true }); const reasons = plan.restartReasons.length ? plan.restartReasons.join(", ") : plan.changedPaths.join(", "); params.logReload.warn(`config change requires gateway restart (${reasons})`); if (process.listenerCount("SIGUSR1") === 0) { params.logReload.warn("no SIGUSR1 listener found; restart skipped"); return; } authorizeGatewaySigusr1Restart(); process.emit("SIGUSR1"); }; return { applyHotReload, requestGatewayRestart }; } //#endregion //#region src/gateway/server-runtime-config.ts async function resolveGatewayRuntimeConfig(params) { const bindMode = params.bind ?? params.cfg.gateway?.bind ?? "loopback"; const customBindHost = params.cfg.gateway?.customBindHost; const bindHost = params.host ?? await resolveGatewayBindHost(bindMode, customBindHost); const controlUiEnabled = params.controlUiEnabled ?? params.cfg.gateway?.controlUi?.enabled ?? true; const openAiChatCompletionsEnabled = params.openAiChatCompletionsEnabled ?? params.cfg.gateway?.http?.endpoints?.chatCompletions?.enabled ?? false; const openResponsesConfig = params.cfg.gateway?.http?.endpoints?.responses; const openResponsesEnabled = params.openResponsesEnabled ?? openResponsesConfig?.enabled ?? false; const controlUiBasePath = normalizeControlUiBasePath(params.cfg.gateway?.controlUi?.basePath); const controlUiRootRaw = params.cfg.gateway?.controlUi?.root; const controlUiRoot = typeof controlUiRootRaw === "string" && controlUiRootRaw.trim().length > 0 ? controlUiRootRaw.trim() : void 0; const authBase = params.cfg.gateway?.auth ?? {}; const authOverrides = params.auth ?? {}; const authConfig = { ...authBase, ...authOverrides }; const tailscaleBase = params.cfg.gateway?.tailscale ?? {}; const tailscaleOverrides = params.tailscale ?? {}; const tailscaleConfig = { ...tailscaleBase, ...tailscaleOverrides }; const tailscaleMode = tailscaleConfig.mode ?? "off"; const resolvedAuth = resolveGatewayAuth({ authConfig, env: process.env, tailscaleMode }); const authMode = resolvedAuth.mode; const hasToken = typeof resolvedAuth.token === "string" && resolvedAuth.token.trim().length > 0; const hasPassword = typeof resolvedAuth.password === "string" && resolvedAuth.password.trim().length > 0; const hasSharedSecret = authMode === "token" && hasToken || authMode === "password" && hasPassword; const hooksConfig = resolveHooksConfig(params.cfg); const canvasHostEnabled = process.env.OPENCLAW_SKIP_CANVAS_HOST !== "1" && params.cfg.canvasHost?.enabled !== false; assertGatewayAuthConfigured(resolvedAuth); if (tailscaleMode === "funnel" && authMode !== "password") throw new Error("tailscale funnel requires gateway auth mode=password (set gateway.auth.password or OPENCLAW_GATEWAY_PASSWORD)"); if (tailscaleMode !== "off" && !isLoopbackHost$2(bindHost)) throw new Error("tailscale serve/funnel requires gateway bind=loopback (127.0.0.1)"); if (!isLoopbackHost$2(bindHost) && !hasSharedSecret) throw new Error(`refusing to bind gateway to ${bindHost}:${params.port} without auth (set gateway.auth.token/password, or set OPENCLAW_GATEWAY_TOKEN/OPENCLAW_GATEWAY_PASSWORD)`); return { bindHost, controlUiEnabled, openAiChatCompletionsEnabled, openResponsesEnabled, openResponsesConfig: openResponsesConfig ? { ...openResponsesConfig, enabled: openResponsesEnabled } : void 0, controlUiBasePath, controlUiRoot, resolvedAuth, authMode, tailscaleConfig, tailscaleMode, hooksConfig, canvasHostEnabled }; } //#endregion //#region src/canvas-host/a2ui.ts const A2UI_PATH = "/__openclaw__/a2ui"; const CANVAS_HOST_PATH = "/__openclaw__/canvas"; const CANVAS_WS_PATH = "/__openclaw__/ws"; let cachedA2uiRootReal; let resolvingA2uiRoot = null; async function resolveA2uiRoot() { const here = path.dirname(fileURLToPath(import.meta.url)); const candidates = [ path.resolve(here, "a2ui"), path.resolve(here, "../../src/canvas-host/a2ui"), path.resolve(process.cwd(), "src/canvas-host/a2ui"), path.resolve(process.cwd(), "dist/canvas-host/a2ui") ]; if (process.execPath) candidates.unshift(path.resolve(path.dirname(process.execPath), "a2ui")); for (const dir of candidates) try { const indexPath = path.join(dir, "index.html"); const bundlePath = path.join(dir, "a2ui.bundle.js"); await fs$1.stat(indexPath); await fs$1.stat(bundlePath); return dir; } catch {} return null; } async function resolveA2uiRootReal() { if (cachedA2uiRootReal !== void 0) return cachedA2uiRootReal; if (!resolvingA2uiRoot) resolvingA2uiRoot = (async () => { const root = await resolveA2uiRoot(); cachedA2uiRootReal = root ? await fs$1.realpath(root) : null; return cachedA2uiRootReal; })(); return resolvingA2uiRoot; } function normalizeUrlPath$1(rawPath) { const decoded = decodeURIComponent(rawPath || "/"); const normalized = path.posix.normalize(decoded); return normalized.startsWith("/") ? normalized : `/${normalized}`; } async function resolveA2uiFilePath(rootReal, urlPath) { const normalized = normalizeUrlPath$1(urlPath); const rel = normalized.replace(/^\/+/, ""); if (rel.split("/").some((p) => p === "..")) return null; let candidate = path.join(rootReal, rel); if (normalized.endsWith("/")) candidate = path.join(candidate, "index.html"); try { if ((await fs$1.stat(candidate)).isDirectory()) candidate = path.join(candidate, "index.html"); } catch {} const rootPrefix = rootReal.endsWith(path.sep) ? rootReal : `${rootReal}${path.sep}`; try { if ((await fs$1.lstat(candidate)).isSymbolicLink()) return null; const real = await fs$1.realpath(candidate); if (!real.startsWith(rootPrefix)) return null; return real; } catch { return null; } } function injectCanvasLiveReload(html) { const snippet = `