UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

899 lines (898 loc) 35.2 kB
import { l as normalizeOptionalString, o as normalizeLowercaseStringOrEmpty } from "./string-coerce-CIXf7egm.js"; import { n as sliceUtf16Safe } from "./utf16-slice-D_ngcYKd.js"; import { r as defaultRuntime } from "./runtime-CF2WjnNZ.js"; import { n as getRuntimeConfig } from "./io.runtime-B9iJRs3w.js"; import { t as pruneMapToMaxSize } from "./map-size-CNcWiFKu.js"; import { u as normalizeMainKey } from "./session-key-BnWWjqNc.js"; import { t as formatErrorMessage } from "./errors-Db3Ymjlb.js"; import { _ as resolveSessionAgentId } from "./agent-scope-DbtJyKUL.js"; import "./io-bdCzpGWJ.js"; import { i as loadOrCreateProcessDeviceIdentity } from "./device-identity-J83pn_rP.js"; import { En as validateNodePresenceActivityPayload, pn as validateNodeHostStatsPayload } from "./src-BiL5aQto.js"; import { x as runWithGatewayIndependentRootWorkContinuation } from "./gateway-work-admission-R1IpuDim.js"; import { D as NODE_PRESENCE_ALIVE_REASONS } from "./nodes-D1M6Vssk.js"; import { i as deleteMediaBuffer } from "./store-Ph57PZ9P.js"; import { n as deliveryContextFromSession } from "./delivery-context.shared-CXmRgetN.js"; import { l as requestHeartbeat } from "./heartbeat-wake-dlWl8dXS.js"; import { a as enqueueSystemEvent, h as withSystemEventOwner } from "./system-events-C03jsM0j.js"; import "./session-accessor-YsytfDtG.js"; import { b as upsertSessionEntryCore } from "./session-accessor.sqlite-entry-CWk3jL7s.js"; import { a as normalizeChannelId } from "./registry-Cz6cv4VC.js"; import "./plugins-Cozj1Enf.js"; import { c as resolveSystemMainSessionTarget } from "./main-session-Br0F9dzh.js"; import { l as resolveAgentHarnessSessionContextError } from "./agent-harness-session-key-BOz3yx0-.js"; import { t as buildOutboundSessionContext } from "./session-context-CpdGMrv6.js"; import { n as resolveSessionModelRef } from "./session-model-ref-CPZiclLt.js"; import { r as resolveGatewayModelSupportsImages } from "./session-utils-model-BYAclq2V.js"; import { i as scopedHeartbeatWakeOptionsForPolicy, n as resolveEventSessionRoutingPolicy, t as resolveEventSessionKeyForPolicy } from "./event-session-routing-CJOO5dMS.js"; import { r as loadGatewaySessionEntry } from "./session-utils-store-CInT2loy.js"; import "./session-utils-Cai0_C6U.js"; import { n as sendDurableMessageBatchCore } from "./send-Dk4bo3m5.js"; import "./runtime-zD0iWK04.js"; import { a as resolveOutboundTarget } from "./targets-CP7C4mlG.js"; import { t as NODE_HOST_STATS_EVENT } from "./node-host-stats-BMvjvr90.js"; import { y as updatePairedDevicePresence } from "./device-pairing-DFIQp3ZY.js"; import { t as createOutboundSendDeps } from "./outbound-send-deps-CzQHPhLv.js"; import { r as agentCommandFromIngress } from "./agent-command-DK8Hh-Mq.js"; import { t as formatForLog } from "./ws-log-Dkg9wKNU.js"; import "./agent-W7bgjwyp.js"; import { n as resolveChatAttachmentMaxBytes } from "./chat-attachment-policy-F0KcWQR6.js"; import { c as persistInboundImagesForTranscript, s as parseMessageWithAttachments, t as INLINE_IMAGE_DURABLE_OMISSION_MARKER } from "./chat-attachments-DAJvw83V.js"; import { t as normalizeRpcAttachmentsToChatAttachments } from "./attachment-normalize-BA7mKleS.js"; import "./push-apns-n6D4554g.js"; import { h as registerApnsRegistration, t as ApnsRegistrationPairingChangedError } from "./push-apns-store-cDZzCfro.js"; import { randomUUID } from "node:crypto"; //#region src/shared/node-presence.ts /** Gateway event name used by node hosts to refresh their last-seen presence. */ const NODE_PRESENCE_ALIVE_EVENT = "node.presence.alive"; /** Gateway event name used by interactive nodes to report recent local input. */ const NODE_PRESENCE_ACTIVITY_EVENT = "node.presence.activity"; const NODE_PRESENCE_ALIVE_REASON_SET = new Set(Object.values(NODE_PRESENCE_ALIVE_REASONS)); /** Normalizes untrusted presence trigger values, defaulting unknown input to background. */ function normalizeNodePresenceAliveReason(value) { const normalized = normalizeOptionalString(value)?.toLowerCase(); if (normalized && NODE_PRESENCE_ALIVE_REASON_SET.has(normalized)) return normalized; return "background"; } //#endregion //#region src/gateway/server-node-events.ts function resolveDefaultServerNodeEventDependencies() { return { agentCommandFromIngress, ApnsRegistrationPairingChangedError, buildOutboundSessionContext, createOutboundSendDeps, defaultRuntime, deleteMediaBuffer, enqueueSystemEvent, formatForLog, getRuntimeConfig, INLINE_IMAGE_DURABLE_OMISSION_MARKER, loadOrCreateProcessDeviceIdentity, loadSessionEntry: loadGatewaySessionEntry, normalizeChannelId, normalizeMainKey, normalizeRpcAttachmentsToChatAttachments, parseMessageWithAttachments, persistInboundImagesForTranscript, registerApnsRegistration, requestHeartbeat, resolveChatAttachmentMaxBytes, resolveGatewayModelSupportsImages, resolveOutboundTarget, resolveSessionAgentId, resolveSessionModelRef, resolveSystemMainSessionTarget, sendDurableMessageBatchCore, updatePairedDevicePresence, upsertSessionEntryCore, withSystemEventOwner }; } const MAX_EXEC_EVENT_OUTPUT_CHARS = 180; const MAX_NOTIFICATION_EVENT_TEXT_CHARS = 120; const VOICE_TRANSCRIPT_DEDUPE_WINDOW_MS = 1500; const MAX_RECENT_VOICE_TRANSCRIPTS = 200; const EXEC_FINISHED_RUN_DEDUPE_WINDOW_MS = 6e5; const MAX_RECENT_EXEC_FINISHED_RUNS = 2e3; const NODE_PRESENCE_PERSIST_MIN_INTERVAL_MS = 6e4; const MAX_RECENT_NODE_PRESENCE_KEYS = 1024; const recentVoiceTranscripts = /* @__PURE__ */ new Map(); const pendingVoiceTranscriptReservations = /* @__PURE__ */ new Map(); const recentExecFinishedRuns = /* @__PURE__ */ new Map(); const recentNodePresencePersistAt = /* @__PURE__ */ new Map(); function normalizeFiniteInteger(value) { return typeof value === "number" && Number.isFinite(value) ? Math.trunc(value) : null; } function dispatchNodeAgentCommand(ctx, nodeId, input, dependencies, isConnectionCurrent, onAdmissionRejected) { runWithGatewayIndependentRootWorkContinuation(async () => { if (isConnectionCurrent && !await isConnectionCurrent()) { await onAdmissionRejected?.(); return; } await dependencies.agentCommandFromIngress(input, dependencies.defaultRuntime, ctx.deps); }, "node-events:agent-turn").catch((err) => { ctx.logGateway.warn(`agent failed node=${nodeId}: ${dependencies.formatForLog(err)}`); }); } function resolveVoiceTranscriptFingerprint(obj, text) { const eventId = normalizeOptionalString(obj.eventId) ?? normalizeOptionalString(obj.providerEventId) ?? normalizeOptionalString(obj.transcriptId); if (eventId) return `event:${eventId}`; const callId = normalizeOptionalString(obj.providerCallId) ?? normalizeOptionalString(obj.callId); const sequence = normalizeFiniteInteger(obj.sequence) ?? normalizeFiniteInteger(obj.seq); if (callId && sequence !== null) return `call-seq:${callId}:${sequence}`; const eventTimestamp = normalizeFiniteInteger(obj.timestamp) ?? normalizeFiniteInteger(obj.ts) ?? normalizeFiniteInteger(obj.eventTimestamp); if (callId && eventTimestamp !== null) return `call-ts:${callId}:${eventTimestamp}`; if (eventTimestamp !== null) return `timestamp:${eventTimestamp}|text:${text}`; return `text:${text}`; } function shouldDropDuplicateVoiceTranscript(params) { const previous = recentVoiceTranscripts.get(params.sessionKey); if (previous && previous.fingerprint === params.fingerprint && params.now - previous.ts <= VOICE_TRANSCRIPT_DEDUPE_WINDOW_MS) return true; recentVoiceTranscripts.set(params.sessionKey, { fingerprint: params.fingerprint, ts: params.now }); if (recentVoiceTranscripts.size > MAX_RECENT_VOICE_TRANSCRIPTS) { const cutoff = params.now - VOICE_TRANSCRIPT_DEDUPE_WINDOW_MS * 2; for (const [key, value] of recentVoiceTranscripts) { if (value.ts < cutoff) recentVoiceTranscripts.delete(key); if (recentVoiceTranscripts.size <= MAX_RECENT_VOICE_TRANSCRIPTS) break; } pruneMapToMaxSize(recentVoiceTranscripts, MAX_RECENT_VOICE_TRANSCRIPTS); } return false; } function reserveVoiceTranscript(params) { let resolveDecision = () => {}; let rejectDecision = () => {}; const decision = new Promise((resolve, reject) => { resolveDecision = resolve; rejectDecision = reject; }); const reservation = { fingerprint: params.fingerprint, receivedAt: params.receivedAt, status: "pending", resolve: resolveDecision, rejectDecision, decision }; const queue = pendingVoiceTranscriptReservations.get(params.sessionKey) ?? []; queue.push(reservation); pendingVoiceTranscriptReservations.set(params.sessionKey, queue); const drain = () => { while (queue[0]?.status === "rejected") { const next = queue.shift(); if (!next) break; next.resolve(null); } const next = queue[0]; if (!next) { pendingVoiceTranscriptReservations.delete(params.sessionKey); return; } if (next.status !== "ready") return; next.status = "checking"; (async () => { try { const admission = (next.isConnectionCurrent ? await next.isConnectionCurrent() : true) && !shouldDropDuplicateVoiceTranscript({ sessionKey: params.sessionKey, fingerprint: next.fingerprint, now: next.receivedAt }) && next.start ? { work: next.start() } : null; queue.shift(); next.resolve(admission); } catch (err) { queue.shift(); next.rejectDecision(err); } drain(); })(); }; const settle = (status) => { if (reservation.status !== "pending") return; reservation.status = status; drain(); }; return { admit: ({ isConnectionCurrent, start }) => { reservation.isConnectionCurrent = isConnectionCurrent; reservation.start = start; settle("ready"); return reservation.decision; }, reject: () => settle("rejected") }; } function dispatchReservedVoiceAgentCommand(params) { runWithGatewayIndependentRootWorkContinuation(async () => { if (params.isConnectionCurrent && !await params.isConnectionCurrent()) { params.reservation.reject(); return; } const admission = await params.reservation.admit({ isConnectionCurrent: params.isConnectionCurrent, start: () => { params.onStart(); return params.dependencies.agentCommandFromIngress(params.input, params.dependencies.defaultRuntime, params.ctx.deps); } }); if (!admission) return; await admission.work; }, "node-events:voice-turn").catch((err) => { params.reservation.reject(); params.ctx.logGateway.warn(`agent failed node=${params.nodeId}: ${params.dependencies.formatForLog(err)}`); }); } function shouldDropDuplicateExecFinished(params) { const fingerprint = `${params.sessionKey}::${params.runId}`; const previousTs = recentExecFinishedRuns.get(fingerprint); if (typeof previousTs === "number" && params.now - previousTs <= EXEC_FINISHED_RUN_DEDUPE_WINDOW_MS) return true; recentExecFinishedRuns.set(fingerprint, params.now); if (recentExecFinishedRuns.size > MAX_RECENT_EXEC_FINISHED_RUNS) { const cutoff = params.now - EXEC_FINISHED_RUN_DEDUPE_WINDOW_MS; for (const [key, ts] of recentExecFinishedRuns) { if (ts < cutoff) recentExecFinishedRuns.delete(key); if (recentExecFinishedRuns.size <= MAX_RECENT_EXEC_FINISHED_RUNS) break; } pruneMapToMaxSize(recentExecFinishedRuns, MAX_RECENT_EXEC_FINISHED_RUNS); } return false; } function pruneBoundedTimestampMap(map, params) { if (map.size <= params.maxEntries) return; const cutoff = params.now - params.ttlMs; for (const [key, ts] of map) { if (ts < cutoff) map.delete(key); if (map.size <= params.maxEntries) return; } pruneMapToMaxSize(map, params.maxEntries); } function compactExecEventOutput(raw) { const normalized = raw.replace(/\s+/g, " ").trim(); if (!normalized) return ""; if (normalized.length <= MAX_EXEC_EVENT_OUTPUT_CHARS) return normalized; return `${sliceUtf16Safe(normalized, 0, Math.max(1, 179))}…`; } function compactNotificationEventText(raw) { const normalized = raw.replace(/\s+/g, " ").trim(); if (!normalized) return ""; if (normalized.length <= MAX_NOTIFICATION_EVENT_TEXT_CHARS) return normalized; return `${sliceUtf16Safe(normalized, 0, Math.max(1, 119))}…`; } async function touchSessionStore(params) { const { storePath } = params; if (!storePath) return; await params.dependencies.upsertSessionEntryCore({ sessionKey: params.canonicalKey, storePath }, { sessionId: params.sessionId, updatedAt: params.now, thinkingLevel: params.entry?.thinkingLevel, fastMode: params.entry?.fastMode, verboseLevel: params.entry?.verboseLevel, reasoningLevel: params.entry?.reasoningLevel, systemSent: params.entry?.systemSent, sendPolicy: params.entry?.sendPolicy, delivery: params.entry?.delivery }); } function queueSessionStoreTouch(params) { runWithGatewayIndependentRootWorkContinuation(async () => { if (params.isConnectionCurrent && !await params.isConnectionCurrent()) return; await touchSessionStore({ storePath: params.storePath, canonicalKey: params.canonicalKey, entry: params.entry, sessionId: params.sessionId, now: params.now, dependencies: params.dependencies }); }, "node-events:voice-persist").catch((err) => { params.ctx.logGateway.warn("voice session-store update failed: " + params.dependencies.formatForLog(err)); }); } async function isNodeEventConnectionCurrent(opts) { if (!opts?.isConnectionCurrent) return true; try { return await opts.isConnectionCurrent(); } catch { return false; } } function pairingChangedResult(event) { return { ok: true, event, handled: false, reason: "pairing_changed" }; } async function cleanupNodeEventMedia(ids, ctx, dependencies) { for (const id of ids) try { await dependencies.deleteMediaBuffer(id); } catch (cleanupErr) { ctx.logGateway.warn(`Failed to cleanup orphaned media ${id}: ${formatErrorMessage(cleanupErr)}`); } } function parseSessionKeyFromPayloadJSON(payloadJSON) { let payload; try { payload = JSON.parse(payloadJSON); } catch { return null; } if (typeof payload !== "object" || payload === null) return null; const sessionKey = normalizeOptionalString(payload.sessionKey) ?? ""; return sessionKey.length > 0 ? sessionKey : null; } function parsePayloadObject(payloadJSON) { if (!payloadJSON) return null; let payload; try { payload = JSON.parse(payloadJSON); } catch { return null; } return typeof payload === "object" && payload !== null ? payload : null; } async function sendReceiptAck(params) { const resolved = params.dependencies.resolveOutboundTarget({ channel: params.channel, to: params.to, cfg: params.cfg, mode: "explicit" }); if (!resolved.ok) throw new Error(String(resolved.error)); const session = params.dependencies.buildOutboundSessionContext({ cfg: params.cfg, sessionKey: params.sessionKey }); const send = await params.dependencies.sendDurableMessageBatchCore({ cfg: params.cfg, channel: params.channel, to: resolved.to, payloads: [{ text: params.text }], session, bestEffort: true, durability: "best_effort", deps: params.dependencies.createOutboundSendDeps(params.deps) }); if (send.status === "failed") throw send.error; } const handleNodeEvent = async (ctx, nodeId, evt, opts, dependencies = resolveDefaultServerNodeEventDependencies()) => { const { ApnsRegistrationPairingChangedError, enqueueSystemEvent, formatForLog, getRuntimeConfig, INLINE_IMAGE_DURABLE_OMISSION_MARKER, loadOrCreateProcessDeviceIdentity, loadSessionEntry, normalizeChannelId, normalizeMainKey, normalizeRpcAttachmentsToChatAttachments, parseMessageWithAttachments, persistInboundImagesForTranscript, registerApnsRegistration, requestHeartbeat, resolveChatAttachmentMaxBytes, resolveGatewayModelSupportsImages, resolveSessionAgentId, resolveSessionModelRef, resolveSystemMainSessionTarget, updatePairedDevicePresence, withSystemEventOwner } = dependencies; if (!await isNodeEventConnectionCurrent(opts)) return pairingChangedResult(evt.event); switch (evt.event) { case "voice.transcript": { const obj = parsePayloadObject(evt.payloadJSON); if (!obj) return; const text = normalizeOptionalString(obj.text) ?? ""; if (!text) return; if (text.length > 2e4) return; const sessionKeyRaw = normalizeOptionalString(obj.sessionKey) ?? ""; const rawMainKey = normalizeMainKey(getRuntimeConfig().session?.mainKey); const { storePath, entry, canonicalKey } = loadSessionEntry(sessionKeyRaw.length > 0 ? sessionKeyRaw : rawMainKey); if (resolveAgentHarnessSessionContextError(canonicalKey, entry)) return; const receivedAt = Date.now(); const fingerprint = resolveVoiceTranscriptFingerprint(obj, text); const sessionId = entry?.sessionId ?? randomUUID(); const runId = randomUUID(); const transcriptReservation = reserveVoiceTranscript({ sessionKey: canonicalKey, fingerprint, receivedAt }); dispatchReservedVoiceAgentCommand({ ctx, nodeId, dependencies, input: { runId, message: text, sessionId, sessionKey: canonicalKey, thinking: "low", deliver: false, messageChannel: "node", inputProvenance: { kind: "external_user", sourceChannel: "voice", sourceTool: "gateway.voice.transcript" }, allowModelOverride: false }, reservation: transcriptReservation, isConnectionCurrent: opts?.isConnectionCurrent, onStart: () => { queueSessionStoreTouch({ ctx, dependencies, storePath, canonicalKey, entry, sessionId, now: receivedAt, isConnectionCurrent: opts?.isConnectionCurrent }); ctx.addChatRun(runId, { sessionKey: canonicalKey, clientRunId: runId }); } }); return; } case "agent.request": { if (!evt.payloadJSON) return; let link; try { link = JSON.parse(evt.payloadJSON); } catch { return; } const sessionKeyRaw = (link?.sessionKey ?? "").trim(); const sessionKey = sessionKeyRaw.length > 0 ? sessionKeyRaw : `node-${nodeId}`; const cfg = getRuntimeConfig(); const { storePath, entry, canonicalKey } = loadSessionEntry(sessionKey); if (resolveAgentHarnessSessionContextError(canonicalKey, entry)) return; let message = (link?.message ?? "").trim(); let transcriptMessage = message; const normalizedAttachments = normalizeRpcAttachmentsToChatAttachments(link?.attachments ?? void 0); let images = []; let imageOrder = []; let offloadedRefs = []; if (!message && normalizedAttachments.length === 0) return; if (message.length > 2e4) return; if (normalizedAttachments.length > 0) { const sessionAgentId = resolveSessionAgentId({ sessionKey, config: cfg }); const modelRef = resolveSessionModelRef(cfg, entry, sessionAgentId); const supportsInlineImages = await resolveGatewayModelSupportsImages({ loadGatewayModelCatalog: ctx.loadGatewayModelCatalog, loadGatewayModelCatalogSnapshot: ctx.loadGatewayModelCatalogSnapshot, agentId: sessionAgentId, provider: modelRef.provider, model: modelRef.model }); if (!await isNodeEventConnectionCurrent(opts)) return pairingChangedResult(evt.event); try { const parsed = await parseMessageWithAttachments(message, normalizedAttachments, { maxBytes: resolveChatAttachmentMaxBytes(cfg), log: ctx.logGateway, supportsInlineImages, acceptNonImage: false }); if (!await isNodeEventConnectionCurrent(opts)) { await cleanupNodeEventMedia((parsed.offloadedRefs ?? []).map((ref) => ref.id), ctx, dependencies); return pairingChangedResult(evt.event); } message = parsed.message.trim(); images = parsed.images; imageOrder = parsed.imageOrder; offloadedRefs = parsed.offloadedRefs; if (message.length > 2e4) { ctx.logGateway.warn(`agent.request message exceeds limit after attachment parsing (length=${message.length})`); if (parsed.offloadedRefs && parsed.offloadedRefs.length > 0) await cleanupNodeEventMedia(parsed.offloadedRefs.map((ref) => ref.id), ctx, dependencies); return; } } catch (err) { ctx.logGateway.warn(`agent.request attachment parse failed: ${formatErrorMessage(err)}`); return; } } if (!message && images.length === 0) return; let channel = normalizeChannelId(normalizeOptionalString(link?.channel) ?? "") ?? void 0; let to = normalizeOptionalString(link?.to); const deliverRequested = Boolean(link?.deliver); const wantsReceipt = Boolean(link?.receipt); const receiptText = normalizeOptionalString(link?.receiptText) || "Just received your iOS share + request, working on it."; const now = Date.now(); const sessionId = entry?.sessionId ?? randomUUID(); if (!await isNodeEventConnectionCurrent(opts)) { await cleanupNodeEventMedia((offloadedRefs ?? []).map((ref) => ref.id), ctx, dependencies); return pairingChangedResult(evt.event); } await touchSessionStore({ storePath, canonicalKey, entry, sessionId, now, dependencies }); if (!await isNodeEventConnectionCurrent(opts)) { await cleanupNodeEventMedia((offloadedRefs ?? []).map((ref) => ref.id), ctx, dependencies); return pairingChangedResult(evt.event); } if (deliverRequested && (!channel || !to)) { const entryContext = deliveryContextFromSession(entry); const entryChannel = entryContext?.channel ? normalizeChannelId(entryContext.channel) : void 0; const entryTo = normalizeOptionalString(entryContext?.to) ?? ""; if (!channel && entryChannel) channel = entryChannel; if (!to && entryTo) to = entryTo; } const deliver = deliverRequested && Boolean(channel && to); const deliveryChannel = deliver ? channel : void 0; const deliveryTo = deliver ? to : void 0; if (deliverRequested && !deliver) ctx.logGateway.warn(`agent delivery disabled node=${nodeId}: missing session delivery route (channel=${channel ?? "-"} to=${to ?? "-"})`); if (!await isNodeEventConnectionCurrent(opts)) { await cleanupNodeEventMedia((offloadedRefs ?? []).map((ref) => ref.id), ctx, dependencies); return pairingChangedResult(evt.event); } const persistedTranscriptMedia = await persistInboundImagesForTranscript({ images, offloadedRefs, log: ctx.logGateway, logContext: "agent.request" }); if (!await isNodeEventConnectionCurrent(opts)) { await cleanupNodeEventMedia(persistedTranscriptMedia.entries.map((media) => media.id), ctx, dependencies); return pairingChangedResult(evt.event); } if (persistedTranscriptMedia.omission === "inline-image-save-failed") transcriptMessage = [transcriptMessage, INLINE_IMAGE_DURABLE_OMISSION_MARKER].filter(Boolean).join("\n"); const transcriptMedia = persistedTranscriptMedia.entries.map((media) => media.fact); if (wantsReceipt && deliveryChannel && deliveryTo) runWithGatewayIndependentRootWorkContinuation(async () => { if (!await isNodeEventConnectionCurrent(opts)) return; await sendReceiptAck({ cfg, deps: ctx.deps, dependencies, sessionKey: canonicalKey, channel: deliveryChannel, to: deliveryTo, text: receiptText }); }, "node-events:delivery").catch((err) => { ctx.logGateway.warn(`agent receipt failed node=${nodeId}: ${formatForLog(err)}`); }); else if (wantsReceipt) ctx.logGateway.warn(`agent receipt skipped node=${nodeId}: missing delivery route (channel=${deliveryChannel ?? "-"} to=${deliveryTo ?? "-"})`); dispatchNodeAgentCommand(ctx, nodeId, { runId: sessionId, message, images, imageOrder, ...transcriptMedia.length > 0 || persistedTranscriptMedia.omission === "inline-image-save-failed" ? { transcriptMessage, ...transcriptMedia.length > 0 ? { transcriptMedia } : {} } : {}, sessionId, sessionKey: canonicalKey, thinking: link?.thinking ?? void 0, deliver, to: deliveryTo, channel: deliveryChannel, timeout: typeof link?.timeoutSeconds === "number" ? link.timeoutSeconds.toString() : void 0, messageChannel: "node", allowModelOverride: false }, dependencies, opts?.isConnectionCurrent, () => cleanupNodeEventMedia(persistedTranscriptMedia.entries.map((media) => media.id), ctx, dependencies)); return; } case "notifications.changed": { const obj = parsePayloadObject(evt.payloadJSON); if (!obj) return; const change = normalizeOptionalString(obj.change) ? normalizeLowercaseStringOrEmpty(obj.change) : void 0; if (change !== "posted" && change !== "removed") return; const keyRaw = normalizeOptionalString(obj.key); if (!keyRaw) return; const key = keyRaw; const requestedSessionKey = normalizeOptionalString(obj.sessionKey); let target; try { target = requestedSessionKey ? { sessionKey: requestedSessionKey } : resolveSystemMainSessionTarget(getRuntimeConfig()); } catch (error) { ctx.logGateway.warn(`notification event not delivered node=${nodeId}: ${formatErrorMessage(error)}`); return; } const sessionKeyRaw = target.sessionKey; const { canonicalKey: sessionKey, entry } = loadSessionEntry(sessionKeyRaw); if (resolveAgentHarnessSessionContextError(sessionKey, entry)) return; const packageName = normalizeOptionalString(obj.packageName) ?? null; const title = compactNotificationEventText(normalizeOptionalString(obj.title) ?? ""); const text = compactNotificationEventText(normalizeOptionalString(obj.text) ?? ""); let summary = `Notification ${change} (node=${nodeId} key=${key}`; if (packageName) summary += ` package=${packageName}`; summary += ")"; if (change === "posted") { const messageParts = [title, text].filter(Boolean); if (messageParts.length > 0) summary += `: ${messageParts.join(" - ")}`; } const eventOptions = { sessionKey, contextKey: `notification:${keyRaw}` }; if (enqueueSystemEvent(summary, target.agentId ? withSystemEventOwner(eventOptions, target.agentId) : eventOptions)) requestHeartbeat({ source: "notifications-event", intent: "event", reason: "notifications-event", ...target.agentId ? { agentId: target.agentId } : {}, sessionKey }); return; } case "chat.subscribe": { if (!evt.payloadJSON) return; const sessionKey = parseSessionKeyFromPayloadJSON(evt.payloadJSON); if (!sessionKey) return; const { canonicalKey } = loadSessionEntry(sessionKey); await ctx.nodeSubscribe(nodeId, canonicalKey, opts?.connId); return; } case "chat.unsubscribe": { if (!evt.payloadJSON) return; const sessionKey = parseSessionKeyFromPayloadJSON(evt.payloadJSON); if (!sessionKey) return; const { canonicalKey } = loadSessionEntry(sessionKey); await ctx.nodeUnsubscribe(nodeId, canonicalKey, opts?.connId); return; } case "exec.started": case "exec.finished": case "exec.denied": { const obj = parsePayloadObject(evt.payloadJSON); if (!obj) return; const sessionKeyRaw = normalizeOptionalString(obj.sessionKey) ?? `node-${nodeId}`; if (!sessionKeyRaw) return; const { canonicalKey: sessionKey } = loadSessionEntry(sessionKeyRaw); const cfg = getRuntimeConfig(); const runId = normalizeOptionalString(obj.runId) ?? ""; if (!ctx.authorizeNodeSystemRunEvent({ nodeId, connId: opts?.connId, ...runId ? { runId } : {}, sessionKey: sessionKeyRaw, terminal: evt.event === "exec.finished" || evt.event === "exec.denied" })) return { ok: true, event: evt.event, handled: false, reason: "unmatched_exec_event" }; if (!(cfg.tools?.exec?.notifyOnExit !== false)) return; if (obj.suppressNotifyOnExit === true) return; if (evt.event === "exec.denied") return; const command = normalizeOptionalString(obj.command) ?? ""; const exitCode = typeof obj.exitCode === "number" && Number.isFinite(obj.exitCode) ? obj.exitCode : void 0; const timedOut = obj.timedOut === true; const output = normalizeOptionalString(obj.output) ?? ""; const reason = (normalizeOptionalString(obj.reason) ?? "").replace(/[()]/g, ""); let text; if (evt.event === "exec.started") { text = `Exec started (node=${nodeId}${runId ? ` id=${runId}` : ""})`; if (command) text += `: ${command}`; } else if (evt.event === "exec.finished") { const exitLabel = timedOut ? "timeout" : `code ${exitCode ?? "?"}`; const compactOutput = compactExecEventOutput(output); if (!(timedOut || exitCode !== 0 || compactOutput.length > 0)) return; if (runId && shouldDropDuplicateExecFinished({ sessionKey, runId, now: Date.now() })) return; text = `Exec finished (node=${nodeId}${runId ? ` id=${runId}` : ""}, ${exitLabel})`; if (compactOutput) text += `\n${compactOutput}`; } else { text = `Exec denied (node=${nodeId}${runId ? ` id=${runId}` : ""}${reason ? `, ${reason}` : ""})`; if (command) text += `: ${command}`; } const eventRouting = resolveEventSessionRoutingPolicy({ cfg, sessionKey }); if (enqueueSystemEvent(text, { sessionKey: resolveEventSessionKeyForPolicy(sessionKey, eventRouting), contextKey: runId ? `exec:${runId}` : "exec" })) requestHeartbeat(scopedHeartbeatWakeOptionsForPolicy(sessionKey, { source: "exec-event", intent: "event", reason: "exec-event", coalesceMs: 0 }, eventRouting)); return; } case "push.apns.register": { const obj = parsePayloadObject(evt.payloadJSON); if (!obj) return; const transport = normalizeLowercaseStringOrEmpty(obj.transport) || "direct"; const topic = typeof obj.topic === "string" ? obj.topic : ""; const environment = obj.environment; try { const expectedPairingGeneration = await opts?.resolveApnsRegistrationGeneration?.(); if (!expectedPairingGeneration) { ctx.logGateway.warn(`push apns register rejected node=${nodeId}: stale or invalidated pairing session`); return pairingChangedResult(evt.event); } if (transport === "relay") { const gatewayDeviceId = normalizeOptionalString(obj.gatewayDeviceId) ?? ""; const currentGatewayDeviceId = loadOrCreateProcessDeviceIdentity().deviceId; if (!gatewayDeviceId || gatewayDeviceId !== currentGatewayDeviceId) { ctx.logGateway.warn(`push relay register rejected node=${nodeId}: gateway identity mismatch`); return; } await registerApnsRegistration({ nodeId, transport: "relay", relayHandle: typeof obj.relayHandle === "string" ? obj.relayHandle : "", sendGrant: typeof obj.sendGrant === "string" ? obj.sendGrant : "", installationId: typeof obj.installationId === "string" ? obj.installationId : "", topic, environment, distribution: obj.distribution, relayOrigin: obj.relayOrigin, tokenDebugSuffix: obj.tokenDebugSuffix, expectedPairingGeneration }); } else await registerApnsRegistration({ nodeId, transport: "direct", token: typeof obj.token === "string" ? obj.token : "", topic, environment, expectedPairingGeneration }); } catch (err) { if (err instanceof ApnsRegistrationPairingChangedError) { ctx.logGateway.warn(`push apns register rejected node=${nodeId}: stale or invalidated pairing session`); return pairingChangedResult(evt.event); } ctx.logGateway.warn(`push apns register failed node=${nodeId}: ${formatForLog(err)}`); } return; } case NODE_HOST_STATS_EVENT: { const obj = parsePayloadObject(evt.payloadJSON); if (!obj || !validateNodeHostStatsPayload(obj)) return { ok: true, event: evt.event, handled: false, reason: "invalid_payload" }; const hostStats = ctx.updateNodeHostStats?.({ nodeId, connId: opts?.connId, stats: obj }); if (!hostStats) return { ok: true, event: evt.event, handled: false, reason: "stale_connection" }; ctx.broadcast("node.hostStats", { nodeId, hostStats }, { dropIfSlow: true }); return { ok: true, event: evt.event, handled: true, reason: "updated" }; } case NODE_PRESENCE_ACTIVITY_EVENT: { const obj = parsePayloadObject(evt.payloadJSON); if (!obj || !validateNodePresenceActivityPayload(obj)) return { ok: true, event: evt.event, handled: false, reason: "invalid_payload" }; if ("action" in obj) { const cleared = ctx.clearNodePresenceActivity?.({ nodeId, connId: opts?.connId }); if (cleared === null || cleared === void 0) return { ok: true, event: evt.event, handled: false, reason: "stale_connection" }; if (cleared) ctx.broadcast("node.presence", { nodeId, lastActiveAtMs: null, presenceUpdatedAtMs: null }, { dropIfSlow: true }); return { ok: true, event: evt.event, handled: true, reason: cleared ? "cleared" : "already_clear" }; } if (opts?.presenceAllowed !== true) return { ok: true, event: evt.event, handled: false, reason: "permission_required" }; const updated = ctx.updateNodePresenceActivity?.({ nodeId, connId: opts.connId, idleSeconds: obj.idleSeconds, ...obj.saturated === true ? { saturated: true } : {} }); if (!updated) return { ok: true, event: evt.event, handled: false, reason: "stale_connection" }; ctx.broadcast("node.presence", { nodeId, ...updated }, { dropIfSlow: true }); return { ok: true, event: evt.event, handled: true, reason: "updated" }; } case NODE_PRESENCE_ALIVE_EVENT: { const obj = parsePayloadObject(evt.payloadJSON); if (!obj) return { ok: true, event: evt.event, handled: false, reason: "invalid_payload" }; const deviceId = normalizeOptionalString(opts?.deviceId); if (!deviceId) return { ok: true, event: evt.event, handled: false, reason: "missing_device_identity" }; const pairingGeneration = opts?.pairingGeneration; if (!pairingGeneration || pairingGeneration.nodeId !== deviceId) return pairingChangedResult(evt.event); const now = Date.now(); const presenceOwnerKey = `${deviceId}\0${pairingGeneration.key}`; if (now - (recentNodePresencePersistAt.get(presenceOwnerKey) ?? 0) < NODE_PRESENCE_PERSIST_MIN_INTERVAL_MS) return { ok: true, event: evt.event, handled: true, reason: "throttled" }; const lastSeenReason = normalizeNodePresenceAliveReason(obj.trigger); try { if (!await updatePairedDevicePresence(deviceId, { lastSeenAtMs: now, lastSeenReason }, pairingGeneration)) return pairingChangedResult(evt.event); recentNodePresencePersistAt.set(presenceOwnerKey, now); pruneBoundedTimestampMap(recentNodePresencePersistAt, { now, ttlMs: NODE_PRESENCE_PERSIST_MIN_INTERVAL_MS * 10, maxEntries: MAX_RECENT_NODE_PRESENCE_KEYS }); return { ok: true, event: evt.event, handled: true, reason: "persisted" }; } catch (err) { ctx.logGateway.warn(`node presence alive failed node=${nodeId}: ${formatForLog(err)}`); return { ok: true, event: evt.event, handled: false, reason: "persist_failed" }; } } default: return { ok: true, event: evt.event, handled: false, reason: "unsupported_event" }; } }; //#endregion export { handleNodeEvent };