feat: ACP thread-bound agents (#23580)

* docs: add ACP thread-bound agents plan doc

* docs: expand ACP implementation specification

* feat(acp): route ACP sessions through core dispatch and lifecycle cleanup

* feat(acp): add /acp commands and Discord spawn gate

* ACP: add acpx runtime plugin backend

* fix(subagents): defer transient lifecycle errors before announce

* Agents: harden ACP sessions_spawn and tighten spawn guidance

* Agents: require explicit ACP target for runtime spawns

* docs: expand ACP control-plane implementation plan

* ACP: harden metadata seeding and spawn guidance

* ACP: centralize runtime control-plane manager and fail-closed dispatch

* ACP: harden runtime manager and unify spawn helpers

* Commands: route ACP sessions through ACP runtime in agent command

* ACP: require persisted metadata for runtime spawns

* Sessions: preserve ACP metadata when updating entries

* Plugins: harden ACP backend registry across loaders

* ACPX: make availability probe compatible with adapters

* E2E: add manual Discord ACP plain-language smoke script

* ACPX: preserve streamed spacing across Discord delivery

* Docs: add ACP Discord streaming strategy

* ACP: harden Discord stream buffering for thread replies

* ACP: reuse shared block reply pipeline for projector

* ACP: unify streaming config and adopt coalesceIdleMs

* Docs: add temporary ACP production hardening plan

* Docs: trim temporary ACP hardening plan goals

* Docs: gate ACP thread controls by backend capabilities

* ACP: add capability-gated runtime controls and /acp operator commands

* Docs: remove temporary ACP hardening plan

* ACP: fix spawn target validation and close cache cleanup

* ACP: harden runtime dispatch and recovery paths

* ACP: split ACP command/runtime internals and centralize policy

* ACP: harden runtime lifecycle, validation, and observability

* ACP: surface runtime and backend session IDs in thread bindings

* docs: add temp plan for binding-service migration

* ACP: migrate thread binding flows to SessionBindingService

* ACP: address review feedback and preserve prompt wording

* ACPX plugin: pin runtime dependency and prefer bundled CLI

* Discord: complete binding-service migration cleanup and restore ACP plan

* Docs: add standalone ACP agents guide

* ACP: route harness intents to thread-bound ACP sessions

* ACP: fix spawn thread routing and queue-owner stall

* ACP: harden startup reconciliation and command bypass handling

* ACP: fix dispatch bypass type narrowing

* ACP: align runtime metadata to agentSessionId

* ACP: normalize session identifier handling and labels

* ACP: mark thread banner session ids provisional until first reply

* ACP: stabilize session identity mapping and startup reconciliation

* ACP: add resolved session-id notices and cwd in thread intros

* Discord: prefix thread meta notices consistently

* Discord: unify ACP/thread meta notices with gear prefix

* Discord: split thread persona naming from meta formatting

* Extensions: bump acpx plugin dependency to 0.1.9

* Agents: gate ACP prompt guidance behind acp.enabled

* Docs: remove temp experiment plan docs

* Docs: scope streaming plan to holy grail refactor

* Docs: refactor ACP agents guide for human-first flow

* Docs/Skill: add ACP feature-flag guidance and direct acpx telephone-game flow

* Docs/Skill: add OpenCode and Pi to ACP harness lists

* Docs/Skill: align ACP harness list with current acpx registry

* Dev/Test: move ACP plain-language smoke script and mark as keep

* Docs/Skill: reorder ACP harness lists with Pi first

* ACP: split control-plane manager into core/types/utils modules

* Docs: refresh ACP thread-bound agents plan

* ACP: extract dispatch lane and split manager domains

* ACP: centralize binding context and remove reverse deps

* Infra: unify system message formatting

* ACP: centralize error boundaries and session id rendering

* ACP: enforce init concurrency cap and strict meta clear

* Tests: fix ACP dispatch binding mock typing

* Tests: fix Discord thread-binding mock drift and ACP request id

* ACP: gate slash bypass and persist cleared overrides

* ACPX: await pre-abort cancel before runTurn return

* Extension: pin acpx runtime dependency to 0.1.11

* Docs: add pinned acpx install strategy for ACP extension

* Extensions/acpx: enforce strict local pinned startup

* Extensions/acpx: tighten acp-router install guidance

* ACPX: retry runtime test temp-dir cleanup

* Extensions/acpx: require proactive ACPX repair for thread spawns

* Extensions/acpx: require restart offer after acpx reinstall

* extensions/acpx: remove workspace protocol devDependency

* extensions/acpx: bump pinned acpx to 0.1.13

* extensions/acpx: sync lockfile after dependency bump

* ACPX: make runtime spawn Windows-safe

* fix: align doctor-config-flow repair tests with default-account migration (#23580) (thanks @osolmaz)
This commit is contained in:
Onur Solmaz
2026-02-26 11:00:09 +01:00
committed by GitHub
parent a9d9a968ed
commit a7d56e3554
151 changed files with 19005 additions and 324 deletions

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../../config/config.js";
import { buildCommandTestParams } from "../commands-spawn.test-harness.js";
import {
isAcpCommandDiscordChannel,
resolveAcpCommandBindingContext,
resolveAcpCommandConversationId,
} from "./context.js";
const baseCfg = {
session: { mainKey: "main", scope: "per-sender" },
} satisfies OpenClawConfig;
describe("commands-acp context", () => {
it("resolves channel/account/thread context from originating fields", () => {
const params = buildCommandTestParams("/acp sessions", baseCfg, {
Provider: "discord",
Surface: "discord",
OriginatingChannel: "discord",
OriginatingTo: "channel:parent-1",
AccountId: "work",
MessageThreadId: "thread-42",
});
expect(resolveAcpCommandBindingContext(params)).toEqual({
channel: "discord",
accountId: "work",
threadId: "thread-42",
conversationId: "thread-42",
});
expect(isAcpCommandDiscordChannel(params)).toBe(true);
});
it("falls back to default account and target-derived conversation id", () => {
const params = buildCommandTestParams("/acp status", baseCfg, {
Provider: "slack",
Surface: "slack",
OriginatingChannel: "slack",
To: "<#123456789>",
});
expect(resolveAcpCommandBindingContext(params)).toEqual({
channel: "slack",
accountId: "default",
threadId: undefined,
conversationId: "123456789",
});
expect(resolveAcpCommandConversationId(params)).toBe("123456789");
expect(isAcpCommandDiscordChannel(params)).toBe(false);
});
});

View File

@@ -0,0 +1,58 @@
import { DISCORD_THREAD_BINDING_CHANNEL } from "../../../channels/thread-bindings-policy.js";
import { resolveConversationIdFromTargets } from "../../../infra/outbound/conversation-id.js";
import type { HandleCommandsParams } from "../commands-types.js";
function normalizeString(value: unknown): string {
if (typeof value === "string") {
return value.trim();
}
if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") {
return `${value}`.trim();
}
return "";
}
export function resolveAcpCommandChannel(params: HandleCommandsParams): string {
const raw =
params.ctx.OriginatingChannel ??
params.command.channel ??
params.ctx.Surface ??
params.ctx.Provider;
return normalizeString(raw).toLowerCase();
}
export function resolveAcpCommandAccountId(params: HandleCommandsParams): string {
const accountId = normalizeString(params.ctx.AccountId);
return accountId || "default";
}
export function resolveAcpCommandThreadId(params: HandleCommandsParams): string | undefined {
const threadId =
params.ctx.MessageThreadId != null ? normalizeString(String(params.ctx.MessageThreadId)) : "";
return threadId || undefined;
}
export function resolveAcpCommandConversationId(params: HandleCommandsParams): string | undefined {
return resolveConversationIdFromTargets({
threadId: params.ctx.MessageThreadId,
targets: [params.ctx.OriginatingTo, params.command.to, params.ctx.To],
});
}
export function isAcpCommandDiscordChannel(params: HandleCommandsParams): boolean {
return resolveAcpCommandChannel(params) === DISCORD_THREAD_BINDING_CHANNEL;
}
export function resolveAcpCommandBindingContext(params: HandleCommandsParams): {
channel: string;
accountId: string;
threadId?: string;
conversationId?: string;
} {
return {
channel: resolveAcpCommandChannel(params),
accountId: resolveAcpCommandAccountId(params),
threadId: resolveAcpCommandThreadId(params),
conversationId: resolveAcpCommandConversationId(params),
};
}

View File

@@ -0,0 +1,203 @@
import { getAcpSessionManager } from "../../../acp/control-plane/manager.js";
import { formatAcpRuntimeErrorText } from "../../../acp/runtime/error-text.js";
import { toAcpRuntimeError } from "../../../acp/runtime/errors.js";
import { getAcpRuntimeBackend, requireAcpRuntimeBackend } from "../../../acp/runtime/registry.js";
import { resolveSessionStorePathForAcp } from "../../../acp/runtime/session-meta.js";
import { loadSessionStore } from "../../../config/sessions.js";
import type { SessionEntry } from "../../../config/sessions/types.js";
import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js";
import type { CommandHandlerResult, HandleCommandsParams } from "../commands-types.js";
import { resolveAcpCommandBindingContext } from "./context.js";
import {
ACP_DOCTOR_USAGE,
ACP_INSTALL_USAGE,
ACP_SESSIONS_USAGE,
formatAcpCapabilitiesText,
resolveAcpInstallCommandHint,
resolveConfiguredAcpBackendId,
stopWithText,
} from "./shared.js";
import { resolveBoundAcpThreadSessionKey } from "./targets.js";
export async function handleAcpDoctorAction(
params: HandleCommandsParams,
restTokens: string[],
): Promise<CommandHandlerResult> {
if (restTokens.length > 0) {
return stopWithText(`⚠️ ${ACP_DOCTOR_USAGE}`);
}
const backendId = resolveConfiguredAcpBackendId(params.cfg);
const installHint = resolveAcpInstallCommandHint(params.cfg);
const registeredBackend = getAcpRuntimeBackend(backendId);
const managerSnapshot = getAcpSessionManager().getObservabilitySnapshot(params.cfg);
const lines = ["ACP doctor:", "-----", `configuredBackend: ${backendId}`];
lines.push(`activeRuntimeSessions: ${managerSnapshot.runtimeCache.activeSessions}`);
lines.push(`runtimeIdleTtlMs: ${managerSnapshot.runtimeCache.idleTtlMs}`);
lines.push(`evictedIdleRuntimes: ${managerSnapshot.runtimeCache.evictedTotal}`);
lines.push(`activeTurns: ${managerSnapshot.turns.active}`);
lines.push(`queueDepth: ${managerSnapshot.turns.queueDepth}`);
lines.push(
`turnLatencyMs: avg=${managerSnapshot.turns.averageLatencyMs}, max=${managerSnapshot.turns.maxLatencyMs}`,
);
lines.push(
`turnCounts: completed=${managerSnapshot.turns.completed}, failed=${managerSnapshot.turns.failed}`,
);
const errorStatsText =
Object.entries(managerSnapshot.errorsByCode)
.map(([code, count]) => `${code}=${count}`)
.join(", ") || "(none)";
lines.push(`errorCodes: ${errorStatsText}`);
if (registeredBackend) {
lines.push(`registeredBackend: ${registeredBackend.id}`);
} else {
lines.push("registeredBackend: (none)");
}
if (registeredBackend?.runtime.doctor) {
try {
const report = await registeredBackend.runtime.doctor();
lines.push(`runtimeDoctor: ${report.ok ? "ok" : "error"} (${report.message})`);
if (report.code) {
lines.push(`runtimeDoctorCode: ${report.code}`);
}
if (report.installCommand) {
lines.push(`runtimeDoctorInstall: ${report.installCommand}`);
}
for (const detail of report.details ?? []) {
lines.push(`runtimeDoctorDetail: ${detail}`);
}
} catch (error) {
lines.push(
`runtimeDoctor: error (${
toAcpRuntimeError({
error,
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "Runtime doctor failed.",
}).message
})`,
);
}
}
try {
const backend = requireAcpRuntimeBackend(backendId);
const capabilities = backend.runtime.getCapabilities
? await backend.runtime.getCapabilities({})
: { controls: [] as string[], configOptionKeys: [] as string[] };
lines.push("healthy: yes");
lines.push(`capabilities: ${formatAcpCapabilitiesText(capabilities.controls ?? [])}`);
if ((capabilities.configOptionKeys?.length ?? 0) > 0) {
lines.push(`configKeys: ${capabilities.configOptionKeys?.join(", ")}`);
}
return stopWithText(lines.join("\n"));
} catch (error) {
const acpError = toAcpRuntimeError({
error,
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "ACP backend doctor failed.",
});
lines.push("healthy: no");
lines.push(formatAcpRuntimeErrorText(acpError));
lines.push(`next: ${installHint}`);
lines.push(`next: openclaw config set plugins.entries.${backendId}.enabled true`);
if (backendId.toLowerCase() === "acpx") {
lines.push("next: verify acpx is installed (`acpx --help`).");
}
return stopWithText(lines.join("\n"));
}
}
export function handleAcpInstallAction(
params: HandleCommandsParams,
restTokens: string[],
): CommandHandlerResult {
if (restTokens.length > 0) {
return stopWithText(`⚠️ ${ACP_INSTALL_USAGE}`);
}
const backendId = resolveConfiguredAcpBackendId(params.cfg);
const installHint = resolveAcpInstallCommandHint(params.cfg);
const lines = [
"ACP install:",
"-----",
`configuredBackend: ${backendId}`,
`run: ${installHint}`,
`then: openclaw config set plugins.entries.${backendId}.enabled true`,
"then: /acp doctor",
];
return stopWithText(lines.join("\n"));
}
function formatAcpSessionLine(params: {
key: string;
entry: SessionEntry;
currentSessionKey?: string;
threadId?: string;
}): string {
const acp = params.entry.acp;
if (!acp) {
return "";
}
const marker = params.currentSessionKey === params.key ? "*" : " ";
const label = params.entry.label?.trim() || acp.agent;
const threadText = params.threadId ? `, thread:${params.threadId}` : "";
return `${marker} ${label} (${acp.mode}, ${acp.state}, backend:${acp.backend}${threadText}) -> ${params.key}`;
}
export function handleAcpSessionsAction(
params: HandleCommandsParams,
restTokens: string[],
): CommandHandlerResult {
if (restTokens.length > 0) {
return stopWithText(ACP_SESSIONS_USAGE);
}
const currentSessionKey = resolveBoundAcpThreadSessionKey(params) || params.sessionKey;
if (!currentSessionKey) {
return stopWithText("⚠️ Missing session key.");
}
const { storePath } = resolveSessionStorePathForAcp({
cfg: params.cfg,
sessionKey: currentSessionKey,
});
let store: Record<string, SessionEntry>;
try {
store = loadSessionStore(storePath);
} catch {
store = {};
}
const bindingContext = resolveAcpCommandBindingContext(params);
const normalizedChannel = bindingContext.channel;
const normalizedAccountId = bindingContext.accountId || undefined;
const bindingService = getSessionBindingService();
const rows = Object.entries(store)
.filter(([, entry]) => Boolean(entry?.acp))
.toSorted(([, a], [, b]) => (b?.updatedAt ?? 0) - (a?.updatedAt ?? 0))
.slice(0, 20)
.map(([key, entry]) => {
const bindingThreadId = bindingService
.listBySession(key)
.find(
(binding) =>
(!normalizedChannel || binding.conversation.channel === normalizedChannel) &&
(!normalizedAccountId || binding.conversation.accountId === normalizedAccountId),
)?.conversation.conversationId;
return formatAcpSessionLine({
key,
entry,
currentSessionKey,
threadId: bindingThreadId,
});
})
.filter(Boolean);
if (rows.length === 0) {
return stopWithText("ACP sessions:\n-----\n(none)");
}
return stopWithText(["ACP sessions:", "-----", ...rows].join("\n"));
}

View File

@@ -0,0 +1,588 @@
import { randomUUID } from "node:crypto";
import { getAcpSessionManager } from "../../../acp/control-plane/manager.js";
import {
cleanupFailedAcpSpawn,
type AcpSpawnRuntimeCloseHandle,
} from "../../../acp/control-plane/spawn.js";
import {
isAcpEnabledByPolicy,
resolveAcpAgentPolicyError,
resolveAcpDispatchPolicyError,
resolveAcpDispatchPolicyMessage,
} from "../../../acp/policy.js";
import { AcpRuntimeError } from "../../../acp/runtime/errors.js";
import {
resolveAcpSessionCwd,
resolveAcpThreadSessionDetailLines,
} from "../../../acp/runtime/session-identifiers.js";
import {
resolveThreadBindingIntroText,
resolveThreadBindingThreadName,
} from "../../../channels/thread-bindings-messages.js";
import {
formatThreadBindingDisabledError,
formatThreadBindingSpawnDisabledError,
resolveThreadBindingSessionTtlMsForChannel,
resolveThreadBindingSpawnPolicy,
} from "../../../channels/thread-bindings-policy.js";
import type { OpenClawConfig } from "../../../config/config.js";
import type { SessionAcpMeta } from "../../../config/sessions/types.js";
import { callGateway } from "../../../gateway/call.js";
import {
getSessionBindingService,
type SessionBindingRecord,
} from "../../../infra/outbound/session-binding-service.js";
import type { CommandHandlerResult, HandleCommandsParams } from "../commands-types.js";
import {
resolveAcpCommandAccountId,
resolveAcpCommandBindingContext,
resolveAcpCommandThreadId,
} from "./context.js";
import {
ACP_STEER_OUTPUT_LIMIT,
collectAcpErrorText,
parseSpawnInput,
parseSteerInput,
resolveCommandRequestId,
stopWithText,
type AcpSpawnThreadMode,
withAcpCommandErrorBoundary,
} from "./shared.js";
import { resolveAcpTargetSessionKey } from "./targets.js";
async function bindSpawnedAcpSessionToThread(params: {
commandParams: HandleCommandsParams;
sessionKey: string;
agentId: string;
label?: string;
threadMode: AcpSpawnThreadMode;
sessionMeta?: SessionAcpMeta;
}): Promise<{ ok: true; binding: SessionBindingRecord } | { ok: false; error: string }> {
const { commandParams, threadMode } = params;
if (threadMode === "off") {
return {
ok: false,
error: "internal: thread binding is disabled for this spawn",
};
}
const bindingContext = resolveAcpCommandBindingContext(commandParams);
const channel = bindingContext.channel;
if (!channel) {
return {
ok: false,
error: "ACP thread binding requires a channel context.",
};
}
const accountId = resolveAcpCommandAccountId(commandParams);
const spawnPolicy = resolveThreadBindingSpawnPolicy({
cfg: commandParams.cfg,
channel,
accountId,
kind: "acp",
});
if (!spawnPolicy.enabled) {
return {
ok: false,
error: formatThreadBindingDisabledError({
channel: spawnPolicy.channel,
accountId: spawnPolicy.accountId,
kind: "acp",
}),
};
}
if (!spawnPolicy.spawnEnabled) {
return {
ok: false,
error: formatThreadBindingSpawnDisabledError({
channel: spawnPolicy.channel,
accountId: spawnPolicy.accountId,
kind: "acp",
}),
};
}
const bindingService = getSessionBindingService();
const capabilities = bindingService.getCapabilities({
channel: spawnPolicy.channel,
accountId: spawnPolicy.accountId,
});
if (!capabilities.adapterAvailable) {
return {
ok: false,
error: `Thread bindings are unavailable for ${channel}.`,
};
}
if (!capabilities.bindSupported) {
return {
ok: false,
error: `Thread bindings are unavailable for ${channel}.`,
};
}
const currentThreadId = bindingContext.threadId ?? "";
if (threadMode === "here" && !currentThreadId) {
return {
ok: false,
error: `--thread here requires running /acp spawn inside an active ${channel} thread/conversation.`,
};
}
const threadId = currentThreadId || undefined;
const placement = threadId ? "current" : "child";
if (!capabilities.placements.includes(placement)) {
return {
ok: false,
error: `Thread bindings do not support ${placement} placement for ${channel}.`,
};
}
const channelId = placement === "child" ? bindingContext.conversationId : undefined;
if (placement === "child" && !channelId) {
return {
ok: false,
error: `Could not resolve a ${channel} conversation for ACP thread spawn.`,
};
}
const senderId = commandParams.command.senderId?.trim() || "";
if (threadId) {
const existingBinding = bindingService.resolveByConversation({
channel: spawnPolicy.channel,
accountId: spawnPolicy.accountId,
conversationId: threadId,
});
const boundBy =
typeof existingBinding?.metadata?.boundBy === "string"
? existingBinding.metadata.boundBy.trim()
: "";
if (existingBinding && boundBy && boundBy !== "system" && senderId && senderId !== boundBy) {
return {
ok: false,
error: `Only ${boundBy} can rebind this thread.`,
};
}
}
const label = params.label || params.agentId;
const conversationId = threadId || channelId;
if (!conversationId) {
return {
ok: false,
error: `Could not resolve a ${channel} conversation for ACP thread spawn.`,
};
}
try {
const binding = await bindingService.bind({
targetSessionKey: params.sessionKey,
targetKind: "session",
conversation: {
channel: spawnPolicy.channel,
accountId: spawnPolicy.accountId,
conversationId,
},
placement,
metadata: {
threadName: resolveThreadBindingThreadName({
agentId: params.agentId,
label,
}),
agentId: params.agentId,
label,
boundBy: senderId || "unknown",
introText: resolveThreadBindingIntroText({
agentId: params.agentId,
label,
sessionTtlMs: resolveThreadBindingSessionTtlMsForChannel({
cfg: commandParams.cfg,
channel: spawnPolicy.channel,
accountId: spawnPolicy.accountId,
}),
sessionCwd: resolveAcpSessionCwd(params.sessionMeta),
sessionDetails: resolveAcpThreadSessionDetailLines({
sessionKey: params.sessionKey,
meta: params.sessionMeta,
}),
}),
},
});
return {
ok: true,
binding,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
ok: false,
error: message || `Failed to bind a ${channel} thread/conversation to the new ACP session.`,
};
}
}
async function cleanupFailedSpawn(params: {
cfg: OpenClawConfig;
sessionKey: string;
shouldDeleteSession: boolean;
initializedRuntime?: AcpSpawnRuntimeCloseHandle;
}) {
await cleanupFailedAcpSpawn({
cfg: params.cfg,
sessionKey: params.sessionKey,
shouldDeleteSession: params.shouldDeleteSession,
deleteTranscript: false,
runtimeCloseHandle: params.initializedRuntime,
});
}
export async function handleAcpSpawnAction(
params: HandleCommandsParams,
restTokens: string[],
): Promise<CommandHandlerResult> {
if (!isAcpEnabledByPolicy(params.cfg)) {
return stopWithText("ACP is disabled by policy (`acp.enabled=false`).");
}
const parsed = parseSpawnInput(params, restTokens);
if (!parsed.ok) {
return stopWithText(`⚠️ ${parsed.error}`);
}
const spawn = parsed.value;
const agentPolicyError = resolveAcpAgentPolicyError(params.cfg, spawn.agentId);
if (agentPolicyError) {
return stopWithText(
collectAcpErrorText({
error: agentPolicyError,
fallbackCode: "ACP_SESSION_INIT_FAILED",
fallbackMessage: "ACP target agent is not allowed by policy.",
}),
);
}
const acpManager = getAcpSessionManager();
const sessionKey = `agent:${spawn.agentId}:acp:${randomUUID()}`;
let initializedBackend = "";
let initializedMeta: SessionAcpMeta | undefined;
let initializedRuntime: AcpSpawnRuntimeCloseHandle | undefined;
try {
const initialized = await acpManager.initializeSession({
cfg: params.cfg,
sessionKey,
agent: spawn.agentId,
mode: spawn.mode,
cwd: spawn.cwd,
});
initializedRuntime = {
runtime: initialized.runtime,
handle: initialized.handle,
};
initializedBackend = initialized.handle.backend || initialized.meta.backend;
initializedMeta = initialized.meta;
} catch (err) {
return stopWithText(
collectAcpErrorText({
error: err,
fallbackCode: "ACP_SESSION_INIT_FAILED",
fallbackMessage: "Could not initialize ACP session runtime.",
}),
);
}
let binding: SessionBindingRecord | null = null;
if (spawn.thread !== "off") {
const bound = await bindSpawnedAcpSessionToThread({
commandParams: params,
sessionKey,
agentId: spawn.agentId,
label: spawn.label,
threadMode: spawn.thread,
sessionMeta: initializedMeta,
});
if (!bound.ok) {
await cleanupFailedSpawn({
cfg: params.cfg,
sessionKey,
shouldDeleteSession: true,
initializedRuntime,
});
return stopWithText(`⚠️ ${bound.error}`);
}
binding = bound.binding;
}
try {
await callGateway({
method: "sessions.patch",
params: {
key: sessionKey,
...(spawn.label ? { label: spawn.label } : {}),
},
timeoutMs: 10_000,
});
} catch (err) {
await cleanupFailedSpawn({
cfg: params.cfg,
sessionKey,
shouldDeleteSession: true,
initializedRuntime,
});
const message = err instanceof Error ? err.message : String(err);
return stopWithText(`⚠️ ACP spawn failed: ${message}`);
}
const parts = [
`✅ Spawned ACP session ${sessionKey} (${spawn.mode}, backend ${initializedBackend}).`,
];
if (binding) {
const currentThreadId = resolveAcpCommandThreadId(params) ?? "";
const boundConversationId = binding.conversation.conversationId.trim();
if (currentThreadId && boundConversationId === currentThreadId) {
parts.push(`Bound this thread to ${sessionKey}.`);
} else {
parts.push(`Created thread ${boundConversationId} and bound it to ${sessionKey}.`);
}
} else {
parts.push("Session is unbound (use /focus <session-key> to bind this thread/conversation).");
}
const dispatchNote = resolveAcpDispatchPolicyMessage(params.cfg);
if (dispatchNote) {
parts.push(` ${dispatchNote}`);
}
return stopWithText(parts.join(" "));
}
export async function handleAcpCancelAction(
params: HandleCommandsParams,
restTokens: string[],
): Promise<CommandHandlerResult> {
const acpManager = getAcpSessionManager();
const token = restTokens.join(" ").trim() || undefined;
const target = await resolveAcpTargetSessionKey({
commandParams: params,
token,
});
if (!target.ok) {
return stopWithText(`⚠️ ${target.error}`);
}
const resolved = acpManager.resolveSession({
cfg: params.cfg,
sessionKey: target.sessionKey,
});
if (resolved.kind === "none") {
return stopWithText(
collectAcpErrorText({
error: new AcpRuntimeError(
"ACP_SESSION_INIT_FAILED",
`Session is not ACP-enabled: ${target.sessionKey}`,
),
fallbackCode: "ACP_SESSION_INIT_FAILED",
fallbackMessage: "Session is not ACP-enabled.",
}),
);
}
if (resolved.kind === "stale") {
return stopWithText(
collectAcpErrorText({
error: resolved.error,
fallbackCode: "ACP_SESSION_INIT_FAILED",
fallbackMessage: resolved.error.message,
}),
);
}
return await withAcpCommandErrorBoundary({
run: async () =>
await acpManager.cancelSession({
cfg: params.cfg,
sessionKey: target.sessionKey,
reason: "manual-cancel",
}),
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "ACP cancel failed before completion.",
onSuccess: () => stopWithText(`✅ Cancel requested for ACP session ${target.sessionKey}.`),
});
}
async function runAcpSteer(params: {
cfg: OpenClawConfig;
sessionKey: string;
instruction: string;
requestId: string;
}): Promise<string> {
const acpManager = getAcpSessionManager();
let output = "";
await acpManager.runTurn({
cfg: params.cfg,
sessionKey: params.sessionKey,
text: params.instruction,
mode: "steer",
requestId: params.requestId,
onEvent: (event) => {
if (event.type !== "text_delta") {
return;
}
if (event.stream && event.stream !== "output") {
return;
}
if (event.text) {
output += event.text;
if (output.length > ACP_STEER_OUTPUT_LIMIT) {
output = `${output.slice(0, ACP_STEER_OUTPUT_LIMIT)}`;
}
}
},
});
return output.trim();
}
export async function handleAcpSteerAction(
params: HandleCommandsParams,
restTokens: string[],
): Promise<CommandHandlerResult> {
const dispatchPolicyError = resolveAcpDispatchPolicyError(params.cfg);
if (dispatchPolicyError) {
return stopWithText(
collectAcpErrorText({
error: dispatchPolicyError,
fallbackCode: "ACP_DISPATCH_DISABLED",
fallbackMessage: dispatchPolicyError.message,
}),
);
}
const parsed = parseSteerInput(restTokens);
if (!parsed.ok) {
return stopWithText(`⚠️ ${parsed.error}`);
}
const acpManager = getAcpSessionManager();
const target = await resolveAcpTargetSessionKey({
commandParams: params,
token: parsed.value.sessionToken,
});
if (!target.ok) {
return stopWithText(`⚠️ ${target.error}`);
}
const resolved = acpManager.resolveSession({
cfg: params.cfg,
sessionKey: target.sessionKey,
});
if (resolved.kind === "none") {
return stopWithText(
collectAcpErrorText({
error: new AcpRuntimeError(
"ACP_SESSION_INIT_FAILED",
`Session is not ACP-enabled: ${target.sessionKey}`,
),
fallbackCode: "ACP_SESSION_INIT_FAILED",
fallbackMessage: "Session is not ACP-enabled.",
}),
);
}
if (resolved.kind === "stale") {
return stopWithText(
collectAcpErrorText({
error: resolved.error,
fallbackCode: "ACP_SESSION_INIT_FAILED",
fallbackMessage: resolved.error.message,
}),
);
}
return await withAcpCommandErrorBoundary({
run: async () =>
await runAcpSteer({
cfg: params.cfg,
sessionKey: target.sessionKey,
instruction: parsed.value.instruction,
requestId: `${resolveCommandRequestId(params)}:steer`,
}),
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "ACP steer failed before completion.",
onSuccess: (steerOutput) => {
if (!steerOutput) {
return stopWithText(`✅ ACP steer sent to ${target.sessionKey}.`);
}
return stopWithText(`✅ ACP steer sent to ${target.sessionKey}.\n${steerOutput}`);
},
});
}
export async function handleAcpCloseAction(
params: HandleCommandsParams,
restTokens: string[],
): Promise<CommandHandlerResult> {
const acpManager = getAcpSessionManager();
const token = restTokens.join(" ").trim() || undefined;
const target = await resolveAcpTargetSessionKey({
commandParams: params,
token,
});
if (!target.ok) {
return stopWithText(`⚠️ ${target.error}`);
}
const resolved = acpManager.resolveSession({
cfg: params.cfg,
sessionKey: target.sessionKey,
});
if (resolved.kind === "none") {
return stopWithText(
collectAcpErrorText({
error: new AcpRuntimeError(
"ACP_SESSION_INIT_FAILED",
`Session is not ACP-enabled: ${target.sessionKey}`,
),
fallbackCode: "ACP_SESSION_INIT_FAILED",
fallbackMessage: "Session is not ACP-enabled.",
}),
);
}
if (resolved.kind === "stale") {
return stopWithText(
collectAcpErrorText({
error: resolved.error,
fallbackCode: "ACP_SESSION_INIT_FAILED",
fallbackMessage: resolved.error.message,
}),
);
}
let runtimeNotice = "";
try {
const closed = await acpManager.closeSession({
cfg: params.cfg,
sessionKey: target.sessionKey,
reason: "manual-close",
allowBackendUnavailable: true,
clearMeta: true,
});
runtimeNotice = closed.runtimeNotice ? ` (${closed.runtimeNotice})` : "";
} catch (error) {
return stopWithText(
collectAcpErrorText({
error,
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "ACP close failed before completion.",
}),
);
}
const removedBindings = await getSessionBindingService().unbind({
targetSessionKey: target.sessionKey,
reason: "manual",
});
return stopWithText(
`✅ Closed ACP session ${target.sessionKey}${runtimeNotice}. Removed ${removedBindings.length} binding${removedBindings.length === 1 ? "" : "s"}.`,
);
}

View File

@@ -0,0 +1,348 @@
import { getAcpSessionManager } from "../../../acp/control-plane/manager.js";
import {
parseRuntimeTimeoutSecondsInput,
validateRuntimeConfigOptionInput,
validateRuntimeCwdInput,
validateRuntimeModeInput,
validateRuntimeModelInput,
validateRuntimePermissionProfileInput,
} from "../../../acp/control-plane/runtime-options.js";
import { resolveAcpSessionIdentifierLinesFromIdentity } from "../../../acp/runtime/session-identifiers.js";
import type { CommandHandlerResult, HandleCommandsParams } from "../commands-types.js";
import {
ACP_CWD_USAGE,
ACP_MODEL_USAGE,
ACP_PERMISSIONS_USAGE,
ACP_RESET_OPTIONS_USAGE,
ACP_SET_MODE_USAGE,
ACP_STATUS_USAGE,
ACP_TIMEOUT_USAGE,
formatAcpCapabilitiesText,
formatRuntimeOptionsText,
parseOptionalSingleTarget,
parseSetCommandInput,
parseSingleValueCommandInput,
stopWithText,
withAcpCommandErrorBoundary,
} from "./shared.js";
import { resolveAcpTargetSessionKey } from "./targets.js";
export async function handleAcpStatusAction(
params: HandleCommandsParams,
restTokens: string[],
): Promise<CommandHandlerResult> {
const parsed = parseOptionalSingleTarget(restTokens, ACP_STATUS_USAGE);
if (!parsed.ok) {
return stopWithText(`⚠️ ${parsed.error}`);
}
const target = await resolveAcpTargetSessionKey({
commandParams: params,
token: parsed.sessionToken,
});
if (!target.ok) {
return stopWithText(`⚠️ ${target.error}`);
}
return await withAcpCommandErrorBoundary({
run: async () =>
await getAcpSessionManager().getSessionStatus({
cfg: params.cfg,
sessionKey: target.sessionKey,
}),
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "Could not read ACP session status.",
onSuccess: (status) => {
const sessionIdentifierLines = resolveAcpSessionIdentifierLinesFromIdentity({
backend: status.backend,
identity: status.identity,
});
const lines = [
"ACP status:",
"-----",
`session: ${status.sessionKey}`,
`backend: ${status.backend}`,
`agent: ${status.agent}`,
...sessionIdentifierLines,
`sessionMode: ${status.mode}`,
`state: ${status.state}`,
`runtimeOptions: ${formatRuntimeOptionsText(status.runtimeOptions)}`,
`capabilities: ${formatAcpCapabilitiesText(status.capabilities.controls)}`,
`lastActivityAt: ${new Date(status.lastActivityAt).toISOString()}`,
...(status.lastError ? [`lastError: ${status.lastError}`] : []),
...(status.runtimeStatus?.summary ? [`runtime: ${status.runtimeStatus.summary}`] : []),
...(status.runtimeStatus?.details
? [`runtimeDetails: ${JSON.stringify(status.runtimeStatus.details)}`]
: []),
];
return stopWithText(lines.join("\n"));
},
});
}
export async function handleAcpSetModeAction(
params: HandleCommandsParams,
restTokens: string[],
): Promise<CommandHandlerResult> {
const parsed = parseSingleValueCommandInput(restTokens, ACP_SET_MODE_USAGE);
if (!parsed.ok) {
return stopWithText(`⚠️ ${parsed.error}`);
}
const target = await resolveAcpTargetSessionKey({
commandParams: params,
token: parsed.value.sessionToken,
});
if (!target.ok) {
return stopWithText(`⚠️ ${target.error}`);
}
return await withAcpCommandErrorBoundary({
run: async () => {
const runtimeMode = validateRuntimeModeInput(parsed.value.value);
const options = await getAcpSessionManager().setSessionRuntimeMode({
cfg: params.cfg,
sessionKey: target.sessionKey,
runtimeMode,
});
return {
runtimeMode,
options,
};
},
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "Could not update ACP runtime mode.",
onSuccess: ({ runtimeMode, options }) =>
stopWithText(
`✅ Updated ACP runtime mode for ${target.sessionKey}: ${runtimeMode}. Effective options: ${formatRuntimeOptionsText(options)}`,
),
});
}
export async function handleAcpSetAction(
params: HandleCommandsParams,
restTokens: string[],
): Promise<CommandHandlerResult> {
const parsed = parseSetCommandInput(restTokens);
if (!parsed.ok) {
return stopWithText(`⚠️ ${parsed.error}`);
}
const target = await resolveAcpTargetSessionKey({
commandParams: params,
token: parsed.value.sessionToken,
});
if (!target.ok) {
return stopWithText(`⚠️ ${target.error}`);
}
const key = parsed.value.key.trim();
const value = parsed.value.value.trim();
return await withAcpCommandErrorBoundary({
run: async () => {
const lowerKey = key.toLowerCase();
if (lowerKey === "cwd") {
const cwd = validateRuntimeCwdInput(value);
const options = await getAcpSessionManager().updateSessionRuntimeOptions({
cfg: params.cfg,
sessionKey: target.sessionKey,
patch: { cwd },
});
return {
text: `✅ Updated ACP cwd for ${target.sessionKey}: ${cwd}. Effective options: ${formatRuntimeOptionsText(options)}`,
};
}
const validated = validateRuntimeConfigOptionInput(key, value);
const options = await getAcpSessionManager().setSessionConfigOption({
cfg: params.cfg,
sessionKey: target.sessionKey,
key: validated.key,
value: validated.value,
});
return {
text: `✅ Updated ACP config option for ${target.sessionKey}: ${validated.key}=${validated.value}. Effective options: ${formatRuntimeOptionsText(options)}`,
};
},
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "Could not update ACP config option.",
onSuccess: ({ text }) => stopWithText(text),
});
}
export async function handleAcpCwdAction(
params: HandleCommandsParams,
restTokens: string[],
): Promise<CommandHandlerResult> {
const parsed = parseSingleValueCommandInput(restTokens, ACP_CWD_USAGE);
if (!parsed.ok) {
return stopWithText(`⚠️ ${parsed.error}`);
}
const target = await resolveAcpTargetSessionKey({
commandParams: params,
token: parsed.value.sessionToken,
});
if (!target.ok) {
return stopWithText(`⚠️ ${target.error}`);
}
return await withAcpCommandErrorBoundary({
run: async () => {
const cwd = validateRuntimeCwdInput(parsed.value.value);
const options = await getAcpSessionManager().updateSessionRuntimeOptions({
cfg: params.cfg,
sessionKey: target.sessionKey,
patch: { cwd },
});
return {
cwd,
options,
};
},
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "Could not update ACP cwd.",
onSuccess: ({ cwd, options }) =>
stopWithText(
`✅ Updated ACP cwd for ${target.sessionKey}: ${cwd}. Effective options: ${formatRuntimeOptionsText(options)}`,
),
});
}
export async function handleAcpPermissionsAction(
params: HandleCommandsParams,
restTokens: string[],
): Promise<CommandHandlerResult> {
const parsed = parseSingleValueCommandInput(restTokens, ACP_PERMISSIONS_USAGE);
if (!parsed.ok) {
return stopWithText(`⚠️ ${parsed.error}`);
}
const target = await resolveAcpTargetSessionKey({
commandParams: params,
token: parsed.value.sessionToken,
});
if (!target.ok) {
return stopWithText(`⚠️ ${target.error}`);
}
return await withAcpCommandErrorBoundary({
run: async () => {
const permissionProfile = validateRuntimePermissionProfileInput(parsed.value.value);
const options = await getAcpSessionManager().setSessionConfigOption({
cfg: params.cfg,
sessionKey: target.sessionKey,
key: "approval_policy",
value: permissionProfile,
});
return {
permissionProfile,
options,
};
},
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "Could not update ACP permissions profile.",
onSuccess: ({ permissionProfile, options }) =>
stopWithText(
`✅ Updated ACP permissions profile for ${target.sessionKey}: ${permissionProfile}. Effective options: ${formatRuntimeOptionsText(options)}`,
),
});
}
export async function handleAcpTimeoutAction(
params: HandleCommandsParams,
restTokens: string[],
): Promise<CommandHandlerResult> {
const parsed = parseSingleValueCommandInput(restTokens, ACP_TIMEOUT_USAGE);
if (!parsed.ok) {
return stopWithText(`⚠️ ${parsed.error}`);
}
const target = await resolveAcpTargetSessionKey({
commandParams: params,
token: parsed.value.sessionToken,
});
if (!target.ok) {
return stopWithText(`⚠️ ${target.error}`);
}
return await withAcpCommandErrorBoundary({
run: async () => {
const timeoutSeconds = parseRuntimeTimeoutSecondsInput(parsed.value.value);
const options = await getAcpSessionManager().setSessionConfigOption({
cfg: params.cfg,
sessionKey: target.sessionKey,
key: "timeout",
value: String(timeoutSeconds),
});
return {
timeoutSeconds,
options,
};
},
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "Could not update ACP timeout.",
onSuccess: ({ timeoutSeconds, options }) =>
stopWithText(
`✅ Updated ACP timeout for ${target.sessionKey}: ${timeoutSeconds}s. Effective options: ${formatRuntimeOptionsText(options)}`,
),
});
}
export async function handleAcpModelAction(
params: HandleCommandsParams,
restTokens: string[],
): Promise<CommandHandlerResult> {
const parsed = parseSingleValueCommandInput(restTokens, ACP_MODEL_USAGE);
if (!parsed.ok) {
return stopWithText(`⚠️ ${parsed.error}`);
}
const target = await resolveAcpTargetSessionKey({
commandParams: params,
token: parsed.value.sessionToken,
});
if (!target.ok) {
return stopWithText(`⚠️ ${target.error}`);
}
return await withAcpCommandErrorBoundary({
run: async () => {
const model = validateRuntimeModelInput(parsed.value.value);
const options = await getAcpSessionManager().setSessionConfigOption({
cfg: params.cfg,
sessionKey: target.sessionKey,
key: "model",
value: model,
});
return {
model,
options,
};
},
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "Could not update ACP model.",
onSuccess: ({ model, options }) =>
stopWithText(
`✅ Updated ACP model for ${target.sessionKey}: ${model}. Effective options: ${formatRuntimeOptionsText(options)}`,
),
});
}
export async function handleAcpResetOptionsAction(
params: HandleCommandsParams,
restTokens: string[],
): Promise<CommandHandlerResult> {
const parsed = parseOptionalSingleTarget(restTokens, ACP_RESET_OPTIONS_USAGE);
if (!parsed.ok) {
return stopWithText(`⚠️ ${parsed.error}`);
}
const target = await resolveAcpTargetSessionKey({
commandParams: params,
token: parsed.sessionToken,
});
if (!target.ok) {
return stopWithText(`⚠️ ${target.error}`);
}
return await withAcpCommandErrorBoundary({
run: async () =>
await getAcpSessionManager().resetSessionRuntimeOptions({
cfg: params.cfg,
sessionKey: target.sessionKey,
}),
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "Could not reset ACP runtime options.",
onSuccess: () => stopWithText(`✅ Reset ACP runtime options for ${target.sessionKey}.`),
});
}

View File

@@ -0,0 +1,500 @@
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import path from "node:path";
import { toAcpRuntimeErrorText } from "../../../acp/runtime/error-text.js";
import type { AcpRuntimeError } from "../../../acp/runtime/errors.js";
import type { AcpRuntimeSessionMode } from "../../../acp/runtime/types.js";
import { DISCORD_THREAD_BINDING_CHANNEL } from "../../../channels/thread-bindings-policy.js";
import type { OpenClawConfig } from "../../../config/config.js";
import type { AcpSessionRuntimeOptions } from "../../../config/sessions/types.js";
import { normalizeAgentId } from "../../../routing/session-key.js";
import type { CommandHandlerResult, HandleCommandsParams } from "../commands-types.js";
import { resolveAcpCommandChannel, resolveAcpCommandThreadId } from "./context.js";
export const COMMAND = "/acp";
export const ACP_SPAWN_USAGE =
"Usage: /acp spawn [agentId] [--mode persistent|oneshot] [--thread auto|here|off] [--cwd <path>] [--label <label>].";
export const ACP_STEER_USAGE =
"Usage: /acp steer [--session <session-key|session-id|session-label>] <instruction>";
export const ACP_SET_MODE_USAGE =
"Usage: /acp set-mode <mode> [session-key|session-id|session-label]";
export const ACP_SET_USAGE = "Usage: /acp set <key> <value> [session-key|session-id|session-label]";
export const ACP_CWD_USAGE = "Usage: /acp cwd <path> [session-key|session-id|session-label]";
export const ACP_PERMISSIONS_USAGE =
"Usage: /acp permissions <profile> [session-key|session-id|session-label]";
export const ACP_TIMEOUT_USAGE =
"Usage: /acp timeout <seconds> [session-key|session-id|session-label]";
export const ACP_MODEL_USAGE =
"Usage: /acp model <model-id> [session-key|session-id|session-label]";
export const ACP_RESET_OPTIONS_USAGE =
"Usage: /acp reset-options [session-key|session-id|session-label]";
export const ACP_STATUS_USAGE = "Usage: /acp status [session-key|session-id|session-label]";
export const ACP_INSTALL_USAGE = "Usage: /acp install";
export const ACP_DOCTOR_USAGE = "Usage: /acp doctor";
export const ACP_SESSIONS_USAGE = "Usage: /acp sessions";
export const ACP_STEER_OUTPUT_LIMIT = 800;
export const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export type AcpAction =
| "spawn"
| "cancel"
| "steer"
| "close"
| "sessions"
| "status"
| "set-mode"
| "set"
| "cwd"
| "permissions"
| "timeout"
| "model"
| "reset-options"
| "doctor"
| "install"
| "help";
export type AcpSpawnThreadMode = "auto" | "here" | "off";
export type ParsedSpawnInput = {
agentId: string;
mode: AcpRuntimeSessionMode;
thread: AcpSpawnThreadMode;
cwd?: string;
label?: string;
};
export type ParsedSteerInput = {
sessionToken?: string;
instruction: string;
};
export type ParsedSingleValueCommandInput = {
value: string;
sessionToken?: string;
};
export type ParsedSetCommandInput = {
key: string;
value: string;
sessionToken?: string;
};
export function stopWithText(text: string): CommandHandlerResult {
return {
shouldContinue: false,
reply: { text },
};
}
export function resolveAcpAction(tokens: string[]): AcpAction {
const action = tokens[0]?.trim().toLowerCase();
if (
action === "spawn" ||
action === "cancel" ||
action === "steer" ||
action === "close" ||
action === "sessions" ||
action === "status" ||
action === "set-mode" ||
action === "set" ||
action === "cwd" ||
action === "permissions" ||
action === "timeout" ||
action === "model" ||
action === "reset-options" ||
action === "doctor" ||
action === "install" ||
action === "help"
) {
tokens.shift();
return action;
}
return "help";
}
function readOptionValue(params: { tokens: string[]; index: number; flag: string }):
| {
matched: true;
value?: string;
nextIndex: number;
error?: string;
}
| { matched: false } {
const token = params.tokens[params.index] ?? "";
if (token === params.flag) {
const nextValue = params.tokens[params.index + 1]?.trim() ?? "";
if (!nextValue || nextValue.startsWith("--")) {
return {
matched: true,
nextIndex: params.index + 1,
error: `${params.flag} requires a value`,
};
}
return {
matched: true,
value: nextValue,
nextIndex: params.index + 2,
};
}
if (token.startsWith(`${params.flag}=`)) {
const value = token.slice(`${params.flag}=`.length).trim();
if (!value) {
return {
matched: true,
nextIndex: params.index + 1,
error: `${params.flag} requires a value`,
};
}
return {
matched: true,
value,
nextIndex: params.index + 1,
};
}
return { matched: false };
}
function resolveDefaultSpawnThreadMode(params: HandleCommandsParams): AcpSpawnThreadMode {
if (resolveAcpCommandChannel(params) !== DISCORD_THREAD_BINDING_CHANNEL) {
return "off";
}
const currentThreadId = resolveAcpCommandThreadId(params);
return currentThreadId ? "here" : "auto";
}
export function parseSpawnInput(
params: HandleCommandsParams,
tokens: string[],
): { ok: true; value: ParsedSpawnInput } | { ok: false; error: string } {
let mode: AcpRuntimeSessionMode = "persistent";
let thread = resolveDefaultSpawnThreadMode(params);
let cwd: string | undefined;
let label: string | undefined;
let rawAgentId: string | undefined;
for (let i = 0; i < tokens.length; ) {
const token = tokens[i] ?? "";
const modeOption = readOptionValue({ tokens, index: i, flag: "--mode" });
if (modeOption.matched) {
if (modeOption.error) {
return { ok: false, error: `${modeOption.error}. ${ACP_SPAWN_USAGE}` };
}
const raw = modeOption.value?.trim().toLowerCase();
if (raw !== "persistent" && raw !== "oneshot") {
return {
ok: false,
error: `Invalid --mode value "${modeOption.value}". Use persistent or oneshot.`,
};
}
mode = raw;
i = modeOption.nextIndex;
continue;
}
const threadOption = readOptionValue({ tokens, index: i, flag: "--thread" });
if (threadOption.matched) {
if (threadOption.error) {
return { ok: false, error: `${threadOption.error}. ${ACP_SPAWN_USAGE}` };
}
const raw = threadOption.value?.trim().toLowerCase();
if (raw !== "auto" && raw !== "here" && raw !== "off") {
return {
ok: false,
error: `Invalid --thread value "${threadOption.value}". Use auto, here, or off.`,
};
}
thread = raw;
i = threadOption.nextIndex;
continue;
}
const cwdOption = readOptionValue({ tokens, index: i, flag: "--cwd" });
if (cwdOption.matched) {
if (cwdOption.error) {
return { ok: false, error: `${cwdOption.error}. ${ACP_SPAWN_USAGE}` };
}
cwd = cwdOption.value?.trim();
i = cwdOption.nextIndex;
continue;
}
const labelOption = readOptionValue({ tokens, index: i, flag: "--label" });
if (labelOption.matched) {
if (labelOption.error) {
return { ok: false, error: `${labelOption.error}. ${ACP_SPAWN_USAGE}` };
}
label = labelOption.value?.trim();
i = labelOption.nextIndex;
continue;
}
if (token.startsWith("--")) {
return {
ok: false,
error: `Unknown option: ${token}. ${ACP_SPAWN_USAGE}`,
};
}
if (!rawAgentId) {
rawAgentId = token.trim();
i += 1;
continue;
}
return {
ok: false,
error: `Unexpected argument: ${token}. ${ACP_SPAWN_USAGE}`,
};
}
const fallbackAgent = params.cfg.acp?.defaultAgent?.trim() || "";
const selectedAgent = (rawAgentId?.trim() || fallbackAgent).trim();
if (!selectedAgent) {
return {
ok: false,
error: `ACP target agent is required. Pass an agent id or configure acp.defaultAgent. ${ACP_SPAWN_USAGE}`,
};
}
const normalizedAgentId = normalizeAgentId(selectedAgent);
return {
ok: true,
value: {
agentId: normalizedAgentId,
mode,
thread,
cwd,
label: label || undefined,
},
};
}
export function parseSteerInput(
tokens: string[],
): { ok: true; value: ParsedSteerInput } | { ok: false; error: string } {
let sessionToken: string | undefined;
const instructionTokens: string[] = [];
for (let i = 0; i < tokens.length; ) {
const sessionOption = readOptionValue({
tokens,
index: i,
flag: "--session",
});
if (sessionOption.matched) {
if (sessionOption.error) {
return {
ok: false,
error: `${sessionOption.error}. ${ACP_STEER_USAGE}`,
};
}
sessionToken = sessionOption.value?.trim() || undefined;
i = sessionOption.nextIndex;
continue;
}
instructionTokens.push(tokens[i]);
i += 1;
}
const instruction = instructionTokens.join(" ").trim();
if (!instruction) {
return {
ok: false,
error: ACP_STEER_USAGE,
};
}
return {
ok: true,
value: {
sessionToken,
instruction,
},
};
}
export function parseSingleValueCommandInput(
tokens: string[],
usage: string,
): { ok: true; value: ParsedSingleValueCommandInput } | { ok: false; error: string } {
const value = tokens[0]?.trim() || "";
if (!value) {
return { ok: false, error: usage };
}
if (tokens.length > 2) {
return { ok: false, error: usage };
}
const sessionToken = tokens[1]?.trim() || undefined;
return {
ok: true,
value: {
value,
sessionToken,
},
};
}
export function parseSetCommandInput(
tokens: string[],
): { ok: true; value: ParsedSetCommandInput } | { ok: false; error: string } {
const key = tokens[0]?.trim() || "";
const value = tokens[1]?.trim() || "";
if (!key || !value) {
return {
ok: false,
error: ACP_SET_USAGE,
};
}
if (tokens.length > 3) {
return {
ok: false,
error: ACP_SET_USAGE,
};
}
const sessionToken = tokens[2]?.trim() || undefined;
return {
ok: true,
value: {
key,
value,
sessionToken,
},
};
}
export function parseOptionalSingleTarget(
tokens: string[],
usage: string,
): { ok: true; sessionToken?: string } | { ok: false; error: string } {
if (tokens.length > 1) {
return { ok: false, error: usage };
}
const token = tokens[0]?.trim() || "";
return {
ok: true,
...(token ? { sessionToken: token } : {}),
};
}
export function resolveAcpHelpText(): string {
return [
"ACP commands:",
"-----",
"/acp spawn [agentId] [--mode persistent|oneshot] [--thread auto|here|off] [--cwd <path>] [--label <label>]",
"/acp cancel [session-key|session-id|session-label]",
"/acp steer [--session <session-key|session-id|session-label>] <instruction>",
"/acp close [session-key|session-id|session-label]",
"/acp status [session-key|session-id|session-label]",
"/acp set-mode <mode> [session-key|session-id|session-label]",
"/acp set <key> <value> [session-key|session-id|session-label]",
"/acp cwd <path> [session-key|session-id|session-label]",
"/acp permissions <profile> [session-key|session-id|session-label]",
"/acp timeout <seconds> [session-key|session-id|session-label]",
"/acp model <model-id> [session-key|session-id|session-label]",
"/acp reset-options [session-key|session-id|session-label]",
"/acp doctor",
"/acp install",
"/acp sessions",
"",
"Notes:",
"- /focus and /unfocus also work with ACP session keys.",
"- ACP dispatch of normal thread messages is controlled by acp.dispatch.enabled.",
].join("\n");
}
export function resolveConfiguredAcpBackendId(cfg: OpenClawConfig): string {
return cfg.acp?.backend?.trim() || "acpx";
}
export function resolveAcpInstallCommandHint(cfg: OpenClawConfig): string {
const configured = cfg.acp?.runtime?.installCommand?.trim();
if (configured) {
return configured;
}
const backendId = resolveConfiguredAcpBackendId(cfg).toLowerCase();
if (backendId === "acpx") {
const localPath = path.resolve(process.cwd(), "extensions/acpx");
if (existsSync(localPath)) {
return `openclaw plugins install ${localPath}`;
}
return "openclaw plugins install @openclaw/acpx";
}
return `Install and enable the plugin that provides ACP backend "${backendId}".`;
}
export function formatRuntimeOptionsText(options: AcpSessionRuntimeOptions): string {
const extras = options.backendExtras
? Object.entries(options.backendExtras)
.toSorted(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}=${value}`)
.join(", ")
: "";
const parts = [
options.runtimeMode ? `runtimeMode=${options.runtimeMode}` : null,
options.model ? `model=${options.model}` : null,
options.cwd ? `cwd=${options.cwd}` : null,
options.permissionProfile ? `permissionProfile=${options.permissionProfile}` : null,
typeof options.timeoutSeconds === "number" ? `timeoutSeconds=${options.timeoutSeconds}` : null,
extras ? `extras={${extras}}` : null,
].filter(Boolean) as string[];
if (parts.length === 0) {
return "(none)";
}
return parts.join(", ");
}
export function formatAcpCapabilitiesText(controls: string[]): string {
if (controls.length === 0) {
return "(none)";
}
return controls.toSorted().join(", ");
}
export function resolveCommandRequestId(params: HandleCommandsParams): string {
const value =
params.ctx.MessageSidFull ??
params.ctx.MessageSid ??
params.ctx.MessageSidFirst ??
params.ctx.MessageSidLast;
if (typeof value === "string" && value.trim()) {
return value.trim();
}
if (typeof value === "number" || typeof value === "bigint") {
return String(value);
}
return randomUUID();
}
export function collectAcpErrorText(params: {
error: unknown;
fallbackCode: AcpRuntimeError["code"];
fallbackMessage: string;
}): string {
return toAcpRuntimeErrorText({
error: params.error,
fallbackCode: params.fallbackCode,
fallbackMessage: params.fallbackMessage,
});
}
export async function withAcpCommandErrorBoundary<T>(params: {
run: () => Promise<T>;
fallbackCode: AcpRuntimeError["code"];
fallbackMessage: string;
onSuccess: (value: T) => CommandHandlerResult;
}): Promise<CommandHandlerResult> {
try {
const result = await params.run();
return params.onSuccess(result);
} catch (error) {
return stopWithText(
collectAcpErrorText({
error,
fallbackCode: params.fallbackCode,
fallbackMessage: params.fallbackMessage,
}),
);
}
}

View File

@@ -0,0 +1,90 @@
import { callGateway } from "../../../gateway/call.js";
import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js";
import { resolveRequesterSessionKey } from "../commands-subagents/shared.js";
import type { HandleCommandsParams } from "../commands-types.js";
import { resolveAcpCommandBindingContext } from "./context.js";
import { SESSION_ID_RE } from "./shared.js";
async function resolveSessionKeyByToken(token: string): Promise<string | null> {
const trimmed = token.trim();
if (!trimmed) {
return null;
}
const attempts: Array<Record<string, string>> = [{ key: trimmed }];
if (SESSION_ID_RE.test(trimmed)) {
attempts.push({ sessionId: trimmed });
}
attempts.push({ label: trimmed });
for (const params of attempts) {
try {
const resolved = await callGateway<{ key?: string }>({
method: "sessions.resolve",
params,
timeoutMs: 8_000,
});
const key = typeof resolved?.key === "string" ? resolved.key.trim() : "";
if (key) {
return key;
}
} catch {
// Try next resolver strategy.
}
}
return null;
}
export function resolveBoundAcpThreadSessionKey(params: HandleCommandsParams): string | undefined {
const bindingContext = resolveAcpCommandBindingContext(params);
if (!bindingContext.channel || !bindingContext.conversationId) {
return undefined;
}
const binding = getSessionBindingService().resolveByConversation({
channel: bindingContext.channel,
accountId: bindingContext.accountId,
conversationId: bindingContext.conversationId,
});
if (!binding || binding.targetKind !== "session") {
return undefined;
}
return binding.targetSessionKey.trim() || undefined;
}
export async function resolveAcpTargetSessionKey(params: {
commandParams: HandleCommandsParams;
token?: string;
}): Promise<{ ok: true; sessionKey: string } | { ok: false; error: string }> {
const token = params.token?.trim() || "";
if (token) {
const resolved = await resolveSessionKeyByToken(token);
if (!resolved) {
return {
ok: false,
error: `Unable to resolve session target: ${token}`,
};
}
return { ok: true, sessionKey: resolved };
}
const threadBound = resolveBoundAcpThreadSessionKey(params.commandParams);
if (threadBound) {
return {
ok: true,
sessionKey: threadBound,
};
}
const fallback = resolveRequesterSessionKey(params.commandParams, {
preferCommandTarget: true,
});
if (!fallback) {
return {
ok: false,
error: "Missing session key.",
};
}
return {
ok: true,
sessionKey: fallback,
};
}