Include full contents of all nested repositories

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 16:25:02 +01:00
parent 14ff8fd54c
commit 2401ed446f
7271 changed files with 1310112 additions and 6 deletions

View File

@@ -0,0 +1,19 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { createAcpxPluginConfigSchema } from "./src/config.js";
import { createAcpxRuntimeService } from "./src/service.js";
const plugin = {
id: "acpx",
name: "ACPX Runtime",
description: "ACP runtime backend powered by the acpx CLI.",
configSchema: createAcpxPluginConfigSchema(),
register(api: OpenClawPluginApi) {
api.registerService(
createAcpxRuntimeService({
pluginConfig: api.pluginConfig,
}),
);
},
};
export default plugin;

View File

@@ -0,0 +1,55 @@
{
"id": "acpx",
"name": "ACPX Runtime",
"description": "ACP runtime backend powered by a pinned plugin-local acpx CLI.",
"skills": ["./skills"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"cwd": {
"type": "string"
},
"permissionMode": {
"type": "string",
"enum": ["approve-all", "approve-reads", "deny-all"]
},
"nonInteractivePermissions": {
"type": "string",
"enum": ["deny", "fail"]
},
"timeoutSeconds": {
"type": "number",
"minimum": 0.001
},
"queueOwnerTtlSeconds": {
"type": "number",
"minimum": 0
}
}
},
"uiHints": {
"cwd": {
"label": "Default Working Directory",
"help": "Default cwd for ACP session operations when not set per session."
},
"permissionMode": {
"label": "Permission Mode",
"help": "Default acpx permission policy for runtime prompts."
},
"nonInteractivePermissions": {
"label": "Non-Interactive Permission Policy",
"help": "acpx policy when interactive permission prompts are unavailable."
},
"timeoutSeconds": {
"label": "Prompt Timeout Seconds",
"help": "Optional acpx timeout for each 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.",
"advanced": true
}
}
}

View File

@@ -0,0 +1,14 @@
{
"name": "@openclaw/acpx",
"version": "2026.2.26",
"description": "OpenClaw ACP runtime backend via acpx",
"type": "module",
"dependencies": {
"acpx": "^0.1.13"
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,209 @@
---
name: acp-router
description: Route plain-language requests for Pi, Claude Code, Codex, OpenCode, Gemini CLI, or ACP harness work into either OpenClaw ACP runtime sessions or direct acpx-driven sessions ("telephone game" flow).
user-invocable: false
---
# ACP Harness Router
When user intent is "run this in Pi/Claude Code/Codex/OpenCode/Gemini (ACP harness)", do not use subagent runtime or PTY scraping. Route through ACP-aware flows.
## Intent detection
Trigger this skill when the user asks OpenClaw to:
- run something in Pi / Claude Code / Codex / OpenCode / Gemini
- continue existing harness work
- relay instructions to an external coding harness
- keep an external harness conversation in a thread-like conversation
## Mode selection
Choose one of these paths:
1. OpenClaw ACP runtime path (default): use `sessions_spawn` / ACP runtime tools.
2. Direct `acpx` path (telephone game): use `acpx` CLI through `exec` to drive the harness session directly.
Use direct `acpx` when one of these is true:
- user explicitly asks for direct `acpx` driving
- ACP runtime/plugin path is unavailable or unhealthy
- the task is "just relay prompts to harness" and no OpenClaw ACP lifecycle features are needed
Do not use:
- `subagents` runtime for harness control
- `/acp` command delegation as a requirement for the user
- PTY scraping of pi/claude/codex/opencode/gemini CLIs when `acpx` is available
## AgentId mapping
Use these defaults when user names a harness directly:
- "pi" -> `agentId: "pi"`
- "claude" or "claude code" -> `agentId: "claude"`
- "codex" -> `agentId: "codex"`
- "opencode" -> `agentId: "opencode"`
- "gemini" or "gemini cli" -> `agentId: "gemini"`
These defaults match current acpx built-in aliases.
If policy rejects the chosen id, report the policy error clearly and ask for the allowed ACP agent id.
## OpenClaw ACP runtime path
Required behavior:
1. Use `sessions_spawn` with:
- `runtime: "acp"`
- `thread: true`
- `mode: "session"` (unless user explicitly wants one-shot)
2. Put requested work in `task` so the ACP session gets it immediately.
3. Set `agentId` explicitly unless ACP default agent is known.
4. Do not ask user to run slash commands or CLI when this path works directly.
Example:
User: "spawn a test codex session in thread and tell it to say hi"
Call:
```json
{
"task": "Say hi.",
"runtime": "acp",
"agentId": "codex",
"thread": true,
"mode": "session"
}
```
## Thread spawn recovery policy
When the user asks to start a coding harness in a thread (for example "start a codex/claude/pi thread"), treat that as an ACP runtime request and try to satisfy it end-to-end.
Required behavior when ACP backend is unavailable:
1. Do not immediately ask the user to pick an alternate path.
2. First attempt automatic local repair:
- ensure plugin-local pinned acpx is installed in `extensions/acpx`
- verify `${ACPX_CMD} --version`
3. After reinstall/repair, restart the gateway and explicitly offer to run that restart for the user.
4. Retry ACP thread spawn once after repair.
5. Only if repair+retry fails, report the concrete error and then offer fallback options.
When offering fallback, keep ACP first:
- Option 1: retry ACP spawn after showing exact failing step
- Option 2: direct acpx telephone-game flow
Do not default to subagent runtime for these requests.
## ACPX install and version policy (direct acpx path)
For this repo, direct `acpx` calls must follow the same pinned policy as the `@openclaw/acpx` extension.
1. Prefer plugin-local binary, not global PATH:
- `./extensions/acpx/node_modules/.bin/acpx`
2. Resolve pinned version from extension dependency:
- `node -e "console.log(require('./extensions/acpx/package.json').dependencies.acpx)"`
3. If binary is missing or version mismatched, install plugin-local pinned version:
- `cd extensions/acpx && npm install --omit=dev --no-save acpx@<pinnedVersion>`
4. Verify before use:
- `./extensions/acpx/node_modules/.bin/acpx --version`
5. If install/repair changed ACPX artifacts, restart the gateway and offer to run the restart.
6. Do not run `npm install -g acpx` unless the user explicitly asks for global install.
Set and reuse:
```bash
ACPX_CMD="./extensions/acpx/node_modules/.bin/acpx"
```
## Direct acpx path ("telephone game")
Use this path to drive harness sessions without `/acp` or subagent runtime.
### Rules
1. Use `exec` commands that call `${ACPX_CMD}`.
2. Reuse a stable session name per conversation so follow-up prompts stay in the same harness context.
3. Prefer `--format quiet` for clean assistant text to relay back to user.
4. Use `exec` (one-shot) only when the user wants one-shot behavior.
5. Keep working directory explicit (`--cwd`) when task scope depends on repo context.
### Session naming
Use a deterministic name, for example:
- `oc-<harness>-<conversationId>`
Where `conversationId` is thread id when available, otherwise channel/conversation id.
### Command templates
Persistent session (create if missing, then prompt):
```bash
${ACPX_CMD} codex sessions show oc-codex-<conversationId> \
|| ${ACPX_CMD} codex sessions new --name oc-codex-<conversationId>
${ACPX_CMD} codex -s oc-codex-<conversationId> --cwd <workspacePath> --format quiet "<prompt>"
```
One-shot:
```bash
${ACPX_CMD} codex exec --cwd <workspacePath> --format quiet "<prompt>"
```
Cancel in-flight turn:
```bash
${ACPX_CMD} codex cancel -s oc-codex-<conversationId>
```
Close session:
```bash
${ACPX_CMD} codex sessions close oc-codex-<conversationId>
```
### Harness aliases in acpx
- `pi`
- `claude`
- `codex`
- `opencode`
- `gemini`
### Built-in adapter commands in acpx
Defaults are:
- `pi -> npx pi-acp`
- `claude -> npx -y @zed-industries/claude-agent-acp`
- `codex -> npx @zed-industries/codex-acp`
- `opencode -> npx -y opencode-ai acp`
- `gemini -> gemini`
If `~/.acpx/config.json` overrides `agents`, those overrides replace defaults.
### Failure handling
- `acpx: command not found`:
- for thread-spawn ACP requests, install plugin-local pinned acpx in `extensions/acpx` immediately
- restart gateway after install and offer to run the restart automatically
- then retry once
- do not ask for install permission first unless policy explicitly requires it
- do not install global `acpx` unless explicitly requested
- adapter command missing (for example `claude-agent-acp` not found):
- for thread-spawn ACP requests, first restore built-in defaults by removing broken `~/.acpx/config.json` agent overrides
- then retry once before offering fallback
- if user wants binary-based overrides, install exactly the configured adapter binary
- `NO_SESSION`: run `${ACPX_CMD} <agent> sessions new --name <sessionName>` then retry prompt.
- queue busy: either wait for completion (default) or use `--no-wait` when async behavior is explicitly desired.
### Output relay
When relaying to user, return the final assistant text output from `acpx` command result. Avoid relaying raw local tool noise unless user asked for verbose logs.

View File

@@ -0,0 +1,53 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
ACPX_BUNDLED_BIN,
createAcpxPluginConfigSchema,
resolveAcpxPluginConfig,
} from "./config.js";
describe("acpx plugin config parsing", () => {
it("resolves a strict plugin-local acpx command", () => {
const resolved = resolveAcpxPluginConfig({
rawConfig: {
cwd: "/tmp/workspace",
},
workspaceDir: "/tmp/workspace",
});
expect(resolved.command).toBe(ACPX_BUNDLED_BIN);
expect(resolved.cwd).toBe(path.resolve("/tmp/workspace"));
});
it("rejects command overrides", () => {
expect(() =>
resolveAcpxPluginConfig({
rawConfig: {
command: "acpx-custom",
},
workspaceDir: "/tmp/workspace",
}),
).toThrow("unknown config key: command");
});
it("rejects commandArgs overrides", () => {
expect(() =>
resolveAcpxPluginConfig({
rawConfig: {
commandArgs: ["--foo"],
},
workspaceDir: "/tmp/workspace",
}),
).toThrow("unknown config key: commandArgs");
});
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);
});
});

View File

@@ -0,0 +1,196 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { OpenClawPluginConfigSchema } from "openclaw/plugin-sdk";
export const ACPX_PERMISSION_MODES = ["approve-all", "approve-reads", "deny-all"] as const;
export type AcpxPermissionMode = (typeof ACPX_PERMISSION_MODES)[number];
export const ACPX_NON_INTERACTIVE_POLICIES = ["deny", "fail"] as const;
export type AcpxNonInteractivePermissionPolicy = (typeof ACPX_NON_INTERACTIVE_POLICIES)[number];
export const ACPX_PINNED_VERSION = "0.1.13";
const ACPX_BIN_NAME = process.platform === "win32" ? "acpx.cmd" : "acpx";
export const ACPX_PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
export const ACPX_BUNDLED_BIN = path.join(ACPX_PLUGIN_ROOT, "node_modules", ".bin", ACPX_BIN_NAME);
export const ACPX_LOCAL_INSTALL_COMMAND = `npm install --omit=dev --no-save acpx@${ACPX_PINNED_VERSION}`;
export type AcpxPluginConfig = {
cwd?: string;
permissionMode?: AcpxPermissionMode;
nonInteractivePermissions?: AcpxNonInteractivePermissionPolicy;
timeoutSeconds?: number;
queueOwnerTtlSeconds?: number;
};
export type ResolvedAcpxPluginConfig = {
command: string;
cwd: string;
permissionMode: AcpxPermissionMode;
nonInteractivePermissions: AcpxNonInteractivePermissionPolicy;
timeoutSeconds?: number;
queueOwnerTtlSeconds: number;
};
const DEFAULT_PERMISSION_MODE: AcpxPermissionMode = "approve-reads";
const DEFAULT_NON_INTERACTIVE_POLICY: AcpxNonInteractivePermissionPolicy = "fail";
const DEFAULT_QUEUE_OWNER_TTL_SECONDS = 0.1;
type ParseResult =
| { ok: true; value: AcpxPluginConfig | undefined }
| { ok: false; message: string };
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isPermissionMode(value: string): value is AcpxPermissionMode {
return ACPX_PERMISSION_MODES.includes(value as AcpxPermissionMode);
}
function isNonInteractivePermissionPolicy(
value: string,
): value is AcpxNonInteractivePermissionPolicy {
return ACPX_NON_INTERACTIVE_POLICIES.includes(value as AcpxNonInteractivePermissionPolicy);
}
function parseAcpxPluginConfig(value: unknown): ParseResult {
if (value === undefined) {
return { ok: true, value: undefined };
}
if (!isRecord(value)) {
return { ok: false, message: "expected config object" };
}
const allowedKeys = new Set([
"cwd",
"permissionMode",
"nonInteractivePermissions",
"timeoutSeconds",
"queueOwnerTtlSeconds",
]);
for (const key of Object.keys(value)) {
if (!allowedKeys.has(key)) {
return { ok: false, message: `unknown config key: ${key}` };
}
}
const cwd = value.cwd;
if (cwd !== undefined && (typeof cwd !== "string" || cwd.trim() === "")) {
return { ok: false, message: "cwd must be a non-empty string" };
}
const permissionMode = value.permissionMode;
if (
permissionMode !== undefined &&
(typeof permissionMode !== "string" || !isPermissionMode(permissionMode))
) {
return {
ok: false,
message: `permissionMode must be one of: ${ACPX_PERMISSION_MODES.join(", ")}`,
};
}
const nonInteractivePermissions = value.nonInteractivePermissions;
if (
nonInteractivePermissions !== undefined &&
(typeof nonInteractivePermissions !== "string" ||
!isNonInteractivePermissionPolicy(nonInteractivePermissions))
) {
return {
ok: false,
message: `nonInteractivePermissions must be one of: ${ACPX_NON_INTERACTIVE_POLICIES.join(", ")}`,
};
}
const timeoutSeconds = value.timeoutSeconds;
if (
timeoutSeconds !== undefined &&
(typeof timeoutSeconds !== "number" || !Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0)
) {
return { ok: false, message: "timeoutSeconds must be a positive number" };
}
const queueOwnerTtlSeconds = value.queueOwnerTtlSeconds;
if (
queueOwnerTtlSeconds !== undefined &&
(typeof queueOwnerTtlSeconds !== "number" ||
!Number.isFinite(queueOwnerTtlSeconds) ||
queueOwnerTtlSeconds < 0)
) {
return { ok: false, message: "queueOwnerTtlSeconds must be a non-negative number" };
}
return {
ok: true,
value: {
cwd: typeof cwd === "string" ? cwd.trim() : undefined,
permissionMode: typeof permissionMode === "string" ? permissionMode : undefined,
nonInteractivePermissions:
typeof nonInteractivePermissions === "string" ? nonInteractivePermissions : undefined,
timeoutSeconds: typeof timeoutSeconds === "number" ? timeoutSeconds : undefined,
queueOwnerTtlSeconds:
typeof queueOwnerTtlSeconds === "number" ? queueOwnerTtlSeconds : undefined,
},
};
}
export function createAcpxPluginConfigSchema(): OpenClawPluginConfigSchema {
return {
safeParse(value: unknown):
| { success: true; data?: unknown }
| {
success: false;
error: { issues: Array<{ path: Array<string | number>; message: string }> };
} {
const parsed = parseAcpxPluginConfig(value);
if (parsed.ok) {
return { success: true, data: parsed.value };
}
return {
success: false,
error: {
issues: [{ path: [], message: parsed.message }],
},
};
},
jsonSchema: {
type: "object",
additionalProperties: false,
properties: {
cwd: { type: "string" },
permissionMode: {
type: "string",
enum: [...ACPX_PERMISSION_MODES],
},
nonInteractivePermissions: {
type: "string",
enum: [...ACPX_NON_INTERACTIVE_POLICIES],
},
timeoutSeconds: { type: "number", minimum: 0.001 },
queueOwnerTtlSeconds: { type: "number", minimum: 0 },
},
},
};
}
export function resolveAcpxPluginConfig(params: {
rawConfig: unknown;
workspaceDir?: string;
}): ResolvedAcpxPluginConfig {
const parsed = parseAcpxPluginConfig(params.rawConfig);
if (!parsed.ok) {
throw new Error(parsed.message);
}
const normalized = parsed.value ?? {};
const fallbackCwd = params.workspaceDir?.trim() || process.cwd();
const cwd = path.resolve(normalized.cwd?.trim() || fallbackCwd);
return {
command: ACPX_BUNDLED_BIN,
cwd,
permissionMode: normalized.permissionMode ?? DEFAULT_PERMISSION_MODE,
nonInteractivePermissions:
normalized.nonInteractivePermissions ?? DEFAULT_NON_INTERACTIVE_POLICY,
timeoutSeconds: normalized.timeoutSeconds,
queueOwnerTtlSeconds: normalized.queueOwnerTtlSeconds ?? DEFAULT_QUEUE_OWNER_TTL_SECONDS,
};
}

View File

@@ -0,0 +1,125 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ACPX_LOCAL_INSTALL_COMMAND, ACPX_PINNED_VERSION } from "./config.js";
const { resolveSpawnFailureMock, spawnAndCollectMock } = vi.hoisted(() => ({
resolveSpawnFailureMock: vi.fn(() => null),
spawnAndCollectMock: vi.fn(),
}));
vi.mock("./runtime-internals/process.js", () => ({
resolveSpawnFailure: resolveSpawnFailureMock,
spawnAndCollect: spawnAndCollectMock,
}));
import { checkPinnedAcpxVersion, ensurePinnedAcpx } from "./ensure.js";
describe("acpx ensure", () => {
beforeEach(() => {
resolveSpawnFailureMock.mockReset();
resolveSpawnFailureMock.mockReturnValue(null);
spawnAndCollectMock.mockReset();
});
it("accepts the pinned acpx version", async () => {
spawnAndCollectMock.mockResolvedValueOnce({
stdout: `acpx ${ACPX_PINNED_VERSION}\n`,
stderr: "",
code: 0,
error: null,
});
const result = await checkPinnedAcpxVersion({
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,
});
});
it("reports version mismatch", async () => {
spawnAndCollectMock.mockResolvedValueOnce({
stdout: "acpx 0.0.9\n",
stderr: "",
code: 0,
error: null,
});
const result = await checkPinnedAcpxVersion({
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("installs and verifies pinned acpx when precheck fails", async () => {
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,
});
await ensurePinnedAcpx({
command: "/plugin/node_modules/.bin/acpx",
pluginRoot: "/plugin",
expectedVersion: ACPX_PINNED_VERSION,
});
expect(spawnAndCollectMock).toHaveBeenCalledTimes(3);
expect(spawnAndCollectMock.mock.calls[1]?.[0]).toMatchObject({
command: "npm",
args: ["install", "--omit=dev", "--no-save", `acpx@${ACPX_PINNED_VERSION}`],
cwd: "/plugin",
});
});
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(
ensurePinnedAcpx({
command: "/plugin/node_modules/.bin/acpx",
pluginRoot: "/plugin",
expectedVersion: ACPX_PINNED_VERSION,
}),
).rejects.toThrow("failed to install plugin-local acpx");
});
});

View File

@@ -0,0 +1,169 @@
import type { PluginLogger } from "openclaw/plugin-sdk";
import { ACPX_LOCAL_INSTALL_COMMAND, ACPX_PINNED_VERSION, ACPX_PLUGIN_ROOT } from "./config.js";
import { resolveSpawnFailure, 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;
}
export async function checkPinnedAcpxVersion(params: {
command: string;
cwd?: string;
expectedVersion?: string;
}): Promise<AcpxVersionCheckResult> {
const expectedVersion = params.expectedVersion ?? ACPX_PINNED_VERSION;
const cwd = params.cwd ?? ACPX_PLUGIN_ROOT;
const result = await spawnAndCollect({
command: params.command,
args: ["--version"],
cwd,
});
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: ACPX_LOCAL_INSTALL_COMMAND,
};
}
return {
ok: false,
reason: "execution-failed",
message: result.error.message,
expectedVersion,
installCommand: ACPX_LOCAL_INSTALL_COMMAND,
};
}
if ((result.code ?? 0) !== 0) {
const stderr = result.stderr.trim();
return {
ok: false,
reason: "execution-failed",
message: stderr || `acpx --version failed with code ${result.code ?? "unknown"}`,
expectedVersion,
installCommand: ACPX_LOCAL_INSTALL_COMMAND,
};
}
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: ACPX_LOCAL_INSTALL_COMMAND,
};
}
if (installedVersion !== expectedVersion) {
return {
ok: false,
reason: "version-mismatch",
message: `acpx version mismatch: found ${installedVersion}, expected ${expectedVersion}`,
expectedVersion,
installCommand: ACPX_LOCAL_INSTALL_COMMAND,
installedVersion,
};
}
return {
ok: true,
version: installedVersion,
expectedVersion,
};
}
let pendingEnsure: Promise<void> | null = null;
export async function ensurePinnedAcpx(params: {
command: string;
logger?: PluginLogger;
pluginRoot?: string;
expectedVersion?: string;
}): Promise<void> {
if (pendingEnsure) {
return await pendingEnsure;
}
pendingEnsure = (async () => {
const pluginRoot = params.pluginRoot ?? ACPX_PLUGIN_ROOT;
const expectedVersion = params.expectedVersion ?? ACPX_PINNED_VERSION;
const precheck = await checkPinnedAcpxVersion({
command: params.command,
cwd: pluginRoot,
expectedVersion,
});
if (precheck.ok) {
return;
}
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", `acpx@${expectedVersion}`],
cwd: pluginRoot,
});
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 checkPinnedAcpxVersion({
command: params.command,
cwd: pluginRoot,
expectedVersion,
});
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,140 @@
import type { AcpRuntimeEvent } from "openclaw/plugin-sdk";
import {
asOptionalBoolean,
asOptionalString,
asString,
asTrimmedString,
type AcpxErrorEvent,
type AcpxJsonObject,
isRecord,
} from "./shared.js";
export function toAcpxErrorEvent(value: unknown): AcpxErrorEvent | null {
if (!isRecord(value)) {
return null;
}
if (asTrimmedString(value.type) !== "error") {
return null;
}
return {
message: asTrimmedString(value.message) || "acpx reported an error",
code: asOptionalString(value.code),
retryable: asOptionalBoolean(value.retryable),
};
}
export function parseJsonLines(value: string): AcpxJsonObject[] {
const events: AcpxJsonObject[] = [];
for (const line of value.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
try {
const parsed = JSON.parse(trimmed) as unknown;
if (isRecord(parsed)) {
events.push(parsed);
}
} catch {
// Ignore malformed lines; callers handle missing typed events via exit code.
}
}
return events;
}
export function parsePromptEventLine(line: string): AcpRuntimeEvent | null {
const trimmed = line.trim();
if (!trimmed) {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
return {
type: "status",
text: trimmed,
};
}
if (!isRecord(parsed)) {
return null;
}
const type = asTrimmedString(parsed.type);
switch (type) {
case "text": {
const content = asString(parsed.content);
if (content == null || content.length === 0) {
return null;
}
return {
type: "text_delta",
text: content,
stream: "output",
};
}
case "thought": {
const content = asString(parsed.content);
if (content == null || content.length === 0) {
return null;
}
return {
type: "text_delta",
text: content,
stream: "thought",
};
}
case "tool_call": {
const title = asTrimmedString(parsed.title) || asTrimmedString(parsed.toolCallId) || "tool";
const status = asTrimmedString(parsed.status);
return {
type: "tool_call",
text: status ? `${title} (${status})` : title,
};
}
case "client_operation": {
const method = asTrimmedString(parsed.method) || "operation";
const status = asTrimmedString(parsed.status);
const summary = asTrimmedString(parsed.summary);
const text = [method, status, summary].filter(Boolean).join(" ");
if (!text) {
return null;
}
return { type: "status", text };
}
case "plan": {
const entries = Array.isArray(parsed.entries) ? parsed.entries : [];
const first = entries.find((entry) => isRecord(entry)) as Record<string, unknown> | undefined;
const content = asTrimmedString(first?.content);
if (!content) {
return null;
}
return { type: "status", text: `plan: ${content}` };
}
case "update": {
const update = asTrimmedString(parsed.update);
if (!update) {
return null;
}
return { type: "status", text: update };
}
case "done": {
return {
type: "done",
stopReason: asOptionalString(parsed.stopReason),
};
}
case "error": {
const message = asTrimmedString(parsed.message) || "acpx runtime error";
return {
type: "error",
message,
code: asOptionalString(parsed.code),
retryable: asOptionalBoolean(parsed.retryable),
};
}
default:
return null;
}
}

View File

@@ -0,0 +1,137 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { existsSync } from "node:fs";
import path from "node:path";
export type SpawnExit = {
code: number | null;
signal: NodeJS.Signals | null;
error: Error | null;
};
type ResolvedSpawnCommand = {
command: string;
args: string[];
shell?: boolean;
};
function resolveSpawnCommand(params: { command: string; args: string[] }): ResolvedSpawnCommand {
if (process.platform !== "win32") {
return { command: params.command, args: params.args };
}
const extension = path.extname(params.command).toLowerCase();
if (extension === ".js" || extension === ".cjs" || extension === ".mjs") {
return {
command: process.execPath,
args: [params.command, ...params.args],
};
}
if (extension === ".cmd" || extension === ".bat") {
return {
command: params.command,
args: params.args,
shell: true,
};
}
return {
command: params.command,
args: params.args,
};
}
export function spawnWithResolvedCommand(params: {
command: string;
args: string[];
cwd: string;
}): ChildProcessWithoutNullStreams {
const resolved = resolveSpawnCommand({
command: params.command,
args: params.args,
});
return spawn(resolved.command, resolved.args, {
cwd: params.cwd,
env: process.env,
stdio: ["pipe", "pipe", "pipe"],
shell: resolved.shell,
});
}
export async function waitForExit(child: ChildProcessWithoutNullStreams): Promise<SpawnExit> {
return await new Promise<SpawnExit>((resolve) => {
let settled = false;
const finish = (result: SpawnExit) => {
if (settled) {
return;
}
settled = true;
resolve(result);
};
child.once("error", (err) => {
finish({ code: null, signal: null, error: err });
});
child.once("close", (code, signal) => {
finish({ code, signal, error: null });
});
});
}
export async function spawnAndCollect(params: {
command: string;
args: string[];
cwd: string;
}): Promise<{
stdout: string;
stderr: string;
code: number | null;
error: Error | null;
}> {
const child = spawnWithResolvedCommand(params);
child.stdin.end();
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += String(chunk);
});
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
const exit = await waitForExit(child);
return {
stdout,
stderr,
code: exit.code,
error: exit.error,
};
}
export function resolveSpawnFailure(
err: unknown,
cwd: string,
): "missing-command" | "missing-cwd" | null {
if (!err || typeof err !== "object") {
return null;
}
const code = (err as NodeJS.ErrnoException).code;
if (code !== "ENOENT") {
return null;
}
return directoryExists(cwd) ? "missing-command" : "missing-cwd";
}
function directoryExists(cwd: string): boolean {
if (!cwd) {
return false;
}
try {
return existsSync(cwd);
} catch {
return false;
}
}

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,619 @@
import fs from "node:fs";
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { runAcpRuntimeAdapterContract } from "../../../src/acp/runtime/adapter-contract.testkit.js";
import { ACPX_PINNED_VERSION, type ResolvedAcpxPluginConfig } from "./config.js";
import { AcpxRuntime, decodeAcpxRuntimeHandleState } from "./runtime.js";
const NOOP_LOGGER = {
info: (_message: string) => {},
warn: (_message: string) => {},
error: (_message: string) => {},
debug: (_message: string) => {},
};
const MOCK_CLI_SCRIPT = String.raw`#!/usr/bin/env node
const fs = require("node:fs");
const args = process.argv.slice(2);
const logPath = process.env.MOCK_ACPX_LOG;
const writeLog = (entry) => {
if (!logPath) return;
fs.appendFileSync(logPath, JSON.stringify(entry) + "\n");
};
if (args.includes("--version")) {
process.stdout.write("mock-acpx ${ACPX_PINNED_VERSION}\\n");
process.exit(0);
}
if (args.includes("--help")) {
process.stdout.write("mock-acpx help\\n");
process.exit(0);
}
const commandIndex = args.findIndex(
(arg) =>
arg === "prompt" ||
arg === "cancel" ||
arg === "sessions" ||
arg === "set-mode" ||
arg === "set" ||
arg === "status",
);
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 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 });
process.stdout.write(JSON.stringify({
type: "session_ensured",
acpxRecordId: "rec-" + ensureName,
acpxSessionId: "sid-" + ensureName,
agentSessionId: "inner-" + ensureName,
name: ensureName,
created: true,
}) + "\n");
process.exit(0);
}
if (command === "cancel") {
writeLog({ kind: "cancel", agent, args, sessionName: sessionFromOption });
process.stdout.write(JSON.stringify({
acpxSessionId: "sid-" + sessionFromOption,
cancelled: true,
}) + "\n");
process.exit(0);
}
if (command === "set-mode") {
writeLog({ kind: "set-mode", agent, args, sessionName: sessionFromOption, mode: setModeValue });
process.stdout.write(JSON.stringify({
type: "mode_set",
acpxSessionId: "sid-" + sessionFromOption,
mode: setModeValue,
}) + "\n");
process.exit(0);
}
if (command === "set") {
writeLog({
kind: "set",
agent,
args,
sessionName: sessionFromOption,
key: setKey,
value: setValue,
});
process.stdout.write(JSON.stringify({
type: "config_set",
acpxSessionId: "sid-" + sessionFromOption,
key: setKey,
value: setValue,
}) + "\n");
process.exit(0);
}
if (command === "status") {
writeLog({ kind: "status", agent, args, sessionName: sessionFromOption });
process.stdout.write(JSON.stringify({
acpxRecordId: sessionFromOption ? "rec-" + sessionFromOption : null,
acpxSessionId: sessionFromOption ? "sid-" + sessionFromOption : null,
agentSessionId: sessionFromOption ? "inner-" + sessionFromOption : null,
status: sessionFromOption ? "alive" : "no-session",
pid: 4242,
uptime: 120,
}) + "\n");
process.exit(0);
}
if (command === "sessions" && args[commandIndex + 1] === "close") {
writeLog({ kind: "close", agent, args, sessionName: closeName });
process.stdout.write(JSON.stringify({
type: "session_closed",
acpxRecordId: "rec-" + closeName,
acpxSessionId: "sid-" + closeName,
name: closeName,
}) + "\n");
process.exit(0);
}
if (command === "prompt") {
const stdinText = fs.readFileSync(0, "utf8");
writeLog({ kind: "prompt", agent, args, sessionName: sessionFromOption, stdinText });
const acpxSessionId = "sid-" + sessionFromOption;
if (stdinText.includes("trigger-error")) {
process.stdout.write(JSON.stringify({
eventVersion: 1,
acpxSessionId,
requestId: "req-1",
seq: 0,
stream: "prompt",
type: "error",
code: "RUNTIME",
message: "mock failure",
}) + "\n");
process.exit(1);
}
if (stdinText.includes("split-spacing")) {
process.stdout.write(JSON.stringify({
eventVersion: 1,
acpxSessionId,
requestId: "req-1",
seq: 0,
stream: "prompt",
type: "text",
content: "alpha",
}) + "\n");
process.stdout.write(JSON.stringify({
eventVersion: 1,
acpxSessionId,
requestId: "req-1",
seq: 1,
stream: "prompt",
type: "text",
content: " beta",
}) + "\n");
process.stdout.write(JSON.stringify({
eventVersion: 1,
acpxSessionId,
requestId: "req-1",
seq: 2,
stream: "prompt",
type: "text",
content: " gamma",
}) + "\n");
process.stdout.write(JSON.stringify({
eventVersion: 1,
acpxSessionId,
requestId: "req-1",
seq: 3,
stream: "prompt",
type: "done",
stopReason: "end_turn",
}) + "\n");
process.exit(0);
}
process.stdout.write(JSON.stringify({
eventVersion: 1,
acpxSessionId,
requestId: "req-1",
seq: 0,
stream: "prompt",
type: "thought",
content: "thinking",
}) + "\n");
process.stdout.write(JSON.stringify({
eventVersion: 1,
acpxSessionId,
requestId: "req-1",
seq: 1,
stream: "prompt",
type: "tool_call",
title: "run-tests",
status: "in_progress",
}) + "\n");
process.stdout.write(JSON.stringify({
eventVersion: 1,
acpxSessionId,
requestId: "req-1",
seq: 2,
stream: "prompt",
type: "text",
content: "echo:" + stdinText.trim(),
}) + "\n");
process.stdout.write(JSON.stringify({
eventVersion: 1,
acpxSessionId,
requestId: "req-1",
seq: 3,
stream: "prompt",
type: "done",
stopReason: "end_turn",
}) + "\n");
process.exit(0);
}
writeLog({ kind: "unknown", args });
process.stdout.write(JSON.stringify({
eventVersion: 1,
acpxSessionId: "unknown",
seq: 0,
stream: "control",
type: "error",
code: "USAGE",
message: "unknown command",
}) + "\n");
process.exit(2);
`;
const tempDirs: string[] = [];
async function createMockRuntime(params?: {
permissionMode?: ResolvedAcpxPluginConfig["permissionMode"];
queueOwnerTtlSeconds?: number;
}): Promise<{
runtime: AcpxRuntime;
logPath: string;
config: ResolvedAcpxPluginConfig;
}> {
const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-acpx-runtime-test-"));
tempDirs.push(dir);
const scriptPath = path.join(dir, "mock-acpx.cjs");
const logPath = path.join(dir, "calls.log");
await writeFile(scriptPath, MOCK_CLI_SCRIPT, "utf8");
await chmod(scriptPath, 0o755);
process.env.MOCK_ACPX_LOG = logPath;
const config: ResolvedAcpxPluginConfig = {
command: scriptPath,
cwd: dir,
permissionMode: params?.permissionMode ?? "approve-all",
nonInteractivePermissions: "fail",
queueOwnerTtlSeconds: params?.queueOwnerTtlSeconds ?? 0.1,
};
return {
runtime: new AcpxRuntime(config, {
queueOwnerTtlSeconds: params?.queueOwnerTtlSeconds,
logger: NOOP_LOGGER,
}),
logPath,
config,
};
}
async function readLogEntries(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>);
}
afterEach(async () => {
delete process.env.MOCK_ACPX_LOG;
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (!dir) {
continue;
}
await rm(dir, {
recursive: true,
force: true,
maxRetries: 10,
retryDelay: 10,
});
}
});
describe("AcpxRuntime", () => {
it("passes the shared ACP adapter contract suite", async () => {
const fixture = await createMockRuntime();
await runAcpRuntimeAdapterContract({
createRuntime: async () => fixture.runtime,
agentId: "codex",
successPrompt: "contract-pass",
errorPrompt: "trigger-error",
assertSuccessEvents: (events) => {
expect(events.some((event) => event.type === "done")).toBe(true);
},
assertErrorOutcome: ({ events, thrown }) => {
expect(events.some((event) => event.type === "error") || Boolean(thrown)).toBe(true);
},
});
const logs = await readLogEntries(fixture.logPath);
expect(logs.some((entry) => entry.kind === "ensure")).toBe(true);
expect(logs.some((entry) => entry.kind === "status")).toBe(true);
expect(logs.some((entry) => entry.kind === "set-mode")).toBe(true);
expect(logs.some((entry) => entry.kind === "set")).toBe(true);
expect(logs.some((entry) => entry.kind === "cancel")).toBe(true);
expect(logs.some((entry) => entry.kind === "close")).toBe(true);
});
it("ensures sessions and streams prompt events", async () => {
const { runtime, logPath } = await createMockRuntime({ queueOwnerTtlSeconds: 180 });
const handle = await runtime.ensureSession({
sessionKey: "agent:codex:acp:123",
agent: "codex",
mode: "persistent",
});
expect(handle.backend).toBe("acpx");
expect(handle.acpxRecordId).toBe("rec-agent:codex:acp:123");
expect(handle.agentSessionId).toBe("inner-agent:codex:acp:123");
expect(handle.backendSessionId).toBe("sid-agent:codex:acp:123");
const decoded = decodeAcpxRuntimeHandleState(handle.runtimeSessionName);
expect(decoded?.acpxRecordId).toBe("rec-agent:codex:acp:123");
expect(decoded?.agentSessionId).toBe("inner-agent:codex:acp:123");
expect(decoded?.backendSessionId).toBe("sid-agent:codex:acp:123");
const events = [];
for await (const event of runtime.runTurn({
handle,
text: "hello world",
mode: "prompt",
requestId: "req-test",
})) {
events.push(event);
}
expect(events).toContainEqual({
type: "text_delta",
text: "thinking",
stream: "thought",
});
expect(events).toContainEqual({
type: "tool_call",
text: "run-tests (in_progress)",
});
expect(events).toContainEqual({
type: "text_delta",
text: "echo:hello world",
stream: "output",
});
expect(events).toContainEqual({
type: "done",
stopReason: "end_turn",
});
const logs = await readLogEntries(logPath);
const ensure = logs.find((entry) => entry.kind === "ensure");
const prompt = logs.find((entry) => entry.kind === "prompt");
expect(ensure).toBeDefined();
expect(prompt).toBeDefined();
expect(Array.isArray(prompt?.args)).toBe(true);
const promptArgs = (prompt?.args as string[]) ?? [];
expect(promptArgs).toContain("--ttl");
expect(promptArgs).toContain("180");
expect(promptArgs).toContain("--approve-all");
});
it("passes a queue-owner TTL by default to avoid long idle stalls", async () => {
const { runtime, logPath } = await createMockRuntime();
const handle = await runtime.ensureSession({
sessionKey: "agent:codex:acp:ttl-default",
agent: "codex",
mode: "persistent",
});
for await (const _event of runtime.runTurn({
handle,
text: "ttl-default",
mode: "prompt",
requestId: "req-ttl-default",
})) {
// drain
}
const logs = await readLogEntries(logPath);
const prompt = logs.find((entry) => entry.kind === "prompt");
expect(prompt).toBeDefined();
const promptArgs = (prompt?.args as string[]) ?? [];
const ttlFlagIndex = promptArgs.indexOf("--ttl");
expect(ttlFlagIndex).toBeGreaterThanOrEqual(0);
expect(promptArgs[ttlFlagIndex + 1]).toBe("0.1");
});
it("preserves leading spaces across streamed text deltas", async () => {
const { runtime } = await createMockRuntime();
const handle = await runtime.ensureSession({
sessionKey: "agent:codex:acp:space",
agent: "codex",
mode: "persistent",
});
const textDeltas: string[] = [];
for await (const event of runtime.runTurn({
handle,
text: "split-spacing",
mode: "prompt",
requestId: "req-space",
})) {
if (event.type === "text_delta" && event.stream === "output") {
textDeltas.push(event.text);
}
}
expect(textDeltas).toEqual(["alpha", " beta", " gamma"]);
expect(textDeltas.join("")).toBe("alpha beta gamma");
});
it("maps acpx error events into ACP runtime error events", async () => {
const { runtime } = await createMockRuntime();
const handle = await runtime.ensureSession({
sessionKey: "agent:codex:acp:456",
agent: "codex",
mode: "persistent",
});
const events = [];
for await (const event of runtime.runTurn({
handle,
text: "trigger-error",
mode: "prompt",
requestId: "req-err",
})) {
events.push(event);
}
expect(events).toContainEqual({
type: "error",
message: "mock failure",
code: "RUNTIME",
retryable: undefined,
});
});
it("supports cancel and close using encoded runtime handle state", async () => {
const { runtime, logPath, config } = await createMockRuntime();
const handle = await runtime.ensureSession({
sessionKey: "agent:claude:acp:789",
agent: "claude",
mode: "persistent",
});
const decoded = decodeAcpxRuntimeHandleState(handle.runtimeSessionName);
expect(decoded?.name).toBe("agent:claude:acp:789");
const secondRuntime = new AcpxRuntime(config, { logger: NOOP_LOGGER });
await secondRuntime.cancel({ handle, reason: "test" });
await secondRuntime.close({ handle, reason: "test" });
const logs = await readLogEntries(logPath);
const cancel = logs.find((entry) => entry.kind === "cancel");
const close = logs.find((entry) => entry.kind === "close");
expect(cancel?.sessionName).toBe("agent:claude:acp:789");
expect(close?.sessionName).toBe("agent:claude:acp:789");
});
it("exposes control capabilities and runs set-mode/set/status commands", async () => {
const { runtime, logPath } = await createMockRuntime();
const handle = await runtime.ensureSession({
sessionKey: "agent:codex:acp:controls",
agent: "codex",
mode: "persistent",
});
const capabilities = runtime.getCapabilities();
expect(capabilities.controls).toContain("session/set_mode");
expect(capabilities.controls).toContain("session/set_config_option");
expect(capabilities.controls).toContain("session/status");
await runtime.setMode({
handle,
mode: "plan",
});
await runtime.setConfigOption({
handle,
key: "model",
value: "openai-codex/gpt-5.3-codex",
});
const status = await runtime.getStatus({ handle });
const ensuredSessionName = "agent:codex:acp:controls";
expect(status.summary).toContain("status=alive");
expect(status.acpxRecordId).toBe("rec-" + ensuredSessionName);
expect(status.backendSessionId).toBe("sid-" + ensuredSessionName);
expect(status.agentSessionId).toBe("inner-" + ensuredSessionName);
expect(status.details?.acpxRecordId).toBe("rec-" + ensuredSessionName);
expect(status.details?.status).toBe("alive");
expect(status.details?.pid).toBe(4242);
const logs = await readLogEntries(logPath);
expect(logs.find((entry) => entry.kind === "set-mode")?.mode).toBe("plan");
expect(logs.find((entry) => entry.kind === "set")?.key).toBe("model");
expect(logs.find((entry) => entry.kind === "status")).toBeDefined();
});
it("skips prompt execution when runTurn starts with an already-aborted signal", async () => {
const { runtime, logPath } = await createMockRuntime();
const handle = await runtime.ensureSession({
sessionKey: "agent:codex:acp:aborted",
agent: "codex",
mode: "persistent",
});
const controller = new AbortController();
controller.abort();
const events = [];
for await (const event of runtime.runTurn({
handle,
text: "should-not-run",
mode: "prompt",
requestId: "req-aborted",
signal: controller.signal,
})) {
events.push(event);
}
const logs = await readLogEntries(logPath);
expect(events).toEqual([]);
expect(logs.some((entry) => entry.kind === "prompt")).toBe(false);
});
it("does not mark backend unhealthy when a per-session cwd is missing", async () => {
const { runtime } = await createMockRuntime();
const missingCwd = path.join(os.tmpdir(), "openclaw-acpx-runtime-test-missing-cwd");
await runtime.probeAvailability();
expect(runtime.isHealthy()).toBe(true);
await expect(
runtime.ensureSession({
sessionKey: "agent:codex:acp:missing-cwd",
agent: "codex",
mode: "persistent",
cwd: missingCwd,
}),
).rejects.toMatchObject({
code: "ACP_SESSION_INIT_FAILED",
message: expect.stringContaining("working directory does not exist"),
});
expect(runtime.isHealthy()).toBe(true);
});
it("marks runtime unhealthy when command is missing", async () => {
const runtime = new AcpxRuntime(
{
command: "/definitely/missing/acpx",
cwd: process.cwd(),
permissionMode: "approve-reads",
nonInteractivePermissions: "fail",
queueOwnerTtlSeconds: 0.1,
},
{ logger: NOOP_LOGGER },
);
await runtime.probeAvailability();
expect(runtime.isHealthy()).toBe(false);
});
it("marks runtime healthy when command is available", async () => {
const { runtime } = await createMockRuntime();
await runtime.probeAvailability();
expect(runtime.isHealthy()).toBe(true);
});
it("returns doctor report for missing command", async () => {
const runtime = new AcpxRuntime(
{
command: "/definitely/missing/acpx",
cwd: process.cwd(),
permissionMode: "approve-reads",
nonInteractivePermissions: "fail",
queueOwnerTtlSeconds: 0.1,
},
{ logger: NOOP_LOGGER },
);
const report = await runtime.doctor();
expect(report.ok).toBe(false);
expect(report.code).toBe("ACP_BACKEND_UNAVAILABLE");
expect(report.installCommand).toContain("acpx");
});
});

View File

@@ -0,0 +1,578 @@
import { createInterface } from "node:readline";
import type {
AcpRuntimeCapabilities,
AcpRuntimeDoctorReport,
AcpRuntime,
AcpRuntimeEnsureInput,
AcpRuntimeErrorCode,
AcpRuntimeEvent,
AcpRuntimeHandle,
AcpRuntimeStatus,
AcpRuntimeTurnInput,
PluginLogger,
} from "openclaw/plugin-sdk";
import { AcpRuntimeError } from "openclaw/plugin-sdk";
import {
ACPX_LOCAL_INSTALL_COMMAND,
ACPX_PINNED_VERSION,
type ResolvedAcpxPluginConfig,
} from "./config.js";
import { checkPinnedAcpxVersion } from "./ensure.js";
import {
parseJsonLines,
parsePromptEventLine,
toAcpxErrorEvent,
} from "./runtime-internals/events.js";
import {
resolveSpawnFailure,
spawnAndCollect,
spawnWithResolvedCommand,
waitForExit,
} from "./runtime-internals/process.js";
import {
asOptionalString,
asTrimmedString,
buildPermissionArgs,
deriveAgentFromSessionKey,
isRecord,
type AcpxHandleState,
type AcpxJsonObject,
} from "./runtime-internals/shared.js";
export const ACPX_BACKEND_ID = "acpx";
const ACPX_RUNTIME_HANDLE_PREFIX = "acpx:v1:";
const DEFAULT_AGENT_FALLBACK = "codex";
const ACPX_CAPABILITIES: AcpRuntimeCapabilities = {
controls: ["session/set_mode", "session/set_config_option", "session/status"],
};
export function encodeAcpxRuntimeHandleState(state: AcpxHandleState): string {
const payload = Buffer.from(JSON.stringify(state), "utf8").toString("base64url");
return `${ACPX_RUNTIME_HANDLE_PREFIX}${payload}`;
}
export function decodeAcpxRuntimeHandleState(runtimeSessionName: string): AcpxHandleState | null {
const trimmed = runtimeSessionName.trim();
if (!trimmed.startsWith(ACPX_RUNTIME_HANDLE_PREFIX)) {
return null;
}
const encoded = trimmed.slice(ACPX_RUNTIME_HANDLE_PREFIX.length);
if (!encoded) {
return null;
}
try {
const raw = Buffer.from(encoded, "base64url").toString("utf8");
const parsed = JSON.parse(raw) as unknown;
if (!isRecord(parsed)) {
return null;
}
const name = asTrimmedString(parsed.name);
const agent = asTrimmedString(parsed.agent);
const cwd = asTrimmedString(parsed.cwd);
const mode = asTrimmedString(parsed.mode);
const acpxRecordId = asOptionalString(parsed.acpxRecordId);
const backendSessionId = asOptionalString(parsed.backendSessionId);
const agentSessionId = asOptionalString(parsed.agentSessionId);
if (!name || !agent || !cwd) {
return null;
}
if (mode !== "persistent" && mode !== "oneshot") {
return null;
}
return {
name,
agent,
cwd,
mode,
...(acpxRecordId ? { acpxRecordId } : {}),
...(backendSessionId ? { backendSessionId } : {}),
...(agentSessionId ? { agentSessionId } : {}),
};
} catch {
return null;
}
}
export class AcpxRuntime implements AcpRuntime {
private healthy = false;
private readonly logger?: PluginLogger;
private readonly queueOwnerTtlSeconds: number;
constructor(
private readonly config: ResolvedAcpxPluginConfig,
opts?: {
logger?: PluginLogger;
queueOwnerTtlSeconds?: number;
},
) {
this.logger = opts?.logger;
const requestedQueueOwnerTtlSeconds = opts?.queueOwnerTtlSeconds;
this.queueOwnerTtlSeconds =
typeof requestedQueueOwnerTtlSeconds === "number" &&
Number.isFinite(requestedQueueOwnerTtlSeconds) &&
requestedQueueOwnerTtlSeconds >= 0
? requestedQueueOwnerTtlSeconds
: this.config.queueOwnerTtlSeconds;
}
isHealthy(): boolean {
return this.healthy;
}
async probeAvailability(): Promise<void> {
const versionCheck = await checkPinnedAcpxVersion({
command: this.config.command,
cwd: this.config.cwd,
expectedVersion: ACPX_PINNED_VERSION,
});
if (!versionCheck.ok) {
this.healthy = false;
return;
}
try {
const result = await spawnAndCollect({
command: this.config.command,
args: ["--help"],
cwd: this.config.cwd,
});
this.healthy = result.error == null && (result.code ?? 0) === 0;
} catch {
this.healthy = false;
}
}
async ensureSession(input: AcpRuntimeEnsureInput): Promise<AcpRuntimeHandle> {
const sessionName = asTrimmedString(input.sessionKey);
if (!sessionName) {
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required.");
}
const agent = asTrimmedString(input.agent);
if (!agent) {
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP agent id is required.");
}
const cwd = asTrimmedString(input.cwd) || this.config.cwd;
const mode = input.mode;
const events = await this.runControlCommand({
args: this.buildControlArgs({
cwd,
command: [agent, "sessions", "ensure", "--name", sessionName],
}),
cwd,
fallbackCode: "ACP_SESSION_INIT_FAILED",
});
const ensuredEvent = events.find(
(event) =>
asOptionalString(event.agentSessionId) ||
asOptionalString(event.acpxSessionId) ||
asOptionalString(event.acpxRecordId),
);
const acpxRecordId = ensuredEvent ? asOptionalString(ensuredEvent.acpxRecordId) : undefined;
const agentSessionId = ensuredEvent ? asOptionalString(ensuredEvent.agentSessionId) : undefined;
const backendSessionId = ensuredEvent
? asOptionalString(ensuredEvent.acpxSessionId)
: undefined;
return {
sessionKey: input.sessionKey,
backend: ACPX_BACKEND_ID,
runtimeSessionName: encodeAcpxRuntimeHandleState({
name: sessionName,
agent,
cwd,
mode,
...(acpxRecordId ? { acpxRecordId } : {}),
...(backendSessionId ? { backendSessionId } : {}),
...(agentSessionId ? { agentSessionId } : {}),
}),
cwd,
...(acpxRecordId ? { acpxRecordId } : {}),
...(backendSessionId ? { backendSessionId } : {}),
...(agentSessionId ? { agentSessionId } : {}),
};
}
async *runTurn(input: AcpRuntimeTurnInput): AsyncIterable<AcpRuntimeEvent> {
const state = this.resolveHandleState(input.handle);
const args = this.buildPromptArgs({
agent: state.agent,
sessionName: state.name,
cwd: state.cwd,
});
const cancelOnAbort = async () => {
await this.cancel({
handle: input.handle,
reason: "abort-signal",
}).catch((err) => {
this.logger?.warn?.(`acpx runtime abort-cancel failed: ${String(err)}`);
});
};
const onAbort = () => {
void cancelOnAbort();
};
if (input.signal?.aborted) {
await cancelOnAbort();
return;
}
if (input.signal) {
input.signal.addEventListener("abort", onAbort, { once: true });
}
const child = spawnWithResolvedCommand({
command: this.config.command,
args,
cwd: state.cwd,
});
child.stdin.on("error", () => {
// Ignore EPIPE when the child exits before stdin flush completes.
});
child.stdin.end(input.text);
let stderr = "";
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
let sawDone = false;
let sawError = false;
const lines = createInterface({ input: child.stdout });
try {
for await (const line of lines) {
const parsed = parsePromptEventLine(line);
if (!parsed) {
continue;
}
if (parsed.type === "done") {
sawDone = true;
}
if (parsed.type === "error") {
sawError = true;
}
yield parsed;
}
const exit = await waitForExit(child);
if (exit.error) {
const spawnFailure = resolveSpawnFailure(exit.error, state.cwd);
if (spawnFailure === "missing-command") {
this.healthy = false;
throw new AcpRuntimeError(
"ACP_BACKEND_UNAVAILABLE",
`acpx command not found: ${this.config.command}`,
{ cause: exit.error },
);
}
if (spawnFailure === "missing-cwd") {
throw new AcpRuntimeError(
"ACP_TURN_FAILED",
`ACP runtime working directory does not exist: ${state.cwd}`,
{ cause: exit.error },
);
}
throw new AcpRuntimeError("ACP_TURN_FAILED", exit.error.message, { cause: exit.error });
}
if ((exit.code ?? 0) !== 0 && !sawError) {
yield {
type: "error",
message: stderr.trim() || `acpx exited with code ${exit.code ?? "unknown"}`,
};
return;
}
if (!sawDone && !sawError) {
yield { type: "done" };
}
} finally {
lines.close();
if (input.signal) {
input.signal.removeEventListener("abort", onAbort);
}
}
}
getCapabilities(): AcpRuntimeCapabilities {
return ACPX_CAPABILITIES;
}
async getStatus(input: { handle: AcpRuntimeHandle }): Promise<AcpRuntimeStatus> {
const state = this.resolveHandleState(input.handle);
const events = await this.runControlCommand({
args: this.buildControlArgs({
cwd: state.cwd,
command: [state.agent, "status", "--session", state.name],
}),
cwd: state.cwd,
fallbackCode: "ACP_TURN_FAILED",
ignoreNoSession: true,
});
const detail = events.find((event) => !toAcpxErrorEvent(event)) ?? events[0];
if (!detail) {
return {
summary: "acpx status unavailable",
};
}
const status = asTrimmedString(detail.status) || "unknown";
const acpxRecordId = asOptionalString(detail.acpxRecordId);
const acpxSessionId = asOptionalString(detail.acpxSessionId);
const agentSessionId = asOptionalString(detail.agentSessionId);
const pid = typeof detail.pid === "number" && Number.isFinite(detail.pid) ? detail.pid : null;
const summary = [
`status=${status}`,
acpxRecordId ? `acpxRecordId=${acpxRecordId}` : null,
acpxSessionId ? `acpxSessionId=${acpxSessionId}` : null,
pid != null ? `pid=${pid}` : null,
]
.filter(Boolean)
.join(" ");
return {
summary,
...(acpxRecordId ? { acpxRecordId } : {}),
...(acpxSessionId ? { backendSessionId: acpxSessionId } : {}),
...(agentSessionId ? { agentSessionId } : {}),
details: detail,
};
}
async setMode(input: { handle: AcpRuntimeHandle; mode: string }): Promise<void> {
const state = this.resolveHandleState(input.handle);
const mode = asTrimmedString(input.mode);
if (!mode) {
throw new AcpRuntimeError("ACP_TURN_FAILED", "ACP runtime mode is required.");
}
await this.runControlCommand({
args: this.buildControlArgs({
cwd: state.cwd,
command: [state.agent, "set-mode", mode, "--session", state.name],
}),
cwd: state.cwd,
fallbackCode: "ACP_TURN_FAILED",
});
}
async setConfigOption(input: {
handle: AcpRuntimeHandle;
key: string;
value: string;
}): Promise<void> {
const state = this.resolveHandleState(input.handle);
const key = asTrimmedString(input.key);
const value = asTrimmedString(input.value);
if (!key || !value) {
throw new AcpRuntimeError("ACP_TURN_FAILED", "ACP config option key/value are required.");
}
await this.runControlCommand({
args: this.buildControlArgs({
cwd: state.cwd,
command: [state.agent, "set", key, value, "--session", state.name],
}),
cwd: state.cwd,
fallbackCode: "ACP_TURN_FAILED",
});
}
async doctor(): Promise<AcpRuntimeDoctorReport> {
const versionCheck = await checkPinnedAcpxVersion({
command: this.config.command,
cwd: this.config.cwd,
expectedVersion: ACPX_PINNED_VERSION,
});
if (!versionCheck.ok) {
this.healthy = false;
const details = [
`expected=${versionCheck.expectedVersion}`,
versionCheck.installedVersion ? `installed=${versionCheck.installedVersion}` : null,
].filter((detail): detail is string => Boolean(detail));
return {
ok: false,
code: "ACP_BACKEND_UNAVAILABLE",
message: versionCheck.message,
installCommand: versionCheck.installCommand,
details,
};
}
try {
const result = await spawnAndCollect({
command: this.config.command,
args: ["--help"],
cwd: this.config.cwd,
});
if (result.error) {
const spawnFailure = resolveSpawnFailure(result.error, this.config.cwd);
if (spawnFailure === "missing-command") {
this.healthy = false;
return {
ok: false,
code: "ACP_BACKEND_UNAVAILABLE",
message: `acpx command not found: ${this.config.command}`,
installCommand: ACPX_LOCAL_INSTALL_COMMAND,
};
}
if (spawnFailure === "missing-cwd") {
this.healthy = false;
return {
ok: false,
code: "ACP_BACKEND_UNAVAILABLE",
message: `ACP runtime working directory does not exist: ${this.config.cwd}`,
};
}
this.healthy = false;
return {
ok: false,
code: "ACP_BACKEND_UNAVAILABLE",
message: result.error.message,
details: [String(result.error)],
};
}
if ((result.code ?? 0) !== 0) {
this.healthy = false;
return {
ok: false,
code: "ACP_BACKEND_UNAVAILABLE",
message: result.stderr.trim() || `acpx exited with code ${result.code ?? "unknown"}`,
};
}
this.healthy = true;
return {
ok: true,
message: `acpx command available (${this.config.command}, version ${versionCheck.version})`,
};
} catch (error) {
this.healthy = false;
return {
ok: false,
code: "ACP_BACKEND_UNAVAILABLE",
message: error instanceof Error ? error.message : String(error),
};
}
}
async cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void> {
const state = this.resolveHandleState(input.handle);
await this.runControlCommand({
args: this.buildControlArgs({
cwd: state.cwd,
command: [state.agent, "cancel", "--session", state.name],
}),
cwd: state.cwd,
fallbackCode: "ACP_TURN_FAILED",
ignoreNoSession: true,
});
}
async close(input: { handle: AcpRuntimeHandle; reason: string }): Promise<void> {
const state = this.resolveHandleState(input.handle);
await this.runControlCommand({
args: this.buildControlArgs({
cwd: state.cwd,
command: [state.agent, "sessions", "close", state.name],
}),
cwd: state.cwd,
fallbackCode: "ACP_TURN_FAILED",
ignoreNoSession: true,
});
}
private resolveHandleState(handle: AcpRuntimeHandle): AcpxHandleState {
const decoded = decodeAcpxRuntimeHandleState(handle.runtimeSessionName);
if (decoded) {
return decoded;
}
const legacyName = asTrimmedString(handle.runtimeSessionName);
if (!legacyName) {
throw new AcpRuntimeError(
"ACP_SESSION_INIT_FAILED",
"Invalid acpx runtime handle: runtimeSessionName is missing.",
);
}
return {
name: legacyName,
agent: deriveAgentFromSessionKey(handle.sessionKey, DEFAULT_AGENT_FALLBACK),
cwd: this.config.cwd,
mode: "persistent",
};
}
private buildControlArgs(params: { cwd: string; command: string[] }): string[] {
return ["--format", "json", "--json-strict", "--cwd", params.cwd, ...params.command];
}
private buildPromptArgs(params: { agent: string; sessionName: string; cwd: string }): string[] {
const args = [
"--format",
"json",
"--json-strict",
"--cwd",
params.cwd,
...buildPermissionArgs(this.config.permissionMode),
"--non-interactive-permissions",
this.config.nonInteractivePermissions,
];
if (this.config.timeoutSeconds) {
args.push("--timeout", String(this.config.timeoutSeconds));
}
args.push("--ttl", String(this.queueOwnerTtlSeconds));
args.push(params.agent, "prompt", "--session", params.sessionName, "--file", "-");
return args;
}
private async runControlCommand(params: {
args: string[];
cwd: string;
fallbackCode: AcpRuntimeErrorCode;
ignoreNoSession?: boolean;
}): Promise<AcpxJsonObject[]> {
const result = await spawnAndCollect({
command: this.config.command,
args: params.args,
cwd: params.cwd,
});
if (result.error) {
const spawnFailure = resolveSpawnFailure(result.error, params.cwd);
if (spawnFailure === "missing-command") {
this.healthy = false;
throw new AcpRuntimeError(
"ACP_BACKEND_UNAVAILABLE",
`acpx command not found: ${this.config.command}`,
{ cause: result.error },
);
}
if (spawnFailure === "missing-cwd") {
throw new AcpRuntimeError(
params.fallbackCode,
`ACP runtime working directory does not exist: ${params.cwd}`,
{ cause: result.error },
);
}
throw new AcpRuntimeError(params.fallbackCode, result.error.message, { cause: result.error });
}
const events = parseJsonLines(result.stdout);
const errorEvent = events.map((event) => toAcpxErrorEvent(event)).find(Boolean) ?? null;
if (errorEvent) {
if (params.ignoreNoSession && errorEvent.code === "NO_SESSION") {
return events;
}
throw new AcpRuntimeError(
params.fallbackCode,
errorEvent.code ? `${errorEvent.code}: ${errorEvent.message}` : errorEvent.message,
);
}
if ((result.code ?? 0) !== 0) {
throw new AcpRuntimeError(
params.fallbackCode,
result.stderr.trim() || `acpx exited with code ${result.code ?? "unknown"}`,
);
}
return events;
}
}

View File

@@ -0,0 +1,173 @@
import type { AcpRuntime, OpenClawPluginServiceContext } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AcpRuntimeError } from "../../../src/acp/runtime/errors.js";
import {
__testing,
getAcpRuntimeBackend,
requireAcpRuntimeBackend,
} from "../../../src/acp/runtime/registry.js";
import { ACPX_BUNDLED_BIN } from "./config.js";
import { createAcpxRuntimeService } from "./service.js";
const { ensurePinnedAcpxSpy } = vi.hoisted(() => ({
ensurePinnedAcpxSpy: vi.fn(async () => {}),
}));
vi.mock("./ensure.js", () => ({
ensurePinnedAcpx: ensurePinnedAcpxSpy,
}));
type RuntimeStub = AcpRuntime & {
probeAvailability(): Promise<void>;
isHealthy(): boolean;
};
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,
};
}
function createServiceContext(
overrides: Partial<OpenClawPluginServiceContext> = {},
): OpenClawPluginServiceContext {
return {
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();
ensurePinnedAcpxSpy.mockReset();
ensurePinnedAcpxSpy.mockImplementation(async () => {});
});
it("registers and unregisters the acpx backend", async () => {
const { runtime, probeAvailabilitySpy } = createRuntimeStub(true);
const service = createAcpxRuntimeService({
runtimeFactory: () => runtime,
});
const context = createServiceContext();
await service.start(context);
expect(getAcpRuntimeBackend("acpx")?.runtime).toBe(runtime);
await vi.waitFor(() => {
expect(ensurePinnedAcpxSpy).toHaveBeenCalledOnce();
expect(probeAvailabilitySpy).toHaveBeenCalledOnce();
});
await service.stop?.(context);
expect(getAcpRuntimeBackend("acpx")).toBeNull();
});
it("marks backend unavailable when runtime health check fails", async () => {
const { runtime } = createRuntimeStub(false);
const service = createAcpxRuntimeService({
runtimeFactory: () => runtime,
});
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,
}),
}),
);
});
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);
ensurePinnedAcpxSpy.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);
});
});

View File

@@ -0,0 +1,102 @@
import type {
AcpRuntime,
OpenClawPluginService,
OpenClawPluginServiceContext,
PluginLogger,
} from "openclaw/plugin-sdk";
import { registerAcpRuntimeBackend, unregisterAcpRuntimeBackend } from "openclaw/plugin-sdk";
import {
ACPX_PINNED_VERSION,
resolveAcpxPluginConfig,
type ResolvedAcpxPluginConfig,
} from "./config.js";
import { ensurePinnedAcpx } from "./ensure.js";
import { ACPX_BACKEND_ID, AcpxRuntime } from "./runtime.js";
type AcpxRuntimeLike = AcpRuntime & {
probeAvailability(): Promise<void>;
isHealthy(): boolean;
};
type AcpxRuntimeFactoryParams = {
pluginConfig: ResolvedAcpxPluginConfig;
queueOwnerTtlSeconds: number;
logger?: PluginLogger;
};
type CreateAcpxRuntimeServiceParams = {
pluginConfig?: unknown;
runtimeFactory?: (params: AcpxRuntimeFactoryParams) => AcpxRuntimeLike;
};
function createDefaultRuntime(params: AcpxRuntimeFactoryParams): AcpxRuntimeLike {
return new AcpxRuntime(params.pluginConfig, {
logger: params.logger,
queueOwnerTtlSeconds: params.queueOwnerTtlSeconds,
});
}
export function createAcpxRuntimeService(
params: CreateAcpxRuntimeServiceParams = {},
): OpenClawPluginService {
let runtime: AcpxRuntimeLike | null = null;
let lifecycleRevision = 0;
return {
id: "acpx-runtime",
async start(ctx: OpenClawPluginServiceContext): Promise<void> {
const pluginConfig = resolveAcpxPluginConfig({
rawConfig: params.pluginConfig,
workspaceDir: ctx.workspaceDir,
});
const runtimeFactory = params.runtimeFactory ?? createDefaultRuntime;
runtime = runtimeFactory({
pluginConfig,
queueOwnerTtlSeconds: pluginConfig.queueOwnerTtlSeconds,
logger: ctx.logger,
});
registerAcpRuntimeBackend({
id: ACPX_BACKEND_ID,
runtime,
healthy: () => runtime?.isHealthy() ?? false,
});
ctx.logger.info(
`acpx runtime backend registered (command: ${pluginConfig.command}, pinned: ${ACPX_PINNED_VERSION})`,
);
lifecycleRevision += 1;
const currentRevision = lifecycleRevision;
void (async () => {
try {
await ensurePinnedAcpx({
command: pluginConfig.command,
logger: ctx.logger,
expectedVersion: ACPX_PINNED_VERSION,
});
if (currentRevision !== lifecycleRevision) {
return;
}
await runtime?.probeAvailability();
if (runtime?.isHealthy()) {
ctx.logger.info("acpx runtime backend ready");
} else {
ctx.logger.warn("acpx runtime backend probe failed after local install");
}
} catch (err) {
if (currentRevision !== lifecycleRevision) {
return;
}
ctx.logger.warn(
`acpx runtime setup failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
})();
},
async stop(_ctx: OpenClawPluginServiceContext): Promise<void> {
lifecycleRevision += 1;
unregisterAcpRuntimeBackend(ACPX_BACKEND_ID);
runtime = null;
},
};
}