diff --git a/.impeccable.md b/.impeccable.md new file mode 100644 index 000000000..bf6a05200 --- /dev/null +++ b/.impeccable.md @@ -0,0 +1,78 @@ +# Impeccable Design Context + +## Users + +**Primary**: Technical teams and enterprises using AI-assisted coding in production workflows. +- DevOps engineers managing remote agents via RCS dashboard +- Development teams collaborating through shared sessions +- Individual developers using terminal CLI daily + +**Context**: Used during focused work sessions — debugging, code review, agent orchestration. Users are in "get things done" mode, not browsing. They value efficiency but also appreciate warmth and personality. + +**Job to be done**: Make advanced AI coding tools accessible and controllable, especially features that normally require enterprise accounts or Anthropic OAuth. + +## Brand Personality + +**3 words**: Warm, Considered, Human + +**Voice**: Like a knowledgeable colleague who's genuinely enthusiastic about the craft — not a corporate product manager. Community-first, open, slightly playful. Chinese developer community culture (贴吧/discord 温暖氛围). + +**Emotional goals**: Confidence (this tool is solid), Warmth (this community is welcoming), Delight (small moments of personality make the difference). + +**References**: +- **Anthropic's own design language** — their clean, considered aesthetic with warm undertones. The terra cotta/burnt orange as a human accent. Lots of breathing room. Typography-forward. +- **NOT**: Generic AI product (no ChatGPT blue, no gradient text, no "AI slop"). NOT corporate SaaS (no Salesforce-blue dashboards, no enterprise sterility). + +**Anti-references**: Corporate enterprise dashboards, generic AI product pages, anything that looks like it was "designed by committee." + +## Aesthetic Direction + +**Theme**: Light + Dark dual mode (user/system preference switch) + +**Tone**: Anthropomorphic warmth meets terminal precision. The brand orange (Claude's terra cotta) is the thread that ties everything together — it's the human element in a technical world. + +**Typography**: Clean, considered, with good hierarchy. Terminal-native for CLI; modern web fonts for Web UI (RCS dashboard, docs). Favor readability and personality. + +**Color**: +- Primary: Claude orange family (`#D77757` / terra cotta) +- Accent: Warm neutrals tinted toward orange +- Semantic: Success/Error/Warning following Anthropic's established palette +- Dark mode: Warm dark surfaces (not cold blue-black) + +**Differentiation**: The CCB brand sits at the intersection of "serious tool" and "community project." It should feel like Anthropic's design principles applied to an open-source context — less corporate polish, more human craft. The mascot "Clawd" and the playful "踩踩背" naming hint at personality that the design should honor. + +**Scope**: All Web UI — RCS control panel, documentation site, landing pages. + +## Design Principles + +1. **Considered over clever** — Every design choice should feel intentional, not trendy. If it doesn't serve the user, it doesn't ship. +2. **Warmth through subtlety** — Orange tints on neutrals, breathing room in layouts, personality in copy. Not giant emoji or aggressive color. +3. **Density with clarity** — Technical users need information density, but not chaos. Every pixel earns its place. +4. **Community voice** — The design should feel like it was made by people who use it, not by a distant design team. Slightly rough edges are fine if they're honest. +5. **Anthropic's shadow** — When in doubt, follow Anthropic's design instincts — the clean layouts, the generous spacing, the warm color temperature. Then add the community touch. + +## Existing Design Assets + +### Brand Colors (from theme system) +- Claude Orange: `rgb(215,119,87)` / `#D77757` +- Claude Blue: `rgb(87,105,247)` / `#5769F7` +- Permission Blue: `rgb(87,105,247)` +- Auto Accept Violet: `rgb(135,0,255)` +- Plan Mode Teal: `rgb(0,102,102)` +- Success: `rgb(78,186,101)` +- Error: `rgb(255,107,128)` +- Warning: `rgb(255,193,7)` + +### Logo +- CCB text + orange play button icon +- Dark/Light SVG variants in `docs/logo/` +- Favicon: Orange circle `#D97706` with white play triangle + +### Mascot +- "Clawd" — terminal-art character with multiple poses +- Theme-aware coloring + +### Theme System +- 7 variants: dark, light, dark-ansi, light-ansi, dark-daltonized, light-daltonized, auto +- 89+ semantic color tokens +- Full documentation in `packages/@ant/ink/docs/04-theme-system.md` diff --git a/CLAUDE.md b/CLAUDE.md index a985a5cad..6604b9357 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ bun run build bun run build:vite # Test -bun test # run all tests (3066 tests / 205 files / 0 fail) +bun test # run all tests (3175 tests / 207 files / 0 fail) bun test src/utils/__tests__/hash.test.ts # run single file bun test --coverage # with coverage report @@ -157,11 +157,12 @@ bun run docs:dev | `packages/@ant/model-provider/` | Model provider 抽象层 | | `packages/builtin-tools/` | 内置工具集(60 个 tool 实现,通过 `@claude-code-best/builtin-tools` 导出) | | `packages/agent-tools/` | Agent 工具集 | +| `packages/acp-link/` | ACP 代理服务器(WebSocket → ACP agent 桥接) | | `packages/cc-knowledge/` | Claude Code 知识库(非 workspace 包) | | `packages/langfuse-dashboard/` | Langfuse 可观测性面板(非 workspace 包) | | `packages/mcp-client/` | MCP 客户端库 | | `packages/mcp-server/` | MCP 服务端库(非 workspace 包) | -| `packages/remote-control-server/` | 自托管 Remote Control Server(Docker 部署,含 Web UI) | +| `packages/remote-control-server/` | 自托管 Remote Control Server(Docker 部署,含 Web UI)— Web UI 已重构为 React + Vite + Radix UI,支持 ACP agent 接入 | | `packages/swarm/` | Swarm 解耦模块(非 workspace 包) | | `packages/shell/` | Shell 抽象(非 workspace 包) | | `packages/audio-capture-napi/` | 原生音频捕获(已恢复) | @@ -173,10 +174,17 @@ bun run docs:dev ### Bridge / Remote Control - **`src/bridge/`** (~38 files) — Remote Control / Bridge 模式。feature-gated by `BRIDGE_MODE`。包含 bridge API、会话管理、JWT 认证、消息传输、权限回调等。Entry: `bridgeMain.ts`。 -- **`packages/remote-control-server/`** — 自托管 RCS,支持 Docker 部署,含 Web UI 控制面板。通过 `bun run rcs` 启动。 +- **`packages/remote-control-server/`** — 自托管 RCS,支持 Docker 部署,含 Web UI 控制面板(React 19 + Vite + Radix UI)。支持 ACP agent 通过 acp-link 接入(ACP WebSocket handler、relay handler、SSE event stream)。通过 `bun run rcs` 启动。 - CLI 快速路径: `claude remote-control` / `claude rc` / `claude bridge`。 - 详见 `docs/features/remote-control-self-hosting.md`。 +### ACP Protocol (Agent Client Protocol) + +- **`src/services/acp/`** — ACP agent 实现,包含 `agent.ts`(AcpAgent 类)、`bridge.ts`(Claude Code ↔ ACP 桥接)、`permissions.ts`(权限处理)、`entry.ts`(入口)。 +- **`packages/acp-link/`** — ACP 代理服务器,将 WebSocket 客户端桥接到 ACP agent。提供 `acp-link` CLI 命令,支持自定义端口/HTTPS/认证/会话管理、RCS 集成(REST 注册 + WS identify 两步流程)、权限模式透传(fallback: 客户端传值 > config > `ACP_PERMISSION_MODE` 环境变量)。 +- ACP 权限管道改进:`createAcpCanUseTool` 统一权限流水线,`applySessionMode` 模式同步,`bypassPermissions` 可用性检测(非 root/sandbox 环境)。 +- ACP Plan 可视化已支持 `session/update plan` 类型的消息展示(PlanView 组件,含进度条/状态图标/优先级标签)。 + ### Daemon Mode - **`src/daemon/`** — Daemon 模式(长驻 supervisor)。feature-gated by `DAEMON`。包含 `main.ts`(entry)和 `workerRegistry.ts`(worker 管理)。 @@ -209,6 +217,12 @@ Feature flags control which functionality is enabled at runtime. 代码中统一 支持 OpenAI、Gemini、Grok 三种第三方 API,通过 `/login` 命令配置,均采用流适配器模式转为 Anthropic 内部格式。详见各兼容层的 docs 文档。 +### 穷鬼模式(Budget Mode) + +- 通过 `/poor` 命令切换,持久化到 `settings.json`。 +- 启用后跳过 `extract_memories`、`prompt_suggestion` 和 `verification_agent`,显著减少 token 消耗。 +- 实现在 `src/commands/poor/poorMode.ts`。 + ### Stubbed/Deleted Modules | Module | Status | @@ -233,7 +247,7 @@ Feature flags control which functionality is enabled at runtime. 代码中统一 ## Testing - **框架**: `bun:test`(内置断言 + mock) -- **当前状态**: 3066 tests / 205 files / 0 fail +- **当前状态**: 3175 tests / 207 files / 0 fail - **单元测试**: 就近放置于 `src/**/__tests__/`,文件名 `.test.ts` - **集成测试**: `tests/integration/` — 4 个文件(cli-arguments, context-build, message-pipeline, tool-chain) - **共享 mock/fixture**: `tests/mocks/`(api-responses, file-system, fixtures/) @@ -278,3 +292,29 @@ bun run typecheck # equivalent to bun run typecheck - **Biome 配置** — 大量 lint 规则被关闭(decompiled 代码不适合严格 lint)。`.tsx` 文件用 120 行宽 + 强制分号;其他文件 80 行宽 + 按需分号。 - **Ink 框架在 `packages/@ant/ink/`** — 不是 `src/ink/`(该目录不存在)。Ink 相关的组件、hooks、keybindings 都在 packages 中。 - **Provider 优先级** — `modelType` 参数 > 环境变量 > 默认 `firstParty`。新增 provider 需在 `src/utils/model/providers.ts` 注册。 + +## Design Context + +Impeccable 设计上下文保存在 `.impeccable.md` 中。设计 Web UI(RCS 控制面板、文档站、着陆页)时必须参考该文件。 + +### 核心设计原则 + +1. **Considered over clever** — 每个设计选择都应感觉有意为之,而非追逐潮流 +2. **Warmth through subtlety** — 通过橙色色调的中性色、留白布局、有温度的文案来传达温暖 +3. **Density with clarity** — 技术用户需要信息密度,但不能混乱 +4. **Community voice** — 设计应感觉是由使用者创造的,而非遥远的设计团队 +5. **Anthropic's shadow** — 遵循 Anthropic 的设计直觉:干净的布局、充足的间距、温暖的色温 + +### 品牌色 + +- 主色:Claude Orange `#D77757`(terra cotta) +- 辅色:Claude Blue `#5769F7` +- 暗色模式使用温暖的深色表面(非冷蓝黑色) + +### 目标用户 + +技术团队/企业,在专业工作流中使用 AI 辅助编程。友好的开源社区氛围,非企业 SaaS 风格。 + +### 视觉参考 + +Anthropic 公司的设计风格 — 干净、考究、温暖的底色。大量留白,以排版为核心。避免 AI 产品常见的设计套路(渐变文字、玻璃态、霓虹色)。 diff --git a/docs/features/acp-link.md b/docs/features/acp-link.md new file mode 100644 index 000000000..1cc0ebf8d --- /dev/null +++ b/docs/features/acp-link.md @@ -0,0 +1,205 @@ +# acp-link — ACP 代理服务器 + +> 源码目录:`packages/acp-link/` +> PR: #292 +> 新增时间:2026-04-18 + +## 一、功能概述 + +`acp-link` 是一个 ACP (Agent Client Protocol) 代理服务器,将 WebSocket 客户端桥接到 ACP agent 的 stdio 接口。它让 ACP agent(如 Claude Code)可以通过 WebSocket 远程访问,而不仅限于本地 stdio。 + +### 核心特性 + +- **WebSocket → stdio 桥接**:将浏览器/远程客户端的 WebSocket 连接转换为 ACP agent 的 stdin/stdout NDJSON 流 +- **会话管理**:创建、加载、恢复、列出、关闭会话 +- **权限审批流程**:客户端可远程审批 agent 的工具权限请求 +- **RCS 集成**:可与 Remote Control Server (RCS) 连接,将 ACP agent 注册到 RCS 并通过 Web UI 交互 +- **HTTPS 支持**:内置自签名证书生成,支持安全连接 +- **Token 认证**:自动生成或通过环境变量配置认证 token + +## 二、架构 + +### 独立模式 + +``` +┌──────────────────┐ WebSocket ┌──────────────────┐ stdio/NDJSON ┌──────────────┐ +│ 浏览器/客户端 │ ◄──────────────►│ acp-link │ ◄────────────────►│ ACP Agent │ +│ (WS Client) │ ws://host:port │ (Proxy Server) │ spawn subprocess │ (Claude等) │ +└──────────────────┘ └──────────────────┘ └──────────────┘ +``` + +### RCS 集成模式 + +``` +┌──────────────┐ WebSocket ┌──────────────────┐ stdio/NDJSON ┌──────────────┐ +│ RCS Web UI │ ◄──────────────►│ Remote Control │ ◄─────────────────►│ acp-link │ +│ (/code/*) │ ACP Relay WS │ Server (RCS) │ ACP events │ + Agent │ +└──────────────┘ └──────────────────┘ └──────────────┘ +``` + +### 文件结构 + +``` +packages/acp-link/ +├── src/ +│ ├── server.ts # 主服务器:WS 连接管理、会话管理、权限处理、消息桥接 +│ ├── rcs-upstream.ts # RCS 上游客户端:REST 注册 + WS identify 两步流程 +│ ├── cert.ts # TLS 证书生成(自签名) +│ ├── logger.ts # 日志模块 +│ ├── types.ts # JSON-RPC 和 ACP 协议类型定义 +│ ├── cli/ +│ │ ├── bin.ts # CLI 入口 +│ │ ├── command.ts # 命令行参数解析 +│ │ ├── app.ts # 应用启动 +│ │ └── context.ts # 上下文配置 +│ └── __tests__/ # 测试(cert, server, types) +├── package.json +└── tsconfig.json +``` + +## 三、安装与使用 + +### 基本用法 + +```bash +# 直接运行(在 monorepo 中) +# 注意:claude 本身不支持 ACP,需要用 ccb-bun --acp 启动 ACP agent +bun packages/acp-link/src/cli/bin.ts ccb-bun -- --acp + +# 指定端口和主机 +acp-link --port 9000 --host 0.0.0.0 ccb-bun -- --acp + +# 启用 HTTPS(自签名证书) +acp-link --https ccb-bun -- --acp + +# 调试模式 +acp-link --debug ccb-bun -- --acp +``` + +### CLI 参考 + +``` +USAGE + acp-link [--port value] [--host value] [--debug] [--no-auth] [--https] ... + acp-link --help + acp-link --version + +FLAGS + [--port] Port to listen on [default = 9315] + [--host] Host to bind to [default = localhost] + [--debug] Enable debug logging to file + [--no-auth] Disable authentication (dangerous) + [--https] Enable HTTPS with self-signed cert + -h --help Print help information and exit + -v --version Print version information and exit + +ARGUMENTS + command... Agent command followed by its arguments (e.g. "ccb-bun -- --acp") +``` + +## 四、认证 + +默认启动时自动生成随机 token。客户端连接时需通过 query 参数传递: + +``` +ws://localhost:9315/ws?token= +``` + +配置固定 token: + +```bash +ACP_AUTH_TOKEN=my-fixed-token acp-link ccb-bun -- --acp +``` + +禁用认证(不推荐,仅用于开发): + +```bash +acp-link --no-auth ccb-bun -- --acp +``` + +## 五、RCS 集成 + +acp-link 支持将 ACP agent 注册到 Remote Control Server,通过 Web UI 远程操控。 + +### 连接方式 + +```bash +# 通过环境变量配置 RCS 连接 +ACP_RCS_URL=http://localhost:3000 \ +ACP_RCS_TOKEN=sk-rcs-your-key \ +ACP_RCS_NAME=my-agent \ +acp-link ccb-bun -- --acp +``` + +### 注册流程(两步) + +1. **REST 注册**:通过 `POST /v1/environments/bridge` 向 RCS 注册环境 +2. **WS identify**:建立 WebSocket 连接后发送 `identify` 消息(携带 agentId),替代完整 `register` + +``` +acp-link RCS + │ │ + │── POST /v1/environments/bridge ──►│ (REST 注册) + │◄── { agentId, sessionId } ───────│ + │ │ + │── WS connect ─────────────────►│ (WebSocket) + │── identify { agentId } ────────►│ (WS 标识) + │◄── registered ─────────────────│ + │ │ + │── ACP events ─────────────────►│ (双向消息转发) + │◄── user prompts/permissions ───│ +``` + +## 六、权限模式 + +### permissionMode 传递链 + +权限模式通过整条链路传递:Web UI → RCS → acp-link → ACP agent。 + +支持的权限模式: +- `default` — 每次请求权限确认 +- `auto` — 自动判断 +- `acceptEdits` — 自动接受编辑 +- `plan` — 规划模式 +- `dontAsk` — 不询问 +- `bypassPermissions` — 绕过权限(需 sandbox 环境) + +### fallback 链 + +当客户端未显式传递 permissionMode 时,使用以下 fallback 链: + +``` +客户端传值 > config.permissionMode > ACP_PERMISSION_MODE 环境变量 +``` + +示例: + +```bash +ACP_PERMISSION_MODE=auto acp-link ccb-bun -- --acp +``` + +## 七、权限管道(2026-04-18 改进) + +### 模式同步 + +`applySessionMode` 在 agent 切换权限模式时同步 `appState.toolPermissionContext.mode`,确保内部权限上下文与 ACP 客户端状态一致。 + +### 统一权限流水线 + +`createAcpCanUseTool` 接入 `hasPermissionsToUseTool` 统一权限流水线,替代原来分散的处理逻辑。支持 `onModeChange` 回调,模式变更时实时同步。 + +### bypass 检测 + +`bypassPermissions` 模式增加可用性检测 — 仅在非 root 或 sandbox 环境中允许启用,防止权限绕过的安全风险。 + +## 八、环境变量 + +| 变量 | 说明 | +|------|------| +| `ACP_AUTH_TOKEN` | 固定认证 token(默认自动生成) | +| `ACP_PERMISSION_MODE` | 默认权限模式 fallback | +| `ACP_RCS_URL` | RCS 服务器地址(启用 RCS 集成) | +| `ACP_RCS_TOKEN` | RCS API token | +| `ACP_RCS_NAME` | Agent 名称(在 RCS 中显示) | +| `ACP_RCS_CHANNEL_GROUP` | Channel group ID | +| `ACP_MAX_SESSIONS` | 最大会话数 | diff --git a/docs/features/remote-control-self-hosting.md b/docs/features/remote-control-self-hosting.md index 3e42e71db..12255769d 100644 --- a/docs/features/remote-control-self-hosting.md +++ b/docs/features/remote-control-self-hosting.md @@ -13,17 +13,22 @@ ┌──────────────────┐ HTTP/SSE │ │ In-Memory │ │ │ Web UI 控制面板 │ ◄─────────────── │ │ Store │ │ │ (/code/*) │ │ └──────────────┘ │ -└──────────────────┘ │ ┌──────────────┐ │ - │ │ JWT Auth │ │ +│ (React + Vite) │ │ ┌──────────────┐ │ +└──────────────────┘ │ │ JWT Auth │ │ │ └──────────────┘ │ - └──────────────────────┘ +┌──────────────────┐ │ ┌──────────────┐ │ +│ acp-link │ ◄── ACP Relay ─── │ │ ACP Handler │ │ +│ + ACP Agent │ WebSocket │ └──────────────┘ │ +└──────────────────┘ └──────────────────────┘ ``` **RCS 是一个纯内存的中间服务**,它的职责是: - 接收 Claude Code CLI 的环境注册和工作轮询 +- 接收 acp-link 的 ACP agent 注册,支持 WebSocket relay 桥接 - 提供 Web UI 供操作者远程监控和审批 - 通过 WebSocket/SSE 双向传输消息 - 管理会话、环境、权限请求 +- 提供 ACP SSE event stream 供外部消费者订阅 channel group 事件 ## 前置条件 @@ -169,17 +174,70 @@ claude bridge ## Web UI 控制面板 -通过 `/remote-control` 命令获取 URL 后,在浏览器打开即可使用。功能: +通过 `/remote-control` 命令获取 URL 后,在浏览器打开即可使用。 -- 查看已注册的运行环境(environment 模式) +### 技术栈(v2,2026-04-18 重构) + +Web UI 已从原生 JS 重构为 **React + Vite + Radix UI**: + +- **框架**: React 19 + Vite 构建,TypeScript +- **UI 组件**: Radix UI primitives(Dialog、Tabs、Select、Popover 等) +- **聊天组件**: 完整的 ACP 聊天界面,支持 Plan 可视化、工具调用展示、权限审批 +- **AI Elements**: 独立的 AI 交互组件库(message、reasoning、tool、code-block、prompt-input 等) +- **ACP 直连**: 支持 QR 码扫描自动跳转 ACP 直连视图(`ACPDirectView`) +- **主题系统**: 暗色/亮色主题切换,遵循 Impeccable 设计系统 + +### 功能 + +- 查看已注册的运行环境(environment 模式),区分 ACP Agent 和 Claude Code 类型 - 创建和管理会话 - 实时查看对话消息和工具调用 - 查看 Autopilot 状态(`standby` / `sleeping`)和自动运行指示 - 查看 authoritative task snapshots 驱动的 Tasks 面板 - 审批 Claude Code 的工具权限请求 +- 权限模式选择器(6 种模式:默认/自动接受编辑/跳过权限/规划/不询问/自动判断) +- 模型选择器(可选可用模型) +- Plan 可视化(进度条、状态图标、优先级标签) +- ACP QR 扫描自动跳转到 ACP 聊天界面 Web UI 使用 UUID 认证(无需用户账户),适合受信任网络环境。 +## ACP 支持 + +RCS 支持 ACP (Agent Client Protocol) agent 通过 `acp-link` 包接入。 + +### 架构 + +``` +acp-link ──REST注册──► RCS POST /v1/environments/bridge +acp-link ──WS identify──► RCS WebSocket (携带 agentId) +acp-link ◄──ACP relay──► RCS ◄──Web UI WS──► 浏览器 +``` + +### 后端组件 + +| 文件 | 职责 | +|------|------| +| `src/routes/acp/index.ts` | ACP REST 路由:agents 列表、channel groups、relay | +| `src/transport/acp-ws-handler.ts` | ACP WebSocket 处理:agent 注册、心跳、消息转发 | +| `src/transport/acp-relay-handler.ts` | 前端 WS → acp-link 透传 + EventBus inbound 转发 | +| `src/transport/acp-sse-writer.ts` | SSE event stream 供外部消费者订阅 | + +### acp-link 连接 + +详见 [acp-link 文档](./acp-link.md)。 + +```bash +# 在 RCS 环境中启动 acp-link +# 注意:claude 本身不支持 ACP,需要用 ccb-bun --acp +ACP_RCS_URL=http://localhost:3000 \ +ACP_RCS_TOKEN=sk-rcs-your-key \ +ACP_RCS_NAME=my-agent \ +acp-link ccb-bun -- --acp +``` + +ACP session 在 Web UI 中显示紫色标签,与普通 Claude Code session 区分。 + ## 工作流程详解 ``` diff --git a/mint.json b/mint.json index b341d2632..d7a214f9f 100644 --- a/mint.json +++ b/mint.json @@ -138,6 +138,7 @@ "docs/features/voice-mode", "docs/features/bridge-mode", "docs/features/remote-control-self-hosting", + "docs/features/acp-link", "docs/features/proactive", "docs/features/ultraplan" ] diff --git a/package.json b/package.json index 203ec49db..cec32ad27 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-code-best", - "version": "1.4.1", + "version": "1.4.2", "description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal", "type": "module", "author": "claude-code-best ", diff --git a/packages/acp-link/package.json b/packages/acp-link/package.json index 21518d75c..050f54fb5 100644 --- a/packages/acp-link/package.json +++ b/packages/acp-link/package.json @@ -1,6 +1,6 @@ { "name": "acp-link", - "version": "1.0.0", + "version": "1.0.1", "description": "ACP proxy server that bridges WebSocket clients to ACP agents", "author": "claude-code-best", "type": "module", diff --git a/packages/acp-link/src/server.ts b/packages/acp-link/src/server.ts index 5b4d229ce..f239eacd9 100644 --- a/packages/acp-link/src/server.ts +++ b/packages/acp-link/src/server.ts @@ -20,6 +20,8 @@ export interface ServerConfig { debug?: boolean; token?: string; https?: boolean; + /** Default permission mode for new sessions (e.g. "auto", "default", "bypassPermissions") */ + permissionMode?: string; } // Pending permission request @@ -85,6 +87,7 @@ let AGENT_CWD: string; let SERVER_PORT: number; let SERVER_HOST: string; let AUTH_TOKEN: string | undefined; +let DEFAULT_PERMISSION_MODE: string | undefined; const clients = new Map(); @@ -318,7 +321,7 @@ async function handleConnect(ws: WSContext): Promise { async function handleNewSession( ws: WSContext, - params: { cwd?: string }, + params: { cwd?: string; permissionMode?: string }, ): Promise { const state = clients.get(ws); if (!state?.connection) { @@ -329,9 +332,11 @@ async function handleNewSession( try { const sessionCwd = params.cwd || AGENT_CWD; + const permissionMode = params.permissionMode || DEFAULT_PERMISSION_MODE; const result = await state.connection.newSession({ cwd: sessionCwd, mcpServers: [], + ...(permissionMode ? { _meta: { permissionMode } } : {}), }); state.sessionId = result.sessionId; @@ -598,6 +603,7 @@ export async function startServer(config: ServerConfig): Promise { SERVER_PORT = port; SERVER_HOST = host; AUTH_TOKEN = token; + DEFAULT_PERMISSION_MODE = config.permissionMode || process.env.ACP_PERMISSION_MODE; // Initialize RCS upstream client if configured const rcsUrl = process.env.ACP_RCS_URL; @@ -634,7 +640,7 @@ export async function startServer(config: ServerConfig): Promise { handleDisconnect(relayWs); break; case "new_session": - await handleNewSession(relayWs, (msg.payload as { cwd?: string }) || {}); + await handleNewSession(relayWs, (msg.payload as { cwd?: string; permissionMode?: string }) || {}); break; case "prompt": await handlePrompt(relayWs, msg.payload as { content: ContentBlock[] }); @@ -734,7 +740,7 @@ export async function startServer(config: ServerConfig): Promise { handleDisconnect(ws); break; case "new_session": - await handleNewSession(ws, (data.payload as { cwd?: string }) || {}); + await handleNewSession(ws, (data.payload as { cwd?: string; permissionMode?: string }) || {}); break; case "prompt": await handlePrompt(ws, data.payload as { content: ContentBlock[] }); diff --git a/packages/remote-control-server/src/__tests__/automationState.test.ts b/packages/remote-control-server/src/__tests__/automationState.test.ts new file mode 100644 index 000000000..cb322d2e7 --- /dev/null +++ b/packages/remote-control-server/src/__tests__/automationState.test.ts @@ -0,0 +1,182 @@ +import { describe, test, expect } from "bun:test"; +import { + getAutomationStateSnapshot, + getAutomationStateEventPayload, + automationStatesEqual, +} from "../services/automationState"; +import type { AutomationStateResponse } from "../types/api"; + +// ============================================================================= +// normalizeAutomationState (via getAutomationStateSnapshot) +// ============================================================================= + +describe("normalizeAutomationState", () => { + test("returns undefined when metadata has no automation_state key", () => { + expect(getAutomationStateSnapshot({})).toBeUndefined(); + expect(getAutomationStateSnapshot({ other: true })).toBeUndefined(); + expect(getAutomationStateSnapshot(null)).toBeUndefined(); + expect(getAutomationStateSnapshot(undefined)).toBeUndefined(); + }); + + test("returns disabled state for null automation_state", () => { + const result = getAutomationStateSnapshot({ automation_state: null }); + expect(result).toEqual({ + enabled: false, + phase: null, + next_tick_at: null, + sleep_until: null, + }); + }); + + test("returns disabled state for non-object automation_state", () => { + for (const val of ["string", 123, true, []]) { + const result = getAutomationStateSnapshot({ automation_state: val }); + expect(result?.enabled).toBe(false); + } + }); + + test("normalizes enabled: true correctly", () => { + const result = getAutomationStateSnapshot({ automation_state: { enabled: true } }); + expect(result?.enabled).toBe(true); + }); + + test("normalizes enabled to false for non-true values", () => { + const result = getAutomationStateSnapshot({ automation_state: { enabled: "yes" } }); + expect(result?.enabled).toBe(false); + }); + + test("accepts phase: standby", () => { + const result = getAutomationStateSnapshot({ automation_state: { enabled: true, phase: "standby" } }); + expect(result?.phase).toBe("standby"); + }); + + test("accepts phase: sleeping", () => { + const result = getAutomationStateSnapshot({ automation_state: { enabled: true, phase: "sleeping" } }); + expect(result?.phase).toBe("sleeping"); + }); + + test("rejects invalid phase values", () => { + for (const phase of ["running", "idle", "active", "", null]) { + const result = getAutomationStateSnapshot({ automation_state: { enabled: true, phase } }); + expect(result?.phase).toBeNull(); + } + }); + + test("normalizes next_tick_at as number", () => { + const result = getAutomationStateSnapshot({ automation_state: { enabled: true, next_tick_at: 12345 } }); + expect(result?.next_tick_at).toBe(12345); + }); + + test("normalizes next_tick_at as null for non-number", () => { + const result = getAutomationStateSnapshot({ automation_state: { enabled: true, next_tick_at: "soon" } }); + expect(result?.next_tick_at).toBeNull(); + }); + + test("normalizes sleep_until as number", () => { + const result = getAutomationStateSnapshot({ automation_state: { enabled: true, sleep_until: 99999 } }); + expect(result?.sleep_until).toBe(99999); + }); + + test("normalizes sleep_until as null for non-number", () => { + const result = getAutomationStateSnapshot({ automation_state: { enabled: true, sleep_until: false } }); + expect(result?.sleep_until).toBeNull(); + }); + + test("fully normalizes a complete valid state", () => { + const result = getAutomationStateSnapshot({ + automation_state: { enabled: true, phase: "sleeping", next_tick_at: 100, sleep_until: 200 }, + }); + expect(result).toEqual({ + enabled: true, + phase: "sleeping", + next_tick_at: 100, + sleep_until: 200, + }); + }); +}); + +// ============================================================================= +// getAutomationStateEventPayload +// ============================================================================= + +describe("getAutomationStateEventPayload", () => { + test("returns disabled default when no automation_state in metadata", () => { + const result = getAutomationStateEventPayload({}); + expect(result).toEqual({ + enabled: false, + phase: null, + next_tick_at: null, + sleep_until: null, + }); + }); + + test("returns disabled default for null metadata", () => { + const result = getAutomationStateEventPayload(null); + expect(result).toEqual({ + enabled: false, + phase: null, + next_tick_at: null, + sleep_until: null, + }); + }); + + test("returns normalized state when automation_state present", () => { + const result = getAutomationStateEventPayload({ + automation_state: { enabled: true, phase: "standby", next_tick_at: 50, sleep_until: 60 }, + }); + expect(result).toEqual({ + enabled: true, + phase: "standby", + next_tick_at: 50, + sleep_until: 60, + }); + }); + + test("returns a new object each call (not frozen reference)", () => { + const a = getAutomationStateEventPayload({}); + const b = getAutomationStateEventPayload({}); + expect(a).toEqual(b); + expect(a).not.toBe(b); + }); +}); + +// ============================================================================= +// automationStatesEqual +// ============================================================================= + +describe("automationStatesEqual", () => { + const base: AutomationStateResponse = { + enabled: true, + phase: "standby", + next_tick_at: 100, + sleep_until: 200, + }; + + test("returns true for identical states", () => { + expect(automationStatesEqual(base, { ...base })).toBe(true); + }); + + test("returns false when enabled differs", () => { + expect(automationStatesEqual(base, { ...base, enabled: false })).toBe(false); + }); + + test("returns false when phase differs", () => { + expect(automationStatesEqual(base, { ...base, phase: "sleeping" })).toBe(false); + expect(automationStatesEqual(base, { ...base, phase: null })).toBe(false); + }); + + test("returns false when next_tick_at differs", () => { + expect(automationStatesEqual(base, { ...base, next_tick_at: 999 })).toBe(false); + expect(automationStatesEqual(base, { ...base, next_tick_at: null })).toBe(false); + }); + + test("returns false when sleep_until differs", () => { + expect(automationStatesEqual(base, { ...base, sleep_until: 999 })).toBe(false); + expect(automationStatesEqual(base, { ...base, sleep_until: null })).toBe(false); + }); + + test("returns true when both are disabled defaults", () => { + const disabled: AutomationStateResponse = { enabled: false, phase: null, next_tick_at: null, sleep_until: null }; + expect(automationStatesEqual(disabled, { ...disabled })).toBe(true); + }); +}); diff --git a/packages/remote-control-server/src/__tests__/client-payload.test.ts b/packages/remote-control-server/src/__tests__/client-payload.test.ts new file mode 100644 index 000000000..f8d5f676a --- /dev/null +++ b/packages/remote-control-server/src/__tests__/client-payload.test.ts @@ -0,0 +1,256 @@ +import { describe, test, expect } from "bun:test"; +import { toClientPayload } from "../transport/client-payload"; +import type { SessionEvent } from "../transport/event-bus"; + +function makeEvent(overrides: Partial & Pick): SessionEvent { + return { + id: "evt-1", + payload: null, + direction: "inbound", + seqNum: 1, + createdAt: Date.now(), + ...overrides, + }; +} + +// ============================================================================= +// user / user_message +// ============================================================================= + +describe("toClientPayload — user message", () => { + test("maps user type with content", () => { + const event = makeEvent({ + type: "user", + sessionId: "sess-1", + payload: { content: "hello" }, + }); + const result = toClientPayload(event); + expect(result.type).toBe("user"); + expect(result.session_id).toBe("sess-1"); + expect((result as any).message.role).toBe("user"); + expect((result as any).message.content).toBe("hello"); + }); + + test("maps user_message type same as user", () => { + const event = makeEvent({ + type: "user_message", + sessionId: "sess-2", + payload: { content: "world" }, + }); + const result = toClientPayload(event); + expect(result.type).toBe("user"); + expect(result.session_id).toBe("sess-2"); + }); + + test("falls back to message field when content is missing", () => { + const event = makeEvent({ + type: "user", + sessionId: "sess-3", + payload: { message: "fallback msg" }, + }); + const result = toClientPayload(event); + expect((result as any).message.content).toBe("fallback msg"); + }); + + test("falls back to empty string when both content and message missing", () => { + const event = makeEvent({ + type: "user", + sessionId: "sess-4", + payload: {}, + }); + const result = toClientPayload(event); + expect((result as any).message.content).toBe(""); + }); + + test("includes isSynthetic when true", () => { + const event = makeEvent({ + type: "user", + sessionId: "sess-5", + payload: { content: "auto", isSynthetic: true }, + }); + const result = toClientPayload(event); + expect((result as any).isSynthetic).toBe(true); + }); + + test("does not include isSynthetic when false", () => { + const event = makeEvent({ + type: "user", + sessionId: "sess-6", + payload: { content: "manual", isSynthetic: false }, + }); + const result = toClientPayload(event); + expect((result as any).isSynthetic).toBeUndefined(); + }); + + test("uses payload.uuid when present", () => { + const event = makeEvent({ + type: "user", + sessionId: "sess-7", + payload: { content: "hi", uuid: "custom-uuid" }, + }); + const result = toClientPayload(event); + expect(result.uuid).toBe("custom-uuid"); + }); + + test("falls back to event.id when payload.uuid is missing", () => { + const event = makeEvent({ + type: "user", + sessionId: "sess-8", + payload: { content: "hi" }, + }); + const result = toClientPayload(event); + expect(result.uuid).toBe("evt-1"); + }); +}); + +// ============================================================================= +// permission_response / control_response +// ============================================================================= + +describe("toClientPayload — permission response", () => { + test("approved=true maps to allow behavior", () => { + const event = makeEvent({ + type: "permission_response", + sessionId: "sess-1", + payload: { approved: true, request_id: "req-1" }, + }); + const result = toClientPayload(event); + expect(result.type).toBe("control_response"); + const resp = (result as any).response; + expect(resp.subtype).toBe("success"); + expect(resp.request_id).toBe("req-1"); + expect(resp.response.behavior).toBe("allow"); + }); + + test("approved=false maps to deny behavior with error", () => { + const event = makeEvent({ + type: "permission_response", + sessionId: "sess-2", + payload: { approved: false, request_id: "req-2" }, + }); + const result = toClientPayload(event); + expect(result.type).toBe("control_response"); + const resp = (result as any).response; + expect(resp.subtype).toBe("error"); + expect(resp.error).toBe("Permission denied by user"); + expect(resp.response.behavior).toBe("deny"); + }); + + test("approved=false includes feedback message when provided", () => { + const event = makeEvent({ + type: "permission_response", + sessionId: "sess-3", + payload: { approved: false, request_id: "req-3", message: "please revise" }, + }); + const result = toClientPayload(event); + expect((result as any).response.message).toBe("please revise"); + }); + + test("passes through existingResponse directly", () => { + const existingResponse = { subtype: "success", custom: true }; + const event = makeEvent({ + type: "control_response", + sessionId: "sess-4", + payload: { approved: true, response: existingResponse }, + }); + const result = toClientPayload(event); + expect(result.type).toBe("control_response"); + expect((result as any).response).toBe(existingResponse); + }); + + test("includes updatedInput when approved with updated_input", () => { + const updatedInput = { file_path: "/new/path" }; + const event = makeEvent({ + type: "permission_response", + sessionId: "sess-5", + payload: { approved: true, request_id: "req-5", updated_input: updatedInput }, + }); + const result = toClientPayload(event); + expect((result as any).response.response.updatedInput).toEqual(updatedInput); + }); + + test("includes updatedPermissions when approved with updated_permissions", () => { + const perms = [{ type: "allow", tool: "bash" }]; + const event = makeEvent({ + type: "permission_response", + sessionId: "sess-6", + payload: { approved: true, request_id: "req-6", updated_permissions: perms }, + }); + const result = toClientPayload(event); + expect((result as any).response.response.updatedPermissions).toEqual(perms); + }); +}); + +// ============================================================================= +// interrupt +// ============================================================================= + +describe("toClientPayload — interrupt", () => { + test("maps interrupt to control_request with subtype interrupt", () => { + const event = makeEvent({ + type: "interrupt", + sessionId: "sess-1", + }); + const result = toClientPayload(event); + expect(result.type).toBe("control_request"); + expect((result as any).request_id).toBe("evt-1"); + expect((result as any).request.subtype).toBe("interrupt"); + }); +}); + +// ============================================================================= +// control_request +// ============================================================================= + +describe("toClientPayload — control_request", () => { + test("passes through request_id and request from payload", () => { + const event = makeEvent({ + type: "control_request", + sessionId: "sess-1", + payload: { request_id: "req-99", request: { subtype: "permission", tool: "bash" } }, + }); + const result = toClientPayload(event); + expect(result.type).toBe("control_request"); + expect((result as any).request_id).toBe("req-99"); + expect((result as any).request.subtype).toBe("permission"); + }); + + test("falls back request to payload when no request field", () => { + const event = makeEvent({ + type: "control_request", + sessionId: "sess-2", + payload: { request_id: "req-10", custom: "data" }, + }); + const result = toClientPayload(event); + expect((result as any).request).toEqual({ request_id: "req-10", custom: "data" }); + }); + + test("falls back request_id to event.id when missing", () => { + const event = makeEvent({ + type: "control_request", + sessionId: "sess-3", + payload: { request: { subtype: "test" } }, + }); + const result = toClientPayload(event); + expect((result as any).request_id).toBe("evt-1"); + }); +}); + +// ============================================================================= +// default fallback +// ============================================================================= + +describe("toClientPayload — default types", () => { + test("passes through unknown type with type/uuid/session_id/message", () => { + const event = makeEvent({ + type: "assistant", + sessionId: "sess-1", + payload: { uuid: "u-1", content: "response text" }, + }); + const result = toClientPayload(event); + expect(result.type).toBe("assistant"); + expect(result.uuid).toBe("u-1"); + expect(result.session_id).toBe("sess-1"); + expect(result.message).toEqual({ uuid: "u-1", content: "response text" }); + }); +}); diff --git a/packages/remote-control-server/src/__tests__/transport-normalize.test.ts b/packages/remote-control-server/src/__tests__/transport-normalize.test.ts new file mode 100644 index 000000000..5ef361875 --- /dev/null +++ b/packages/remote-control-server/src/__tests__/transport-normalize.test.ts @@ -0,0 +1,188 @@ +import { describe, test, expect } from "bun:test"; + +const { normalizePayload } = await import("../services/transport"); + +// extractContent is not exported; we test it via normalizePayload's content field + +// ============================================================================= +// extractContent (via normalizePayload content field) +// ============================================================================= + +describe("extractContent", () => { + test("returns empty string for null payload", () => { + const result = normalizePayload("assistant", null); + expect(result.content).toBe(""); + }); + + test("returns empty string for undefined payload", () => { + const result = normalizePayload("assistant", undefined); + expect(result.content).toBe(""); + }); + + test("returns the string for string payload", () => { + const result = normalizePayload("assistant", "hello world"); + expect(result.content).toBe("hello world"); + }); + + test("extracts content field from object payload", () => { + const result = normalizePayload("assistant", { content: "direct content" }); + expect(result.content).toBe("direct content"); + }); + + test("extracts message.content string from object payload", () => { + const result = normalizePayload("assistant", { message: { content: "msg content" } }); + expect(result.content).toBe("msg content"); + }); + + test("extracts text blocks from message.content array", () => { + const payload = { + message: { + content: [ + { type: "text", text: "Hello " }, + { type: "text", text: "World" }, + ], + }, + }; + const result = normalizePayload("assistant", payload); + expect(result.content).toBe("Hello World"); + }); + + test("ignores non-text blocks in message.content array", () => { + const payload = { + message: { + content: [ + { type: "image", url: "http://example.com/img.png" }, + { type: "text", text: "only this" }, + ], + }, + }; + const result = normalizePayload("assistant", payload); + expect(result.content).toBe("only this"); + }); + + test("returns empty string when no extractable content", () => { + const result = normalizePayload("assistant", { foo: "bar" }); + expect(result.content).toBe(""); + }); + + test("prefers direct content over message.content", () => { + const result = normalizePayload("assistant", { content: "direct", message: { content: "nested" } }); + expect(result.content).toBe("direct"); + }); +}); + +// ============================================================================= +// normalizePayload — field preservation +// ============================================================================= + +describe("normalizePayload — field preservation", () => { + test("preserves raw payload", () => { + const payload = { content: "test", extra: true }; + const result = normalizePayload("assistant", payload); + expect(result.raw).toBe(payload); + }); + + test("preserves uuid field", () => { + const result = normalizePayload("assistant", { uuid: "u-123" }); + expect(result.uuid).toBe("u-123"); + }); + + test("does not preserve uuid when empty string", () => { + const result = normalizePayload("assistant", { uuid: "" }); + expect(result.uuid).toBeUndefined(); + }); + + test("preserves isSynthetic boolean", () => { + const result = normalizePayload("assistant", { isSynthetic: true }); + expect(result.isSynthetic).toBe(true); + }); + + test("preserves status string", () => { + const result = normalizePayload("assistant", { status: "running" }); + expect(result.status).toBe("running"); + }); + + test("preserves subtype string", () => { + const result = normalizePayload("assistant", { subtype: "progress" }); + expect(result.subtype).toBe("progress"); + }); + + test("preserves tool_name from tool_name field", () => { + const result = normalizePayload("tool", { tool_name: "bash" }); + expect(result.tool_name).toBe("bash"); + }); + + test("preserves tool_name from name field", () => { + const result = normalizePayload("tool", { name: "read" }); + expect(result.tool_name).toBe("read"); + }); + + test("preserves tool_input from tool_input field", () => { + const input = { command: "ls" }; + const result = normalizePayload("tool", { tool_input: input }); + expect(result.tool_input).toEqual(input); + }); + + test("preserves tool_input from input field", () => { + const input = { path: "/tmp" }; + const result = normalizePayload("tool", { input }); + expect(result.tool_input).toEqual(input); + }); + + test("preserves request_id", () => { + const result = normalizePayload("permission", { request_id: "req-1" }); + expect(result.request_id).toBe("req-1"); + }); + + test("preserves request object", () => { + const req = { subtype: "permission" }; + const result = normalizePayload("permission", { request: req }); + expect(result.request).toEqual(req); + }); + + test("preserves approved field", () => { + const result = normalizePayload("permission", { approved: true }); + expect(result.approved).toBe(true); + }); + + test("preserves updated_input", () => { + const input = { command: "rm -rf" }; + const result = normalizePayload("permission", { updated_input: input }); + expect(result.updated_input).toEqual(input); + }); + + test("preserves message field for backward compat", () => { + const msg = { role: "user", content: "hi" }; + const result = normalizePayload("assistant", { message: msg }); + expect(result.message).toEqual(msg); + }); +}); + +// ============================================================================= +// normalizePayload — task_state special handling +// ============================================================================= + +describe("normalizePayload — task_state type", () => { + test("preserves task_list_id (snake_case)", () => { + const result = normalizePayload("task_state", { task_list_id: "tl-1" }); + expect(result.task_list_id).toBe("tl-1"); + }); + + test("preserves taskListId (camelCase)", () => { + const result = normalizePayload("task_state", { taskListId: "tl-2" }); + expect(result.taskListId).toBe("tl-2"); + }); + + test("preserves tasks array", () => { + const tasks = [{ id: "t1", title: "Task 1" }]; + const result = normalizePayload("task_state", { tasks }); + expect(result.tasks).toEqual(tasks); + }); + + test("does not preserve task fields for non-task_state type", () => { + const result = normalizePayload("assistant", { task_list_id: "tl-1", taskListId: "tl-2", tasks: [] }); + expect(result.task_list_id).toBeUndefined(); + expect(result.taskListId).toBeUndefined(); + expect(result.tasks).toBeUndefined(); + }); +}); diff --git a/packages/remote-control-server/web/components/ACPMain.tsx b/packages/remote-control-server/web/components/ACPMain.tsx index 1551a5e40..216ff8e8e 100644 --- a/packages/remote-control-server/web/components/ACPMain.tsx +++ b/packages/remote-control-server/web/components/ACPMain.tsx @@ -7,13 +7,14 @@ import { MessageSquare, Plus, PanelLeftClose, PanelLeft } from "lucide-react"; interface ACPMainProps { client: ACPClient; + agentId?: string; } /** * Main container — Anthropic sidebar + chat layout. * Sidebar: sectioned by recency, orange active state, warm raised bg. */ -export function ACPMain({ client }: ACPMainProps) { +export function ACPMain({ client, agentId }: ACPMainProps) { const [sidebarCollapsed, setSidebarCollapsed] = useState(false); // Handle session selection @@ -36,16 +37,16 @@ export function ACPMain({ client }: ACPMainProps) { {/* 侧边栏 — Anthropic warm sidebar, hidden on mobile */}
{/* 头部 */} -
+
{!sidebarCollapsed && ( - 会话 + 会话 )} -
+
{!sidebarCollapsed && (
); @@ -170,11 +171,12 @@ function SidebarSessionList({ const groups = groupByRecency(sorted); return ( -