Skip to content

Commit 34154ee

Browse files
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>
1 parent 29cc74a commit 34154ee

142 files changed

Lines changed: 17847 additions & 5577 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

bun.lock

Lines changed: 906 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/acp-link/.gitignore

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# dependencies (bun install)
2+
node_modules
3+
4+
# output
5+
out
6+
dist
7+
*.tgz
8+
9+
# code coverage
10+
coverage
11+
*.lcov
12+
13+
# logs
14+
logs
15+
_.log
16+
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
17+
18+
# dotenv environment variable files
19+
.env
20+
.env.development.local
21+
.env.test.local
22+
.env.production.local
23+
.env.local
24+
25+
# caches
26+
.eslintcache
27+
.cache
28+
*.tsbuildinfo
29+
30+
# IntelliJ based IDEs
31+
.idea
32+
33+
# Finder (MacOS) folder config
34+
.DS_Store

packages/acp-link/README.md

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# acp-link
2+
3+
ACP proxy server that bridges WebSocket clients to ACP (Agent Client Protocol) agents.
4+
5+
> Source code adapted from [chrome-acp](https://github.com/Areo-Joe/chrome-acp).
6+
7+
## Installation
8+
9+
### From source
10+
11+
```bash
12+
# From monorepo root
13+
bun install
14+
```
15+
16+
## Usage
17+
18+
```bash
19+
# Via global install
20+
acp-link /path/to/agent
21+
22+
# Via source
23+
bun src/cli/bin.ts /path/to/agent
24+
```
25+
26+
### Examples
27+
28+
```bash
29+
# Basic usage
30+
acp-link /path/to/agent
31+
32+
# With custom port and host
33+
acp-link --port 9000 --host 0.0.0.0 /path/to/agent
34+
35+
# With debug logging
36+
acp-link --debug /path/to/agent
37+
38+
# Enable HTTPS with self-signed certificate
39+
acp-link --https /path/to/agent
40+
41+
# Disable authentication (dangerous)
42+
acp-link --no-auth /path/to/agent
43+
44+
# Pass arguments to the agent (use -- to separate)
45+
acp-link /path/to/agent -- --verbose --model gpt-4
46+
```
47+
48+
## CLI Reference
49+
50+
```
51+
USAGE
52+
acp-link [--port value] [--host value] [--debug] [--no-auth] [--https] <command>...
53+
acp-link --help
54+
acp-link --version
55+
56+
FLAGS
57+
[--port] Port to listen on [default = 9315]
58+
[--host] Host to bind to [default = localhost]
59+
[--debug] Enable debug logging to file
60+
[--no-auth] Disable authentication (dangerous)
61+
[--https] Enable HTTPS with self-signed cert
62+
-h --help Print help information and exit
63+
-v --version Print version information and exit
64+
65+
ARGUMENTS
66+
command... Agent command followed by its arguments
67+
```
68+
69+
## How It Works
70+
71+
1. Listens for WebSocket connections from clients
72+
2. When a "connect" message is received, spawns the configured ACP agent as a subprocess
73+
3. Bridges messages between the WebSocket (client) and stdin/stdout (agent via ACP protocol)
74+
4. Supports session management: create, load, resume, list sessions
75+
5. Handles permission approval flow and heartbeat keepalive
76+
77+
## Authentication
78+
79+
By default, a random token is auto-generated on startup. Pass it as a query parameter:
80+
81+
```
82+
ws://localhost:9315/ws?token=<your-token>
83+
```
84+
85+
Set `ACP_AUTH_TOKEN` env var to use a fixed token, or use `--no-auth` to disable (not recommended).
86+
87+
## License
88+
89+
MIT

packages/acp-link/package.json

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
{
2+
"name": "acp-link",
3+
"version": "1.0.0",
4+
"description": "ACP proxy server that bridges WebSocket clients to ACP agents",
5+
"author": "claude-code-best",
6+
"type": "module",
7+
"main": "./dist/server.js",
8+
"types": "./dist/server.d.ts",
9+
"bin": {
10+
"acp-link": "dist/cli/bin.js"
11+
},
12+
"files": [
13+
"dist"
14+
],
15+
"scripts": {
16+
"build": "tsc",
17+
"dev": "bun run src/cli/bin.ts",
18+
"prepublishOnly": "bun run build"
19+
},
20+
"devDependencies": {
21+
"@types/selfsigned": "^2.0.4",
22+
"@types/ws": "^8.18.1"
23+
},
24+
"dependencies": {
25+
"@agentclientprotocol/sdk": "^0.19.0",
26+
"@hono/node-server": "^1.13.8",
27+
"@hono/node-ws": "^1.0.5",
28+
"@stricli/auto-complete": "^1.2.4",
29+
"@stricli/core": "^1.2.4",
30+
"hono": "^4.7.0",
31+
"pino": "^10.3.0",
32+
"pino-pretty": "^13.1.3",
33+
"selfsigned": "^5.5.0"
34+
},
35+
"engines": {
36+
"node": ">=18"
37+
},
38+
"license": "MIT"
39+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { describe, test, expect } from "bun:test";
2+
import { getLanIPs } from "../cert.js";
3+
4+
describe("getLanIPs", () => {
5+
test("returns an array", () => {
6+
const ips = getLanIPs();
7+
expect(Array.isArray(ips)).toBe(true);
8+
});
9+
10+
test("returns only IPv4 addresses", () => {
11+
const ips = getLanIPs();
12+
for (const ip of ips) {
13+
// IPv4 format: x.x.x.x
14+
expect(ip).toMatch(/^\d+\.\d+\.\d+\.\d+$/);
15+
}
16+
});
17+
18+
test("does not include loopback addresses", () => {
19+
const ips = getLanIPs();
20+
expect(ips).not.toContain("127.0.0.1");
21+
});
22+
23+
test("may be empty in isolated environments", () => {
24+
// This test just ensures it doesn't throw
25+
const ips = getLanIPs();
26+
expect(ips.length).toBeGreaterThanOrEqual(0);
27+
});
28+
});
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { describe, test, expect } from "bun:test";
2+
import type { ServerConfig } from "../server.js";
3+
4+
describe("Server HTTP endpoints", () => {
5+
test("package.json has correct bin and main entries", async () => {
6+
const pkg = await import("../../package.json", { with: { type: "json" } });
7+
expect(pkg.default.name).toBe("acp-link");
8+
expect(pkg.default.main).toBe("./dist/server.js");
9+
expect(pkg.default.bin).toBeDefined();
10+
expect(pkg.default.bin["acp-link"]).toBe("dist/cli/bin.js");
11+
});
12+
13+
test("ServerConfig interface accepts all expected fields", () => {
14+
const config: ServerConfig = {
15+
port: 9315,
16+
host: "localhost",
17+
command: "echo",
18+
args: [],
19+
cwd: "/tmp",
20+
debug: false,
21+
token: "test-token",
22+
https: false,
23+
};
24+
expect(config.port).toBe(9315);
25+
expect(config.token).toBe("test-token");
26+
});
27+
28+
test("ServerConfig allows optional fields to be omitted", () => {
29+
const config: ServerConfig = {
30+
port: 9315,
31+
host: "localhost",
32+
command: "echo",
33+
args: [],
34+
cwd: "/tmp",
35+
};
36+
expect(config.debug).toBeUndefined();
37+
expect(config.token).toBeUndefined();
38+
expect(config.https).toBeUndefined();
39+
});
40+
});
41+
42+
describe("WebSocket message types", () => {
43+
const clientMessageTypes = [
44+
"connect",
45+
"disconnect",
46+
"new_session",
47+
"prompt",
48+
"permission_response",
49+
"cancel",
50+
"set_session_model",
51+
"list_sessions",
52+
"load_session",
53+
"resume_session",
54+
"ping",
55+
];
56+
57+
test("all client message types are recognized", () => {
58+
expect(clientMessageTypes.length).toBe(11);
59+
expect(clientMessageTypes).toContain("ping");
60+
expect(clientMessageTypes).toContain("connect");
61+
expect(clientMessageTypes).toContain("cancel");
62+
});
63+
});
64+
65+
describe("Heartbeat constants", () => {
66+
test("PERMISSION_TIMEOUT_MS is 5 minutes", () => {
67+
const PERMISSION_TIMEOUT_MS = 5 * 60 * 1000;
68+
expect(PERMISSION_TIMEOUT_MS).toBe(300_000);
69+
});
70+
71+
test("HEARTBEAT_INTERVAL_MS is 30 seconds", () => {
72+
const HEARTBEAT_INTERVAL_MS = 30_000;
73+
expect(HEARTBEAT_INTERVAL_MS).toBe(30_000);
74+
});
75+
});
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, test, expect } from "bun:test";
2+
import { isRequest, isResponse, isNotification } from "../types.js";
3+
import type { JsonRpcRequest, JsonRpcResponse, JsonRpcNotification } from "../types.js";
4+
5+
describe("isRequest", () => {
6+
test("returns true for a valid JSON-RPC request", () => {
7+
const msg: JsonRpcRequest = { jsonrpc: "2.0", id: 1, method: "test" };
8+
expect(isRequest(msg)).toBe(true);
9+
});
10+
11+
test("returns true for request with params", () => {
12+
const msg = { jsonrpc: "2.0" as const, id: "abc", method: "test", params: { x: 1 } };
13+
expect(isRequest(msg)).toBe(true);
14+
});
15+
16+
test("returns false for response (no method)", () => {
17+
const msg: JsonRpcResponse = { jsonrpc: "2.0", id: 1, result: {} };
18+
expect(isRequest(msg)).toBe(false);
19+
});
20+
21+
test("returns false for notification (no id)", () => {
22+
const msg: JsonRpcNotification = { jsonrpc: "2.0", method: "notify" };
23+
expect(isRequest(msg)).toBe(false);
24+
});
25+
});
26+
27+
describe("isResponse", () => {
28+
test("returns true for a valid JSON-RPC response with result", () => {
29+
const msg: JsonRpcResponse = { jsonrpc: "2.0", id: 1, result: "ok" };
30+
expect(isResponse(msg)).toBe(true);
31+
});
32+
33+
test("returns true for a valid JSON-RPC error response", () => {
34+
const msg: JsonRpcResponse = { jsonrpc: "2.0", id: 2, error: { code: -32600, message: "bad" } };
35+
expect(isResponse(msg)).toBe(true);
36+
});
37+
38+
test("returns false for request (has method)", () => {
39+
const msg: JsonRpcRequest = { jsonrpc: "2.0", id: 1, method: "test" };
40+
expect(isResponse(msg)).toBe(false);
41+
});
42+
43+
test("returns false for notification", () => {
44+
const msg: JsonRpcNotification = { jsonrpc: "2.0", method: "notify" };
45+
expect(isResponse(msg)).toBe(false);
46+
});
47+
});
48+
49+
describe("isNotification", () => {
50+
test("returns true for a valid JSON-RPC notification", () => {
51+
const msg: JsonRpcNotification = { jsonrpc: "2.0", method: "update" };
52+
expect(isNotification(msg)).toBe(true);
53+
});
54+
55+
test("returns true for notification with params", () => {
56+
const msg = { jsonrpc: "2.0" as const, method: "progress", params: { pct: 50 } };
57+
expect(isNotification(msg)).toBe(true);
58+
});
59+
60+
test("returns false for request (has id)", () => {
61+
const msg: JsonRpcRequest = { jsonrpc: "2.0", id: 1, method: "test" };
62+
expect(isNotification(msg)).toBe(false);
63+
});
64+
65+
test("returns false for response (no method)", () => {
66+
const msg: JsonRpcResponse = { jsonrpc: "2.0", id: 1, result: null };
67+
expect(isNotification(msg)).toBe(false);
68+
});
69+
});

0 commit comments

Comments
 (0)