mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 17:53:39 +00:00
* feat(node): update headless runtimes automatically when idle Stage private packaged node runtimes and activate only after all owned work and cleanup settle. Preserve pairing and launch options, keep automatic activations at least 12 hours apart, and leave shared-state migration and Gateway installation ownership with the normal updater. Document defaults, opt-outs, runtime version discovery, and recovery. Support recoverable Windows selector replacement across service restarts. Refs #151462 * fix(node): complete auto-update integration and settings defaults Capture the launcher in compiled test runtimes and trusted wrapper inventories, register the package update proof, and use the canonical Kysely read path. Preserve the inherited enabled state in the config UI and align caller tests with the node shutdown lifecycle. * fix(node): preserve retained plugin work during automatic updates Require an explicit idle result from plugin commands, preserve older-plugin work, and connect bundled lifetime owners to automatic node updates. Join canceled inference and failed terminal cleanup before command settlement; keep historical state repair with Doctor. Document compatibility and extend installed-package proof for retained legacy plugin work. * test(browser): align idle-work fixtures with runtime exports * test(browser): extract proxy request fixtures * test(node): retain idle assertions across native cleanup
355 lines
13 KiB
TypeScript
355 lines
13 KiB
TypeScript
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 { resolveSessionAgentIdsStrict } from "openclaw/plugin-sdk/agent-scope-runtime";
|
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
|
import { resolveNodeHostExecutable } from "openclaw/plugin-sdk/node-host";
|
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
|
import {
|
|
createSessionCatalogFamily,
|
|
createSessionCatalogNodeHostBindings,
|
|
importSessionCatalogHistory,
|
|
listAdoptedSessionCatalogSessions,
|
|
sessionCatalogAdoptedSessionKey,
|
|
type SessionCatalogEntrySnapshot,
|
|
type SessionCatalogSession,
|
|
} 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,
|
|
} from "./session-catalog-shared.js";
|
|
import {
|
|
isExactOpenCodeSessionCursor,
|
|
listLocalOpenCodeSessionPage,
|
|
readLocalOpenCodeTranscriptPage,
|
|
requireLocalOpenCodeSession,
|
|
} from "./session-catalog.js";
|
|
import {
|
|
checkOpenCodeUpstreamActivity,
|
|
linkContinuedOpenCodeSession,
|
|
} from "./session-upstream-activity.js";
|
|
|
|
const MAX_HOSTS = 100;
|
|
const ACPX_BACKEND_ID = "acpx";
|
|
const OPENCODE_ACP_AGENT_ID = "opencode";
|
|
const OPENCODE_ADOPTED_SESSION_KEY_PREFIX = "plugin:opencode:catalog-adopt:";
|
|
|
|
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 openCodeUsesProcessHomeFallback(env: NodeJS.ProcessEnv): boolean {
|
|
return !env.OPENCODE_DB?.trim() && !path.isAbsolute(env.XDG_DATA_HOME?.trim() ?? "");
|
|
}
|
|
|
|
function assertOpenCodeLocalAccess(hostId: string, allowProcessHomeFallback?: boolean): void {
|
|
if (
|
|
hostId === LOCAL_HOST_ID &&
|
|
allowProcessHomeFallback === false &&
|
|
openCodeUsesProcessHomeFallback(process.env)
|
|
) {
|
|
throw new Error("local OpenCode sessions are unavailable in isolated state");
|
|
}
|
|
}
|
|
|
|
function currentOpenCodeCatalogConfig(api: OpenClawPluginApi): OpenClawConfig {
|
|
return (api.runtime.config?.current?.() ?? api.config ?? {}) as OpenClawConfig;
|
|
}
|
|
|
|
function listAdoptedOpenCodeSessions(
|
|
api: OpenClawPluginApi,
|
|
agentId?: string,
|
|
sessionEntries?: SessionCatalogEntrySnapshot,
|
|
): Map<string, string> {
|
|
return listAdoptedSessionCatalogSessions({
|
|
...(agentId ? { agentId } : {}),
|
|
config: currentOpenCodeCatalogConfig(api),
|
|
pluginId: api.id,
|
|
runtime: api.runtime,
|
|
sessionEntries,
|
|
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 loadContinuableOpenCodeSession(
|
|
api: OpenClawPluginApi,
|
|
threadId: string,
|
|
): Promise<SessionCatalogSession> {
|
|
const page = await listLocalOpenCodeSessionPage(
|
|
{ searchTerm: threadId, limit: MAX_PAGE_LIMIT },
|
|
{ configIdentity: currentOpenCodeCatalogConfig(api), forceRefresh: true },
|
|
).catch(() => undefined);
|
|
const session = page?.sessions.find((candidate) => candidate.threadId === threadId);
|
|
if (!session) {
|
|
throw new Error("OpenCode session is unavailable");
|
|
}
|
|
return session;
|
|
}
|
|
|
|
async function createAdoptedOpenCodeSession(params: {
|
|
api: OpenClawPluginApi;
|
|
agentId: string;
|
|
threadId: string;
|
|
session: SessionCatalogSession;
|
|
}): Promise<{ sessionKey: string }> {
|
|
const config = currentOpenCodeCatalogConfig(params.api);
|
|
const marker = { sourceThreadId: params.threadId };
|
|
const created = await params.api.runtime.agent.session.createSessionEntry({
|
|
cfg: config,
|
|
key: sessionCatalogAdoptedSessionKey(OPENCODE_ADOPTED_SESSION_KEY_PREFIX, params.threadId),
|
|
agentId: params.agentId,
|
|
recoverMatchingInitialEntry: true,
|
|
...(params.session.name ? { displayName: params.session.name } : {}),
|
|
...(params.session.cwd ? { spawnedCwd: params.session.cwd } : {}),
|
|
initialEntry: {
|
|
acpBackendId: ACPX_BACKEND_ID,
|
|
acpSessionBinding: {
|
|
acpAgentId: OPENCODE_ACP_AGENT_ID,
|
|
agentSessionId: params.threadId,
|
|
},
|
|
pluginExtensions: { opencode: { sessionCatalog: marker } },
|
|
},
|
|
afterCreate: async (entry) => {
|
|
await importSessionCatalogHistory({
|
|
catalogId: "opencode",
|
|
threadId: params.threadId,
|
|
read: async ({ cursor, limit }) =>
|
|
await readLocalOpenCodeTranscriptPage({
|
|
threadId: params.threadId,
|
|
limit,
|
|
...(cursor ? { cursor } : {}),
|
|
}),
|
|
sessionId: entry.sessionId,
|
|
sessionKey: entry.key,
|
|
agentId: entry.agentId,
|
|
...(params.session.cwd ? { cwd: params.session.cwd } : {}),
|
|
config,
|
|
});
|
|
return { pluginExtensions: { opencode: { sessionCatalog: marker } } };
|
|
},
|
|
});
|
|
return { sessionKey: created.key };
|
|
}
|
|
|
|
function createOpenCodeNodeHostBindings(api: OpenClawPluginApi) {
|
|
const available = ({ config, env }: { config: unknown; env: NodeJS.ProcessEnv }) =>
|
|
fullConfigCatalogEnabled(config) && executableOnPath("opencode", env);
|
|
return createSessionCatalogNodeHostBindings({
|
|
capability: CAPABILITY,
|
|
listCommand: OPENCODE_SESSIONS_LIST_COMMAND,
|
|
readCommand: OPENCODE_SESSION_READ_COMMAND,
|
|
terminalCommand: OPENCODE_TERMINAL_RESUME_COMMAND,
|
|
sessionIdPattern: SESSION_ID_PATTERN,
|
|
executable: "opencode",
|
|
hasActiveWork: () => false,
|
|
args: (threadId) => ["--session", threadId],
|
|
listAvailable: available,
|
|
terminalAvailable: available,
|
|
parseParams: parseNodeParams,
|
|
list: async (params) =>
|
|
await listLocalOpenCodeSessionPage(params, {
|
|
configIdentity: currentOpenCodeCatalogConfig(api),
|
|
}),
|
|
read: readLocalOpenCodeTranscriptPage,
|
|
requireSession: requireLocalOpenCodeSession,
|
|
terminalIoRequiredMessage: "OpenCode terminal command requires duplex transport",
|
|
terminalUnavailableMessage: "OpenCode CLI is unavailable",
|
|
invalidThreadIdMessage: "INVALID_REQUEST: threadId is invalid",
|
|
});
|
|
}
|
|
|
|
export function registerOpenCodeSessionCatalog(api: OpenClawPluginApi): void {
|
|
if (!isOpenCodeSessionCatalogEnabled(api.pluginConfig)) {
|
|
return;
|
|
}
|
|
const provider = createSessionCatalogFamily(
|
|
{
|
|
runtime: api.runtime,
|
|
local: {
|
|
hostId: LOCAL_HOST_ID,
|
|
label: "Local OpenCode",
|
|
available: (query) =>
|
|
(query.allowProcessHomeFallback !== false ||
|
|
!openCodeUsesProcessHomeFallback(process.env)) &&
|
|
resolveNodeHostExecutable("opencode", {
|
|
env: process.env,
|
|
pathEnv: process.env.PATH ?? "",
|
|
strategy: "fallback",
|
|
}) !== undefined,
|
|
list: async (query) =>
|
|
await listLocalOpenCodeSessionPage(
|
|
{
|
|
limit: query.limitPerHost,
|
|
...(query.search ? { searchTerm: query.search } : {}),
|
|
cursor: query.cursors?.[LOCAL_HOST_ID],
|
|
},
|
|
{ configIdentity: currentOpenCodeCatalogConfig(api) },
|
|
),
|
|
read: async (request) =>
|
|
await readLocalOpenCodeTranscriptPage({
|
|
threadId: request.threadId,
|
|
...(request.limit ? { limit: request.limit } : {}),
|
|
...(request.cursor !== undefined ? { cursor: request.cursor } : {}),
|
|
}),
|
|
assertAccess: assertOpenCodeLocalAccess,
|
|
},
|
|
node: {
|
|
listCommand: OPENCODE_SESSIONS_LIST_COMMAND,
|
|
readCommand: OPENCODE_SESSION_READ_COMMAND,
|
|
terminalCommand: OPENCODE_TERMINAL_RESUME_COMMAND,
|
|
timeoutMs: NODE_TIMEOUT_MS,
|
|
maxHosts: MAX_HOSTS,
|
|
maxPageLimit: MAX_PAGE_LIMIT,
|
|
sessionIdPattern: SESSION_ID_PATTERN,
|
|
},
|
|
capabilities: {
|
|
local: () => ({
|
|
canContinue: resolveAcpSessionAvailability({
|
|
config: currentOpenCodeCatalogConfig(api),
|
|
backendId: ACPX_BACKEND_ID,
|
|
agentId: OPENCODE_ACP_AGENT_ID,
|
|
}).available,
|
|
canOpenTerminal: true,
|
|
}),
|
|
node: (node) => {
|
|
const commands = node.invocableCommands ?? node.commands;
|
|
return {
|
|
canContinue: false,
|
|
canOpenTerminal: commands?.includes(OPENCODE_TERMINAL_RESUME_COMMAND) === true,
|
|
};
|
|
},
|
|
project: (session, capabilities) => ({ ...session, ...capabilities }),
|
|
},
|
|
messages: {
|
|
invalidNodeCursor: "OpenCode node returned an invalid cursor",
|
|
invalidNodeSessionPage: "OpenCode node returned an invalid session page",
|
|
invalidNodeTranscriptPage: "OpenCode node returned an invalid transcript page",
|
|
invalidHostId: "OpenCode session catalog hostId is invalid",
|
|
localReadFailed: "Local OpenCode sessions are unavailable",
|
|
nodeInvokeFailed: "Paired node OpenCode sessions are unavailable",
|
|
nodeReadUnavailable: "paired-node OpenCode session host is unavailable",
|
|
nodeTerminalUnavailable: "paired-node OpenCode terminal is unavailable",
|
|
sessionUnavailable: "OpenCode session is unavailable",
|
|
},
|
|
continuation: {
|
|
resolveAgentId: (agentId) =>
|
|
resolveSessionAgentIdsStrict({ config: api.config, agentId }).sessionAgentId,
|
|
availability: () =>
|
|
resolveAcpSessionAvailability({
|
|
config: currentOpenCodeCatalogConfig(api),
|
|
backendId: ACPX_BACKEND_ID,
|
|
agentId: OPENCODE_ACP_AGENT_ID,
|
|
}),
|
|
listAdopted: (agentId, sessionEntries) =>
|
|
listAdoptedOpenCodeSessions(api, agentId, sessionEntries),
|
|
loadSession: async (threadId) => await loadContinuableOpenCodeSession(api, threadId),
|
|
validateSession: () => undefined,
|
|
create: async (params) => await createAdoptedOpenCodeSession({ api, ...params }),
|
|
complete: async (continued, threadId) =>
|
|
await linkContinuedOpenCodeSession(continued.sessionKey, threadId),
|
|
nodeReadOnlyMessage: "paired-node OpenCode session rows are view-only",
|
|
},
|
|
terminal: {
|
|
executable: "opencode",
|
|
uploadPathStyle: "native",
|
|
args: (threadId) => ["--session", threadId],
|
|
title: (threadId) => `opencode --session ${threadId.slice(0, 12)}…`,
|
|
requireLocalSession: requireLocalOpenCodeSession,
|
|
unavailableMessage: "OpenCode CLI is unavailable",
|
|
},
|
|
checkUpstreamActivity: (probes, policy) =>
|
|
checkOpenCodeUpstreamActivity(
|
|
probes.filter(
|
|
(probe) =>
|
|
probe.hostId !== LOCAL_HOST_ID ||
|
|
policy?.allowProcessHomeFallback !== false ||
|
|
!openCodeUsesProcessHomeFallback(process.env),
|
|
),
|
|
),
|
|
},
|
|
isExactOpenCodeSessionCursor,
|
|
);
|
|
api.registerSessionCatalog({
|
|
id: "opencode",
|
|
label: "OpenCode",
|
|
supportsProcessHomeIsolation: true,
|
|
...provider,
|
|
});
|
|
const nodeHost = createOpenCodeNodeHostBindings(api);
|
|
for (const command of nodeHost.commands) {
|
|
api.registerNodeHostCommand(command);
|
|
}
|
|
for (const policy of nodeHost.policies) {
|
|
api.registerNodeInvokePolicy(policy);
|
|
}
|
|
}
|