mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-19 06:45:50 +00:00
feat: 支持 acp-link 包进行 acp 通用的 remote-control (#292)
* fix: 修复超时问题 * feat: 添加 acp-link 代码 * refactor: 样式重构完成 * feat: RCS 添加 ACP 后端支持 - 新增 ACP WebSocket handler (agent 注册、EventBus 订阅) - 新增 relay handler (前端 WS → acp-link 透传 + EventBus inbound 转发) - 新增 SSE event stream 供外部消费者订阅 channel group 事件 - ACP REST 接口无鉴权 (agents、channel-groups) - WebSocket 端点保留 token 鉴权 - SPA 路由 /acp/ 指向 acp.html Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: 添加 ACP 专属前端界面 - 新增 /acp/ SPA 页面 (agent 列表 + 实时交互) - Agent 列表按 channel group 分组,显示在线状态 - 通过 RCS WebSocket relay 与 agent 通信 - Vite multi-page 构建 (index.html + acp.html) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: acp-link 支持 RCS relay 双向通信 - rcs-upstream 新增 messageHandler 转发非控制消息 - server.ts 新增虚拟 WS + relay client state 处理 relay ACP 消息 - newSession/loadSession 补充 mcpServers 参数 - 连接成功后显示 ACP Dashboard URL Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: 移除 FileExplorer 及文件操作相关代码 - 删除 FileExplorer 组件 - ACPMain 移除 Files tab,仅保留 Chat 和 History - client.ts 移除 listDir/readFile/onFileChanges 等方法 - types.ts 移除 FileItem/FileContent/FileChange 等类型 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: 修复类型问题 * feat: RCS 后端统一 ACP/Bridge 注册逻辑 - store: EnvironmentRecord 增加 capabilities 字段、storeFindEnvironmentByMachineName 复用逻辑 - store: 新增 storeGetSessionOwners,支持未绑定 session 自动 claim - environment: registerEnvironment 支持 ACP 复用已有记录,返回 session_id - session: resolveOwnedWebSessionId 支持无 owner session 自动绑定 - acp-ws-handler: 新增 handleIdentify 支持 REST+WS 两步注册 - acp routes: /acp/relay 和 /acp/agents 支持 UUID 认证 - event-bus: 增加 error 类型 payload 日志 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: acp-link 改 REST 注册 + WS identify 两步流程 - rcs-upstream: 新增 registerViaRest() 通过 POST /v1/environments/bridge 注册 - rcs-upstream: WS 连接后发送 identify 替代 register,携带 agentId - rcs-upstream: 入口链接改为 /code/?sid=${sessionId} 实现用户绑定 - server: 修复心跳跳过 relay 虚拟连接的 bug - server: maxSessions 配置传入 RCS upstream Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: 前端统一 Chat 组件 + ACP 聊天界面重构 - 新增 chat/ 组件: ChatView, ChatInput, MessageBubble, ToolCallGroup, PermissionPanel, SessionSidebar, CommandMenu - ACPMain: 重构支持完整 ACP 协议交互(session/prompt/permission) - rcs-chat-adapter: 统一 bridge session SSE 适配器 - ACPClient: 增强 session 管理、permission 流程、streaming 支持 - index.css: 新增 chat 相关样式、动画、布局 - useCommands: 新增快捷命令 hook Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: 删除 /acp/ 独立页面,ACP 聊天统一到 /code/:sessionId - 删除 acp.html、acp-main.tsx 入口文件和 pages/acp/ 目录 - SessionDetail: ACP session 在同一页面渲染 ACPSessionDetail 组件 - App.tsx: ?sid= 参数自动调用 apiBind 绑定用户 UUID - Dashboard: 统一 session 列表导航,ACP 显示紫色标签 - relay-client: 改用 UUID 认证替代 API token - EnvironmentList: 显示 workerType 标签(ACP Agent / Claude Code) - index.ts: 移除 /acp/ SPA 路由,vite.config 移除 acp 入口 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * build: 更新构建及测试修复 --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
28
packages/acp-link/src/__tests__/cert.test.ts
Normal file
28
packages/acp-link/src/__tests__/cert.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { getLanIPs } from "../cert.js";
|
||||
|
||||
describe("getLanIPs", () => {
|
||||
test("returns an array", () => {
|
||||
const ips = getLanIPs();
|
||||
expect(Array.isArray(ips)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns only IPv4 addresses", () => {
|
||||
const ips = getLanIPs();
|
||||
for (const ip of ips) {
|
||||
// IPv4 format: x.x.x.x
|
||||
expect(ip).toMatch(/^\d+\.\d+\.\d+\.\d+$/);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not include loopback addresses", () => {
|
||||
const ips = getLanIPs();
|
||||
expect(ips).not.toContain("127.0.0.1");
|
||||
});
|
||||
|
||||
test("may be empty in isolated environments", () => {
|
||||
// This test just ensures it doesn't throw
|
||||
const ips = getLanIPs();
|
||||
expect(ips.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
75
packages/acp-link/src/__tests__/server.test.ts
Normal file
75
packages/acp-link/src/__tests__/server.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import type { ServerConfig } from "../server.js";
|
||||
|
||||
describe("Server HTTP endpoints", () => {
|
||||
test("package.json has correct bin and main entries", async () => {
|
||||
const pkg = await import("../../package.json", { with: { type: "json" } });
|
||||
expect(pkg.default.name).toBe("acp-link");
|
||||
expect(pkg.default.main).toBe("./dist/server.js");
|
||||
expect(pkg.default.bin).toBeDefined();
|
||||
expect(pkg.default.bin["acp-link"]).toBe("dist/cli/bin.js");
|
||||
});
|
||||
|
||||
test("ServerConfig interface accepts all expected fields", () => {
|
||||
const config: ServerConfig = {
|
||||
port: 9315,
|
||||
host: "localhost",
|
||||
command: "echo",
|
||||
args: [],
|
||||
cwd: "/tmp",
|
||||
debug: false,
|
||||
token: "test-token",
|
||||
https: false,
|
||||
};
|
||||
expect(config.port).toBe(9315);
|
||||
expect(config.token).toBe("test-token");
|
||||
});
|
||||
|
||||
test("ServerConfig allows optional fields to be omitted", () => {
|
||||
const config: ServerConfig = {
|
||||
port: 9315,
|
||||
host: "localhost",
|
||||
command: "echo",
|
||||
args: [],
|
||||
cwd: "/tmp",
|
||||
};
|
||||
expect(config.debug).toBeUndefined();
|
||||
expect(config.token).toBeUndefined();
|
||||
expect(config.https).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebSocket message types", () => {
|
||||
const clientMessageTypes = [
|
||||
"connect",
|
||||
"disconnect",
|
||||
"new_session",
|
||||
"prompt",
|
||||
"permission_response",
|
||||
"cancel",
|
||||
"set_session_model",
|
||||
"list_sessions",
|
||||
"load_session",
|
||||
"resume_session",
|
||||
"ping",
|
||||
];
|
||||
|
||||
test("all client message types are recognized", () => {
|
||||
expect(clientMessageTypes.length).toBe(11);
|
||||
expect(clientMessageTypes).toContain("ping");
|
||||
expect(clientMessageTypes).toContain("connect");
|
||||
expect(clientMessageTypes).toContain("cancel");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Heartbeat constants", () => {
|
||||
test("PERMISSION_TIMEOUT_MS is 5 minutes", () => {
|
||||
const PERMISSION_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
expect(PERMISSION_TIMEOUT_MS).toBe(300_000);
|
||||
});
|
||||
|
||||
test("HEARTBEAT_INTERVAL_MS is 30 seconds", () => {
|
||||
const HEARTBEAT_INTERVAL_MS = 30_000;
|
||||
expect(HEARTBEAT_INTERVAL_MS).toBe(30_000);
|
||||
});
|
||||
});
|
||||
69
packages/acp-link/src/__tests__/types.test.ts
Normal file
69
packages/acp-link/src/__tests__/types.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { isRequest, isResponse, isNotification } from "../types.js";
|
||||
import type { JsonRpcRequest, JsonRpcResponse, JsonRpcNotification } from "../types.js";
|
||||
|
||||
describe("isRequest", () => {
|
||||
test("returns true for a valid JSON-RPC request", () => {
|
||||
const msg: JsonRpcRequest = { jsonrpc: "2.0", id: 1, method: "test" };
|
||||
expect(isRequest(msg)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for request with params", () => {
|
||||
const msg = { jsonrpc: "2.0" as const, id: "abc", method: "test", params: { x: 1 } };
|
||||
expect(isRequest(msg)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for response (no method)", () => {
|
||||
const msg: JsonRpcResponse = { jsonrpc: "2.0", id: 1, result: {} };
|
||||
expect(isRequest(msg)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for notification (no id)", () => {
|
||||
const msg: JsonRpcNotification = { jsonrpc: "2.0", method: "notify" };
|
||||
expect(isRequest(msg)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isResponse", () => {
|
||||
test("returns true for a valid JSON-RPC response with result", () => {
|
||||
const msg: JsonRpcResponse = { jsonrpc: "2.0", id: 1, result: "ok" };
|
||||
expect(isResponse(msg)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for a valid JSON-RPC error response", () => {
|
||||
const msg: JsonRpcResponse = { jsonrpc: "2.0", id: 2, error: { code: -32600, message: "bad" } };
|
||||
expect(isResponse(msg)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for request (has method)", () => {
|
||||
const msg: JsonRpcRequest = { jsonrpc: "2.0", id: 1, method: "test" };
|
||||
expect(isResponse(msg)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for notification", () => {
|
||||
const msg: JsonRpcNotification = { jsonrpc: "2.0", method: "notify" };
|
||||
expect(isResponse(msg)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isNotification", () => {
|
||||
test("returns true for a valid JSON-RPC notification", () => {
|
||||
const msg: JsonRpcNotification = { jsonrpc: "2.0", method: "update" };
|
||||
expect(isNotification(msg)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for notification with params", () => {
|
||||
const msg = { jsonrpc: "2.0" as const, method: "progress", params: { pct: 50 } };
|
||||
expect(isNotification(msg)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for request (has id)", () => {
|
||||
const msg: JsonRpcRequest = { jsonrpc: "2.0", id: 1, method: "test" };
|
||||
expect(isNotification(msg)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for response (no method)", () => {
|
||||
const msg: JsonRpcResponse = { jsonrpc: "2.0", id: 1, result: null };
|
||||
expect(isNotification(msg)).toBe(false);
|
||||
});
|
||||
});
|
||||
174
packages/acp-link/src/cert.ts
Normal file
174
packages/acp-link/src/cert.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Self-signed certificate generation for HTTPS support
|
||||
*/
|
||||
|
||||
import { X509Certificate } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir, networkInterfaces } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { generate } from "selfsigned";
|
||||
|
||||
/**
|
||||
* Get all LAN IPv4 addresses
|
||||
*/
|
||||
export function getLanIPs(): string[] {
|
||||
const ips: string[] = [];
|
||||
const nets = networkInterfaces();
|
||||
for (const name of Object.keys(nets)) {
|
||||
for (const net of nets[name] || []) {
|
||||
// Skip internal (loopback) and non-IPv4 addresses
|
||||
if (!net.internal && net.family === "IPv4") {
|
||||
ips.push(net.address);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ips;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract IP addresses from certificate's Subject Alternative Name (SAN)
|
||||
* SAN format: "IP Address:192.168.1.100, IP Address:127.0.0.1, DNS:localhost"
|
||||
*/
|
||||
function extractSanIPs(x509: X509Certificate): string[] {
|
||||
const san = x509.subjectAltName;
|
||||
if (!san) return [];
|
||||
|
||||
const ips: string[] = [];
|
||||
// Parse "IP Address:x.x.x.x" entries from SAN string
|
||||
const parts = san.split(", ");
|
||||
for (const part of parts) {
|
||||
const match = part.match(/^IP Address:(.+)$/);
|
||||
if (match && match[1]) {
|
||||
ips.push(match[1]);
|
||||
}
|
||||
}
|
||||
return ips;
|
||||
}
|
||||
|
||||
const CERT_DIR = join(homedir(), ".acp-proxy");
|
||||
const KEY_PATH = join(CERT_DIR, "key.pem");
|
||||
const CERT_PATH = join(CERT_DIR, "cert.pem");
|
||||
|
||||
// Certificate validity in days
|
||||
const CERT_VALIDITY_DAYS = 365;
|
||||
|
||||
export interface TlsOptions {
|
||||
key: string;
|
||||
cert: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or generate self-signed certificate
|
||||
* Certificates are cached in ~/.acp-proxy/
|
||||
*/
|
||||
export async function getOrCreateCertificate(): Promise<TlsOptions> {
|
||||
// Ensure directory exists
|
||||
if (!existsSync(CERT_DIR)) {
|
||||
mkdirSync(CERT_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Check if certificates already exist and are still valid
|
||||
if (existsSync(KEY_PATH) && existsSync(CERT_PATH)) {
|
||||
const certPem = readFileSync(CERT_PATH, "utf-8");
|
||||
const keyPem = readFileSync(KEY_PATH, "utf-8");
|
||||
|
||||
try {
|
||||
const x509 = new X509Certificate(certPem);
|
||||
const validTo = new Date(x509.validTo);
|
||||
const now = new Date();
|
||||
|
||||
// Check if cert is expired or will expire within 7 days
|
||||
const daysUntilExpiry = Math.floor((validTo.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (daysUntilExpiry <= 7) {
|
||||
// Certificate expired or expiring soon
|
||||
console.log(`⚠️ Certificate ${daysUntilExpiry <= 0 ? "expired" : `expires in ${daysUntilExpiry} days`}, regenerating...`);
|
||||
} else {
|
||||
// Check if current LAN IPs are in the certificate's SAN
|
||||
const currentLanIPs = getLanIPs();
|
||||
const certSanIPs = extractSanIPs(x509);
|
||||
|
||||
// Check if all current LAN IPs are covered by the certificate
|
||||
const missingIPs = currentLanIPs.filter(ip => !certSanIPs.includes(ip));
|
||||
|
||||
if (missingIPs.length === 0) {
|
||||
console.log(`🔐 Using existing certificate from ${CERT_DIR}`);
|
||||
console.log(` Valid for ${daysUntilExpiry} more days`);
|
||||
return { key: keyPem, cert: certPem };
|
||||
}
|
||||
|
||||
// LAN IP changed, regenerate
|
||||
console.log(`⚠️ LAN IP changed (missing: ${missingIPs.join(", ")}), regenerating certificate...`);
|
||||
}
|
||||
} catch {
|
||||
// Failed to parse certificate, regenerate
|
||||
console.log(`⚠️ Invalid certificate, regenerating...`);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new self-signed certificate
|
||||
console.log(`🔐 Generating self-signed certificate...`);
|
||||
|
||||
const attrs = [{ name: "commonName", value: "ACP Proxy Server" }];
|
||||
|
||||
// Calculate expiry date
|
||||
const notAfterDate = new Date();
|
||||
notAfterDate.setDate(notAfterDate.getDate() + CERT_VALIDITY_DAYS);
|
||||
|
||||
// Build altNames: localhost + loopback + all LAN IPs
|
||||
const altNames: Array<{ type: 1 | 2 | 6 | 7; value?: string; ip?: string }> = [
|
||||
{ type: 2, value: "localhost" },
|
||||
{ type: 7, ip: "127.0.0.1" },
|
||||
{ type: 7, ip: "::1" },
|
||||
];
|
||||
|
||||
// Add all current LAN IPs
|
||||
const lanIPs = getLanIPs();
|
||||
for (const ip of lanIPs) {
|
||||
altNames.push({ type: 7, ip });
|
||||
}
|
||||
|
||||
if (lanIPs.length > 0) {
|
||||
console.log(` Including LAN IPs: ${lanIPs.join(", ")}`);
|
||||
}
|
||||
|
||||
const pems = await generate(attrs, {
|
||||
keySize: 2048,
|
||||
notAfterDate,
|
||||
algorithm: "sha256",
|
||||
extensions: [
|
||||
{
|
||||
name: "basicConstraints",
|
||||
cA: true,
|
||||
},
|
||||
{
|
||||
name: "keyUsage",
|
||||
keyCertSign: true,
|
||||
digitalSignature: true,
|
||||
keyEncipherment: true,
|
||||
},
|
||||
{
|
||||
name: "extKeyUsage",
|
||||
serverAuth: true,
|
||||
},
|
||||
{
|
||||
name: "subjectAltName",
|
||||
altNames,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Save certificates
|
||||
writeFileSync(KEY_PATH, pems.private);
|
||||
writeFileSync(CERT_PATH, pems.cert);
|
||||
|
||||
console.log(`✅ Certificate saved to ${CERT_DIR}`);
|
||||
console.log(` Valid for ${CERT_VALIDITY_DAYS} days`);
|
||||
console.log(` ⚠️ First access will show a security warning - click "Advanced" → "Proceed"`);
|
||||
|
||||
return {
|
||||
key: pems.private,
|
||||
cert: pems.cert,
|
||||
};
|
||||
}
|
||||
|
||||
18
packages/acp-link/src/cli/app.ts
Normal file
18
packages/acp-link/src/cli/app.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { buildApplication } from "@stricli/core";
|
||||
import { createRequire } from "node:module";
|
||||
import { command } from "./command.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const pkg = require("../../package.json") as { version: string };
|
||||
|
||||
export const app = buildApplication(command, {
|
||||
name: "acp-link",
|
||||
versionInfo: {
|
||||
currentVersion: pkg.version,
|
||||
},
|
||||
scanner: {
|
||||
caseStyle: "allow-kebab-for-camel",
|
||||
allowArgumentEscapeSequence: true,
|
||||
},
|
||||
});
|
||||
|
||||
7
packages/acp-link/src/cli/bin.ts
Normal file
7
packages/acp-link/src/cli/bin.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
import { run } from "@stricli/core";
|
||||
import { app } from "./app.js";
|
||||
import { buildContext } from "./context.js";
|
||||
|
||||
await run(app, process.argv.slice(2), buildContext());
|
||||
|
||||
90
packages/acp-link/src/cli/command.ts
Normal file
90
packages/acp-link/src/cli/command.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { buildCommand, numberParser } from "@stricli/core";
|
||||
import type { LocalContext } from "./context.js";
|
||||
|
||||
export const command = buildCommand({
|
||||
docs: {
|
||||
brief: "Start the ACP proxy server",
|
||||
fullDescription:
|
||||
"Starts a WebSocket proxy server that bridges clients to ACP agents. " +
|
||||
"The agent command is spawned as a subprocess and communicates via stdin/stdout.\n\n" +
|
||||
"Use -- to pass arguments to the agent:\n" +
|
||||
" acp-link /path/to/agent -- --verbose --model gpt-4\n\n" +
|
||||
"For remote access, set ACP_AUTH_TOKEN environment variable or let it auto-generate.",
|
||||
},
|
||||
parameters: {
|
||||
flags: {
|
||||
port: {
|
||||
kind: "parsed",
|
||||
parse: numberParser,
|
||||
brief: "Port to listen on",
|
||||
default: "9315",
|
||||
},
|
||||
host: {
|
||||
kind: "parsed",
|
||||
parse: String,
|
||||
brief: "Host to bind to (use 0.0.0.0 for remote access)",
|
||||
default: "localhost",
|
||||
},
|
||||
debug: {
|
||||
kind: "boolean",
|
||||
brief: "Enable debug logging to file",
|
||||
default: false,
|
||||
},
|
||||
"no-auth": {
|
||||
kind: "boolean",
|
||||
brief: "DANGEROUS: Disable authentication (not recommended)",
|
||||
default: false,
|
||||
},
|
||||
https: {
|
||||
kind: "boolean",
|
||||
brief: "Enable HTTPS with auto-generated self-signed certificate",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
positional: {
|
||||
kind: "array",
|
||||
parameter: {
|
||||
brief: "Agent command and arguments (use -- before agent flags)",
|
||||
parse: String,
|
||||
placeholder: "command",
|
||||
},
|
||||
minimum: 1,
|
||||
},
|
||||
},
|
||||
func: async function (
|
||||
this: LocalContext,
|
||||
flags: { port: number; host: string; debug: boolean; "no-auth": boolean; https: boolean },
|
||||
...args: readonly string[]
|
||||
) {
|
||||
const port = flags.port;
|
||||
const host = flags.host;
|
||||
const debug = flags.debug;
|
||||
const noAuth = flags["no-auth"];
|
||||
const https = flags.https;
|
||||
const [command, ...agentArgs] = args;
|
||||
const cwd = process.cwd();
|
||||
|
||||
// Determine auth token
|
||||
// Priority: ACP_AUTH_TOKEN env var > auto-generate (unless --no-auth)
|
||||
let token: string | undefined;
|
||||
if (noAuth) {
|
||||
console.warn("⚠️ WARNING: Authentication disabled. This is dangerous for remote access!");
|
||||
token = undefined;
|
||||
} else {
|
||||
token = process.env.ACP_AUTH_TOKEN;
|
||||
if (!token) {
|
||||
// Auto-generate random token
|
||||
const { randomBytes } = await import("node:crypto");
|
||||
token = randomBytes(32).toString("hex");
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize logger
|
||||
const { initLogger } = await import("../logger.js");
|
||||
initLogger({ debug });
|
||||
|
||||
// Import and run the server
|
||||
const { startServer } = await import("../server.js");
|
||||
await startServer({ port, host, command: command!, args: [...agentArgs], cwd, debug, token, https });
|
||||
},
|
||||
});
|
||||
10
packages/acp-link/src/cli/context.ts
Normal file
10
packages/acp-link/src/cli/context.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { CommandContext } from "@stricli/core";
|
||||
|
||||
export interface LocalContext extends CommandContext {}
|
||||
|
||||
export function buildContext(): LocalContext {
|
||||
return {
|
||||
process,
|
||||
};
|
||||
}
|
||||
|
||||
83
packages/acp-link/src/logger.ts
Normal file
83
packages/acp-link/src/logger.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import pino from "pino";
|
||||
import { join } from "node:path";
|
||||
import { mkdirSync, existsSync } from "node:fs";
|
||||
|
||||
let rootLogger: pino.Logger;
|
||||
|
||||
export interface LoggerConfig {
|
||||
debug: boolean;
|
||||
logDir?: string;
|
||||
}
|
||||
|
||||
/** Pretty-print config for console output */
|
||||
const PRETTY_CONFIG = {
|
||||
colorize: true,
|
||||
translateTime: "SYS:HH:MM:ss.l",
|
||||
ignore: "pid,hostname",
|
||||
} as const;
|
||||
|
||||
export function initLogger(config: LoggerConfig): pino.Logger {
|
||||
const { debug, logDir } = config;
|
||||
|
||||
if (debug) {
|
||||
const dir = logDir || join(process.cwd(), ".acp-proxy");
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const timestamp = now.toISOString()
|
||||
.replace(/T/, "_")
|
||||
.replace(/:/g, "-")
|
||||
.replace(/\..+/, "");
|
||||
const logFile = join(dir, `acp-proxy-${timestamp}.log`);
|
||||
|
||||
// Debug mode: JSON to file + pretty to console (multistream)
|
||||
rootLogger = pino(
|
||||
{
|
||||
level: "trace",
|
||||
timestamp: pino.stdTimeFunctions.isoTime,
|
||||
},
|
||||
pino.transport({
|
||||
targets: [
|
||||
{ target: "pino/file", options: { destination: logFile } },
|
||||
{ target: "pino-pretty", options: { ...PRETTY_CONFIG, destination: 1 } },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
console.log(`📝 Debug logging enabled: ${logFile}`);
|
||||
} else {
|
||||
rootLogger = pino(
|
||||
{ level: "info", timestamp: pino.stdTimeFunctions.isoTime },
|
||||
pino.transport({
|
||||
target: "pino-pretty",
|
||||
options: { ...PRETTY_CONFIG, destination: 1 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return rootLogger;
|
||||
}
|
||||
|
||||
/** Get the root logger (auto-creates a default one if not initialized). */
|
||||
export function getLogger(): pino.Logger {
|
||||
if (!rootLogger) {
|
||||
rootLogger = pino(
|
||||
{ level: "info" },
|
||||
pino.transport({
|
||||
target: "pino-pretty",
|
||||
options: { ...PRETTY_CONFIG, destination: 1 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return rootLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a child logger scoped to a module.
|
||||
* Usage: `const log = createLogger("agent"); log.info({ pid }, "spawned")`
|
||||
*/
|
||||
export function createLogger(module: string): pino.Logger {
|
||||
return getLogger().child({ module });
|
||||
}
|
||||
258
packages/acp-link/src/rcs-upstream.ts
Normal file
258
packages/acp-link/src/rcs-upstream.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { createLogger } from "./logger.js";
|
||||
|
||||
export interface RcsUpstreamConfig {
|
||||
rcsUrl: string; // e.g. "http://localhost:3000"
|
||||
apiToken: string;
|
||||
agentName: string;
|
||||
channelGroupId?: string;
|
||||
capabilities?: Record<string, unknown>;
|
||||
maxSessions?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* RCS upstream client — connects acp-link to a Remote Control Server.
|
||||
*
|
||||
* Lifecycle:
|
||||
* 1. connect() — opens WS to RCS
|
||||
* 2. Sends register message
|
||||
* 3. Waits for registered response
|
||||
* 4. Forwards all ACP events via send()
|
||||
* 5. Reconnects with exponential backoff on failure
|
||||
*/
|
||||
export class RcsUpstreamClient {
|
||||
private static log = createLogger("rcs-upstream");
|
||||
private ws: WebSocket | null = null;
|
||||
private registered = false;
|
||||
private reconnectAttempts = 0;
|
||||
private closed = false;
|
||||
private readonly maxReconnectDelay = 30_000;
|
||||
private readonly baseReconnectDelay = 1_000;
|
||||
/** Agent ID obtained from REST registration */
|
||||
private agentId: string | null = null;
|
||||
/** Session ID from REST registration (ACP agents auto-create a session) */
|
||||
private sessionId: string | undefined;
|
||||
|
||||
/** Handler for incoming ACP messages from RCS relay */
|
||||
private messageHandler: ((message: Record<string, unknown>) => void) | null = null;
|
||||
|
||||
constructor(private config: RcsUpstreamConfig) {}
|
||||
|
||||
/** Get the agent ID from REST registration */
|
||||
getAgentId(): string | null {
|
||||
return this.agentId;
|
||||
}
|
||||
|
||||
/** Set handler for incoming ACP messages from RCS relay */
|
||||
setMessageHandler(handler: (message: Record<string, unknown>) => void): void {
|
||||
this.messageHandler = handler;
|
||||
}
|
||||
|
||||
/** Register via REST API before establishing WS connection */
|
||||
private async registerViaRest(): Promise<string> {
|
||||
const baseUrl = this.config.rcsUrl
|
||||
.replace(/^ws:\/\//, "http://")
|
||||
.replace(/^wss:\/\//, "https://")
|
||||
.replace(/\/acp\/ws.*$/, "")
|
||||
.replace(/\/$/, "");
|
||||
|
||||
const url = `${baseUrl}/v1/environments/bridge`;
|
||||
RcsUpstreamClient.log.info({ url }, "REST register");
|
||||
|
||||
const resp = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${this.config.apiToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
machine_name: this.config.agentName,
|
||||
worker_type: "acp",
|
||||
bridge_id: this.config.channelGroupId || undefined,
|
||||
max_sessions: this.config.maxSessions,
|
||||
capabilities: this.config.capabilities,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
throw new Error(`REST register failed (${resp.status}): ${text}`);
|
||||
}
|
||||
|
||||
const data = await resp.json() as { environment_id: string; environment_secret: string; status: string; session_id?: string };
|
||||
this.agentId = data.environment_id;
|
||||
this.sessionId = data.session_id;
|
||||
RcsUpstreamClient.log.info({ agentId: this.agentId, sessionId: this.sessionId }, "REST register success");
|
||||
return data.environment_id;
|
||||
}
|
||||
|
||||
/** Normalize RCS URL: accept http(s) base URL and convert to ws(s) + /acp/ws path */
|
||||
private buildWsUrl(): string {
|
||||
let raw = this.config.rcsUrl;
|
||||
raw = raw.replace(/^http:\/\//, "ws://").replace(/^https:\/\//, "wss://");
|
||||
const url = new URL(raw);
|
||||
const path = url.pathname.replace(/\/+$/, "");
|
||||
if (!path || path === "/") {
|
||||
url.pathname = "/acp/ws";
|
||||
}
|
||||
if (this.config.apiToken) {
|
||||
url.searchParams.set("token", this.config.apiToken);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/** Open connection to RCS: REST register → WS identify */
|
||||
async connect(): Promise<void> {
|
||||
if (this.closed) return;
|
||||
|
||||
// Step 1: REST registration
|
||||
try {
|
||||
await this.registerViaRest();
|
||||
} catch (err) {
|
||||
RcsUpstreamClient.log.error({ err }, "REST registration failed");
|
||||
if (!this.closed) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: WebSocket connection with identify
|
||||
const wsUrl = this.buildWsUrl();
|
||||
RcsUpstreamClient.log.info({ url: wsUrl }, "connecting WS");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
RcsUpstreamClient.log.debug("ws open — sending identify");
|
||||
this.ws!.send(
|
||||
JSON.stringify({
|
||||
type: "identify",
|
||||
agent_id: this.agentId,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
let data: Record<string, unknown>;
|
||||
try {
|
||||
data = JSON.parse(event.data as string);
|
||||
} catch {
|
||||
RcsUpstreamClient.log.warn({ raw: String(event.data).slice(0, 200) }, "invalid JSON from server");
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === "identified") {
|
||||
RcsUpstreamClient.log.info({ agent_id: data.agent_id, channel_group_id: data.channel_group_id }, "identified");
|
||||
this.registered = true;
|
||||
this.reconnectAttempts = 0;
|
||||
const webBase = this.config.rcsUrl
|
||||
.replace(/^ws:\/\//, "http://")
|
||||
.replace(/^wss:\/\//, "https://")
|
||||
.replace(/\/acp\/ws.*$/, "")
|
||||
.replace(/\/$/, "");
|
||||
console.log();
|
||||
if (this.sessionId) {
|
||||
console.log(` 🔗 Dashboard: ${webBase}/code/?sid=${this.sessionId}`);
|
||||
} else {
|
||||
console.log(` 🔗 Dashboard: ${webBase}/code/`);
|
||||
}
|
||||
if (this.agentId) {
|
||||
console.log(` Agent ID: ${this.agentId}`);
|
||||
}
|
||||
console.log();
|
||||
resolve();
|
||||
} else if (data.type === "registered") {
|
||||
// Legacy fallback: server still uses old register flow
|
||||
RcsUpstreamClient.log.info({ agent_id: data.agent_id }, "registered (legacy)");
|
||||
this.agentId = (data.agent_id as string) || this.agentId;
|
||||
this.registered = true;
|
||||
this.reconnectAttempts = 0;
|
||||
resolve();
|
||||
} else if (data.type === "error") {
|
||||
RcsUpstreamClient.log.error({ message: data.message }, "server error");
|
||||
if (!this.registered) {
|
||||
reject(new Error(data.message as string));
|
||||
}
|
||||
} else if (data.type === "keep_alive") {
|
||||
// ignore keepalive
|
||||
} else {
|
||||
// Forward ACP protocol messages to handler (for RCS relay support)
|
||||
RcsUpstreamClient.log.debug({ type: data.type }, "forwarding to relay handler");
|
||||
this.messageHandler?.(data);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
// onclose fires after onerror with the actual close code, so we log there
|
||||
if (!this.registered) {
|
||||
reject(new Error("WebSocket connection failed"));
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
RcsUpstreamClient.log.info({ code: event.code, reason: event.reason || undefined }, "ws closed");
|
||||
this.registered = false;
|
||||
this.ws = null;
|
||||
if (!this.closed) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
RcsUpstreamClient.log.error({ err }, "connect threw");
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Send an ACP message to RCS for broadcast */
|
||||
send(message: object): void {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.registered) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.ws.send(JSON.stringify(message));
|
||||
} catch (err) {
|
||||
RcsUpstreamClient.log.error({ err }, "send failed");
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if registered with RCS */
|
||||
isRegistered(): boolean {
|
||||
return this.registered && this.ws !== null && this.ws.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
/** Close the RCS connection permanently */
|
||||
async close(): Promise<void> {
|
||||
this.closed = true;
|
||||
this.registered = false;
|
||||
if (this.ws) {
|
||||
this.ws.close(1000, "client shutdown");
|
||||
this.ws = null;
|
||||
}
|
||||
RcsUpstreamClient.log.info("closed");
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.closed) return;
|
||||
|
||||
const delay = Math.min(
|
||||
this.baseReconnectDelay * 2 ** this.reconnectAttempts,
|
||||
this.maxReconnectDelay,
|
||||
);
|
||||
const jitter = delay * Math.random() * 0.2;
|
||||
const actualDelay = delay + jitter;
|
||||
this.reconnectAttempts++;
|
||||
|
||||
RcsUpstreamClient.log.warn({ attempt: this.reconnectAttempts, delayMs: Math.round(actualDelay) }, "reconnecting");
|
||||
|
||||
setTimeout(async () => {
|
||||
if (this.closed) return;
|
||||
try {
|
||||
await this.connect();
|
||||
} catch {
|
||||
// connect() itself logs the error; nothing to add here
|
||||
}
|
||||
}, actualDelay);
|
||||
}
|
||||
}
|
||||
889
packages/acp-link/src/server.ts
Normal file
889
packages/acp-link/src/server.ts
Normal file
@@ -0,0 +1,889 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { createServer as createHttpsServer } from "node:https";
|
||||
import { Writable, Readable } from "node:stream";
|
||||
import * as acp from "@agentclientprotocol/sdk";
|
||||
import { Hono } from "hono";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { createNodeWebSocket } from "@hono/node-ws";
|
||||
import type { WSContext } from "hono/ws";
|
||||
import type { WebSocket as RawWebSocket } from "ws";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { getOrCreateCertificate, getLanIPs } from "./cert.js";
|
||||
import { RcsUpstreamClient, type RcsUpstreamConfig } from "./rcs-upstream.js";
|
||||
|
||||
export interface ServerConfig {
|
||||
port: number;
|
||||
host: string;
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
debug?: boolean;
|
||||
token?: string;
|
||||
https?: boolean;
|
||||
}
|
||||
|
||||
// Pending permission request
|
||||
interface PendingPermission {
|
||||
resolve: (outcome: { outcome: "cancelled" } | { outcome: "selected"; optionId: string }) => void;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
// PromptCapabilities from ACP protocol
|
||||
// Reference: Zed's prompt_capabilities to check image support
|
||||
interface PromptCapabilities {
|
||||
audio?: boolean;
|
||||
embeddedContext?: boolean;
|
||||
image?: boolean;
|
||||
}
|
||||
|
||||
// SessionModelState from ACP protocol
|
||||
// Reference: Zed's AgentModelSelector reads from state.available_models
|
||||
interface SessionModelState {
|
||||
availableModels: Array<{
|
||||
modelId: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
}>;
|
||||
currentModelId: string;
|
||||
}
|
||||
|
||||
// AgentCapabilities from ACP protocol
|
||||
// Reference: Zed's AcpConnection.agent_capabilities
|
||||
// Matches SDK's AgentCapabilities exactly
|
||||
interface AgentCapabilities {
|
||||
_meta?: Record<string, unknown> | null;
|
||||
loadSession?: boolean;
|
||||
mcpCapabilities?: {
|
||||
_meta?: Record<string, unknown> | null;
|
||||
clientServers?: boolean;
|
||||
};
|
||||
promptCapabilities?: PromptCapabilities;
|
||||
sessionCapabilities?: {
|
||||
_meta?: Record<string, unknown> | null;
|
||||
fork?: Record<string, unknown> | null;
|
||||
list?: Record<string, unknown> | null;
|
||||
resume?: Record<string, unknown> | null;
|
||||
};
|
||||
}
|
||||
|
||||
// Track connected clients and their agent connections
|
||||
interface ClientState {
|
||||
process: ChildProcess | null;
|
||||
connection: acp.ClientSideConnection | null;
|
||||
sessionId: string | null;
|
||||
pendingPermissions: Map<string, PendingPermission>;
|
||||
agentCapabilities: AgentCapabilities | null;
|
||||
promptCapabilities: PromptCapabilities | null;
|
||||
modelState: SessionModelState | null;
|
||||
isAlive: boolean;
|
||||
}
|
||||
|
||||
// Module-level state (set when server starts)
|
||||
let AGENT_COMMAND: string;
|
||||
let AGENT_ARGS: string[];
|
||||
let AGENT_CWD: string;
|
||||
let SERVER_PORT: number;
|
||||
let SERVER_HOST: string;
|
||||
let AUTH_TOKEN: string | undefined;
|
||||
|
||||
const clients = new Map<WSContext, ClientState>();
|
||||
|
||||
// Module-scoped child loggers
|
||||
const logWs = createLogger("ws");
|
||||
const logAgent = createLogger("agent");
|
||||
const logSession = createLogger("session");
|
||||
const logPrompt = createLogger("prompt");
|
||||
const logPerm = createLogger("perm");
|
||||
const logRelay = createLogger("relay");
|
||||
const logServer = createLogger("server");
|
||||
|
||||
// RCS upstream client (optional — enabled via ACP_RCS_URL env var)
|
||||
let rcsUpstream: RcsUpstreamClient | null = null;
|
||||
|
||||
/**
|
||||
* Create a virtual WSContext for RCS relay messages.
|
||||
* Responses via send() go to RCS upstream (not a local WS).
|
||||
*/
|
||||
function createRelayWs(): WSContext {
|
||||
return {
|
||||
get readyState() { return 1; }, // always OPEN
|
||||
send: () => {}, // no-op — responses go through rcsUpstream.send()
|
||||
close: () => {},
|
||||
raw: null,
|
||||
isInner: false,
|
||||
url: "",
|
||||
origin: "",
|
||||
protocol: "",
|
||||
} as unknown as WSContext;
|
||||
}
|
||||
|
||||
// Permission request timeout (5 minutes)
|
||||
const PERMISSION_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
// Heartbeat interval for WebSocket ping/pong (30 seconds)
|
||||
const HEARTBEAT_INTERVAL_MS = 30_000;
|
||||
|
||||
// Generate unique request ID
|
||||
function generateRequestId(): string {
|
||||
return `perm_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||
}
|
||||
|
||||
// Send a message to the WebSocket client (and optionally forward to RCS upstream)
|
||||
function send(ws: WSContext, type: string, payload?: unknown): void {
|
||||
if (ws.readyState === 1) {
|
||||
// WebSocket.OPEN
|
||||
ws.send(JSON.stringify({ type, payload }));
|
||||
}
|
||||
// Forward to RCS upstream if connected
|
||||
if (rcsUpstream?.isRegistered()) {
|
||||
rcsUpstream.send({ type, payload });
|
||||
}
|
||||
}
|
||||
|
||||
// Create a Client implementation that forwards events to WebSocket
|
||||
function createClient(ws: WSContext, clientState: ClientState): acp.Client {
|
||||
return {
|
||||
async requestPermission(params) {
|
||||
const requestId = generateRequestId();
|
||||
logPerm.debug({ requestId, title: params.toolCall.title }, "requested");
|
||||
|
||||
const outcomePromise = new Promise<{ outcome: "cancelled" } | { outcome: "selected"; optionId: string }>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
logPerm.warn({ requestId }, "timed out");
|
||||
clientState.pendingPermissions.delete(requestId);
|
||||
resolve({ outcome: "cancelled" });
|
||||
}, PERMISSION_TIMEOUT_MS);
|
||||
|
||||
clientState.pendingPermissions.set(requestId, { resolve, timeout });
|
||||
});
|
||||
|
||||
send(ws, "permission_request", {
|
||||
requestId,
|
||||
sessionId: params.sessionId,
|
||||
options: params.options,
|
||||
toolCall: params.toolCall,
|
||||
});
|
||||
|
||||
const outcome = await outcomePromise;
|
||||
logPerm.debug({ requestId, outcome: outcome.outcome }, "resolved");
|
||||
|
||||
return { outcome };
|
||||
},
|
||||
|
||||
async sessionUpdate(params) {
|
||||
send(ws, "session_update", params);
|
||||
},
|
||||
|
||||
async readTextFile(params) {
|
||||
logWs.debug({ path: params.path }, "readTextFile");
|
||||
return { content: "" };
|
||||
},
|
||||
|
||||
async writeTextFile(params) {
|
||||
logWs.debug({ path: params.path }, "writeTextFile");
|
||||
return {};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Handle permission response from client
|
||||
function handlePermissionResponse(ws: WSContext, payload: { requestId: string; outcome: { outcome: "cancelled" } | { outcome: "selected"; optionId: string } }): void {
|
||||
const state = clients.get(ws);
|
||||
if (!state) {
|
||||
logPerm.warn("response from unknown client");
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = state.pendingPermissions.get(payload.requestId);
|
||||
if (!pending) {
|
||||
logPerm.warn({ requestId: payload.requestId }, "response for unknown request");
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(pending.timeout);
|
||||
state.pendingPermissions.delete(payload.requestId);
|
||||
pending.resolve(payload.outcome);
|
||||
}
|
||||
|
||||
// Cancel all pending permissions for a client (called on disconnect)
|
||||
function cancelPendingPermissions(clientState: ClientState): void {
|
||||
for (const [requestId, pending] of clientState.pendingPermissions) {
|
||||
logPerm.debug({ requestId }, "cancelled on disconnect");
|
||||
clearTimeout(pending.timeout);
|
||||
pending.resolve({ outcome: "cancelled" });
|
||||
}
|
||||
clientState.pendingPermissions.clear();
|
||||
}
|
||||
|
||||
async function handleConnect(ws: WSContext): Promise<void> {
|
||||
const state = clients.get(ws);
|
||||
if (!state) return;
|
||||
|
||||
// If already connected to a running agent, just resend status
|
||||
// This handles frontend reconnections without restarting the agent process
|
||||
// Check both .killed and .exitCode to detect crashed processes
|
||||
if (state.connection && state.process && !state.process.killed && state.process.exitCode === null) {
|
||||
logAgent.info("already connected, resending status");
|
||||
send(ws, "status", {
|
||||
connected: true,
|
||||
agentInfo: { name: AGENT_COMMAND },
|
||||
capabilities: state.agentCapabilities,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Kill existing process if any (only if not healthy)
|
||||
if (state.process) {
|
||||
cancelPendingPermissions(state);
|
||||
state.process.kill();
|
||||
state.process = null;
|
||||
state.connection = null;
|
||||
}
|
||||
|
||||
try {
|
||||
logAgent.info({ command: AGENT_COMMAND, args: AGENT_ARGS }, "spawning");
|
||||
|
||||
const agentProcess = spawn(AGENT_COMMAND, AGENT_ARGS, {
|
||||
cwd: AGENT_CWD,
|
||||
stdio: ["pipe", "pipe", "inherit"],
|
||||
});
|
||||
|
||||
state.process = agentProcess;
|
||||
|
||||
// Clean up state when agent process exits unexpectedly
|
||||
agentProcess.on("exit", (code) => {
|
||||
logAgent.info({ exitCode: code }, "agent process exited");
|
||||
// Only clear if this is still the current process
|
||||
if (state.process === agentProcess) {
|
||||
state.process = null;
|
||||
state.connection = null;
|
||||
state.sessionId = null;
|
||||
}
|
||||
});
|
||||
|
||||
const input = Writable.toWeb(agentProcess.stdin!) as unknown as WritableStream<Uint8Array>;
|
||||
const output = Readable.toWeb(agentProcess.stdout!) as unknown as ReadableStream<Uint8Array>;
|
||||
|
||||
const stream = acp.ndJsonStream(input, output);
|
||||
const connection = new acp.ClientSideConnection(
|
||||
(_agent) => createClient(ws, state),
|
||||
stream,
|
||||
);
|
||||
|
||||
state.connection = connection;
|
||||
|
||||
const initResult = await connection.initialize({
|
||||
protocolVersion: acp.PROTOCOL_VERSION,
|
||||
clientInfo: { name: "zed", version: "1.0.0" },
|
||||
clientCapabilities: {
|
||||
fs: { readTextFile: true, writeTextFile: true },
|
||||
},
|
||||
});
|
||||
|
||||
const agentCaps = initResult.agentCapabilities;
|
||||
state.agentCapabilities = agentCaps ? {
|
||||
_meta: agentCaps._meta,
|
||||
loadSession: agentCaps.loadSession,
|
||||
mcpCapabilities: agentCaps.mcpCapabilities,
|
||||
promptCapabilities: agentCaps.promptCapabilities,
|
||||
sessionCapabilities: agentCaps.sessionCapabilities,
|
||||
} : null;
|
||||
state.promptCapabilities = agentCaps?.promptCapabilities ?? null;
|
||||
|
||||
logAgent.info({
|
||||
protocolVersion: initResult.protocolVersion,
|
||||
loadSession: !!state.agentCapabilities?.loadSession,
|
||||
sessionList: !!state.agentCapabilities?.sessionCapabilities?.list,
|
||||
sessionResume: !!state.agentCapabilities?.sessionCapabilities?.resume,
|
||||
hasMcp: !!state.agentCapabilities?.mcpCapabilities,
|
||||
}, "initialized");
|
||||
|
||||
send(ws, "status", {
|
||||
connected: true,
|
||||
agentInfo: initResult.agentInfo,
|
||||
capabilities: state.agentCapabilities,
|
||||
});
|
||||
|
||||
connection.closed.then(() => {
|
||||
logAgent.info("connection closed");
|
||||
state.connection = null;
|
||||
state.sessionId = null;
|
||||
send(ws, "status", { connected: false });
|
||||
});
|
||||
} catch (error) {
|
||||
logAgent.error({ error: (error as Error).message }, "connect failed");
|
||||
send(ws, "error", { message: `Failed to connect: ${(error as Error).message}` });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNewSession(
|
||||
ws: WSContext,
|
||||
params: { cwd?: string },
|
||||
): Promise<void> {
|
||||
const state = clients.get(ws);
|
||||
if (!state?.connection) {
|
||||
logAgent.warn({ hasState: !!state, hasProcess: !!state?.process, processKilled: state?.process?.killed, exitCode: state?.process?.exitCode }, "handleNewSession: not connected to agent");
|
||||
send(ws, "error", { message: "Not connected to agent" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionCwd = params.cwd || AGENT_CWD;
|
||||
const result = await state.connection.newSession({
|
||||
cwd: sessionCwd,
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
state.sessionId = result.sessionId;
|
||||
state.modelState = result.models ?? null;
|
||||
logSession.info({ sessionId: result.sessionId, cwd: sessionCwd, hasModels: !!result.models }, "created");
|
||||
|
||||
send(ws, "session_created", {
|
||||
...result,
|
||||
promptCapabilities: state.promptCapabilities,
|
||||
models: state.modelState,
|
||||
});
|
||||
} catch (error) {
|
||||
logSession.error({ error: (error as Error).message }, "create failed");
|
||||
send(ws, "error", { message: `Failed to create session: ${(error as Error).message}` });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Session History Operations
|
||||
// Reference: Zed's AgentConnection trait - list_sessions, load_session, resume_session
|
||||
// ============================================================================
|
||||
|
||||
async function handleListSessions(
|
||||
ws: WSContext,
|
||||
params: { cwd?: string; cursor?: string },
|
||||
): Promise<void> {
|
||||
const state = clients.get(ws);
|
||||
if (!state?.connection) {
|
||||
logAgent.warn({ hasState: !!state, hasProcess: !!state?.process, processKilled: state?.process?.killed, exitCode: state?.process?.exitCode }, "handleListSessions: not connected to agent");
|
||||
send(ws, "error", { message: "Not connected to agent" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.agentCapabilities?.sessionCapabilities?.list) {
|
||||
send(ws, "error", { message: "Listing sessions is not supported by this agent" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await state.connection.listSessions({
|
||||
cwd: params.cwd,
|
||||
cursor: params.cursor,
|
||||
});
|
||||
|
||||
const MAX_SESSIONS = 20;
|
||||
const sessions = result.sessions.slice(0, MAX_SESSIONS);
|
||||
logSession.info({ total: result.sessions.length, returned: sessions.length, hasMore: !!result.nextCursor }, "listed");
|
||||
|
||||
send(ws, "session_list", {
|
||||
sessions: sessions.map((s: acp.SessionInfo) => ({
|
||||
_meta: s._meta,
|
||||
cwd: s.cwd,
|
||||
sessionId: s.sessionId,
|
||||
title: s.title,
|
||||
updatedAt: s.updatedAt,
|
||||
})),
|
||||
nextCursor: result.nextCursor,
|
||||
_meta: result._meta,
|
||||
});
|
||||
} catch (error) {
|
||||
logSession.error({ error: (error as Error).message }, "list failed");
|
||||
send(ws, "error", { message: `Failed to list sessions: ${(error as Error).message}` });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLoadSession(
|
||||
ws: WSContext,
|
||||
params: { sessionId: string; cwd?: string },
|
||||
): Promise<void> {
|
||||
const state = clients.get(ws);
|
||||
if (!state?.connection) {
|
||||
logAgent.warn({ hasState: !!state, hasProcess: !!state?.process, processKilled: state?.process?.killed, exitCode: state?.process?.exitCode }, "handleLoadSession: not connected to agent");
|
||||
send(ws, "error", { message: "Not connected to agent" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.agentCapabilities?.loadSession) {
|
||||
send(ws, "error", { message: "Loading sessions is not supported by this agent" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionCwd = params.cwd || AGENT_CWD;
|
||||
const sessionId = params.sessionId;
|
||||
const result = await state.connection.loadSession({
|
||||
sessionId,
|
||||
cwd: sessionCwd,
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
state.sessionId = sessionId;
|
||||
state.modelState = result.models ?? null;
|
||||
logSession.info({ sessionId, cwd: sessionCwd }, "loaded");
|
||||
|
||||
send(ws, "session_loaded", {
|
||||
sessionId,
|
||||
promptCapabilities: state.promptCapabilities,
|
||||
models: state.modelState,
|
||||
});
|
||||
} catch (error) {
|
||||
logSession.error({ error: (error as Error).message }, "load failed");
|
||||
send(ws, "error", { message: `Failed to load session: ${(error as Error).message}` });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResumeSession(
|
||||
ws: WSContext,
|
||||
params: { sessionId: string; cwd?: string },
|
||||
): Promise<void> {
|
||||
const state = clients.get(ws);
|
||||
if (!state?.connection) {
|
||||
logAgent.warn({ hasState: !!state, hasProcess: !!state?.process, processKilled: state?.process?.killed, exitCode: state?.process?.exitCode }, "handleResumeSession: not connected to agent");
|
||||
send(ws, "error", { message: "Not connected to agent" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.agentCapabilities?.sessionCapabilities?.resume) {
|
||||
send(ws, "error", { message: "Resuming sessions is not supported by this agent" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionCwd = params.cwd || AGENT_CWD;
|
||||
const sessionId = params.sessionId;
|
||||
const result = await state.connection.unstable_resumeSession({
|
||||
sessionId,
|
||||
cwd: sessionCwd,
|
||||
});
|
||||
|
||||
state.sessionId = sessionId;
|
||||
state.modelState = result.models ?? null;
|
||||
logSession.info({ sessionId, cwd: sessionCwd }, "resumed");
|
||||
|
||||
send(ws, "session_resumed", {
|
||||
sessionId,
|
||||
promptCapabilities: state.promptCapabilities,
|
||||
models: state.modelState,
|
||||
});
|
||||
} catch (error) {
|
||||
logSession.error({ error: (error as Error).message }, "resume failed");
|
||||
send(ws, "error", { message: `Failed to resume session: ${(error as Error).message}` });
|
||||
}
|
||||
}
|
||||
|
||||
// Reference: Zed's AcpThread.send() forwards Vec<acp::ContentBlock> to agent
|
||||
async function handlePrompt(
|
||||
ws: WSContext,
|
||||
params: { content: ContentBlock[] },
|
||||
): Promise<void> {
|
||||
const state = clients.get(ws);
|
||||
if (!state?.connection || !state.sessionId) {
|
||||
send(ws, "error", { message: "No active session" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const firstText = params.content.find(b => b.type === "text")?.text;
|
||||
const images = params.content.filter(b => b.type === "image");
|
||||
logPrompt.debug({
|
||||
text: firstText?.slice(0, 100),
|
||||
imageCount: images.length,
|
||||
blockCount: params.content.length,
|
||||
}, "sending");
|
||||
|
||||
const result = await state.connection.prompt({
|
||||
sessionId: state.sessionId,
|
||||
prompt: params.content as acp.ContentBlock[],
|
||||
});
|
||||
|
||||
logPrompt.info({ stopReason: result.stopReason }, "completed");
|
||||
send(ws, "prompt_complete", result);
|
||||
} catch (error) {
|
||||
logPrompt.error({ error: (error as Error).message }, "failed");
|
||||
send(ws, "error", { message: `Prompt failed: ${(error as Error).message}` });
|
||||
}
|
||||
}
|
||||
|
||||
function handleDisconnect(ws: WSContext): void {
|
||||
const state = clients.get(ws);
|
||||
if (!state) return;
|
||||
|
||||
if (state.process) {
|
||||
state.process.kill();
|
||||
state.process = null;
|
||||
}
|
||||
state.connection = null;
|
||||
state.sessionId = null;
|
||||
|
||||
send(ws, "status", { connected: false });
|
||||
}
|
||||
|
||||
// Handle cancel request from client
|
||||
async function handleCancel(ws: WSContext): Promise<void> {
|
||||
const state = clients.get(ws);
|
||||
if (!state?.connection || !state.sessionId) {
|
||||
logWs.warn("cancel requested but no active session");
|
||||
return;
|
||||
}
|
||||
|
||||
logSession.info({ sessionId: state.sessionId }, "cancel requested");
|
||||
cancelPendingPermissions(state);
|
||||
|
||||
try {
|
||||
await state.connection.cancel({ sessionId: state.sessionId });
|
||||
logSession.info({ sessionId: state.sessionId }, "cancel sent");
|
||||
} catch (error) {
|
||||
logSession.error({ error: (error as Error).message }, "cancel failed");
|
||||
}
|
||||
}
|
||||
|
||||
// Reference: Zed's AgentModelSelector.select_model() calls connection.set_session_model()
|
||||
async function handleSetSessionModel(
|
||||
ws: WSContext,
|
||||
params: { modelId: string },
|
||||
): Promise<void> {
|
||||
const state = clients.get(ws);
|
||||
if (!state?.connection || !state.sessionId) {
|
||||
send(ws, "error", { message: "No active session" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.modelState) {
|
||||
send(ws, "error", { message: "Model selection not supported by this agent" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
logSession.info({ sessionId: state.sessionId, modelId: params.modelId }, "setting model");
|
||||
await state.connection.unstable_setSessionModel({
|
||||
sessionId: state.sessionId,
|
||||
modelId: params.modelId,
|
||||
});
|
||||
state.modelState = { ...state.modelState, currentModelId: params.modelId };
|
||||
send(ws, "model_changed", { modelId: params.modelId });
|
||||
logSession.info({ modelId: params.modelId }, "model changed");
|
||||
} catch (error) {
|
||||
logSession.error({ error: (error as Error).message }, "set model failed");
|
||||
send(ws, "error", { message: `Failed to set model: ${(error as Error).message}` });
|
||||
}
|
||||
}
|
||||
|
||||
// ContentBlock type matching @agentclientprotocol/sdk
|
||||
interface ContentBlock {
|
||||
type: string;
|
||||
text?: string;
|
||||
data?: string;
|
||||
mimeType?: string;
|
||||
uri?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface ProxyMessage {
|
||||
type: "connect" | "disconnect" | "new_session" | "prompt" | "cancel" | "set_session_model";
|
||||
payload?: { cwd?: string } | { content: ContentBlock[] } | { modelId: string };
|
||||
}
|
||||
|
||||
export async function startServer(config: ServerConfig): Promise<void> {
|
||||
const { port, host, command, args, cwd, token, https } = config;
|
||||
|
||||
// Set module-level config
|
||||
AGENT_COMMAND = command;
|
||||
AGENT_ARGS = args;
|
||||
AGENT_CWD = cwd;
|
||||
SERVER_PORT = port;
|
||||
SERVER_HOST = host;
|
||||
AUTH_TOKEN = token;
|
||||
|
||||
// Initialize RCS upstream client if configured
|
||||
const rcsUrl = process.env.ACP_RCS_URL;
|
||||
const rcsToken = process.env.ACP_RCS_TOKEN;
|
||||
if (rcsUrl) {
|
||||
rcsUpstream = new RcsUpstreamClient({
|
||||
rcsUrl,
|
||||
apiToken: rcsToken || "",
|
||||
agentName: command,
|
||||
maxSessions: 1,
|
||||
});
|
||||
|
||||
const relayWs = createRelayWs();
|
||||
const relayState: ClientState = {
|
||||
process: null,
|
||||
connection: null,
|
||||
sessionId: null,
|
||||
pendingPermissions: new Map(),
|
||||
agentCapabilities: null,
|
||||
promptCapabilities: null,
|
||||
modelState: null,
|
||||
isAlive: true,
|
||||
};
|
||||
clients.set(relayWs, relayState);
|
||||
|
||||
rcsUpstream.setMessageHandler(async (msg) => {
|
||||
try {
|
||||
logRelay.debug({ type: msg.type }, "processing");
|
||||
switch (msg.type) {
|
||||
case "connect":
|
||||
await handleConnect(relayWs);
|
||||
break;
|
||||
case "disconnect":
|
||||
handleDisconnect(relayWs);
|
||||
break;
|
||||
case "new_session":
|
||||
await handleNewSession(relayWs, (msg.payload as { cwd?: string }) || {});
|
||||
break;
|
||||
case "prompt":
|
||||
await handlePrompt(relayWs, msg.payload as { content: ContentBlock[] });
|
||||
break;
|
||||
case "permission_response":
|
||||
handlePermissionResponse(relayWs, msg.payload as { requestId: string; outcome: { outcome: "cancelled" } | { outcome: "selected"; optionId: string } });
|
||||
break;
|
||||
case "cancel":
|
||||
await handleCancel(relayWs);
|
||||
break;
|
||||
case "set_session_model":
|
||||
await handleSetSessionModel(relayWs, msg.payload as { modelId: string });
|
||||
break;
|
||||
case "list_sessions":
|
||||
await handleListSessions(relayWs, (msg.payload as { cwd?: string; cursor?: string }) || {});
|
||||
break;
|
||||
case "load_session":
|
||||
await handleLoadSession(relayWs, msg.payload as { sessionId: string; cwd?: string });
|
||||
break;
|
||||
case "resume_session":
|
||||
await handleResumeSession(relayWs, msg.payload as { sessionId: string; cwd?: string });
|
||||
break;
|
||||
case "ping":
|
||||
send(relayWs, "pong");
|
||||
break;
|
||||
default:
|
||||
logRelay.warn({ type: msg.type }, "unknown message type");
|
||||
}
|
||||
} catch (error) {
|
||||
logRelay.error({ error: (error as Error).message }, "handler error");
|
||||
}
|
||||
});
|
||||
|
||||
rcsUpstream.connect().catch((err) => {
|
||||
logRelay.warn({ error: (err as Error).message }, "initial connection failed");
|
||||
});
|
||||
logRelay.info({ url: rcsUrl }, "upstream enabled");
|
||||
}
|
||||
|
||||
const app = new Hono();
|
||||
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
|
||||
|
||||
// Health check endpoint
|
||||
app.get("/health", (c) => {
|
||||
return c.json({ status: "ok" });
|
||||
});
|
||||
|
||||
// WebSocket endpoint with token validation
|
||||
app.get(
|
||||
"/ws",
|
||||
upgradeWebSocket((c) => {
|
||||
if (AUTH_TOKEN) {
|
||||
const url = new URL(c.req.url);
|
||||
const providedToken = url.searchParams.get("token");
|
||||
if (providedToken !== AUTH_TOKEN) {
|
||||
logWs.warn("connection rejected: invalid token");
|
||||
return {
|
||||
onOpen(_event, ws) {
|
||||
ws.close(4001, "Unauthorized: Invalid token");
|
||||
},
|
||||
onMessage() {},
|
||||
onClose() {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
onOpen(_event, ws) {
|
||||
logWs.info("client connected");
|
||||
const state: ClientState = {
|
||||
process: null,
|
||||
connection: null,
|
||||
sessionId: null,
|
||||
pendingPermissions: new Map(),
|
||||
agentCapabilities: null,
|
||||
promptCapabilities: null,
|
||||
modelState: null,
|
||||
isAlive: true,
|
||||
};
|
||||
clients.set(ws, state);
|
||||
|
||||
const rawWs = ws.raw as RawWebSocket;
|
||||
rawWs.on("pong", () => {
|
||||
state.isAlive = true;
|
||||
});
|
||||
},
|
||||
async onMessage(event, ws) {
|
||||
try {
|
||||
const data = JSON.parse(event.data.toString());
|
||||
logWs.debug({ type: data.type }, "received");
|
||||
|
||||
switch (data.type) {
|
||||
case "connect":
|
||||
await handleConnect(ws);
|
||||
break;
|
||||
case "disconnect":
|
||||
handleDisconnect(ws);
|
||||
break;
|
||||
case "new_session":
|
||||
await handleNewSession(ws, (data.payload as { cwd?: string }) || {});
|
||||
break;
|
||||
case "prompt":
|
||||
await handlePrompt(ws, data.payload as { content: ContentBlock[] });
|
||||
break;
|
||||
case "permission_response":
|
||||
handlePermissionResponse(ws, data.payload);
|
||||
break;
|
||||
case "cancel":
|
||||
await handleCancel(ws);
|
||||
break;
|
||||
case "set_session_model":
|
||||
await handleSetSessionModel(ws, data.payload as { modelId: string });
|
||||
break;
|
||||
case "list_sessions":
|
||||
await handleListSessions(ws, (data.payload as { cwd?: string; cursor?: string }) || {});
|
||||
break;
|
||||
case "load_session":
|
||||
await handleLoadSession(ws, data.payload as { sessionId: string; cwd?: string });
|
||||
break;
|
||||
case "resume_session":
|
||||
await handleResumeSession(ws, data.payload as { sessionId: string; cwd?: string });
|
||||
break;
|
||||
case "ping":
|
||||
send(ws, "pong");
|
||||
break;
|
||||
default:
|
||||
send(ws, "error", { message: `Unknown message type: ${data.type}` });
|
||||
}
|
||||
} catch (error) {
|
||||
logWs.error({ error: (error as Error).message }, "message error");
|
||||
send(ws, "error", { message: `Error: ${(error as Error).message}` });
|
||||
}
|
||||
},
|
||||
onClose(_event, ws) {
|
||||
logWs.info("client disconnected");
|
||||
const state = clients.get(ws);
|
||||
if (state) {
|
||||
cancelPendingPermissions(state);
|
||||
}
|
||||
handleDisconnect(ws);
|
||||
clients.delete(ws);
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// Create server with optional HTTPS
|
||||
let server;
|
||||
if (https) {
|
||||
const tlsOptions = await getOrCreateCertificate();
|
||||
server = serve({
|
||||
fetch: app.fetch,
|
||||
port,
|
||||
hostname: host,
|
||||
createServer: createHttpsServer,
|
||||
serverOptions: tlsOptions,
|
||||
});
|
||||
} else {
|
||||
server = serve({ fetch: app.fetch, port, hostname: host });
|
||||
}
|
||||
injectWebSocket(server);
|
||||
|
||||
// Heartbeat: periodically ping all connected clients
|
||||
setInterval(() => {
|
||||
for (const [ws, state] of clients) {
|
||||
// Skip virtual relay connections (no raw socket, always alive)
|
||||
if (!ws.raw && state.isAlive) continue;
|
||||
if (!ws.raw) {
|
||||
// Connection already closed, clean up
|
||||
clients.delete(ws);
|
||||
continue;
|
||||
}
|
||||
if (!state.isAlive) {
|
||||
logWs.info("heartbeat timeout, terminating");
|
||||
(ws.raw as RawWebSocket).terminate();
|
||||
continue;
|
||||
}
|
||||
state.isAlive = false;
|
||||
(ws.raw as RawWebSocket).ping();
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
|
||||
// Protocol strings based on HTTPS mode
|
||||
const wsProtocol = https ? "wss" : "ws";
|
||||
|
||||
// Get actual LAN IP when binding to 0.0.0.0
|
||||
let displayHost = host;
|
||||
if (host === "0.0.0.0") {
|
||||
const lanIPs = getLanIPs();
|
||||
displayHost = lanIPs[0] || "localhost";
|
||||
}
|
||||
|
||||
// Build URLs
|
||||
const localWsUrl = `${wsProtocol}://localhost:${port}/ws`;
|
||||
const networkWsUrl = `${wsProtocol}://${displayHost}:${port}/ws`;
|
||||
|
||||
// Print startup banner
|
||||
console.log();
|
||||
console.log(` 🚀 ACP Proxy Server${https ? " (HTTPS)" : ""}`);
|
||||
console.log();
|
||||
console.log(` Connection:`);
|
||||
if (host === "0.0.0.0") {
|
||||
console.log(` URL: ${networkWsUrl}`);
|
||||
} else {
|
||||
console.log(` URL: ${localWsUrl}`);
|
||||
}
|
||||
if (AUTH_TOKEN) {
|
||||
console.log(` Token: ${AUTH_TOKEN}`);
|
||||
}
|
||||
console.log();
|
||||
if (!AUTH_TOKEN) {
|
||||
console.log(` ⚠️ Authentication disabled (--no-auth)`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
const agentDisplay = AGENT_ARGS.length > 0
|
||||
? `${AGENT_COMMAND} ${AGENT_ARGS.join(" ")}`
|
||||
: AGENT_COMMAND;
|
||||
console.log(` 📦 Agent: ${agentDisplay}`);
|
||||
console.log(` CWD: ${AGENT_CWD}`);
|
||||
console.log();
|
||||
console.log(` Press Ctrl+C to stop`);
|
||||
console.log();
|
||||
|
||||
logServer.info({
|
||||
port,
|
||||
host,
|
||||
https,
|
||||
wsEndpoint: `${wsProtocol}://${displayHost}:${port}/ws`,
|
||||
agent: AGENT_COMMAND,
|
||||
agentArgs: AGENT_ARGS,
|
||||
cwd: AGENT_CWD,
|
||||
authEnabled: !!AUTH_TOKEN,
|
||||
}, "started");
|
||||
|
||||
// Keep the server running
|
||||
await new Promise(() => {});
|
||||
}
|
||||
|
||||
// Graceful shutdown — close RCS upstream on process exit
|
||||
process.on("SIGINT", async () => {
|
||||
if (rcsUpstream) {
|
||||
await rcsUpstream.close();
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
process.on("SIGTERM", async () => {
|
||||
if (rcsUpstream) {
|
||||
await rcsUpstream.close();
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
150
packages/acp-link/src/types.ts
Normal file
150
packages/acp-link/src/types.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
// JSON-RPC 2.0 Types
|
||||
export interface JsonRpcRequest {
|
||||
jsonrpc: "2.0";
|
||||
id: string | number;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}
|
||||
|
||||
export interface JsonRpcResponse {
|
||||
jsonrpc: "2.0";
|
||||
id: string | number;
|
||||
result?: unknown;
|
||||
error?: JsonRpcError;
|
||||
}
|
||||
|
||||
export interface JsonRpcNotification {
|
||||
jsonrpc: "2.0";
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}
|
||||
|
||||
export interface JsonRpcError {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export type JsonRpcMessage =
|
||||
| JsonRpcRequest
|
||||
| JsonRpcResponse
|
||||
| JsonRpcNotification;
|
||||
|
||||
// Helper to check message types
|
||||
export function isRequest(msg: JsonRpcMessage): msg is JsonRpcRequest {
|
||||
return "method" in msg && "id" in msg;
|
||||
}
|
||||
|
||||
export function isResponse(msg: JsonRpcMessage): msg is JsonRpcResponse {
|
||||
return "id" in msg && !("method" in msg);
|
||||
}
|
||||
|
||||
export function isNotification(
|
||||
msg: JsonRpcMessage,
|
||||
): msg is JsonRpcNotification {
|
||||
return "method" in msg && !("id" in msg);
|
||||
}
|
||||
|
||||
// ACP Protocol Types
|
||||
|
||||
// Client -> Server messages (from extension to proxy)
|
||||
export interface ProxyConnectParams {
|
||||
command: string; // Command to launch the agent (e.g., "claude-agent")
|
||||
args?: string[]; // Optional arguments
|
||||
cwd?: string; // Working directory for the agent
|
||||
}
|
||||
|
||||
export interface ProxyMessage {
|
||||
type: "connect" | "disconnect" | "message";
|
||||
payload?: ProxyConnectParams | JsonRpcMessage;
|
||||
}
|
||||
|
||||
// Server -> Client messages (from proxy to extension)
|
||||
export interface ProxyStatus {
|
||||
type: "status";
|
||||
connected: boolean;
|
||||
agentInfo?: {
|
||||
name?: string;
|
||||
version?: string;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ProxyAgentMessage {
|
||||
type: "agent_message";
|
||||
payload: JsonRpcMessage;
|
||||
}
|
||||
|
||||
export interface ProxyError {
|
||||
type: "error";
|
||||
message: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export type ProxyResponse = ProxyStatus | ProxyAgentMessage | ProxyError;
|
||||
|
||||
// ACP Initialization
|
||||
export interface InitializeParams {
|
||||
protocolVersion: string;
|
||||
clientInfo: {
|
||||
name: string;
|
||||
version: string;
|
||||
};
|
||||
capabilities?: ClientCapabilities;
|
||||
}
|
||||
|
||||
export interface ClientCapabilities {
|
||||
streaming?: boolean;
|
||||
toolApproval?: boolean;
|
||||
}
|
||||
|
||||
export interface InitializeResult {
|
||||
protocolVersion: string;
|
||||
serverInfo: {
|
||||
name: string;
|
||||
version: string;
|
||||
};
|
||||
capabilities?: ServerCapabilities;
|
||||
}
|
||||
|
||||
export interface ServerCapabilities {
|
||||
streaming?: boolean;
|
||||
tools?: boolean;
|
||||
}
|
||||
|
||||
// ACP Session
|
||||
export interface SessionSetupParams {
|
||||
sessionId?: string;
|
||||
context?: SessionContext;
|
||||
}
|
||||
|
||||
export interface SessionContext {
|
||||
workingDirectory?: string;
|
||||
files?: string[];
|
||||
}
|
||||
|
||||
// ACP Prompt
|
||||
export interface PromptParams {
|
||||
sessionId: string;
|
||||
messages: PromptMessage[];
|
||||
}
|
||||
|
||||
export interface PromptMessage {
|
||||
role: "user" | "assistant";
|
||||
content: string | ContentPart[];
|
||||
}
|
||||
|
||||
export interface ContentPart {
|
||||
type: "text" | "image" | "file";
|
||||
text?: string;
|
||||
data?: string;
|
||||
mimeType?: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
// Content streaming notification
|
||||
export interface ContentNotification {
|
||||
sessionId: string;
|
||||
content: string;
|
||||
done?: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user