UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

702 lines (701 loc) • 23.1 kB
import { a as asOptionalRecord, c as isRecord } from "../../record-coerce-TIsquoVR.mjs"; import { A as withTimeout } from "../../fs-safe-CS1DrPTK.mjs"; import { b as redactToolPayloadText } from "../../redact-BEIE6NCT.mjs"; import { t as areDiagnosticsEnabledForProcess } from "../../diagnostic-events-BflWr4hm.mjs"; import { t as createSubsystemLogger } from "../../subsystem-BlrgtxyM.mjs"; import { S as isSubagentSessionKey } from "../../session-key-BC4m_Ly5.mjs"; import { i as listAgentIds } from "../../agent-roster-B45JERT5.mjs"; import { n as validateJsonSchemaValue } from "../../schema-validator-CbLrj3Mc.mjs"; import "../../string-coerce-runtime-B1b8UNzb.mjs"; import "../../routing-D93XBoA5.mjs"; import "../../agent-scope-runtime-kyXy8x5x.mjs"; import { _ as createSessionCatalogGitHubLinker, g as readSessionTranscriptCatalogTitle, h as readSessionTranscriptCatalogPage, v as createSessionCatalogSourceActorProjector } from "../../session-transcript-runtime-BP9tuXFg.mjs"; import { t as definePluginEntry } from "../../plugin-entry-BsETFXpv.mjs"; import "../../json-schema-runtime-j7GCYcMi.mjs"; import "../../time-runtime-C32Rd9tg.mjs"; import "../../logging-core-D09a-xJP.mjs"; import "../../diagnostic-runtime-J2IUYsw2.mjs"; import "../../session-catalog-lT3bkPZk.mjs"; import { n as sessionCatalogPaging, t as publishSessionCatalogHost } from "../../session-catalog-paging-DRaYTete.mjs"; //#region extensions/session-share/src/config.ts function sessionShareConfig(config) { const value = config.plugins?.entries?.["session-share"]?.config; return isRecord(value) ? value : {}; } function sessionShareGroups(config) { const share = sessionShareConfig(config).share; if (!isRecord(share) || !Array.isArray(share.groups)) return []; return share.groups.filter((group) => typeof group === "string" && group.length > 0); } function sessionShareNodeBinding(config, nodeId) { const nodes = sessionShareConfig(config).nodes; const binding = isRecord(nodes) ? nodes[nodeId] : void 0; return { ...isRecord(binding) && typeof binding.owner === "string" ? { owner: binding.owner } : {}, linkGitHubIdentities: isRecord(binding) && binding.linkGitHubIdentities === true }; } //#endregion //#region extensions/session-share/src/node-commands.ts const SESSION_SHARE_LIST_COMMAND = "openclaw.sessions.list.v1"; const SESSION_SHARE_READ_COMMAND = "openclaw.sessions.read.v1"; const SESSION_SHARE_COMMANDS = [SESSION_SHARE_LIST_COMMAND, SESSION_SHARE_READ_COMMAND]; const parameterMessages = { listNotObject: "Session list parameters must be an object", unknownListParameter: (key) => `Unknown session list parameter: ${key}`, invalidSearchTerm: "searchTerm must be a non-empty string of at most 500 characters", readNotObject: "Session read parameters must be an object", unknownReadParameter: (key) => `Unknown session read parameter: ${key}`, invalidThreadId: "threadId must be a non-empty session key of at most 512 characters" }; function parseNodeParams(paramsJSON) { return paramsJSON ? JSON.parse(paramsJSON) : void 0; } function sharedEntries(api) { const config = api.runtime.config.current(); const groups = new Set(sessionShareGroups(config)); if (groups.size === 0) return []; return listAgentIds(config).toSorted().flatMap((agentId) => { const storePath = api.runtime.agent.session.resolveStorePath(config.session?.store, { agentId }); return api.runtime.agent.session.listSessionEntries({ agentId, storePath, readOnly: true }).map((session) => Object.assign({}, session, { agentId, storePath })); }).filter(({ sessionKey, entry }) => entry.category !== void 0 && groups.has(entry.category) && entry.incognito !== true && entry.visibility !== "draft" && !isSubagentSessionKey(sessionKey) && entry.createdVia !== "spawn" && !entry.spawnedBy?.trim() && !/^agent:[^:]+:catalog:/i.test(sessionKey)); } function createSessionShareNodeCommands(api) { const source = { pluginId: "session-share", sourceDomain: "openclaw" }; return [{ command: SESSION_SHARE_LIST_COMMAND, hasActiveWork: () => false, cap: "openclaw-sessions", dangerous: false, isAvailable: ({ config }) => sessionShareGroups(config).length > 0, async handle(paramsJSON) { const params = sessionCatalogPaging.parseListParams(parseNodeParams(paramsJSON), { searchMaxLength: 500, messages: parameterMessages }); const offset = sessionCatalogPaging.decodeCursor(params.cursor); const search = params.searchTerm?.toLowerCase(); const sessions = []; for (const { agentId, sessionKey, storePath, entry } of sharedEntries(api)) { const name = search ? readSessionTranscriptCatalogTitle({ agentId, sessionKey, storePath, entry }) : void 0; if (search && !name?.toLowerCase().includes(search) && !sessionKey.toLowerCase().includes(search)) continue; sessions.push({ agentId, storePath, threadId: sessionKey, name, entry, recencyAt: Math.max(entry.updatedAt, entry.lastInteractionAt ?? 0, entry.lastActivityAt ?? 0) }); } sessions.sort((left, right) => right.recencyAt - left.recencyAt || left.threadId.localeCompare(right.threadId)); const selected = sessions.slice(offset, offset + params.limit); if (!search) for (const session of selected) session.name = readSessionTranscriptCatalogTitle({ agentId: session.agentId, sessionKey: session.threadId, storePath: session.storePath, entry: session.entry }); const projectCreator = createSessionCatalogSourceActorProjector({ ...source, actors: selected.map(({ entry }) => entry.createdActor) }); const page = selected.map(({ threadId, name, entry, recencyAt }) => { const archived = entry.archivedAt !== void 0; const cwd = entry.execCwd ?? entry.spawnedCwd ?? entry.spawnedWorkspaceDir ?? entry.worktree?.canonicalWorkspaceDir ?? entry.worktree?.repoRoot; return { threadId, name, color: entry.color, cwd: cwd ? redactToolPayloadText(cwd).slice(0, 6e3) : void 0, status: archived ? "archived" : "idle", createdAt: entry.createdAt, updatedAt: entry.updatedAt, recencyAt, gitBranch: entry.worktree?.branch ? redactToolPayloadText(entry.worktree.branch).slice(0, 6e3) : void 0, archived, canContinue: false, canArchive: false, canOpenTerminal: false, createdActor: projectCreator(entry.createdActor) }; }); return JSON.stringify({ sessions: page, ...offset + page.length < sessions.length ? { nextCursor: sessionCatalogPaging.encodeCursor(offset + page.length) } : {} }); } }, { command: SESSION_SHARE_READ_COMMAND, hasActiveWork: () => false, cap: "openclaw-sessions", dangerous: false, isAvailable: ({ config }) => sessionShareGroups(config).length > 0, async handle(paramsJSON) { const params = sessionCatalogPaging.parseReadParams(parseNodeParams(paramsJSON), { threadIdMaxLength: 512, threadIdPattern: /^[^\0\r\n]+$/, cursorMaxLength: 1200, messages: parameterMessages }); const session = sharedEntries(api).find(({ sessionKey }) => sessionKey === params.threadId); if (!session) throw new Error("Session is not shared. The source operator must select its group and keep it non-draft."); const page = await readSessionTranscriptCatalogPage({ ...source, agentId: session.agentId, sessionKey: session.sessionKey, storePath: session.storePath, limit: params.limit, cursor: params.cursor }); if (!sharedEntries(api).some(({ agentId, sessionKey, storePath, entry }) => agentId === session.agentId && sessionKey === session.sessionKey && storePath === session.storePath && entry.sessionId === session.entry.sessionId)) throw new Error("Session is no longer shared. Refresh the session catalog."); return JSON.stringify({ threadId: session.sessionKey, ...page }); } }]; } //#endregion //#region extensions/session-share/src/wire.ts const shortString = { type: "string", minLength: 1, maxLength: 512 }; const label = { type: "string", minLength: 1, maxLength: 200 }; const text = { type: "string", maxLength: 6e3 }; const cursor = { type: "string", minLength: 1, maxLength: 2048, pattern: "^[A-Za-z0-9_-]+$" }; const identity = { oneOf: [ { type: "object", additionalProperties: false, required: [ "type", "pluginId", "domain", "idKind", "id" ], properties: { type: { const: "remote" }, pluginId: shortString, domain: shortString, idKind: shortString, id: shortString } }, { type: "object", additionalProperties: false, required: [ "type", "pluginId", "accountId", "senderKind", "id" ], properties: { type: { const: "observation" }, pluginId: { anyOf: [shortString, { type: "null" }] }, accountId: { anyOf: [shortString, { type: "null" }] }, senderKind: { enum: [ "human", "bot", "unknown" ] }, id: shortString } }, { type: "object", additionalProperties: false, required: ["type", "id"], properties: { type: { const: "agent" }, id: shortString } }, { type: "object", additionalProperties: false, required: [ "type", "actorType", "source", "id" ], properties: { type: { const: "legacy" }, actorType: shortString, source: { anyOf: [shortString, { type: "null" }] }, id: text } } ] }; const participant = { type: "object", additionalProperties: false, required: ["identity"], properties: { identity, label, avatarUrl: shortString } }; const sessionPageSchema = { type: "object", additionalProperties: false, required: ["sessions"], properties: { nextCursor: cursor, sessions: { type: "array", maxItems: 100, items: { type: "object", additionalProperties: false, required: [ "threadId", "status", "archived", "canContinue", "canArchive" ], properties: { threadId: shortString, name: text, color: shortString, cwd: text, status: { enum: [ "live", "idle", "archived" ] }, createdAt: { type: "number" }, updatedAt: { type: "number" }, recencyAt: { type: "number" }, gitBranch: text, archived: { type: "boolean" }, createdActor: { type: "object", additionalProperties: false, required: ["type"], properties: { type: { enum: [ "human", "agent", "system" ] }, id: shortString, identity, label, avatarUrl: shortString } }, canContinue: { const: false }, canArchive: { const: false }, canOpenTerminal: { const: false } } } } } }; const transcriptPageSchema = { type: "object", additionalProperties: false, required: ["threadId", "items"], properties: { threadId: shortString, nextCursor: cursor, items: { type: "array", maxItems: 100, items: { type: "object", additionalProperties: false, required: ["type"], properties: { type: { enum: [ "userMessage", "agentMessage", "reasoning", "toolCall", "toolResult", "other" ] }, id: shortString, text, timestamp: shortString, model: shortString, sender: participant, truncated: { type: "boolean" } } } } } }; function isSessionPage(value) { return validateJsonSchemaValue({ schema: sessionPageSchema, cacheKey: "session-share.sessions.v1", value }).ok; } function isTranscriptPage(value) { return validateJsonSchemaValue({ schema: transcriptPageSchema, cacheKey: "session-share.transcript.v1", value }).ok; } function unwrapPayload(value) { if (isRecord(value) && typeof value.payloadJSON === "string") { if (value.payloadJSON.length > 2097152) throw new Error("Session catalog response exceeded the page limit"); return JSON.parse(value.payloadJSON); } return value; } function parseSessionSharePage(raw) { const value = unwrapPayload(raw); if (!isSessionPage(value) || value.nextCursor !== void 0 && !sessionCatalogPaging.isExactCursor(value.nextCursor)) throw new Error("Invalid OpenClaw session page from paired node"); return value; } function parseSessionShareTranscriptPage(raw, threadId) { const value = unwrapPayload(raw); if (!isTranscriptPage(value) || value.threadId !== threadId) throw new Error("Invalid OpenClaw transcript page from paired node"); return value; } //#endregion //#region extensions/session-share/src/session-catalog.ts const log = createSubsystemLogger("gateway/session-catalog"); const nodeErrorCodes = /* @__PURE__ */ new Set([ "TIMEOUT", "NOT_CONNECTED", "PAIRING_CHANGED", "ROUTE_CHANGED", "ABORTED", "UNAVAILABLE", "POLICY_CHANGED", "APPROVAL_AUTHORITY_CLOSED" ]); async function observeCatalogPhase(phase, operation) { if (!areDiagnosticsEnabledForProcess() || !log.isEnabled("warn")) return operation(); const started = performance.now(); const finish = (outcome, error) => { try { if (!areDiagnosticsEnabledForProcess() || !log.isEnabled("warn")) return; const elapsedMs = performance.now() - started; if (elapsedMs < 1e3) return; const details = asOptionalRecord(asOptionalRecord(error)?.details); const nodeError = asOptionalRecord(details?.nodeError); const nodeErrorCode = nodeError?.code; log.warn("slow Session Share catalog phase", { phase, elapsedMs: Math.round(elapsedMs), outcome, ...nodeError ? { nodeErrorCode: typeof nodeErrorCode === "string" && nodeErrorCodes.has(nodeErrorCode) ? nodeErrorCode : "unknown" } : {}, ...typeof details?.nodeCommandDispatched === "boolean" ? { nodeCommandDispatched: details.nodeCommandDispatched } : {} }); } catch {} }; try { const value = await operation(); finish("resolved"); return value; } catch (error) { finish("rejected", error); throw error; } } function namespaceIdentity(identity, hostId) { return identity.type === "remote" && identity.pluginId === "session-share" ? { ...identity, domain: hostId } : identity; } function nodeLabel(node) { return node.displayName?.trim() || node.remoteIp?.trim() || node.nodeId; } function isSessionHost(node) { return SESSION_SHARE_COMMANDS.every((command) => node.commands?.includes(command)); } function bindSession(session, hostId, owner, linkParticipant) { const sourceActor = session.createdActor; const portable = sourceActor?.type === "human" && (sourceActor.identity?.type === "remote" || sourceActor.identity?.type === "observation"); const actor = !portable && owner ? owner : sourceActor; if (!actor?.identity) return actor ? { ...session, createdActor: actor } : session; const participant = { identity: namespaceIdentity(actor.identity, hostId), label: actor.label, avatarUrl: actor.avatarUrl }; const linked = portable && linkParticipant ? linkParticipant(participant) : participant; return { ...session, createdActor: { ...actor, ...linked, ...linked.identity.type === "profile" ? { id: linked.identity.id } : {} } }; } function createSessionShareCatalog(api) { const pages = /* @__PURE__ */ new Map(); let connectedNodes = /* @__PURE__ */ new Map(); const active = /* @__PURE__ */ new Set(); let config; let invokeNode; let lifetime = new AbortController(); api.registerService({ id: "session-share-catalog", start(ctx) { lifetime = new AbortController(); invokeNode = ctx.invokeNode; }, async stop() { invokeNode = void 0; lifetime.abort(); pages.clear(); connectedNodes.clear(); await Promise.allSettled(active); } }); const bindingFor = (nodeId) => sessionShareNodeBinding(api.runtime.config.current(), nodeId); async function listNode(node, query, deadline) { const { onHost, waitUntil, signal: callerSignal } = query; const hostId = `node:${node.nodeId}`; const common = { hostId, label: nodeLabel(node), kind: "node", nodeId: node.nodeId, connected: node.connected === true }; if (!node.connected) return { ...common, sessions: [], error: { code: "NODE_OFFLINE", message: "Paired node is offline" } }; const failed = () => ({ ...common, sessions: [], error: { code: "NODE_INVOKE_FAILED", message: "Cannot list OpenClaw sessions. Check the paired node's session-share configuration and connection." } }); const project = (page) => { const binding = bindingFor(node.nodeId); const linker = binding.owner || binding.linkGitHubIdentities ? createSessionCatalogGitHubLinker() : void 0; const owner = binding.owner ? linker?.resolveOwner(binding.owner) : void 0; const linkParticipant = binding.linkGitHubIdentities ? linker?.linkParticipant : void 0; return { ...common, ...page, sessions: page.sessions.map((session) => bindSession(session, hostId, owner, linkParticipant)) }; }; try { query.signal?.throwIfAborted(); const cursor = query.cursors?.[hostId]; if (cursor !== void 0) sessionCatalogPaging.decodeCursor(cursor); const invokeListing = invokeNode; if (!invokeListing) return failed(); const params = { limit: sessionCatalogPaging.boundedLimit(query.limitPerHost), ...query.search ? { searchTerm: query.search } : {}, ...cursor !== void 0 ? { cursor } : {} }; const key = JSON.stringify([node.nodeId, params]); let entry = pages.get(key); if (!entry) { if (pages.size >= 32) { const evict = [...pages].find(([, candidate]) => !candidate.pending); if (evict) pages.delete(evict[0]); } entry = { nodeId: node.nodeId, connection: node.connectedAtMs }; pages.set(key, entry); } const publication = entry; const revision = config; const signal = lifetime.signal; const current = () => !signal.aborted && api.runtime.config.current() === revision && connectedNodes.has(node.nodeId) && connectedNodes.get(node.nodeId) === publication.connection; const follower = Boolean(publication.pending); if (!publication.pending) { const pending = observeCatalogPhase("invoke", () => invokeListing({ nodeId: node.nodeId, command: SESSION_SHARE_LIST_COMMAND, params, timeoutMs: 3e4, signal })).then((raw) => { const page = parseSessionSharePage(raw); if (current()) publication.page = page; return page; }); publication.pending = pending; active.add(pending); const release = () => { publication.pending = void 0; active.delete(pending); if (pages.size > 32 && pages.get(key) === publication) pages.delete(key); }; pending.then(release, release); } const completed = publication.pending.then((page) => current() ? project(page) : failed(), failed); if (query.allowPartialResults !== true || !onHost || !waitUntil) return await completed; const loading = () => { publishSessionCatalogHost({ waitUntil, onHost: (host) => { if (current() && !callerSignal?.aborted) return onHost?.(host); } }, completed); return { ...publication.page ? project(publication.page) : { ...common, sessions: [] }, pending: true }; }; const remaining = deadline - performance.now(); if (follower || publication.page || remaining <= 0) return loading(); return await withTimeout(completed, remaining, { message: "Session Share catalog is still loading" }).catch(loading); } catch { query.signal?.throwIfAborted(); return failed(); } } return { id: "openclaw", label: "OpenClaw sessions", supportsProcessHomeIsolation: true, audience: "session-viewers", async list(query) { const deadline = performance.now() + 5e3; query.signal?.throwIfAborted(); const currentConfig = api.runtime.config.current(); let nodes; try { nodes = (await observeCatalogPhase("discovery", () => query.listNodes?.() ?? api.runtime.nodes.list())).nodes; } catch { query.signal?.throwIfAborted(); return []; } query.signal?.throwIfAborted(); if (api.runtime.config.current() !== currentConfig) return []; if (config !== currentConfig) { config = currentConfig; pages.clear(); } connectedNodes = new Map(nodes.filter((node) => node.connected && isSessionHost(node)).map((node) => [node.nodeId, node.connectedAtMs])); for (const [key, entry] of pages) if (!connectedNodes.has(entry.nodeId) || connectedNodes.get(entry.nodeId) !== entry.connection) pages.delete(key); const requested = query.hostIds ? new Set(query.hostIds) : void 0; const pending = nodes.filter((node) => isSessionHost(node) && (!requested || requested.has(`node:${node.nodeId}`))).toSorted((left, right) => nodeLabel(left).localeCompare(nodeLabel(right)) || left.nodeId.localeCompare(right.nodeId)).slice(0, 32).map(async (node) => { const host = await listNode(node, query, deadline); query.signal?.throwIfAborted(); if (!host.pending) query.onHost?.(host); return host; }); let hosts; try { hosts = await Promise.all(pending); } finally { await Promise.allSettled(pending); } query.signal?.throwIfAborted(); return hosts; }, async read(request) { if (!request.hostId.startsWith("node:") || !request.hostId.slice(5)) throw new Error("Select a paired node host to read an OpenClaw session"); const nodeId = request.hostId.slice(5); const node = (await api.runtime.nodes.list()).nodes.find((candidate) => candidate.nodeId === nodeId && candidate.connected && isSessionHost(candidate)); if (!node) throw new Error("OpenClaw session node is unavailable. Reconnect it and refresh the catalog."); const page = parseSessionShareTranscriptPage(await api.runtime.nodes.invoke({ nodeId, command: SESSION_SHARE_READ_COMMAND, timeoutMs: 3e4, scopes: ["operator.write"], params: { threadId: request.threadId, limit: sessionCatalogPaging.boundedLimit(request.limit), ...request.cursor !== void 0 ? { cursor: request.cursor } : {} } }), request.threadId); const linkParticipant = bindingFor(nodeId).linkGitHubIdentities ? createSessionCatalogGitHubLinker().linkParticipant : void 0; return { ...page, hostId: request.hostId, label: nodeLabel(node), items: page.items.map((item) => { if (!item.sender) return item; const sender = { ...item.sender, identity: namespaceIdentity(item.sender.identity, request.hostId) }; return Object.assign({}, item, { sender: linkParticipant?.(sender) ?? sender }); }) }; } }; } //#endregion //#region extensions/session-share/index.ts var session_share_default = definePluginEntry({ id: "session-share", name: "Session Share", description: "Read-only OpenClaw sessions on paired gateways", register(api) { api.registerSessionCatalog(createSessionShareCatalog(api)); for (const command of createSessionShareNodeCommands(api)) api.registerNodeHostCommand(command); api.registerNodeInvokePolicy({ commands: SESSION_SHARE_COMMANDS, defaultPlatforms: [ "macos", "linux", "windows" ], handle: (context) => context.invokeNode() }); } }); //#endregion export { session_share_default as default };