import { accessSync, constants, statSync } from "node:fs"; import path from "node:path"; import process from "node:process"; import { resolveAcpSessionAvailability } from "openclaw/plugin-sdk/acp-runtime"; import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { resolveNodeHostExecutable } from "openclaw/plugin-sdk/node-host"; import type { OpenClawPluginApi, OpenClawPluginNodeHostCommand, OpenClawPluginNodeInvokePolicy, } from "openclaw/plugin-sdk/plugin-entry"; import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime"; import type { SessionCatalogHost, SessionCatalogProvider, SessionCatalogSession, SessionCatalogTranscriptItem, SessionsCatalogReadResult, } from "openclaw/plugin-sdk/session-catalog"; import { createSessionCatalogAdoptionCoordinator, importSessionCatalogHistory, listAdoptedSessionCatalogSessions, sessionCatalogAdoptedSessionKey, sessionCatalogAdoptedSourceKey, } from "openclaw/plugin-sdk/session-catalog"; import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { OPENCODE_LOCAL_SESSION_HOST_ID as LOCAL_HOST_ID, OPENCODE_NODE_INVOKE_TIMEOUT_MS as NODE_TIMEOUT_MS, OPENCODE_SESSIONS_CAPABILITY as CAPABILITY, OPENCODE_SESSIONS_LIST_COMMAND, OPENCODE_SESSION_CATALOG_MAX_PAGE_LIMIT as MAX_PAGE_LIMIT, OPENCODE_SESSION_ID_PATTERN as SESSION_ID_PATTERN, OPENCODE_SESSION_READ_COMMAND, OPENCODE_TERMINAL_RESUME_COMMAND, OpenCodeCatalogParamsError, } from "./session-catalog-shared.js"; import { createOpenCodeTerminalNodeHostCommand, openOpenCodeCatalogTerminal, } from "./session-catalog-terminal.js"; import { isExactOpenCodeSessionCursor, listLocalOpenCodeSessionPage, readLocalOpenCodeTranscriptPage, type OpenCodeSessionPage, } from "./session-catalog.js"; import { checkOpenCodeUpstreamActivity, linkContinuedOpenCodeSession, } from "./session-upstream-activity.js"; const MAX_HOSTS = 100; const TRANSCRIPT_ITEM_TYPES = new Set([ "userMessage", "agentMessage", "reasoning", "toolCall", "toolResult", "other", ]); const ACPX_BACKEND_ID = "acpx"; const OPENCODE_ACP_AGENT_ID = "opencode"; const OPENCODE_ADOPTED_SESSION_KEY_PREFIX = "plugin:opencode:catalog-adopt:"; const continueAdoption = createSessionCatalogAdoptionCoordinator< Awaited> >(); function isOptionalString(value: unknown): boolean { return value === undefined || typeof value === "string"; } function isOptionalNumber(value: unknown): boolean { return value === undefined || typeof value === "number"; } function isNodeSession(value: unknown): value is SessionCatalogSession { return ( isRecord(value) && typeof value.threadId === "string" && SESSION_ID_PATTERN.test(value.threadId) && typeof value.status === "string" && value.status.length > 0 && typeof value.archived === "boolean" && typeof value.canContinue === "boolean" && typeof value.canArchive === "boolean" && isOptionalString(value.name) && isOptionalString(value.cwd) && isOptionalString(value.source) && isOptionalString(value.modelProvider) && isOptionalString(value.cliVersion) && isOptionalString(value.gitBranch) && isOptionalString(value.sessionKey) && isOptionalNumber(value.createdAt) && isOptionalNumber(value.updatedAt) && isOptionalNumber(value.recencyAt) ); } function isNodeTranscriptItem(value: unknown): value is SessionCatalogTranscriptItem { return ( isRecord(value) && typeof value.type === "string" && TRANSCRIPT_ITEM_TYPES.has(value.type) && isOptionalString(value.id) && isOptionalString(value.text) && isOptionalString(value.timestamp) && isOptionalString(value.model) && (value.truncated === undefined || typeof value.truncated === "boolean") ); } function executableOnPath(command: string, env: NodeJS.ProcessEnv): boolean { const pathValue = env.PATH ?? env.Path ?? ""; const delimiter = process.platform === "win32" ? ";" : path.delimiter; const extensions = process.platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";") : [""]; for (const directory of pathValue.split(delimiter)) { for (const extension of extensions) { if (!directory.trim()) { continue; } const candidate = path.join(directory, `${command}${extension}`); try { if (!statSync(candidate).isFile()) { continue; } if (process.platform !== "win32") { accessSync(candidate, constants.X_OK); } return true; } catch { // Keep searching PATH. } } } return false; } function parseNodeParams(paramsJSON?: string | null): unknown { if (!paramsJSON) { return undefined; } try { return JSON.parse(paramsJSON) as unknown; } catch (error) { throw new Error("OpenCode session parameters must be valid JSON", { cause: error }); } } function fullConfigCatalogEnabled(config: unknown): boolean { if (!isRecord(config) || !isRecord(config.plugins) || !isRecord(config.plugins.entries)) { return true; } const entry = config.plugins.entries.opencode; if (!isRecord(entry) || !isRecord(entry.config) || !isRecord(entry.config.sessionCatalog)) { return true; } return entry.config.sessionCatalog.enabled !== false; } function isOpenCodeSessionCatalogEnabled(pluginConfig: unknown): boolean { return ( !isRecord(pluginConfig) || !isRecord(pluginConfig.sessionCatalog) || pluginConfig.sessionCatalog.enabled !== false ); } function createOpenCodeSessionNodeHostCommands( api: OpenClawPluginApi, ): OpenClawPluginNodeHostCommand[] { const available = ({ config, env }: { config: unknown; env: NodeJS.ProcessEnv }) => fullConfigCatalogEnabled(config) && executableOnPath("opencode", env); return [ { command: OPENCODE_SESSIONS_LIST_COMMAND, cap: CAPABILITY, dangerous: false, isAvailable: available, handle: async (paramsJSON) => JSON.stringify( await listLocalOpenCodeSessionPage(parseNodeParams(paramsJSON), { configIdentity: currentOpenCodeCatalogConfig(api), }), ), }, { command: OPENCODE_SESSION_READ_COMMAND, cap: CAPABILITY, dangerous: false, isAvailable: available, handle: async (paramsJSON) => JSON.stringify(await readLocalOpenCodeTranscriptPage(parseNodeParams(paramsJSON))), }, createOpenCodeTerminalNodeHostCommand(available), ]; } function createOpenCodeSessionNodeInvokePolicies(): OpenClawPluginNodeInvokePolicy[] { return [ { commands: [ OPENCODE_SESSIONS_LIST_COMMAND, OPENCODE_SESSION_READ_COMMAND, OPENCODE_TERMINAL_RESUME_COMMAND, ], defaultPlatforms: ["macos", "linux", "windows"], handle: (context) => context.command === OPENCODE_TERMINAL_RESUME_COMMAND ? { ok: true } : context.invokeNode(), }, ]; } function nodeLabel(node: { displayName?: string; remoteIp?: string; nodeId: string }): string { return node.displayName?.trim() || node.remoteIp?.trim() || node.nodeId; } function unwrapNodePayload(value: unknown): unknown { return isRecord(value) && typeof value.payloadJSON === "string" ? (JSON.parse(value.payloadJSON) as unknown) : value; } type CatalogNode = Awaited>["nodes"][number]; function setCatalogCapabilities( page: OpenCodeSessionPage, capabilities: { canContinue: boolean; canOpenTerminal: boolean }, ): OpenCodeSessionPage { for (const session of page.sessions) { session.canContinue = capabilities.canContinue; session.canOpenTerminal = capabilities.canOpenTerminal; } return page; } async function listOpenCodeNodeHost( runtime: PluginRuntime, query: Parameters[0], node: CatalogNode, ): Promise { const hostId = `node:${node.nodeId}`; const common = { hostId, label: nodeLabel(node), kind: "node" as const, connected: node.connected === true, nodeId: node.nodeId, }; if (node.connected !== true) { return { ...common, sessions: [], error: { code: "NODE_OFFLINE", message: "Paired node is offline" }, }; } try { const cursor = query.cursors?.[hostId]; if (cursor !== undefined && !isExactOpenCodeSessionCursor(cursor)) { throw new Error("cursor is invalid"); } const raw = await runtime.nodes.invoke({ nodeId: node.nodeId, command: OPENCODE_SESSIONS_LIST_COMMAND, params: { ...(query.limitPerHost ? { limit: query.limitPerHost } : {}), ...(query.search ? { searchTerm: query.search } : {}), ...(cursor !== undefined ? { cursor } : {}), }, timeoutMs: NODE_TIMEOUT_MS, scopes: ["operator.write"], }); const page = parseNodeSessionPage(unwrapNodePayload(raw)); const commands = node.invocableCommands ?? node.commands; const canOpenTerminal = commands?.includes(OPENCODE_TERMINAL_RESUME_COMMAND) === true; return { ...common, ...setCatalogCapabilities(page, { canContinue: false, canOpenTerminal }), }; } catch { return { ...common, sessions: [], error: { code: "NODE_INVOKE_FAILED", message: "Paired node OpenCode sessions are unavailable", }, }; } } function parseNodeSessionPage(value: unknown): OpenCodeSessionPage { if ( !isRecord(value) || !Array.isArray(value.sessions) || value.sessions.length > MAX_PAGE_LIMIT ) { throw new Error("OpenCode node returned an invalid session page"); } if (!value.sessions.every(isNodeSession)) { throw new Error("OpenCode node returned an invalid session page"); } const sessions = value.sessions; const nextCursor = value.nextCursor; if (nextCursor !== undefined && !isExactOpenCodeSessionCursor(nextCursor)) { throw new Error("OpenCode node returned an invalid cursor"); } return { sessions, ...(nextCursor !== undefined ? { nextCursor } : {}) }; } function parseNodeTranscriptPage(value: unknown, threadId: string): SessionsCatalogReadResult { if ( !isRecord(value) || value.threadId !== threadId || !Array.isArray(value.items) || value.items.length > MAX_PAGE_LIMIT || !value.items.every(isNodeTranscriptItem) ) { throw new Error("OpenCode node returned an invalid transcript page"); } const nextCursor = value.nextCursor; if (nextCursor !== undefined && !isExactOpenCodeSessionCursor(nextCursor)) { throw new Error("OpenCode node returned an invalid cursor"); } return { hostId: LOCAL_HOST_ID, threadId, items: value.items, ...(nextCursor !== undefined ? { nextCursor } : {}), }; } async function listOpenCodeHosts( api: OpenClawPluginApi, query: Parameters[0], ): Promise { const runtime = api.runtime; const config = currentOpenCodeCatalogConfig(api); const canContinue = resolveAcpSessionAvailability({ config, backendId: ACPX_BACKEND_ID, agentId: OPENCODE_ACP_AGENT_ID, }).available; const requested = query.hostIds ? new Set(query.hostIds) : undefined; const hosts: SessionCatalogHost[] = []; if ( (!requested || requested.has(LOCAL_HOST_ID)) && resolveNodeHostExecutable("opencode", { env: process.env, pathEnv: process.env.PATH ?? "", strategy: "fallback", }) ) { try { hosts.push({ hostId: LOCAL_HOST_ID, label: "Local OpenCode", kind: "gateway", connected: true, ...(await listLocalOpenCodeSessionPage( { limit: query.limitPerHost, ...(query.search ? { searchTerm: query.search } : {}), cursor: query.cursors?.[LOCAL_HOST_ID], }, { configIdentity: config }, ).then((page) => setCatalogCapabilities(page, { canContinue, canOpenTerminal: true }))), }); } catch { hosts.push({ hostId: LOCAL_HOST_ID, label: "Local OpenCode", kind: "gateway", connected: true, sessions: [], error: { code: "LOCAL_READ_FAILED", message: "Local OpenCode sessions are unavailable" }, }); } } let nodes: Awaited>["nodes"]; try { nodes = (await (query.listNodes?.() ?? runtime.nodes.list())).nodes; } catch { return hosts; } const eligible = nodes .filter( (node) => node.commands?.includes(OPENCODE_SESSIONS_LIST_COMMAND) && (!requested || requested.has(`node:${node.nodeId}`)), ) .toSorted((left, right) => nodeLabel(left).localeCompare(nodeLabel(right))) .slice(0, MAX_HOSTS - hosts.length); const nodeHosts = await Promise.all( eligible.map((node) => listOpenCodeNodeHost(runtime, query, node)), ); return [...hosts, ...nodeHosts]; } async function readOpenCodeTranscript( runtime: PluginRuntime, request: Parameters[0], ): Promise { const cursor = request.cursor; if (cursor !== undefined && !isExactOpenCodeSessionCursor(cursor)) { throw new Error("cursor is invalid"); } if (request.hostId === LOCAL_HOST_ID) { return await readLocalOpenCodeTranscriptPage({ threadId: request.threadId, ...(request.limit ? { limit: request.limit } : {}), ...(cursor !== undefined ? { cursor } : {}), }); } if (!request.hostId.startsWith("node:")) { throw new Error("hostId is invalid"); } const nodeId = request.hostId.slice("node:".length); const node = (await runtime.nodes.list()).nodes.find( (candidate) => candidate.nodeId === nodeId && candidate.connected === true && candidate.commands?.includes(OPENCODE_SESSION_READ_COMMAND), ); if (!node) { throw new Error("paired-node OpenCode session host is unavailable"); } const raw = await runtime.nodes.invoke({ nodeId, command: OPENCODE_SESSION_READ_COMMAND, params: { threadId: request.threadId, ...(request.limit ? { limit: request.limit } : {}), ...(cursor !== undefined ? { cursor } : {}), }, timeoutMs: NODE_TIMEOUT_MS, scopes: ["operator.write"], }); return { ...parseNodeTranscriptPage(unwrapNodePayload(raw), request.threadId), hostId: request.hostId, label: nodeLabel(node), }; } function currentOpenCodeCatalogConfig(api: OpenClawPluginApi): OpenClawConfig { return (api.runtime.config?.current?.() ?? api.config ?? {}) as OpenClawConfig; } function listAdoptedOpenCodeSessions(api: OpenClawPluginApi): Map { return listAdoptedSessionCatalogSessions({ config: currentOpenCodeCatalogConfig(api), pluginId: api.id, runtime: api.runtime, sourceFromEntry: (entry) => { const opencode = isRecord(entry.pluginExtensions?.opencode) ? entry.pluginExtensions.opencode : undefined; const marker = opencode && isRecord(opencode.sessionCatalog) ? opencode.sessionCatalog : undefined; return marker && typeof marker.sourceThreadId === "string" ? { hostId: LOCAL_HOST_ID, threadId: marker.sourceThreadId } : undefined; }, }); } async function continueOpenCodeSession( api: OpenClawPluginApi, hostId: string, threadId: string, ): Promise>> { if (hostId.startsWith("node:")) { throw new OpenCodeCatalogParamsError("paired-node OpenCode session rows are view-only"); } if (hostId !== LOCAL_HOST_ID) { throw new OpenCodeCatalogParamsError("OpenCode session catalog hostId is invalid"); } const availability = resolveAcpSessionAvailability({ config: currentOpenCodeCatalogConfig(api), backendId: ACPX_BACKEND_ID, agentId: OPENCODE_ACP_AGENT_ID, }); if (!availability.available) { throw new OpenCodeCatalogParamsError(availability.message); } const sourceKey = sessionCatalogAdoptedSourceKey(hostId, threadId); return await continueAdoption({ sourceKey, findExisting: () => listAdoptedOpenCodeSessions(api).get(sourceKey), create: async () => { const config = currentOpenCodeCatalogConfig(api); const page = await listLocalOpenCodeSessionPage( { searchTerm: threadId, limit: MAX_PAGE_LIMIT, }, { configIdentity: config, forceRefresh: true }, ).catch(() => undefined); const record = page?.sessions.find((session) => session.threadId === threadId); if (!record) { throw new OpenCodeCatalogParamsError("OpenCode session is unavailable"); } const currentAvailability = resolveAcpSessionAvailability({ config, backendId: ACPX_BACKEND_ID, agentId: OPENCODE_ACP_AGENT_ID, }); if (!currentAvailability.available) { throw new OpenCodeCatalogParamsError(currentAvailability.message); } const marker = { sourceThreadId: threadId }; const created = await api.runtime.agent.session.createSessionEntry({ cfg: config, key: sessionCatalogAdoptedSessionKey(OPENCODE_ADOPTED_SESSION_KEY_PREFIX, threadId), agentId: resolveDefaultAgentId(config), recoverMatchingInitialEntry: true, ...(record.name ? { label: record.name } : {}), ...(record.cwd ? { spawnedCwd: record.cwd } : {}), initialEntry: { acpBackendId: ACPX_BACKEND_ID, acpSessionBinding: { acpAgentId: OPENCODE_ACP_AGENT_ID, agentSessionId: threadId, }, pluginExtensions: { opencode: { sessionCatalog: marker } }, }, afterCreate: async (entry) => { await importSessionCatalogHistory({ catalogId: "opencode", threadId, read: async ({ cursor, limit }) => await readOpenCodeTranscript(api.runtime, { hostId, threadId, limit, ...(cursor ? { cursor } : {}), }), sessionId: entry.sessionId, sessionKey: entry.key, agentId: entry.agentId, ...(record.cwd ? { cwd: record.cwd } : {}), config, }); return { pluginExtensions: { opencode: { sessionCatalog: marker } } }; }, }); return { sessionKey: created.key }; }, complete: async (continued) => await linkContinuedOpenCodeSession(continued.sessionKey, threadId), }); } export function registerOpenCodeSessionCatalog(api: OpenClawPluginApi): void { if (!isOpenCodeSessionCatalogEnabled(api.pluginConfig)) { return; } api.registerSessionCatalog({ id: "opencode", label: "OpenCode", list: async (query) => await listOpenCodeHosts(api, query), read: async (request) => await readOpenCodeTranscript(api.runtime, request), continueSession: async (request) => await continueOpenCodeSession(api, request.hostId, request.threadId), checkUpstreamActivity: checkOpenCodeUpstreamActivity, openTerminal: async (request) => await openOpenCodeCatalogTerminal({ runtime: api.runtime, ...request, parseNodeSessionPage, unwrapNodePayload, }), }); for (const command of createOpenCodeSessionNodeHostCommands(api)) { api.registerNodeHostCommand(command); } for (const policy of createOpenCodeSessionNodeInvokePolicies()) { api.registerNodeInvokePolicy(policy); } }