@gguf/claw
Version:
Multi-channel AI gateway with extensible messaging integrations
1,269 lines (1,254 loc) • 42.6 kB
JavaScript
import "./paths-B4BZAPZh.js";
import { B as theme, S as shortenHomePath, y as resolveUserPath } from "./utils-CP9YLh6M.js";
import "./registry-B-j4DRfe.js";
import { f as defaultRuntime } from "./subsystem-BCQGGxdd.js";
import "./exec-DYqRzFbo.js";
import "./agent-scope-BnZW9Gh2.js";
import "./model-selection-CqaTAlhy.js";
import "./github-copilot-token-D2zp6kMZ.js";
import "./boolean-BsqeuxE6.js";
import "./env-VriqyjXT.js";
import { h as GATEWAY_CLIENT_NAMES, m as GATEWAY_CLIENT_MODES } from "./message-channel-Bena1Tzd.js";
import { V as VERSION, i as loadConfig } from "./config-PQiujvsf.js";
import "./manifest-registry-4k4vkhPS.js";
import "./tailscale-C3JsBEiY.js";
import { i as resolveGatewayAuth } from "./auth-DZSWPd8D.js";
import { t as GatewayClient } from "./client-BdSkEtCd.js";
import { t as buildGatewayConnectionDetails } from "./call-BCz_8mqq.js";
import "./pairing-token-B-_eiWlR.js";
import { t as formatDocsLink } from "./links-BVCMOGeE.js";
import { t as isMainModule } from "./is-main-CSCRjuD_.js";
import { t as ensureOpenClawCliOnPath } from "./path-env-BufaTTaN.js";
import { t as DANGEROUS_ACP_TOOLS } from "./dangerous-tools-DyngmY3-.js";
import { n as inheritOptionFromParent } from "./command-options-ByE5SNIE.js";
import { fileURLToPath } from "node:url";
import fs from "node:fs";
import path from "node:path";
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import * as readline$1 from "node:readline";
import { Readable, Writable } from "node:stream";
import { AgentSideConnection, ClientSideConnection, PROTOCOL_VERSION, ndJsonStream } from "@agentclientprotocol/sdk";
//#region src/acp/client.ts
const SAFE_AUTO_APPROVE_KINDS = new Set(["read", "search"]);
function asRecord(value) {
return value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
}
function readFirstStringValue(source, keys) {
if (!source) return;
for (const key of keys) {
const value = source[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
}
function normalizeToolName(value) {
const normalized = value.trim().toLowerCase();
if (!normalized) return;
return normalized;
}
function parseToolNameFromTitle(title) {
if (!title) return;
const head = title.split(":", 1)[0]?.trim();
if (!head || !/^[a-zA-Z0-9._-]+$/.test(head)) return;
return normalizeToolName(head);
}
function resolveToolKindForPermission(params, toolName) {
const toolCall = params.toolCall;
const kindRaw = typeof toolCall?.kind === "string" ? toolCall.kind.trim().toLowerCase() : "";
if (kindRaw) return kindRaw;
const name = toolName ?? parseToolNameFromTitle(typeof toolCall?.title === "string" ? toolCall.title : void 0);
if (!name) return;
const normalized = name.toLowerCase();
const hasToken = (token) => {
return new RegExp(`(?:^|[._-])${token}(?:$|[._-])`).test(normalized);
};
if (normalized === "read" || hasToken("read")) return "read";
if (normalized === "search" || hasToken("search") || hasToken("find")) return "search";
if (normalized.includes("fetch") || normalized.includes("http")) return "fetch";
if (normalized.includes("write") || normalized.includes("edit") || normalized.includes("patch")) return "edit";
if (normalized.includes("delete") || normalized.includes("remove")) return "delete";
if (normalized.includes("move") || normalized.includes("rename")) return "move";
if (normalized.includes("exec") || normalized.includes("run") || normalized.includes("bash")) return "execute";
return "other";
}
function resolveToolNameForPermission(params) {
const toolCall = params.toolCall;
const toolMeta = asRecord(toolCall?._meta);
const rawInput = asRecord(toolCall?.rawInput);
const fromMeta = readFirstStringValue(toolMeta, [
"toolName",
"tool_name",
"name"
]);
const fromRawInput = readFirstStringValue(rawInput, [
"tool",
"toolName",
"tool_name",
"name"
]);
const fromTitle = parseToolNameFromTitle(toolCall?.title);
return normalizeToolName(fromMeta ?? fromRawInput ?? fromTitle ?? "");
}
function pickOption(options, kinds) {
for (const kind of kinds) {
const match = options.find((option) => option.kind === kind);
if (match) return match;
}
}
function selectedPermission(optionId) {
return { outcome: {
outcome: "selected",
optionId
} };
}
function cancelledPermission() {
return { outcome: { outcome: "cancelled" } };
}
function promptUserPermission(toolName, toolTitle) {
if (!process.stdin.isTTY || !process.stderr.isTTY) {
console.error(`[permission denied] ${toolName ?? "unknown"}: non-interactive terminal`);
return Promise.resolve(false);
}
return new Promise((resolve) => {
let settled = false;
const rl = readline$1.createInterface({
input: process.stdin,
output: process.stderr
});
const finish = (approved) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
rl.close();
resolve(approved);
};
const timeout = setTimeout(() => {
console.error(`\n[permission timeout] denied: ${toolName ?? "unknown"}`);
finish(false);
}, 3e4);
const label = toolTitle ? toolName ? `${toolTitle} (${toolName})` : toolTitle : toolName ?? "unknown tool";
rl.question(`\n[permission] Allow "${label}"? (y/N) `, (answer) => {
const approved = answer.trim().toLowerCase() === "y";
console.error(`[permission ${approved ? "approved" : "denied"}] ${toolName ?? "unknown"}`);
finish(approved);
});
});
}
async function resolvePermissionRequest(params, deps = {}) {
const log = deps.log ?? ((line) => console.error(line));
const prompt = deps.prompt ?? promptUserPermission;
const options = params.options ?? [];
const toolTitle = params.toolCall?.title ?? "tool";
const toolName = resolveToolNameForPermission(params);
const toolKind = resolveToolKindForPermission(params, toolName);
if (options.length === 0) {
log(`[permission cancelled] ${toolName ?? "unknown"}: no options available`);
return cancelledPermission();
}
const allowOption = pickOption(options, ["allow_once", "allow_always"]);
const rejectOption = pickOption(options, ["reject_once", "reject_always"]);
const isSafeKind = Boolean(toolKind && SAFE_AUTO_APPROVE_KINDS.has(toolKind));
if (!(!toolName || !isSafeKind || DANGEROUS_ACP_TOOLS.has(toolName))) {
const option = allowOption ?? options[0];
if (!option) {
log(`[permission cancelled] ${toolName}: no selectable options`);
return cancelledPermission();
}
log(`[permission auto-approved] ${toolName} (${toolKind ?? "unknown"})`);
return selectedPermission(option.optionId);
}
log(`\n[permission requested] ${toolTitle}${toolName ? ` (${toolName})` : ""}${toolKind ? ` [${toolKind}]` : ""}`);
const approved = await prompt(toolName, toolTitle);
if (approved && allowOption) return selectedPermission(allowOption.optionId);
if (!approved && rejectOption) return selectedPermission(rejectOption.optionId);
log(`[permission cancelled] ${toolName ?? "unknown"}: missing ${approved ? "allow" : "reject"} option`);
return cancelledPermission();
}
function toArgs(value) {
if (!value) return [];
return Array.isArray(value) ? value : [value];
}
function buildServerArgs(opts) {
const args = ["acp", ...toArgs(opts.serverArgs)];
if (opts.serverVerbose && !args.includes("--verbose") && !args.includes("-v")) args.push("--verbose");
return args;
}
function resolveSelfEntryPath() {
try {
const here = fileURLToPath(import.meta.url);
const candidate = path.resolve(path.dirname(here), "..", "entry.js");
if (fs.existsSync(candidate)) return candidate;
} catch {}
const argv1 = process.argv[1]?.trim();
if (argv1) return path.isAbsolute(argv1) ? argv1 : path.resolve(process.cwd(), argv1);
return null;
}
function printSessionUpdate(notification) {
const update = notification.update;
if (!("sessionUpdate" in update)) return;
switch (update.sessionUpdate) {
case "agent_message_chunk":
if (update.content?.type === "text") process.stdout.write(update.content.text);
return;
case "tool_call":
console.log(`\n[tool] ${update.title} (${update.status})`);
return;
case "tool_call_update":
if (update.status) console.log(`[tool update] ${update.toolCallId}: ${update.status}`);
return;
case "available_commands_update": {
const names = update.availableCommands?.map((cmd) => `/${cmd.name}`).join(" ");
if (names) console.log(`\n[commands] ${names}`);
return;
}
default: return;
}
}
async function createAcpClient(opts = {}) {
const cwd = opts.cwd ?? process.cwd();
const log = Boolean(opts.verbose) ? (msg) => console.error(`[acp-client] ${msg}`) : () => {};
ensureOpenClawCliOnPath();
const serverArgs = buildServerArgs(opts);
const entryPath = resolveSelfEntryPath();
const serverCommand = opts.serverCommand ?? (entryPath ? process.execPath : "openclaw");
const effectiveArgs = opts.serverCommand || !entryPath ? serverArgs : [entryPath, ...serverArgs];
log(`spawning: ${serverCommand} ${effectiveArgs.join(" ")}`);
const agent = spawn(serverCommand, effectiveArgs, {
stdio: [
"pipe",
"pipe",
"inherit"
],
cwd
});
if (!agent.stdin || !agent.stdout) throw new Error("Failed to create ACP stdio pipes");
const client = new ClientSideConnection(() => ({
sessionUpdate: async (params) => {
printSessionUpdate(params);
},
requestPermission: async (params) => {
return resolvePermissionRequest(params);
}
}), ndJsonStream(Writable.toWeb(agent.stdin), Readable.toWeb(agent.stdout)));
log("initializing");
await client.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: {
fs: {
readTextFile: true,
writeTextFile: true
},
terminal: true
},
clientInfo: {
name: "openclaw-acp-client",
version: "1.0.0"
}
});
log("creating session");
return {
client,
agent,
sessionId: (await client.newSession({
cwd,
mcpServers: []
})).sessionId
};
}
async function runAcpClientInteractive(opts = {}) {
const { client, agent, sessionId } = await createAcpClient(opts);
const rl = readline$1.createInterface({
input: process.stdin,
output: process.stdout
});
console.log("OpenClaw ACP client");
console.log(`Session: ${sessionId}`);
console.log("Type a prompt, or \"exit\" to quit.\n");
const prompt = () => {
rl.question("> ", async (input) => {
const text = input.trim();
if (!text) {
prompt();
return;
}
if (text === "exit" || text === "quit") {
agent.kill();
rl.close();
process.exit(0);
}
try {
const response = await client.prompt({
sessionId,
prompt: [{
type: "text",
text
}]
});
console.log(`\n[${response.stopReason}]\n`);
} catch (err) {
console.error(`\n[error] ${String(err)}\n`);
}
prompt();
});
};
prompt();
agent.on("exit", (code) => {
console.log(`\nAgent exited with code ${code ?? 0}`);
rl.close();
process.exit(code ?? 0);
});
}
//#endregion
//#region src/acp/secret-file.ts
function readSecretFromFile(filePath, label) {
const resolvedPath = resolveUserPath(filePath.trim());
if (!resolvedPath) throw new Error(`${label} file path is empty.`);
let raw = "";
try {
raw = fs.readFileSync(resolvedPath, "utf8");
} catch (err) {
throw new Error(`Failed to read ${label} file at ${resolvedPath}: ${String(err)}`, { cause: err });
}
const secret = raw.trim();
if (!secret) throw new Error(`${label} file at ${resolvedPath} is empty.`);
return secret;
}
//#endregion
//#region src/infra/fixed-window-rate-limit.ts
function createFixedWindowRateLimiter(params) {
const maxRequests = Math.max(1, Math.floor(params.maxRequests));
const windowMs = Math.max(1, Math.floor(params.windowMs));
const now = params.now ?? Date.now;
let count = 0;
let windowStartMs = 0;
return {
consume() {
const nowMs = now();
if (nowMs - windowStartMs >= windowMs) {
windowStartMs = nowMs;
count = 0;
}
if (count >= maxRequests) return {
allowed: false,
retryAfterMs: Math.max(0, windowStartMs + windowMs - nowMs),
remaining: 0
};
count += 1;
return {
allowed: true,
retryAfterMs: 0,
remaining: Math.max(0, maxRequests - count)
};
},
reset() {
count = 0;
windowStartMs = 0;
}
};
}
//#endregion
//#region src/acp/commands.ts
function getAvailableCommands() {
return [
{
name: "help",
description: "Show help and common commands."
},
{
name: "commands",
description: "List available commands."
},
{
name: "status",
description: "Show current status."
},
{
name: "context",
description: "Explain context usage (list|detail|json).",
input: { hint: "list | detail | json" }
},
{
name: "whoami",
description: "Show sender id (alias: /id)."
},
{
name: "id",
description: "Alias for /whoami."
},
{
name: "subagents",
description: "List or manage sub-agents."
},
{
name: "config",
description: "Read or write config (owner-only)."
},
{
name: "debug",
description: "Set runtime-only overrides (owner-only)."
},
{
name: "usage",
description: "Toggle usage footer (off|tokens|full)."
},
{
name: "stop",
description: "Stop the current run."
},
{
name: "restart",
description: "Restart the gateway (if enabled)."
},
{
name: "dock-telegram",
description: "Route replies to Telegram."
},
{
name: "dock-discord",
description: "Route replies to Discord."
},
{
name: "dock-slack",
description: "Route replies to Slack."
},
{
name: "activation",
description: "Set group activation (mention|always)."
},
{
name: "send",
description: "Set send mode (on|off|inherit)."
},
{
name: "reset",
description: "Reset the session (/new)."
},
{
name: "new",
description: "Reset the session (/reset)."
},
{
name: "think",
description: "Set thinking level (off|minimal|low|medium|high|xhigh)."
},
{
name: "verbose",
description: "Set verbose mode (on|full|off)."
},
{
name: "reasoning",
description: "Toggle reasoning output (on|off|stream)."
},
{
name: "elevated",
description: "Toggle elevated mode (on|off)."
},
{
name: "model",
description: "Select a model (list|status|<name>)."
},
{
name: "queue",
description: "Adjust queue mode and options."
},
{
name: "bash",
description: "Run a host command (if enabled)."
},
{
name: "compact",
description: "Compact the session history."
}
];
}
//#endregion
//#region src/acp/event-mapper.ts
function extractTextFromPrompt(prompt, maxBytes) {
const parts = [];
let totalBytes = 0;
for (const block of prompt) {
let blockText;
if (block.type === "text") blockText = block.text;
else if (block.type === "resource") {
const resource = block.resource;
if (resource?.text) blockText = resource.text;
} else if (block.type === "resource_link") {
const title = block.title ? ` (${block.title})` : "";
const uri = block.uri ?? "";
blockText = uri ? `[Resource link${title}] ${uri}` : `[Resource link${title}]`;
}
if (blockText !== void 0) {
if (maxBytes !== void 0) {
const separatorBytes = parts.length > 0 ? 1 : 0;
totalBytes += separatorBytes + Buffer.byteLength(blockText, "utf-8");
if (totalBytes > maxBytes) throw new Error(`Prompt exceeds maximum allowed size of ${maxBytes} bytes`);
}
parts.push(blockText);
}
}
return parts.join("\n");
}
function extractAttachmentsFromPrompt(prompt) {
const attachments = [];
for (const block of prompt) {
if (block.type !== "image") continue;
const image = block;
if (!image.data || !image.mimeType) continue;
attachments.push({
type: "image",
mimeType: image.mimeType,
content: image.data
});
}
return attachments;
}
function formatToolTitle(name, args) {
const base = name ?? "tool";
if (!args || Object.keys(args).length === 0) return base;
return `${base}: ${Object.entries(args).map(([key, value]) => {
const raw = typeof value === "string" ? value : JSON.stringify(value);
return `${key}: ${raw.length > 100 ? `${raw.slice(0, 100)}...` : raw}`;
}).join(", ")}`;
}
function inferToolKind(name) {
if (!name) return "other";
const normalized = name.toLowerCase();
if (normalized.includes("read")) return "read";
if (normalized.includes("write") || normalized.includes("edit")) return "edit";
if (normalized.includes("delete") || normalized.includes("remove")) return "delete";
if (normalized.includes("move") || normalized.includes("rename")) return "move";
if (normalized.includes("search") || normalized.includes("find")) return "search";
if (normalized.includes("exec") || normalized.includes("run") || normalized.includes("bash")) return "execute";
if (normalized.includes("fetch") || normalized.includes("http")) return "fetch";
return "other";
}
//#endregion
//#region src/acp/meta.ts
function readString(meta, keys) {
if (!meta) return;
for (const key of keys) {
const value = meta[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
}
function readBool(meta, keys) {
if (!meta) return;
for (const key of keys) {
const value = meta[key];
if (typeof value === "boolean") return value;
}
}
function readNumber(meta, keys) {
if (!meta) return;
for (const key of keys) {
const value = meta[key];
if (typeof value === "number" && Number.isFinite(value)) return value;
}
}
//#endregion
//#region src/acp/session-mapper.ts
function parseSessionMeta(meta) {
if (!meta || typeof meta !== "object") return {};
const record = meta;
return {
sessionKey: readString(record, [
"sessionKey",
"session",
"key"
]),
sessionLabel: readString(record, ["sessionLabel", "label"]),
resetSession: readBool(record, ["resetSession", "reset"]),
requireExisting: readBool(record, ["requireExistingSession", "requireExisting"]),
prefixCwd: readBool(record, ["prefixCwd"])
};
}
async function resolveSessionKey(params) {
const requestedLabel = params.meta.sessionLabel ?? params.opts.defaultSessionLabel;
const requestedKey = params.meta.sessionKey ?? params.opts.defaultSessionKey;
const requireExisting = params.meta.requireExisting ?? params.opts.requireExistingSession ?? false;
if (params.meta.sessionLabel) {
const resolved = await params.gateway.request("sessions.resolve", { label: params.meta.sessionLabel });
if (!resolved?.key) throw new Error(`Unable to resolve session label: ${params.meta.sessionLabel}`);
return resolved.key;
}
if (params.meta.sessionKey) {
if (!requireExisting) return params.meta.sessionKey;
const resolved = await params.gateway.request("sessions.resolve", { key: params.meta.sessionKey });
if (!resolved?.key) throw new Error(`Session key not found: ${params.meta.sessionKey}`);
return resolved.key;
}
if (requestedLabel) {
const resolved = await params.gateway.request("sessions.resolve", { label: requestedLabel });
if (!resolved?.key) throw new Error(`Unable to resolve session label: ${requestedLabel}`);
return resolved.key;
}
if (requestedKey) {
if (!requireExisting) return requestedKey;
const resolved = await params.gateway.request("sessions.resolve", { key: requestedKey });
if (!resolved?.key) throw new Error(`Session key not found: ${requestedKey}`);
return resolved.key;
}
return params.fallbackKey;
}
async function resetSessionIfNeeded(params) {
if (!(params.meta.resetSession ?? params.opts.resetSession ?? false)) return;
await params.gateway.request("sessions.reset", { key: params.sessionKey });
}
//#endregion
//#region src/acp/session.ts
const DEFAULT_MAX_SESSIONS = 5e3;
const DEFAULT_IDLE_TTL_MS = 1440 * 60 * 1e3;
function createInMemorySessionStore(options = {}) {
const maxSessions = Math.max(1, options.maxSessions ?? DEFAULT_MAX_SESSIONS);
const idleTtlMs = Math.max(1e3, options.idleTtlMs ?? DEFAULT_IDLE_TTL_MS);
const now = options.now ?? Date.now;
const sessions = /* @__PURE__ */ new Map();
const runIdToSessionId = /* @__PURE__ */ new Map();
const touchSession = (session, nowMs) => {
session.lastTouchedAt = nowMs;
};
const removeSession = (sessionId) => {
const session = sessions.get(sessionId);
if (!session) return false;
if (session.activeRunId) runIdToSessionId.delete(session.activeRunId);
session.abortController?.abort();
sessions.delete(sessionId);
return true;
};
const reapIdleSessions = (nowMs) => {
const idleBefore = nowMs - idleTtlMs;
for (const [sessionId, session] of sessions.entries()) {
if (session.activeRunId || session.abortController) continue;
if (session.lastTouchedAt > idleBefore) continue;
removeSession(sessionId);
}
};
const evictOldestIdleSession = () => {
let oldestSessionId = null;
let oldestLastTouchedAt = Number.POSITIVE_INFINITY;
for (const [sessionId, session] of sessions.entries()) {
if (session.activeRunId || session.abortController) continue;
if (session.lastTouchedAt >= oldestLastTouchedAt) continue;
oldestLastTouchedAt = session.lastTouchedAt;
oldestSessionId = sessionId;
}
if (!oldestSessionId) return false;
return removeSession(oldestSessionId);
};
const createSession = (params) => {
const nowMs = now();
const sessionId = params.sessionId ?? randomUUID();
const existingSession = sessions.get(sessionId);
if (existingSession) {
existingSession.sessionKey = params.sessionKey;
existingSession.cwd = params.cwd;
touchSession(existingSession, nowMs);
return existingSession;
}
reapIdleSessions(nowMs);
if (sessions.size >= maxSessions && !evictOldestIdleSession()) throw new Error(`ACP session limit reached (max ${maxSessions}). Close idle ACP clients and retry.`);
const session = {
sessionId,
sessionKey: params.sessionKey,
cwd: params.cwd,
createdAt: nowMs,
lastTouchedAt: nowMs,
abortController: null,
activeRunId: null
};
sessions.set(sessionId, session);
return session;
};
const hasSession = (sessionId) => sessions.has(sessionId);
const getSession = (sessionId) => {
const session = sessions.get(sessionId);
if (session) touchSession(session, now());
return session;
};
const getSessionByRunId = (runId) => {
const sessionId = runIdToSessionId.get(runId);
if (!sessionId) return;
const session = sessions.get(sessionId);
if (session) touchSession(session, now());
return session;
};
const setActiveRun = (sessionId, runId, abortController) => {
const session = sessions.get(sessionId);
if (!session) return;
session.activeRunId = runId;
session.abortController = abortController;
runIdToSessionId.set(runId, sessionId);
touchSession(session, now());
};
const clearActiveRun = (sessionId) => {
const session = sessions.get(sessionId);
if (!session) return;
if (session.activeRunId) runIdToSessionId.delete(session.activeRunId);
session.activeRunId = null;
session.abortController = null;
touchSession(session, now());
};
const cancelActiveRun = (sessionId) => {
const session = sessions.get(sessionId);
if (!session?.abortController) return false;
session.abortController.abort();
if (session.activeRunId) runIdToSessionId.delete(session.activeRunId);
session.abortController = null;
session.activeRunId = null;
touchSession(session, now());
return true;
};
const clearAllSessionsForTest = () => {
for (const session of sessions.values()) session.abortController?.abort();
sessions.clear();
runIdToSessionId.clear();
};
return {
createSession,
hasSession,
getSession,
getSessionByRunId,
setActiveRun,
clearActiveRun,
cancelActiveRun,
clearAllSessionsForTest
};
}
const defaultAcpSessionStore = createInMemorySessionStore();
//#endregion
//#region src/acp/types.ts
const ACP_AGENT_INFO = {
name: "openclaw-acp",
title: "OpenClaw ACP Gateway",
version: VERSION
};
//#endregion
//#region src/acp/translator.ts
const MAX_PROMPT_BYTES = 2 * 1024 * 1024;
const SESSION_CREATE_RATE_LIMIT_DEFAULT_MAX_REQUESTS = 120;
const SESSION_CREATE_RATE_LIMIT_DEFAULT_WINDOW_MS = 1e4;
var AcpGatewayAgent = class {
constructor(connection, gateway, opts = {}) {
this.pendingPrompts = /* @__PURE__ */ new Map();
this.connection = connection;
this.gateway = gateway;
this.opts = opts;
this.log = opts.verbose ? (msg) => process.stderr.write(`[acp] ${msg}\n`) : () => {};
this.sessionStore = opts.sessionStore ?? defaultAcpSessionStore;
this.sessionCreateRateLimiter = createFixedWindowRateLimiter({
maxRequests: Math.max(1, opts.sessionCreateRateLimit?.maxRequests ?? SESSION_CREATE_RATE_LIMIT_DEFAULT_MAX_REQUESTS),
windowMs: Math.max(1e3, opts.sessionCreateRateLimit?.windowMs ?? SESSION_CREATE_RATE_LIMIT_DEFAULT_WINDOW_MS)
});
}
start() {
this.log("ready");
}
handleGatewayReconnect() {
this.log("gateway reconnected");
}
handleGatewayDisconnect(reason) {
this.log(`gateway disconnected: ${reason}`);
for (const pending of this.pendingPrompts.values()) {
pending.reject(/* @__PURE__ */ new Error(`Gateway disconnected: ${reason}`));
this.sessionStore.clearActiveRun(pending.sessionId);
}
this.pendingPrompts.clear();
}
async handleGatewayEvent(evt) {
if (evt.event === "chat") {
await this.handleChatEvent(evt);
return;
}
if (evt.event === "agent") await this.handleAgentEvent(evt);
}
async initialize(_params) {
return {
protocolVersion: PROTOCOL_VERSION,
agentCapabilities: {
loadSession: true,
promptCapabilities: {
image: true,
audio: false,
embeddedContext: true
},
mcpCapabilities: {
http: false,
sse: false
},
sessionCapabilities: { list: {} }
},
agentInfo: ACP_AGENT_INFO,
authMethods: []
};
}
async newSession(params) {
if (params.mcpServers.length > 0) this.log(`ignoring ${params.mcpServers.length} MCP servers`);
this.enforceSessionCreateRateLimit("newSession");
const sessionId = randomUUID();
const meta = parseSessionMeta(params._meta);
const sessionKey = await resolveSessionKey({
meta,
fallbackKey: `acp:${sessionId}`,
gateway: this.gateway,
opts: this.opts
});
await resetSessionIfNeeded({
meta,
sessionKey,
gateway: this.gateway,
opts: this.opts
});
const session = this.sessionStore.createSession({
sessionId,
sessionKey,
cwd: params.cwd
});
this.log(`newSession: ${session.sessionId} -> ${session.sessionKey}`);
await this.sendAvailableCommands(session.sessionId);
return { sessionId: session.sessionId };
}
async loadSession(params) {
if (params.mcpServers.length > 0) this.log(`ignoring ${params.mcpServers.length} MCP servers`);
if (!this.sessionStore.hasSession(params.sessionId)) this.enforceSessionCreateRateLimit("loadSession");
const meta = parseSessionMeta(params._meta);
const sessionKey = await resolveSessionKey({
meta,
fallbackKey: params.sessionId,
gateway: this.gateway,
opts: this.opts
});
await resetSessionIfNeeded({
meta,
sessionKey,
gateway: this.gateway,
opts: this.opts
});
const session = this.sessionStore.createSession({
sessionId: params.sessionId,
sessionKey,
cwd: params.cwd
});
this.log(`loadSession: ${session.sessionId} -> ${session.sessionKey}`);
await this.sendAvailableCommands(session.sessionId);
return {};
}
async unstable_listSessions(params) {
const limit = readNumber(params._meta, ["limit"]) ?? 100;
const result = await this.gateway.request("sessions.list", { limit });
const cwd = params.cwd ?? process.cwd();
return {
sessions: result.sessions.map((session) => ({
sessionId: session.key,
cwd,
title: session.displayName ?? session.label ?? session.key,
updatedAt: session.updatedAt ? new Date(session.updatedAt).toISOString() : void 0,
_meta: {
sessionKey: session.key,
kind: session.kind,
channel: session.channel
}
})),
nextCursor: null
};
}
async authenticate(_params) {
return {};
}
async setSessionMode(params) {
const session = this.sessionStore.getSession(params.sessionId);
if (!session) throw new Error(`Session ${params.sessionId} not found`);
if (!params.modeId) return {};
try {
await this.gateway.request("sessions.patch", {
key: session.sessionKey,
thinkingLevel: params.modeId
});
this.log(`setSessionMode: ${session.sessionId} -> ${params.modeId}`);
} catch (err) {
this.log(`setSessionMode error: ${String(err)}`);
}
return {};
}
async prompt(params) {
const session = this.sessionStore.getSession(params.sessionId);
if (!session) throw new Error(`Session ${params.sessionId} not found`);
if (session.abortController) this.sessionStore.cancelActiveRun(params.sessionId);
const meta = parseSessionMeta(params._meta);
const userText = extractTextFromPrompt(params.prompt, MAX_PROMPT_BYTES);
const attachments = extractAttachmentsFromPrompt(params.prompt);
const prefixCwd = meta.prefixCwd ?? this.opts.prefixCwd ?? true;
const displayCwd = shortenHomePath(session.cwd);
const message = prefixCwd ? `[Working directory: ${displayCwd}]\n\n${userText}` : userText;
if (Buffer.byteLength(message, "utf-8") > MAX_PROMPT_BYTES) throw new Error(`Prompt exceeds maximum allowed size of ${MAX_PROMPT_BYTES} bytes`);
const abortController = new AbortController();
const runId = randomUUID();
this.sessionStore.setActiveRun(params.sessionId, runId, abortController);
return new Promise((resolve, reject) => {
this.pendingPrompts.set(params.sessionId, {
sessionId: params.sessionId,
sessionKey: session.sessionKey,
idempotencyKey: runId,
resolve,
reject
});
this.gateway.request("chat.send", {
sessionKey: session.sessionKey,
message,
attachments: attachments.length > 0 ? attachments : void 0,
idempotencyKey: runId,
thinking: readString(params._meta, ["thinking", "thinkingLevel"]),
deliver: readBool(params._meta, ["deliver"]),
timeoutMs: readNumber(params._meta, ["timeoutMs"])
}, { expectFinal: true }).catch((err) => {
this.pendingPrompts.delete(params.sessionId);
this.sessionStore.clearActiveRun(params.sessionId);
reject(err instanceof Error ? err : new Error(String(err)));
});
});
}
async cancel(params) {
const session = this.sessionStore.getSession(params.sessionId);
if (!session) return;
this.sessionStore.cancelActiveRun(params.sessionId);
try {
await this.gateway.request("chat.abort", { sessionKey: session.sessionKey });
} catch (err) {
this.log(`cancel error: ${String(err)}`);
}
const pending = this.pendingPrompts.get(params.sessionId);
if (pending) {
this.pendingPrompts.delete(params.sessionId);
pending.resolve({ stopReason: "cancelled" });
}
}
async handleAgentEvent(evt) {
const payload = evt.payload;
if (!payload) return;
const stream = payload.stream;
const data = payload.data;
const sessionKey = payload.sessionKey;
if (!stream || !data || !sessionKey) return;
if (stream !== "tool") return;
const phase = data.phase;
const name = data.name;
const toolCallId = data.toolCallId;
if (!toolCallId) return;
const pending = this.findPendingBySessionKey(sessionKey);
if (!pending) return;
if (phase === "start") {
if (!pending.toolCalls) pending.toolCalls = /* @__PURE__ */ new Set();
if (pending.toolCalls.has(toolCallId)) return;
pending.toolCalls.add(toolCallId);
const args = data.args;
await this.connection.sessionUpdate({
sessionId: pending.sessionId,
update: {
sessionUpdate: "tool_call",
toolCallId,
title: formatToolTitle(name, args),
status: "in_progress",
rawInput: args,
kind: inferToolKind(name)
}
});
return;
}
if (phase === "result") {
const isError = Boolean(data.isError);
await this.connection.sessionUpdate({
sessionId: pending.sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId,
status: isError ? "failed" : "completed",
rawOutput: data.result
}
});
}
}
async handleChatEvent(evt) {
const payload = evt.payload;
if (!payload) return;
const sessionKey = payload.sessionKey;
const state = payload.state;
const runId = payload.runId;
const messageData = payload.message;
if (!sessionKey || !state) return;
const pending = this.findPendingBySessionKey(sessionKey);
if (!pending) return;
if (runId && pending.idempotencyKey !== runId) return;
if (state === "delta" && messageData) {
await this.handleDeltaEvent(pending.sessionId, messageData);
return;
}
if (state === "final") {
this.finishPrompt(pending.sessionId, pending, "end_turn");
return;
}
if (state === "aborted") {
this.finishPrompt(pending.sessionId, pending, "cancelled");
return;
}
if (state === "error") this.finishPrompt(pending.sessionId, pending, "refusal");
}
async handleDeltaEvent(sessionId, messageData) {
const fullText = messageData.content?.find((c) => c.type === "text")?.text ?? "";
const pending = this.pendingPrompts.get(sessionId);
if (!pending) return;
const sentSoFar = pending.sentTextLength ?? 0;
if (fullText.length <= sentSoFar) return;
const newText = fullText.slice(sentSoFar);
pending.sentTextLength = fullText.length;
pending.sentText = fullText;
await this.connection.sessionUpdate({
sessionId,
update: {
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: newText
}
}
});
}
finishPrompt(sessionId, pending, stopReason) {
this.pendingPrompts.delete(sessionId);
this.sessionStore.clearActiveRun(sessionId);
pending.resolve({ stopReason });
}
findPendingBySessionKey(sessionKey) {
for (const pending of this.pendingPrompts.values()) if (pending.sessionKey === sessionKey) return pending;
}
async sendAvailableCommands(sessionId) {
await this.connection.sessionUpdate({
sessionId,
update: {
sessionUpdate: "available_commands_update",
availableCommands: getAvailableCommands()
}
});
}
enforceSessionCreateRateLimit(method) {
const budget = this.sessionCreateRateLimiter.consume();
if (budget.allowed) return;
throw new Error(`ACP session creation rate limit exceeded for ${method}; retry after ${Math.ceil(budget.retryAfterMs / 1e3)}s.`);
}
};
//#endregion
//#region src/acp/server.ts
function serveAcpGateway(opts = {}) {
const cfg = loadConfig();
const connection = buildGatewayConnectionDetails({
config: cfg,
url: opts.gatewayUrl
});
const isRemoteMode = cfg.gateway?.mode === "remote";
const remote = isRemoteMode ? cfg.gateway?.remote : void 0;
const auth = resolveGatewayAuth({
authConfig: cfg.gateway?.auth,
env: process.env
});
const token = opts.gatewayToken ?? (isRemoteMode ? remote?.token?.trim() : void 0) ?? process.env.OPENCLAW_GATEWAY_TOKEN ?? auth.token;
const password = opts.gatewayPassword ?? (isRemoteMode ? remote?.password?.trim() : void 0) ?? process.env.OPENCLAW_GATEWAY_PASSWORD ?? auth.password;
let agent = null;
let onClosed;
const closed = new Promise((resolve) => {
onClosed = resolve;
});
let stopped = false;
const gateway = new GatewayClient({
url: connection.url,
token: token || void 0,
password: password || void 0,
clientName: GATEWAY_CLIENT_NAMES.CLI,
clientDisplayName: "ACP",
clientVersion: "acp",
mode: GATEWAY_CLIENT_MODES.CLI,
onEvent: (evt) => {
agent?.handleGatewayEvent(evt);
},
onHelloOk: () => {
agent?.handleGatewayReconnect();
},
onClose: (code, reason) => {
agent?.handleGatewayDisconnect(`${code}: ${reason}`);
if (stopped) onClosed();
}
});
const shutdown = () => {
if (stopped) return;
stopped = true;
gateway.stop();
onClosed();
};
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
new AgentSideConnection((conn) => {
agent = new AcpGatewayAgent(conn, gateway, opts);
agent.start();
return agent;
}, ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin)));
gateway.start();
return closed;
}
function parseArgs(args) {
const opts = {};
let tokenFile;
let passwordFile;
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === "--url" || arg === "--gateway-url") {
opts.gatewayUrl = args[i + 1];
i += 1;
continue;
}
if (arg === "--token" || arg === "--gateway-token") {
opts.gatewayToken = args[i + 1];
i += 1;
continue;
}
if (arg === "--token-file" || arg === "--gateway-token-file") {
tokenFile = args[i + 1];
i += 1;
continue;
}
if (arg === "--password" || arg === "--gateway-password") {
opts.gatewayPassword = args[i + 1];
i += 1;
continue;
}
if (arg === "--password-file" || arg === "--gateway-password-file") {
passwordFile = args[i + 1];
i += 1;
continue;
}
if (arg === "--session") {
opts.defaultSessionKey = args[i + 1];
i += 1;
continue;
}
if (arg === "--session-label") {
opts.defaultSessionLabel = args[i + 1];
i += 1;
continue;
}
if (arg === "--require-existing") {
opts.requireExistingSession = true;
continue;
}
if (arg === "--reset-session") {
opts.resetSession = true;
continue;
}
if (arg === "--no-prefix-cwd") {
opts.prefixCwd = false;
continue;
}
if (arg === "--verbose" || arg === "-v") {
opts.verbose = true;
continue;
}
if (arg === "--help" || arg === "-h") {
printHelp();
process.exit(0);
}
}
if (opts.gatewayToken?.trim() && tokenFile?.trim()) throw new Error("Use either --token or --token-file.");
if (opts.gatewayPassword?.trim() && passwordFile?.trim()) throw new Error("Use either --password or --password-file.");
if (tokenFile?.trim()) opts.gatewayToken = readSecretFromFile(tokenFile, "Gateway token");
if (passwordFile?.trim()) opts.gatewayPassword = readSecretFromFile(passwordFile, "Gateway password");
return opts;
}
function printHelp() {
console.log(`Usage: openclaw acp [options]
Gateway-backed ACP server for IDE integration.
Options:
--url <url> Gateway WebSocket URL
--token <token> Gateway auth token
--token-file <path> Read gateway auth token from file
--password <password> Gateway auth password
--password-file <path> Read gateway auth password from file
--session <key> Default session key (e.g. "agent:main:main")
--session-label <label> Default session label to resolve
--require-existing Fail if the session key/label does not exist
--reset-session Reset the session key before first use
--no-prefix-cwd Do not prefix prompts with the working directory
--verbose, -v Verbose logging to stderr
--help, -h Show this help message
`);
}
if (isMainModule({ currentFile: fileURLToPath(import.meta.url) })) {
const argv = process.argv.slice(2);
if (argv.includes("--token") || argv.includes("--gateway-token")) console.error("Warning: --token can be exposed via process listings. Prefer --token-file or OPENCLAW_GATEWAY_TOKEN.");
if (argv.includes("--password") || argv.includes("--gateway-password")) console.error("Warning: --password can be exposed via process listings. Prefer --password-file or OPENCLAW_GATEWAY_PASSWORD.");
serveAcpGateway(parseArgs(argv)).catch((err) => {
console.error(String(err));
process.exit(1);
});
}
//#endregion
//#region src/cli/acp-cli.ts
function resolveSecretOption(params) {
const direct = params.direct?.trim();
const file = params.file?.trim();
if (direct && file) throw new Error(`Use either ${params.directFlag} or ${params.fileFlag} for ${params.label}.`);
if (file) return readSecretFromFile(file, params.label);
return direct || void 0;
}
function warnSecretCliFlag(flag) {
defaultRuntime.error(`Warning: ${flag} can be exposed via process listings. Prefer ${flag}-file or environment variables.`);
}
function registerAcpCli(program) {
const acp = program.command("acp").description("Run an ACP bridge backed by the Gateway");
acp.option("--url <url>", "Gateway WebSocket URL (defaults to gateway.remote.url when configured)").option("--token <token>", "Gateway token (if required)").option("--token-file <path>", "Read gateway token from file").option("--password <password>", "Gateway password (if required)").option("--password-file <path>", "Read gateway password from file").option("--session <key>", "Default session key (e.g. agent:main:main)").option("--session-label <label>", "Default session label to resolve").option("--require-existing", "Fail if the session key/label does not exist", false).option("--reset-session", "Reset the session key before first use", false).option("--no-prefix-cwd", "Do not prefix prompts with the working directory", false).option("-v, --verbose", "Verbose logging to stderr", false).addHelpText("after", () => `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/acp", "docs.openclaw.ai/cli/acp")}\n`).action(async (opts) => {
try {
const gatewayToken = resolveSecretOption({
direct: opts.token,
file: opts.tokenFile,
directFlag: "--token",
fileFlag: "--token-file",
label: "Gateway token"
});
const gatewayPassword = resolveSecretOption({
direct: opts.password,
file: opts.passwordFile,
directFlag: "--password",
fileFlag: "--password-file",
label: "Gateway password"
});
if (opts.token) warnSecretCliFlag("--token");
if (opts.password) warnSecretCliFlag("--password");
await serveAcpGateway({
gatewayUrl: opts.url,
gatewayToken,
gatewayPassword,
defaultSessionKey: opts.session,
defaultSessionLabel: opts.sessionLabel,
requireExistingSession: Boolean(opts.requireExisting),
resetSession: Boolean(opts.resetSession),
prefixCwd: !opts.noPrefixCwd,
verbose: Boolean(opts.verbose)
});
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
acp.command("client").description("Run an interactive ACP client against the local ACP bridge").option("--cwd <dir>", "Working directory for the ACP session").option("--server <command>", "ACP server command (default: openclaw)").option("--server-args <args...>", "Extra arguments for the ACP server").option("--server-verbose", "Enable verbose logging on the ACP server", false).option("-v, --verbose", "Verbose client logging", false).action(async (opts, command) => {
const inheritedVerbose = inheritOptionFromParent(command, "verbose");
try {
await runAcpClientInteractive({
cwd: opts.cwd,
serverCommand: opts.server,
serverArgs: opts.serverArgs,
serverVerbose: Boolean(opts.serverVerbose),
verbose: Boolean(opts.verbose || inheritedVerbose)
});
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
}
//#endregion
export { registerAcpCli };