refactor(acpx): embed ACP runtime in plugin

This commit is contained in:
Peter Steinberger
2026-04-05 12:36:40 +01:00
parent 1a537fcfcf
commit fb61986767
41 changed files with 7027 additions and 4255 deletions

View File

@@ -5,7 +5,7 @@ import { createAcpxPluginConfigSchema } from "./src/config-schema.js";
const plugin = {
id: "acpx",
name: "ACPX Runtime",
description: "ACP runtime backend powered by the acpx CLI.",
description: "Embedded ACP runtime backend with plugin-owned session and transport management.",
configSchema: () => createAcpxPluginConfigSchema(),
register(api: OpenClawPluginApi) {
api.registerService(

View File

@@ -2,24 +2,20 @@
"id": "acpx",
"enabledByDefault": true,
"name": "ACPX Runtime",
"description": "ACP runtime backend powered by acpx with configurable command path and version policy.",
"description": "Embedded ACP runtime backend with plugin-owned session and transport management.",
"skills": ["./skills"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"command": {
"type": "string",
"minLength": 1
},
"expectedVersion": {
"type": "string",
"minLength": 1
},
"cwd": {
"type": "string",
"minLength": 1
},
"stateDir": {
"type": "string",
"minLength": 1
},
"permissionMode": {
"type": "string",
"enum": ["approve-all", "approve-reads", "deny-all"]
@@ -65,33 +61,42 @@
},
"required": ["command"]
}
},
"agents": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"command": {
"type": "string",
"minLength": 1
}
},
"required": ["command"]
}
}
}
},
"uiHints": {
"command": {
"label": "acpx Command",
"help": "Optional path/command override for acpx (for example /home/user/repos/acpx/dist/cli.js). Leave unset to use plugin-local bundled acpx."
},
"expectedVersion": {
"label": "Expected acpx Version",
"help": "Exact version to enforce or \"any\" to skip strict version matching."
},
"cwd": {
"label": "Default Working Directory",
"help": "Default cwd for ACP session operations when not set per session."
"help": "Default working directory for embedded ACP session operations when not set per session."
},
"stateDir": {
"label": "State Directory",
"help": "Directory used for embedded ACP session state and persistence."
},
"permissionMode": {
"label": "Permission Mode",
"help": "Default acpx permission policy for runtime prompts."
"help": "Default permission policy for embedded ACP runtime prompts."
},
"nonInteractivePermissions": {
"label": "Non-Interactive Permission Policy",
"help": "acpx policy when interactive permission prompts are unavailable."
"help": "Policy when interactive permission prompts are unavailable."
},
"pluginToolsMcpBridge": {
"label": "Plugin Tools MCP Bridge",
"help": "Default off. When enabled, inject the built-in OpenClaw plugin-tools MCP server into ACPX sessions so ACP agents can call plugin-registered tools.",
"help": "Default off. When enabled, inject the built-in OpenClaw plugin-tools MCP server into embedded ACP sessions so ACP agents can call plugin-registered tools.",
"advanced": true
},
"strictWindowsCmdWrapper": {
@@ -101,17 +106,22 @@
},
"timeoutSeconds": {
"label": "Prompt Timeout Seconds",
"help": "Optional acpx timeout for each runtime turn.",
"help": "Optional timeout for each embedded runtime turn.",
"advanced": true
},
"queueOwnerTtlSeconds": {
"label": "Queue Owner TTL Seconds",
"help": "Idle queue-owner TTL for acpx prompt turns. Keep this short in OpenClaw to avoid delayed completion after each turn.",
"help": "Reserved compatibility field for future queued embedded prompt ownership.",
"advanced": true
},
"mcpServers": {
"label": "MCP Servers",
"help": "Named MCP server definitions to inject into ACPX-backed session bootstrap. Each entry needs a command and can include args and env.",
"help": "Named MCP server definitions to inject into embedded ACP session bootstrap. Each entry needs a command and can include args and env.",
"advanced": true
},
"agents": {
"label": "Agent Commands",
"help": "Optional per-agent command overrides for the embedded ACP runtime.",
"advanced": true
}
}

View File

@@ -1,10 +1,10 @@
{
"name": "@openclaw/acpx",
"version": "2026.4.4",
"description": "OpenClaw ACP runtime backend via acpx",
"description": "OpenClaw ACP runtime backend",
"type": "module",
"dependencies": {
"acpx": "0.4.1"
"@agentclientprotocol/sdk": "^0.9.4"
},
"openclaw": {
"extensions": [

View File

@@ -1,6 +1,7 @@
export type { AcpRuntimeErrorCode } from "openclaw/plugin-sdk/acp-runtime";
export {
AcpRuntimeError,
getAcpRuntimeBackend,
registerAcpRuntimeBackend,
unregisterAcpRuntimeBackend,
} from "openclaw/plugin-sdk/acp-runtime";
@@ -12,6 +13,7 @@ export type {
AcpRuntimeEvent,
AcpRuntimeHandle,
AcpRuntimeStatus,
AcpRuntimeTurnAttachment,
AcpRuntimeTurnInput,
AcpSessionUpdateTag,
} from "openclaw/plugin-sdk/acp-runtime";

View File

@@ -0,0 +1,153 @@
import type { OutputErrorAcpPayload } from "./runtime-types.js";
const RESOURCE_NOT_FOUND_ACP_CODES = new Set([-32001, -32002]);
function asRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
return value as Record<string, unknown>;
}
function toAcpErrorPayload(value: unknown): OutputErrorAcpPayload | undefined {
const record = asRecord(value);
if (!record) {
return undefined;
}
if (typeof record.code !== "number" || !Number.isFinite(record.code)) {
return undefined;
}
if (typeof record.message !== "string" || record.message.length === 0) {
return undefined;
}
return {
code: record.code,
message: record.message,
data: record.data,
};
}
function extractAcpErrorInternal(value: unknown, depth: number): OutputErrorAcpPayload | undefined {
if (depth > 5) {
return undefined;
}
const direct = toAcpErrorPayload(value);
if (direct) {
return direct;
}
const record = asRecord(value);
if (!record) {
return undefined;
}
if ("error" in record) {
const nested = extractAcpErrorInternal(record.error, depth + 1);
if (nested) {
return nested;
}
}
if ("acp" in record) {
const nested = extractAcpErrorInternal(record.acp, depth + 1);
if (nested) {
return nested;
}
}
if ("cause" in record) {
const nested = extractAcpErrorInternal(record.cause, depth + 1);
if (nested) {
return nested;
}
}
return undefined;
}
export function formatUnknownErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (error && typeof error === "object") {
const maybeMessage = (error as { message?: unknown }).message;
if (typeof maybeMessage === "string" && maybeMessage.length > 0) {
return maybeMessage;
}
try {
return JSON.stringify(error);
} catch {
// fall through
}
}
return String(error);
}
// Matches "session" followed by optional ID (quoted or unquoted) followed by "not found"
// Examples: "Session \"abc\" not found", "Session abc-123 not found"
const SESSION_NOT_FOUND_PATTERN = /session\s+["'\w-]+\s+not found/i;
function isSessionNotFoundText(value: unknown): boolean {
if (typeof value !== "string") {
return false;
}
const normalized = value.toLowerCase();
return (
normalized.includes("resource_not_found") ||
normalized.includes("resource not found") ||
normalized.includes("session not found") ||
normalized.includes("unknown session") ||
normalized.includes("invalid session identifier") ||
SESSION_NOT_FOUND_PATTERN.test(value)
);
}
function hasSessionNotFoundHint(value: unknown, depth = 0): boolean {
if (depth > 4) {
return false;
}
if (isSessionNotFoundText(value)) {
return true;
}
if (Array.isArray(value)) {
return value.some((entry) => hasSessionNotFoundHint(entry, depth + 1));
}
const record = asRecord(value);
if (!record) {
return false;
}
return Object.values(record).some((entry) => hasSessionNotFoundHint(entry, depth + 1));
}
export function extractAcpError(error: unknown): OutputErrorAcpPayload | undefined {
return extractAcpErrorInternal(error, 0);
}
export function isAcpResourceNotFoundError(error: unknown): boolean {
const acp = extractAcpError(error);
if (acp && RESOURCE_NOT_FOUND_ACP_CODES.has(acp.code)) {
return true;
}
if (acp) {
if (isSessionNotFoundText(acp.message)) {
return true;
}
if (hasSessionNotFoundHint(acp.data)) {
return true;
}
}
return isSessionNotFoundText(formatUnknownErrorMessage(error));
}

View File

@@ -0,0 +1,137 @@
import type { AnyMessage, SessionNotification } from "@agentclientprotocol/sdk";
type JsonRpcId = string | number | null;
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function hasValidId(value: unknown): value is JsonRpcId {
return (
value === null ||
typeof value === "string" ||
(typeof value === "number" && Number.isFinite(value))
);
}
function isErrorObject(value: unknown): value is { code: number; message: string } {
const record = asRecord(value);
return (
!!record &&
typeof record.code === "number" &&
Number.isFinite(record.code) &&
typeof record.message === "string"
);
}
function hasResultOrError(value: Record<string, unknown>): boolean {
const hasResult = Object.hasOwn(value, "result");
const hasError = Object.hasOwn(value, "error");
if (hasResult && hasError) {
return false;
}
if (!hasResult && !hasError) {
return false;
}
if (hasError && !isErrorObject(value.error)) {
return false;
}
return true;
}
export function isAcpJsonRpcMessage(value: unknown): value is AnyMessage {
const record = asRecord(value);
if (!record || record.jsonrpc !== "2.0") {
return false;
}
const hasMethod = typeof record.method === "string" && record.method.length > 0;
const hasId = Object.hasOwn(record, "id");
if (hasMethod && !hasId) {
// Notification
return true;
}
if (hasMethod && hasId) {
// Request
return hasValidId(record.id);
}
if (!hasMethod && hasId) {
// Response
if (!hasValidId(record.id)) {
return false;
}
return hasResultOrError(record);
}
return false;
}
export function isJsonRpcNotification(message: AnyMessage): boolean {
return (
Object.hasOwn(message, "method") &&
typeof (message as { method?: unknown }).method === "string" &&
!Object.hasOwn(message, "id")
);
}
export function isSessionUpdateNotification(message: AnyMessage): boolean {
return (
isJsonRpcNotification(message) && (message as { method?: unknown }).method === "session/update"
);
}
export function extractSessionUpdateNotification(
message: AnyMessage,
): SessionNotification | undefined {
if (!isSessionUpdateNotification(message)) {
return undefined;
}
const params = asRecord((message as { params?: unknown }).params);
if (!params) {
return undefined;
}
const sessionId = typeof params.sessionId === "string" ? params.sessionId : null;
if (!sessionId) {
return undefined;
}
const update = asRecord(params.update);
if (!update || typeof update.sessionUpdate !== "string") {
return undefined;
}
return {
sessionId,
update: update as SessionNotification["update"],
};
}
export function parsePromptStopReason(message: AnyMessage): string | undefined {
if (!Object.hasOwn(message, "id") || !Object.hasOwn(message, "result")) {
return undefined;
}
const record = asRecord((message as { result?: unknown }).result);
if (!record) {
return undefined;
}
return typeof record.stopReason === "string" ? record.stopReason : undefined;
}
export function parseJsonRpcErrorMessage(message: AnyMessage): string | undefined {
if (!Object.hasOwn(message, "error")) {
return undefined;
}
const errorRecord = asRecord((message as { error?: unknown }).error);
if (!errorRecord || typeof errorRecord.message !== "string") {
return undefined;
}
return errorRecord.message;
}

View File

@@ -0,0 +1,35 @@
const AGENT_SESSION_ID_META_KEYS = ["agentSessionId", "sessionId"] as const;
export function normalizeAgentSessionId(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function asMetaRecord(meta: unknown): Record<string, unknown> | undefined {
if (!meta || typeof meta !== "object" || Array.isArray(meta)) {
return undefined;
}
return meta as Record<string, unknown>;
}
export function extractAgentSessionId(meta: unknown): string | undefined {
const record = asMetaRecord(meta);
if (!record) {
return undefined;
}
for (const key of AGENT_SESSION_ID_META_KEYS) {
const normalized = normalizeAgentSessionId(record[key]);
if (normalized) {
return normalized;
}
}
return undefined;
}
export { AGENT_SESSION_ID_META_KEYS };

View File

@@ -0,0 +1,61 @@
const ACP_ADAPTER_PACKAGE_RANGES = {
pi: "^0.0.22",
codex: "^0.11.1",
claude: "^0.25.0",
} as const;
export const AGENT_REGISTRY: Record<string, string> = {
pi: `npx pi-acp@${ACP_ADAPTER_PACKAGE_RANGES.pi}`,
openclaw: "openclaw acp",
codex: `npx @zed-industries/codex-acp@${ACP_ADAPTER_PACKAGE_RANGES.codex}`,
claude: `npx -y @agentclientprotocol/claude-agent-acp@${ACP_ADAPTER_PACKAGE_RANGES.claude}`,
gemini: "gemini --acp",
cursor: "cursor-agent acp",
copilot: "copilot --acp --stdio",
droid: "droid exec --output-format acp",
iflow: "iflow --experimental-acp",
kilocode: "npx -y @kilocode/cli acp",
kimi: "kimi acp",
kiro: "kiro-cli-chat acp",
opencode: "npx -y opencode-ai acp",
qoder: "qodercli --acp",
qwen: "qwen --acp",
trae: "traecli acp serve",
};
const AGENT_ALIASES: Record<string, string> = {
"factory-droid": "droid",
factorydroid: "droid",
};
export const DEFAULT_AGENT_NAME = "codex";
export function normalizeAgentName(value: string): string {
return value.trim().toLowerCase();
}
export function mergeAgentRegistry(overrides?: Record<string, string>): Record<string, string> {
if (!overrides) {
return { ...AGENT_REGISTRY };
}
const merged = { ...AGENT_REGISTRY };
for (const [name, command] of Object.entries(overrides)) {
const normalized = normalizeAgentName(name);
if (!normalized || !command.trim()) {
continue;
}
merged[normalized] = command.trim();
}
return merged;
}
export function resolveAgentCommand(agentName: string, overrides?: Record<string, string>): string {
const normalized = normalizeAgentName(agentName);
const registry = mergeAgentRegistry(overrides);
return registry[normalized] ?? registry[AGENT_ALIASES[normalized] ?? normalized] ?? agentName;
}
export function listBuiltInAgents(overrides?: Record<string, string>): string[] {
return Object.keys(mergeAgentRegistry(overrides));
}

View File

@@ -22,9 +22,8 @@ export type AcpxMcpServer = {
};
export type AcpxPluginConfig = {
command?: string;
expectedVersion?: string;
cwd?: string;
stateDir?: string;
permissionMode?: AcpxPermissionMode;
nonInteractivePermissions?: AcpxNonInteractivePermissionPolicy;
pluginToolsMcpBridge?: boolean;
@@ -32,15 +31,12 @@ export type AcpxPluginConfig = {
timeoutSeconds?: number;
queueOwnerTtlSeconds?: number;
mcpServers?: Record<string, McpServerConfig>;
agents?: Record<string, { command: string }>;
};
export type ResolvedAcpxPluginConfig = {
command: string;
expectedVersion?: string;
allowPluginLocalInstall: boolean;
stripProviderAuthEnvVars: boolean;
installCommand: string;
cwd: string;
stateDir: string;
permissionMode: AcpxPermissionMode;
nonInteractivePermissions: AcpxNonInteractivePermissionPolicy;
pluginToolsMcpBridge: boolean;
@@ -48,6 +44,7 @@ export type ResolvedAcpxPluginConfig = {
timeoutSeconds?: number;
queueOwnerTtlSeconds: number;
mcpServers: Record<string, McpServerConfig>;
agents: Record<string, string>;
};
const nonEmptyTrimmedString = (message: string) =>
@@ -72,9 +69,8 @@ const McpServerConfigSchema = z.object({
});
export const AcpxPluginConfigSchema = z.strictObject({
command: nonEmptyTrimmedString("command must be a non-empty string").optional(),
expectedVersion: nonEmptyTrimmedString("expectedVersion must be a non-empty string").optional(),
cwd: nonEmptyTrimmedString("cwd must be a non-empty string").optional(),
stateDir: nonEmptyTrimmedString("stateDir must be a non-empty string").optional(),
permissionMode: z
.enum(ACPX_PERMISSION_MODES, {
error: `permissionMode must be one of: ${ACPX_PERMISSION_MODES.join(", ")}`,
@@ -98,6 +94,14 @@ export const AcpxPluginConfigSchema = z.strictObject({
.min(0, { error: "queueOwnerTtlSeconds must be a number >= 0" })
.optional(),
mcpServers: z.record(z.string(), McpServerConfigSchema).optional(),
agents: z
.record(
z.string(),
z.strictObject({
command: nonEmptyTrimmedString("agents.<id>.command must be a non-empty string"),
}),
)
.optional(),
});
export function createAcpxPluginConfigSchema(): OpenClawPluginConfigSchema {

View File

@@ -1,312 +1,70 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { describe, expect, it } from "vitest";
import {
bundledDistPluginRootAt,
bundledPluginRootAt,
} from "../../../test/helpers/bundled-plugin-paths.js";
import {
ACPX_BUNDLED_BIN,
ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME,
ACPX_PINNED_VERSION,
createAcpxPluginConfigSchema,
resolveAcpxPluginRoot,
resolveAcpxPluginConfig,
resolvePluginToolsMcpServerConfig,
} from "./config.js";
import { resolveAcpxPluginConfig, resolveAcpxPluginRoot } from "./config.js";
describe("acpx plugin config parsing", () => {
it("resolves source-layout plugin root from a file under src", () => {
const pluginRoot = fs.mkdtempSync(path.join(os.tmpdir(), "acpx-root-source-"));
try {
fs.mkdirSync(path.join(pluginRoot, "src"), { recursive: true });
fs.writeFileSync(path.join(pluginRoot, "package.json"), "{}\n", "utf8");
fs.writeFileSync(path.join(pluginRoot, "openclaw.plugin.json"), "{}\n", "utf8");
const moduleUrl = pathToFileURL(path.join(pluginRoot, "src", "config.ts")).href;
expect(resolveAcpxPluginRoot(moduleUrl)).toBe(pluginRoot);
} finally {
fs.rmSync(pluginRoot, { recursive: true, force: true });
}
});
it("resolves bundled-layout plugin root from the dist entry file", () => {
const pluginRoot = fs.mkdtempSync(path.join(os.tmpdir(), "acpx-root-dist-"));
try {
fs.writeFileSync(path.join(pluginRoot, "package.json"), "{}\n", "utf8");
fs.writeFileSync(path.join(pluginRoot, "openclaw.plugin.json"), "{}\n", "utf8");
const moduleUrl = pathToFileURL(path.join(pluginRoot, "index.js")).href;
expect(resolveAcpxPluginRoot(moduleUrl)).toBe(pluginRoot);
} finally {
fs.rmSync(pluginRoot, { recursive: true, force: true });
}
});
it("prefers the workspace plugin root for dist plugin bundles", () => {
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "acpx-root-workspace-"));
const workspacePluginRoot = bundledPluginRootAt(repoRoot, "acpx");
const bundledPluginRoot = bundledDistPluginRootAt(repoRoot, "acpx");
try {
fs.mkdirSync(workspacePluginRoot, { recursive: true });
fs.mkdirSync(bundledPluginRoot, { recursive: true });
fs.writeFileSync(path.join(workspacePluginRoot, "package.json"), "{}\n", "utf8");
fs.writeFileSync(path.join(workspacePluginRoot, "openclaw.plugin.json"), "{}\n", "utf8");
fs.writeFileSync(path.join(bundledPluginRoot, "package.json"), "{}\n", "utf8");
fs.writeFileSync(path.join(bundledPluginRoot, "openclaw.plugin.json"), "{}\n", "utf8");
const moduleUrl = pathToFileURL(path.join(bundledPluginRoot, "index.js")).href;
expect(resolveAcpxPluginRoot(moduleUrl)).toBe(workspacePluginRoot);
} finally {
fs.rmSync(repoRoot, { recursive: true, force: true });
}
});
it("resolves workspace plugin root from dist shared chunks", () => {
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "acpx-root-shared-dist-"));
const workspacePluginRoot = bundledPluginRootAt(repoRoot, "acpx");
try {
fs.mkdirSync(path.join(repoRoot, "dist"), { recursive: true });
fs.mkdirSync(workspacePluginRoot, { recursive: true });
fs.writeFileSync(path.join(workspacePluginRoot, "package.json"), "{}\n", "utf8");
fs.writeFileSync(path.join(workspacePluginRoot, "openclaw.plugin.json"), "{}\n", "utf8");
const moduleUrl = pathToFileURL(path.join(repoRoot, "dist", "register.runtime.js")).href;
expect(resolveAcpxPluginRoot(moduleUrl)).toBe(workspacePluginRoot);
} finally {
fs.rmSync(repoRoot, { recursive: true, force: true });
}
});
it("resolves workspace plugin root from dist-runtime shared chunks", () => {
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "acpx-root-shared-dist-runtime-"));
const workspacePluginRoot = bundledPluginRootAt(repoRoot, "acpx");
try {
fs.mkdirSync(workspacePluginRoot, { recursive: true });
fs.mkdirSync(path.join(repoRoot, "dist-runtime"), { recursive: true });
fs.writeFileSync(path.join(workspacePluginRoot, "package.json"), "{}\n", "utf8");
fs.writeFileSync(path.join(workspacePluginRoot, "openclaw.plugin.json"), "{}\n", "utf8");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "dist-runtime", "register.runtime.js"),
).href;
expect(resolveAcpxPluginRoot(moduleUrl)).toBe(workspacePluginRoot);
} finally {
fs.rmSync(repoRoot, { recursive: true, force: true });
}
});
it("resolves bundled acpx with pinned version by default", () => {
describe("embedded acpx plugin config", () => {
it("resolves workspace stateDir and cwd by default", () => {
const workspaceDir = "/tmp/openclaw-acpx";
const resolved = resolveAcpxPluginConfig({
rawConfig: {
cwd: "/tmp/workspace",
},
workspaceDir: "/tmp/workspace",
rawConfig: undefined,
workspaceDir,
});
expect(resolved.command).toBe(ACPX_BUNDLED_BIN);
expect(resolved.expectedVersion).toBe(ACPX_PINNED_VERSION);
expect(resolved.allowPluginLocalInstall).toBe(true);
expect(resolved.stripProviderAuthEnvVars).toBe(true);
expect(resolved.cwd).toBe(path.resolve("/tmp/workspace"));
expect(resolved.pluginToolsMcpBridge).toBe(false);
expect(resolved.mcpServers).toEqual({});
expect(resolved.strictWindowsCmdWrapper).toBe(true);
expect(resolved.cwd).toBe(workspaceDir);
expect(resolved.stateDir).toBe(path.join(workspaceDir, "state"));
expect(resolved.permissionMode).toBe("approve-reads");
expect(resolved.nonInteractivePermissions).toBe("fail");
expect(resolved.agents).toEqual({});
});
it("accepts command override and disables plugin-local auto-install", () => {
const command = "/home/user/repos/acpx/dist/cli.js";
it("accepts agent command overrides", () => {
const resolved = resolveAcpxPluginConfig({
rawConfig: {
command,
},
workspaceDir: "/tmp/workspace",
});
expect(resolved.command).toBe(path.resolve(command));
expect(resolved.expectedVersion).toBeUndefined();
expect(resolved.allowPluginLocalInstall).toBe(false);
expect(resolved.stripProviderAuthEnvVars).toBe(false);
});
it("resolves relative command paths against workspace directory", () => {
const resolved = resolveAcpxPluginConfig({
rawConfig: {
command: "../acpx/dist/cli.js",
},
workspaceDir: "/home/user/repos/openclaw",
});
expect(resolved.command).toBe(path.resolve("/home/user/repos/openclaw", "../acpx/dist/cli.js"));
expect(resolved.expectedVersion).toBeUndefined();
expect(resolved.allowPluginLocalInstall).toBe(false);
expect(resolved.stripProviderAuthEnvVars).toBe(false);
});
it("keeps bare command names as-is", () => {
const resolved = resolveAcpxPluginConfig({
rawConfig: {
command: "acpx",
},
workspaceDir: "/tmp/workspace",
});
expect(resolved.command).toBe("acpx");
expect(resolved.expectedVersion).toBeUndefined();
expect(resolved.allowPluginLocalInstall).toBe(false);
expect(resolved.stripProviderAuthEnvVars).toBe(false);
});
it("accepts exact expectedVersion override", () => {
const command = "/home/user/repos/acpx/dist/cli.js";
const resolved = resolveAcpxPluginConfig({
rawConfig: {
command,
expectedVersion: "0.1.99",
},
workspaceDir: "/tmp/workspace",
});
expect(resolved.command).toBe(path.resolve(command));
expect(resolved.expectedVersion).toBe("0.1.99");
expect(resolved.allowPluginLocalInstall).toBe(false);
expect(resolved.stripProviderAuthEnvVars).toBe(false);
});
it("treats expectedVersion=any as no version constraint", () => {
const resolved = resolveAcpxPluginConfig({
rawConfig: {
command: "/home/user/repos/acpx/dist/cli.js",
expectedVersion: "any",
},
workspaceDir: "/tmp/workspace",
});
expect(resolved.expectedVersion).toBeUndefined();
});
it("rejects commandArgs overrides", () => {
expect(() =>
resolveAcpxPluginConfig({
rawConfig: {
commandArgs: ["--foo"],
agents: {
claude: { command: "claude --acp" },
codex: { command: "codex custom-acp" },
},
workspaceDir: "/tmp/workspace",
}),
).toThrow("unknown config key: commandArgs");
});
},
workspaceDir: "/tmp/openclaw-acpx",
});
it("schema rejects empty cwd", () => {
const schema = createAcpxPluginConfigSchema();
if (!schema.safeParse) {
throw new Error("acpx config schema missing safeParse");
}
const parsed = schema.safeParse({ cwd: " " });
expect(parsed.success).toBe(false);
expect(resolved.agents).toEqual({
claude: "claude --acp",
codex: "codex custom-acp",
});
});
it("injects the built-in plugin-tools MCP server only when explicitly enabled", () => {
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "acpx-plugin-tools-dist-"));
const pluginRoot = path.join(repoRoot, "extensions", "acpx");
const distEntry = path.join(repoRoot, "dist", "mcp", "plugin-tools-serve.js");
try {
fs.mkdirSync(path.join(pluginRoot, "src"), { recursive: true });
fs.mkdirSync(path.dirname(distEntry), { recursive: true });
fs.writeFileSync(path.join(pluginRoot, "package.json"), "{}\n", "utf8");
fs.writeFileSync(path.join(pluginRoot, "openclaw.plugin.json"), "{}\n", "utf8");
fs.writeFileSync(path.join(pluginRoot, "src", "config.ts"), "// test\n", "utf8");
fs.writeFileSync(distEntry, "// built entry\n", "utf8");
const resolved = resolveAcpxPluginConfig({
rawConfig: {
pluginToolsMcpBridge: true,
},
workspaceDir: repoRoot,
moduleUrl: pathToFileURL(path.join(pluginRoot, "src", "config.ts")).href,
});
expect(resolved.pluginToolsMcpBridge).toBe(true);
expect(resolved.mcpServers[ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME]).toEqual({
command: process.execPath,
args: [distEntry],
});
} finally {
fs.rmSync(repoRoot, { recursive: true, force: true });
}
});
it("falls back to the source plugin-tools MCP server entry when dist is absent", () => {
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "acpx-plugin-tools-src-"));
const pluginRoot = path.join(repoRoot, "extensions", "acpx");
const sourceConfigUrl = pathToFileURL(path.join(pluginRoot, "src", "config.ts")).href;
try {
fs.mkdirSync(path.join(pluginRoot, "src"), { recursive: true });
fs.mkdirSync(path.join(repoRoot, "src", "mcp"), { recursive: true });
fs.writeFileSync(path.join(pluginRoot, "package.json"), "{}\n", "utf8");
fs.writeFileSync(path.join(pluginRoot, "openclaw.plugin.json"), "{}\n", "utf8");
fs.writeFileSync(path.join(pluginRoot, "src", "config.ts"), "// test\n", "utf8");
fs.writeFileSync(
path.join(repoRoot, "src", "mcp", "plugin-tools-serve.ts"),
"// test\n",
"utf8",
);
expect(resolvePluginToolsMcpServerConfig(sourceConfigUrl)).toEqual({
command: process.execPath,
args: ["--import", "tsx", path.join(repoRoot, "src", "mcp", "plugin-tools-serve.ts")],
});
} finally {
fs.rmSync(repoRoot, { recursive: true, force: true });
}
});
it("rejects reserved MCP server name collisions when the plugin-tools bridge is enabled", () => {
expect(() =>
resolveAcpxPluginConfig({
rawConfig: {
pluginToolsMcpBridge: true,
mcpServers: {
[ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME]: {
command: "node",
},
},
},
workspaceDir: "/tmp/workspace",
}),
).toThrow(
`mcpServers.${ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME} is reserved when pluginToolsMcpBridge=true`,
);
});
it("accepts strictWindowsCmdWrapper override", () => {
const resolved = resolveAcpxPluginConfig({
rawConfig: {
strictWindowsCmdWrapper: true,
pluginToolsMcpBridge: true,
},
workspaceDir: "/tmp/workspace",
workspaceDir: "/tmp/openclaw-acpx",
});
expect(resolved.strictWindowsCmdWrapper).toBe(true);
});
it("rejects non-boolean strictWindowsCmdWrapper", () => {
expect(() =>
resolveAcpxPluginConfig({
rawConfig: {
strictWindowsCmdWrapper: "yes",
},
workspaceDir: "/tmp/workspace",
}),
).toThrow("strictWindowsCmdWrapper must be a boolean");
const server = resolved.mcpServers["openclaw-plugin-tools"];
expect(server).toBeDefined();
expect(server.command).toBe(process.execPath);
expect(Array.isArray(server.args)).toBe(true);
expect(server.args?.length).toBeGreaterThan(0);
});
it("keeps the runtime json schema in sync with the manifest config schema", () => {
const pluginRoot = resolveAcpxPluginRoot();
const manifest = JSON.parse(
fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf8"),
fs.readFileSync(path.join(pluginRoot, "openclaw.plugin.json"), "utf8"),
) as { configSchema?: unknown };
expect(createAcpxPluginConfigSchema().jsonSchema).toEqual(manifest.configSchema);
expect(manifest.configSchema).toMatchObject({
type: "object",
additionalProperties: false,
properties: expect.objectContaining({
cwd: expect.any(Object),
stateDir: expect.any(Object),
agents: expect.any(Object),
mcpServers: expect.any(Object),
}),
});
});
});

View File

@@ -23,9 +23,7 @@ export {
createAcpxPluginConfigSchema,
} from "./config-schema.js";
export const ACPX_VERSION_ANY = "any";
export const ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME = "openclaw-plugin-tools";
const ACPX_BIN_NAME = process.platform === "win32" ? "acpx.cmd" : "acpx";
function isAcpxPluginRoot(dir: string): boolean {
return (
@@ -103,19 +101,6 @@ export function resolveAcpxPluginRoot(moduleUrl: string = import.meta.url): stri
}
export const ACPX_PLUGIN_ROOT = resolveAcpxPluginRoot();
const pluginPkg = JSON.parse(fs.readFileSync(path.join(ACPX_PLUGIN_ROOT, "package.json"), "utf8"));
const acpxVersion: unknown = pluginPkg?.dependencies?.acpx;
if (typeof acpxVersion !== "string" || acpxVersion.trim() === "") {
throw new Error(
`Could not read acpx version from ${path.join(ACPX_PLUGIN_ROOT, "package.json")} — expected a non-empty string at dependencies.acpx`,
);
}
export const ACPX_PINNED_VERSION: string = acpxVersion.replace(/^[^0-9]*/, "");
export const ACPX_BUNDLED_BIN = path.join(ACPX_PLUGIN_ROOT, "node_modules", ".bin", ACPX_BIN_NAME);
export function buildAcpxLocalInstallCommand(version: string = ACPX_PINNED_VERSION): string {
return `npm install --omit=dev --no-save --package-lock=false acpx@${version}`;
}
export const ACPX_LOCAL_INSTALL_COMMAND = buildAcpxLocalInstallCommand();
const DEFAULT_PERMISSION_MODE: AcpxPermissionMode = "approve-reads";
const DEFAULT_NON_INTERACTIVE_POLICY: AcpxNonInteractivePermissionPolicy = "fail";
@@ -153,18 +138,6 @@ function parseAcpxPluginConfig(value: unknown): ParseResult {
};
}
function resolveConfiguredCommand(params: { configured?: string; workspaceDir?: string }): string {
const configured = params.configured?.trim();
if (!configured) {
return ACPX_BUNDLED_BIN;
}
if (path.isAbsolute(configured) || configured.includes(path.sep) || configured.includes("/")) {
const baseDir = params.workspaceDir?.trim() || process.cwd();
return path.resolve(baseDir, configured);
}
return configured;
}
function resolveOpenClawRoot(currentRoot: string): string {
if (
path.basename(currentRoot) === "acpx" &&
@@ -238,34 +211,26 @@ export function resolveAcpxPluginConfig(params: {
throw new Error(parsed.message);
}
const normalized = parsed.value ?? {};
const fallbackCwd = params.workspaceDir?.trim() || process.cwd();
const workspaceDir = params.workspaceDir?.trim() || process.cwd();
const fallbackCwd = workspaceDir;
const cwd = path.resolve(normalized.cwd?.trim() || fallbackCwd);
const command = resolveConfiguredCommand({
configured: normalized.command,
workspaceDir: params.workspaceDir,
});
const allowPluginLocalInstall = command === ACPX_BUNDLED_BIN;
const stripProviderAuthEnvVars = command === ACPX_BUNDLED_BIN;
const configuredExpectedVersion = normalized.expectedVersion;
const expectedVersion =
configuredExpectedVersion === ACPX_VERSION_ANY
? undefined
: (configuredExpectedVersion ?? (allowPluginLocalInstall ? ACPX_PINNED_VERSION : undefined));
const installCommand = buildAcpxLocalInstallCommand(expectedVersion ?? ACPX_PINNED_VERSION);
const stateDir = path.resolve(normalized.stateDir?.trim() || path.join(workspaceDir, "state"));
const pluginToolsMcpBridge = normalized.pluginToolsMcpBridge === true;
const mcpServers = resolveConfiguredMcpServers({
mcpServers: normalized.mcpServers,
pluginToolsMcpBridge,
moduleUrl: params.moduleUrl,
});
const agents = Object.fromEntries(
Object.entries(normalized.agents ?? {}).map(([name, entry]) => [
name.trim().toLowerCase(),
entry.command.trim(),
]),
);
return {
command,
expectedVersion,
allowPluginLocalInstall,
stripProviderAuthEnvVars,
installCommand,
cwd,
stateDir,
permissionMode: normalized.permissionMode ?? DEFAULT_PERMISSION_MODE,
nonInteractivePermissions:
normalized.nonInteractivePermissions ?? DEFAULT_NON_INTERACTIVE_POLICY,
@@ -275,5 +240,6 @@ export function resolveAcpxPluginConfig(params: {
timeoutSeconds: normalized.timeoutSeconds,
queueOwnerTtlSeconds: normalized.queueOwnerTtlSeconds ?? DEFAULT_QUEUE_OWNER_TTL_SECONDS,
mcpServers,
agents,
};
}

View File

@@ -1,318 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
ACPX_LOCAL_INSTALL_COMMAND,
ACPX_PINNED_VERSION,
buildAcpxLocalInstallCommand,
} from "./config.js";
const { resolveSpawnFailureMock, spawnAndCollectMock } = vi.hoisted(() => ({
resolveSpawnFailureMock: vi.fn<
(error: unknown, cwd: string) => "missing-command" | "missing-cwd" | null
>(() => null),
spawnAndCollectMock: vi.fn(),
}));
vi.mock("./runtime-internals/process.js", () => ({
resolveSpawnFailure: resolveSpawnFailureMock,
spawnAndCollect: spawnAndCollectMock,
}));
import { checkAcpxVersion, ensureAcpx } from "./ensure.js";
describe("acpx ensure", () => {
const tempDirs: string[] = [];
beforeEach(() => {
resolveSpawnFailureMock.mockReset();
resolveSpawnFailureMock.mockReturnValue(null);
spawnAndCollectMock.mockReset();
});
function makeTempAcpxInstall(version: string): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "acpx-ensure-test-"));
tempDirs.push(root);
const packageRoot = path.join(root, "node_modules", "acpx");
fs.mkdirSync(path.join(packageRoot, "dist"), { recursive: true });
fs.mkdirSync(path.join(root, "node_modules", ".bin"), { recursive: true });
fs.writeFileSync(
path.join(packageRoot, "package.json"),
JSON.stringify({ name: "acpx", version }, null, 2),
"utf8",
);
fs.writeFileSync(path.join(packageRoot, "dist", "cli.js"), "#!/usr/bin/env node\n", "utf8");
const binPath = path.join(root, "node_modules", ".bin", "acpx");
fs.symlinkSync(path.join(packageRoot, "dist", "cli.js"), binPath);
return binPath;
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
function mockEnsureInstallFlow() {
spawnAndCollectMock
.mockResolvedValueOnce({
stdout: "acpx 0.0.9\n",
stderr: "",
code: 0,
error: null,
})
.mockResolvedValueOnce({
stdout: "added 1 package\n",
stderr: "",
code: 0,
error: null,
})
.mockResolvedValueOnce({
stdout: `acpx ${ACPX_PINNED_VERSION}\n`,
stderr: "",
code: 0,
error: null,
});
}
function expectEnsureInstallCalls(stripProviderAuthEnvVars?: boolean) {
expect(spawnAndCollectMock.mock.calls[0]?.[0]).toMatchObject({
command: "/plugin/node_modules/.bin/acpx",
args: ["--version"],
cwd: "/plugin",
stripProviderAuthEnvVars,
});
expect(spawnAndCollectMock.mock.calls[1]?.[0]).toMatchObject({
command: "npm",
args: [
"install",
"--omit=dev",
"--no-save",
"--package-lock=false",
`acpx@${ACPX_PINNED_VERSION}`,
],
cwd: "/plugin",
stripProviderAuthEnvVars,
});
expect(spawnAndCollectMock.mock.calls[2]?.[0]).toMatchObject({
command: "/plugin/node_modules/.bin/acpx",
args: ["--version"],
cwd: "/plugin",
stripProviderAuthEnvVars,
});
}
it("accepts the pinned acpx version", async () => {
spawnAndCollectMock.mockResolvedValueOnce({
stdout: `acpx ${ACPX_PINNED_VERSION}\n`,
stderr: "",
code: 0,
error: null,
});
const result = await checkAcpxVersion({
command: "/plugin/node_modules/.bin/acpx",
cwd: "/plugin",
expectedVersion: ACPX_PINNED_VERSION,
});
expect(result).toEqual({
ok: true,
version: ACPX_PINNED_VERSION,
expectedVersion: ACPX_PINNED_VERSION,
});
expect(spawnAndCollectMock).toHaveBeenCalledWith({
command: "/plugin/node_modules/.bin/acpx",
args: ["--version"],
cwd: "/plugin",
stripProviderAuthEnvVars: undefined,
});
});
it("reports version mismatch", async () => {
spawnAndCollectMock.mockResolvedValueOnce({
stdout: "acpx 0.0.9\n",
stderr: "",
code: 0,
error: null,
});
const result = await checkAcpxVersion({
command: "/plugin/node_modules/.bin/acpx",
cwd: "/plugin",
expectedVersion: ACPX_PINNED_VERSION,
});
expect(result).toMatchObject({
ok: false,
reason: "version-mismatch",
expectedVersion: ACPX_PINNED_VERSION,
installedVersion: "0.0.9",
installCommand: ACPX_LOCAL_INSTALL_COMMAND,
});
});
it("falls back to package.json version when --version is unsupported", async () => {
const command = makeTempAcpxInstall(ACPX_PINNED_VERSION);
spawnAndCollectMock.mockResolvedValueOnce({
stdout: "",
stderr: "error: unknown option '--version'",
code: 2,
error: null,
});
const result = await checkAcpxVersion({
command,
cwd: path.dirname(path.dirname(command)),
expectedVersion: ACPX_PINNED_VERSION,
});
expect(result).toEqual({
ok: true,
version: ACPX_PINNED_VERSION,
expectedVersion: ACPX_PINNED_VERSION,
});
});
it("accepts command availability when expectedVersion is unset", async () => {
spawnAndCollectMock.mockResolvedValueOnce({
stdout: "Usage: acpx [options]\n",
stderr: "",
code: 0,
error: null,
});
const result = await checkAcpxVersion({
command: "/custom/acpx",
cwd: "/custom",
expectedVersion: undefined,
});
expect(result).toEqual({
ok: true,
version: "unknown",
expectedVersion: undefined,
});
expect(spawnAndCollectMock).toHaveBeenCalledWith({
command: "/custom/acpx",
args: ["--help"],
cwd: "/custom",
stripProviderAuthEnvVars: undefined,
});
});
it("forwards stripProviderAuthEnvVars to version checks", async () => {
spawnAndCollectMock.mockResolvedValueOnce({
stdout: "Usage: acpx [options]\n",
stderr: "",
code: 0,
error: null,
});
await checkAcpxVersion({
command: "/plugin/node_modules/.bin/acpx",
cwd: "/plugin",
expectedVersion: undefined,
stripProviderAuthEnvVars: true,
});
expect(spawnAndCollectMock).toHaveBeenCalledWith({
command: "/plugin/node_modules/.bin/acpx",
args: ["--help"],
cwd: "/plugin",
stripProviderAuthEnvVars: true,
});
});
it("installs and verifies pinned acpx when precheck fails", async () => {
mockEnsureInstallFlow();
await ensureAcpx({
command: "/plugin/node_modules/.bin/acpx",
pluginRoot: "/plugin",
expectedVersion: ACPX_PINNED_VERSION,
});
expect(spawnAndCollectMock).toHaveBeenCalledTimes(3);
expectEnsureInstallCalls();
});
it("threads stripProviderAuthEnvVars through version probes and install", async () => {
mockEnsureInstallFlow();
await ensureAcpx({
command: "/plugin/node_modules/.bin/acpx",
pluginRoot: "/plugin",
expectedVersion: ACPX_PINNED_VERSION,
stripProviderAuthEnvVars: true,
});
expectEnsureInstallCalls(true);
});
it("fails with actionable error when npm install fails", async () => {
spawnAndCollectMock
.mockResolvedValueOnce({
stdout: "acpx 0.0.9\n",
stderr: "",
code: 0,
error: null,
})
.mockResolvedValueOnce({
stdout: "",
stderr: "network down",
code: 1,
error: null,
});
await expect(
ensureAcpx({
command: "/plugin/node_modules/.bin/acpx",
pluginRoot: "/plugin",
expectedVersion: ACPX_PINNED_VERSION,
}),
).rejects.toThrow("failed to install plugin-local acpx");
});
it("skips install path when allowInstall=false", async () => {
spawnAndCollectMock.mockResolvedValueOnce({
stdout: "",
stderr: "",
code: 0,
error: new Error("not found"),
});
resolveSpawnFailureMock.mockReturnValue("missing-command");
await expect(
ensureAcpx({
command: "/custom/acpx",
pluginRoot: "/plugin",
expectedVersion: undefined,
allowInstall: false,
}),
).rejects.toThrow("acpx command not found at /custom/acpx");
expect(spawnAndCollectMock).toHaveBeenCalledTimes(1);
});
it("uses expectedVersion for install command metadata", async () => {
spawnAndCollectMock.mockResolvedValueOnce({
stdout: "acpx 0.0.9\n",
stderr: "",
code: 0,
error: null,
});
const result = await checkAcpxVersion({
command: "/plugin/node_modules/.bin/acpx",
cwd: "/plugin",
expectedVersion: "0.2.0",
});
expect(result).toMatchObject({
ok: false,
installCommand: buildAcpxLocalInstallCommand("0.2.0"),
});
});
});

View File

@@ -1,282 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import type { PluginLogger } from "../runtime-api.js";
import { ACPX_PINNED_VERSION, ACPX_PLUGIN_ROOT, buildAcpxLocalInstallCommand } from "./config.js";
import {
resolveSpawnFailure,
type SpawnCommandOptions,
spawnAndCollect,
} from "./runtime-internals/process.js";
const SEMVER_PATTERN = /\b\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\b/;
export type AcpxVersionCheckResult =
| {
ok: true;
version: string;
expectedVersion?: string;
}
| {
ok: false;
reason: "missing-command" | "missing-version" | "version-mismatch" | "execution-failed";
message: string;
expectedVersion?: string;
installCommand: string;
installedVersion?: string;
};
function extractVersion(stdout: string, stderr: string): string | null {
const combined = `${stdout}\n${stderr}`;
const match = combined.match(SEMVER_PATTERN);
return match?.[0] ?? null;
}
function isExpectedVersionConfigured(value: string | undefined): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function supportsPathResolution(command: string): boolean {
return path.isAbsolute(command) || command.includes("/") || command.includes("\\");
}
function isUnsupportedVersionProbe(stdout: string, stderr: string): boolean {
const combined = `${stdout}\n${stderr}`.toLowerCase();
return combined.includes("unknown option") && combined.includes("--version");
}
function resolveVersionFromPackage(command: string, cwd: string): string | null {
if (!supportsPathResolution(command)) {
return null;
}
const commandPath = path.isAbsolute(command) ? command : path.resolve(cwd, command);
let current: string;
try {
current = path.dirname(fs.realpathSync(commandPath));
} catch {
return null;
}
while (true) {
const packageJsonPath = path.join(current, "package.json");
try {
const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as {
name?: unknown;
version?: unknown;
};
if (parsed.name === "acpx" && typeof parsed.version === "string" && parsed.version.trim()) {
return parsed.version.trim();
}
} catch {
// no-op; continue walking up
}
const parent = path.dirname(current);
if (parent === current) {
return null;
}
current = parent;
}
}
function resolveVersionCheckResult(params: {
expectedVersion?: string;
installedVersion: string;
installCommand: string;
}): AcpxVersionCheckResult {
if (params.expectedVersion && params.installedVersion !== params.expectedVersion) {
return {
ok: false,
reason: "version-mismatch",
message: `acpx version mismatch: found ${params.installedVersion}, expected ${params.expectedVersion}`,
expectedVersion: params.expectedVersion,
installCommand: params.installCommand,
installedVersion: params.installedVersion,
};
}
return {
ok: true,
version: params.installedVersion,
expectedVersion: params.expectedVersion,
};
}
export async function checkAcpxVersion(params: {
command: string;
cwd?: string;
expectedVersion?: string;
stripProviderAuthEnvVars?: boolean;
spawnOptions?: SpawnCommandOptions;
}): Promise<AcpxVersionCheckResult> {
const expectedVersion = params.expectedVersion?.trim() || undefined;
const installCommand = buildAcpxLocalInstallCommand(expectedVersion ?? ACPX_PINNED_VERSION);
const cwd = params.cwd ?? ACPX_PLUGIN_ROOT;
const hasExpectedVersion = isExpectedVersionConfigured(expectedVersion);
const probeArgs = hasExpectedVersion ? ["--version"] : ["--help"];
const spawnParams = {
command: params.command,
args: probeArgs,
cwd,
stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
};
let result: Awaited<ReturnType<typeof spawnAndCollect>>;
try {
result = params.spawnOptions
? await spawnAndCollect(spawnParams, params.spawnOptions)
: await spawnAndCollect(spawnParams);
} catch (error) {
return {
ok: false,
reason: "execution-failed",
message: error instanceof Error ? error.message : String(error),
expectedVersion,
installCommand,
};
}
if (result.error) {
const spawnFailure = resolveSpawnFailure(result.error, cwd);
if (spawnFailure === "missing-command") {
return {
ok: false,
reason: "missing-command",
message: `acpx command not found at ${params.command}`,
expectedVersion,
installCommand,
};
}
return {
ok: false,
reason: "execution-failed",
message: result.error.message,
expectedVersion,
installCommand,
};
}
if ((result.code ?? 0) !== 0) {
if (hasExpectedVersion && isUnsupportedVersionProbe(result.stdout, result.stderr)) {
const installedVersion = resolveVersionFromPackage(params.command, cwd);
if (installedVersion) {
return resolveVersionCheckResult({ expectedVersion, installedVersion, installCommand });
}
}
const stderr = result.stderr.trim();
return {
ok: false,
reason: "execution-failed",
message:
stderr ||
`acpx ${hasExpectedVersion ? "--version" : "--help"} failed with code ${result.code ?? "unknown"}`,
expectedVersion,
installCommand,
};
}
if (!hasExpectedVersion) {
return {
ok: true,
version: "unknown",
expectedVersion,
};
}
const installedVersion = extractVersion(result.stdout, result.stderr);
if (!installedVersion) {
return {
ok: false,
reason: "missing-version",
message: "acpx --version output did not include a parseable version",
expectedVersion,
installCommand,
};
}
return resolveVersionCheckResult({ expectedVersion, installedVersion, installCommand });
}
let pendingEnsure: Promise<void> | null = null;
export async function ensureAcpx(params: {
command: string;
logger?: PluginLogger;
pluginRoot?: string;
expectedVersion?: string;
allowInstall?: boolean;
stripProviderAuthEnvVars?: boolean;
spawnOptions?: SpawnCommandOptions;
}): Promise<void> {
if (pendingEnsure) {
return await pendingEnsure;
}
pendingEnsure = (async () => {
const pluginRoot = params.pluginRoot ?? ACPX_PLUGIN_ROOT;
const expectedVersion = params.expectedVersion?.trim() || undefined;
const installVersion = expectedVersion ?? ACPX_PINNED_VERSION;
const allowInstall = params.allowInstall ?? true;
const precheck = await checkAcpxVersion({
command: params.command,
cwd: pluginRoot,
expectedVersion,
stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
spawnOptions: params.spawnOptions,
});
if (precheck.ok) {
return;
}
if (!allowInstall) {
throw new Error(precheck.message);
}
params.logger?.warn(
`acpx local binary unavailable or mismatched (${precheck.message}); running plugin-local install`,
);
const install = await spawnAndCollect({
command: "npm",
args: [
"install",
"--omit=dev",
"--no-save",
"--package-lock=false",
`acpx@${installVersion}`,
],
cwd: pluginRoot,
stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
});
if (install.error) {
const spawnFailure = resolveSpawnFailure(install.error, pluginRoot);
if (spawnFailure === "missing-command") {
throw new Error("npm is required to install plugin-local acpx but was not found on PATH");
}
throw new Error(`failed to install plugin-local acpx: ${install.error.message}`);
}
if ((install.code ?? 0) !== 0) {
const stderr = install.stderr.trim();
const stdout = install.stdout.trim();
const detail = stderr || stdout || `npm exited with code ${install.code ?? "unknown"}`;
throw new Error(`failed to install plugin-local acpx: ${detail}`);
}
const postcheck = await checkAcpxVersion({
command: params.command,
cwd: pluginRoot,
expectedVersion,
stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
spawnOptions: params.spawnOptions,
});
if (!postcheck.ok) {
throw new Error(`plugin-local acpx verification failed after install: ${postcheck.message}`);
}
params.logger?.info(`acpx plugin-local binary ready (version ${postcheck.version})`);
})();
try {
await pendingEnsure;
} finally {
pendingEnsure = null;
}
}

View File

@@ -0,0 +1,287 @@
import {
extractAcpError,
formatUnknownErrorMessage,
isAcpResourceNotFoundError,
} from "./acp-error-shapes.js";
import {
AuthPolicyError,
PermissionDeniedError,
PermissionPromptUnavailableError,
} from "./errors.js";
import {
EXIT_CODES,
OUTPUT_ERROR_CODES,
OUTPUT_ERROR_ORIGINS,
type ExitCode,
type OutputErrorAcpPayload,
type OutputErrorCode,
type OutputErrorOrigin,
} from "./runtime-types.js";
const AUTH_REQUIRED_ACP_CODES = new Set([-32000]);
const QUERY_CLOSED_BEFORE_RESPONSE_DETAIL = "query closed before response received";
type ErrorMeta = {
outputCode?: OutputErrorCode;
detailCode?: string;
origin?: OutputErrorOrigin;
retryable?: boolean;
acp?: OutputErrorAcpPayload;
};
export type NormalizedOutputError = {
code: OutputErrorCode;
message: string;
detailCode?: string;
origin?: OutputErrorOrigin;
retryable?: boolean;
acp?: OutputErrorAcpPayload;
};
export type NormalizeOutputErrorOptions = {
defaultCode?: OutputErrorCode;
detailCode?: string;
origin?: OutputErrorOrigin;
retryable?: boolean;
acp?: OutputErrorAcpPayload;
};
function asRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
return value as Record<string, unknown>;
}
function isAuthRequiredMessage(value: string | undefined): boolean {
if (!value) {
return false;
}
const normalized = value.toLowerCase();
return (
normalized.includes("auth required") ||
normalized.includes("authentication required") ||
normalized.includes("authorization required") ||
normalized.includes("credential required") ||
normalized.includes("credentials required") ||
normalized.includes("token required") ||
normalized.includes("login required")
);
}
function isAcpAuthRequiredPayload(acp: OutputErrorAcpPayload | undefined): boolean {
if (!acp) {
return false;
}
if (!AUTH_REQUIRED_ACP_CODES.has(acp.code)) {
return false;
}
if (isAuthRequiredMessage(acp.message)) {
return true;
}
const data = asRecord(acp.data);
if (!data) {
return false;
}
if (data.authRequired === true) {
return true;
}
const methodId = data.methodId;
if (typeof methodId === "string" && methodId.trim().length > 0) {
return true;
}
const methods = data.methods;
if (Array.isArray(methods) && methods.length > 0) {
return true;
}
return false;
}
function isOutputErrorCode(value: unknown): value is OutputErrorCode {
return typeof value === "string" && OUTPUT_ERROR_CODES.includes(value as OutputErrorCode);
}
function isOutputErrorOrigin(value: unknown): value is OutputErrorOrigin {
return typeof value === "string" && OUTPUT_ERROR_ORIGINS.includes(value as OutputErrorOrigin);
}
function readOutputErrorMeta(error: unknown): ErrorMeta {
const record = asRecord(error);
if (!record) {
return {};
}
const outputCode = isOutputErrorCode(record.outputCode) ? record.outputCode : undefined;
const detailCode =
typeof record.detailCode === "string" && record.detailCode.trim().length > 0
? record.detailCode
: undefined;
const origin = isOutputErrorOrigin(record.origin) ? record.origin : undefined;
const retryable = typeof record.retryable === "boolean" ? record.retryable : undefined;
const acp = extractAcpError(record.acp);
return {
outputCode,
detailCode,
origin,
retryable,
acp,
};
}
function isTimeoutLike(error: unknown): boolean {
return error instanceof Error && error.name === "TimeoutError";
}
function isNoSessionLike(error: unknown): boolean {
return error instanceof Error && error.name === "NoSessionError";
}
function isUsageLike(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
return (
error.name === "CommanderError" ||
error.name === "InvalidArgumentError" ||
asRecord(error)?.code === "commander.invalidArgument"
);
}
export function formatErrorMessage(error: unknown): string {
return formatUnknownErrorMessage(error);
}
export { extractAcpError, isAcpResourceNotFoundError };
export function isAcpQueryClosedBeforeResponseError(error: unknown): boolean {
const acp = extractAcpError(error);
if (!acp || acp.code !== -32603) {
return false;
}
const data = asRecord(acp.data);
const details = data?.details;
if (typeof details !== "string") {
return false;
}
return details.toLowerCase().includes(QUERY_CLOSED_BEFORE_RESPONSE_DETAIL);
}
function mapErrorCode(error: unknown): OutputErrorCode | undefined {
if (error instanceof PermissionPromptUnavailableError) {
return "PERMISSION_PROMPT_UNAVAILABLE";
}
if (error instanceof PermissionDeniedError) {
return "PERMISSION_DENIED";
}
if (isTimeoutLike(error)) {
return "TIMEOUT";
}
if (isNoSessionLike(error) || isAcpResourceNotFoundError(error)) {
return "NO_SESSION";
}
if (isUsageLike(error)) {
return "USAGE";
}
return undefined;
}
export function normalizeOutputError(
error: unknown,
options: NormalizeOutputErrorOptions = {},
): NormalizedOutputError {
const meta = readOutputErrorMeta(error);
const mapped = mapErrorCode(error);
let code = mapped ?? options.defaultCode ?? "RUNTIME";
if (meta.outputCode) {
code = meta.outputCode;
}
if (code === "RUNTIME" && isAcpResourceNotFoundError(error)) {
code = "NO_SESSION";
}
const acp = options.acp ?? meta.acp ?? extractAcpError(error);
const detailCode =
meta.detailCode ??
options.detailCode ??
(error instanceof AuthPolicyError || isAcpAuthRequiredPayload(acp)
? "AUTH_REQUIRED"
: undefined);
return {
code,
message: formatErrorMessage(error),
detailCode,
origin: meta.origin ?? options.origin,
retryable: meta.retryable ?? options.retryable,
acp,
};
}
/**
* Returns true when an error from `client.prompt()` looks transient and
* can reasonably be retried (e.g. model-API 400/500, network hiccups that
* surface as ACP internal errors).
*
* Errors that are definitively non-recoverable (auth, missing session,
* invalid params, timeout, permission) return false.
*/
export function isRetryablePromptError(error: unknown): boolean {
if (error instanceof PermissionDeniedError || error instanceof PermissionPromptUnavailableError) {
return false;
}
if (isTimeoutLike(error) || isNoSessionLike(error) || isUsageLike(error)) {
return false;
}
// Extract ACP payload once and reuse for all subsequent checks.
const acp = extractAcpError(error);
if (!acp) {
// Non-ACP errors (e.g. process crash) are not retried at the prompt level.
return false;
}
// Resource-not-found (session gone) — check using the already-extracted payload.
if (acp.code === -32001 || acp.code === -32002) {
return false;
}
// Auth-required errors are never retryable. Use the same thorough check as normalizeOutputError.
if (isAcpAuthRequiredPayload(acp)) {
return false;
}
// Method-not-found or invalid-params are permanent protocol errors.
if (acp.code === -32601 || acp.code === -32602) {
return false;
}
// ACP internal errors (-32603) typically wrap model-API failures → retryable.
// Parse errors (-32700) can also be transient.
return acp.code === -32603 || acp.code === -32700;
}
export function exitCodeForOutputErrorCode(code: OutputErrorCode): ExitCode {
switch (code) {
case "USAGE":
return EXIT_CODES.USAGE;
case "TIMEOUT":
return EXIT_CODES.TIMEOUT;
case "NO_SESSION":
return EXIT_CODES.NO_SESSION;
case "PERMISSION_DENIED":
case "PERMISSION_PROMPT_UNAVAILABLE":
return EXIT_CODES.PERMISSION_DENIED;
case "RUNTIME":
default:
return EXIT_CODES.ERROR;
}
}

View File

@@ -0,0 +1,168 @@
import type { OutputErrorAcpPayload, OutputErrorCode, OutputErrorOrigin } from "./runtime-types.js";
type AcpxErrorOptions = ErrorOptions & {
outputCode?: OutputErrorCode;
detailCode?: string;
origin?: OutputErrorOrigin;
retryable?: boolean;
acp?: OutputErrorAcpPayload;
outputAlreadyEmitted?: boolean;
};
export class AcpxOperationalError extends Error {
readonly outputCode?: OutputErrorCode;
readonly detailCode?: string;
readonly origin?: OutputErrorOrigin;
readonly retryable?: boolean;
readonly acp?: OutputErrorAcpPayload;
readonly outputAlreadyEmitted?: boolean;
constructor(message: string, options?: AcpxErrorOptions) {
super(message, options);
this.name = new.target.name;
this.outputCode = options?.outputCode;
this.detailCode = options?.detailCode;
this.origin = options?.origin;
this.retryable = options?.retryable;
this.acp = options?.acp;
this.outputAlreadyEmitted = options?.outputAlreadyEmitted;
}
}
export class SessionNotFoundError extends AcpxOperationalError {
readonly sessionId: string;
constructor(sessionId: string) {
super(`Session not found: ${sessionId}`);
this.sessionId = sessionId;
}
}
export class SessionResolutionError extends AcpxOperationalError {}
export class AgentSpawnError extends AcpxOperationalError {
readonly agentCommand: string;
constructor(agentCommand: string, cause?: unknown) {
super(`Failed to spawn agent command: ${agentCommand}`, {
cause: cause instanceof Error ? cause : undefined,
});
this.agentCommand = agentCommand;
}
}
export class AgentDisconnectedError extends AcpxOperationalError {
readonly reason: string;
readonly exitCode: number | null;
readonly signal: NodeJS.Signals | null;
constructor(
reason: string,
exitCode: number | null,
signal: NodeJS.Signals | null,
options?: AcpxErrorOptions,
) {
super(
`ACP agent disconnected during request (${reason}, exit=${exitCode ?? "null"}, signal=${signal ?? "null"})`,
{
outputCode: "RUNTIME",
detailCode: "AGENT_DISCONNECTED",
origin: "acp",
...options,
},
);
this.reason = reason;
this.exitCode = exitCode;
this.signal = signal;
}
}
export class SessionResumeRequiredError extends AcpxOperationalError {
constructor(message: string, options?: AcpxErrorOptions) {
super(message, {
outputCode: "RUNTIME",
detailCode: "SESSION_RESUME_REQUIRED",
origin: "acp",
retryable: true,
...options,
});
}
}
export class GeminiAcpStartupTimeoutError extends AcpxOperationalError {
constructor(message: string, options?: AcpxErrorOptions) {
super(message, {
outputCode: "TIMEOUT",
detailCode: "GEMINI_ACP_STARTUP_TIMEOUT",
origin: "acp",
...options,
});
}
}
export class SessionModeReplayError extends AcpxOperationalError {
constructor(message: string, options?: AcpxErrorOptions) {
super(message, {
outputCode: "RUNTIME",
detailCode: "SESSION_MODE_REPLAY_FAILED",
origin: "acp",
...options,
});
}
}
export class SessionModelReplayError extends AcpxOperationalError {
constructor(message: string, options?: AcpxErrorOptions) {
super(message, {
outputCode: "RUNTIME",
detailCode: "SESSION_MODEL_REPLAY_FAILED",
origin: "acp",
...options,
});
}
}
export class ClaudeAcpSessionCreateTimeoutError extends AcpxOperationalError {
constructor(message: string, options?: AcpxErrorOptions) {
super(message, {
outputCode: "TIMEOUT",
detailCode: "CLAUDE_ACP_SESSION_CREATE_TIMEOUT",
origin: "acp",
...options,
});
}
}
export class CopilotAcpUnsupportedError extends AcpxOperationalError {
constructor(message: string, options?: AcpxErrorOptions) {
super(message, {
outputCode: "RUNTIME",
detailCode: "COPILOT_ACP_UNSUPPORTED",
origin: "acp",
...options,
});
}
}
export class AuthPolicyError extends AcpxOperationalError {
constructor(message: string, options?: AcpxErrorOptions) {
super(message, {
outputCode: "RUNTIME",
detailCode: "AUTH_REQUIRED",
origin: "acp",
...options,
});
}
}
export class QueueConnectionError extends AcpxOperationalError {}
export class QueueProtocolError extends AcpxOperationalError {}
export class PermissionDeniedError extends AcpxOperationalError {}
export class PermissionPromptUnavailableError extends AcpxOperationalError {
constructor() {
super("Permission prompt unavailable in non-interactive mode");
}
}

View File

@@ -0,0 +1,73 @@
import { DEFAULT_AGENT_NAME, resolveAgentCommand } from "../agents/registry.js";
import type { ResolvedAcpxPluginConfig } from "../config.js";
import type { McpServer } from "../runtime-types.js";
import { AcpClient } from "../transport/acp-client.js";
export type RuntimeHealthReport = {
ok: boolean;
message: string;
details?: string[];
};
function toSdkMcpServers(config: ResolvedAcpxPluginConfig): McpServer[] {
return Object.entries(config.mcpServers).map(([name, server]) => ({
name,
command: server.command,
args: [...(server.args ?? [])],
env: Object.entries(server.env ?? {}).map(([envName, value]) => ({
name: envName,
value,
})),
}));
}
function resolveProbeAgentName(config: ResolvedAcpxPluginConfig): string {
const configuredAgents = Object.keys(config.agents);
return configuredAgents[0] ?? DEFAULT_AGENT_NAME;
}
export async function probeEmbeddedRuntime(
config: ResolvedAcpxPluginConfig,
): Promise<RuntimeHealthReport> {
const agentName = resolveProbeAgentName(config);
const agentCommand = resolveAgentCommand(agentName, config.agents);
const client = new AcpClient({
agentCommand,
cwd: config.cwd,
mcpServers: toSdkMcpServers(config),
permissionMode: config.permissionMode,
nonInteractivePermissions: config.nonInteractivePermissions,
verbose: false,
});
try {
await client.start();
return {
ok: true,
message: "embedded ACP runtime ready",
details: [
`agent=${agentName}`,
`command=${agentCommand}`,
`cwd=${config.cwd}`,
`stateDir=${config.stateDir}`,
...(client.initializeResult?.protocolVersion
? [`protocolVersion=${client.initializeResult.protocolVersion}`]
: []),
],
};
} catch (error) {
return {
ok: false,
message: "embedded ACP runtime probe failed",
details: [
`agent=${agentName}`,
`command=${agentCommand}`,
`cwd=${config.cwd}`,
`stateDir=${config.stateDir}`,
error instanceof Error ? error.message : String(error),
],
};
} finally {
await client.close().catch(() => {});
}
}

View File

@@ -0,0 +1,681 @@
import { randomUUID } from "node:crypto";
import type {
ContentBlock,
SessionNotification,
SessionUpdate,
ToolCall,
ToolCallUpdate,
UsageUpdate,
} from "@agentclientprotocol/sdk";
import { textPrompt } from "../prompt-content.js";
import type {
ClientOperation,
PromptInput,
SessionAcpxState,
SessionConversation,
SessionAgentContent,
SessionAgentMessage,
SessionMessage,
SessionTokenUsage,
SessionToolResult,
SessionToolResultContent,
SessionToolUse,
SessionUserContent,
} from "../runtime-types.js";
export type LegacyHistoryEntry = {
role: "user" | "assistant";
timestamp: string;
textPreview: string;
};
const MAX_RUNTIME_MESSAGES = 200;
const MAX_RUNTIME_AGENT_TEXT_CHARS = 8_000;
const MAX_RUNTIME_THINKING_CHARS = 4_000;
const MAX_RUNTIME_TOOL_IO_CHARS = 4_000;
const MAX_RUNTIME_REQUEST_TOKEN_USAGE = 100;
function isoNow(): string {
return new Date().toISOString();
}
function deepClone<T>(value: T): T {
try {
return structuredClone(value);
} catch {
return value;
}
}
function hasOwn(source: object, key: string): boolean {
return Object.prototype.hasOwnProperty.call(source, key);
}
function normalizeAgentName(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function extractText(content: ContentBlock): string | undefined {
if (content.type === "text") {
return content.text;
}
if (content.type === "resource_link") {
return content.title ?? content.name ?? content.uri;
}
if (content.type === "resource") {
if ("text" in content.resource && typeof content.resource.text === "string") {
return content.resource.text;
}
return content.resource.uri;
}
return undefined;
}
function contentToUserContent(content: ContentBlock): SessionUserContent | undefined {
if (content.type === "text") {
return {
Text: content.text,
};
}
if (content.type === "resource_link") {
const value = content.title ?? content.name ?? content.uri;
return {
Mention: {
uri: content.uri,
content: value,
},
};
}
if (content.type === "resource") {
if ("text" in content.resource && typeof content.resource.text === "string") {
return {
Text: content.resource.text,
};
}
return {
Mention: {
uri: content.resource.uri,
content: content.resource.uri,
},
};
}
if (content.type === "image") {
return {
Image: {
source: content.data,
size: null,
},
};
}
return undefined;
}
function nextUserMessageId(): string {
return randomUUID();
}
function isUserMessage(message: SessionMessage): message is {
User: SessionConversation["messages"][number] extends infer T
? T extends { User: infer U }
? U
: never
: never;
} {
return typeof message === "object" && message !== null && hasOwn(message, "User");
}
function isAgentMessage(message: SessionMessage): message is { Agent: SessionAgentMessage } {
return typeof message === "object" && message !== null && hasOwn(message, "Agent");
}
function isAgentTextContent(content: SessionAgentContent): content is { Text: string } {
return hasOwn(content, "Text");
}
function isAgentThinkingContent(
content: SessionAgentContent,
): content is { Thinking: { text: string; signature?: string | null } } {
return hasOwn(content, "Thinking");
}
function isAgentToolUseContent(
content: SessionAgentContent,
): content is { ToolUse: SessionToolUse } {
return hasOwn(content, "ToolUse");
}
function updateConversationTimestamp(conversation: SessionConversation, timestamp: string): void {
conversation.updated_at = timestamp;
}
function ensureAgentMessage(conversation: SessionConversation): SessionAgentMessage {
const last = conversation.messages.at(-1);
if (last && isAgentMessage(last)) {
return last.Agent;
}
const created: SessionAgentMessage = {
content: [],
tool_results: {},
};
conversation.messages.push({ Agent: created });
return created;
}
function appendAgentText(agent: SessionAgentMessage, text: string): void {
if (!text.trim()) {
return;
}
const last = agent.content.at(-1);
if (last && isAgentTextContent(last)) {
last.Text = trimRuntimeText(`${last.Text}${text}`, MAX_RUNTIME_AGENT_TEXT_CHARS);
return;
}
const next: SessionAgentContent = {
Text: text,
};
agent.content.push(next);
}
function appendAgentThinking(agent: SessionAgentMessage, text: string): void {
if (!text.trim()) {
return;
}
const last = agent.content.at(-1);
if (last && isAgentThinkingContent(last)) {
last.Thinking.text = trimRuntimeText(
`${last.Thinking.text}${text}`,
MAX_RUNTIME_THINKING_CHARS,
);
return;
}
const next: SessionAgentContent = {
Thinking: {
text,
signature: null,
},
};
agent.content.push(next);
}
function trimRuntimeText(value: string, maxChars: number): string {
if (value.length <= maxChars) {
return value;
}
return `${value.slice(0, Math.max(0, maxChars - 3))}...`;
}
function statusIndicatesComplete(status: unknown): boolean {
if (typeof status !== "string") {
return false;
}
const normalized = status.toLowerCase();
return (
normalized.includes("complete") ||
normalized.includes("done") ||
normalized.includes("success") ||
normalized.includes("failed") ||
normalized.includes("error") ||
normalized.includes("cancel")
);
}
function statusIndicatesError(status: unknown): boolean {
if (typeof status !== "string") {
return false;
}
const normalized = status.toLowerCase();
return normalized.includes("fail") || normalized.includes("error");
}
function toToolResultContent(value: unknown): SessionToolResultContent {
if (typeof value === "string") {
return { Text: trimRuntimeText(value, MAX_RUNTIME_TOOL_IO_CHARS) };
}
if (value != null) {
try {
return { Text: trimRuntimeText(JSON.stringify(value), MAX_RUNTIME_TOOL_IO_CHARS) };
} catch {
return { Text: "[Unserializable value]" };
}
}
return { Text: "" };
}
function toRawInput(value: unknown): string {
if (typeof value === "string") {
return trimRuntimeText(value, MAX_RUNTIME_TOOL_IO_CHARS);
}
try {
return trimRuntimeText(JSON.stringify(value ?? {}), MAX_RUNTIME_TOOL_IO_CHARS);
} catch {
return value == null ? "" : "[Unserializable input]";
}
}
function ensureToolUseContent(agent: SessionAgentMessage, toolCallId: string): SessionToolUse {
for (const content of agent.content) {
if (isAgentToolUseContent(content) && content.ToolUse.id === toolCallId) {
return content.ToolUse;
}
}
const created: SessionToolUse = {
id: toolCallId,
name: "tool_call",
raw_input: "{}",
input: {},
is_input_complete: false,
thought_signature: null,
};
agent.content.push({ ToolUse: created });
return created;
}
function upsertToolResult(
agent: SessionAgentMessage,
toolCallId: string,
patch: Partial<SessionToolResult>,
): void {
const existing = agent.tool_results[toolCallId];
const next: SessionToolResult = {
tool_use_id: toolCallId,
tool_name: patch.tool_name ?? existing?.tool_name ?? "tool_call",
is_error: patch.is_error ?? existing?.is_error ?? false,
content: patch.content ?? existing?.content ?? { Text: "" },
output: patch.output ?? existing?.output,
};
agent.tool_results[toolCallId] = next;
}
function applyToolCallUpdate(agent: SessionAgentMessage, update: ToolCall | ToolCallUpdate): void {
const tool = ensureToolUseContent(agent, update.toolCallId);
if (hasOwn(update, "title")) {
tool.name =
normalizeAgentName((update as { title?: unknown }).title) ?? tool.name ?? "tool_call";
}
if (hasOwn(update, "kind")) {
const kindName = normalizeAgentName((update as { kind?: unknown }).kind);
if (!tool.name || tool.name === "tool_call") {
tool.name = kindName ?? tool.name;
}
}
if (hasOwn(update, "rawInput")) {
const rawInput = deepClone((update as { rawInput?: unknown }).rawInput);
tool.input = rawInput ?? {};
tool.raw_input = toRawInput(rawInput);
}
if (hasOwn(update, "status")) {
tool.is_input_complete = statusIndicatesComplete((update as { status?: unknown }).status);
}
if (
hasOwn(update, "rawOutput") ||
hasOwn(update, "status") ||
hasOwn(update, "title") ||
hasOwn(update, "kind")
) {
const status = (update as { status?: unknown }).status;
const output = hasOwn(update, "rawOutput")
? deepClone((update as { rawOutput?: unknown }).rawOutput)
: undefined;
upsertToolResult(agent, update.toolCallId, {
tool_name: tool.name,
is_error: statusIndicatesError(status),
content: output === undefined ? undefined : toToolResultContent(output),
output,
});
}
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
return value as Record<string, unknown>;
}
function numberField(source: Record<string, unknown>, keys: readonly string[]): number | undefined {
for (const key of keys) {
const value = source[key];
if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
return value;
}
}
return undefined;
}
function usageToTokenUsage(update: UsageUpdate): SessionTokenUsage | undefined {
const updateRecord = asRecord(update);
const usageMeta = asRecord(updateRecord?._meta)?.usage;
const source = asRecord(usageMeta) ?? updateRecord;
if (!source) {
return undefined;
}
const normalized: SessionTokenUsage = {
input_tokens: numberField(source, ["input_tokens", "inputTokens"]),
output_tokens: numberField(source, ["output_tokens", "outputTokens"]),
cache_creation_input_tokens: numberField(source, [
"cache_creation_input_tokens",
"cacheCreationInputTokens",
"cachedWriteTokens",
]),
cache_read_input_tokens: numberField(source, [
"cache_read_input_tokens",
"cacheReadInputTokens",
"cachedReadTokens",
]),
};
if (
normalized.input_tokens === undefined &&
normalized.output_tokens === undefined &&
normalized.cache_creation_input_tokens === undefined &&
normalized.cache_read_input_tokens === undefined
) {
return undefined;
}
return normalized;
}
function ensureAcpxState(state: SessionAcpxState | undefined): SessionAcpxState {
return state ?? {};
}
function lastUserMessageId(conversation: SessionConversation): string | undefined {
for (let index = conversation.messages.length - 1; index >= 0; index -= 1) {
const message = conversation.messages[index];
if (message && isUserMessage(message)) {
return message.User.id;
}
}
return undefined;
}
export function createSessionConversation(timestamp = isoNow()): SessionConversation {
return {
title: null,
messages: [],
updated_at: timestamp,
cumulative_token_usage: {},
request_token_usage: {},
};
}
export function cloneSessionConversation(
conversation: SessionConversation | undefined,
): SessionConversation {
if (!conversation) {
return createSessionConversation();
}
return {
title: conversation.title,
messages: deepClone(conversation.messages ?? []),
updated_at: conversation.updated_at,
cumulative_token_usage: deepClone(conversation.cumulative_token_usage ?? {}),
request_token_usage: deepClone(conversation.request_token_usage ?? {}),
};
}
export function cloneSessionAcpxState(
state: SessionAcpxState | undefined,
): SessionAcpxState | undefined {
if (!state) {
return undefined;
}
return {
current_mode_id: state.current_mode_id,
desired_mode_id: state.desired_mode_id,
current_model_id: state.current_model_id,
available_models: state.available_models ? [...state.available_models] : undefined,
available_commands: state.available_commands ? [...state.available_commands] : undefined,
config_options: state.config_options ? deepClone(state.config_options) : undefined,
session_options: state.session_options
? {
model: state.session_options.model,
allowed_tools: state.session_options.allowed_tools
? [...state.session_options.allowed_tools]
: undefined,
max_turns: state.session_options.max_turns,
}
: undefined,
};
}
export function appendLegacyHistory(
conversation: SessionConversation,
entries: LegacyHistoryEntry[],
): void {
for (const entry of entries) {
const text = entry.textPreview?.trim();
if (!text) {
continue;
}
if (entry.role === "user") {
conversation.messages.push({
User: {
id: nextUserMessageId(),
content: [{ Text: text }],
},
});
} else {
conversation.messages.push({
Agent: {
content: [{ Text: text }],
tool_results: {},
},
});
}
updateConversationTimestamp(conversation, entry.timestamp || conversation.updated_at);
}
}
export function recordPromptSubmission(
conversation: SessionConversation,
prompt: PromptInput | string,
timestamp = isoNow(),
): void {
const normalizedPrompt = typeof prompt === "string" ? textPrompt(prompt) : prompt;
const userContent = normalizedPrompt
.map((content) => contentToUserContent(content))
.filter((content) => content !== undefined);
if (userContent.length === 0) {
return;
}
conversation.messages.push({
User: {
id: nextUserMessageId(),
content: userContent.map((content) => {
if ("Text" in content) {
return {
Text: trimRuntimeText(content.Text, MAX_RUNTIME_AGENT_TEXT_CHARS),
};
}
return content;
}),
},
});
updateConversationTimestamp(conversation, timestamp);
trimConversationForRuntime(conversation);
}
export function recordSessionUpdate(
conversation: SessionConversation,
state: SessionAcpxState | undefined,
notification: SessionNotification,
timestamp = isoNow(),
): SessionAcpxState {
const acpx = ensureAcpxState(state);
const update: SessionUpdate = notification.update;
switch (update.sessionUpdate) {
case "user_message_chunk": {
const userContent = contentToUserContent(update.content);
if (userContent) {
conversation.messages.push({
User: {
id: nextUserMessageId(),
content: [userContent],
},
});
}
break;
}
case "agent_message_chunk": {
const text = extractText(update.content);
if (text) {
const agent = ensureAgentMessage(conversation);
appendAgentText(agent, text);
}
break;
}
case "agent_thought_chunk": {
const text = extractText(update.content);
if (text) {
const agent = ensureAgentMessage(conversation);
appendAgentThinking(agent, text);
}
break;
}
case "tool_call":
case "tool_call_update": {
const agent = ensureAgentMessage(conversation);
applyToolCallUpdate(agent, update);
break;
}
case "usage_update": {
const usage = usageToTokenUsage(update);
if (usage) {
conversation.cumulative_token_usage = usage;
const userId = lastUserMessageId(conversation);
if (userId) {
conversation.request_token_usage[userId] = usage;
}
}
break;
}
case "session_info_update": {
if (hasOwn(update, "title")) {
conversation.title = update.title ?? null;
}
if (hasOwn(update, "updatedAt")) {
conversation.updated_at = update.updatedAt ?? conversation.updated_at;
}
break;
}
case "available_commands_update": {
acpx.available_commands = update.availableCommands
.map((entry) => entry.name)
.filter((entry) => typeof entry === "string" && entry.trim().length > 0);
break;
}
case "current_mode_update": {
acpx.current_mode_id = update.currentModeId;
break;
}
case "config_option_update": {
acpx.config_options = deepClone(update.configOptions);
break;
}
default:
break;
}
updateConversationTimestamp(conversation, timestamp);
trimConversationForRuntime(conversation);
return acpx;
}
export function recordClientOperation(
conversation: SessionConversation,
state: SessionAcpxState | undefined,
operation: ClientOperation,
timestamp = isoNow(),
): SessionAcpxState {
const acpx = ensureAcpxState(state);
updateConversationTimestamp(conversation, timestamp);
trimConversationForRuntime(conversation);
return acpx;
}
export function trimConversationForRuntime(conversation: SessionConversation): void {
if (conversation.messages.length > MAX_RUNTIME_MESSAGES) {
conversation.messages = conversation.messages.slice(-MAX_RUNTIME_MESSAGES);
}
for (const message of conversation.messages) {
if (!isAgentMessage(message)) {
if (isUserMessage(message)) {
message.User.content = message.User.content.map((content) => {
if ("Text" in content) {
return {
Text: trimRuntimeText(content.Text, MAX_RUNTIME_AGENT_TEXT_CHARS),
};
}
return content;
});
}
continue;
}
for (const content of message.Agent.content) {
if ("Text" in content) {
content.Text = trimRuntimeText(content.Text, MAX_RUNTIME_AGENT_TEXT_CHARS);
} else if ("Thinking" in content) {
content.Thinking.text = trimRuntimeText(content.Thinking.text, MAX_RUNTIME_THINKING_CHARS);
} else if ("ToolUse" in content) {
content.ToolUse.raw_input = trimRuntimeText(
content.ToolUse.raw_input,
MAX_RUNTIME_TOOL_IO_CHARS,
);
}
}
for (const result of Object.values(message.Agent.tool_results)) {
if ("Text" in result.content) {
result.content.Text = trimRuntimeText(result.content.Text, MAX_RUNTIME_TOOL_IO_CHARS);
}
if (typeof result.output === "string") {
result.output = trimRuntimeText(result.output, MAX_RUNTIME_TOOL_IO_CHARS);
}
}
}
const requestUsageEntries = Object.entries(conversation.request_token_usage);
if (requestUsageEntries.length > MAX_RUNTIME_REQUEST_TOKEN_USAGE) {
conversation.request_token_usage = Object.fromEntries(
requestUsageEntries.slice(-MAX_RUNTIME_REQUEST_TOKEN_USAGE),
);
}
}

View File

@@ -0,0 +1,315 @@
import { safeParseJsonWithSchema } from "openclaw/plugin-sdk/extension-shared";
import { z } from "zod";
import type { AcpRuntimeEvent, AcpSessionUpdateTag } from "../../runtime-api.js";
import {
asOptionalBoolean,
asOptionalString,
asString,
asTrimmedString,
type AcpxErrorEvent,
type AcpxJsonObject,
isRecord,
} from "./shared.js";
const AcpxJsonObjectSchema = z.record(z.string(), z.unknown());
const AcpxErrorEventSchema = z.object({
type: z.literal("error"),
message: z.string().trim().min(1).catch("acpx reported an error"),
code: z.string().optional(),
retryable: z.boolean().optional(),
});
export function toAcpxErrorEvent(value: unknown): AcpxErrorEvent | null {
const parsed = AcpxErrorEventSchema.safeParse(value);
return parsed.success ? parsed.data : null;
}
export function parseJsonLines(value: string): AcpxJsonObject[] {
const events: AcpxJsonObject[] = [];
for (const line of value.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
const parsed = safeParseJsonWithSchema(AcpxJsonObjectSchema, trimmed);
if (parsed) {
events.push(parsed);
}
}
return events;
}
function asOptionalFiniteNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function resolveStructuredPromptPayload(parsed: Record<string, unknown>): {
type: string;
payload: Record<string, unknown>;
tag?: AcpSessionUpdateTag;
} {
const method = asTrimmedString(parsed.method);
if (method === "session/update") {
const params = parsed.params;
if (isRecord(params) && isRecord(params.update)) {
const update = params.update;
const tag = asOptionalString(update.sessionUpdate) as AcpSessionUpdateTag | undefined;
return {
type: tag ?? "",
payload: update,
...(tag ? { tag } : {}),
};
}
}
const sessionUpdate = asOptionalString(parsed.sessionUpdate) as AcpSessionUpdateTag | undefined;
if (sessionUpdate) {
return {
type: sessionUpdate,
payload: parsed,
tag: sessionUpdate,
};
}
const type = asTrimmedString(parsed.type);
const tag = asOptionalString(parsed.tag) as AcpSessionUpdateTag | undefined;
return {
type,
payload: parsed,
...(tag ? { tag } : {}),
};
}
function resolveStatusTextForTag(params: {
tag: AcpSessionUpdateTag;
payload: Record<string, unknown>;
}): string | null {
const { tag, payload } = params;
if (tag === "available_commands_update") {
const commands = Array.isArray(payload.availableCommands) ? payload.availableCommands : [];
return commands.length > 0
? `available commands updated (${commands.length})`
: "available commands updated";
}
if (tag === "current_mode_update") {
const mode =
asTrimmedString(payload.currentModeId) ||
asTrimmedString(payload.modeId) ||
asTrimmedString(payload.mode);
return mode ? `mode updated: ${mode}` : "mode updated";
}
if (tag === "config_option_update") {
const id = asTrimmedString(payload.id) || asTrimmedString(payload.configOptionId);
const value =
asTrimmedString(payload.currentValue) ||
asTrimmedString(payload.value) ||
asTrimmedString(payload.optionValue);
if (id && value) {
return `config updated: ${id}=${value}`;
}
if (id) {
return `config updated: ${id}`;
}
return "config updated";
}
if (tag === "session_info_update") {
return (
asTrimmedString(payload.summary) || asTrimmedString(payload.message) || "session updated"
);
}
if (tag === "plan") {
const entries = Array.isArray(payload.entries) ? payload.entries : [];
const first = entries.find((entry) => isRecord(entry)) as Record<string, unknown> | undefined;
const content = asTrimmedString(first?.content);
return content ? `plan: ${content}` : null;
}
return null;
}
function resolveTextChunk(params: {
payload: Record<string, unknown>;
stream: "output" | "thought";
tag: AcpSessionUpdateTag;
}): AcpRuntimeEvent | null {
const contentRaw = params.payload.content;
if (isRecord(contentRaw)) {
const contentType = asTrimmedString(contentRaw.type);
if (contentType && contentType !== "text") {
return null;
}
const text = asString(contentRaw.text);
if (text && text.length > 0) {
return {
type: "text_delta",
text,
stream: params.stream,
tag: params.tag,
};
}
}
const text = asString(params.payload.text);
if (!text || text.length === 0) {
return null;
}
return {
type: "text_delta",
text,
stream: params.stream,
tag: params.tag,
};
}
function createTextDeltaEvent(params: {
content: string | null | undefined;
stream: "output" | "thought";
tag?: AcpSessionUpdateTag;
}): AcpRuntimeEvent | null {
if (params.content == null || params.content.length === 0) {
return null;
}
return {
type: "text_delta",
text: params.content,
stream: params.stream,
...(params.tag ? { tag: params.tag } : {}),
};
}
function createToolCallEvent(params: {
payload: Record<string, unknown>;
tag: AcpSessionUpdateTag;
}): AcpRuntimeEvent {
const title = asTrimmedString(params.payload.title) || "tool call";
const status = asTrimmedString(params.payload.status);
const toolCallId = asOptionalString(params.payload.toolCallId);
return {
type: "tool_call",
text: status ? `${title} (${status})` : title,
tag: params.tag,
...(toolCallId ? { toolCallId } : {}),
...(status ? { status } : {}),
title,
};
}
export function parsePromptEventLine(line: string): AcpRuntimeEvent | null {
const trimmed = line.trim();
if (!trimmed) {
return null;
}
const parsed = safeParseJsonWithSchema(AcpxJsonObjectSchema, trimmed);
if (!parsed) {
return {
type: "status",
text: trimmed,
};
}
const structured = resolveStructuredPromptPayload(parsed);
const type = structured.type;
const payload = structured.payload;
const tag = structured.tag;
switch (type) {
case "text":
return createTextDeltaEvent({
content: asString(payload.content),
stream: "output",
tag,
});
case "thought":
return createTextDeltaEvent({
content: asString(payload.content),
stream: "thought",
tag,
});
case "tool_call":
return createToolCallEvent({
payload,
tag: (tag ?? "tool_call") as AcpSessionUpdateTag,
});
case "tool_call_update":
return createToolCallEvent({
payload,
tag: (tag ?? "tool_call_update") as AcpSessionUpdateTag,
});
case "agent_message_chunk":
return resolveTextChunk({
payload,
stream: "output",
tag: "agent_message_chunk",
});
case "agent_thought_chunk":
return resolveTextChunk({
payload,
stream: "thought",
tag: "agent_thought_chunk",
});
case "usage_update": {
const used = asOptionalFiniteNumber(payload.used);
const size = asOptionalFiniteNumber(payload.size);
const text =
used != null && size != null ? `usage updated: ${used}/${size}` : "usage updated";
return {
type: "status",
text,
tag: "usage_update",
...(used != null ? { used } : {}),
...(size != null ? { size } : {}),
};
}
case "available_commands_update":
case "current_mode_update":
case "config_option_update":
case "session_info_update":
case "plan": {
const text = resolveStatusTextForTag({
tag: type as AcpSessionUpdateTag,
payload,
});
if (!text) {
return null;
}
return {
type: "status",
text,
tag: type as AcpSessionUpdateTag,
};
}
case "client_operation": {
const method = asTrimmedString(payload.method) || "operation";
const status = asTrimmedString(payload.status);
const summary = asTrimmedString(payload.summary);
const text = [method, status, summary].filter(Boolean).join(" ");
if (!text) {
return null;
}
return { type: "status", text, ...(tag ? { tag } : {}) };
}
case "update": {
const update = asTrimmedString(payload.update);
if (!update) {
return null;
}
return { type: "status", text: update, ...(tag ? { tag } : {}) };
}
case "done": {
return {
type: "done",
stopReason: asOptionalString(payload.stopReason),
};
}
case "error": {
const message = asTrimmedString(payload.message) || "acpx runtime error";
return {
type: "error",
message,
code: asOptionalString(payload.code),
retryable: asOptionalBoolean(payload.retryable),
};
}
default:
return null;
}
}

View File

@@ -0,0 +1,56 @@
import type { ResolvedAcpxPluginConfig } from "../config.js";
export type AcpxHandleState = {
name: string;
agent: string;
cwd: string;
mode: "persistent" | "oneshot";
acpxRecordId?: string;
backendSessionId?: string;
agentSessionId?: string;
};
export type AcpxJsonObject = Record<string, unknown>;
export type AcpxErrorEvent = {
message: string;
code?: string;
retryable?: boolean;
};
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function asTrimmedString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
export function asString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
export function asOptionalString(value: unknown): string | undefined {
const text = asTrimmedString(value);
return text || undefined;
}
export function asOptionalBoolean(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
}
export function deriveAgentFromSessionKey(sessionKey: string, fallbackAgent: string): string {
const match = sessionKey.match(/^agent:([^:]+):/i);
const candidate = match?.[1] ? asTrimmedString(match[1]) : "";
return candidate || fallbackAgent;
}
export function buildPermissionArgs(mode: ResolvedAcpxPluginConfig["permissionMode"]): string[] {
if (mode === "approve-all") {
return ["--approve-all"];
}
if (mode === "deny-all") {
return ["--deny-all"];
}
return ["--approve-reads"];
}

View File

@@ -0,0 +1,88 @@
import type { PerfMetricsSnapshot } from "./runtime-types.js";
type TimingBucket = {
count: number;
totalMs: number;
maxMs: number;
};
const counters = new Map<string, number>();
const gauges = new Map<string, number>();
const timings = new Map<string, TimingBucket>();
function hrNow(): bigint {
return process.hrtime.bigint();
}
function durationMs(start: bigint): number {
return Number(process.hrtime.bigint() - start) / 1_000_000;
}
function roundMetric(value: number): number {
return Number(value.toFixed(3));
}
export function incrementPerfCounter(name: string, delta = 1): void {
counters.set(name, (counters.get(name) ?? 0) + delta);
}
export function setPerfGauge(name: string, value: number): void {
gauges.set(name, value);
}
export function recordPerfDuration(name: string, durationMsValue: number): void {
const next = timings.get(name) ?? {
count: 0,
totalMs: 0,
maxMs: 0,
};
next.count += 1;
next.totalMs += durationMsValue;
next.maxMs = Math.max(next.maxMs, durationMsValue);
timings.set(name, next);
}
export async function measurePerf<T>(name: string, run: () => Promise<T>): Promise<T> {
const startedAt = hrNow();
try {
return await run();
} finally {
recordPerfDuration(name, durationMs(startedAt));
}
}
export function startPerfTimer(name: string): () => number {
const startedAt = hrNow();
return () => {
const elapsedMs = durationMs(startedAt);
recordPerfDuration(name, elapsedMs);
return elapsedMs;
};
}
export function getPerfMetricsSnapshot(): PerfMetricsSnapshot {
return {
counters: Object.fromEntries(counters.entries()),
gauges: Object.fromEntries(gauges.entries()),
timings: Object.fromEntries(
[...timings.entries()].map(([name, bucket]) => [
name,
{
count: bucket.count,
totalMs: roundMetric(bucket.totalMs),
maxMs: roundMetric(bucket.maxMs),
},
]),
),
};
}
export function resetPerfMetrics(): void {
counters.clear();
gauges.clear();
timings.clear();
}
export function formatPerfMetric(name: string, durationMsValue: number): string {
return `${name}=${roundMetric(durationMsValue)}ms`;
}

View File

@@ -0,0 +1,217 @@
import type { ContentBlock } from "@agentclientprotocol/sdk";
export type PromptInput = ContentBlock[];
export class PromptInputValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "PromptInputValidationError";
}
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
return value as Record<string, unknown>;
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function isBase64Data(value: string): boolean {
if (value.length === 0 || value.length % 4 !== 0) {
return false;
}
return /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value);
}
function isImageMimeType(value: string): boolean {
return /^image\/[A-Za-z0-9.+-]+$/i.test(value);
}
function isTextBlock(value: unknown): value is Extract<ContentBlock, { type: "text" }> {
const record = asRecord(value);
return record?.type === "text" && typeof record.text === "string";
}
function isImageBlock(value: unknown): value is Extract<ContentBlock, { type: "image" }> {
const record = asRecord(value);
return (
record?.type === "image" &&
isNonEmptyString(record.mimeType) &&
isImageMimeType(record.mimeType) &&
typeof record.data === "string" &&
isBase64Data(record.data)
);
}
function isResourceLinkBlock(
value: unknown,
): value is Extract<ContentBlock, { type: "resource_link" }> {
const record = asRecord(value);
return (
record?.type === "resource_link" &&
isNonEmptyString(record.uri) &&
(record.title === undefined || typeof record.title === "string") &&
(record.name === undefined || typeof record.name === "string")
);
}
function isResourcePayload(value: unknown): boolean {
const record = asRecord(value);
if (!record || !isNonEmptyString(record.uri)) {
return false;
}
return record.text === undefined || typeof record.text === "string";
}
function isResourceBlock(value: unknown): value is Extract<ContentBlock, { type: "resource" }> {
const record = asRecord(value);
return record?.type === "resource" && isResourcePayload(record.resource);
}
function isContentBlock(value: unknown): value is ContentBlock {
return (
isTextBlock(value) ||
isImageBlock(value) ||
isResourceLinkBlock(value) ||
isResourceBlock(value)
);
}
function getContentBlockValidationError(value: unknown, index: number): string | undefined {
const record = asRecord(value);
if (!record || typeof record.type !== "string") {
return `prompt[${index}] must be an ACP content block object`;
}
switch (record.type) {
case "text":
return typeof record.text === "string"
? undefined
: `prompt[${index}] text block must include a string text field`;
case "image":
if (!isNonEmptyString(record.mimeType)) {
return `prompt[${index}] image block must include a non-empty mimeType`;
}
if (!isImageMimeType(record.mimeType)) {
return `prompt[${index}] image block mimeType must start with image/`;
}
if (typeof record.data !== "string" || record.data.length === 0) {
return `prompt[${index}] image block must include non-empty base64 data`;
}
if (!isBase64Data(record.data)) {
return `prompt[${index}] image block data must be valid base64`;
}
return undefined;
case "resource_link":
if (!isNonEmptyString(record.uri)) {
return `prompt[${index}] resource_link block must include a non-empty uri`;
}
if (record.title !== undefined && typeof record.title !== "string") {
return `prompt[${index}] resource_link block title must be a string when present`;
}
if (record.name !== undefined && typeof record.name !== "string") {
return `prompt[${index}] resource_link block name must be a string when present`;
}
return undefined;
case "resource":
if (!asRecord(record.resource)) {
return `prompt[${index}] resource block must include a resource object`;
}
if (!isResourcePayload(record.resource)) {
return `prompt[${index}] resource block resource must include a non-empty uri and optional text`;
}
return undefined;
default:
return `prompt[${index}] has unsupported content block type ${JSON.stringify(record.type)}`;
}
}
export function isPromptInput(value: unknown): value is PromptInput {
return Array.isArray(value) && value.every((entry) => isContentBlock(entry));
}
export function textPrompt(text: string): PromptInput {
return [
{
type: "text",
text,
},
];
}
function parseStructuredPrompt(source: string): PromptInput | undefined {
if (!source.startsWith("[")) {
return undefined;
}
try {
const parsed = JSON.parse(source) as unknown;
if (isPromptInput(parsed)) {
return parsed;
}
if (Array.isArray(parsed)) {
const detail =
parsed
.map((entry, index) => getContentBlockValidationError(entry, index))
.find((message) => message !== undefined) ??
"Structured prompt JSON must be an array of valid ACP content blocks";
throw new PromptInputValidationError(detail);
}
return undefined;
} catch (error) {
if (error instanceof PromptInputValidationError) {
throw error;
}
return undefined;
}
}
export function parsePromptSource(source: string): PromptInput {
const trimmed = source.trim();
const structured = parseStructuredPrompt(trimmed);
if (structured) {
return structured;
}
if (!trimmed) {
return [];
}
return textPrompt(trimmed);
}
export function mergePromptSourceWithText(source: string, suffixText: string): PromptInput {
const prompt = parsePromptSource(source);
const appended = suffixText.trim();
if (!appended) {
return prompt;
}
if (prompt.length === 0) {
return textPrompt(appended);
}
return [...prompt, ...textPrompt(appended)];
}
export function promptToDisplayText(prompt: PromptInput): string {
return prompt
.map((block) => {
switch (block.type) {
case "text":
return block.text;
case "resource_link":
return block.title ?? block.name ?? block.uri;
case "resource":
return "text" in block.resource && typeof block.resource.text === "string"
? block.resource.text
: block.resource.uri;
case "image":
return `[image] ${block.mimeType}`;
default:
return "";
}
})
.filter((entry) => entry.trim().length > 0)
.join("\n\n")
.trim();
}

View File

@@ -0,0 +1,15 @@
import {
AGENT_SESSION_ID_META_KEYS,
extractAgentSessionId,
normalizeAgentSessionId,
} from "./agent-session-id.js";
export const RUNTIME_SESSION_ID_META_KEYS = AGENT_SESSION_ID_META_KEYS;
export function normalizeRuntimeSessionId(value: unknown): string | undefined {
return normalizeAgentSessionId(value);
}
export function extractRuntimeSessionId(meta: unknown): string | undefined {
return extractAgentSessionId(meta);
}

View File

@@ -0,0 +1,372 @@
import type {
AgentCapabilities,
AnyMessage,
McpServer,
SessionNotification,
SessionConfigOption,
SetSessionConfigOptionResponse,
StopReason,
} from "@agentclientprotocol/sdk";
export type { McpServer, SessionNotification } from "@agentclientprotocol/sdk";
import type { PromptInput } from "./prompt-content.js";
export const EXIT_CODES = {
SUCCESS: 0,
ERROR: 1,
USAGE: 2,
TIMEOUT: 3,
NO_SESSION: 4,
PERMISSION_DENIED: 5,
INTERRUPTED: 130,
} as const;
export type ExitCode = (typeof EXIT_CODES)[keyof typeof EXIT_CODES];
export const OUTPUT_FORMATS = ["text", "json", "quiet"] as const;
export type OutputFormat = (typeof OUTPUT_FORMATS)[number];
export const PERMISSION_MODES = ["approve-all", "approve-reads", "deny-all"] as const;
export type PermissionMode = (typeof PERMISSION_MODES)[number];
export const AUTH_POLICIES = ["skip", "fail"] as const;
export type AuthPolicy = (typeof AUTH_POLICIES)[number];
export const NON_INTERACTIVE_PERMISSION_POLICIES = ["deny", "fail"] as const;
export type NonInteractivePermissionPolicy = (typeof NON_INTERACTIVE_PERMISSION_POLICIES)[number];
export const SESSION_RESUME_POLICIES = ["allow-new", "same-session-only"] as const;
export type SessionResumePolicy = (typeof SESSION_RESUME_POLICIES)[number];
export const OUTPUT_STREAMS = ["prompt", "control"] as const;
export type OutputStream = (typeof OUTPUT_STREAMS)[number];
export type AcpJsonRpcMessage = AnyMessage;
export type AcpMessageDirection = "outbound" | "inbound";
export const OUTPUT_ERROR_CODES = [
"NO_SESSION",
"TIMEOUT",
"PERMISSION_DENIED",
"PERMISSION_PROMPT_UNAVAILABLE",
"RUNTIME",
"USAGE",
] as const;
export type OutputErrorCode = (typeof OUTPUT_ERROR_CODES)[number];
export const OUTPUT_ERROR_ORIGINS = ["cli", "runtime", "queue", "acp"] as const;
export type OutputErrorOrigin = (typeof OUTPUT_ERROR_ORIGINS)[number];
export const QUEUE_ERROR_DETAIL_CODES = [
"QUEUE_OWNER_CLOSED",
"QUEUE_OWNER_SHUTTING_DOWN",
"QUEUE_OWNER_OVERLOADED",
"QUEUE_OWNER_GENERATION_MISMATCH",
"QUEUE_REQUEST_INVALID",
"QUEUE_REQUEST_PAYLOAD_INVALID_JSON",
"QUEUE_ACK_MISSING",
"QUEUE_DISCONNECTED_BEFORE_ACK",
"QUEUE_DISCONNECTED_BEFORE_COMPLETION",
"QUEUE_PROTOCOL_INVALID_JSON",
"QUEUE_PROTOCOL_MALFORMED_MESSAGE",
"QUEUE_PROTOCOL_UNEXPECTED_RESPONSE",
"QUEUE_NOT_ACCEPTING_REQUESTS",
"QUEUE_CONTROL_REQUEST_FAILED",
"QUEUE_RUNTIME_PROMPT_FAILED",
] as const;
export type QueueErrorDetailCode = (typeof QUEUE_ERROR_DETAIL_CODES)[number];
export type OutputErrorAcpPayload = {
code: number;
message: string;
data?: unknown;
};
export type PermissionStats = {
requested: number;
approved: number;
denied: number;
cancelled: number;
};
export type ClientOperationMethod =
| "fs/read_text_file"
| "fs/write_text_file"
| "terminal/create"
| "terminal/output"
| "terminal/wait_for_exit"
| "terminal/kill"
| "terminal/release";
export type ClientOperationStatus = "running" | "completed" | "failed";
export type ClientOperation = {
method: ClientOperationMethod;
status: ClientOperationStatus;
summary: string;
details?: string;
timestamp: string;
};
export type SessionEventLog = {
active_path: string;
segment_count: number;
max_segment_bytes: number;
max_segments: number;
last_write_at?: string;
last_write_error?: string | null;
};
export type PerfMetricSummary = {
count: number;
totalMs: number;
maxMs: number;
};
export type PerfMetricsSnapshot = {
counters: Record<string, number>;
timings: Record<string, PerfMetricSummary>;
gauges: Record<string, number>;
};
export type OutputFormatterContext = {
sessionId: string;
};
export type OutputPolicy = {
format: OutputFormat;
jsonStrict: boolean;
suppressReads: boolean;
suppressNonJsonStderr: boolean;
queueErrorAlreadyEmitted: boolean;
suppressSdkConsoleErrors: boolean;
};
export type OutputErrorEmissionPolicy = {
queueErrorAlreadyEmitted: boolean;
};
export interface OutputFormatter {
setContext(context: OutputFormatterContext): void;
onAcpMessage(message: AcpJsonRpcMessage): void;
onError(params: {
code: OutputErrorCode;
detailCode?: string;
origin?: OutputErrorOrigin;
message: string;
retryable?: boolean;
acp?: OutputErrorAcpPayload;
timestamp?: string;
}): void;
flush(): void;
}
export type AcpClientOptions = {
agentCommand: string;
cwd: string;
mcpServers?: McpServer[];
permissionMode: PermissionMode;
nonInteractivePermissions?: NonInteractivePermissionPolicy;
authCredentials?: Record<string, string>;
authPolicy?: AuthPolicy;
suppressSdkConsoleErrors?: boolean;
verbose?: boolean;
sessionOptions?: {
model?: string;
allowedTools?: string[];
maxTurns?: number;
};
onAcpMessage?: (direction: AcpMessageDirection, message: AcpJsonRpcMessage) => void;
onAcpOutputMessage?: (direction: AcpMessageDirection, message: AcpJsonRpcMessage) => void;
onSessionUpdate?: (notification: SessionNotification) => void;
onClientOperation?: (operation: ClientOperation) => void;
};
export const SESSION_RECORD_SCHEMA = "openclaw.acpx.session.v1" as const;
export type SessionMessageImage = {
source: string;
size?: {
width: number;
height: number;
} | null;
};
export type SessionUserContent =
| {
Text: string;
}
| {
Mention: {
uri: string;
content: string;
};
}
| {
Image: SessionMessageImage;
};
export type SessionToolUse = {
id: string;
name: string;
raw_input: string;
input: unknown;
is_input_complete: boolean;
thought_signature?: string | null;
};
export type SessionToolResultContent =
| {
Text: string;
}
| {
Image: SessionMessageImage;
};
export type SessionToolResult = {
tool_use_id: string;
tool_name: string;
is_error: boolean;
content: SessionToolResultContent;
output?: unknown;
};
export type SessionAgentContent =
| {
Text: string;
}
| {
Thinking: {
text: string;
signature?: string | null;
};
}
| {
RedactedThinking: string;
}
| {
ToolUse: SessionToolUse;
};
export type SessionUserMessage = {
id: string;
content: SessionUserContent[];
};
export type SessionAgentMessage = {
content: SessionAgentContent[];
tool_results: Record<string, SessionToolResult>;
reasoning_details?: unknown;
};
export type SessionMessage =
| {
User: SessionUserMessage;
}
| {
Agent: SessionAgentMessage;
}
| "Resume";
export type SessionTokenUsage = {
input_tokens?: number;
output_tokens?: number;
cache_creation_input_tokens?: number;
cache_read_input_tokens?: number;
};
export type SessionConversation = {
title?: string | null;
messages: SessionMessage[];
updated_at: string;
cumulative_token_usage: SessionTokenUsage;
request_token_usage: Record<string, SessionTokenUsage>;
};
export type SessionAcpxState = {
current_mode_id?: string;
desired_mode_id?: string;
current_model_id?: string;
available_models?: string[];
available_commands?: string[];
config_options?: SessionConfigOption[];
session_options?: {
model?: string;
allowed_tools?: string[];
max_turns?: number;
};
};
export type SessionRecord = {
schema: typeof SESSION_RECORD_SCHEMA;
acpxRecordId: string;
acpSessionId: string;
agentSessionId?: string;
agentCommand: string;
cwd: string;
name?: string;
createdAt: string;
lastUsedAt: string;
lastSeq: number;
lastRequestId?: string;
eventLog: SessionEventLog;
closed?: boolean;
closedAt?: string;
pid?: number;
agentStartedAt?: string;
lastPromptAt?: string;
lastAgentExitCode?: number | null;
lastAgentExitSignal?: NodeJS.Signals | null;
lastAgentExitAt?: string;
lastAgentDisconnectReason?: string;
protocolVersion?: number;
agentCapabilities?: AgentCapabilities;
title?: string | null;
messages: SessionMessage[];
updated_at: string;
cumulative_token_usage: SessionTokenUsage;
request_token_usage: Record<string, SessionTokenUsage>;
acpx?: SessionAcpxState;
};
export type RunPromptResult = {
stopReason: StopReason;
permissionStats: PermissionStats;
sessionId: string;
};
export type SessionSendResult = RunPromptResult & {
record: SessionRecord;
resumed: boolean;
loadError?: string;
};
export type SessionSetModeResult = {
record: SessionRecord;
resumed: boolean;
loadError?: string;
};
export type SessionSetConfigOptionResult = {
record: SessionRecord;
response: SetSessionConfigOptionResponse;
resumed: boolean;
loadError?: string;
};
export type SessionSetModelResult = {
record: SessionRecord;
resumed: boolean;
loadError?: string;
};
export type SessionEnsureResult = {
record: SessionRecord;
created: boolean;
};
export type SessionEnqueueResult = {
queued: true;
sessionId: string;
requestId: string;
};
export type SessionSendOutcome = SessionSendResult | SessionEnqueueResult;
export type { PromptInput };

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,314 +1,105 @@
import fs from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { AcpRuntimeError } from "openclaw/plugin-sdk/acp-runtime";
import {
__testing,
getAcpRuntimeBackend,
requireAcpRuntimeBackend,
} from "openclaw/plugin-sdk/acp-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AcpRuntime, OpenClawPluginServiceContext } from "../runtime-api.js";
import { ACPX_BUNDLED_BIN, ACPX_PINNED_VERSION } from "./config.js";
import { afterEach, describe, expect, it, vi } from "vitest";
const { runtimeRegistry } = vi.hoisted(() => ({
runtimeRegistry: new Map<string, { runtime: unknown; healthy?: () => boolean }>(),
}));
vi.mock("../runtime-api.js", () => ({
getAcpRuntimeBackend: (id: string) => runtimeRegistry.get(id),
registerAcpRuntimeBackend: (entry: { id: string; runtime: unknown; healthy?: () => boolean }) => {
runtimeRegistry.set(entry.id, entry);
},
unregisterAcpRuntimeBackend: (id: string) => {
runtimeRegistry.delete(id);
},
}));
import { getAcpRuntimeBackend } from "../runtime-api.js";
import { createAcpxRuntimeService } from "./service.js";
const { ensureAcpxSpy } = vi.hoisted(() => ({
ensureAcpxSpy: vi.fn(async () => {}),
}));
const tempDirs: string[] = [];
vi.mock("./ensure.js", () => ({
ensureAcpx: ensureAcpxSpy,
}));
type RuntimeStub = AcpRuntime & {
probeAvailability(): Promise<void>;
isHealthy(): boolean;
doctor?(): Promise<{
ok: boolean;
message: string;
details?: string[];
}>;
};
function createRuntimeStub(healthy: boolean): {
runtime: RuntimeStub;
probeAvailabilitySpy: ReturnType<typeof vi.fn>;
isHealthySpy: ReturnType<typeof vi.fn>;
} {
const probeAvailabilitySpy = vi.fn(async () => {});
const isHealthySpy = vi.fn(() => healthy);
return {
runtime: {
ensureSession: vi.fn(async (input) => ({
sessionKey: input.sessionKey,
backend: "acpx",
runtimeSessionName: input.sessionKey,
})),
runTurn: vi.fn(async function* () {
yield { type: "done" as const };
}),
cancel: vi.fn(async () => {}),
close: vi.fn(async () => {}),
async probeAvailability() {
await probeAvailabilitySpy();
},
isHealthy() {
return isHealthySpy();
},
},
probeAvailabilitySpy,
isHealthySpy,
};
async function makeTempDir(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-acpx-service-"));
tempDirs.push(dir);
return dir;
}
function createRetryingRuntimeStub(
healthSequence: boolean[],
doctorReport: { ok: boolean; message: string; details?: string[] } = {
ok: false,
message: "acpx help check failed",
details: ["stderr=temporary startup race"],
},
): {
runtime: RuntimeStub;
probeAvailabilitySpy: ReturnType<typeof vi.fn>;
isHealthySpy: ReturnType<typeof vi.fn>;
doctorSpy: ReturnType<typeof vi.fn>;
} {
let probeCount = 0;
const probeAvailabilitySpy = vi.fn(async () => {
probeCount += 1;
});
const isHealthySpy = vi.fn(() => {
const index = Math.max(0, probeCount - 1);
return healthSequence[Math.min(index, healthSequence.length - 1)] ?? false;
});
const doctorSpy = vi.fn(async () => doctorReport);
return {
runtime: {
ensureSession: vi.fn(async (input) => ({
sessionKey: input.sessionKey,
backend: "acpx",
runtimeSessionName: input.sessionKey,
})),
runTurn: vi.fn(async function* () {
yield { type: "done" as const };
}),
cancel: vi.fn(async () => {}),
close: vi.fn(async () => {}),
async probeAvailability() {
await probeAvailabilitySpy();
},
isHealthy() {
return isHealthySpy();
},
async doctor() {
return await doctorSpy();
},
},
probeAvailabilitySpy,
isHealthySpy,
doctorSpy,
};
}
afterEach(async () => {
runtimeRegistry.clear();
for (const dir of tempDirs.splice(0)) {
await fs.rm(dir, { recursive: true, force: true });
}
});
function createServiceContext(
overrides: Partial<OpenClawPluginServiceContext> = {},
): OpenClawPluginServiceContext {
function createServiceContext(workspaceDir: string) {
return {
workspaceDir,
stateDir: path.join(workspaceDir, ".openclaw-plugin-state"),
config: {},
workspaceDir: "/tmp/workspace",
stateDir: "/tmp/state",
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
...overrides,
};
}
describe("createAcpxRuntimeService", () => {
beforeEach(() => {
__testing.resetAcpRuntimeBackendsForTests();
ensureAcpxSpy.mockReset();
ensureAcpxSpy.mockImplementation(async () => {});
});
it("registers and unregisters the acpx backend", async () => {
const { runtime, probeAvailabilitySpy } = createRuntimeStub(true);
it("registers and unregisters the embedded backend", async () => {
const workspaceDir = await makeTempDir();
const ctx = createServiceContext(workspaceDir);
const runtime = {
ensureSession: vi.fn(),
runTurn: vi.fn(),
cancel: vi.fn(),
close: vi.fn(),
probeAvailability: vi.fn(async () => {}),
isHealthy: vi.fn(() => true),
doctor: vi.fn(async () => ({ ok: true, message: "ok" })),
};
const service = createAcpxRuntimeService({
runtimeFactory: () => runtime,
runtimeFactory: () => runtime as never,
});
const context = createServiceContext();
await service.start(context);
await service.start(ctx);
expect(getAcpRuntimeBackend("acpx")?.runtime).toBe(runtime);
await vi.waitFor(() => {
expect(ensureAcpxSpy).toHaveBeenCalledOnce();
expect(ensureAcpxSpy).toHaveBeenCalledWith(
expect.objectContaining({
stripProviderAuthEnvVars: true,
}),
);
expect(probeAvailabilitySpy).toHaveBeenCalledOnce();
});
await service.stop?.(ctx);
await service.stop?.(context);
expect(getAcpRuntimeBackend("acpx")).toBeNull();
expect(getAcpRuntimeBackend("acpx")).toBeUndefined();
});
it("marks backend unavailable when runtime health check fails", async () => {
const { runtime } = createRuntimeStub(false);
const service = createAcpxRuntimeService({
runtimeFactory: () => runtime,
healthProbeRetryDelaysMs: [],
});
const context = createServiceContext();
await service.start(context);
expect(() => requireAcpRuntimeBackend("acpx")).toThrowError(AcpRuntimeError);
try {
requireAcpRuntimeBackend("acpx");
throw new Error("expected ACP backend lookup to fail");
} catch (error) {
expect((error as AcpRuntimeError).code).toBe("ACP_BACKEND_UNAVAILABLE");
}
});
it("passes queue-owner TTL from plugin config", async () => {
const { runtime } = createRuntimeStub(true);
const runtimeFactory = vi.fn(() => runtime);
const service = createAcpxRuntimeService({
runtimeFactory,
pluginConfig: {
queueOwnerTtlSeconds: 0.25,
},
});
const context = createServiceContext();
await service.start(context);
expect(runtimeFactory).toHaveBeenCalledWith(
expect.objectContaining({
queueOwnerTtlSeconds: 0.25,
pluginConfig: expect.objectContaining({
command: ACPX_BUNDLED_BIN,
expectedVersion: ACPX_PINNED_VERSION,
allowPluginLocalInstall: true,
}),
}),
);
});
it("uses a short default queue-owner TTL", async () => {
const { runtime } = createRuntimeStub(true);
const runtimeFactory = vi.fn(() => runtime);
const service = createAcpxRuntimeService({
runtimeFactory,
});
const context = createServiceContext();
await service.start(context);
expect(runtimeFactory).toHaveBeenCalledWith(
expect.objectContaining({
queueOwnerTtlSeconds: 0.1,
}),
);
});
it("does not block startup while acpx ensure runs", async () => {
const { runtime } = createRuntimeStub(true);
ensureAcpxSpy.mockImplementation(() => new Promise<void>(() => {}));
const service = createAcpxRuntimeService({
runtimeFactory: () => runtime,
});
const context = createServiceContext();
const startResult = await Promise.race([
Promise.resolve(service.start(context)).then(() => "started"),
new Promise<string>((resolve) => setTimeout(() => resolve("timed_out"), 100)),
]);
expect(startResult).toBe("started");
expect(getAcpRuntimeBackend("acpx")?.runtime).toBe(runtime);
});
it("creates the workspace dir before probing acpx", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "acpx-service-workspace-"));
const workspaceDir = path.join(tempRoot, "workspace");
const { runtime, probeAvailabilitySpy } = createRuntimeStub(true);
const service = createAcpxRuntimeService({
runtimeFactory: ({ pluginConfig }) => {
expect(pluginConfig.cwd).toBe(workspaceDir);
return runtime;
},
});
const context = createServiceContext({ workspaceDir });
try {
await service.start(context);
expect(fs.existsSync(workspaceDir)).toBe(true);
await vi.waitFor(() => {
expect(probeAvailabilitySpy).toHaveBeenCalledOnce();
});
} finally {
await service.stop?.(context);
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("retries health probes until the runtime becomes healthy", async () => {
const { runtime, probeAvailabilitySpy, doctorSpy } = createRetryingRuntimeStub([
false,
false,
true,
]);
const service = createAcpxRuntimeService({
runtimeFactory: () => runtime,
healthProbeRetryDelaysMs: [0, 0],
});
const context = createServiceContext();
await service.start(context);
await vi.waitFor(() => {
expect(probeAvailabilitySpy).toHaveBeenCalledTimes(3);
});
expect(doctorSpy).toHaveBeenCalledTimes(2);
expect(context.logger.warn).toHaveBeenCalledWith(
expect.stringContaining("probe attempt 1 failed"),
);
expect(context.logger.info).toHaveBeenCalledWith(
"acpx runtime backend ready after 3 probe attempts",
);
});
it("does not treat doctor ok as healthy when the runtime still reports unhealthy", async () => {
const { runtime, probeAvailabilitySpy, doctorSpy } = createRetryingRuntimeStub([false], {
ok: true,
message: "acpx help check passed",
it("creates the embedded runtime state directory before probing", async () => {
const workspaceDir = await makeTempDir();
const stateDir = path.join(workspaceDir, "custom-state");
const ctx = createServiceContext(workspaceDir);
const probeAvailability = vi.fn(async () => {
await fs.access(stateDir);
});
const service = createAcpxRuntimeService({
runtimeFactory: () => runtime,
healthProbeRetryDelaysMs: [],
pluginConfig: { stateDir },
runtimeFactory: () =>
({
ensureSession: vi.fn(),
runTurn: vi.fn(),
cancel: vi.fn(),
close: vi.fn(),
probeAvailability,
isHealthy: () => true,
doctor: async () => ({ ok: true, message: "ok" }),
}) as never,
});
const context = createServiceContext();
await service.start(context);
await service.start(ctx);
await vi.waitFor(() => {
expect(probeAvailabilitySpy).toHaveBeenCalledOnce();
expect(doctorSpy).toHaveBeenCalledOnce();
expect(context.logger.warn).toHaveBeenCalledWith(
"acpx runtime backend probe failed: acpx help check passed",
);
});
expect(context.logger.info).not.toHaveBeenCalledWith("acpx runtime backend ready");
expect(() => requireAcpRuntimeBackend("acpx")).toThrowError(AcpRuntimeError);
expect(probeAvailability).toHaveBeenCalledOnce();
await service.stop?.(ctx);
});
});

View File

@@ -7,7 +7,6 @@ import type {
} from "../runtime-api.js";
import { registerAcpRuntimeBackend, unregisterAcpRuntimeBackend } from "../runtime-api.js";
import { resolveAcpxPluginConfig, type ResolvedAcpxPluginConfig } from "./config.js";
import { ensureAcpx } from "./ensure.js";
import { ACPX_BACKEND_ID, AcpxRuntime } from "./runtime.js";
type AcpxRuntimeLike = AcpRuntime & {
@@ -29,17 +28,11 @@ type AcpxRuntimeFactoryParams = {
type CreateAcpxRuntimeServiceParams = {
pluginConfig?: unknown;
runtimeFactory?: (params: AcpxRuntimeFactoryParams) => AcpxRuntimeLike;
healthProbeRetryDelaysMs?: number[];
};
const DEFAULT_HEALTH_PROBE_RETRY_DELAYS_MS = [250, 1_000, 2_500];
function delay(ms: number): Promise<void> {
if (ms <= 0) {
return Promise.resolve();
}
return new Promise((resolve) => {
setTimeout(resolve, ms);
function createDefaultRuntime(params: AcpxRuntimeFactoryParams): AcpxRuntimeLike {
return new AcpxRuntime(params.pluginConfig, {
logger: params.logger,
});
}
@@ -48,13 +41,6 @@ function formatDoctorFailureMessage(report: { message: string; details?: string[
return detailText ? `${report.message} (${detailText})` : report.message;
}
function createDefaultRuntime(params: AcpxRuntimeFactoryParams): AcpxRuntimeLike {
return new AcpxRuntime(params.pluginConfig, {
logger: params.logger,
queueOwnerTtlSeconds: params.queueOwnerTtlSeconds,
});
}
export function createAcpxRuntimeService(
params: CreateAcpxRuntimeServiceParams = {},
): OpenClawPluginService {
@@ -68,11 +54,8 @@ export function createAcpxRuntimeService(
rawConfig: params.pluginConfig,
workspaceDir: ctx.workspaceDir,
});
if (ctx.workspaceDir?.trim()) {
await fs.mkdir(ctx.workspaceDir, { recursive: true });
}
const healthProbeRetryDelaysMs =
params.healthProbeRetryDelaysMs ?? DEFAULT_HEALTH_PROBE_RETRY_DELAYS_MS;
await fs.mkdir(pluginConfig.stateDir, { recursive: true });
const runtimeFactory = params.runtimeFactory ?? createDefaultRuntime;
runtime = runtimeFactory({
pluginConfig,
@@ -85,75 +68,33 @@ export function createAcpxRuntimeService(
runtime,
healthy: () => runtime?.isHealthy() ?? false,
});
const expectedVersionLabel = pluginConfig.expectedVersion ?? "any";
const installLabel = pluginConfig.allowPluginLocalInstall ? "enabled" : "disabled";
ctx.logger.info(
`acpx runtime backend registered (command: ${pluginConfig.command}, expectedVersion: ${expectedVersionLabel}, pluginLocalInstall: ${installLabel})`,
);
ctx.logger.info(`embedded acpx runtime backend registered (cwd: ${pluginConfig.cwd})`);
lifecycleRevision += 1;
const currentRevision = lifecycleRevision;
void (async () => {
try {
await ensureAcpx({
command: pluginConfig.command,
logger: ctx.logger,
expectedVersion: pluginConfig.expectedVersion,
allowInstall: pluginConfig.allowPluginLocalInstall,
stripProviderAuthEnvVars: pluginConfig.stripProviderAuthEnvVars,
spawnOptions: {
strictWindowsCmdWrapper: pluginConfig.strictWindowsCmdWrapper,
},
});
await runtime?.probeAvailability();
if (currentRevision !== lifecycleRevision) {
return;
}
let lastFailureMessage: string | undefined;
for (let attempt = 0; attempt <= healthProbeRetryDelaysMs.length; attempt += 1) {
await runtime?.probeAvailability();
if (currentRevision !== lifecycleRevision) {
return;
}
if (runtime?.isHealthy()) {
ctx.logger.info(
attempt === 0
? "acpx runtime backend ready"
: `acpx runtime backend ready after ${attempt + 1} probe attempts`,
);
return;
}
const doctorReport = await runtime?.doctor?.();
if (currentRevision !== lifecycleRevision) {
return;
}
if (doctorReport) {
lastFailureMessage = formatDoctorFailureMessage(doctorReport);
} else {
lastFailureMessage = "acpx runtime backend remained unhealthy after probe";
}
const retryDelayMs = healthProbeRetryDelaysMs[attempt];
if (retryDelayMs == null) {
break;
}
ctx.logger.warn(
`acpx runtime backend probe attempt ${attempt + 1} failed: ${lastFailureMessage}; retrying in ${retryDelayMs}ms`,
);
await delay(retryDelayMs);
if (currentRevision !== lifecycleRevision) {
return;
}
if (runtime?.isHealthy()) {
ctx.logger.info("embedded acpx runtime backend ready");
return;
}
const doctorReport = await runtime?.doctor?.();
if (currentRevision !== lifecycleRevision) {
return;
}
ctx.logger.warn(
`acpx runtime backend probe failed: ${lastFailureMessage ?? "backend remained unhealthy after setup"}`,
`embedded acpx runtime backend probe failed: ${doctorReport ? formatDoctorFailureMessage(doctorReport) : "backend remained unhealthy after probe"}`,
);
} catch (err) {
if (currentRevision !== lifecycleRevision) {
return;
}
ctx.logger.warn(
`acpx runtime setup failed: ${err instanceof Error ? err.message : String(err)}`,
`embedded acpx runtime setup failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
})();

View File

@@ -0,0 +1,94 @@
import type { SessionModelState } from "@agentclientprotocol/sdk";
import type { SessionAcpxState, SessionRecord } from "./runtime-types.js";
function ensureAcpxState(state: SessionAcpxState | undefined): SessionAcpxState {
return state ?? {};
}
export function normalizeModeId(modeId: string | undefined): string | undefined {
if (typeof modeId !== "string") {
return undefined;
}
const trimmed = modeId.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function normalizeModelId(modelId: string | undefined): string | undefined {
if (typeof modelId !== "string") {
return undefined;
}
const trimmed = modelId.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
export function getDesiredModeId(state: SessionAcpxState | undefined): string | undefined {
return normalizeModeId(state?.desired_mode_id);
}
export function setDesiredModeId(record: SessionRecord, modeId: string | undefined): void {
const acpx = ensureAcpxState(record.acpx);
const normalized = normalizeModeId(modeId);
if (normalized) {
acpx.desired_mode_id = normalized;
} else {
delete acpx.desired_mode_id;
}
record.acpx = acpx;
}
export function getDesiredModelId(state: SessionAcpxState | undefined): string | undefined {
return normalizeModelId(state?.session_options?.model);
}
export function setDesiredModelId(record: SessionRecord, modelId: string | undefined): void {
const acpx = ensureAcpxState(record.acpx);
const normalized = normalizeModelId(modelId);
const sessionOptions = { ...acpx.session_options };
if (normalized) {
sessionOptions.model = normalized;
} else {
delete sessionOptions.model;
}
if (
typeof sessionOptions.model === "string" ||
Array.isArray(sessionOptions.allowed_tools) ||
typeof sessionOptions.max_turns === "number"
) {
acpx.session_options = sessionOptions;
} else {
delete acpx.session_options;
}
record.acpx = acpx;
}
export function setCurrentModelId(record: SessionRecord, modelId: string | undefined): void {
const acpx = ensureAcpxState(record.acpx);
const normalized = normalizeModelId(modelId);
if (normalized) {
acpx.current_model_id = normalized;
} else {
delete acpx.current_model_id;
}
record.acpx = acpx;
}
export function syncAdvertisedModelState(
record: SessionRecord,
models: SessionModelState | undefined,
): void {
if (!models) {
return;
}
const acpx = ensureAcpxState(record.acpx);
acpx.current_model_id = models.currentModelId;
acpx.available_models = models.availableModels.map((model) => model.modelId);
record.acpx = acpx;
}

View File

@@ -0,0 +1,81 @@
export class TimeoutError extends Error {
constructor(timeoutMs: number) {
super(`Timed out after ${timeoutMs}ms`);
this.name = "TimeoutError";
}
}
export class InterruptedError extends Error {
constructor() {
super("Interrupted");
this.name = "InterruptedError";
}
}
export async function withTimeout<T>(promise: Promise<T>, timeoutMs?: number): Promise<T> {
if (timeoutMs == null || timeoutMs <= 0) {
return await promise;
}
let timer: NodeJS.Timeout | undefined;
const timeoutPromise = new Promise<T>((_resolve, reject) => {
timer = setTimeout(() => {
reject(new TimeoutError(timeoutMs));
}, timeoutMs);
});
try {
return await Promise.race([promise, timeoutPromise]);
} finally {
if (timer) {
clearTimeout(timer);
}
}
}
export async function withInterrupt<T>(
run: () => Promise<T>,
onInterrupt: () => Promise<void>,
): Promise<T> {
return await new Promise<T>((resolve, reject) => {
let settled = false;
const finish = (cb: () => void) => {
if (settled) {
return;
}
settled = true;
process.off("SIGINT", onSigint);
process.off("SIGTERM", onSigterm);
process.off("SIGHUP", onSighup);
cb();
};
const rejectInterrupted = () => {
void onInterrupt().finally(() => {
finish(() => reject(new InterruptedError()));
});
};
const onSigint = () => {
rejectInterrupted();
};
const onSigterm = () => {
rejectInterrupted();
};
const onSighup = () => {
rejectInterrupted();
};
process.once("SIGINT", onSigint);
process.once("SIGTERM", onSigterm);
process.once("SIGHUP", onSighup);
void run().then(
(result) => finish(() => resolve(result)),
(error) => finish(() => reject(error)),
);
});
}

View File

@@ -0,0 +1,50 @@
import { normalizeRuntimeSessionId } from "../runtime-session-id.js";
import type { SessionConversation, SessionRecord } from "../runtime-types.js";
import type { AgentLifecycleSnapshot } from "../transport/acp-client.js";
export function applyLifecycleSnapshotToRecord(
record: SessionRecord,
snapshot: AgentLifecycleSnapshot,
): void {
record.pid = snapshot.pid;
record.agentStartedAt = snapshot.startedAt;
if (snapshot.lastExit) {
record.lastAgentExitCode = snapshot.lastExit.exitCode;
record.lastAgentExitSignal = snapshot.lastExit.signal;
record.lastAgentExitAt = snapshot.lastExit.exitedAt;
record.lastAgentDisconnectReason = snapshot.lastExit.reason;
return;
}
record.lastAgentExitCode = undefined;
record.lastAgentExitSignal = undefined;
record.lastAgentExitAt = undefined;
record.lastAgentDisconnectReason = undefined;
}
export function reconcileAgentSessionId(
record: SessionRecord,
agentSessionId: string | undefined,
): void {
const normalized = normalizeRuntimeSessionId(agentSessionId);
if (!normalized) {
return;
}
record.agentSessionId = normalized;
}
export function sessionHasAgentMessages(record: SessionRecord): boolean {
return record.messages.some(
(message) => typeof message === "object" && message !== null && "Agent" in message,
);
}
export function applyConversation(record: SessionRecord, conversation: SessionConversation): void {
record.title = conversation.title;
record.messages = conversation.messages;
record.updated_at = conversation.updated_at;
record.cumulative_token_usage = conversation.cumulative_token_usage;
record.request_token_usage = conversation.request_token_usage;
}

View File

@@ -0,0 +1,535 @@
import type {
AcpRuntimeEvent,
AcpRuntimeHandle,
AcpRuntimeStatus,
AcpRuntimeTurnAttachment,
} from "../../runtime-api.js";
import { resolveAgentCommand } from "../agents/registry.js";
import type { ResolvedAcpxPluginConfig } from "../config.js";
import { normalizeOutputError } from "../error-normalization.js";
import {
cloneSessionAcpxState,
cloneSessionConversation,
createSessionConversation,
recordClientOperation,
recordPromptSubmission,
recordSessionUpdate,
trimConversationForRuntime,
} from "../history/conversation.js";
import { parsePromptEventLine } from "../history/projector.js";
import { textPrompt, type PromptInput } from "../prompt-content.js";
import type {
ClientOperation,
McpServer,
SessionAcpxState,
SessionConversation,
SessionRecord,
SessionResumePolicy,
} from "../runtime-types.js";
import { withTimeout } from "../session-runtime-helpers.js";
import { AcpClient } from "../transport/acp-client.js";
import {
applyConversation,
applyLifecycleSnapshotToRecord,
reconcileAgentSessionId,
} from "./lifecycle.js";
import { connectAndLoadSession } from "./reconnect.js";
import { SessionRepository, SESSION_RECORD_SCHEMA } from "./repository.js";
type ActiveSessionController = {
hasActivePrompt: () => boolean;
requestCancelActivePrompt: () => Promise<boolean>;
setSessionMode: (modeId: string) => Promise<void>;
setSessionConfigOption: (configId: string, value: string) => Promise<void>;
};
type Deferred<T> = {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
};
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
class AsyncEventQueue {
private readonly items: AcpRuntimeEvent[] = [];
private readonly waits: Deferred<AcpRuntimeEvent | null>[] = [];
private closed = false;
push(item: AcpRuntimeEvent): void {
if (this.closed) {
return;
}
const waiter = this.waits.shift();
if (waiter) {
waiter.resolve(item);
return;
}
this.items.push(item);
}
close(): void {
if (this.closed) {
return;
}
this.closed = true;
for (const waiter of this.waits.splice(0)) {
waiter.resolve(null);
}
}
async next(): Promise<AcpRuntimeEvent | null> {
if (this.items.length > 0) {
return this.items.shift() ?? null;
}
if (this.closed) {
return null;
}
const waiter = createDeferred<AcpRuntimeEvent | null>();
this.waits.push(waiter);
return await waiter.promise;
}
async *iterate(): AsyncIterable<AcpRuntimeEvent> {
while (true) {
const next = await this.next();
if (!next) {
return;
}
yield next;
}
}
}
function isoNow(): string {
return new Date().toISOString();
}
function toPromptInput(
text: string,
attachments?: AcpRuntimeTurnAttachment[],
): PromptInput | string {
if (!attachments || attachments.length === 0) {
return text;
}
const blocks: Array<
{ type: "text"; text: string } | { type: "image"; mimeType: string; data: string }
> = [];
if (text) {
blocks.push({ type: "text", text });
}
for (const attachment of attachments) {
if (attachment.mediaType.startsWith("image/")) {
blocks.push({
type: "image",
mimeType: attachment.mediaType,
data: attachment.data,
});
}
}
return blocks.length > 0 ? blocks : textPrompt(text);
}
function toSdkMcpServers(config: ResolvedAcpxPluginConfig): McpServer[] {
return Object.entries(config.mcpServers).map(([name, server]) => ({
name,
command: server.command,
args: [...(server.args ?? [])],
env: Object.entries(server.env ?? {}).map(([envName, value]) => ({
name: envName,
value,
})),
}));
}
function createInitialRecord(params: {
sessionKey: string;
sessionId: string;
agentCommand: string;
cwd: string;
agentSessionId?: string;
}): SessionRecord {
const now = isoNow();
return {
schema: SESSION_RECORD_SCHEMA,
acpxRecordId: params.sessionKey,
acpSessionId: params.sessionId,
agentSessionId: params.agentSessionId,
agentCommand: params.agentCommand,
cwd: params.cwd,
name: params.sessionKey,
createdAt: now,
lastUsedAt: now,
lastSeq: 0,
eventLog: {
active_path: "",
segment_count: 0,
max_segment_bytes: 0,
max_segments: 0,
last_write_at: undefined,
last_write_error: null,
},
closed: false,
closedAt: undefined,
...createSessionConversation(now),
acpx: {},
};
}
function statusSummary(record: SessionRecord): string {
const parts = [
`session=${record.acpxRecordId}`,
`backendSessionId=${record.acpSessionId}`,
record.agentSessionId ? `agentSessionId=${record.agentSessionId}` : null,
record.pid != null ? `pid=${record.pid}` : null,
record.closed ? "closed" : "open",
].filter(Boolean);
return parts.join(" ");
}
export class SessionRuntimeManager {
private readonly repository: SessionRepository;
private readonly activeControllers = new Map<string, ActiveSessionController>();
constructor(private readonly config: ResolvedAcpxPluginConfig) {
this.repository = new SessionRepository(config);
}
async ensureSession(input: {
sessionKey: string;
agent: string;
cwd?: string;
resumeSessionId?: string;
}): Promise<SessionRecord> {
const existing = await this.repository.load(input.sessionKey);
if (existing) {
existing.closed = false;
existing.closedAt = undefined;
await this.repository.save(existing);
return existing;
}
const cwd = input.cwd?.trim() || this.config.cwd;
const agentCommand = resolveAgentCommand(input.agent, this.config.agents);
const client = new AcpClient({
agentCommand,
cwd,
mcpServers: toSdkMcpServers(this.config),
permissionMode: this.config.permissionMode,
nonInteractivePermissions: this.config.nonInteractivePermissions,
verbose: false,
});
try {
await client.start();
let sessionId: string;
let agentSessionId: string | undefined;
if (input.resumeSessionId) {
const loaded = await client.loadSession(input.resumeSessionId, cwd);
sessionId = input.resumeSessionId;
agentSessionId = loaded.agentSessionId;
} else {
const created = await client.createSession(cwd);
sessionId = created.sessionId;
agentSessionId = created.agentSessionId;
}
const record = createInitialRecord({
sessionKey: input.sessionKey,
sessionId,
agentCommand,
cwd,
agentSessionId,
});
record.protocolVersion = client.initializeResult?.protocolVersion;
record.agentCapabilities = client.initializeResult?.agentCapabilities;
applyLifecycleSnapshotToRecord(record, client.getAgentLifecycleSnapshot());
await this.repository.save(record);
return record;
} finally {
await client.close();
}
}
async *runTurn(input: {
handle: AcpRuntimeHandle;
text: string;
attachments?: AcpRuntimeTurnAttachment[];
requestId: string;
signal?: AbortSignal;
}): AsyncIterable<AcpRuntimeEvent> {
const record = await this.requireRecord(input.handle.acpxRecordId ?? input.handle.sessionKey);
const conversation = cloneSessionConversation(record);
let acpxState = cloneSessionAcpxState(record.acpx);
recordPromptSubmission(conversation, toPromptInput(input.text, input.attachments), isoNow());
trimConversationForRuntime(conversation);
const queue = new AsyncEventQueue();
const client = new AcpClient({
agentCommand: record.agentCommand,
cwd: record.cwd,
mcpServers: toSdkMcpServers(this.config),
permissionMode: this.config.permissionMode,
nonInteractivePermissions: this.config.nonInteractivePermissions,
verbose: false,
});
let activeSessionId = record.acpSessionId;
let sawDone = false;
const activeController: ActiveSessionController = {
hasActivePrompt: () => client.hasActivePrompt(),
requestCancelActivePrompt: async () => await client.requestCancelActivePrompt(),
setSessionMode: async (modeId: string) => {
await client.setSessionMode(activeSessionId, modeId);
},
setSessionConfigOption: async (configId: string, value: string) => {
await client.setSessionConfigOption(activeSessionId, configId, value);
},
};
const emitParsed = (payload: Record<string, unknown>): void => {
const parsed = parsePromptEventLine(JSON.stringify(payload));
if (!parsed) {
return;
}
if (parsed.type === "done") {
sawDone = true;
}
queue.push(parsed);
};
const abortHandler = () => {
void activeController.requestCancelActivePrompt();
};
if (input.signal) {
if (input.signal.aborted) {
queue.close();
return;
}
input.signal.addEventListener("abort", abortHandler, { once: true });
}
this.activeControllers.set(record.acpxRecordId, activeController);
void (async () => {
try {
client.setEventHandlers({
onSessionUpdate: (notification) => {
acpxState = recordSessionUpdate(conversation, acpxState, notification);
trimConversationForRuntime(conversation);
emitParsed({
jsonrpc: "2.0",
method: "session/update",
params: notification,
});
},
onClientOperation: (operation: ClientOperation) => {
acpxState = recordClientOperation(conversation, acpxState, operation);
trimConversationForRuntime(conversation);
emitParsed({
type: "client_operation",
...operation,
});
},
});
const { sessionId, resumed, loadError } = await connectAndLoadSession({
client,
record,
resumePolicy: "allow-new" satisfies SessionResumePolicy,
timeoutMs: this.timeoutMs,
activeController,
onClientAvailable: (controller) => {
this.activeControllers.set(record.acpxRecordId, controller);
},
onConnectedRecord: (connectedRecord) => {
connectedRecord.lastPromptAt = isoNow();
},
onSessionIdResolved: (sessionIdValue) => {
activeSessionId = sessionIdValue;
},
});
record.lastRequestId = input.requestId;
record.lastPromptAt = isoNow();
record.closed = false;
record.closedAt = undefined;
record.lastUsedAt = isoNow();
if (resumed || loadError) {
emitParsed({
type: "status",
text: loadError ? `load fallback: ${loadError}` : "session resumed",
});
}
const response = await withTimeout(
client.prompt(sessionId, toPromptInput(input.text, input.attachments)),
this.timeoutMs,
);
record.acpSessionId = activeSessionId;
reconcileAgentSessionId(record, record.agentSessionId);
record.protocolVersion = client.initializeResult?.protocolVersion;
record.agentCapabilities = client.initializeResult?.agentCapabilities;
record.acpx = acpxState;
applyConversation(record, conversation);
applyLifecycleSnapshotToRecord(record, client.getAgentLifecycleSnapshot());
await this.repository.save(record);
if (!sawDone) {
queue.push({
type: "done",
stopReason: response.stopReason,
});
}
} catch (error) {
const normalized = normalizeOutputError(error, { origin: "runtime" });
queue.push({
type: "error",
message: normalized.message,
code: normalized.code,
retryable: normalized.retryable,
});
} finally {
if (input.signal) {
input.signal.removeEventListener("abort", abortHandler);
}
this.activeControllers.delete(record.acpxRecordId);
client.clearEventHandlers();
applyLifecycleSnapshotToRecord(record, client.getAgentLifecycleSnapshot());
record.acpx = acpxState;
applyConversation(record, conversation);
record.lastUsedAt = isoNow();
await this.repository.save(record).catch(() => {});
await client.close().catch(() => {});
queue.close();
}
})();
yield* queue.iterate();
}
async getStatus(handle: AcpRuntimeHandle): Promise<AcpRuntimeStatus> {
const record = await this.requireRecord(handle.acpxRecordId ?? handle.sessionKey);
return {
summary: statusSummary(record),
acpxRecordId: record.acpxRecordId,
backendSessionId: record.acpSessionId,
agentSessionId: record.agentSessionId,
details: {
cwd: record.cwd,
lastUsedAt: record.lastUsedAt,
closed: record.closed === true,
},
};
}
async setMode(handle: AcpRuntimeHandle, mode: string): Promise<void> {
const record = await this.requireRecord(handle.acpxRecordId ?? handle.sessionKey);
const controller = this.activeControllers.get(record.acpxRecordId);
if (controller) {
await controller.setSessionMode(mode);
} else {
const client = new AcpClient({
agentCommand: record.agentCommand,
cwd: record.cwd,
mcpServers: toSdkMcpServers(this.config),
permissionMode: this.config.permissionMode,
nonInteractivePermissions: this.config.nonInteractivePermissions,
verbose: false,
});
try {
await client.start();
const { sessionId } = await connectAndLoadSession({
client,
record,
timeoutMs: this.timeoutMs,
activeController: {
hasActivePrompt: () => false,
requestCancelActivePrompt: async () => false,
setSessionMode: async () => {},
setSessionConfigOption: async () => {},
},
});
await client.setSessionMode(sessionId, mode);
} finally {
applyLifecycleSnapshotToRecord(record, client.getAgentLifecycleSnapshot());
await this.repository.save(record).catch(() => {});
await client.close();
}
}
record.acpx = {
...(record.acpx ?? ({} as SessionAcpxState)),
desired_mode_id: mode,
};
await this.repository.save(record);
}
async setConfigOption(handle: AcpRuntimeHandle, key: string, value: string): Promise<void> {
const record = await this.requireRecord(handle.acpxRecordId ?? handle.sessionKey);
const controller = this.activeControllers.get(record.acpxRecordId);
if (controller) {
await controller.setSessionConfigOption(key, value);
} else {
const client = new AcpClient({
agentCommand: record.agentCommand,
cwd: record.cwd,
mcpServers: toSdkMcpServers(this.config),
permissionMode: this.config.permissionMode,
nonInteractivePermissions: this.config.nonInteractivePermissions,
verbose: false,
});
try {
await client.start();
const { sessionId } = await connectAndLoadSession({
client,
record,
timeoutMs: this.timeoutMs,
activeController: {
hasActivePrompt: () => false,
requestCancelActivePrompt: async () => false,
setSessionMode: async () => {},
setSessionConfigOption: async () => {},
},
});
await client.setSessionConfigOption(sessionId, key, value);
} finally {
applyLifecycleSnapshotToRecord(record, client.getAgentLifecycleSnapshot());
await this.repository.save(record).catch(() => {});
await client.close();
}
}
await this.repository.save(record);
}
async cancel(handle: AcpRuntimeHandle): Promise<void> {
const controller = this.activeControllers.get(handle.acpxRecordId ?? handle.sessionKey);
await controller?.requestCancelActivePrompt();
}
async close(handle: AcpRuntimeHandle): Promise<void> {
const record = await this.requireRecord(handle.acpxRecordId ?? handle.sessionKey);
await this.cancel(handle);
record.closed = true;
record.closedAt = isoNow();
await this.repository.save(record);
}
private get timeoutMs(): number | undefined {
return this.config.timeoutSeconds != null ? this.config.timeoutSeconds * 1_000 : undefined;
}
private async requireRecord(sessionId: string): Promise<SessionRecord> {
const record = await this.repository.load(sessionId);
if (!record) {
throw new Error(`ACP session not found: ${sessionId}`);
}
return record;
}
}

View File

@@ -0,0 +1,277 @@
import {
extractAcpError,
formatErrorMessage,
isAcpQueryClosedBeforeResponseError,
isAcpResourceNotFoundError,
} from "../error-normalization.js";
import {
SessionModeReplayError,
SessionModelReplayError,
SessionResumeRequiredError,
} from "../errors.js";
import { incrementPerfCounter } from "../perf-metrics.js";
import type { SessionRecord, SessionResumePolicy } from "../runtime-types.js";
import {
getDesiredModeId,
getDesiredModelId,
setCurrentModelId,
syncAdvertisedModelState,
} from "../session-mode-preference.js";
import { InterruptedError, TimeoutError, withTimeout } from "../session-runtime-helpers.js";
import type { AcpClient } from "../transport/acp-client.js";
import {
applyLifecycleSnapshotToRecord,
reconcileAgentSessionId,
sessionHasAgentMessages,
} from "./lifecycle.js";
type QueueOwnerActiveSessionController = {
hasActivePrompt: () => boolean;
requestCancelActivePrompt: () => Promise<boolean>;
setSessionMode: (modeId: string) => Promise<void>;
setSessionConfigOption: (configId: string, value: string) => Promise<void>;
};
function isProcessAlive(pid: number | undefined): boolean {
if (!pid || !Number.isInteger(pid) || pid <= 0 || pid === process.pid) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
export type ConnectAndLoadSessionOptions = {
client: AcpClient;
record: SessionRecord;
resumePolicy?: SessionResumePolicy;
timeoutMs?: number;
verbose?: boolean;
activeController: QueueOwnerActiveSessionController;
onClientAvailable?: (controller: QueueOwnerActiveSessionController) => void;
onConnectedRecord?: (record: SessionRecord) => void;
onSessionIdResolved?: (sessionId: string) => void;
};
export type ConnectAndLoadSessionResult = {
sessionId: string;
agentSessionId?: string;
resumed: boolean;
loadError?: string;
};
// JSON-RPC codes that indicate the agent does not support session/load.
// -32601 = Method not found, -32602 = Invalid params.
const SESSION_LOAD_UNSUPPORTED_CODES = new Set([-32601, -32602]);
function shouldFallbackToNewSession(error: unknown, record: SessionRecord): boolean {
if (error instanceof TimeoutError || error instanceof InterruptedError) {
return false;
}
if (isAcpResourceNotFoundError(error)) {
return true;
}
const acp = extractAcpError(error);
if (acp && SESSION_LOAD_UNSUPPORTED_CODES.has(acp.code)) {
return true;
}
// Some adapters return JSON-RPC internal errors when trying to
// load sessions that have never produced an agent turn yet.
if (!sessionHasAgentMessages(record)) {
if (isAcpQueryClosedBeforeResponseError(error)) {
return true;
}
if (acp?.code === -32603) {
return true;
}
}
return false;
}
function requiresSameSession(resumePolicy: SessionResumePolicy | undefined): boolean {
return resumePolicy === "same-session-only";
}
function makeSessionResumeRequiredError(params: {
record: SessionRecord;
reason: string;
cause?: unknown;
}): SessionResumeRequiredError {
return new SessionResumeRequiredError(
`Persistent ACP session ${params.record.acpSessionId} could not be resumed: ${params.reason}`,
{
cause: params.cause instanceof Error ? params.cause : undefined,
},
);
}
export async function connectAndLoadSession(
options: ConnectAndLoadSessionOptions,
): Promise<ConnectAndLoadSessionResult> {
const record = options.record;
const client = options.client;
const sameSessionOnly = requiresSameSession(options.resumePolicy);
const originalSessionId = record.acpSessionId;
const originalAgentSessionId = record.agentSessionId;
const desiredModeId = getDesiredModeId(record.acpx);
const desiredModelId = getDesiredModelId(record.acpx);
const storedProcessAlive = isProcessAlive(record.pid);
const shouldReconnect = Boolean(record.pid) && !storedProcessAlive;
if (options.verbose) {
if (storedProcessAlive) {
process.stderr.write(
`[acpx] saved session pid ${record.pid} is running; reconnecting with loadSession\n`,
);
} else if (shouldReconnect) {
process.stderr.write(
`[acpx] saved session pid ${record.pid} is dead; respawning agent and attempting session/load\n`,
);
}
}
const reusingLoadedSession = client.hasReusableSession(record.acpSessionId);
if (reusingLoadedSession) {
incrementPerfCounter("runtime.connect_and_load.reused_session");
} else {
await withTimeout(client.start(), options.timeoutMs);
}
options.onClientAvailable?.(options.activeController);
applyLifecycleSnapshotToRecord(record, client.getAgentLifecycleSnapshot());
record.closed = false;
record.closedAt = undefined;
options.onConnectedRecord?.(record);
let resumed = false;
let loadError: string | undefined;
let sessionId = record.acpSessionId;
let createdFreshSession = false;
let pendingAgentSessionId = record.agentSessionId;
let sessionModels: import("../transport/acp-client.js").SessionLoadResult["models"];
if (reusingLoadedSession) {
resumed = true;
} else if (client.supportsLoadSession()) {
try {
const loadResult = await withTimeout(
client.loadSessionWithOptions(record.acpSessionId, record.cwd, {
suppressReplayUpdates: true,
}),
options.timeoutMs,
);
reconcileAgentSessionId(record, loadResult.agentSessionId);
sessionModels = loadResult.models;
resumed = true;
} catch (error) {
loadError = formatErrorMessage(error);
if (sameSessionOnly) {
throw makeSessionResumeRequiredError({
record,
reason: loadError,
cause: error,
});
}
if (!shouldFallbackToNewSession(error, record)) {
throw error;
}
const createdSession = await withTimeout(client.createSession(record.cwd), options.timeoutMs);
sessionId = createdSession.sessionId;
createdFreshSession = true;
pendingAgentSessionId = createdSession.agentSessionId;
sessionModels = createdSession.models;
}
} else {
if (sameSessionOnly) {
throw makeSessionResumeRequiredError({
record,
reason: "agent does not support session/load",
});
}
const createdSession = await withTimeout(client.createSession(record.cwd), options.timeoutMs);
sessionId = createdSession.sessionId;
createdFreshSession = true;
pendingAgentSessionId = createdSession.agentSessionId;
sessionModels = createdSession.models;
}
if (createdFreshSession && desiredModeId) {
try {
await withTimeout(client.setSessionMode(sessionId, desiredModeId), options.timeoutMs);
if (options.verbose) {
process.stderr.write(
`[acpx] replayed desired mode ${desiredModeId} on fresh ACP session ${sessionId} (previous ${originalSessionId})\n`,
);
}
} catch (error) {
const message =
`Failed to replay saved session mode ${desiredModeId} on fresh ACP session ${sessionId}: ` +
formatErrorMessage(error);
record.acpSessionId = originalSessionId;
record.agentSessionId = originalAgentSessionId;
if (options.verbose) {
process.stderr.write(`[acpx] ${message}\n`);
}
throw new SessionModeReplayError(message, {
cause: error instanceof Error ? error : undefined,
retryable: true,
});
}
}
if (
createdFreshSession &&
desiredModelId &&
sessionModels &&
desiredModelId !== sessionModels.currentModelId
) {
try {
await withTimeout(client.setSessionModel(sessionId, desiredModelId), options.timeoutMs);
setCurrentModelId(record, desiredModelId);
if (options.verbose) {
process.stderr.write(
`[acpx] replayed desired model ${desiredModelId} on fresh ACP session ${sessionId} (previous ${originalSessionId})\n`,
);
}
} catch (error) {
const message =
`Failed to replay saved session model ${desiredModelId} on fresh ACP session ${sessionId}: ` +
formatErrorMessage(error);
record.acpSessionId = originalSessionId;
record.agentSessionId = originalAgentSessionId;
if (options.verbose) {
process.stderr.write(`[acpx] ${message}\n`);
}
throw new SessionModelReplayError(message, {
cause: error instanceof Error ? error : undefined,
retryable: true,
});
}
}
if (createdFreshSession) {
record.acpSessionId = sessionId;
reconcileAgentSessionId(record, pendingAgentSessionId);
}
syncAdvertisedModelState(record, sessionModels);
if (createdFreshSession && desiredModelId && sessionModels) {
setCurrentModelId(record, desiredModelId);
}
options.onSessionIdResolved?.(sessionId);
return {
sessionId,
agentSessionId: record.agentSessionId,
resumed,
loadError,
};
}

View File

@@ -0,0 +1,54 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { ResolvedAcpxPluginConfig } from "../config.js";
import type { SessionRecord } from "../runtime-types.js";
export const SESSION_RECORD_SCHEMA = "openclaw.acpx.session.v1" as const;
function safeSessionId(sessionId: string): string {
return encodeURIComponent(sessionId);
}
export class SessionRepository {
constructor(private readonly config: ResolvedAcpxPluginConfig) {}
get sessionDir(): string {
return path.join(this.config.stateDir, "sessions");
}
async ensureDir(): Promise<void> {
await fs.mkdir(this.sessionDir, { recursive: true });
}
filePath(sessionId: string): string {
return path.join(this.sessionDir, `${safeSessionId(sessionId)}.json`);
}
async load(sessionId: string): Promise<SessionRecord | null> {
try {
const payload = await fs.readFile(this.filePath(sessionId), "utf8");
return JSON.parse(payload) as SessionRecord;
} catch {
return null;
}
}
async save(record: SessionRecord): Promise<void> {
await this.ensureDir();
const target = this.filePath(record.acpxRecordId);
const temp = `${target}.${process.pid}.${Date.now()}.tmp`;
await fs.writeFile(temp, `${JSON.stringify(record, null, 2)}\n`, "utf8");
await fs.rename(temp, target);
}
async close(sessionId: string): Promise<SessionRecord | null> {
const record = await this.load(sessionId);
if (!record) {
return null;
}
record.closed = true;
record.closedAt = new Date().toISOString();
await this.save(record);
return record;
}
}

View File

@@ -1,602 +0,0 @@
import fs from "node:fs";
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import type { ResolvedAcpxPluginConfig } from "../config.js";
import { ACPX_PINNED_VERSION } from "../config.js";
import { AcpxRuntime } from "../runtime.js";
export const NOOP_LOGGER = {
info: (_message: string) => {},
warn: (_message: string) => {},
error: (_message: string) => {},
debug: (_message: string) => {},
};
const tempDirs: string[] = [];
let sharedMockCliScriptPath: Promise<string> | null = null;
let logFileSequence = 0;
const MOCK_CLI_SCRIPT = String.raw`#!/usr/bin/env node
const fs = require("node:fs");
const path = require("node:path");
(async () => {
const args = process.argv.slice(2);
const logPath = process.env.MOCK_ACPX_LOG;
const statePath =
process.env.MOCK_ACPX_STATE ||
path.join(path.dirname(logPath || process.cwd()), "mock-acpx-state.json");
const openclawShell = process.env.OPENCLAW_SHELL || "";
const writeLog = (entry) => {
if (!logPath) return;
fs.appendFileSync(logPath, JSON.stringify(entry) + "\n");
};
const emitJson = (payload) => process.stdout.write(JSON.stringify(payload) + "\n");
const flushAndExit = (code) => process.stdout.write("", () => process.exit(code));
const emitJsonAndExit = (payload, code = 0) => {
emitJson(payload);
flushAndExit(code);
};
const emitTextAndExit = (text, code = 0) => process.stdout.write(text, () => process.exit(code));
const emitUpdate = (sessionId, update) =>
emitJson({
jsonrpc: "2.0",
method: "session/update",
params: { sessionId, update },
});
const readState = () => {
try {
const raw = fs.readFileSync(statePath, "utf8");
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return {
byName:
parsed.byName && typeof parsed.byName === "object" && !Array.isArray(parsed.byName)
? parsed.byName
: {},
byAgentSessionId:
parsed.byAgentSessionId &&
typeof parsed.byAgentSessionId === "object" &&
!Array.isArray(parsed.byAgentSessionId)
? parsed.byAgentSessionId
: {},
};
}
} catch {}
return { byName: {}, byAgentSessionId: {} };
};
const writeState = (state) => {
fs.writeFileSync(statePath, JSON.stringify(state), "utf8");
};
const defaultAgentSessionIdForName = (name) => {
if (process.env.MOCK_ACPX_ENSURE_NO_AGENT_SESSION_ID === "1") {
return "";
}
const prefix = process.env.MOCK_ACPX_AGENT_SESSION_PREFIX || "inner-";
return prefix + name;
};
const cleanupAgentLookup = (state, name) => {
for (const [sessionId, mappedName] of Object.entries(state.byAgentSessionId)) {
if (mappedName === name) {
delete state.byAgentSessionId[sessionId];
}
}
};
const storeSessionByName = (name, overrides = {}) => {
const state = readState();
const existing = state.byName[name] && typeof state.byName[name] === "object" ? state.byName[name] : {};
const next = {
acpxRecordId: "rec-" + name,
acpxSessionId: "sid-" + name,
agentSessionId: defaultAgentSessionIdForName(name),
...existing,
...overrides,
};
if (!next.acpxRecordId) {
next.acpxRecordId = "rec-" + name;
}
if (!next.acpxSessionId) {
next.acpxSessionId = "sid-" + name;
}
cleanupAgentLookup(state, name);
state.byName[name] = next;
if (next.agentSessionId) {
state.byAgentSessionId[next.agentSessionId] = name;
}
writeState(state);
return { name, ...next };
};
const findSessionByReference = (reference) => {
if (!reference) {
return null;
}
const state = readState();
const byName = state.byName[reference];
if (byName && typeof byName === "object") {
return { name: reference, ...byName };
}
const mappedName = state.byAgentSessionId[reference];
if (mappedName) {
const mapped = state.byName[mappedName];
if (mapped && typeof mapped === "object") {
return { name: mappedName, ...mapped };
}
}
for (const [name, session] of Object.entries(state.byName)) {
if (!session || typeof session !== "object") {
continue;
}
if (session.acpxSessionId === reference) {
return { name, ...session };
}
}
return null;
};
const resolveSession = (reference) => findSessionByReference(reference) || storeSessionByName(reference);
if (args.includes("--version")) {
return emitTextAndExit("mock-acpx ${ACPX_PINNED_VERSION}\\n");
}
if (args.includes("--help")) {
if (process.env.MOCK_ACPX_HELP_SIGNAL) {
process.kill(process.pid, process.env.MOCK_ACPX_HELP_SIGNAL);
}
return emitTextAndExit("mock-acpx help\\n");
}
const commandIndex = args.findIndex(
(arg) =>
arg === "prompt" ||
arg === "cancel" ||
arg === "sessions" ||
arg === "set-mode" ||
arg === "set" ||
arg === "status" ||
arg === "config",
);
const command = commandIndex >= 0 ? args[commandIndex] : "";
const agent = commandIndex > 0 ? args[commandIndex - 1] : "unknown";
const readFlag = (flag) => {
const idx = args.indexOf(flag);
if (idx < 0) return "";
return String(args[idx + 1] || "");
};
const sessionFromOption = readFlag("--session");
const ensureName = readFlag("--name");
const resumeSessionId = readFlag("--resume-session");
const closeName =
command === "sessions" && args[commandIndex + 1] === "close"
? String(args[commandIndex + 2] || "")
: "";
const setModeValue = command === "set-mode" ? String(args[commandIndex + 1] || "") : "";
const setKey = command === "set" ? String(args[commandIndex + 1] || "") : "";
const setValue = command === "set" ? String(args[commandIndex + 2] || "") : "";
if (command === "sessions" && args[commandIndex + 1] === "ensure") {
writeLog({ kind: "ensure", agent, args, sessionName: ensureName });
if (process.env.MOCK_ACPX_ENSURE_STDERR) {
process.stderr.write(String(process.env.MOCK_ACPX_ENSURE_STDERR) + "\n");
}
if (process.env.MOCK_ACPX_ENSURE_EXIT_1 === "1") {
storeSessionByName(ensureName, resumeSessionId ? { agentSessionId: resumeSessionId } : {});
return emitJsonAndExit({
jsonrpc: "2.0",
id: null,
error: {
code: -32603,
message: process.env.MOCK_ACPX_ENSURE_ERROR_MESSAGE || "mock ensure failure",
},
}, 1);
}
if (process.env.MOCK_ACPX_ENSURE_EMPTY === "1") {
emitJson({ action: "session_ensured", name: ensureName });
} else {
const session = storeSessionByName(ensureName, resumeSessionId ? { agentSessionId: resumeSessionId } : {});
emitJson({
action: "session_ensured",
acpxRecordId: session.acpxRecordId,
acpxSessionId: session.acpxSessionId,
...(session.agentSessionId ? { agentSessionId: session.agentSessionId } : {}),
name: ensureName,
created: true,
});
}
flushAndExit(0);
return;
}
if (command === "sessions" && args[commandIndex + 1] === "new") {
writeLog({ kind: "new", agent, args, sessionName: ensureName });
if (process.env.MOCK_ACPX_NEW_FAIL_ON_RESUME === "1" && args.includes("--resume-session")) {
return emitJsonAndExit(
{
jsonrpc: "2.0",
id: null,
error: {
code: -32603,
message: "mock stale resume session",
},
},
1,
);
}
if (process.env.MOCK_ACPX_NEW_EMPTY === "1") {
emitJson({ action: "session_created", name: ensureName });
} else {
const session = storeSessionByName(ensureName, resumeSessionId ? { agentSessionId: resumeSessionId } : {});
emitJson({
action: "session_created",
acpxRecordId: session.acpxRecordId,
acpxSessionId: session.acpxSessionId,
...(session.agentSessionId ? { agentSessionId: session.agentSessionId } : {}),
name: ensureName,
created: true,
});
}
flushAndExit(0);
return;
}
if (command === "config" && args[commandIndex + 1] === "show") {
const configuredAgents = process.env.MOCK_ACPX_CONFIG_SHOW_AGENTS
? JSON.parse(process.env.MOCK_ACPX_CONFIG_SHOW_AGENTS)
: {};
emitJson({
defaultAgent: "codex",
defaultPermissions: "approve-reads",
nonInteractivePermissions: "deny",
authPolicy: "skip",
ttl: 300,
timeout: null,
format: "text",
agents: configuredAgents,
authMethods: [],
paths: {
global: "/tmp/mock-global.json",
project: "/tmp/mock-project.json",
},
loaded: {
global: false,
project: false,
},
});
flushAndExit(0);
return;
}
if (command === "cancel") {
const session = findSessionByReference(sessionFromOption);
writeLog({ kind: "cancel", agent, args, sessionName: sessionFromOption });
return emitJsonAndExit({
acpxSessionId: session ? session.acpxSessionId : "sid-" + sessionFromOption,
cancelled: true,
});
}
if (command === "set-mode") {
const session = findSessionByReference(sessionFromOption);
writeLog({ kind: "set-mode", agent, args, sessionName: sessionFromOption, mode: setModeValue });
return emitJsonAndExit({
action: "mode_set",
acpxSessionId: session ? session.acpxSessionId : "sid-" + sessionFromOption,
mode: setModeValue,
});
}
if (command === "set") {
const session = findSessionByReference(sessionFromOption);
writeLog({
kind: "set",
agent,
args,
sessionName: sessionFromOption,
key: setKey,
value: setValue,
});
emitJson({
action: "config_set",
acpxSessionId: session ? session.acpxSessionId : "sid-" + sessionFromOption,
key: setKey,
value: setValue,
});
flushAndExit(0);
return;
}
if (command === "status") {
const session = findSessionByReference(sessionFromOption);
writeLog({ kind: "status", agent, args, sessionName: sessionFromOption });
if (process.env.MOCK_ACPX_STATUS_SIGNAL) {
process.kill(process.pid, process.env.MOCK_ACPX_STATUS_SIGNAL);
}
const status = process.env.MOCK_ACPX_STATUS_STATUS || (sessionFromOption ? "alive" : "no-session");
const summary = process.env.MOCK_ACPX_STATUS_SUMMARY || "";
const omitStatusIds = process.env.MOCK_ACPX_STATUS_NO_IDS === "1";
emitJson({
acpxRecordId: !omitStatusIds && session ? session.acpxRecordId : null,
acpxSessionId: !omitStatusIds && session ? session.acpxSessionId : null,
agentSessionId: !omitStatusIds && session ? session.agentSessionId || null : null,
status,
...(summary ? { summary } : {}),
pid: 4242,
uptime: 120,
});
flushAndExit(0);
return;
}
if (command === "sessions" && args[commandIndex + 1] === "close") {
const session = findSessionByReference(closeName) || storeSessionByName(closeName);
writeLog({ kind: "close", agent, args, sessionName: closeName });
return emitJsonAndExit({
action: "session_closed",
acpxRecordId: session.acpxRecordId,
acpxSessionId: session.acpxSessionId,
name: closeName,
});
}
if (command === "prompt") {
const stdinText = fs.readFileSync(0, "utf8");
const session = resolveSession(sessionFromOption);
writeLog({
kind: "prompt",
agent,
args,
sessionName: sessionFromOption,
stdinText,
openclawShell,
openaiApiKey: process.env.OPENAI_API_KEY || "",
githubToken: process.env.GITHUB_TOKEN || "",
});
const requestId = "req-1";
let activeSessionId = session.agentSessionId || sessionFromOption;
emitJson({
jsonrpc: "2.0",
id: 0,
method: "session/load",
params: {
sessionId: sessionFromOption,
cwd: process.cwd(),
mcpServers: [],
},
});
const shouldRejectLoad =
process.env.MOCK_ACPX_PROMPT_LOAD_INVALID === "1" &&
(!session.agentSessionId || sessionFromOption !== session.agentSessionId);
if (shouldRejectLoad) {
const nextAgentSessionId =
process.env.MOCK_ACPX_PROMPT_NEW_AGENT_SESSION_ID || "agent-fallback-" + session.name;
const refreshed = storeSessionByName(session.name, {
agentSessionId: nextAgentSessionId,
});
emitJson({
jsonrpc: "2.0",
id: 0,
error: {
code: -32002,
message: "Invalid session identifier",
},
});
emitJson({
jsonrpc: "2.0",
id: 0,
result: {
sessionId: nextAgentSessionId,
},
});
activeSessionId = refreshed.agentSessionId || nextAgentSessionId;
} else {
if (process.env.MOCK_ACPX_PROMPT_OMIT_LOAD_RESULT !== "1") {
emitJson({
jsonrpc: "2.0",
id: 0,
result: {
sessionId: activeSessionId,
},
});
}
}
emitJson({
jsonrpc: "2.0",
id: requestId,
method: "session/prompt",
params: {
sessionId: activeSessionId,
prompt: [
{
type: "text",
text: stdinText.trim(),
},
],
},
});
if (stdinText.includes("trigger-error")) {
return emitJsonAndExit({
type: "error",
code: "-32000",
message: "mock failure",
}, 1);
}
if (stdinText.includes("permission-denied")) {
flushAndExit(5);
return;
}
if (process.env.MOCK_ACPX_PROMPT_SIGNAL) {
process.kill(process.pid, process.env.MOCK_ACPX_PROMPT_SIGNAL);
}
if (stdinText.includes("split-spacing")) {
emitUpdate(activeSessionId, {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "alpha" },
});
emitUpdate(activeSessionId, {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: " beta" },
});
emitUpdate(activeSessionId, {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: " gamma" },
});
emitJson({ type: "done", stopReason: "end_turn" });
flushAndExit(0);
return;
}
if (stdinText.includes("double-done")) {
emitUpdate(activeSessionId, {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "ok" },
});
emitJson({ type: "done", stopReason: "end_turn" });
emitJson({ type: "done", stopReason: "end_turn" });
flushAndExit(0);
return;
}
emitUpdate(activeSessionId, {
sessionUpdate: "agent_thought_chunk",
content: { type: "text", text: "thinking" },
});
emitUpdate(activeSessionId, {
sessionUpdate: "tool_call",
toolCallId: "tool-1",
title: "run-tests",
status: "in_progress",
kind: "command",
});
emitUpdate(activeSessionId, {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "echo:" + stdinText.trim() },
});
emitJson({ type: "done", stopReason: "end_turn" });
flushAndExit(0);
return;
}
writeLog({ kind: "unknown", args });
emitJsonAndExit({
type: "error",
code: "USAGE",
message: "unknown command",
}, 2);
})().catch((error) => {
process.stderr.write(String(error) + "\\n");
process.exit(1);
});
`;
export async function createMockRuntimeFixture(params?: {
permissionMode?: ResolvedAcpxPluginConfig["permissionMode"];
queueOwnerTtlSeconds?: number;
mcpServers?: ResolvedAcpxPluginConfig["mcpServers"];
}): Promise<{
runtime: AcpxRuntime;
logPath: string;
config: ResolvedAcpxPluginConfig;
}> {
const scriptPath = await ensureMockCliScriptPath();
const dir = path.dirname(scriptPath);
const logPath = path.join(dir, `calls-${logFileSequence++}.log`);
const statePath = path.join(dir, `state-${logFileSequence - 1}.json`);
process.env.MOCK_ACPX_LOG = logPath;
process.env.MOCK_ACPX_STATE = statePath;
const config: ResolvedAcpxPluginConfig = {
command: scriptPath,
allowPluginLocalInstall: false,
stripProviderAuthEnvVars: false,
installCommand: "n/a",
cwd: dir,
permissionMode: params?.permissionMode ?? "approve-all",
nonInteractivePermissions: "fail",
pluginToolsMcpBridge: false,
strictWindowsCmdWrapper: true,
queueOwnerTtlSeconds: params?.queueOwnerTtlSeconds ?? 0.1,
mcpServers: params?.mcpServers ?? {},
};
return {
runtime: new AcpxRuntime(config, {
queueOwnerTtlSeconds: params?.queueOwnerTtlSeconds,
logger: NOOP_LOGGER,
}),
logPath,
config,
};
}
async function ensureMockCliScriptPath(): Promise<string> {
if (sharedMockCliScriptPath) {
return await sharedMockCliScriptPath;
}
sharedMockCliScriptPath = (async () => {
const dir = await mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-acpx-runtime-test-"),
);
tempDirs.push(dir);
const scriptPath = path.join(dir, "mock-acpx.cjs");
await writeFile(scriptPath, MOCK_CLI_SCRIPT, "utf8");
await chmod(scriptPath, 0o755);
return scriptPath;
})();
return await sharedMockCliScriptPath;
}
export async function readMockRuntimeLogEntries(
logPath: string,
): Promise<Array<Record<string, unknown>>> {
if (!fs.existsSync(logPath)) {
return [];
}
const raw = await readFile(logPath, "utf8");
return raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => JSON.parse(line) as Record<string, unknown>);
}
export async function cleanupMockRuntimeFixtures(): Promise<void> {
delete process.env.MOCK_ACPX_LOG;
delete process.env.MOCK_ACPX_STATE;
delete process.env.MOCK_ACPX_CONFIG_SHOW_AGENTS;
delete process.env.MOCK_ACPX_ENSURE_ERROR_MESSAGE;
delete process.env.MOCK_ACPX_ENSURE_EXIT_1;
delete process.env.MOCK_ACPX_ENSURE_STDERR;
delete process.env.MOCK_ACPX_NEW_FAIL_ON_RESUME;
delete process.env.MOCK_ACPX_ENSURE_EMPTY;
delete process.env.MOCK_ACPX_ENSURE_NO_AGENT_SESSION_ID;
delete process.env.MOCK_ACPX_NEW_EMPTY;
delete process.env.MOCK_ACPX_AGENT_SESSION_PREFIX;
delete process.env.MOCK_ACPX_PROMPT_LOAD_INVALID;
delete process.env.MOCK_ACPX_PROMPT_NEW_AGENT_SESSION_ID;
delete process.env.MOCK_ACPX_STATUS_STATUS;
delete process.env.MOCK_ACPX_STATUS_NO_IDS;
delete process.env.MOCK_ACPX_STATUS_SUMMARY;
sharedMockCliScriptPath = null;
logFileSequence = 0;
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (!dir) {
continue;
}
await rm(dir, {
recursive: true,
force: true,
maxRetries: 10,
retryDelay: 10,
});
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,240 @@
import fs from "node:fs/promises";
import path from "node:path";
import type {
ReadTextFileRequest,
ReadTextFileResponse,
WriteTextFileRequest,
WriteTextFileResponse,
} from "@agentclientprotocol/sdk";
import { PermissionDeniedError, PermissionPromptUnavailableError } from "../errors.js";
import type {
ClientOperation,
NonInteractivePermissionPolicy,
PermissionMode,
} from "../runtime-types.js";
import { promptForPermission } from "./permission-prompt.js";
const WRITE_PREVIEW_MAX_LINES = 16;
const WRITE_PREVIEW_MAX_CHARS = 1_200;
export type FileSystemHandlersOptions = {
cwd: string;
permissionMode: PermissionMode;
nonInteractivePermissions?: NonInteractivePermissionPolicy;
onOperation?: (operation: ClientOperation) => void;
confirmWrite?: (filePath: string, preview: string) => Promise<boolean>;
};
function nowIso(): string {
return new Date().toISOString();
}
function isWithinRoot(rootDir: string, targetPath: string): boolean {
const relative = path.relative(rootDir, targetPath);
return relative.length === 0 || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function toWritePreview(content: string): string {
const normalized = content.replace(/\r\n/g, "\n");
const lines = normalized.split("\n");
const visibleLines = lines.slice(0, WRITE_PREVIEW_MAX_LINES);
let preview = visibleLines.join("\n");
if (lines.length > visibleLines.length) {
preview += `\n... (${lines.length - visibleLines.length} more lines)`;
}
if (preview.length > WRITE_PREVIEW_MAX_CHARS) {
preview = `${preview.slice(0, WRITE_PREVIEW_MAX_CHARS - 3)}...`;
}
return preview;
}
async function defaultConfirmWrite(filePath: string, preview: string): Promise<boolean> {
return await promptForPermission({
header: `[permission] Allow write to ${filePath}?`,
details: preview,
prompt: "Allow write? (y/N) ",
});
}
function canPromptForPermission(): boolean {
return Boolean(process.stdin.isTTY && process.stderr.isTTY);
}
export class FileSystemHandlers {
private readonly rootDir: string;
private permissionMode: PermissionMode;
private nonInteractivePermissions: NonInteractivePermissionPolicy;
private readonly onOperation?: (operation: ClientOperation) => void;
private readonly usesDefaultConfirmWrite: boolean;
private readonly confirmWrite: (filePath: string, preview: string) => Promise<boolean>;
constructor(options: FileSystemHandlersOptions) {
this.rootDir = path.resolve(options.cwd);
this.permissionMode = options.permissionMode;
this.nonInteractivePermissions = options.nonInteractivePermissions ?? "deny";
this.onOperation = options.onOperation;
this.usesDefaultConfirmWrite = options.confirmWrite == null;
this.confirmWrite = options.confirmWrite ?? defaultConfirmWrite;
}
updatePermissionPolicy(
permissionMode: PermissionMode,
nonInteractivePermissions?: NonInteractivePermissionPolicy,
): void {
this.permissionMode = permissionMode;
this.nonInteractivePermissions = nonInteractivePermissions ?? "deny";
}
async readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
const filePath = this.resolvePathWithinRoot(params.path);
const summary = `read_text_file: ${filePath}`;
this.emitOperation({
method: "fs/read_text_file",
status: "running",
summary,
details: this.readWindowDetails(params.line, params.limit),
timestamp: nowIso(),
});
try {
if (this.permissionMode === "deny-all") {
throw new PermissionDeniedError("Permission denied for fs/read_text_file (--deny-all)");
}
const content = await fs.readFile(filePath, "utf8");
const sliced = this.sliceContent(content, params.line, params.limit);
this.emitOperation({
method: "fs/read_text_file",
status: "completed",
summary,
details: this.readWindowDetails(params.line, params.limit),
timestamp: nowIso(),
});
return { content: sliced };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.emitOperation({
method: "fs/read_text_file",
status: "failed",
summary,
details: message,
timestamp: nowIso(),
});
throw error;
}
}
async writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
const filePath = this.resolvePathWithinRoot(params.path);
const preview = toWritePreview(params.content);
const summary = `write_text_file: ${filePath}`;
this.emitOperation({
method: "fs/write_text_file",
status: "running",
summary,
details: preview,
timestamp: nowIso(),
});
try {
if (!(await this.isWriteApproved(filePath, preview))) {
throw new PermissionDeniedError("Permission denied for fs/write_text_file");
}
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, params.content, "utf8");
this.emitOperation({
method: "fs/write_text_file",
status: "completed",
summary,
details: preview,
timestamp: nowIso(),
});
return {};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.emitOperation({
method: "fs/write_text_file",
status: "failed",
summary,
details: message,
timestamp: nowIso(),
});
throw error;
}
}
private async isWriteApproved(filePath: string, preview: string): Promise<boolean> {
if (this.permissionMode === "approve-all") {
return true;
}
if (this.permissionMode === "deny-all") {
return false;
}
if (
this.usesDefaultConfirmWrite &&
this.nonInteractivePermissions === "fail" &&
!canPromptForPermission()
) {
throw new PermissionPromptUnavailableError();
}
return await this.confirmWrite(filePath, preview);
}
private resolvePathWithinRoot(rawPath: string): string {
if (!path.isAbsolute(rawPath)) {
throw new Error(`Path must be absolute: ${rawPath}`);
}
const resolved = path.resolve(rawPath);
if (!isWithinRoot(this.rootDir, resolved)) {
throw new Error(`Path is outside allowed cwd subtree: ${resolved}`);
}
return resolved;
}
private sliceContent(
content: string,
line: number | null | undefined,
limit: number | null | undefined,
): string {
if (line == null && limit == null) {
return content;
}
const lines = content.split("\n");
const startLine = line == null ? 1 : Math.max(1, Math.trunc(line));
const startIndex = Math.max(0, startLine - 1);
const maxLines = limit == null ? undefined : Math.max(0, Math.trunc(limit));
if (maxLines === 0) {
return "";
}
const endIndex =
maxLines == null ? lines.length : Math.min(lines.length, startIndex + maxLines);
return lines.slice(startIndex, endIndex).join("\n");
}
private readWindowDetails(
line: number | null | undefined,
limit: number | null | undefined,
): string | undefined {
if (line == null && limit == null) {
return undefined;
}
const start = line == null ? 1 : Math.max(1, Math.trunc(line));
const max = limit == null ? "all" : Math.max(0, Math.trunc(limit));
return `line=${start}, limit=${max}`;
}
private emitOperation(operation: ClientOperation): void {
this.onOperation?.(operation);
}
}

View File

@@ -0,0 +1,33 @@
import readline from "node:readline/promises";
export type PermissionPromptOptions = {
prompt: string;
header?: string;
details?: string;
};
export async function promptForPermission(options: PermissionPromptOptions): Promise<boolean> {
if (!process.stdin.isTTY || !process.stderr.isTTY) {
return false;
}
if (options.header) {
process.stderr.write(`\n${options.header}\n`);
}
if (options.details && options.details.trim().length > 0) {
process.stderr.write(`${options.details}\n`);
}
const rl = readline.createInterface({
input: process.stdin,
output: process.stderr,
});
try {
const answer = await rl.question(options.prompt);
const normalized = answer.trim().toLowerCase();
return normalized === "y" || normalized === "yes";
} finally {
rl.close();
}
}

View File

@@ -0,0 +1,174 @@
import {
type PermissionOption,
type RequestPermissionRequest,
type RequestPermissionResponse,
type ToolKind,
} from "@agentclientprotocol/sdk";
import { PermissionPromptUnavailableError } from "../errors.js";
import type { NonInteractivePermissionPolicy, PermissionMode } from "../runtime-types.js";
import { promptForPermission } from "./permission-prompt.js";
type PermissionDecision = "approved" | "denied" | "cancelled";
const PERMISSION_MODE_RANK: Record<PermissionMode, number> = {
"deny-all": 0,
"approve-reads": 1,
"approve-all": 2,
};
function selected(optionId: string): RequestPermissionResponse {
return { outcome: { outcome: "selected", optionId } };
}
function cancelled(): RequestPermissionResponse {
return { outcome: { outcome: "cancelled" } };
}
function pickOption(
options: PermissionOption[],
kinds: PermissionOption["kind"][],
): PermissionOption | undefined {
for (const kind of kinds) {
const match = options.find((option) => option.kind === kind);
if (match) {
return match;
}
}
return undefined;
}
function inferToolKind(params: RequestPermissionRequest): ToolKind | undefined {
if (params.toolCall.kind) {
return params.toolCall.kind;
}
const title = params.toolCall.title?.trim().toLowerCase();
if (!title) {
return undefined;
}
const head = title.split(":", 1)[0]?.trim();
if (!head) {
return undefined;
}
if (head.includes("read") || head.includes("cat")) {
return "read";
}
if (head.includes("search") || head.includes("find") || head.includes("grep")) {
return "search";
}
if (head.includes("write") || head.includes("edit") || head.includes("patch")) {
return "edit";
}
if (head.includes("delete") || head.includes("remove")) {
return "delete";
}
if (head.includes("move") || head.includes("rename")) {
return "move";
}
if (head.includes("run") || head.includes("execute") || head.includes("bash")) {
return "execute";
}
if (head.includes("fetch") || head.includes("http") || head.includes("url")) {
return "fetch";
}
if (head.includes("think")) {
return "think";
}
return "other";
}
function isAutoApprovedReadKind(kind: ToolKind | undefined): boolean {
return kind === "read" || kind === "search";
}
async function promptForToolPermission(params: RequestPermissionRequest): Promise<boolean> {
const toolName = params.toolCall.title ?? "tool";
const toolKind = inferToolKind(params) ?? "other";
return await promptForPermission({
prompt: `\n[permission] Allow ${toolName} [${toolKind}]? (y/N) `,
});
}
function canPromptForPermission(): boolean {
return Boolean(process.stdin.isTTY && process.stderr.isTTY);
}
export function permissionModeSatisfies(actual: PermissionMode, required: PermissionMode): boolean {
return PERMISSION_MODE_RANK[actual] >= PERMISSION_MODE_RANK[required];
}
export async function resolvePermissionRequest(
params: RequestPermissionRequest,
mode: PermissionMode,
nonInteractivePolicy: NonInteractivePermissionPolicy = "deny",
): Promise<RequestPermissionResponse> {
const options = params.options ?? [];
if (options.length === 0) {
return cancelled();
}
const allowOption = pickOption(options, ["allow_once", "allow_always"]);
const rejectOption = pickOption(options, ["reject_once", "reject_always"]);
if (mode === "approve-all") {
if (allowOption) {
return selected(allowOption.optionId);
}
return selected(options[0].optionId);
}
if (mode === "deny-all") {
if (rejectOption) {
return selected(rejectOption.optionId);
}
return cancelled();
}
const kind = inferToolKind(params);
if (isAutoApprovedReadKind(kind) && allowOption) {
return selected(allowOption.optionId);
}
if (!canPromptForPermission()) {
if (nonInteractivePolicy === "fail") {
throw new PermissionPromptUnavailableError();
}
if (rejectOption) {
return selected(rejectOption.optionId);
}
return cancelled();
}
const approved = await promptForToolPermission(params);
if (approved && allowOption) {
return selected(allowOption.optionId);
}
if (!approved && rejectOption) {
return selected(rejectOption.optionId);
}
return cancelled();
}
export function classifyPermissionDecision(
params: RequestPermissionRequest,
response: RequestPermissionResponse,
): PermissionDecision {
if (response.outcome.outcome !== "selected") {
return "cancelled";
}
const selectedOptionId = response.outcome.optionId;
const selectedOption = params.options.find((option) => option.optionId === selectedOptionId);
if (!selectedOption) {
return "cancelled";
}
if (selectedOption.kind === "allow_once" || selectedOption.kind === "allow_always") {
return "approved";
}
return "denied";
}

View File

@@ -0,0 +1,76 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
function readWindowsEnvValue(env: NodeJS.ProcessEnv, key: string): string | undefined {
const matchedKey = Object.keys(env).find((entry) => entry.toUpperCase() === key);
return matchedKey ? env[matchedKey] : undefined;
}
function resolveWindowsCommand(
command: string,
env: NodeJS.ProcessEnv = process.env,
): string | undefined {
const extensions = (readWindowsEnvValue(env, "PATHEXT") ?? ".COM;.EXE;.BAT;.CMD")
.split(";")
.map((value) => value.trim().toLowerCase())
.filter((value) => value.length > 0);
const commandExtension = path.extname(command);
const candidates =
commandExtension.length > 0
? [command]
: extensions.map((extension) => `${command}${extension}`);
const hasPath = command.includes("/") || command.includes("\\") || path.isAbsolute(command);
if (hasPath) {
return candidates.find((candidate) => fs.existsSync(candidate));
}
const pathValue = readWindowsEnvValue(env, "PATH");
if (!pathValue) {
return undefined;
}
for (const directory of pathValue.split(";")) {
const trimmedDirectory = directory.trim();
if (trimmedDirectory.length === 0) {
continue;
}
for (const candidate of candidates) {
const resolved = path.join(trimmedDirectory, candidate);
if (fs.existsSync(resolved)) {
return resolved;
}
}
}
return undefined;
}
function shouldUseWindowsBatchShell(
command: string,
platform: NodeJS.Platform = process.platform,
env: NodeJS.ProcessEnv = process.env,
): boolean {
if (platform !== "win32") {
return false;
}
const resolvedCommand = resolveWindowsCommand(command, env) ?? command;
const ext = path.extname(resolvedCommand).toLowerCase();
return ext === ".cmd" || ext === ".bat";
}
export function buildSpawnCommandOptions(
command: string,
options: Parameters<typeof spawn>[2],
platform: NodeJS.Platform = process.platform,
env: NodeJS.ProcessEnv = process.env,
): Parameters<typeof spawn>[2] {
if (!shouldUseWindowsBatchShell(command, platform, env)) {
return options;
}
return {
...options,
shell: true,
};
}

View File

@@ -0,0 +1,462 @@
import { spawn, type ChildProcessByStdio } from "node:child_process";
import { randomUUID } from "node:crypto";
import type { Readable } from "node:stream";
import type {
CreateTerminalRequest,
CreateTerminalResponse,
KillTerminalRequest,
KillTerminalResponse,
ReleaseTerminalRequest,
ReleaseTerminalResponse,
TerminalOutputRequest,
TerminalOutputResponse,
WaitForTerminalExitRequest,
WaitForTerminalExitResponse,
} from "@agentclientprotocol/sdk";
import { PermissionDeniedError, PermissionPromptUnavailableError } from "../errors.js";
import type {
ClientOperation,
NonInteractivePermissionPolicy,
PermissionMode,
} from "../runtime-types.js";
import { promptForPermission } from "./permission-prompt.js";
import { buildSpawnCommandOptions } from "./spawn.js";
const DEFAULT_TERMINAL_OUTPUT_LIMIT_BYTES = 64 * 1024;
const DEFAULT_KILL_GRACE_MS = 1_500;
type ManagedTerminal = {
process: ChildProcessByStdio<null, Readable, Readable>;
output: Buffer;
truncated: boolean;
outputByteLimit: number;
exitCode: number | null | undefined;
signal: NodeJS.Signals | null | undefined;
exitPromise: Promise<WaitForTerminalExitResponse>;
resolveExit: (response: WaitForTerminalExitResponse) => void;
};
export type TerminalManagerOptions = {
cwd: string;
permissionMode: PermissionMode;
nonInteractivePermissions?: NonInteractivePermissionPolicy;
onOperation?: (operation: ClientOperation) => void;
confirmExecute?: (commandLine: string) => Promise<boolean>;
killGraceMs?: number;
};
type TerminalSpawnOptions = {
cwd: string;
env: NodeJS.ProcessEnv | undefined;
stdio: ["ignore", "pipe", "pipe"];
shell?: true;
windowsHide: true;
};
function nowIso(): string {
return new Date().toISOString();
}
function toCommandLine(command: string, args: string[] | undefined): string {
const renderedArgs = (args ?? []).map((arg) => JSON.stringify(arg)).join(" ");
return renderedArgs.length > 0 ? `${command} ${renderedArgs}` : command;
}
function toEnvObject(env: CreateTerminalRequest["env"]): NodeJS.ProcessEnv | undefined {
if (!env || env.length === 0) {
return undefined;
}
const merged: NodeJS.ProcessEnv = { ...process.env };
for (const entry of env) {
merged[entry.name] = entry.value;
}
return merged;
}
export function buildTerminalSpawnOptions(
command: string,
cwd: string,
env: CreateTerminalRequest["env"],
platform: NodeJS.Platform = process.platform,
): TerminalSpawnOptions {
const resolvedEnv = toEnvObject(env);
const options: TerminalSpawnOptions = {
cwd,
env: resolvedEnv,
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
};
return buildSpawnCommandOptions(
command,
options,
platform,
resolvedEnv ?? process.env,
) as TerminalSpawnOptions;
}
function trimToUtf8Boundary(buffer: Buffer, limit: number): Buffer {
if (limit <= 0) {
return Buffer.alloc(0);
}
if (buffer.length <= limit) {
return buffer;
}
let start = buffer.length - limit;
while (start < buffer.length && (buffer[start] & 0b1100_0000) === 0b1000_0000) {
start += 1;
}
if (start >= buffer.length) {
start = buffer.length - limit;
}
return buffer.subarray(start);
}
function waitForSpawn(process: ChildProcessByStdio<null, Readable, Readable>): Promise<void> {
return new Promise((resolve, reject) => {
const onSpawn = () => {
process.off("error", onError);
resolve();
};
const onError = (error: Error) => {
process.off("spawn", onSpawn);
reject(error);
};
process.once("spawn", onSpawn);
process.once("error", onError);
});
}
async function defaultConfirmExecute(commandLine: string): Promise<boolean> {
return await promptForPermission({
prompt: `\n[permission] Allow terminal command "${commandLine}"? (y/N) `,
});
}
function canPromptForPermission(): boolean {
return Boolean(process.stdin.isTTY && process.stderr.isTTY);
}
function waitMs(ms: number): Promise<void> {
return new Promise<void>((resolve) => {
setTimeout(resolve, Math.max(0, ms));
});
}
export class TerminalManager {
private readonly cwd: string;
private permissionMode: PermissionMode;
private nonInteractivePermissions: NonInteractivePermissionPolicy;
private readonly onOperation?: (operation: ClientOperation) => void;
private readonly usesDefaultConfirmExecute: boolean;
private readonly confirmExecute: (commandLine: string) => Promise<boolean>;
private readonly killGraceMs: number;
private readonly terminals = new Map<string, ManagedTerminal>();
constructor(options: TerminalManagerOptions) {
this.cwd = options.cwd;
this.permissionMode = options.permissionMode;
this.nonInteractivePermissions = options.nonInteractivePermissions ?? "deny";
this.onOperation = options.onOperation;
this.usesDefaultConfirmExecute = options.confirmExecute == null;
this.confirmExecute = options.confirmExecute ?? defaultConfirmExecute;
this.killGraceMs = Math.max(0, Math.round(options.killGraceMs ?? DEFAULT_KILL_GRACE_MS));
}
updatePermissionPolicy(
permissionMode: PermissionMode,
nonInteractivePermissions?: NonInteractivePermissionPolicy,
): void {
this.permissionMode = permissionMode;
this.nonInteractivePermissions = nonInteractivePermissions ?? "deny";
}
async createTerminal(params: CreateTerminalRequest): Promise<CreateTerminalResponse> {
const commandLine = toCommandLine(params.command, params.args);
const summary = `terminal/create: ${commandLine}`;
this.emitOperation({
method: "terminal/create",
status: "running",
summary,
timestamp: nowIso(),
});
try {
if (!(await this.isExecuteApproved(commandLine))) {
throw new PermissionDeniedError("Permission denied for terminal/create");
}
const outputByteLimit = Math.max(
0,
Math.round(params.outputByteLimit ?? DEFAULT_TERMINAL_OUTPUT_LIMIT_BYTES),
);
const proc = spawn(
params.command,
params.args ?? [],
buildTerminalSpawnOptions(params.command, params.cwd ?? this.cwd, params.env),
);
await waitForSpawn(proc);
let resolveExit: (response: WaitForTerminalExitResponse) => void = () => {};
const exitPromise = new Promise<WaitForTerminalExitResponse>((resolve) => {
resolveExit = resolve;
});
const terminal: ManagedTerminal = {
process: proc,
output: Buffer.alloc(0),
truncated: false,
outputByteLimit,
exitCode: undefined,
signal: undefined,
exitPromise,
resolveExit,
};
const appendOutput = (chunk: Buffer | string): void => {
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
if (bytes.length === 0) {
return;
}
terminal.output = Buffer.concat([terminal.output, bytes]);
if (terminal.output.length > terminal.outputByteLimit) {
terminal.output = trimToUtf8Boundary(terminal.output, terminal.outputByteLimit);
terminal.truncated = true;
}
};
proc.stdout.on("data", appendOutput);
proc.stderr.on("data", appendOutput);
proc.once("exit", (exitCode, signal) => {
terminal.exitCode = exitCode;
terminal.signal = signal;
terminal.resolveExit({
exitCode: exitCode ?? null,
signal: signal ?? null,
});
});
const terminalId = randomUUID();
this.terminals.set(terminalId, terminal);
this.emitOperation({
method: "terminal/create",
status: "completed",
summary,
details: `terminalId=${terminalId}`,
timestamp: nowIso(),
});
return { terminalId };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.emitOperation({
method: "terminal/create",
status: "failed",
summary,
details: message,
timestamp: nowIso(),
});
throw error;
}
}
async terminalOutput(params: TerminalOutputRequest): Promise<TerminalOutputResponse> {
const terminal = this.getTerminal(params.terminalId);
if (!terminal) {
throw new Error(`Unknown terminal: ${params.terminalId}`);
}
const hasExitStatus = terminal.exitCode !== undefined || terminal.signal !== undefined;
this.emitOperation({
method: "terminal/output",
status: "completed",
summary: `terminal/output: ${params.terminalId}`,
timestamp: nowIso(),
});
return {
output: terminal.output.toString("utf8"),
truncated: terminal.truncated,
exitStatus: hasExitStatus
? {
exitCode: terminal.exitCode ?? null,
signal: terminal.signal ?? null,
}
: undefined,
};
}
async waitForTerminalExit(
params: WaitForTerminalExitRequest,
): Promise<WaitForTerminalExitResponse> {
const terminal = this.getTerminal(params.terminalId);
if (!terminal) {
throw new Error(`Unknown terminal: ${params.terminalId}`);
}
const response = await terminal.exitPromise;
this.emitOperation({
method: "terminal/wait_for_exit",
status: "completed",
summary: `terminal/wait_for_exit: ${params.terminalId}`,
details: `exitCode=${response.exitCode ?? "null"}, signal=${response.signal ?? "null"}`,
timestamp: nowIso(),
});
return response;
}
async killTerminal(params: KillTerminalRequest): Promise<KillTerminalResponse> {
const terminal = this.getTerminal(params.terminalId);
if (!terminal) {
throw new Error(`Unknown terminal: ${params.terminalId}`);
}
const summary = `terminal/kill: ${params.terminalId}`;
this.emitOperation({
method: "terminal/kill",
status: "running",
summary,
timestamp: nowIso(),
});
try {
await this.killProcess(terminal);
this.emitOperation({
method: "terminal/kill",
status: "completed",
summary,
timestamp: nowIso(),
});
return {};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.emitOperation({
method: "terminal/kill",
status: "failed",
summary,
details: message,
timestamp: nowIso(),
});
throw error;
}
}
async releaseTerminal(params: ReleaseTerminalRequest): Promise<ReleaseTerminalResponse> {
const summary = `terminal/release: ${params.terminalId}`;
this.emitOperation({
method: "terminal/release",
status: "running",
summary,
timestamp: nowIso(),
});
const terminal = this.getTerminal(params.terminalId);
if (!terminal) {
this.emitOperation({
method: "terminal/release",
status: "completed",
summary,
details: "already released",
timestamp: nowIso(),
});
return {};
}
try {
await this.killProcess(terminal);
await terminal.exitPromise.catch(() => {
// ignore best-effort wait failures
});
terminal.output = Buffer.alloc(0);
this.terminals.delete(params.terminalId);
this.emitOperation({
method: "terminal/release",
status: "completed",
summary,
timestamp: nowIso(),
});
return {};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.emitOperation({
method: "terminal/release",
status: "failed",
summary,
details: message,
timestamp: nowIso(),
});
throw error;
}
}
async shutdown(): Promise<void> {
for (const terminalId of Array.from(this.terminals.keys())) {
await this.releaseTerminal({ terminalId, sessionId: "shutdown" });
}
}
private getTerminal(terminalId: string): ManagedTerminal | undefined {
return this.terminals.get(terminalId);
}
private emitOperation(operation: ClientOperation): void {
this.onOperation?.(operation);
}
private async isExecuteApproved(commandLine: string): Promise<boolean> {
if (this.permissionMode === "approve-all") {
return true;
}
if (this.permissionMode === "deny-all") {
return false;
}
if (
this.usesDefaultConfirmExecute &&
this.nonInteractivePermissions === "fail" &&
!canPromptForPermission()
) {
throw new PermissionPromptUnavailableError();
}
return await this.confirmExecute(commandLine);
}
private isRunning(terminal: ManagedTerminal): boolean {
return terminal.exitCode === undefined && terminal.signal === undefined;
}
private async killProcess(terminal: ManagedTerminal): Promise<void> {
if (!this.isRunning(terminal)) {
return;
}
try {
terminal.process.kill("SIGTERM");
} catch {
return;
}
const exitedAfterTerm = await Promise.race([
terminal.exitPromise.then(() => true),
waitMs(this.killGraceMs).then(() => false),
]);
if (exitedAfterTerm || !this.isRunning(terminal)) {
return;
}
try {
terminal.process.kill("SIGKILL");
} catch {
return;
}
await Promise.race([terminal.exitPromise.then(() => undefined), waitMs(this.killGraceMs)]);
}
}

View File

@@ -20,6 +20,7 @@ export type {
AcpRuntimeEvent,
AcpRuntimeHandle,
AcpRuntimeStatus,
AcpRuntimeTurnAttachment,
AcpRuntimeTurnInput,
AcpSessionUpdateTag,
} from "../acp/runtime/types.js";