| import { randomUUID } from "node:crypto"; |
| 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 { 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 { resolveAcpInstallCommandHint, resolveConfiguredAcpBackendId } from "./install-hints.js"; |
|
|
| export const COMMAND = "/acp"; |
| export const ACP_SPAWN_USAGE = |
| "Usage: /acp spawn [harness-id] [--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 { SESSION_ID_RE } from "../../../sessions/session-id.js"; |
|
|
| 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; |
| }; |
|
|
| const ACP_UNICODE_DASH_PREFIX_RE = |
| /^[\u2010\u2011\u2012\u2013\u2014\u2015\u2212\uFE58\uFE63\uFF0D]+/; |
|
|
| 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 = normalizeAcpOptionToken(params.tokens[params.index] ?? ""); |
| if (token === params.flag) { |
| const nextValue = normalizeAcpOptionToken(params.tokens[params.index + 1] ?? ""); |
| 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 normalizeAcpOptionToken(raw: string): string { |
| const token = raw.trim(); |
| if (!token || token.startsWith("--")) { |
| return token; |
| } |
| const dashPrefix = token.match(ACP_UNICODE_DASH_PREFIX_RE)?.[0]; |
| if (!dashPrefix) { |
| return token; |
| } |
| return `--${token.slice(dashPrefix.length)}`; |
| } |
|
|
| 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 } { |
| const normalizedTokens = tokens.map((token) => normalizeAcpOptionToken(token)); |
| 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 < normalizedTokens.length; ) { |
| const token = normalizedTokens[i] ?? ""; |
|
|
| const modeOption = readOptionValue({ tokens: normalizedTokens, 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: normalizedTokens, |
| 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: normalizedTokens, 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: normalizedTokens, 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 harness id is required. Pass an ACP harness id (for example codex) 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 } { |
| const normalizedTokens = tokens.map((token) => normalizeAcpOptionToken(token)); |
| let sessionToken: string | undefined; |
| const instructionTokens: string[] = []; |
|
|
| for (let i = 0; i < normalizedTokens.length; ) { |
| const sessionOption = readOptionValue({ |
| tokens: normalizedTokens, |
| 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 [harness-id] [--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:", |
| "- /acp spawn harness-id is an ACP runtime harness alias (for example codex), not an OpenClaw agents.list id.", |
| "- /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 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, |
| }), |
| ); |
| } |
| } |
|
|