mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-20 21:23:23 +00:00
refactor(acpx): embed ACP runtime in plugin
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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";
|
||||
|
||||
153
extensions/acpx/src/acp-error-shapes.ts
Normal file
153
extensions/acpx/src/acp-error-shapes.ts
Normal 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));
|
||||
}
|
||||
137
extensions/acpx/src/acp-jsonrpc.ts
Normal file
137
extensions/acpx/src/acp-jsonrpc.ts
Normal 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;
|
||||
}
|
||||
35
extensions/acpx/src/agent-session-id.ts
Normal file
35
extensions/acpx/src/agent-session-id.ts
Normal 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 };
|
||||
61
extensions/acpx/src/agents/registry.ts
Normal file
61
extensions/acpx/src/agents/registry.ts
Normal 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));
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
287
extensions/acpx/src/error-normalization.ts
Normal file
287
extensions/acpx/src/error-normalization.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
168
extensions/acpx/src/errors.ts
Normal file
168
extensions/acpx/src/errors.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
73
extensions/acpx/src/health/probe.ts
Normal file
73
extensions/acpx/src/health/probe.ts
Normal 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(() => {});
|
||||
}
|
||||
}
|
||||
681
extensions/acpx/src/history/conversation.ts
Normal file
681
extensions/acpx/src/history/conversation.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
315
extensions/acpx/src/history/projector.ts
Normal file
315
extensions/acpx/src/history/projector.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
56
extensions/acpx/src/history/shared.ts
Normal file
56
extensions/acpx/src/history/shared.ts
Normal 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"];
|
||||
}
|
||||
88
extensions/acpx/src/perf-metrics.ts
Normal file
88
extensions/acpx/src/perf-metrics.ts
Normal 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`;
|
||||
}
|
||||
217
extensions/acpx/src/prompt-content.ts
Normal file
217
extensions/acpx/src/prompt-content.ts
Normal 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();
|
||||
}
|
||||
15
extensions/acpx/src/runtime-session-id.ts
Normal file
15
extensions/acpx/src/runtime-session-id.ts
Normal 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);
|
||||
}
|
||||
372
extensions/acpx/src/runtime-types.ts
Normal file
372
extensions/acpx/src/runtime-types.ts
Normal 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
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)}`,
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
94
extensions/acpx/src/session-mode-preference.ts
Normal file
94
extensions/acpx/src/session-mode-preference.ts
Normal 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;
|
||||
}
|
||||
81
extensions/acpx/src/session-runtime-helpers.ts
Normal file
81
extensions/acpx/src/session-runtime-helpers.ts
Normal 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)),
|
||||
);
|
||||
});
|
||||
}
|
||||
50
extensions/acpx/src/session/lifecycle.ts
Normal file
50
extensions/acpx/src/session/lifecycle.ts
Normal 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;
|
||||
}
|
||||
535
extensions/acpx/src/session/manager.ts
Normal file
535
extensions/acpx/src/session/manager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
277
extensions/acpx/src/session/reconnect.ts
Normal file
277
extensions/acpx/src/session/reconnect.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
54
extensions/acpx/src/session/repository.ts
Normal file
54
extensions/acpx/src/session/repository.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
1870
extensions/acpx/src/transport/acp-client.ts
Normal file
1870
extensions/acpx/src/transport/acp-client.ts
Normal file
File diff suppressed because it is too large
Load Diff
240
extensions/acpx/src/transport/filesystem.ts
Normal file
240
extensions/acpx/src/transport/filesystem.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
33
extensions/acpx/src/transport/permission-prompt.ts
Normal file
33
extensions/acpx/src/transport/permission-prompt.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
174
extensions/acpx/src/transport/permissions.ts
Normal file
174
extensions/acpx/src/transport/permissions.ts
Normal 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";
|
||||
}
|
||||
76
extensions/acpx/src/transport/spawn.ts
Normal file
76
extensions/acpx/src/transport/spawn.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
462
extensions/acpx/src/transport/terminal.ts
Normal file
462
extensions/acpx/src/transport/terminal.ts
Normal 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)]);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export type {
|
||||
AcpRuntimeEvent,
|
||||
AcpRuntimeHandle,
|
||||
AcpRuntimeStatus,
|
||||
AcpRuntimeTurnAttachment,
|
||||
AcpRuntimeTurnInput,
|
||||
AcpSessionUpdateTag,
|
||||
} from "../acp/runtime/types.js";
|
||||
|
||||
Reference in New Issue
Block a user