Compare commits

..

4 Commits

Author SHA1 Message Date
unraid
c5f52cd668 fix: daemon 子进程 spawn 跨平台修复 + CliLaunchSpec 集中化重构
- 新建 src/utils/cliLaunch.ts: 集中化 CLI 子进程启动层
  - buildCliLaunch(): 标准化启动规范(execArgv snapshot + bundled mode 检测)
  - spawnCli(): 统一 spawn(自动 windowsHide)
  - quoteCliLaunch(): tmux shell 引用
- 修复 --daemon-worker=kind 等号格式解析(cli.tsx)
- 修复 daemon/bg fast path 缺少 setShellIfWindows()(Windows git-bash 发现)
- 修复 checkPathExists 用 execSync('dir') 改为 existsSync(消除 cmd.exe 弹窗)
- 修复 CLAUDE_CODE_GIT_BASH_PATH env 传播给子进程
- 7 个 spawn 站点迁移到 CliLaunchSpec
- BgEngine 接口新增 supportsInteractiveInput capability
- daemon bg 无 -p 时 detached 引擎给出清晰错误提示
2026-04-14 22:16:35 +08:00
unraid
4c409df35d chore: full Biome lint cleanup — zero errors, zero warnings
- Remove 203 unused biome-ignore suppression comments (noConsole rule is off)
- Apply all FIXABLE transforms: Math.pow->**, parseInt radix,
  noUselessContinue, noUselessUndefinedInitialization, useIndexOf,
  useRegexLiterals, useShorthandFunctionType, noPrototypeBuiltins
- Add targeted suppressions for 31 intentional patterns
- Format all src/ files via Biome (quote style, import line width)
- Result: 0 errors, 0 warnings across 2649 files
2026-04-14 19:56:13 +08:00
unraid
ee369549a8 fix: correct detectMimeFromBase64 to decode raw bytes from base64
The charCodeAt approach compared raw byte magic numbers against
base64-encoded characters which never matched. Decode first 12 raw
bytes and check standard magic byte signatures directly.
Fixes API 400 on Windows (JPEG) and macOS (PNG) screenshots.
2026-04-14 19:54:11 +08:00
unraid
637c9081f6 feat: integrate 5 feature branches + daemon/job 命令层级化 + 跨平台后台引擎 + TypeScript 错误修复
Squashed merge of:
1. fix/mcp-tsc-errors — 修复上游 MCP 重构后的 tsc 错误和测试失败
2. feat/pipe-mute-disconnect — Pipe IPC 逻辑断开、/lang 命令、mute 状态机
3. feat/stub-recovery-all — 实现全部 stub 恢复 (task 001-012)
4. feat/kairos-activation — KAIROS 激活解除阻塞 + 工具实现
5. codex/openclaw-autonomy-pr — 自治权限系统、运行记录、managed flows

Additional:
6. daemon/job 命令层级化重构 (subcommand 架构)
7. 跨平台后台引擎抽象 (detached/tmux engines)
8. 修复 src/ 中 43 个预存在的 TypeScript 类型错误
9. 修复 langfuse isolated test mock 完整性
10. 修复 CodeRabbit 审查的 Critical/Major/Minor 问题
11. remote-control-server logger 抽象 (测试 stderr 静默化)
12. /simplify 审查修复 (代码复用、质量、效率)
2026-04-14 19:53:36 +08:00
1851 changed files with 56110 additions and 91534 deletions

View File

@@ -8,7 +8,7 @@ on:
jobs: jobs:
ci: ci:
runs-on: ubuntu-latest runs-on: macos-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -23,16 +23,8 @@ jobs:
- name: Type check - name: Type check
run: bunx tsc --noEmit run: bunx tsc --noEmit
- name: Test with Coverage - name: Test
run: | run: bun test
set -o pipefail
bun test --coverage --coverage-reporter=lcov 2>&1 | grep -vE '^\s*(\(pass\)|\(skip\))' | sed '/^.*\/__tests__\/.*:$/d' | cat -s
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
file: ./coverage/lcov.info
token: ${{ secrets.CODECOV_TOKEN }}
- name: Build - name: Build
run: bun run build:vite run: bun run build

10
.gitignore vendored
View File

@@ -13,6 +13,7 @@ src/utils/vendor/
# AI tool runtime directories # AI tool runtime directories
.agents/ .agents/
.claude/ .claude/
.codex/
.omx/ .omx/
.docs/task/ .docs/task/
# Binary / screenshot files (root only) # Binary / screenshot files (root only)
@@ -29,12 +30,3 @@ __pycache__/
logs logs
data data
.omc
.codex/*
!.codex/agents/
!.codex/agents/**
!.codex/skills/
!.codex/skills/**
.codex/skills/.system/**
!.codex/prompts/
!.codex/prompts/**

View File

@@ -1,78 +0,0 @@
# 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`

204
02-kairos (1).md Normal file
View File

@@ -0,0 +1,204 @@
# KAIROS — 永不关机的 Claude
> 源码位置:`src/assistant/`、`src/proactive/`、`src/services/autoDream/`
> 编译开关:`feature('KAIROS')`、`feature('KAIROS_BRIEF')`、`feature('KAIROS_CHANNELS')`
> 远程开关GrowthBook `tengu_kairos`
关掉终端 Claude 还在运行的持久助手模式。KAIROS 是 Claude Code 中最复杂的隐藏功能之一。
---
## 核心概念
KAIROS 让 Claude 从"一次性对话工具"变成"持久运行的 AI 助手"
- 关闭终端后 Claude 仍在后台运行
- 每天自动写日志
- 晚上自动"做梦"整理记忆
- 没人说话时自己找活干
- 命令超 15 秒自动丢后台
---
## 激活流程
定义在 `src/main.tsx`(约第 1054-1092 行),需要通过五层检查:
```
1. feature('KAIROS') ← 编译时 flag
2. settings.assistant: true ← .claude/settings.json
3. 目录信任状态检查 ← 防恶意仓库劫持
4. tengu_kairos ← GrowthBook 远程开关
5. setKairosActive(true) ← 全局状态激活
```
`--assistant` CLI 参数可跳过远程开关检查(用于 Agent SDK daemon 模式)。
全局状态存储在 `src/bootstrap/state.ts`
- `kairosActive: boolean`(默认 `false`
- `getKairosActive()` / `setKairosActive(true)`
---
## 跨会话持久运行
### 会话恢复
`src/utils/conversationRecovery.ts` 中使用 `feature('KAIROS')` 条件导入 `BriefTool``SendUserFileTool`。在反序列化会话时识别这些工具的结果为"终端工具结果",判断 turn 是正常完成还是被中断。
### 持久 Cron 任务
关键在 `.claude/scheduled_tasks.json`。标记为 `permanent: true` 的任务不受 7 天自动过期限制:
- `catch-up`:恢复中断的工作
- `morning-checkin`:每日早间签到
- `dream`:记忆整合
### 会话历史 API
`src/assistant/sessionHistory.ts` 通过 OAuth API 加载远程会话历史,使用 `v1/sessions/{sessionId}/events` 端点,支持分页拉取。
---
## 做梦机制Dream
KAIROS 最精巧的子系统——后台运行的子代理,将分散的会话记忆整合为持久的结构化知识。
### 触发条件(三层门控,由廉到贵)
定义在 `src/services/autoDream/autoDream.ts`
```
1. 时间门控:距上次整合超过 24 小时minHours
2. 会话门控:至少 5 个新会话minSessions
3. 锁门控:没有其他进程正在整合
```
阈值通过 GrowthBook `tengu_onyx_plover` 远程配置动态控制。
### 四阶段整合流程
定义在 `src/services/autoDream/consolidationPrompt.ts`
| 阶段 | 动作 |
|------|------|
| **Orient** | 列出记忆目录、读取 `MEMORY.md` 索引、浏览已有主题文件 |
| **Gather** | 从每日日志、已有记忆、JSONL transcript 中搜集新信号 |
| **Consolidate** | 合并新信号到主题文件,转换相对日期为绝对日期,删除过时事实 |
| **Prune** | 更新 `MEMORY.md` 索引,保持在行数和大小限制内 |
### 锁机制
`src/services/autoDream/consolidationLock.ts`
- 使用 `.consolidate-lock` 文件
- 文件 mtime = `lastConsolidatedAt`
- 文件内容 = 持有者 PID
- 支持 PID 存活检查1 小时超时)
- double-write 后 re-read 验证防竞争
### 每日日志
路径由 `src/memdir/paths.ts``getAutoMemDailyLogPath()` 计算:
```
<autoMemPath>/logs/YYYY/MM/YYYY-MM-DD.md
```
### UI 呈现
- Footer pill 标签显示 **"dreaming"**
- `src/components/tasks/DreamDetailDialog.tsx` 提供专门的详情对话框
- 支持查看实时进度和手动中止
- `Shift+Down` 打开后台任务对话框
---
## 主动模式Proactive Mode
没人说话时 Claude 自己找活干。
### 核心状态
`src/proactive/index.ts` 维护三个状态:
| 状态 | 说明 |
|------|------|
| `active` | 是否激活 |
| `paused` | 是否暂停(用户按 Esc 取消时暂停,下次输入恢复) |
| `contextBlocked` | API 错误时阻塞 tick防止 tick-error-tick 死循环 |
### 激活方式
- `--proactive` CLI 参数
- `CLAUDE_CODE_PROACTIVE` 环境变量
-`feature('PROACTIVE') || feature('KAIROS')` 保护
### 系统提示
激活后追加:
```
# Proactive Mode
You are in proactive mode. Take initiative -- explore, act, and make progress
without waiting for instructions.
Start by briefly greeting the user.
You will receive periodic <tick> prompts. These are check-ins. Do whatever
seems most useful, or call Sleep if there's nothing to do.
```
### SleepTool 集成
设置中的 `minSleepDurationMs``maxSleepDurationMs` 控制 Sleep 持续时间范围,节流 proactive tick 频率。没活干就 Sleep 等着。
---
## 后台任务管理
### Cron 调度器
`src/utils/cronScheduler.ts`
- 每 1 秒 tick 一次(`CHECK_INTERVAL_MS = 1000`
- 使用 chokidar 监视 `.claude/scheduled_tasks.json`
- 支持调度器锁(`src/utils/cronTasksLock.ts`),防止多实例重复触发
- 锁探测间隔 5 秒,持有者崩溃时自动接管
### 任务类型
| 类型 | 说明 |
|------|------|
| 一次性(`recurring: false` | 触发后自动删除,支持错过任务检测 |
| 循环(`recurring: true` | 触发后重新调度,默认 7 天过期 |
| 永久(`permanent: true` | 不受过期限制KAIROS 专用) |
| 会话级(`durable: false` | 仅内存中,进程退出即消失 |
### Jitter 防雷群机制
`src/utils/cronJitterConfig.ts`
- 循环任务:基于 taskId 的确定性延迟interval 的 10%,上限 15 分钟)
- 一次性任务:在 :00 和 :30 施加最多 90 秒提前量
- 运维可在事故期间推送配置变更60 秒内全客户端生效
---
## 关键源码文件
| 文件 | 职责 |
|------|------|
| `src/bootstrap/state.ts` | KAIROS 全局状态 |
| `src/assistant/index.ts` | 助手模式入口 |
| `src/assistant/sessionHistory.ts` | 远程会话历史 API |
| `src/proactive/index.ts` | 主动模式状态管理 |
| `src/services/autoDream/autoDream.ts` | Auto-Dream 引擎 |
| `src/services/autoDream/consolidationPrompt.ts` | 整合提示(四阶段) |
| `src/services/autoDream/consolidationLock.ts` | 整合锁 |
| `src/services/autoDream/config.ts` | Dream 配置 |
| `src/tasks/DreamTask/DreamTask.ts` | Dream 任务定义 |
| `src/utils/cronScheduler.ts` | Cron 调度器 |
| `src/utils/cronTasks.ts` | Cron 任务持久化 |
| `src/skills/bundled/dream.ts` | `/dream` Skill存根 |

283
AGENTS.md Normal file
View File

@@ -0,0 +1,283 @@
# AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
## Project Overview
This is a **reverse-engineered / decompiled** version of Anthropic's official Codex CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced — **`bunx tsc --noEmit` must pass with zero errors**.
## Git Commit Message Convention
使用 **Conventional Commits** 规范:
```
<type>: <描述>
```
常见 type`feat``fix``docs``chore``refactor`
示例:
- `feat: 添加模型 1M 上下文切换`
- `fix: 修复初次登陆的校验问题`
- `chore: remove prefetchOfficialMcpUrls call on startup`
## Commands
```bash
# Install dependencies
bun install
# Dev mode (runs cli.tsx with MACRO defines injected via -d flags)
bun run dev
# Dev mode with debugger (set BUN_INSPECT=9229 to pick port)
bun run dev:inspect
# Pipe mode
echo "say hello" | bun run src/entrypoints/cli.tsx -p
# Build (code splitting, outputs dist/cli.js + chunk files)
bun run build
# Test
bun test # run all tests (2453 tests / 137 files / 0 fail)
bun test src/utils/__tests__/hash.test.ts # run single file
bun test --coverage # with coverage report
# Lint & Format (Biome)
bun run lint # check only
bun run lint:fix # auto-fix
bun run format # format all src/
# Health check
bun run health
# Check unused exports
bun run check:unused
# Remote Control Server
bun run rcs
# Docs dev server (Mintlify)
bun run docs:dev
```
详细的测试规范、覆盖状态和改进计划见 `docs/testing-spec.md`
## Architecture
### Runtime & Build
- **Runtime**: Bun (not Node.js). All imports, builds, and execution use Bun APIs.
- **Build**: `build.ts` 执行 `Bun.build()` with `splitting: true`,入口 `src/entrypoints/cli.tsx`,输出 `dist/cli.js` + chunk files。Build 默认启用 19 个 feature见下方 Feature Flag 段)。构建后自动替换 `import.meta.require` 为 Node.js 兼容版本(产物 bun/node 都可运行)。
- **Dev mode**: `scripts/dev.ts` 通过 Bun `-d` flag 注入 `MACRO.*` defines运行 `src/entrypoints/cli.tsx`。默认启用全部 feature。
- **Module system**: ESM (`"type": "module"`), TSX with `react-jsx` transform.
- **Monorepo**: Bun workspaces — 14 个 internal packages in `packages/` resolved via `workspace:*`
- **Lint/Format**: Biome (`biome.json`)。`bun run lint` / `bun run lint:fix` / `bun run format`
- **Defines**: 集中管理在 `scripts/defines.ts`。当前版本 `2.1.888`
- **CI**: GitHub Actions — `ci.yml`(构建+测试)、`release-rcs.yml`RCS 发布)、`update-contributors.yml`(自动更新贡献者)。
### Entry & Bootstrap
1. **`src/entrypoints/cli.tsx`** (323 行) — True entrypoint。`main()` 函数按优先级处理多条快速路径:
- `--version` / `-v` — 零模块加载
- `--dump-system-prompt` — feature-gated (DUMP_SYSTEM_PROMPT)
- `--Codex-in-chrome-mcp` / `--chrome-native-host`
- `--computer-use-mcp` — 独立 MCP server 模式
- `--daemon-worker=<kind>` — feature-gated (DAEMON)
- `remote-control` / `rc` / `remote` / `sync` / `bridge` — feature-gated (BRIDGE_MODE)
- `daemon` [subcommand] — feature-gated (DAEMON)
- `ps` / `logs` / `attach` / `kill` / `--bg` — feature-gated (BG_SESSIONS)
- `new` / `list` / `reply` — Template job commands
- `environment-runner` / `self-hosted-runner` — BYOC runner
- `--tmux` + `--worktree` 组合
- 默认路径:加载 `main.tsx` 启动完整 CLI
2. **`src/main.tsx`** (~6970 行) — Commander.js CLI definition。注册大量 subcommands`mcp` (serve/add/remove/list...)、`server``ssh``open``auth``plugin``agents``auto-mode``doctor``update` 等。主 `.action()` 处理器负责权限、MCP、会话恢复、REPL/Headless 模式分发。
3. **`src/entrypoints/init.ts`** — One-time initialization (telemetry, config, trust dialog)。
### Core Loop
- **`src/query.ts`** — The main API query function. Sends messages to Codex API, handles streaming responses, processes tool calls, and manages the conversation turn loop.
- **`src/QueryEngine.ts`** — Higher-level orchestrator wrapping `query()`. Manages conversation state, compaction, file history snapshots, attribution, and turn-level bookkeeping. Used by the REPL screen.
- **`src/screens/REPL.tsx`** — The interactive REPL screen (React/Ink component). Handles user input, message display, tool permission prompts, and keyboard shortcuts.
### API Layer
- **`src/services/api/Codex.ts`** — Core API client. Builds request params (system prompt, messages, tools, betas), calls the Anthropic SDK streaming endpoint, and processes `BetaRawMessageStreamEvent` events.
- **7 providers**: `firstParty` (Anthropic direct), `bedrock` (AWS), `vertex` (Google Cloud), `foundry`, `openai`, `gemini`, `grok` (xAI)。
- Provider selection in `src/utils/model/providers.ts`。优先级modelType 参数 > 环境变量 > 默认 firstParty。
### Tool System
- **`src/Tool.ts`** — Tool interface definition (`Tool` type) and utilities (`findToolByName`, `toolMatchesName`).
- **`src/tools.ts`** (387 行) — Tool registry. Assembles the tool list; some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`.
- **`src/tools/<ToolName>/`** — 55 个 tool 目录。主要分类:
- **文件操作**: FileEditTool, FileReadTool, FileWriteTool, GlobTool, GrepTool
- **Shell/执行**: BashTool, PowerShellTool, REPLTool
- **Agent 系统**: AgentTool, TaskCreateTool, TaskUpdateTool, TaskListTool, TaskGetTool
- **规划**: EnterPlanModeTool, ExitPlanModeV2Tool, VerifyPlanExecutionTool
- **Web/MCP**: WebFetchTool, WebSearchTool, MCPTool, McpAuthTool
- **调度**: CronCreateTool, CronDeleteTool, CronListTool
- **其他**: LSPTool, ConfigTool, SkillTool, EnterWorktreeTool, ExitWorktreeTool 等
- **`src/tools/shared/`** — Tool 共享工具函数。
### UI Layer (Ink)
- **`src/ink.ts`** — Ink render wrapper with ThemeProvider injection.
- **`packages/@ant/ink/`** — Custom Ink frameworkforked/internal包含 components、core、hooks、keybindings、theme、utils。注意不是 `src/ink/`
- **`src/components/`** — 149 个组件目录/文件,渲染于终端 Ink 环境中。关键组件:
- `App.tsx` — Root provider (AppState, Stats, FpsMetrics)
- `Messages.tsx` / `MessageRow.tsx` — Conversation message rendering
- `PromptInput/` — User input handling
- `permissions/` — Tool permission approval UI
- `design-system/` — 复用 UI 组件Dialog, FuzzyPicker, ProgressBar, ThemeProvider 等)
- Components use React Compiler runtime (`react/compiler-runtime`) — decompiled output has `_c()` memoization calls throughout.
### State Management
- **`src/state/AppState.tsx`** — Central app state type and context provider. Contains messages, tools, permissions, MCP connections, etc.
- **`src/state/AppStateStore.ts`** — Default state and store factory.
- **`src/state/store.ts`** — Zustand-style store for AppState (`createStore`).
- **`src/state/selectors.ts`** — State selectors.
- **`src/bootstrap/state.ts`** — Module-level singletons for session-global state (session ID, CWD, project root, token counts, model overrides, client type, permission mode).
### Workspace Packages
| Package | 说明 |
|---------|------|
| `packages/@ant/ink/` | Forked Ink 框架components、hooks、keybindings、theme |
| `packages/@ant/computer-use-mcp/` | Computer Use MCP server截图/键鼠/剪贴板/应用管理) |
| `packages/@ant/computer-use-input/` | 键鼠模拟dispatcher + darwin/win32/linux backend |
| `packages/@ant/computer-use-swift/` | 截图 + 应用管理dispatcher + per-platform backend |
| `packages/@ant/Codex-for-chrome-mcp/` | Chrome 浏览器控制(通过 `--chrome` 启用) |
| `packages/remote-control-server/` | 自托管 Remote Control ServerDocker 部署,含 Web UI |
| `packages/swarm/` | Swarm 解耦模块 |
| `packages/shell/` | Shell 抽象 |
| `packages/audio-capture-napi/` | 原生音频捕获(已恢复) |
| `packages/color-diff-napi/` | 颜色差异计算完整实现11 tests |
| `packages/image-processor-napi/` | 图像处理(已恢复) |
| `packages/modifiers-napi/` | 键盘修饰键检测stub |
| `packages/url-handler-napi/` | URL scheme 处理stub |
### Bridge / Remote Control
- **`src/bridge/`** (~37 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` 启动。
- CLI 快速路径: `Codex remote-control` / `Codex rc` / `Codex bridge`
- 详见 `docs/features/remote-control-self-hosting.md`
### Daemon Mode
- **`src/daemon/`** — Daemon 模式(长驻 supervisor。feature-gated by `DAEMON`。包含 `main.ts`entry`workerRegistry.ts`worker 管理)。
### Context & System Prompt
- **`src/context.ts`** — Builds system/user context for the API call (git status, date, AGENTS.md contents, memory files).
- **`src/utils/claudemd.ts`** — Discovers and loads AGENTS.md files from project hierarchy.
### Feature Flag System
Feature flags control which functionality is enabled at runtime. 代码中统一通过 `import { feature } from 'bun:bundle'` 导入,调用 `feature('FLAG_NAME')` 返回 `boolean`
**启用方式**: 环境变量 `FEATURE_<FLAG_NAME>=1`。例如 `FEATURE_BUDDY=1 bun run dev`
**Build 默认 features**19 个,见 `build.ts`:
- 基础: `BUDDY`, `TRANSCRIPT_CLASSIFIER`, `BRIDGE_MODE`, `AGENT_TRIGGERS_REMOTE`, `CHICAGO_MCP`, `VOICE_MODE`
- 统计/缓存: `SHOT_STATS`, `PROMPT_CACHE_BREAK_DETECTION`, `TOKEN_BUDGET`
- P0 本地: `AGENT_TRIGGERS`, `ULTRATHINK`, `BUILTIN_EXPLORE_PLAN_AGENTS`, `LODESTONE`
- P1 API 依赖: `EXTRACT_MEMORIES`, `VERIFICATION_AGENT`, `KAIROS_BRIEF`, `AWAY_SUMMARY`, `ULTRAPLAN`
- P2: `DAEMON`
**Dev mode 默认**: 全部启用(见 `scripts/dev.ts`)。
**类型声明**: `src/types/internal-modules.d.ts` 中声明了 `bun:bundle` 模块的 `feature` 函数签名。
**新增功能的正确做法**: 保留 `import { feature } from 'bun:bundle'` + `feature('FLAG_NAME')` 的标准模式,在运行时通过环境变量或配置控制,不要绕过 feature flag 直接 import。
### Multi-API 兼容层
所有兼容层均采用流适配器模式:将第三方 API 格式转为 Anthropic 内部格式,下游代码完全不改。
#### OpenAI 兼容层
通过 `CLAUDE_CODE_USE_OPENAI=1` 启用,支持 Ollama/DeepSeek/vLLM 等任意 OpenAI Chat Completions 协议端点。含 DeepSeek thinking mode 支持。
- **`src/services/api/openai/`** — client、消息/工具转换、流适配、模型映射
- 关键环境变量:`CLAUDE_CODE_USE_OPENAI``OPENAI_API_KEY``OPENAI_BASE_URL``OPENAI_MODEL`
#### Gemini 兼容层
通过 `CLAUDE_CODE_USE_GEMINI=1` 启用。独立环境变量体系。
- **`src/services/api/gemini/`** — client、模型映射、类型定义
- 关键环境变量:`GEMINI_API_KEY`(必填)、`GEMINI_MODEL`(直接指定)、`GEMINI_DEFAULT_SONNET_MODEL`/`GEMINI_DEFAULT_OPUS_MODEL`(按能力映射)
- 模型映射优先级:`GEMINI_MODEL` > `GEMINI_DEFAULT_*_MODEL` > `ANTHROPIC_DEFAULT_*_MODEL`(已废弃) > 原样返回
#### Grok 兼容层
通过 `CLAUDE_CODE_USE_GROK=1` 启用。自定义模型映射支持 xAI Grok API。
- **`src/services/api/grok/`** — client、模型映射
详见各兼容层的 docs 文档。
### Stubbed/Deleted Modules
| Module | Status |
|--------|--------|
| Computer Use (`@ant/*`) | Restored — macOS + Windows + Linux后端完整度不一 |
| `*-napi` packages | `audio-capture-napi``image-processor-napi` 已恢复;`color-diff-napi` 完整;`modifiers-napi``url-handler-napi` 仍为 stub |
| Voice Mode | Restored — Push-to-Talk 语音输入(需 Anthropic OAuth |
| OpenAI/Gemini/Grok 兼容层 | Restored |
| Remote Control Server | Restored — 自托管 RCS + Web UI |
| Analytics / GrowthBook / Sentry | Empty implementations |
| Magic Docs / LSP Server | Removed |
| Plugins / Marketplace | Removed |
| MCP OAuth | Simplified |
### Key Type Files
- **`src/types/global.d.ts`** — Declares `MACRO`, `BUILD_TARGET`, `BUILD_ENV` and internal Anthropic-only identifiers.
- **`src/types/internal-modules.d.ts`** — Type declarations for `bun:bundle`, `bun:ffi`, `@anthropic-ai/mcpb`.
- **`src/types/message.ts`** — Message type hierarchy (UserMessage, AssistantMessage, SystemMessage, etc.).
- **`src/types/permissions.ts`** — Permission mode and result types.
## Testing
- **框架**: `bun:test`(内置断言 + mock
- **当前状态**: 2472 tests / 138 files / 0 fail
- **单元测试**: 就近放置于 `src/**/__tests__/`,文件名 `<module>.test.ts`
- **集成测试**: `tests/integration/` — 4 个文件cli-arguments, context-build, message-pipeline, tool-chain
- **共享 mock/fixture**: `tests/mocks/`api-responses, file-system, fixtures/
- **命名**: `describe("functionName")` + `test("behavior description")`,英文
- **Mock 模式**: 对重依赖模块使用 `mock.module()` + `await import()` 解锁(必须内联在测试文件中,不能从共享 helper 导入)
- **包测试**: `packages/` 下各包也有独立测试(如 `color-diff-napi` 11 tests
### 类型检查
项目使用 TypeScript strict 模式,**tsc 必须零错误**。每次修改后运行:
```bash
bunx tsc --noEmit
```
**类型规范**
- 生产代码禁止 `as any`;测试文件中 mock 数据可用 `as any`
- 类型不匹配优先用 `as unknown as SpecificType` 双重断言,或补充 interface
- 未知结构对象用 `Record<string, unknown>` 替代 `any`
- 联合类型用类型守卫type guard收窄不要强转
- `msg.request` 属性访问:`const req = msg.request as Record<string, unknown>`
- Ink `color` prop`as keyof Theme` 而非 `as any`
## Working with This Codebase
- **tsc must pass** — `bunx tsc --noEmit` 必须零错误,任何修改都不能引入新的类型错误。
- **Feature flags** — 默认全部关闭(`feature()` 返回 `false`。Dev/build 各有自己的默认启用列表。不要在 `cli.tsx` 中重定义 `feature` 函数。
- **React Compiler output** — Components have decompiled memoization boilerplate (`const $ = _c(N)`). This is normal.
- **`bun:bundle` import** — `import { feature } from 'bun:bundle'` 是 Bun 内置模块,由运行时/构建器解析。不要用自定义函数替代它。**`feature()` 只能直接用在 `if` 语句或三元表达式的条件位置**Bun 编译器限制),不能赋值给变量、不能放在箭头函数体里、不能作为 `&&` 链的一部分。正确:`if (feature('X')) {}``feature('X') ? a : b`
- **`src/` path alias** — tsconfig maps `src/*` to `./src/*`. Imports like `import { ... } from 'src/utils/...'` are valid.
- **MACRO defines** — 集中管理在 `scripts/defines.ts`。Dev mode 通过 `bun -d` 注入build 通过 `Bun.build({ define })` 注入。修改版本号等常量只改这个文件。
- **构建产物兼容 Node.js** — `build.ts` 会自动后处理 `import.meta.require`,产物可直接用 `node dist/cli.js` 运行。
- **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` 注册。

115
CLAUDE.md
View File

@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## Project Overview
This is a **reverse-engineered / decompiled** version of Anthropic's official Claude Code CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced(见 Working with This Codebase 段的 tsc 要求)。 This is a **reverse-engineered / decompiled** version of Anthropic's official Claude Code CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced**`bunx tsc --noEmit` must pass with zero errors**.
## Git Commit Message Convention ## Git Commit Message Convention
@@ -39,11 +39,8 @@ echo "say hello" | bun run src/entrypoints/cli.tsx -p
# Build (code splitting, outputs dist/cli.js + chunk files) # Build (code splitting, outputs dist/cli.js + chunk files)
bun run build bun run build
# Build with Vite (alternative build pipeline)
bun run build:vite
# Test # Test
bun test # run all tests (3175 tests / 207 files / 0 fail) bun test # run all tests (2453 tests / 137 files / 0 fail)
bun test src/utils/__tests__/hash.test.ts # run single file bun test src/utils/__tests__/hash.test.ts # run single file
bun test --coverage # with coverage report bun test --coverage # with coverage report
@@ -58,8 +55,6 @@ bun run health
# Check unused exports # Check unused exports
bun run check:unused bun run check:unused
bun run typecheck
# Remote Control Server # Remote Control Server
bun run rcs bun run rcs
@@ -77,14 +72,14 @@ bun run docs:dev
- **Build**: `build.ts` 执行 `Bun.build()` with `splitting: true`,入口 `src/entrypoints/cli.tsx`,输出 `dist/cli.js` + chunk files。Build 默认启用 19 个 feature见下方 Feature Flag 段)。构建后自动替换 `import.meta.require` 为 Node.js 兼容版本(产物 bun/node 都可运行)。 - **Build**: `build.ts` 执行 `Bun.build()` with `splitting: true`,入口 `src/entrypoints/cli.tsx`,输出 `dist/cli.js` + chunk files。Build 默认启用 19 个 feature见下方 Feature Flag 段)。构建后自动替换 `import.meta.require` 为 Node.js 兼容版本(产物 bun/node 都可运行)。
- **Dev mode**: `scripts/dev.ts` 通过 Bun `-d` flag 注入 `MACRO.*` defines运行 `src/entrypoints/cli.tsx`。默认启用全部 feature。 - **Dev mode**: `scripts/dev.ts` 通过 Bun `-d` flag 注入 `MACRO.*` defines运行 `src/entrypoints/cli.tsx`。默认启用全部 feature。
- **Module system**: ESM (`"type": "module"`), TSX with `react-jsx` transform. - **Module system**: ESM (`"type": "module"`), TSX with `react-jsx` transform.
- **Monorepo**: Bun workspaces — 15workspace packages + 若干辅助目录 in `packages/` resolved via `workspace:*` - **Monorepo**: Bun workspaces — 14internal packages in `packages/` resolved via `workspace:*`
- **Lint/Format**: Biome (`biome.json`)。`bun run lint` / `bun run lint:fix` / `bun run format` - **Lint/Format**: Biome (`biome.json`)。`bun run lint` / `bun run lint:fix` / `bun run format`
- **Defines**: 集中管理在 `scripts/defines.ts`。当前版本 `2.1.888` - **Defines**: 集中管理在 `scripts/defines.ts`。当前版本 `2.1.888`
- **CI**: GitHub Actions — `ci.yml`(构建+测试)、`release-rcs.yml`RCS 发布)、`update-contributors.yml`(自动更新贡献者)。 - **CI**: GitHub Actions — `ci.yml`(构建+测试)、`release-rcs.yml`RCS 发布)、`update-contributors.yml`(自动更新贡献者)。
### Entry & Bootstrap ### Entry & Bootstrap
1. **`src/entrypoints/cli.tsx`** (373 行) — True entrypoint。`main()` 函数按优先级处理多条快速路径: 1. **`src/entrypoints/cli.tsx`** (323 行) — True entrypoint。`main()` 函数按优先级处理多条快速路径:
- `--version` / `-v` — 零模块加载 - `--version` / `-v` — 零模块加载
- `--dump-system-prompt` — feature-gated (DUMP_SYSTEM_PROMPT) - `--dump-system-prompt` — feature-gated (DUMP_SYSTEM_PROMPT)
- `--claude-in-chrome-mcp` / `--chrome-native-host` - `--claude-in-chrome-mcp` / `--chrome-native-host`
@@ -97,7 +92,7 @@ bun run docs:dev
- `environment-runner` / `self-hosted-runner` — BYOC runner - `environment-runner` / `self-hosted-runner` — BYOC runner
- `--tmux` + `--worktree` 组合 - `--tmux` + `--worktree` 组合
- 默认路径:加载 `main.tsx` 启动完整 CLI - 默认路径:加载 `main.tsx` 启动完整 CLI
2. **`src/main.tsx`** (~6981 行) — Commander.js CLI definition。注册大量 subcommands`mcp` (serve/add/remove/list...)、`server``ssh``open``auth``plugin``agents``auto-mode``doctor``update` 等。主 `.action()` 处理器负责权限、MCP、会话恢复、REPL/Headless 模式分发。 2. **`src/main.tsx`** (~6970 行) — Commander.js CLI definition。注册大量 subcommands`mcp` (serve/add/remove/list...)、`server``ssh``open``auth``plugin``agents``auto-mode``doctor``update` 等。主 `.action()` 处理器负责权限、MCP、会话恢复、REPL/Headless 模式分发。
3. **`src/entrypoints/init.ts`** — One-time initialization (telemetry, config, trust dialog)。 3. **`src/entrypoints/init.ts`** — One-time initialization (telemetry, config, trust dialog)。
### Core Loop ### Core Loop
@@ -115,8 +110,8 @@ bun run docs:dev
### Tool System ### Tool System
- **`src/Tool.ts`** — Tool interface definition (`Tool` type) and utilities (`findToolByName`, `toolMatchesName`). - **`src/Tool.ts`** — Tool interface definition (`Tool` type) and utilities (`findToolByName`, `toolMatchesName`).
- **`src/tools.ts`** (392 行) — Tool registry. Assembles the tool list; tools are imported from `@claude-code-best/builtin-tools` package. Some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`. - **`src/tools.ts`** (387 行) — Tool registry. Assembles the tool list; some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`.
- **`packages/builtin-tools/src/tools/`** — 59子目录(含 shared/testing 等工具目录),通过 `@claude-code-best/builtin-tools` 包导出。主要分类: - **`src/tools/<ToolName>/`** — 55 tool 目录。主要分类:
- **文件操作**: FileEditTool, FileReadTool, FileWriteTool, GlobTool, GrepTool - **文件操作**: FileEditTool, FileReadTool, FileWriteTool, GlobTool, GrepTool
- **Shell/执行**: BashTool, PowerShellTool, REPLTool - **Shell/执行**: BashTool, PowerShellTool, REPLTool
- **Agent 系统**: AgentTool, TaskCreateTool, TaskUpdateTool, TaskListTool, TaskGetTool - **Agent 系统**: AgentTool, TaskCreateTool, TaskUpdateTool, TaskListTool, TaskGetTool
@@ -124,6 +119,7 @@ bun run docs:dev
- **Web/MCP**: WebFetchTool, WebSearchTool, MCPTool, McpAuthTool - **Web/MCP**: WebFetchTool, WebSearchTool, MCPTool, McpAuthTool
- **调度**: CronCreateTool, CronDeleteTool, CronListTool - **调度**: CronCreateTool, CronDeleteTool, CronListTool
- **其他**: LSPTool, ConfigTool, SkillTool, EnterWorktreeTool, ExitWorktreeTool 等 - **其他**: LSPTool, ConfigTool, SkillTool, EnterWorktreeTool, ExitWorktreeTool 等
- **`src/tools/shared/`** — Tool 共享工具函数。
### UI Layer (Ink) ### UI Layer (Ink)
@@ -154,17 +150,9 @@ bun run docs:dev
| `packages/@ant/computer-use-input/` | 键鼠模拟dispatcher + darwin/win32/linux backend | | `packages/@ant/computer-use-input/` | 键鼠模拟dispatcher + darwin/win32/linux backend |
| `packages/@ant/computer-use-swift/` | 截图 + 应用管理dispatcher + per-platform backend | | `packages/@ant/computer-use-swift/` | 截图 + 应用管理dispatcher + per-platform backend |
| `packages/@ant/claude-for-chrome-mcp/` | Chrome 浏览器控制(通过 `--chrome` 启用) | | `packages/@ant/claude-for-chrome-mcp/` | Chrome 浏览器控制(通过 `--chrome` 启用) |
| `packages/@ant/model-provider/` | Model provider 抽象层 | | `packages/remote-control-server/` | 自托管 Remote Control ServerDocker 部署,含 Web UI |
| `packages/builtin-tools/` | 内置工具集60 个 tool 实现,通过 `@claude-code-best/builtin-tools` 导出) | | `packages/swarm/` | Swarm 解耦模块 |
| `packages/agent-tools/` | Agent 工具集 | | `packages/shell/` | Shell 抽象 |
| `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 ServerDocker 部署,含 Web UI— Web UI 已重构为 React + Vite + Radix UI支持 ACP agent 接入 |
| `packages/swarm/` | Swarm 解耦模块(非 workspace 包) |
| `packages/shell/` | Shell 抽象(非 workspace 包) |
| `packages/audio-capture-napi/` | 原生音频捕获(已恢复) | | `packages/audio-capture-napi/` | 原生音频捕获(已恢复) |
| `packages/color-diff-napi/` | 颜色差异计算完整实现11 tests | | `packages/color-diff-napi/` | 颜色差异计算完整实现11 tests |
| `packages/image-processor-napi/` | 图像处理(已恢复) | | `packages/image-processor-napi/` | 图像处理(已恢复) |
@@ -173,18 +161,11 @@ bun run docs:dev
### Bridge / Remote Control ### Bridge / Remote Control
- **`src/bridge/`** (~38 files) — Remote Control / Bridge 模式。feature-gated by `BRIDGE_MODE`。包含 bridge API、会话管理、JWT 认证、消息传输、权限回调等。Entry: `bridgeMain.ts` - **`src/bridge/`** (~37 files) — Remote Control / Bridge 模式。feature-gated by `BRIDGE_MODE`。包含 bridge API、会话管理、JWT 认证、消息传输、权限回调等。Entry: `bridgeMain.ts`
- **`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` 启动。 - **`packages/remote-control-server/`** — 自托管 RCS支持 Docker 部署,含 Web UI 控制面板。通过 `bun run rcs` 启动。
- CLI 快速路径: `claude remote-control` / `claude rc` / `claude bridge` - CLI 快速路径: `claude remote-control` / `claude rc` / `claude bridge`
- 详见 `docs/features/remote-control-self-hosting.md` - 详见 `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 ### Daemon Mode
- **`src/daemon/`** — Daemon 模式(长驻 supervisor。feature-gated by `DAEMON`。包含 `main.ts`entry`workerRegistry.ts`worker 管理)。 - **`src/daemon/`** — Daemon 模式(长驻 supervisor。feature-gated by `DAEMON`。包含 `main.ts`entry`workerRegistry.ts`worker 管理)。
@@ -215,13 +196,30 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
### Multi-API 兼容层 ### Multi-API 兼容层
支持 OpenAI、Gemini、Grok 三种第三方 API通过 `/login` 命令配置,均采用流适配器模式转为 Anthropic 内部格式。详见各兼容层的 docs 文档 所有兼容层均采用流适配器模式:将第三方 API 格式转为 Anthropic 内部格式,下游代码完全不改
### 穷鬼模式Budget Mode #### OpenAI 兼容层
- 通过 `/poor` 命令切换,持久化到 `settings.json` 通过 `CLAUDE_CODE_USE_OPENAI=1` 启用,支持 Ollama/DeepSeek/vLLM 等任意 OpenAI Chat Completions 协议端点。含 DeepSeek thinking mode 支持
- 启用后跳过 `extract_memories``prompt_suggestion``verification_agent`,显著减少 token 消耗。
- 实现在 `src/commands/poor/poorMode.ts` - **`src/services/api/openai/`** — client、消息/工具转换、流适配、模型映射
- 关键环境变量:`CLAUDE_CODE_USE_OPENAI``OPENAI_API_KEY``OPENAI_BASE_URL``OPENAI_MODEL`
#### Gemini 兼容层
通过 `CLAUDE_CODE_USE_GEMINI=1` 启用。独立环境变量体系。
- **`src/services/api/gemini/`** — client、模型映射、类型定义
- 关键环境变量:`GEMINI_API_KEY`(必填)、`GEMINI_MODEL`(直接指定)、`GEMINI_DEFAULT_SONNET_MODEL`/`GEMINI_DEFAULT_OPUS_MODEL`(按能力映射)
- 模型映射优先级:`GEMINI_MODEL` > `GEMINI_DEFAULT_*_MODEL` > `ANTHROPIC_DEFAULT_*_MODEL`(已废弃) > 原样返回
#### Grok 兼容层
通过 `CLAUDE_CODE_USE_GROK=1` 启用。自定义模型映射支持 xAI Grok API。
- **`src/services/api/grok/`** — client、模型映射
详见各兼容层的 docs 文档。
### Stubbed/Deleted Modules ### Stubbed/Deleted Modules
@@ -247,29 +245,20 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
## Testing ## Testing
- **框架**: `bun:test`(内置断言 + mock - **框架**: `bun:test`(内置断言 + mock
- **当前状态**: 3175 tests / 207 files / 0 fail - **当前状态**: 2472 tests / 138 files / 0 fail
- **单元测试**: 就近放置于 `src/**/__tests__/`,文件名 `<module>.test.ts` - **单元测试**: 就近放置于 `src/**/__tests__/`,文件名 `<module>.test.ts`
- **集成测试**: `tests/integration/` — 4 个文件cli-arguments, context-build, message-pipeline, tool-chain - **集成测试**: `tests/integration/` — 4 个文件cli-arguments, context-build, message-pipeline, tool-chain
- **共享 mock/fixture**: `tests/mocks/`api-responses, file-system, fixtures/ - **共享 mock/fixture**: `tests/mocks/`api-responses, file-system, fixtures/
- **命名**: `describe("functionName")` + `test("behavior description")`,英文 - **命名**: `describe("functionName")` + `test("behavior description")`,英文
- **Mock 模式**: 对重依赖模块使用 `mock.module()` + `await import()` 解锁(必须内联在测试文件中,不能从共享 helper 导入)
- **包测试**: `packages/` 下各包也有独立测试(如 `color-diff-napi` 11 tests - **包测试**: `packages/` 下各包也有独立测试(如 `color-diff-napi` 11 tests
### Mock 使用规范
**只 mock 有副作用的依赖链,不 mock 纯函数/纯数据模块。**
被迫 mock 的根源:`log.ts` / `debug.ts``bootstrap/state.ts`(模块级 `realpathSync` / `randomUUID` 副作用)。必须 mock 的模块:`log.ts``debug.ts``bun:bundle``settings/settings.js``config.ts``auth.ts`、第三方网络库。
不要 mock纯函数模块`errors.ts``stringUtils.js`、mock 值与真实实现相同的模块、mock 路径与实际 import 不匹配的模块。
路径规则:统一用 `.ts` 扩展名 + `src/*` 别名路径,禁止双重 mock 同一模块。
### 类型检查 ### 类型检查
项目使用 TypeScript strict 模式,**tsc 必须零错误**。每次修改后运行: 项目使用 TypeScript strict 模式,**tsc 必须零错误**。每次修改后运行:
```bash ```bash
bun run typecheck # equivalent to bun run typecheck bunx tsc --noEmit
``` ```
**类型规范** **类型规范**
@@ -282,7 +271,7 @@ bun run typecheck # equivalent to bun run typecheck
## Working with This Codebase ## Working with This Codebase
- **tsc must pass** — `bun run typecheck` 必须零错误,任何修改都不能引入新的类型错误。 - **tsc must pass** — `bunx tsc --noEmit` 必须零错误,任何修改都不能引入新的类型错误。
- **Feature flags** — 默认全部关闭(`feature()` 返回 `false`。Dev/build 各有自己的默认启用列表。不要在 `cli.tsx` 中重定义 `feature` 函数。 - **Feature flags** — 默认全部关闭(`feature()` 返回 `false`。Dev/build 各有自己的默认启用列表。不要在 `cli.tsx` 中重定义 `feature` 函数。
- **React Compiler output** — Components have decompiled memoization boilerplate (`const $ = _c(N)`). This is normal. - **React Compiler output** — Components have decompiled memoization boilerplate (`const $ = _c(N)`). This is normal.
- **`bun:bundle` import** — `import { feature } from 'bun:bundle'` 是 Bun 内置模块,由运行时/构建器解析。不要用自定义函数替代它。**`feature()` 只能直接用在 `if` 语句或三元表达式的条件位置**Bun 编译器限制),不能赋值给变量、不能放在箭头函数体里、不能作为 `&&` 链的一部分。正确:`if (feature('X')) {}``feature('X') ? a : b` - **`bun:bundle` import** — `import { feature } from 'bun:bundle'` 是 Bun 内置模块,由运行时/构建器解析。不要用自定义函数替代它。**`feature()` 只能直接用在 `if` 语句或三元表达式的条件位置**Bun 编译器限制),不能赋值给变量、不能放在箭头函数体里、不能作为 `&&` 链的一部分。正确:`if (feature('X')) {}``feature('X') ? a : b`
@@ -292,29 +281,3 @@ bun run typecheck # equivalent to bun run typecheck
- **Biome 配置** — 大量 lint 规则被关闭decompiled 代码不适合严格 lint`.tsx` 文件用 120 行宽 + 强制分号;其他文件 80 行宽 + 按需分号。 - **Biome 配置** — 大量 lint 规则被关闭decompiled 代码不适合严格 lint`.tsx` 文件用 120 行宽 + 强制分号;其他文件 80 行宽 + 按需分号。
- **Ink 框架在 `packages/@ant/ink/`** — 不是 `src/ink/`该目录不存在。Ink 相关的组件、hooks、keybindings 都在 packages 中。 - **Ink 框架在 `packages/@ant/ink/`** — 不是 `src/ink/`该目录不存在。Ink 相关的组件、hooks、keybindings 都在 packages 中。
- **Provider 优先级** — `modelType` 参数 > 环境变量 > 默认 `firstParty`。新增 provider 需在 `src/utils/model/providers.ts` 注册。 - **Provider 优先级** — `modelType` 参数 > 环境变量 > 默认 `firstParty`。新增 provider 需在 `src/utils/model/providers.ts` 注册。
## Design Context
Impeccable 设计上下文保存在 `.impeccable.md` 中。设计 Web UIRCS 控制面板、文档站、着陆页)时必须参考该文件。
### 核心设计原则
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 产品常见的设计套路(渐变文字、玻璃态、霓虹色)。

View File

@@ -10,25 +10,27 @@
> Which Claude do you like? The open source one is the best. > Which Claude do you like? The open source one is the best.
牢 A (Anthropic) 官方 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI 工具的源码反编译/逆向还原项目。目标是将 Claude Code 大部分功能及工程化能力复现 (问就是老佛爷已经付过钱了)。虽然很难绷, 但是它叫做 CCB(踩踩背)... 而且, 我们实现了企业版或者需要登陆 Claude 账号才能使用的特性, 实现技术普惠 牢 A (Anthropic) 官方 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI 工具的源码反编译/逆向还原项目。目标是将 Claude Code 大部分功能及工程化能力复现 (问就是老佛爷已经付过钱了)。虽然很难绷, 但是它叫做 CCB(踩踩背)...
[文档在这里, 支持投稿 PR](https://ccb.agent-aura.top/) | [留影文档在这里](./Friends.md) | [Discord 群组](https://discord.gg/qZU6zS7Q) [文档在这里, 支持投稿 PR](https://ccb.agent-aura.top/) | [留影文档在这里](./Friends.md) | [Discord 群组](https://discord.gg/qZU6zS7Q)
| 特性 | 说明 | 文档 | | 特性 | 说明 | 文档 |
|------|------|------| |------|------|------|
| **Claude 群控技术** | Pipe IPC 多实例协作:同机 main/sub 自动编排 + LAN 跨机器零配置发现与通讯,`/pipes` 选择面板 + `Shift+↓` 交互 + 消息广播路由 | [Pipe IPC](https://ccb.agent-aura.top/docs/features/pipes-and-lan) / [LAN](https://ccb.agent-aura.top/docs/features/lan-pipes) | | **Claude 群控技术** | Pipe IPC 多实例协作:同机 main/sub 自动编排 + LAN 跨机器零配置发现与通讯,`/pipes` 选择面板 + `Shift+↓` 交互 + 消息广播路由 | [Pipe IPC](https://ccb.agent-aura.top/docs/features/pipes-and-lan) / [LAN](https://ccb.agent-aura.top/docs/features/lan-pipes) |
| **ACP 协议一等一支持** | 支持接入 Zed、Cursor 等 IDE支持会话恢复、Skills、权限桥接 | [文档](https://ccb.agent-aura.top/docs/features/acp-zed) | | Remote Control 私有部署 | Docker 自托管 RCS + Web UI | [文档](https://ccb.agent-aura.top/docs/features/remote-control-self-hosting) |
| **Remote Control 私有部署** | Docker 自托管远程界面, 可以手机上看 CC | [文档](https://ccb.agent-aura.top/docs/features/remote-control-self-hosting) | | /dream 记忆整理 | 自动整理和优化记忆文件 | [文档](https://ccb.agent-aura.top/docs/features/auto-dream) |
| **Langfuse 监控** | 企业级 Agent 监控, 可以清晰看到每次 agent loop 细节, 可以一键转化为数据集 | [文档](https://ccb.agent-aura.top/docs/features/langfuse-monitoring) | | Web Search | 内置网页搜索工具 | [文档](https://ccb.agent-aura.top/docs/features/web-browser-tool) |
| **Web Search** | 内置网页搜索工具, 支持 bing 和 brave 搜索 | [文档](https://ccb.agent-aura.top/docs/features/web-browser-tool) | | 自定义模型供应商 | OpenAI/Anthropic/Gemini/Grok 兼容 | [文档](https://ccb.agent-aura.top/docs/features/custom-platform-login) |
| **Poor Mode** | 穷鬼模式,关闭记忆提取和键入建议,大幅度减少并发请求 | /poor 可以开关 |
| **自定义模型供应商** | OpenAI/Anthropic/Gemini/Grok 兼容 | [文档](https://ccb.agent-aura.top/docs/features/custom-platform-login) |
| Voice Mode | Push-to-Talk 语音输入 | [文档](https://ccb.agent-aura.top/docs/features/voice-mode) | | Voice Mode | Push-to-Talk 语音输入 | [文档](https://ccb.agent-aura.top/docs/features/voice-mode) |
| Computer Use | 屏幕截图、键鼠控制 | [文档](https://ccb.agent-aura.top/docs/features/computer-use) | | Computer Use | 屏幕截图、键鼠控制 | [文档](https://ccb.agent-aura.top/docs/features/computer-use) |
| Chrome Use | 浏览器自动化、表单填写、数据抓取 | [自托管](https://ccb.agent-aura.top/docs/features/chrome-use-mcp) [原生版](https://ccb.agent-aura.top/docs/features/claude-in-chrome-mcp) | | Chrome Use | 浏览器自动化、表单填写、数据抓取 | [魔改版](docs/features/chrome-use-mcp) [原生版](https://ccb.agent-aura.top/docs/features/claude-in-chrome-mcp) |
| Sentry | 企业级错误追踪 | [文档](https://ccb.agent-aura.top/docs/internals/sentry-setup) | | Sentry | 企业级错误追踪 | [文档](https://ccb.agent-aura.top/docs/internals/sentry-setup) |
| GrowthBook | 企业级特性开关 | [文档](https://ccb.agent-aura.top/docs/internals/growthbook-adapter) | | GrowthBook | 企业级特性开关 | [文档](https://ccb.agent-aura.top/docs/internals/growthbook-adapter) |
| /dream 记忆整理 | 自动整理和优化记忆文件 | [文档](https://ccb.agent-aura.top/docs/features/auto-dream) | | Langfuse 监控 | LLM 调用/工具执行/多 Agent 全链路追踪 | [文档](https://ccb.agent-aura.top/docs/features/langfuse-monitoring) |
| Poor Mode | 穷鬼模式,关闭记忆提取和键入建议 | /poor 可以开关 |
- 🔮 [ ] V6 — 大规模重构石山代码全面模块分包全新分支main 封存为历史版本)
- 🚀 [想要启动项目](#快速开始源码版) - 🚀 [想要启动项目](#快速开始源码版)
- 🐛 [想要调试项目](#vs-code-调试) - 🐛 [想要调试项目](#vs-code-调试)

View File

@@ -11,7 +11,6 @@ rmSync(outdir, { recursive: true, force: true })
// Default features that match the official CLI build. // Default features that match the official CLI build.
// Additional features can be enabled via FEATURE_<NAME>=1 env vars. // Additional features can be enabled via FEATURE_<NAME>=1 env vars.
const DEFAULT_BUILD_FEATURES = [ const DEFAULT_BUILD_FEATURES = [
'BUDDY', 'TRANSCRIPT_CLASSIFIER', 'BRIDGE_MODE',
'AGENT_TRIGGERS_REMOTE', 'AGENT_TRIGGERS_REMOTE',
'CHICAGO_MCP', 'CHICAGO_MCP',
'VOICE_MODE', 'VOICE_MODE',
@@ -31,8 +30,6 @@ const DEFAULT_BUILD_FEATURES = [
'ULTRAPLAN', 'ULTRAPLAN',
// P2: daemon + remote control server // P2: daemon + remote control server
'DAEMON', 'DAEMON',
// ACP (Agent Client Protocol) agent mode
'ACP',
// PR-package restored features // PR-package restored features
'WORKFLOW_SCRIPTS', 'WORKFLOW_SCRIPTS',
'HISTORY_SNIP', 'HISTORY_SNIP',
@@ -93,27 +90,8 @@ for (const file of files) {
} }
} }
// Also patch unguarded globalThis.Bun destructuring from third-party deps
// (e.g. @anthropic-ai/sandbox-runtime) so Node.js doesn't crash at import time.
let bunPatched = 0
const BUN_DESTRUCTURE = /var \{([^}]+)\} = globalThis\.Bun;?/g
const BUN_DESTRUCTURE_SAFE = 'var {$1} = typeof globalThis.Bun !== "undefined" ? globalThis.Bun : {};'
for (const file of files) {
if (!file.endsWith('.js')) continue
const filePath = join(outdir, file)
const content = await readFile(filePath, 'utf-8')
if (BUN_DESTRUCTURE.test(content)) {
await writeFile(
filePath,
content.replace(BUN_DESTRUCTURE, BUN_DESTRUCTURE_SAFE),
)
bunPatched++
}
}
BUN_DESTRUCTURE.lastIndex = 0
console.log( console.log(
`Bundled ${result.outputs.length} files to ${outdir}/ (patched ${patched} for import.meta.require, ${bunPatched} for Bun destructure)`, `Bundled ${result.outputs.length} files to ${outdir}/ (patched ${patched} for Node.js compat)`,
) )
// Step 4: Copy native .node addon files (audio-capture) // Step 4: Copy native .node addon files (audio-capture)
@@ -143,7 +121,46 @@ const cliNode = join(outdir, 'cli-node.js')
await writeFile(cliBun, '#!/usr/bin/env bun\nimport "./cli.js"\n') await writeFile(cliBun, '#!/usr/bin/env bun\nimport "./cli.js"\n')
await writeFile(cliNode, '#!/usr/bin/env node\nimport "./cli.js"\n') // Node.js entry needs a Bun API polyfill because Bun.build({ target: 'bun' })
// emits globalThis.Bun references (e.g. Bun.$ shell tag in computer-use-input,
// Bun.which in chunk-ys6smqg9) that crash at import time under plain Node.js.
const NODE_BUN_POLYFILL = `#!/usr/bin/env node
// Bun API polyfill for Node.js runtime
if (typeof globalThis.Bun === "undefined") {
const { execFileSync } = await import("child_process");
const { resolve, delimiter } = await import("path");
const { accessSync, constants: { X_OK } } = await import("fs");
function which(bin) {
const isWin = process.platform === "win32";
const pathExt = isWin ? (process.env.PATHEXT || ".EXE").split(";") : [""];
for (const dir of (process.env.PATH || "").split(delimiter)) {
for (const ext of pathExt) {
const candidate = resolve(dir, bin + ext);
try { accessSync(candidate, X_OK); return candidate; } catch {}
}
}
return null;
}
// Bun.$ is the shell template tag (e.g. $\`osascript ...\`). Only used by
// computer-use-input/darwin — stub it so the top-level destructuring
// \`var { $ } = globalThis.Bun\` doesn't crash.
function $(parts, ...args) {
throw new Error("Bun.$ shell API is not available in Node.js. Use Bun runtime for this feature.");
}
function hash(data, seed) {
let h = ((seed || 0) ^ 0x811c9dc5) >>> 0;
for (let i = 0; i < data.length; i++) {
h ^= data.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
return h;
}
globalThis.Bun = { which, $, hash };
}
import "./cli.js"
`
await writeFile(cliNode, NODE_BUN_POLYFILL)
// NOTE: when new Bun-specific globals appear in bundled output, add them here.
// Make both executable // Make both executable
const { chmodSync } = await import('fs') const { chmodSync } = await import('fs')

1029
bun.lock

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -1,17 +0,0 @@
flowchart TB
START((输入)) --> CTX["Context 管理"]
CTX --> LLM["LLM 流式输出"]
LLM --> TC{tool_use?}
TC --> |是| EXEC["执行工具"]
EXEC --> CTX
TC --> |否| DONE((完成))
classDef proc fill:#eef,stroke:#66c,color:#224
classDef decision fill:#fee,stroke:#c66,color:#422
classDef io fill:#eff,stroke:#6cc,color:#244
class CTX,LLM,EXEC proc
class TC decision
class START,DONE io

View File

@@ -1,40 +0,0 @@
flowchart TB
START((输入)) --> CTX["Context 管理"]
CTX --> PRE["Pre-sampling Hook"]
PRE --> LLM["LLM 流式输出"]
LLM --> TC{tool_use?}
TC --> |是| PERM{需权限?}
PERM --> |是| USER["👤 用户审批"]
USER --> |allow| TOOL_PRE
USER --> |deny| DENIED["拒绝"]
PERM --> |否| TOOL_PRE["Pre-tool Hook"]
TOOL_PRE --> EXEC["并发执行工具"]
EXEC --> TOOL_POST["Post-tool Hook"]
TOOL_POST --> CTX
DENIED --> CTX
TC --> |否| POST["Post-sampling Hook"]
POST --> STOP{"Stop Hook"}
STOP --> |不通过| CTX
STOP --> |通过| BUDGET{"Token Budget"}
BUDGET --> |继续| CTX
BUDGET --> |完成| DONE((完成))
subgraph SUB["子 Agent"]
FORK["AgentTool"] --> RECURSE["递归调用"]
end
EXEC -.-> FORK
classDef proc fill:#eef,stroke:#66c,color:#224
classDef decision fill:#fee,stroke:#c66,color:#422
classDef hook fill:#ffe,stroke:#cc6,color:#442
classDef io fill:#eff,stroke:#6cc,color:#244
classDef sub fill:#efe,stroke:#6a6,color:#242
class CTX,LLM,EXEC proc
class TC,PERM,STOP,BUDGET decision
class PRE,TOOL_PRE,TOOL_POST,POST hook
class START,DONE,USER,DENIED io
class FORK,RECURSE sub

View File

@@ -1,205 +0,0 @@
# 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] <command>...
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=<your-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` | 最大会话数 |

View File

@@ -1,189 +0,0 @@
# ACP (Agent Client Protocol) — Zed / IDE 集成
> Feature Flag: `FEATURE_ACP=1`build 和 dev 模式默认启用)
> 实现状态:可用(支持 Zed、Cursor 等 ACP 客户端)
> 源码目录:`src/services/acp/`
## 一、功能概述
ACP (Agent Client Protocol) 是一种标准化的 stdio 协议,允许 IDE 和编辑器通过 stdin/stdout 的 NDJSON 流驱动 AI Agent。CCB 实现了完整的 ACP agent 端,可以被 Zed、Cursor 等支持 ACP 的客户端直接调用。
### 核心特性
- **会话管理**:新建 / 恢复 / 加载 / 分叉 / 关闭会话
- **历史回放**:恢复会话时自动加载并回放对话历史
- **权限桥接**ACP 客户端的权限决策映射到 CCB 的工具权限系统
- **斜杠命令 & Skills**:加载真实命令列表,支持 `/commit``/review` 等 prompt 型 skill
- **Context Window 跟踪**:精确的 usage_update含 model prefix matching
- **Prompt 排队**:支持连续发送多条 prompt自动排队处理
- **模式切换**auto / default / acceptEdits / plan / dontAsk / bypassPermissions
- **模型切换**:运行时切换 AI 模型
## 二、架构
```
┌──────────────┐ NDJSON/stdio ┌──────────────────┐
│ Zed / IDE │ ◄────────────────► │ CCB ACP Agent │
│ (Client) │ stdin / stdout │ (Agent) │
└──────────────┘ │ │
│ entry.ts │ ← stdio → NDJSON stream
│ agent.ts │ ← ACP protocol handler
│ bridge.ts │ ← SDKMessage → ACP SessionUpdate
│ permissions.ts │ ← 权限桥接
│ utils.ts │ ← 通用工具
│ │
│ QueryEngine │ ← 内部查询引擎
└──────────────────┘
```
### 文件职责
| 文件 | 职责 |
|------|------|
| `entry.ts` | 入口,创建 stdio → NDJSON stream启动 `AgentSideConnection` |
| `agent.ts` | 实现 ACP `Agent` 接口:会话 CRUD、prompt、cancel、模式/模型切换 |
| `bridge.ts` | `SDKMessage` → ACP `SessionUpdate` 转换:文本/思考/工具/用量/编辑 diff |
| `permissions.ts` | ACP `requestPermission()` → CCB `CanUseToolFn` 桥接 |
| `utils.ts` | Pushable、流转换、权限模式解析、session fingerprint、路径显示 |
## 三、配置 Zed 编辑器
### 3.1 Zed settings.json 配置
打开 Zed 的 `settings.json``Cmd+,` → Open Settings添加 `agent_servers` 配置:
```json
{
"agent_servers": {
"ccb": {
"type": "custom",
"command": "ccb",
"args": ["--acp"]
}
}
}
```
### 3.3 API 认证配置
CCB 的 ACP agent 在启动时会自动加载 `settings.json` 中的环境变量(`ANTHROPIC_BASE_URL``ANTHROPIC_AUTH_TOKEN` 等)。确保已通过 `/login` 配置好 API 供应商。
也可通过环境变量传入:
```json
{
"agent_servers": {
"claude-code": {
"command": "ccb",
"args": ["--acp"],
"env": {
"ANTHROPIC_BASE_URL": "https://api.example.com/v1",
"ANTHROPIC_AUTH_TOKEN": "sk-xxx"
}
}
}
}
```
### 3.4 在 Zed 中使用
1. 配置完成后重启 Zed
2. 打开任意项目目录
3.`Cmd+'`macOS`Ctrl+'`Linux打开 Agent Panel
4. 在 Agent Panel 顶部的下拉菜单中选择 **claude-code**
5. 开始对话
### 3.5 功能说明
| 功能 | 操作 |
|------|------|
| 对话 | 在 Agent Panel 中直接输入消息 |
| 斜杠命令 | 输入 `/` 查看可用 skills 列表(如 `/commit``/review` |
| 工具权限 | 弹出权限请求时选择 Allow / Reject / Always Allow |
| 模式切换 | 通过 Agent Panel 的设置菜单切换 auto/default/plan 等模式 |
| 模型切换 | 通过 Agent Panel 的设置菜单切换 AI 模型 |
| 会话恢复 | 关闭重开 Zed 后,之前的会话可自动恢复(含历史消息) |
## 四、配置其他 ACP 客户端
ACP 是开放协议,任何支持 ACP 的客户端都可以连接 CCB。通用配置模式
```
命令: ccb --acp
参数: ["--acp"]
通信: stdin/stdout NDJSON
协议版本: ACP v1
```
### 4.1 Cursor
在 Cursor 的设置中配置 MCP / Agent Server使用同样的 `ccb --acp` 命令。
### 4.2 自定义客户端
使用 `@agentclientprotocol/sdk` 可以快速构建 ACP 客户端:
```typescript
import { ClientSideConnection, ndJsonStream } from '@agentclientprotocol/sdk'
// 创建连接(将 ccb --acp 作为子进程启动)
const child = spawn('ccb', ['--acp'])
const stream = ndJsonStream(
Writable.toWeb(child.stdin),
Readable.toWeb(child.stdout),
)
const client = new ClientSideConnection(stream)
// 初始化
await client.initialize({ clientCapabilities: {} })
// 创建会话
const { sessionId } = await client.newSession({
cwd: '/path/to/project',
})
// 发送 prompt
const response = await client.prompt({
sessionId,
prompt: [{ type: 'text', text: 'Hello, explain this project' }],
})
// 监听 session 更新
client.on('sessionUpdate', (update) => {
console.log('Update:', update)
})
```
## 五、ACP 协议支持矩阵
| 方法 | 状态 | 说明 |
|------|------|------|
| `initialize` | ✅ | 返回 agent 信息和能力 |
| `authenticate` | ✅ | 无需认证(自托管) |
| `newSession` | ✅ | 创建新会话 |
| `resumeSession` | ✅ | 恢复已有会话(含历史回放) |
| `loadSession` | ✅ | 加载指定会话(含历史回放) |
| `listSessions` | ✅ | 列出可用会话 |
| `forkSession` | ✅ | 分叉会话 |
| `closeSession` | ✅ | 关闭会话 |
| `prompt` | ✅ | 发送消息,支持排队 |
| `cancel` | ✅ | 取消当前/排队的 prompt |
| `setSessionMode` | ✅ | 切换权限模式 |
| `setSessionModel` | ✅ | 切换 AI 模型 |
| `setSessionConfigOption` | ✅ | 动态修改配置 |
### SessionUpdate 类型
| 类型 | 状态 | 说明 |
|------|------|------|
| `agent_message_chunk` | ✅ | 助手文本消息 |
| `agent_thought_chunk` | ✅ | 思考/推理内容 |
| `user_message_chunk` | ✅ | 用户消息(历史回放) |
| `tool_call` | ✅ | 工具调用开始 |
| `tool_call_update` | ✅ | 工具调用结果/状态更新 |
| `usage_update` | ✅ | token 用量 + context window |
| `plan` | ✅ | TodoWrite → plan entries |
| `available_commands_update` | ✅ | 斜杠命令 & skills 列表 |
| `current_mode_update` | ✅ | 模式切换通知 |
| `config_option_update` | ✅ | 配置更新通知 |

View File

@@ -6,7 +6,7 @@
### 第一步:安装 Chrome 扩展 ### 第一步:安装 Chrome 扩展
1. 下载扩展https://github.com/hangwin/mcp-chrome/releases 1. 下载扩展https://github.com/hangwin/mcp-chrome/releases(下载最新 zip
2. 解压 zip 文件 2. 解压 zip 文件
3. 打开 Chrome 访问 `chrome://extensions/` 3. 打开 Chrome 访问 `chrome://extensions/`
4. 开启右上角「开发者模式」 4. 开启右上角「开发者模式」

View File

@@ -1,7 +1,7 @@
# KAIROS — 常驻助手模式 # KAIROS — 常驻助手模式
> Feature Flag: `FEATURE_KAIROS=1`(及子 Feature > Feature Flag: `FEATURE_KAIROS=1`(及子 Feature
> 实现状态:核心框架完整,部分子模块为 stubproactive/sleep 节奏控制已可用 > 实现状态:核心框架完整,部分子模块为 stub
> 引用数154全库最大 > 引用数154全库最大
## 一、功能概述 ## 一、功能概述
@@ -74,9 +74,8 @@ KAIROS 在系统提示中注入两大段落:
SleepTool 是 KAIROS/Proactive 的节奏控制核心。工具描述让模型理解"休眠"概念: SleepTool 是 KAIROS/Proactive 的节奏控制核心。工具描述让模型理解"休眠"概念:
- 工具名:`Sleep` - 工具名:`Sleep`
- 功能:等待指定时间后响应 tick prompt;若队列出现新工作或 proactive 被关闭,会提前唤醒 - 功能:等待指定时间后响应 tick prompt
-`<tick_tag>` 配合实现心跳式自主工作 -`<tick_tag>` 配合实现心跳式自主工作
- 远程控制 surfaces 可通过 `automation_state` 看到 `standby` / `sleeping` 两种状态
### 3.3 Bridge 集成 ### 3.3 Bridge 集成
@@ -173,10 +172,8 @@ FEATURE_KAIROS=1 FEATURE_TOKEN_BUDGET=1 bun run dev
| `src/assistant/AssistantSessionChooser.ts` | — | Session 选择 UIstub | | `src/assistant/AssistantSessionChooser.ts` | — | Session 选择 UIstub |
| `src/tools/BriefTool/` | — | BriefTool 实现stub | | `src/tools/BriefTool/` | — | BriefTool 实现stub |
| `src/tools/SleepTool/prompt.ts` | ~30 | SleepTool 工具提示 | | `src/tools/SleepTool/prompt.ts` | ~30 | SleepTool 工具提示 |
| `src/tools/SleepTool/SleepTool.ts` | ~200 | 休眠/唤醒与 automation metadata |
| `src/services/mcp/channelNotification.ts` | 5 | 频道消息接入stub | | `src/services/mcp/channelNotification.ts` | 5 | 频道消息接入stub |
| `src/memdir/memdir.ts` | — | 记忆目录管理stub | | `src/memdir/memdir.ts` | — | 记忆目录管理stub |
| `src/constants/prompts.ts:552-554,843-914` | 72 | 系统提示注入 | | `src/constants/prompts.ts:552-554,843-914` | 72 | 系统提示注入 |
| `src/components/tasks/src/tasks/DreamTask/` | 3 | Dream 任务stub | | `src/components/tasks/src/tasks/DreamTask/` | 3 | Dream 任务stub |
| `src/proactive/index.ts` | — | Proactive 核心KAIROS 共享) | | `src/proactive/index.ts` | — | Proactive 核心(stubKAIROS 共享) |
| `src/utils/sessionState.ts` | — | 向 bridge/CCR 暴露 automation 状态 |

View File

@@ -1,7 +1,7 @@
# PROACTIVE — 主动模式 # PROACTIVE — 主动模式
> Feature Flag: `FEATURE_PROACTIVE=1`(与 `FEATURE_KAIROS=1` 共享功能) > Feature Flag: `FEATURE_PROACTIVE=1`(与 `FEATURE_KAIROS=1` 共享功能)
> 实现状态:核心循环与 SleepTool 已落地,部分外围文档仍在补齐 > 实现状态:核心模块全部 Stub布线完整
> 引用数37 > 引用数37
## 一、功能概述 ## 一、功能概述
@@ -21,13 +21,13 @@ PROACTIVE 实现 Tick 驱动的自主代理。CLI 在用户不输入时也能持
| 模块 | 文件 | 状态 | 说明 | | 模块 | 文件 | 状态 | 说明 |
|------|------|------|------| |------|------|------|------|
| 核心逻辑 | `src/proactive/index.ts` | **已实现** | `activateProactive()``deactivateProactive()``pause/resume``nextTickAt` 调度状态 | | 核心逻辑 | `src/proactive/index.ts` | **Stub** | `activateProactive()``deactivateProactive()``isProactiveActive() => false` |
| SleepTool 提示 | `src/tools/SleepTool/prompt.ts` | **完整** | 工具提示定义(工具名:`Sleep` | | SleepTool 提示 | `src/tools/SleepTool/prompt.ts` | **完整** | 工具提示定义(工具名:`Sleep` |
| 命令注册 | `src/commands.ts:62-65` | **布线** | 动态加载 `./commands/proactive.js` | | 命令注册 | `src/commands.ts:62-65` | **布线** | 动态加载 `./commands/proactive.js` |
| 工具注册 | `src/tools.ts:26-28` | **布线** | SleepTool 动态加载 | | 工具注册 | `src/tools.ts:26-28` | **布线** | SleepTool 动态加载 |
| REPL 集成 | `src/screens/REPL.tsx` | **已实现** | tick 驱动、standby/sleeping 状态、页脚与 bridge automation metadata 上报 | | REPL 集成 | `src/screens/REPL.tsx` | **布线** | tick 驱动逻辑、占位符、页脚 UI |
| 系统提示 | `src/constants/prompts.ts:860-914` | **完整** | 自主工作行为指令(~55 行详细 prompt | | 系统提示 | `src/constants/prompts.ts:860-914` | **完整** | 自主工作行为指令(~55 行详细 prompt |
| 远控状态镜像 | `src/utils/sessionState.ts` | **已实现** | 向 remote-control/CCR 暴露 `automation_state` 元数据 | | 会话存储 | `src/utils/sessionStorage.ts:4892-4912` | **布线** | tick 消息注入对话流 |
### 2.2 系统提示内容 ### 2.2 系统提示内容
@@ -46,7 +46,7 @@ PROACTIVE 实现 Tick 驱动的自主代理。CLI 在用户不输入时也能持
### 2.3 数据流 ### 2.3 数据流
``` ```
activateProactive() activateProactive() [需要实现]
Tick 调度器启动 Tick 调度器启动
@@ -62,22 +62,20 @@ Tick 调度器启动
└── 无事可做 → 必须调用 SleepTool └── 无事可做 → 必须调用 SleepTool
SleepTool 等待 SleepTool 等待 [需要实现]
├── 用户插入新工作 / 队列中有命令 → 立即唤醒
├── proactive 被关闭 → 立即中断
└── 进入休眠时向远端 surfaces 上报 `automation_state = sleeping`
下一个 tick 到达 下一个 tick 到达
``` ```
## 三、当前行为补充 ## 三、需要补全的内容
- `standby`proactive 已开启,当前没有执行中的 turn且已调度下一个 tick。 | 优先级 | 模块 | 工作量 | 说明 |
- `sleeping`:模型显式调用 `SleepTool` 进入等待窗口。 |--------|------|--------|------|
- remote-control/CCR 通过 `external_metadata.automation_state` 接收这两个状态,用于 Web UI 的 Autopilot 状态显示。 | 1 | `src/proactive/index.ts` | 中 | Tick 调度器、activate/deactivate 状态机、pause/resume |
- `SleepTool` 现在不是纯定时器;它会在共享命令队列出现新工作时提前醒来。 | 2 | `src/tools/SleepTool/SleepTool.ts` | 小 | 工具执行(等待指定时间后触发 tick |
| 3 | `src/commands/proactive.js` | 小 | `/proactive` 斜杠命令处理器 |
| 4 | `src/hooks/useProactive.ts` | 中 | React hookREPL 引用但不存在) |
## 四、关键设计决策 ## 四、关键设计决策
@@ -103,11 +101,9 @@ FEATURE_PROACTIVE=1 FEATURE_KAIROS=1 FEATURE_KAIROS_BRIEF=1 bun run dev
| 文件 | 职责 | | 文件 | 职责 |
|------|------| |------|------|
| `src/proactive/index.ts` | 核心逻辑与 next-tick 状态 | | `src/proactive/index.ts` | 核心逻辑stub |
| `src/tools/SleepTool/prompt.ts` | SleepTool 工具提示 | | `src/tools/SleepTool/prompt.ts` | SleepTool 工具提示 |
| `src/tools/SleepTool/SleepTool.ts` | 休眠/唤醒执行逻辑 |
| `src/constants/prompts.ts:860-914` | 自主工作系统提示 | | `src/constants/prompts.ts:860-914` | 自主工作系统提示 |
| `src/screens/REPL.tsx` | REPL tick 集成与 automation 状态上报 | | `src/screens/REPL.tsx` | REPL tick 集成 |
| `src/utils/sessionStorage.ts:4892-4912` | Tick 消息注入 | | `src/utils/sessionStorage.ts:4892-4912` | Tick 消息注入 |
| `src/utils/sessionState.ts` | bridge/CCR metadata 镜像 |
| `src/components/PromptInput/PromptInputFooterLeftSide.tsx` | 页脚 UI 状态 | | `src/components/PromptInput/PromptInputFooterLeftSide.tsx` | 页脚 UI 状态 |

View File

@@ -13,22 +13,17 @@
┌──────────────────┐ HTTP/SSE │ │ In-Memory │ │ ┌──────────────────┐ HTTP/SSE │ │ In-Memory │ │
│ Web UI 控制面板 │ ◄─────────────── │ │ Store │ │ │ Web UI 控制面板 │ ◄─────────────── │ │ Store │ │
│ (/code/*) │ │ └──────────────┘ │ │ (/code/*) │ │ └──────────────┘ │
│ (React + Vite) │ │ ┌──────────────┐ │ └──────────────────┘ │ ┌──────────────┐ │
└──────────────────┘ │ │ JWT Auth │ │ │ │ JWT Auth │ │
│ └──────────────┘ │ │ └──────────────┘ │
┌──────────────────┐ │ ┌──────────────┐ │ ──────────────────────┘
│ acp-link │ ◄── ACP Relay ─── │ │ ACP Handler │ │
│ + ACP Agent │ WebSocket │ └──────────────┘ │
└──────────────────┘ └──────────────────────┘
``` ```
**RCS 是一个纯内存的中间服务**,它的职责是: **RCS 是一个纯内存的中间服务**,它的职责是:
- 接收 Claude Code CLI 的环境注册和工作轮询 - 接收 Claude Code CLI 的环境注册和工作轮询
- 接收 acp-link 的 ACP agent 注册,支持 WebSocket relay 桥接
- 提供 Web UI 供操作者远程监控和审批 - 提供 Web UI 供操作者远程监控和审批
- 通过 WebSocket/SSE 双向传输消息 - 通过 WebSocket/SSE 双向传输消息
- 管理会话、环境、权限请求 - 管理会话、环境、权限请求
- 提供 ACP SSE event stream 供外部消费者订阅 channel group 事件
## 前置条件 ## 前置条件
@@ -143,19 +138,13 @@ bun run dist/cli.js
/remote-control /remote-control
``` ```
环境型 Remote Control例如 `claude remote-control` 子命令)会向 RCS 注册环境,注册成功后在终端显示连接 URL CLI 会向 RCS 注册环境,注册成功后在终端显示连接 URL
``` ```
https://rcs.example.com/code?bridge=<environmentId> https://rcs.example.com/code?bridge=<environmentId>
``` ```
交互式 REPL 方式(`--remote-control``/remote-control`)在某些桥接模式下也可能直接给出会话 URL 同时支持 QR 码扫码打开。该 URL 即 Web UI 控制面板入口,在浏览器中打开即可远程操控当前会话。
```
https://rcs.example.com/code/session_<id>
```
两种 URL 都可以直接在浏览器打开并远程操控当前会话;只有 environment 模式才会出现在 Web UI 的环境列表中。
若已连接,再次执行 `/remote-control` 会显示对话框,包含以下选项: 若已连接,再次执行 `/remote-control` 会显示对话框,包含以下选项:
- **Disconnect this session** — 断开远程连接 - **Disconnect this session** — 断开远程连接
@@ -174,70 +163,15 @@ claude bridge
## Web UI 控制面板 ## Web UI 控制面板
通过 `/remote-control` 命令获取 URL 后,在浏览器打开即可使用。 通过 `/remote-control` 命令获取 URL 后,在浏览器打开即可使用。功能:
### 技术栈v22026-04-18 重构) - 查看已注册的运行环境
Web UI 已从原生 JS 重构为 **React + Vite + Radix UI**
- **框架**: React 19 + Vite 构建TypeScript
- **UI 组件**: Radix UI primitivesDialog、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 的工具权限请求 - 审批 Claude Code 的工具权限请求
- 权限模式选择器6 种模式:默认/自动接受编辑/跳过权限/规划/不询问/自动判断)
- 模型选择器(可选可用模型)
- Plan 可视化(进度条、状态图标、优先级标签)
- ACP QR 扫描自动跳转到 ACP 聊天界面
Web UI 使用 UUID 认证(无需用户账户),适合受信任网络环境。 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 区分。
## 工作流程详解 ## 工作流程详解
``` ```
@@ -275,7 +209,6 @@ ACP session 在 Web UI 中显示紫色标签,与普通 Claude Code session 区
9. 双向通信 9. 双向通信
CLI ──消息/工具调用结果──► RCS ──► Browser CLI ──消息/工具调用结果──► RCS ──► Browser
CLI ◄──权限审批/指令───── RCS ◄──── Browser CLI ◄──权限审批/指令───── RCS ◄──── Browser
CLI ──automation_state / task_state──► RCS ──► Browser
10. 心跳保活(每 20 秒) 10. 心跳保活(每 20 秒)
CLI ──POST /v1/environments/:id/work/:workId/heartbeat──► RCS CLI ──POST /v1/environments/:id/work/:workId/heartbeat──► RCS
@@ -285,13 +218,6 @@ ACP session 在 Web UI 中显示紫色标签,与普通 Claude Code session 区
## 故障排查 ## 故障排查
### Web UI 看不到当前 Autopilot 状态
- `standby`proactive 已开启,正在等待下一个 tick
- `sleeping`:模型正在 `SleepTool` 等待窗口中
这两个状态通过 worker `external_metadata.automation_state` 上报。如果页面只显示普通 working spinner优先检查 CLI 和 RCS 之间的 worker metadata PUT 是否成功。
### CLI 无法连接 ### CLI 无法连接
``` ```
@@ -349,3 +275,4 @@ curl https://rcs.example.com/health
| 依赖 | claude.ai 订阅 + OAuth | 仅需 API Key | | 依赖 | claude.ai 订阅 + OAuth | 仅需 API Key |
自托管模式的核心优势是:设置 `CLAUDE_BRIDGE_BASE_URL` 后,代码自动调用 `isSelfHostedBridge()` 返回 `true`,跳过所有 GrowthBook 和订阅检查,无需 claude.ai 账户即可使用。 自托管模式的核心优势是:设置 `CLAUDE_BRIDGE_BASE_URL` 后,代码自动调用 `isSelfHostedBridge()` 返回 `true`,跳过所有 GrowthBook 和订阅检查,无需 claude.ai 账户即可使用。

View File

@@ -138,7 +138,6 @@
"docs/features/voice-mode", "docs/features/voice-mode",
"docs/features/bridge-mode", "docs/features/bridge-mode",
"docs/features/remote-control-self-hosting", "docs/features/remote-control-self-hosting",
"docs/features/acp-link",
"docs/features/proactive", "docs/features/proactive",
"docs/features/ultraplan" "docs/features/ultraplan"
] ]

View File

@@ -1,6 +1,6 @@
{ {
"name": "claude-code-best", "name": "claude-code-best",
"version": "1.4.3", "version": "1.3.5",
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal", "description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
"type": "module", "type": "module",
"author": "claude-code-best <claude-code-best@proton.me>", "author": "claude-code-best <claude-code-best@proton.me>",
@@ -31,8 +31,7 @@
}, },
"workspaces": [ "workspaces": [
"packages/*", "packages/*",
"packages/@ant/*", "packages/@ant/*"
"packages/@anthropic-ai/*"
], ],
"files": [ "files": [
"dist", "dist",
@@ -41,9 +40,6 @@
], ],
"scripts": { "scripts": {
"build": "bun run build.ts", "build": "bun run build.ts",
"build:vite": "vite build && bun run scripts/post-build.ts",
"build:vite:only": "vite build",
"build:bun": "bun run build.ts",
"dev": "bun run scripts/dev.ts", "dev": "bun run scripts/dev.ts",
"dev:inspect": "bun run scripts/dev-debug.ts", "dev:inspect": "bun run scripts/dev-debug.ts",
"prepublishOnly": "bun run build", "prepublishOnly": "bun run build",
@@ -54,19 +50,19 @@
"test": "bun test", "test": "bun test",
"check:unused": "knip-bun", "check:unused": "knip-bun",
"health": "bun run scripts/health-check.ts", "health": "bun run scripts/health-check.ts",
"postinstall": "node scripts/run-parallel.mjs scripts/postinstall.cjs scripts/setup-chrome-mcp.mjs", "postinstall": "node scripts/postinstall.cjs && node scripts/setup-chrome-mcp.mjs",
"docs:dev": "npx mintlify dev", "docs:dev": "npx mintlify dev",
"typecheck": "tsc --noEmit",
"rcs": "bun run scripts/rcs.ts" "rcs": "bun run scripts/rcs.ts"
}, },
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "^0.19.0", "@claude-code-best/mcp-chrome-bridge": "^2.0.7"
"@claude-code-best/mcp-chrome-bridge": "^2.0.8",
"ws": "^8.20.0"
}, },
"devDependencies": { "devDependencies": {
"@types/he": "^1.2.3",
"@langfuse/otel": "^5.1.0",
"@langfuse/tracing": "^5.1.0",
"@types/lodash-es": "^4.17.12",
"@alcalzone/ansi-tokenize": "^0.3.0", "@alcalzone/ansi-tokenize": "^0.3.0",
"@ant/model-provider": "workspace:*",
"@ant/claude-for-chrome-mcp": "workspace:*", "@ant/claude-for-chrome-mcp": "workspace:*",
"@ant/computer-use-input": "workspace:*", "@ant/computer-use-input": "workspace:*",
"@ant/computer-use-mcp": "workspace:*", "@ant/computer-use-mcp": "workspace:*",
@@ -79,6 +75,9 @@
"@anthropic-ai/sdk": "^0.80.0", "@anthropic-ai/sdk": "^0.80.0",
"@anthropic-ai/vertex-sdk": "^0.14.4", "@anthropic-ai/vertex-sdk": "^0.14.4",
"@anthropic/ink": "workspace:*", "@anthropic/ink": "workspace:*",
"@claude-code-best/builtin-tools": "workspace:*",
"@claude-code-best/agent-tools": "workspace:*",
"@claude-code-best/mcp-client": "workspace:*",
"@aws-sdk/client-bedrock": "^3.1020.0", "@aws-sdk/client-bedrock": "^3.1020.0",
"@aws-sdk/client-bedrock-runtime": "^3.1020.0", "@aws-sdk/client-bedrock-runtime": "^3.1020.0",
"@aws-sdk/client-sts": "^3.1020.0", "@aws-sdk/client-sts": "^3.1020.0",
@@ -86,13 +85,8 @@
"@aws-sdk/credential-providers": "^3.1020.0", "@aws-sdk/credential-providers": "^3.1020.0",
"@azure/identity": "^4.13.1", "@azure/identity": "^4.13.1",
"@biomejs/biome": "^2.4.10", "@biomejs/biome": "^2.4.10",
"@claude-code-best/agent-tools": "workspace:*",
"@claude-code-best/builtin-tools": "workspace:*",
"@claude-code-best/mcp-client": "workspace:*",
"@commander-js/extra-typings": "^14.0.0", "@commander-js/extra-typings": "^14.0.0",
"@growthbook/growthbook": "^1.6.5", "@growthbook/growthbook": "^1.6.5",
"@langfuse/otel": "^5.1.0",
"@langfuse/tracing": "^5.1.0",
"@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/sdk": "^1.29.0",
"@opentelemetry/api": "^1.9.1", "@opentelemetry/api": "^1.9.1",
"@opentelemetry/api-logs": "^0.214.0", "@opentelemetry/api-logs": "^0.214.0",
@@ -115,11 +109,8 @@
"@sentry/node": "^10.47.0", "@sentry/node": "^10.47.0",
"@smithy/core": "^3.23.13", "@smithy/core": "^3.23.13",
"@smithy/node-http-handler": "^4.5.1", "@smithy/node-http-handler": "^4.5.1",
"@types/bun": "^1.3.12", "@types/bun": "^1.3.11",
"@types/cacache": "^20.0.1", "@types/cacache": "^20.0.1",
"@types/he": "^1.2.3",
"@types/lodash-es": "^4.17.12",
"@types/node": "^25.6.0",
"@types/picomatch": "^4.0.3", "@types/picomatch": "^4.0.3",
"@types/plist": "^3.0.5", "@types/plist": "^3.0.5",
"@types/proper-lockfile": "^4.1.4", "@types/proper-lockfile": "^4.1.4",
@@ -175,7 +166,6 @@
"react": "^19.2.4", "react": "^19.2.4",
"react-compiler-runtime": "^1.0.0", "react-compiler-runtime": "^1.0.0",
"react-reconciler": "^0.33.0", "react-reconciler": "^0.33.0",
"rollup": "^4.60.1",
"semver": "^7.7.4", "semver": "^7.7.4",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"shell-quote": "^1.8.3", "shell-quote": "^1.8.3",
@@ -190,11 +180,11 @@
"undici": "^7.24.6", "undici": "^7.24.6",
"url-handler-napi": "workspace:*", "url-handler-napi": "workspace:*",
"usehooks-ts": "^3.1.1", "usehooks-ts": "^3.1.1",
"vite": "^8.0.8",
"vscode-jsonrpc": "^8.2.1", "vscode-jsonrpc": "^8.2.1",
"vscode-languageserver-protocol": "^3.17.5", "vscode-languageserver-protocol": "^3.17.5",
"vscode-languageserver-types": "^3.17.5", "vscode-languageserver-types": "^3.17.5",
"wrap-ansi": "^10.0.0", "wrap-ansi": "^10.0.0",
"ws": "^8.20.0",
"xss": "^1.0.15", "xss": "^1.0.15",
"yaml": "^2.8.3", "yaml": "^2.8.3",
"zod": "^4.3.6" "zod": "^4.3.6"

View File

@@ -1,5 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -5,12 +5,9 @@
* mouse and keyboard via CoreGraphics events and System Events. * mouse and keyboard via CoreGraphics events and System Events.
*/ */
import { execFile, execFileSync } from 'child_process' import { $ } from 'bun'
import { promisify } from 'util'
import type { FrontmostAppInfo, InputBackend } from '../types.js' import type { FrontmostAppInfo, InputBackend } from '../types.js'
const execFileAsync = promisify(execFile)
const KEY_MAP: Record<string, number> = { const KEY_MAP: Record<string, number> = {
return: 36, enter: 36, tab: 48, space: 49, delete: 51, backspace: 51, return: 36, enter: 36, tab: 48, space: 49, delete: 51, backspace: 51,
escape: 53, esc: 53, escape: 53, esc: 53,
@@ -28,17 +25,13 @@ const MODIFIER_MAP: Record<string, string> = {
} }
async function osascript(script: string): Promise<string> { async function osascript(script: string): Promise<string> {
const { stdout } = await execFileAsync('osascript', ['-e', script], { const result = await $`osascript -e ${script}`.quiet().nothrow().text()
encoding: 'utf-8', return result.trim()
})
return stdout.trim()
} }
async function jxa(script: string): Promise<string> { async function jxa(script: string): Promise<string> {
const { stdout } = await execFileAsync('osascript', ['-l', 'JavaScript', '-e', script], { const result = await $`osascript -l JavaScript -e ${script}`.quiet().nothrow().text()
encoding: 'utf-8', return result.trim()
})
return stdout.trim()
} }
function buildMouseJxa(eventType: string, x: number, y: number, btn: number, clickState?: number): string { function buildMouseJxa(eventType: string, x: number, y: number, btn: number, clickState?: number): string {
@@ -122,14 +115,19 @@ export const typeText: InputBackend['typeText'] = async (text) => {
export const getFrontmostAppInfo: InputBackend['getFrontmostAppInfo'] = () => { export const getFrontmostAppInfo: InputBackend['getFrontmostAppInfo'] = () => {
try { try {
const output = execFileSync('osascript', ['-e', ` const result = Bun.spawnSync({
tell application "System Events" cmd: ['osascript', '-e', `
set frontApp to first application process whose frontmost is true tell application "System Events"
set appName to name of frontApp set frontApp to first application process whose frontmost is true
set bundleId to bundle identifier of frontApp set appName to name of frontApp
return bundleId & "|" & appName set bundleId to bundle identifier of frontApp
end tell return bundleId & "|" & appName
`], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }).trim() end tell
`],
stdout: 'pipe',
stderr: 'pipe',
})
const output = new TextDecoder().decode(result.stdout).trim()
if (!output || !output.includes('|')) return null if (!output || !output.includes('|')) return null
const [bundleId, appName] = output.split('|', 2) const [bundleId, appName] = output.split('|', 2)
return { bundleId: bundleId!, appName: appName! } return { bundleId: bundleId!, appName: appName! }

View File

@@ -1,5 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,5 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -274,9 +274,4 @@ export const screenshot: ScreenshotAPI = {
if (displayId !== undefined) args.push('-D', String(displayId)) if (displayId !== undefined) args.push('-D', String(displayId))
return captureScreenToBase64(args) return captureScreenToBase64(args)
}, },
captureWindowTarget(_titleOrHwnd: string | number): ScreenshotResult | null {
// Window capture not supported on macOS via this backend
return null
},
} }

View File

@@ -275,9 +275,4 @@ export const screenshot: ScreenshotAPI = {
return { base64: '', width: 0, height: 0 } return { base64: '', width: 0, height: 0 }
} }
}, },
captureWindowTarget(_titleOrHwnd: string | number): ScreenshotResult | null {
// Window capture not supported on Linux via this backend
return null
},
} }

View File

@@ -76,7 +76,6 @@ export interface ScreenshotAPI {
x: number, y: number, w: number, h: number, x: number, y: number, w: number, h: number,
outW: number, outH: number, quality: number, displayId?: number, outW: number, outH: number, quality: number, displayId?: number,
): Promise<ScreenshotResult> ): Promise<ScreenshotResult>
captureWindowTarget(titleOrHwnd: string | number): ScreenshotResult | null
} }
export interface SwiftBackend { export interface SwiftBackend {

View File

@@ -1,5 +0,0 @@
{
"extends": "../../../tsconfig.json",
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,5 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,18 +0,0 @@
{
"name": "@ant/model-provider",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./types": "./src/types/index.ts",
"./hooks": "./src/hooks/index.ts",
"./client": "./src/client/index.ts"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.80.0",
"openai": "^6.33.0"
}
}

View File

@@ -1,27 +0,0 @@
import type { ClientFactories } from './types.js'
let registeredFactories: ClientFactories | null = null
/**
* Register client factories from the main project.
* Call this during application initialization.
*/
export function registerClientFactories(factories: ClientFactories): void {
registeredFactories = factories
}
/**
* Get registered client factories.
* Throws if not registered (fail-fast).
*/
export function getClientFactories(): ClientFactories {
if (!registeredFactories) {
throw new Error(
'Client factories not registered. ' +
'Call registerClientFactories() during app initialization.',
)
}
return registeredFactories
}
export type { ClientFactories }

View File

@@ -1,35 +0,0 @@
/**
* Client factory interfaces.
* Authentication is handled externally — main project provides factory implementations.
*/
export interface ClientFactories {
/** Get Anthropic client (1st party, Bedrock, Foundry, Vertex) */
getAnthropicClient: (params: {
model?: string
maxRetries: number
fetchOverride?: unknown
source?: string
}) => Promise<unknown>
/** Get OpenAI-compatible client */
getOpenAIClient: (params: {
maxRetries: number
fetchOverride?: unknown
source?: string
}) => unknown
/** Stream Gemini generate content */
streamGeminiGenerateContent: (params: {
model: string
signal?: AbortSignal
fetchOverride?: unknown
body: Record<string, unknown>
}) => AsyncIterable<unknown>
/** Get Grok client (OpenAI-compatible) */
getGrokClient: (params: {
maxRetries: number
fetchOverride?: unknown
source?: string
}) => unknown
}

View File

@@ -1,238 +0,0 @@
import type { APIError } from '@anthropic-ai/sdk'
// SSL/TLS error codes from OpenSSL (used by both Node.js and Bun)
// See: https://www.openssl.org/docs/man3.1/man3/X509_STORE_CTX_get_error.html
const SSL_ERROR_CODES = new Set([
// Certificate verification errors
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
'UNABLE_TO_GET_ISSUER_CERT',
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
'CERT_SIGNATURE_FAILURE',
'CERT_NOT_YET_VALID',
'CERT_HAS_EXPIRED',
'CERT_REVOKED',
'CERT_REJECTED',
'CERT_UNTRUSTED',
// Self-signed certificate errors
'DEPTH_ZERO_SELF_SIGNED_CERT',
'SELF_SIGNED_CERT_IN_CHAIN',
// Chain errors
'CERT_CHAIN_TOO_LONG',
'PATH_LENGTH_EXCEEDED',
// Hostname/altname errors
'ERR_TLS_CERT_ALTNAME_INVALID',
'HOSTNAME_MISMATCH',
// TLS handshake errors
'ERR_TLS_HANDSHAKE_TIMEOUT',
'ERR_SSL_WRONG_VERSION_NUMBER',
'ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC',
])
export type ConnectionErrorDetails = {
code: string
message: string
isSSLError: boolean
}
/**
* Extracts connection error details from the error cause chain.
* The Anthropic SDK wraps underlying errors in the `cause` property.
* This function walks the cause chain to find the root error code/message.
*/
export function extractConnectionErrorDetails(
error: unknown,
): ConnectionErrorDetails | null {
if (!error || typeof error !== 'object') {
return null
}
// Walk the cause chain to find the root error with a code
let current: unknown = error
const maxDepth = 5 // Prevent infinite loops
let depth = 0
while (current && depth < maxDepth) {
if (
current instanceof Error &&
'code' in current &&
typeof current.code === 'string'
) {
const code = current.code
const isSSLError = SSL_ERROR_CODES.has(code)
return {
code,
message: current.message,
isSSLError,
}
}
// Move to the next cause in the chain
if (
current instanceof Error &&
'cause' in current &&
current.cause !== current
) {
current = current.cause
depth++
} else {
break
}
}
return null
}
/**
* Returns an actionable hint for SSL/TLS errors, intended for contexts outside
* the main API client (OAuth token exchange, preflight connectivity checks)
* where `formatAPIError` doesn't apply.
*/
export function getSSLErrorHint(error: unknown): string | null {
const details = extractConnectionErrorDetails(error)
if (!details?.isSSLError) {
return null
}
return `SSL certificate error (${details.code}). If you are behind a corporate proxy or TLS-intercepting firewall, set NODE_EXTRA_CA_CERTS to your CA bundle path, or ask IT to allowlist *.anthropic.com. Run /doctor for details.`
}
/**
* Strips HTML content (e.g., CloudFlare error pages) from a message string,
* returning a user-friendly title or empty string if HTML is detected.
* Returns the original message unchanged if no HTML is found.
*/
function sanitizeMessageHTML(message: string): string {
if (message.includes('<!DOCTYPE html') || message.includes('<html')) {
const titleMatch = message.match(/<title>([^<]+)<\/title>/)
if (titleMatch && titleMatch[1]) {
return titleMatch[1].trim()
}
return ''
}
return message
}
/**
* Detects if an error message contains HTML content (e.g., CloudFlare error pages)
* and returns a user-friendly message instead
*/
export function sanitizeAPIError(apiError: APIError): string {
const message = apiError.message
if (!message) {
return ''
}
return sanitizeMessageHTML(message)
}
/**
* Shapes of deserialized API errors from session JSONL.
*/
type NestedAPIError = {
error?: {
message?: string
error?: { message?: string }
}
}
function hasNestedError(value: unknown): value is NestedAPIError {
return (
typeof value === 'object' &&
value !== null &&
'error' in value &&
typeof value.error === 'object' &&
value.error !== null
)
}
/**
* Extract a human-readable message from a deserialized API error that lacks
* a top-level `.message`.
*/
function extractNestedErrorMessage(error: APIError): string | null {
if (!hasNestedError(error)) {
return null
}
const narrowed: NestedAPIError = error
const nested = narrowed.error
// Standard Anthropic API shape: { error: { error: { message } } }
const deepMsg = nested?.error?.message
if (typeof deepMsg === 'string' && deepMsg.length > 0) {
const sanitized = sanitizeMessageHTML(deepMsg)
if (sanitized.length > 0) {
return sanitized
}
}
// Bedrock shape: { error: { message } }
const msg = nested?.message
if (typeof msg === 'string' && msg.length > 0) {
const sanitized = sanitizeMessageHTML(msg)
if (sanitized.length > 0) {
return sanitized
}
}
return null
}
export function formatAPIError(error: APIError): string {
// Extract connection error details from the cause chain
const connectionDetails = extractConnectionErrorDetails(error)
if (connectionDetails) {
const { code, isSSLError } = connectionDetails
// Handle timeout errors
if (code === 'ETIMEDOUT') {
return 'Request timed out. Check your internet connection and proxy settings'
}
// Handle SSL/TLS errors with specific messages
if (isSSLError) {
switch (code) {
case 'UNABLE_TO_VERIFY_LEAF_SIGNATURE':
case 'UNABLE_TO_GET_ISSUER_CERT':
case 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY':
return 'Unable to connect to API: SSL certificate verification failed. Check your proxy or corporate SSL certificates'
case 'CERT_HAS_EXPIRED':
return 'Unable to connect to API: SSL certificate has expired'
case 'CERT_REVOKED':
return 'Unable to connect to API: SSL certificate has been revoked'
case 'DEPTH_ZERO_SELF_SIGNED_CERT':
case 'SELF_SIGNED_CERT_IN_CHAIN':
return 'Unable to connect to API: Self-signed certificate detected. Check your proxy or corporate SSL certificates'
case 'ERR_TLS_CERT_ALTNAME_INVALID':
case 'HOSTNAME_MISMATCH':
return 'Unable to connect to API: SSL certificate hostname mismatch'
case 'CERT_NOT_YET_VALID':
return 'Unable to connect to API: SSL certificate is not yet valid'
default:
return `Unable to connect to API: SSL error (${code})`
}
}
}
if (error.message === 'Connection error.') {
// If we have a code but it's not SSL, include it for debugging
if (connectionDetails?.code) {
return `Unable to connect to API (${connectionDetails.code})`
}
return 'Unable to connect to API. Check your internet connection'
}
// Guard: when deserialized from JSONL (e.g. --resume), the error object may
// be a plain object without a `.message` property.
if (!error.message) {
return (
extractNestedErrorMessage(error) ??
`API error (status ${error.status ?? 'unknown'})`
)
}
const sanitizedMessage = sanitizeAPIError(error)
// Use sanitized message if it's different from the original (i.e., HTML was sanitized)
return sanitizedMessage !== error.message && sanitizedMessage.length > 0
? sanitizedMessage
: error.message
}

View File

@@ -1,27 +0,0 @@
import type { ModelProviderHooks } from './types.js'
let registeredHooks: ModelProviderHooks | null = null
/**
* Register hooks from the main project.
* Call this during application initialization.
*/
export function registerHooks(hooks: ModelProviderHooks): void {
registeredHooks = hooks
}
/**
* Get registered hooks.
* Throws if hooks not registered (fail-fast).
*/
export function getHooks(): ModelProviderHooks {
if (!registeredHooks) {
throw new Error(
'ModelProvider hooks not registered. ' +
'Call registerHooks() during app initialization.',
)
}
return registeredHooks
}
export type { ModelProviderHooks }

View File

@@ -1,48 +0,0 @@
/**
* Hooks for dependency injection.
* Main project provides implementations; model-provider calls them.
*
* This decouples the model-provider from main project specifics like
* analytics, cost tracking, feature flags, etc.
*/
export interface ModelProviderHooks {
/** Log an analytics event (replaces direct logEvent calls) */
logEvent: (eventName: string, metadata?: Record<string, unknown>) => void
/** Report API cost after each response */
reportCost: (params: {
costUSD: number
usage: Record<string, unknown>
model: string
}) => void
/** Get tool permission context */
getToolPermissionContext?: () => Promise<Record<string, unknown>>
/** Debug logging */
logForDebugging: (msg: string, opts?: { level?: string }) => void
/** Error logging */
logError: (error: Error) => void
/** Get feature flag value */
getFeatureFlag?: (flagName: string) => unknown
/** Get session ID */
getSessionId: () => string
/** Add a notification */
addNotification?: (notification: Record<string, unknown>) => void
/** Get API provider name */
getAPIProvider: () => string
/** Get user ID */
getOrCreateUserID: () => string
/** Check if non-interactive session */
isNonInteractiveSession: () => boolean
/** Get OAuth account info */
getOauthAccountInfo?: () => Record<string, unknown> | undefined
}

View File

@@ -1,63 +0,0 @@
// @ant/model-provider
// Model provider abstraction layer for Claude Code
//
// This package owns the model calling logic and provides:
// - Core query functions (queryModelWithStreaming, etc.)
// - Provider implementations (Anthropic, OpenAI, Gemini, Grok)
// - Type definitions (Message, Tool, Usage, etc.)
// - Dependency injection hooks (analytics, cost tracking, etc.)
//
// Initialization:
// registerClientFactories({ ... }) // inject auth clients
// registerHooks({ ... }) // inject analytics/cost/logging
// Hooks (dependency injection)
export { registerHooks, getHooks } from './hooks/index.js'
export type { ModelProviderHooks } from './hooks/types.js'
// Client factories
export { registerClientFactories, getClientFactories } from './client/index.js'
export type { ClientFactories } from './client/types.js'
// Types
export * from './types/index.js'
// Provider model mappings
export { resolveOpenAIModel } from './providers/openai/modelMapping.js'
export { resolveGrokModel } from './providers/grok/modelMapping.js'
export { resolveGeminiModel } from './providers/gemini/modelMapping.js'
// Gemini provider utilities
export { anthropicMessagesToGemini } from './providers/gemini/convertMessages.js'
export { anthropicToolsToGemini, anthropicToolChoiceToGemini } from './providers/gemini/convertTools.js'
export { adaptGeminiStreamToAnthropic } from './providers/gemini/streamAdapter.js'
export {
GEMINI_THOUGHT_SIGNATURE_FIELD,
type GeminiContent,
type GeminiGenerateContentRequest,
type GeminiPart,
type GeminiStreamChunk,
type GeminiTool,
type GeminiFunctionCallingConfig,
type GeminiFunctionDeclaration,
type GeminiFunctionCall,
type GeminiFunctionResponse,
type GeminiInlineData,
type GeminiUsageMetadata,
type GeminiCandidate,
} from './providers/gemini/types.js'
// Error utilities
export {
formatAPIError,
extractConnectionErrorDetails,
sanitizeAPIError,
getSSLErrorHint,
type ConnectionErrorDetails,
} from './errorUtils.js'
// Shared OpenAI conversion utilities
export { anthropicMessagesToOpenAI } from './shared/openaiConvertMessages.js'
export type { ConvertMessagesOptions } from './shared/openaiConvertMessages.js'
export { anthropicToolsToOpenAI, anthropicToolChoiceToOpenAI } from './shared/openaiConvertTools.js'
export { adaptOpenAIStreamToAnthropic } from './shared/openaiStreamAdapter.js'

View File

@@ -1,54 +0,0 @@
// Error type constants for the model provider package.
// Error string constants extracted from src/services/api/errors.ts.
// The full error handling functions remain in the main project (Phase 4).
export const API_ERROR_MESSAGE_PREFIX = 'API Error'
export const PROMPT_TOO_LONG_ERROR_MESSAGE = 'Prompt is too long'
export const CREDIT_BALANCE_TOO_LOW_ERROR_MESSAGE = 'Credit balance is too low'
export const INVALID_API_KEY_ERROR_MESSAGE = 'Not logged in · Please run /login'
export const INVALID_API_KEY_ERROR_MESSAGE_EXTERNAL =
'Invalid API key · Fix external API key'
export const ORG_DISABLED_ERROR_MESSAGE_ENV_KEY_WITH_OAUTH =
'Your ANTHROPIC_API_KEY belongs to a disabled organization · Unset the environment variable to use your subscription instead'
export const ORG_DISABLED_ERROR_MESSAGE_ENV_KEY =
'Your ANTHROPIC_API_KEY belongs to a disabled organization · Update or unset the environment variable'
export const TOKEN_REVOKED_ERROR_MESSAGE =
'OAuth token revoked · Please run /login'
export const CCR_AUTH_ERROR_MESSAGE =
'Authentication error · This may be a temporary network issue, please try again'
export const REPEATED_529_ERROR_MESSAGE = 'Repeated 529 Overloaded errors'
export const CUSTOM_OFF_SWITCH_MESSAGE =
'Opus is experiencing high load, please use /model to switch to Sonnet'
export const API_TIMEOUT_ERROR_MESSAGE = 'Request timed out'
export const OAUTH_ORG_NOT_ALLOWED_ERROR_MESSAGE =
'Your account does not have access to Claude Code. Please run /login.'
/** Error classification types returned by classifyAPIError */
export type APIErrorClassification =
| 'aborted'
| 'api_timeout'
| 'repeated_529'
| 'capacity_off_switch'
| 'rate_limit'
| 'server_overload'
| 'prompt_too_long'
| 'pdf_too_large'
| 'pdf_password_protected'
| 'image_too_large'
| 'tool_use_mismatch'
| 'unexpected_tool_result'
| 'duplicate_tool_use_id'
| 'invalid_model'
| 'credit_balance_low'
| 'invalid_api_key'
| 'token_revoked'
| 'oauth_org_not_allowed'
| 'auth_error'
| 'bedrock_model_access'
| 'server_error'
| 'client_error'
| 'ssl_cert_error'
| 'connection_error'
| 'unknown'

View File

@@ -1,6 +0,0 @@
// Type definitions for @ant/model-provider
export * from './message.js'
export * from './usage.js'
export * from './errors.js'
export * from './systemPrompt.js'

View File

@@ -1,129 +0,0 @@
// Core message types for the model provider package.
// Moved from src/types/message.ts to decouple the API layer from the main project.
import type { UUID } from 'crypto'
import type {
ContentBlockParam,
ContentBlock,
} from '@anthropic-ai/sdk/resources/index.mjs'
import type { BetaUsage } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
/**
* Base message type with discriminant `type` field and common properties.
* Individual message subtypes (UserMessage, AssistantMessage, etc.) extend
* this with narrower `type` literals and additional fields.
*/
export type MessageType = 'user' | 'assistant' | 'system' | 'attachment' | 'progress' | 'grouped_tool_use' | 'collapsed_read_search'
/** A single content element inside message.content arrays. */
export type ContentItem = ContentBlockParam | ContentBlock
export type MessageContent = string | ContentBlockParam[] | ContentBlock[]
/**
* Typed content array — used in narrowed message subtypes so that
* `message.content[0]` resolves to `ContentItem` instead of
* `string | ContentBlockParam | ContentBlock`.
*/
export type TypedMessageContent = ContentItem[]
export type Message = {
type: MessageType
uuid: UUID
isMeta?: boolean
isCompactSummary?: boolean
toolUseResult?: unknown
isVisibleInTranscriptOnly?: boolean
attachment?: { type: string; toolUseID?: string; [key: string]: unknown; addedNames: string[]; addedLines: string[]; removedNames: string[] }
message?: {
role?: string
id?: string
content?: MessageContent
usage?: BetaUsage | Record<string, unknown>
[key: string]: unknown
}
[key: string]: unknown
}
export type AssistantMessage = Message & {
type: 'assistant'
message: NonNullable<Message['message']>
}
export type AttachmentMessage<T = { type: string; [key: string]: unknown }> = Message & { type: 'attachment'; attachment: T }
export type ProgressMessage<T = unknown> = Message & { type: 'progress'; data: T }
export type SystemLocalCommandMessage = Message & { type: 'system' }
export type SystemMessage = Message & { type: 'system' }
export type UserMessage = Message & {
type: 'user'
message: NonNullable<Message['message']>
imagePasteIds?: number[]
}
export type NormalizedUserMessage = UserMessage
export type RequestStartEvent = { type: string; [key: string]: unknown }
export type StreamEvent = { type: string; [key: string]: unknown }
export type SystemCompactBoundaryMessage = Message & {
type: 'system'
compactMetadata: {
preservedSegment?: {
headUuid: UUID
tailUuid: UUID
anchorUuid: UUID
[key: string]: unknown
}
[key: string]: unknown
}
}
export type TombstoneMessage = Message
export type ToolUseSummaryMessage = Message
export type MessageOrigin = string
export type CompactMetadata = Record<string, unknown>
export type SystemAPIErrorMessage = Message & { type: 'system' }
export type SystemFileSnapshotMessage = Message & { type: 'system' }
export type NormalizedAssistantMessage<T = unknown> = AssistantMessage
export type NormalizedMessage = Message
export type PartialCompactDirection = string
export type StopHookInfo = {
command?: string
durationMs?: number
[key: string]: unknown
}
export type SystemAgentsKilledMessage = Message & { type: 'system' }
export type SystemApiMetricsMessage = Message & { type: 'system' }
export type SystemAwaySummaryMessage = Message & { type: 'system' }
export type SystemBridgeStatusMessage = Message & { type: 'system' }
export type SystemInformationalMessage = Message & { type: 'system' }
export type SystemMemorySavedMessage = Message & { type: 'system' }
export type SystemMessageLevel = string
export type SystemMicrocompactBoundaryMessage = Message & { type: 'system' }
export type SystemPermissionRetryMessage = Message & { type: 'system' }
export type SystemScheduledTaskFireMessage = Message & { type: 'system' }
export type SystemStopHookSummaryMessage = Message & {
type: 'system'
subtype: string
hookLabel: string
hookCount: number
totalDurationMs?: number
hookInfos: StopHookInfo[]
}
export type SystemTurnDurationMessage = Message & { type: 'system' }
export type GroupedToolUseMessage = Message & {
type: 'grouped_tool_use'
toolName: string
messages: NormalizedAssistantMessage[]
results: NormalizedUserMessage[]
displayMessage: NormalizedAssistantMessage | NormalizedUserMessage
}
// CollapsibleMessage is used by the main project's CollapsedReadSearchGroup
export type CollapsibleMessage =
| AssistantMessage
| UserMessage
| GroupedToolUseMessage
export type HookResultMessage = Message
export type SystemThinkingMessage = Message & { type: 'system' }

View File

@@ -1,10 +0,0 @@
// System prompt branded type.
// Dependency-free so it can be imported from anywhere without circular imports.
export type SystemPrompt = readonly string[] & {
readonly __brand: 'SystemPrompt'
}
export function asSystemPrompt(value: readonly string[]): SystemPrompt {
return value as SystemPrompt
}

View File

@@ -1,49 +0,0 @@
// Usage types for the model provider package.
// Moved from src/entrypoints/sdk/sdkUtilityTypes.ts and src/services/api/emptyUsage.ts
/**
* Non-nullable usage object representing token consumption from an API response.
* Moved from src/entrypoints/sdk/sdkUtilityTypes.ts
*/
export type NonNullableUsage = {
inputTokens?: number
outputTokens?: number
cacheReadInputTokens?: number
cacheCreationInputTokens?: number
input_tokens: number
cache_creation_input_tokens: number
cache_read_input_tokens: number
output_tokens: number
server_tool_use: { web_search_requests: number; web_fetch_requests: number }
service_tier: string
cache_creation: {
ephemeral_1h_input_tokens: number
ephemeral_5m_input_tokens: number
}
inference_geo: string
iterations: unknown[]
speed: string
cache_deleted_input_tokens?: number
[key: string]: unknown
}
/**
* Zero-initialized usage object. Extracted from logging.ts so that
* bridge/replBridge.ts can import it without transitively pulling in
* api/errors.ts → utils/messages.ts → BashTool.tsx → the world.
*/
export const EMPTY_USAGE: Readonly<NonNullableUsage> = {
input_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
output_tokens: 0,
server_tool_use: { web_search_requests: 0, web_fetch_requests: 0 },
service_tier: 'standard',
cache_creation: {
ephemeral_1h_input_tokens: 0,
ephemeral_5m_input_tokens: 0,
},
inference_geo: '',
iterations: [],
speed: 'standard',
}

View File

@@ -1,7 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true
},
"include": ["src/**/*.ts", "src/**/*.tsx"]
}

View File

@@ -1,34 +0,0 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

View File

@@ -1,89 +0,0 @@
# acp-link
ACP proxy server that bridges WebSocket clients to ACP (Agent Client Protocol) agents.
> Source code adapted from [chrome-acp](https://github.com/Areo-Joe/chrome-acp).
## Installation
### From source
```bash
# From monorepo root
bun install
```
## Usage
```bash
# Via global install
acp-link /path/to/agent
# Via source
bun src/cli/bin.ts /path/to/agent
```
### Examples
```bash
# Basic usage
acp-link /path/to/agent
# With custom port and host
acp-link --port 9000 --host 0.0.0.0 /path/to/agent
# With debug logging
acp-link --debug /path/to/agent
# Enable HTTPS with self-signed certificate
acp-link --https /path/to/agent
# Disable authentication (dangerous)
acp-link --no-auth /path/to/agent
# Pass arguments to the agent (use -- to separate)
acp-link /path/to/agent -- --verbose --model gpt-4
```
## CLI Reference
```
USAGE
acp-link [--port value] [--host value] [--debug] [--no-auth] [--https] <command>...
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
```
## How It Works
1. Listens for WebSocket connections from clients
2. When a "connect" message is received, spawns the configured ACP agent as a subprocess
3. Bridges messages between the WebSocket (client) and stdin/stdout (agent via ACP protocol)
4. Supports session management: create, load, resume, list sessions
5. Handles permission approval flow and heartbeat keepalive
## Authentication
By default, a random token is auto-generated on startup. Pass it as a query parameter:
```
ws://localhost:9315/ws?token=<your-token>
```
Set `ACP_AUTH_TOKEN` env var to use a fixed token, or use `--no-auth` to disable (not recommended).
## License
MIT

View File

@@ -1,39 +0,0 @@
{
"name": "acp-link",
"version": "1.0.1",
"description": "ACP proxy server that bridges WebSocket clients to ACP agents",
"author": "claude-code-best",
"type": "module",
"main": "./dist/server.js",
"types": "./dist/server.d.ts",
"bin": {
"acp-link": "dist/cli/bin.js"
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"dev": "bun run src/cli/bin.ts",
"prepublishOnly": "bun run build"
},
"devDependencies": {
"@types/selfsigned": "^2.0.4",
"@types/ws": "^8.18.1"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.19.0",
"@hono/node-server": "^1.13.8",
"@hono/node-ws": "^1.0.5",
"@stricli/auto-complete": "^1.2.4",
"@stricli/core": "^1.2.4",
"hono": "^4.7.0",
"pino": "^10.3.0",
"pino-pretty": "^13.1.3",
"selfsigned": "^5.5.0"
},
"engines": {
"node": ">=18"
},
"license": "MIT"
}

View File

@@ -1,28 +0,0 @@
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);
});
});

View File

@@ -1,75 +0,0 @@
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);
});
});

View File

@@ -1,69 +0,0 @@
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);
});
});

View File

@@ -1,174 +0,0 @@
/**
* 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,
};
}

View File

@@ -1,18 +0,0 @@
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,
},
});

View File

@@ -1,7 +0,0 @@
#!/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());

View File

@@ -1,90 +0,0 @@
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 });
},
});

View File

@@ -1,10 +0,0 @@
import type { CommandContext } from "@stricli/core";
export interface LocalContext extends CommandContext {}
export function buildContext(): LocalContext {
return {
process,
};
}

View File

@@ -1,83 +0,0 @@
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 });
}

View File

@@ -1,258 +0,0 @@
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);
}
}

View File

@@ -1,895 +0,0 @@
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;
/** Default permission mode for new sessions (e.g. "auto", "default", "bypassPermissions") */
permissionMode?: string;
}
// 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;
let DEFAULT_PERMISSION_MODE: 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; permissionMode?: 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 permissionMode = params.permissionMode || DEFAULT_PERMISSION_MODE;
const result = await state.connection.newSession({
cwd: sessionCwd,
mcpServers: [],
...(permissionMode ? { _meta: { permissionMode } } : {}),
});
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;
DEFAULT_PERMISSION_MODE = config.permissionMode || process.env.ACP_PERMISSION_MODE;
// 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; permissionMode?: 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; permissionMode?: 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);
});

View File

@@ -1,150 +0,0 @@
// 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;
}

View File

@@ -1,37 +0,0 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ES2022",
"module": "NodeNext",
"moduleDetection": "force",
"allowJs": true,
// Node.js module resolution
"moduleResolution": "NodeNext",
"verbatimModuleSyntax": true,
// Output
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"esModuleInterop": true,
"resolveJsonModule": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/__tests__"]
}

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import type { CoreTool, Tool, Tools, AnyObject, ToolResult, ValidationResult, PermissionResult } from '@claude-code-best/agent-tools' import type { CoreTool, Tool, Tools, AnyObject, ToolResult, ValidationResult, PermissionResult } from '@claude-code-best/agent-tools'
import type { Tool as HostTool } from '../../../../src/Tool.js' import type { Tool as HostTool } from '../../src/Tool.js'
describe('agent-tools compatibility', () => { describe('agent-tools compatibility', () => {
test('CoreTool structural compatibility with host Tool', () => { test('CoreTool structural compatibility with host Tool', () => {
@@ -27,7 +27,7 @@ describe('agent-tools compatibility', () => {
} }
// This assignment should work if HostTool structurally extends CoreTool // This assignment should work if HostTool structurally extends CoreTool
const coreTool: CoreTool = mockHostTool as unknown as CoreTool const coreTool: CoreTool = mockHostTool as CoreTool
expect(coreTool.name).toBe('test') expect(coreTool.name).toBe('test')
expect(coreTool.isEnabled()).toBe(true) expect(coreTool.isEnabled()).toBe(true)
}) })

View File

@@ -1,5 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,5 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -7,17 +7,6 @@ mock.module("src/utils/model/agent.js", () => ({
mock.module("src/utils/settings/constants.js", () => ({ mock.module("src/utils/settings/constants.js", () => ({
getSourceDisplayName: (source: string) => source, getSourceDisplayName: (source: string) => source,
getSourceDisplayNameLowercase: (source: string) => source,
getSourceDisplayNameCapitalized: (source: string) => source,
getSettingSourceName: (source: string) => source,
getSettingSourceDisplayNameLowercase: (source: string) => source,
getSettingSourceDisplayNameCapitalized: (source: string) => source,
parseSettingSourcesFlag: () => [],
getEnabledSettingSources: () => [],
isSettingSourceEnabled: () => true,
SETTING_SOURCES: ["localSettings", "userSettings", "projectSettings"],
SOURCES: ["localSettings", "userSettings", "projectSettings"],
CLAUDE_CODE_SETTINGS_SCHEMA_URL: "https://json.schemastore.org/claude-code-settings.json",
})); }));
const { const {

View File

@@ -87,7 +87,7 @@ mock.module("src/tasks/LocalAgentTask/LocalAgentTask.js", () => ({
updateProgressFromMessage: noop, updateProgressFromMessage: noop,
})); }));
mock.module("src/utils/debug.ts", () => ({ mock.module("src/utils/debug.js", () => ({
getMinDebugLogLevel: () => "warn", getMinDebugLogLevel: () => "warn",
isDebugMode: () => false, isDebugMode: () => false,
enableDebugLogging: () => false, enableDebugLogging: () => false,

View File

@@ -1,4 +1,11 @@
import { describe, expect, test } from "bun:test"; import { mock, describe, expect, test } from "bun:test";
// Mock commands.ts to cut the heavy shell/prefix.ts → analytics → api chain
mock.module("src/utils/bash/commands.ts", () => ({
splitCommand_DEPRECATED: (cmd: string) =>
cmd.split(/\s*(?:[|;&]+)\s*/).filter(Boolean),
quote: (args: string[]) => args.join(" "),
}));
const { interpretCommandResult } = await import("../commandSemantics"); const { interpretCommandResult } = await import("../commandSemantics");

View File

@@ -1,10 +1,19 @@
import { mock, describe, expect, test } from "bun:test"; import { mock, describe, expect, test } from "bun:test";
mock.module("src/utils/debug.ts", () => ({ mock.module("src/utils/debug.js", () => ({
logForDebugging: () => {}, logForDebugging: () => {},
isDebugMode: () => false, isDebugMode: () => false,
})); }));
mock.module("src/utils/errors.js", () => ({
errorMessage: (e: unknown) => String(e),
}));
mock.module("src/utils/stringUtils.js", () => ({
plural: (n: number, singular: string, plural?: string) =>
n === 1 ? singular : (plural ?? singular + "s"),
}));
const { const {
formatGoToDefinitionResult, formatGoToDefinitionResult,
formatFindReferencesResult, formatFindReferencesResult,

View File

@@ -7,18 +7,6 @@ mock.module("src/utils/cwd.js", () => ({
getCwd: () => mockCwd, getCwd: () => mockCwd,
})); }));
// Defensive: agent.test.ts can corrupt Bun's src/* path alias at runtime.
mock.module("src/utils/powershell/parser.js", () => ({
PS_TOKENIZER_DASH_CHARS: new Set(['-', '\u2013', '\u2014', '\u2015']),
COMMON_ALIASES: {},
commandHasArgAbbreviation: () => false,
deriveSecurityFlags: () => ({}),
getAllCommands: () => [],
getVariablesByScope: () => [],
hasCommandNamed: () => false,
parsePowerShellCommandCached: () => ({ valid: false, errors: [], statements: [], variables: [], hasStopParsing: false, originalCommand: "" }),
}))
const { isGitInternalPathPS, isDotGitPathPS } = await import("../gitSafety"); const { isGitInternalPathPS, isDotGitPathPS } = await import("../gitSafety");
describe("isGitInternalPathPS", () => { describe("isGitInternalPathPS", () => {

View File

@@ -32,58 +32,6 @@ mock.module("src/utils/powershell/dangerousCmdlets.js", () => ({
]), ]),
})); }));
// Defensive: agent.test.ts can corrupt Bun's src/* path alias at runtime.
// Provide parser stubs so powershellSecurity.ts loads without the alias.
// The tests build ParsedPowerShellCommand objects manually via makeParsed(),
// so the real parser implementations are not needed for these specific tests.
const MOCK_COMMON_ALIASES: Record<string, string> = {
iex: "Invoke-Expression",
ii: "Invoke-Item",
sal: "Set-Alias",
ipmo: "Import-Module",
iwmi: "Invoke-WmiMethod",
saps: "Start-Process",
start: "Start-Process",
};
mock.module("src/utils/powershell/parser.js", () => ({
COMMON_ALIASES: MOCK_COMMON_ALIASES,
commandHasArgAbbreviation: (cmd: any, fullParam: string, minPrefix: string) => {
const fullLower = fullParam.toLowerCase()
const prefixLower = minPrefix.toLowerCase()
return cmd.args.some((a: string) => {
const lower = a.toLowerCase()
const colonIdx = lower.indexOf(':')
const paramPart = colonIdx > 0 ? lower.slice(0, colonIdx) : lower
return paramPart.startsWith(prefixLower) && fullLower.startsWith(paramPart)
})
},
deriveSecurityFlags: () => ({ hasRedirectToVariable: false, hasPipelineVariable: false, hasFormatHex: false, hasScriptBlocks: false, hasSubExpressions: false, hasExpandableStrings: false, hasSplatting: false, hasStopParsing: false, hasMemberInvocations: false, hasAssignments: false }),
getAllCommands: (parsed: any) => parsed.statements.flatMap((s: any) => s.commands || []),
getVariablesByScope: () => [],
hasCommandNamed: (parsed: any, name: string) => {
const lower = name.toLowerCase()
const canonicalFromAlias = MOCK_COMMON_ALIASES[lower]?.toLowerCase()
return parsed.statements.some((s: any) => (s.commands || []).some((c: any) => {
const cmdLower = c.name.toLowerCase()
if (cmdLower === lower) return true
const canonical = MOCK_COMMON_ALIASES[cmdLower]?.toLowerCase()
if (canonical === lower) return true
if (canonicalFromAlias && cmdLower === canonicalFromAlias) return true
return false
}))
},
parsePowerShellCommandCached: () => ({ valid: false, errors: [], statements: [], variables: [], hasStopParsing: false, originalCommand: "" }),
PARSE_SCRIPT_BODY: "",
WINDOWS_MAX_COMMAND_LENGTH: 32000,
MAX_COMMAND_LENGTH: 32000,
PS_TOKENIZER_DASH_CHARS: new Set(['-', '\u2013', '\u2014', '\u2015']),
mapStatementType: (t: string) => t,
mapElementType: (t: string) => t,
classifyCommandName: () => ({ type: 'external', name: '' }),
stripModulePrefix: (n: string) => n,
}));
// Real parser functions work without mocks since they're pure // Real parser functions work without mocks since they're pure
const { powershellCommandIsSafe } = await import("../powershellSecurity.js"); const { powershellCommandIsSafe } = await import("../powershellSecurity.js");

View File

@@ -3,11 +3,8 @@ import { z } from 'zod/v4'
import type { ToolResultBlockParam } from 'src/Tool.js' import type { ToolResultBlockParam } from 'src/Tool.js'
import { buildTool } from 'src/Tool.js' import { buildTool } from 'src/Tool.js'
import { lazySchema } from 'src/utils/lazySchema.js' import { lazySchema } from 'src/utils/lazySchema.js'
import { notifyAutomationStateChanged } from 'src/utils/sessionState.js'
import { SLEEP_TOOL_NAME, DESCRIPTION, SLEEP_TOOL_PROMPT } from './prompt.js' import { SLEEP_TOOL_NAME, DESCRIPTION, SLEEP_TOOL_PROMPT } from './prompt.js'
const SLEEP_WAKE_CHECK_INTERVAL_MS = 500
const inputSchema = lazySchema(() => const inputSchema = lazySchema(() =>
z.strictObject({ z.strictObject({
duration_seconds: z duration_seconds: z
@@ -22,36 +19,6 @@ type SleepInput = z.infer<InputSchema>
type SleepOutput = { slept_seconds: number; interrupted: boolean } type SleepOutput = { slept_seconds: number; interrupted: boolean }
function isProactiveAutomationEnabled(): boolean {
if (!(feature('PROACTIVE') || feature('KAIROS'))) {
return false
}
const mod =
require('src/proactive/index.js') as typeof import('src/proactive/index.js')
return mod.isProactiveActive()
}
function isProactiveSleepAllowed(): boolean {
if (!(feature('PROACTIVE') || feature('KAIROS'))) {
return true
}
const mod =
require('src/proactive/index.js') as typeof import('src/proactive/index.js')
return mod.isProactiveActive()
}
function hasQueuedWakeSignal(): boolean {
const queue =
require('src/utils/messageQueueManager.js') as typeof import('src/utils/messageQueueManager.js')
return queue.hasCommandsInQueue()
}
function shouldInterruptSleep(): boolean {
return !isProactiveSleepAllowed() || hasQueuedWakeSignal()
}
export const SleepTool = buildTool({ export const SleepTool = buildTool({
name: SLEEP_TOOL_NAME, name: SLEEP_TOOL_NAME,
searchHint: 'wait pause sleep rest idle duration timer', searchHint: 'wait pause sleep rest idle duration timer',
@@ -75,9 +42,6 @@ export const SleepTool = buildTool({
isReadOnly() { isReadOnly() {
return true return true
}, },
interruptBehavior() {
return 'cancel'
},
userFacingName() { userFacingName() {
return SLEEP_TOOL_NAME return SLEEP_TOOL_NAME
@@ -103,84 +67,53 @@ export const SleepTool = buildTool({
}, },
async call(input: SleepInput, context) { async call(input: SleepInput, context) {
// Don't enter sleep if proactive was disabled or new work arrived while // Refuse to sleep when proactive mode is off — prevents the model from
// the model was deciding to wait. // re-issuing Sleep after an interruption caused by /proactive disable.
if (shouldInterruptSleep()) { if (feature('PROACTIVE') || feature('KAIROS')) {
return { const mod =
data: { require('src/proactive/index.js') as typeof import('src/proactive/index.js')
slept_seconds: 0, if (!mod.isProactiveActive()) {
interrupted: true, return {
}, data: {
slept_seconds: 0,
interrupted: true,
},
}
} }
} }
const { duration_seconds } = input const { duration_seconds } = input
const startTime = Date.now() const startTime = Date.now()
const sleepUntil = startTime + duration_seconds * 1000
if (isProactiveAutomationEnabled()) {
notifyAutomationStateChanged({
enabled: true,
phase: 'sleeping',
next_tick_at: null,
sleep_until: sleepUntil,
})
}
try { try {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
let timer: ReturnType<typeof setTimeout> | null = null const timer = setTimeout(resolve, duration_seconds * 1000)
let wakeCheck: ReturnType<typeof setInterval> | null = null
let settled = false
const cleanup = () => {
if (timer !== null) {
clearTimeout(timer)
timer = null
}
if (wakeCheck !== null) {
clearInterval(wakeCheck)
wakeCheck = null
}
context.abortController.signal.removeEventListener('abort', onAbort)
}
const finish = () => {
if (settled) return
settled = true
cleanup()
resolve()
}
const interrupt = () => {
if (settled) return
settled = true
cleanup()
reject(new Error('interrupted'))
}
const onAbort = () => {
interrupt()
}
timer = setTimeout(finish, duration_seconds * 1000)
// Abort via user interrupt // Abort via user interrupt
if (context.abortController.signal.aborted) { context.abortController.signal.addEventListener(
interrupt() 'abort',
return () => {
} clearTimeout(timer)
context.abortController.signal.addEventListener('abort', onAbort, { clearInterval(proactiveCheck)
once: true, reject(new Error('interrupted'))
}) },
{ once: true },
)
// Poll proactive state and the shared command queue so new work can // Poll proactive state — if deactivated mid-sleep, interrupt early
// wake Sleep without waiting for the full duration. // so the user doesn't have to wait for the full duration.
wakeCheck = setInterval(() => { const proactiveCheck =
if (shouldInterruptSleep()) { feature('PROACTIVE') || feature('KAIROS')
interrupt() ? setInterval(() => {
} const mod =
}, SLEEP_WAKE_CHECK_INTERVAL_MS) require('src/proactive/index.js') as typeof import('src/proactive/index.js')
if (!mod.isProactiveActive()) {
clearTimeout(timer)
clearInterval(proactiveCheck)
reject(new Error('interrupted'))
}
}, 500)
: (null as unknown as ReturnType<typeof setInterval>)
}) })
return { return {
data: { data: {
@@ -196,17 +129,6 @@ export const SleepTool = buildTool({
interrupted: true, interrupted: true,
}, },
} }
} finally {
notifyAutomationStateChanged(
isProactiveAutomationEnabled()
? {
enabled: true,
phase: null,
next_tick_at: null,
sleep_until: null,
}
: null,
)
} }
}, },
}) })

View File

@@ -1,41 +0,0 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import { SleepTool } from '../SleepTool'
import {
enqueue,
getCommandQueue,
resetCommandQueue,
} from 'src/utils/messageQueueManager.js'
describe('SleepTool', () => {
beforeEach(() => {
resetCommandQueue()
})
test('declares cancel interrupt behavior', () => {
expect(SleepTool.interruptBehavior()).toBe('cancel')
})
test('wakes early when queued work arrives', async () => {
const sleepPromise = SleepTool.call(
{ duration_seconds: 10 },
{ abortController: new AbortController() } as any,
)
setTimeout(() => {
enqueue({
value: 'wake up',
mode: 'prompt',
})
}, 20)
const result = await sleepPromise
expect(result.data.interrupted).toBe(true)
expect(result.data.slept_seconds).toBeLessThan(10)
expect(getCommandQueue()).toHaveLength(1)
expect(getCommandQueue()[0]).toMatchObject({
value: 'wake up',
mode: 'prompt',
})
})
})

View File

@@ -5,8 +5,6 @@ let isFirstPartyBaseUrl = true
// Only mock the external dependency that controls adapter selection // Only mock the external dependency that controls adapter selection
mock.module('src/utils/model/providers.js', () => ({ mock.module('src/utils/model/providers.js', () => ({
isFirstPartyAnthropicBaseUrl: () => isFirstPartyBaseUrl, isFirstPartyAnthropicBaseUrl: () => isFirstPartyBaseUrl,
getAPIProvider: () => 'firstParty',
getAPIProviderForStatsig: () => 'firstParty',
})) }))
const { createAdapter } = await import('../adapters/index') const { createAdapter } = await import('../adapters/index')

View File

@@ -1,14 +1,4 @@
import { describe, expect, mock, test } from 'bun:test' import { describe, expect, mock, test } from 'bun:test'
const _abortMock = () => ({
AbortError: class AbortError extends Error {
constructor(message?: string) { super(message); this.name = 'AbortError' }
},
isAbortError: (e: unknown) => e instanceof Error && (e as Error).name === 'AbortError',
})
mock.module('src/utils/errors.js', _abortMock)
mock.module('src/utils/errors', _abortMock)
import { extractBingResults, decodeHtmlEntities } from '../adapters/bingAdapter' import { extractBingResults, decodeHtmlEntities } from '../adapters/bingAdapter'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -1,17 +1,5 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
// Defensive mock: agent.test.ts mocks config.js which can corrupt Bun's
// src/* path alias resolution. Provide AbortError directly so the dynamic
// import in createAdapter() never needs to resolve the alias at runtime.
const _abortMock = () => ({
AbortError: class AbortError extends Error {
constructor(message?: string) { super(message); this.name = 'AbortError' }
},
isAbortError: (e: unknown) => e instanceof Error && (e as Error).name === 'AbortError',
})
mock.module('src/utils/errors.js', _abortMock)
mock.module('src/utils/errors', _abortMock)
const originalBraveSearchApiKey = process.env.BRAVE_SEARCH_API_KEY const originalBraveSearchApiKey = process.env.BRAVE_SEARCH_API_KEY
const originalBraveApiKey = process.env.BRAVE_API_KEY const originalBraveApiKey = process.env.BRAVE_API_KEY

View File

@@ -1,5 +0,0 @@
{
"extends": "../../tsconfig.json",
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -72,18 +72,18 @@ describe("detectColorMode", () => {
describe("detectLanguage", () => { describe("detectLanguage", () => {
test("detects language from file extension", () => { test("detects language from file extension", () => {
expect(detectLanguage("index.ts", null)).toBe("ts"); expect(detectLanguage("index.ts")).toBe("ts");
expect(detectLanguage("main.py", null)).toBe("py"); expect(detectLanguage("main.py")).toBe("py");
expect(detectLanguage("style.css", null)).toBe("css"); expect(detectLanguage("style.css")).toBe("css");
}); });
test("detects language from known filenames", () => { test("detects language from known filenames", () => {
expect(detectLanguage("Makefile", null)).toBe("makefile"); expect(detectLanguage("Makefile")).toBe("makefile");
expect(detectLanguage("Dockerfile", null)).toBe("dockerfile"); expect(detectLanguage("Dockerfile")).toBe("dockerfile");
}); });
test("returns null for unknown extensions", () => { test("returns null for unknown extensions", () => {
expect(detectLanguage("file.xyz123", null)).toBeNull(); expect(detectLanguage("file.xyz123")).toBeNull();
}); });
}); });

View File

@@ -1,5 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,5 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -38,7 +38,7 @@ describe('InProcessTransport', () => {
let received: JSONRPCMessage | null = null let received: JSONRPCMessage | null = null
client.onmessage = (msg) => { received = msg } client.onmessage = (msg) => { received = msg }
await server.send({ jsonrpc: '2.0', result: 42, id: 1 } as any) await server.send({ jsonrpc: '2.0', result: 42, id: 1 })
await new Promise(resolve => setTimeout(resolve, 10)) await new Promise(resolve => setTimeout(resolve, 10))

View File

@@ -57,9 +57,9 @@ describe('discoverTools', () => {
expect(tool.name).toBe('mcp__my-server__search') expect(tool.name).toBe('mcp__my-server__search')
expect(tool.mcpInfo).toEqual({ serverName: 'my-server', toolName: 'search' }) expect(tool.mcpInfo).toEqual({ serverName: 'my-server', toolName: 'search' })
expect(tool.isMcp).toBe(true) expect(tool.isMcp).toBe(true)
expect(tool.isReadOnly({} as any)).toBe(true) expect(tool.isReadOnly()).toBe(true)
expect(tool.userFacingName(undefined)).toBe('Search Items') expect(tool.userFacingName()).toBe('Search Items')
expect(await tool.description({} as any, { isNonInteractiveSession: false, toolPermissionContext: {}, tools: [] })).toBe('Search for items') expect(await tool.description()).toBe('Search for items')
}) })
test('respects skipPrefix option', async () => { test('respects skipPrefix option', async () => {

View File

@@ -65,7 +65,7 @@ describe('createMcpManager', () => {
const result = await manager.connect('test-server', { command: 'npx', args: [] }) const result = await manager.connect('test-server', { command: 'npx', args: [] })
expect(result.type).toBe('connected') expect(result.type).toBe('connected')
expect(connectedEvent as unknown as string).toBe('test-server') expect(connectedEvent).toBe('test-server')
}) })
test('disconnect calls cleanup and emits disconnected', async () => { test('disconnect calls cleanup and emits disconnected', async () => {

View File

@@ -1,5 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,5 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -4,21 +4,10 @@ WORKDIR /app
ARG VERSION=0.1.0 ARG VERSION=0.1.0
# Copy package files for install
COPY packages/remote-control-server/package.json ./package.json COPY packages/remote-control-server/package.json ./package.json
# Install all dependencies (including devDeps for vite build)
RUN bun install RUN bun install
# Copy source code
COPY packages/remote-control-server/src ./src COPY packages/remote-control-server/src ./src
COPY packages/remote-control-server/tsconfig.json ./tsconfig.json
# Copy web frontend source and build it
COPY packages/remote-control-server/web ./web
RUN bun run build:web
# Build backend
RUN bun build src/index.ts --outfile=dist/server.js --target=bun \ RUN bun build src/index.ts --outfile=dist/server.js --target=bun \
--define "process.env.RCS_VERSION=\"${VERSION}\"" --define "process.env.RCS_VERSION=\"${VERSION}\""
@@ -30,9 +19,8 @@ ENV RCS_VERSION=${VERSION}
WORKDIR /app WORKDIR /app
# Copy built artifacts
COPY --from=builder /app/dist/server.js ./dist/server.js COPY --from=builder /app/dist/server.js ./dist/server.js
COPY --from=builder /app/web/dist ./web/dist COPY packages/remote-control-server/web ./web
VOLUME /app/data VOLUME /app/data

View File

@@ -99,13 +99,6 @@ volumes:
rcs-data: rcs-data:
``` ```
## ACP 兼容的 remote-control
```sh
ACP_RCS_URL=http://localhost:3000 ACP_RCS_TOKEN=test-my-key acp-link ccb-bun -- --acp
```
## 反向代理配置 ## 反向代理配置
使用 Nginx 或 Caddy 反向代理时,需要支持 WebSocket 升级: 使用 Nginx 或 Caddy 反向代理时,需要支持 WebSocket 升级:

View File

@@ -1,23 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@@ -4,60 +4,24 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "bun run --watch src/index.ts", "dev": "bun run --watch src/index.ts",
"dev:web": "cd web && bunx vite",
"start": "bun run src/index.ts", "start": "bun run src/index.ts",
"build:web": "cd web && bunx vite build", "build:web": "cd web && bun run build",
"preview:web": "cd web && bunx vite preview",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/react": "^3.0.170",
"ai": "^6.0.168",
"hono": "^4.7.0", "hono": "^4.7.0",
"jsqr": "^1.4.0", "uuid": "^11.0.0"
"qrcode": "^1.5.4",
"uuid": "^11.0.0",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"browser-image-compression": "^2.0.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"lucide-react": "^0.555.0",
"motion": "^12.29.2",
"nanoid": "^5.1.6",
"qr-scanner": "^1.4.2",
"radix-ui": "^1.4.3",
"react": "^19",
"react-dom": "^19",
"react-resizable-panels": "^4",
"shiki": "^3.17.0",
"streamdown": "^1.6.8",
"tailwind-merge": "^3.3.1",
"use-stick-to-bottom": "^1.1.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"typescript": "^5.7.0",
"vite": "^6.0.0",
"@vitejs/plugin-react": "^4.0.0", "@vitejs/plugin-react": "^4.0.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"typescript": "^5.7.0", "@tailwindcss/vite": "^4.0.0"
"vite": "^6.0.0",
"tw-animate-css": "^1.4.0"
} }
} }

View File

@@ -1,182 +0,0 @@
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);
});
});

View File

@@ -1,256 +0,0 @@
import { describe, test, expect } from "bun:test";
import { toClientPayload } from "../transport/client-payload";
import type { SessionEvent } from "../transport/event-bus";
function makeEvent(overrides: Partial<SessionEvent> & Pick<SessionEvent, "type" | "sessionId">): 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" });
});
});

View File

@@ -25,18 +25,17 @@ import {
storeUpdateSession, storeUpdateSession,
storeGetEnvironment, storeGetEnvironment,
storeGetSession, storeGetSession,
storeListActiveEnvironments,
} from "../store"; } from "../store";
import { getEventBus, getAllEventBuses, removeEventBus } from "../transport/event-bus";
import { runDisconnectMonitorSweep } from "../services/disconnect-monitor";
describe("Disconnect Monitor Logic", () => { describe("Disconnect Monitor Logic", () => {
beforeEach(() => { beforeEach(() => {
storeReset(); storeReset();
for (const [key] of getAllEventBuses()) {
removeEventBus(key);
}
}); });
// Test the logic directly rather than the interval-based monitor
// to avoid long-running tests with timers
test("environment times out when lastPollAt is too old", () => { test("environment times out when lastPollAt is too old", () => {
const env = storeCreateEnvironment({ secret: "s" }); const env = storeCreateEnvironment({ secret: "s" });
const timeoutMs = 300 * 1000; // 5 minutes const timeoutMs = 300 * 1000; // 5 minutes
@@ -45,7 +44,14 @@ describe("Disconnect Monitor Logic", () => {
const oldDate = new Date(Date.now() - timeoutMs - 60000); const oldDate = new Date(Date.now() - timeoutMs - 60000);
storeUpdateEnvironment(env.id, { lastPollAt: oldDate }); storeUpdateEnvironment(env.id, { lastPollAt: oldDate });
runDisconnectMonitorSweep(); // Check the timeout logic (same as in disconnect-monitor.ts)
const now = Date.now();
const envs = storeListActiveEnvironments();
for (const e of envs) {
if (e.lastPollAt && now - e.lastPollAt.getTime() > timeoutMs) {
storeUpdateEnvironment(e.id, { status: "disconnected" });
}
}
const updated = storeGetEnvironment(env.id); const updated = storeGetEnvironment(env.id);
expect(updated?.status).toBe("disconnected"); expect(updated?.status).toBe("disconnected");
@@ -53,56 +59,43 @@ describe("Disconnect Monitor Logic", () => {
test("environment stays active when lastPollAt is recent", () => { test("environment stays active when lastPollAt is recent", () => {
const env = storeCreateEnvironment({ secret: "s" }); const env = storeCreateEnvironment({ secret: "s" });
runDisconnectMonitorSweep(); const timeoutMs = 300 * 1000;
// lastPollAt is recent (just created)
const now = Date.now();
const envs = storeListActiveEnvironments();
for (const e of envs) {
if (e.lastPollAt && now - e.lastPollAt.getTime() > timeoutMs) {
storeUpdateEnvironment(e.id, { status: "disconnected" });
}
}
const updated = storeGetEnvironment(env.id); const updated = storeGetEnvironment(env.id);
expect(updated?.status).toBe("active"); expect(updated?.status).toBe("active");
}); });
test("session becomes inactive when updatedAt is too old", () => { test("session becomes inactive when updatedAt is too old", () => {
const session = storeCreateSession({}); const session = storeCreateSession({ status: "idle" });
storeUpdateSession(session.id, { status: "running" }); storeUpdateSession(session.id, { status: "running" });
const timeoutMs = 300 * 1000 * 2; // 2x disconnect timeout
// Simulate updatedAt being older than 2x timeout
// We can't directly set updatedAt, but we can verify the logic
// by checking that recently updated sessions are not marked inactive
const now = Date.now();
const rec = storeGetSession(session.id); const rec = storeGetSession(session.id);
expect(rec).toBeTruthy(); // Session was just updated, should not be inactive
if (!rec) return; expect(rec?.status).toBe("running");
expect(now - rec!.updatedAt.getTime()).toBeLessThan(timeoutMs);
rec.updatedAt = new Date(Date.now() - 300 * 1000 * 2 - 60000);
runDisconnectMonitorSweep();
const updated = storeGetSession(session.id);
expect(updated?.status).toBe("inactive");
}); });
test("session stays running when recently updated", () => { test("session stays running when recently updated", () => {
const session = storeCreateSession({}); const session = storeCreateSession({});
storeUpdateSession(session.id, { status: "running" }); storeUpdateSession(session.id, { status: "running" });
runDisconnectMonitorSweep(); const timeoutMs = 300 * 1000 * 2;
const updated = storeGetSession(session.id);
expect(updated?.status).toBe("running");
});
test("session timeout publishes an inactive session_status event", () => {
const session = storeCreateSession({});
storeUpdateSession(session.id, { status: "idle" });
const rec = storeGetSession(session.id); const rec = storeGetSession(session.id);
expect(rec).toBeTruthy(); expect(rec?.status).toBe("running");
if (!rec) return; expect(Date.now() - rec!.updatedAt.getTime()).toBeLessThan(timeoutMs);
rec.updatedAt = new Date(Date.now() - 300 * 1000 * 2 - 60000);
const bus = getEventBus(session.id);
const events: Array<{ type: string; payload: { status?: string } }> = [];
bus.subscribe((event) => {
events.push({ type: event.type, payload: event.payload as { status?: string } });
});
runDisconnectMonitorSweep();
expect(events).toContainEqual({
type: "session_status",
payload: { status: "inactive" },
});
}); });
}); });

View File

@@ -19,18 +19,16 @@ mock.module("../config", () => ({
import { Hono } from "hono"; import { Hono } from "hono";
import { storeReset, storeCreateSession, storeCreateEnvironment, storeBindSession } from "../store"; import { storeReset, storeCreateSession, storeCreateEnvironment, storeBindSession } from "../store";
import { removeEventBus, getAllEventBuses, getEventBus } from "../transport/event-bus"; import { removeEventBus, getAllEventBuses } from "../transport/event-bus";
import { issueToken } from "../auth/token"; import { issueToken } from "../auth/token";
import { publishSessionEvent } from "../services/transport";
// Import route modules // Import route modules
import v1Sessions from "../routes/v1/sessions"; import v1Sessions from "../routes/v1/sessions";
import v1Environments from "../routes/v1/environments"; import v1Environments from "../routes/v1/environments";
import v1EnvironmentsWork from "../routes/v1/environments.work"; import v1EnvironmentsWork from "../routes/v1/environments.work";
import v1SessionIngress, { websocket as sessionIngressWebsocket } from "../routes/v1/session-ingress"; import v1SessionIngress from "../routes/v1/session-ingress";
import v2CodeSessions from "../routes/v2/code-sessions"; import v2CodeSessions from "../routes/v2/code-sessions";
import v2Worker from "../routes/v2/worker"; import v2Worker from "../routes/v2/worker";
import v2WorkerEventsStream from "../routes/v2/worker-events-stream";
import v2WorkerEvents from "../routes/v2/worker-events"; import v2WorkerEvents from "../routes/v2/worker-events";
import webAuth from "../routes/web/auth"; import webAuth from "../routes/web/auth";
import webSessions from "../routes/web/sessions"; import webSessions from "../routes/web/sessions";
@@ -45,7 +43,6 @@ function createApp() {
app.route("/v2/session_ingress", v1SessionIngress); app.route("/v2/session_ingress", v1SessionIngress);
app.route("/v1/code/sessions", v2CodeSessions); app.route("/v1/code/sessions", v2CodeSessions);
app.route("/v1/code/sessions", v2Worker); app.route("/v1/code/sessions", v2Worker);
app.route("/v1/code/sessions", v2WorkerEventsStream);
app.route("/v1/code/sessions", v2WorkerEvents); app.route("/v1/code/sessions", v2WorkerEvents);
app.route("/web", webAuth); app.route("/web", webAuth);
app.route("/web", webSessions); app.route("/web", webSessions);
@@ -56,11 +53,6 @@ function createApp() {
const AUTH_HEADERS = { Authorization: "Bearer test-api-key", "X-Username": "testuser" }; const AUTH_HEADERS = { Authorization: "Bearer test-api-key", "X-Username": "testuser" };
function toWebSessionId(sessionId: string): string {
if (!sessionId.startsWith("cse_")) return sessionId;
return `session_${sessionId.slice("cse_".length)}`;
}
describe("V1 Session Routes", () => { describe("V1 Session Routes", () => {
let app: Hono; let app: Hono;
@@ -117,24 +109,6 @@ describe("V1 Session Routes", () => {
expect(res.status).toBe(404); expect(res.status).toBe(404);
}); });
test("GET /v1/sessions/:id — resolves compat code session IDs", async () => {
const createRes = await app.request("/v1/code/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const {
session: { id },
} = await createRes.json();
const getRes = await app.request(`/v1/sessions/${toWebSessionId(id)}`, {
headers: AUTH_HEADERS,
});
expect(getRes.status).toBe(200);
const body = await getRes.json();
expect(body.id).toBe(id);
});
test("PATCH /v1/sessions/:id — updates title", async () => { test("PATCH /v1/sessions/:id — updates title", async () => {
const createRes = await app.request("/v1/sessions", { const createRes = await app.request("/v1/sessions", {
method: "POST", method: "POST",
@@ -168,32 +142,6 @@ describe("V1 Session Routes", () => {
expect(archiveRes.status).toBe(200); expect(archiveRes.status).toBe(200);
}); });
test("POST /v1/sessions/:id/archive — archives compat code session IDs", async () => {
const createRes = await app.request("/v1/code/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const {
session: { id },
} = await createRes.json();
const compatId = toWebSessionId(id);
const archiveRes = await app.request(`/v1/sessions/${compatId}/archive`, {
method: "POST",
headers: AUTH_HEADERS,
});
expect(archiveRes.status).toBe(200);
const getRes = await app.request(`/v1/sessions/${compatId}`, {
headers: AUTH_HEADERS,
});
expect(getRes.status).toBe(200);
const body = await getRes.json();
expect(body.id).toBe(id);
expect(body.status).toBe("archived");
});
test("POST /v1/sessions/:id/events — publishes events", async () => { test("POST /v1/sessions/:id/events — publishes events", async () => {
const createRes = await app.request("/v1/sessions", { const createRes = await app.request("/v1/sessions", {
method: "POST", method: "POST",
@@ -212,30 +160,6 @@ describe("V1 Session Routes", () => {
expect(body.events).toBe(1); expect(body.events).toBe(1);
}); });
test("POST /v1/sessions/:id/events — resolves compat code session IDs", async () => {
const createRes = await app.request("/v1/code/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const {
session: { id },
} = await createRes.json();
const compatId = toWebSessionId(id);
const eventsRes = await app.request(`/v1/sessions/${compatId}/events`, {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({ events: [{ type: "user", content: "hello from compat" }] }),
});
expect(eventsRes.status).toBe(200);
const events = getEventBus(id).getEventsSince(0);
expect(events).toHaveLength(1);
expect(events[0]?.type).toBe("user");
expect((events[0]?.payload as { content?: string }).content).toBe("hello from compat");
});
test("POST /v1/sessions with environment_id creates work item", async () => { test("POST /v1/sessions with environment_id creates work item", async () => {
// First register an environment // First register an environment
const envRes = await app.request("/v1/environments/bridge", { const envRes = await app.request("/v1/environments/bridge", {
@@ -519,26 +443,6 @@ describe("Web Auth Routes", () => {
expect(body.ok).toBe(true); expect(body.ok).toBe(true);
}); });
test("POST /web/bind — binds compat code session ID to UUID", async () => {
const sessRes = await app.request("/v1/code/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const body = await sessRes.json();
const compatId = toWebSessionId(body.session.id);
const bindRes = await app.request("/web/bind?uuid=test-uuid", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId: compatId }),
});
expect(bindRes.status).toBe(200);
const bindBody = await bindRes.json();
expect(bindBody.ok).toBe(true);
expect(bindBody.sessionId).toBe(compatId);
});
test("POST /web/bind — 404 for unknown session", async () => { test("POST /web/bind — 404 for unknown session", async () => {
const res = await app.request("/web/bind?uuid=test-uuid", { const res = await app.request("/web/bind?uuid=test-uuid", {
method: "POST", method: "POST",
@@ -597,24 +501,6 @@ describe("Web Session Routes", () => {
expect(sessions[0].id).toBe(id); expect(sessions[0].id).toBe(id);
}); });
test("GET /web/sessions and /all — serialize owned code sessions as compat IDs", async () => {
const codeSession = storeCreateSession({ idPrefix: "cse_" });
storeBindSession(codeSession.id, "user-1");
const compatId = toWebSessionId(codeSession.id);
const listRes = await app.request("/web/sessions?uuid=user-1");
expect(listRes.status).toBe(200);
const sessions = await listRes.json();
expect(sessions).toHaveLength(1);
expect(sessions[0].id).toBe(compatId);
const allRes = await app.request("/web/sessions/all?uuid=user-1");
expect(allRes.status).toBe(200);
const summaries = await allRes.json();
expect(summaries).toHaveLength(1);
expect(summaries[0].id).toBe(compatId);
});
test("GET /web/sessions — requires UUID", async () => { test("GET /web/sessions — requires UUID", async () => {
const res = await app.request("/web/sessions"); const res = await app.request("/web/sessions");
expect(res.status).toBe(401); expect(res.status).toBe(401);
@@ -639,33 +525,6 @@ describe("Web Session Routes", () => {
expect(sessions).toHaveLength(1); // only user-1's session, not user-2's expect(sessions).toHaveLength(1); // only user-1's session, not user-2's
}); });
test("GET /web/sessions and /all — hides archived and inactive sessions", async () => {
const archived = storeCreateSession({});
const inactive = storeCreateSession({});
const open = storeCreateSession({});
storeBindSession(archived.id, "user-1");
storeBindSession(inactive.id, "user-1");
storeBindSession(open.id, "user-1");
await app.request(`/v1/sessions/${archived.id}/archive`, {
method: "POST",
headers: AUTH_HEADERS,
});
const { storeUpdateSession } = await import("../store");
storeUpdateSession(inactive.id, { status: "inactive" });
const listRes = await app.request("/web/sessions?uuid=user-1");
expect(listRes.status).toBe(200);
const sessions = await listRes.json();
expect(sessions.map((session: { id: string }) => session.id)).toEqual([open.id]);
const allRes = await app.request("/web/sessions/all?uuid=user-1");
expect(allRes.status).toBe(200);
const summaries = await allRes.json();
expect(summaries.map((session: { id: string }) => session.id)).toEqual([open.id]);
});
test("GET /web/sessions/:id — returns owned session", async () => { test("GET /web/sessions/:id — returns owned session", async () => {
const createRes = await app.request("/web/sessions?uuid=user-1", { const createRes = await app.request("/web/sessions?uuid=user-1", {
method: "POST", method: "POST",
@@ -678,44 +537,6 @@ describe("Web Session Routes", () => {
expect(getRes.status).toBe(200); expect(getRes.status).toBe(200);
}); });
test("GET /web/sessions/:id — includes automation_state snapshot when worker metadata has it", async () => {
const createRes = await app.request("/v1/code/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const {
session: { id },
} = await createRes.json();
storeBindSession(id, "user-1");
await app.request(`/v1/code/sessions/${id}/worker`, {
method: "PUT",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({
worker_epoch: 1,
external_metadata: {
automation_state: {
enabled: true,
phase: "standby",
next_tick_at: 123456,
sleep_until: null,
},
},
}),
});
const getRes = await app.request(`/web/sessions/${toWebSessionId(id)}?uuid=user-1`);
expect(getRes.status).toBe(200);
const body = await getRes.json();
expect(body.automation_state).toEqual({
enabled: true,
phase: "standby",
next_tick_at: 123456,
sleep_until: null,
});
});
test("GET /web/sessions/:id — 403 for non-owner", async () => { test("GET /web/sessions/:id — 403 for non-owner", async () => {
const createRes = await app.request("/web/sessions?uuid=user-1", { const createRes = await app.request("/web/sessions?uuid=user-1", {
method: "POST", method: "POST",
@@ -742,51 +563,6 @@ describe("Web Session Routes", () => {
expect(body.events).toEqual([]); expect(body.events).toEqual([]);
}); });
test("GET /web/sessions/:id/history — returns task_state snapshots", async () => {
const createRes = await app.request("/web/sessions?uuid=user-1", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { id } = await createRes.json();
publishSessionEvent(
id,
"task_state",
{
task_list_id: "team-alpha",
tasks: [{ id: "1", subject: "Investigate", status: "pending" }],
},
"inbound",
);
const histRes = await app.request(`/web/sessions/${id}/history?uuid=user-1`);
expect(histRes.status).toBe(200);
const body = await histRes.json();
expect(body.events).toHaveLength(1);
expect(body.events[0]?.type).toBe("task_state");
expect(body.events[0]?.payload.task_list_id).toBe("team-alpha");
expect(body.events[0]?.payload.tasks).toEqual([
{ id: "1", subject: "Investigate", status: "pending" },
]);
});
test("GET /web/sessions/:id and history — supports compat code session IDs", async () => {
const codeSession = storeCreateSession({ idPrefix: "cse_" });
storeBindSession(codeSession.id, "user-1");
const compatId = toWebSessionId(codeSession.id);
const getRes = await app.request(`/web/sessions/${compatId}?uuid=user-1`);
expect(getRes.status).toBe(200);
const session = await getRes.json();
expect(session.id).toBe(compatId);
const histRes = await app.request(`/web/sessions/${compatId}/history?uuid=user-1`);
expect(histRes.status).toBe(200);
const history = await histRes.json();
expect(history.events).toEqual([]);
});
test("GET /web/sessions/:id/history — 403 for non-owner", async () => { test("GET /web/sessions/:id/history — 403 for non-owner", async () => {
const createRes = await app.request("/web/sessions?uuid=user-1", { const createRes = await app.request("/web/sessions?uuid=user-1", {
method: "POST", method: "POST",
@@ -871,24 +647,6 @@ describe("Web Session Routes", () => {
} }
}); });
test("GET /web/sessions/:id/events — supports compat code session IDs", async () => {
const codeSession = storeCreateSession({ idPrefix: "cse_" });
storeBindSession(codeSession.id, "user-1");
const compatId = toWebSessionId(codeSession.id);
const eventsRes = await app.request(`/web/sessions/${compatId}/events?uuid=user-1`);
expect(eventsRes.status).toBe(200);
expect(eventsRes.headers.get("Content-Type")).toBe("text/event-stream");
const reader = eventsRes.body?.getReader();
if (reader) {
const { value } = await reader.read();
const text = new TextDecoder().decode(value!);
expect(text).toContain(": keepalive");
reader.cancel();
}
});
test("GET /web/sessions/:id/events — 403 for non-owner", async () => { test("GET /web/sessions/:id/events — 403 for non-owner", async () => {
const createRes = await app.request("/web/sessions?uuid=user-1", { const createRes = await app.request("/web/sessions?uuid=user-1", {
method: "POST", method: "POST",
@@ -900,25 +658,6 @@ describe("Web Session Routes", () => {
const eventsRes = await app.request(`/web/sessions/${id}/events?uuid=user-2`); const eventsRes = await app.request(`/web/sessions/${id}/events?uuid=user-2`);
expect(eventsRes.status).toBe(403); expect(eventsRes.status).toBe(403);
}); });
test("GET /web/sessions/:id/events — 409 for archived session", async () => {
const createRes = await app.request("/web/sessions?uuid=user-1", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { id } = await createRes.json();
await app.request(`/v1/sessions/${id}/archive`, {
method: "POST",
headers: AUTH_HEADERS,
});
const res = await app.request(`/web/sessions/${id}/events?uuid=user-1`);
expect(res.status).toBe(409);
const body = await res.json();
expect(body.error.type).toBe("session_closed");
});
}); });
describe("Web Control Routes", () => { describe("Web Control Routes", () => {
@@ -953,32 +692,6 @@ describe("Web Control Routes", () => {
expect(body.event).toBeTruthy(); expect(body.event).toBeTruthy();
}); });
test("POST /web/sessions/:id/events/control/interrupt — supports compat code session IDs", async () => {
const rawSessionId = storeCreateSession({ idPrefix: "cse_" }).id;
storeBindSession(rawSessionId, "user-1");
const compatId = toWebSessionId(rawSessionId);
const eventsRes = await app.request(`/web/sessions/${compatId}/events?uuid=user-1`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "user", content: "hello" }),
});
expect(eventsRes.status).toBe(200);
const controlRes = await app.request(`/web/sessions/${compatId}/control?uuid=user-1`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "permission_response", approved: true, request_id: "r1" }),
});
expect(controlRes.status).toBe(200);
const interruptRes = await app.request(`/web/sessions/${compatId}/interrupt?uuid=user-1`, {
method: "POST",
headers: { "Content-Type": "application/json" },
});
expect(interruptRes.status).toBe(200);
});
test("POST /web/sessions/:id/events — 403 for non-owner", async () => { test("POST /web/sessions/:id/events — 403 for non-owner", async () => {
const res = await app.request(`/web/sessions/${sessionId}/events?uuid=user-2`, { const res = await app.request(`/web/sessions/${sessionId}/events?uuid=user-2`, {
method: "POST", method: "POST",
@@ -1030,33 +743,6 @@ describe("Web Control Routes", () => {
}); });
expect(res.status).toBe(403); expect(res.status).toBe(403);
}); });
test("POST /web/sessions/:id/events/control/interrupt — 409 for archived session", async () => {
await app.request(`/v1/sessions/${sessionId}/archive`, {
method: "POST",
headers: AUTH_HEADERS,
});
const eventsRes = await app.request(`/web/sessions/${sessionId}/events?uuid=user-1`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "user", content: "hello" }),
});
expect(eventsRes.status).toBe(409);
const controlRes = await app.request(`/web/sessions/${sessionId}/control?uuid=user-1`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "permission_response", approved: true, request_id: "r1" }),
});
expect(controlRes.status).toBe(409);
const interruptRes = await app.request(`/web/sessions/${sessionId}/interrupt?uuid=user-1`, {
method: "POST",
headers: { "Content-Type": "application/json" },
});
expect(interruptRes.status).toBe(409);
});
}); });
describe("Web Environment Routes", () => { describe("Web Environment Routes", () => {
@@ -1136,81 +822,6 @@ describe("V1 Session Ingress Routes (HTTP)", () => {
}); });
expect(res.status).toBe(404); expect(res.status).toBe(404);
}); });
test("POST /v2/session_ingress/session/:sessionId/events — resolves compat code session IDs", async () => {
const sessRes = await app.request("/v1/code/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const {
session: { id },
} = await sessRes.json();
const compatId = toWebSessionId(id);
const res = await app.request(`/v2/session_ingress/session/${compatId}/events`, {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({ events: [{ type: "assistant", message: { role: "assistant", content: "compat ok" } }] }),
});
expect(res.status).toBe(200);
const events = getEventBus(id).getEventsSince(0);
expect(events).toHaveLength(1);
expect(events[0]?.type).toBe("assistant");
});
test("GET /v2/session_ingress/ws/:sessionId — resolves compat code session IDs", async () => {
const sessRes = await app.request("/v1/code/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const {
session: { id },
} = await sessRes.json();
const compatId = toWebSessionId(id);
publishSessionEvent(id, "user", { content: "compat ws replay" }, "outbound");
const server = Bun.serve({
port: 0,
fetch: app.fetch,
websocket: {
...sessionIngressWebsocket,
idleTimeout: 30,
},
});
try {
const message = await new Promise<string>((resolve, reject) => {
const ws = new WebSocket(`ws://127.0.0.1:${server.port}/v2/session_ingress/ws/${compatId}?token=test-api-key`);
const timeout = setTimeout(() => {
ws.close();
reject(new Error("Timed out waiting for compat WebSocket replay"));
}, 2000);
ws.onmessage = (event) => {
const data = typeof event.data === "string" ? event.data : String(event.data);
if (data.includes("\"type\":\"user\"")) {
clearTimeout(timeout);
ws.close();
resolve(data);
}
};
ws.onerror = () => {
clearTimeout(timeout);
reject(new Error("Compat WebSocket connection failed"));
};
});
expect(message).toContain("\"type\":\"user\"");
expect(message).toContain(`\"session_id\":\"${id}\"`);
expect(message).toContain("compat ws replay");
} finally {
await server.stop(true);
}
});
}); });
describe("V2 Worker Events Routes", () => { describe("V2 Worker Events Routes", () => {
@@ -1245,252 +856,6 @@ describe("V2 Worker Events Routes", () => {
expect(body.count).toBe(1); expect(body.count).toBe(1);
}); });
test("POST /v1/code/sessions/:id/worker/events — unwraps CCR batch payloads", async () => {
const sessRes = await app.request("/v1/code/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { session: { id } } = await sessRes.json();
const res = await app.request(`/v1/code/sessions/${id}/worker/events`, {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({
worker_epoch: 1,
events: [{ payload: { type: "assistant", content: "response" } }],
}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.count).toBe(1);
const events = getEventBus(id).getEventsSince(0);
expect(events).toHaveLength(1);
expect(events[0]?.type).toBe("assistant");
expect((events[0]?.payload as { content?: string }).content).toBe("response");
});
test("GET/PUT /v1/code/sessions/:id/worker — stores worker state", async () => {
const sessRes = await app.request("/v1/code/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { session: { id } } = await sessRes.json();
const putRes = await app.request(`/v1/code/sessions/${id}/worker`, {
method: "PUT",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({
worker_epoch: 1,
worker_status: "running",
external_metadata: {
permission_mode: "default",
automation_state: {
enabled: true,
phase: "sleeping",
next_tick_at: null,
sleep_until: 123456,
},
},
}),
});
expect(putRes.status).toBe(200);
const getRes = await app.request(`/v1/code/sessions/${id}/worker`, {
headers: AUTH_HEADERS,
});
expect(getRes.status).toBe(200);
const body = await getRes.json();
expect(body.worker.worker_status).toBe("running");
expect(body.worker.external_metadata.permission_mode).toBe("default");
expect(body.worker.external_metadata.automation_state).toEqual({
enabled: true,
phase: "sleeping",
next_tick_at: null,
sleep_until: 123456,
});
const events = getEventBus(id).getEventsSince(0);
expect(events.some((event) => event.type === "automation_state")).toBe(true);
expect(events.at(-1)?.payload).toEqual({
enabled: true,
phase: "sleeping",
next_tick_at: null,
sleep_until: 123456,
});
});
test("POST /v1/code/sessions/:id/worker/heartbeat — updates heartbeat", async () => {
const sessRes = await app.request("/v1/code/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { session: { id } } = await sessRes.json();
const heartbeatRes = await app.request(`/v1/code/sessions/${id}/worker/heartbeat`, {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({ worker_epoch: 1 }),
});
expect(heartbeatRes.status).toBe(200);
const getRes = await app.request(`/v1/code/sessions/${id}/worker`, {
headers: AUTH_HEADERS,
});
const body = await getRes.json();
expect(body.worker.last_heartbeat_at).toBeTruthy();
});
test("GET /v1/code/sessions/:id/worker/events/stream — emits CCR client_event frames", async () => {
const sessRes = await app.request("/v1/code/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { session: { id } } = await sessRes.json();
const streamRes = await app.request(`/v1/code/sessions/${id}/worker/events/stream`, {
headers: AUTH_HEADERS,
});
expect(streamRes.status).toBe(200);
const reader = streamRes.body?.getReader();
expect(reader).toBeTruthy();
if (!reader) return;
const firstChunk = await reader.read();
const keepalive = new TextDecoder().decode(firstChunk.value!);
expect(keepalive).toContain(": keepalive");
publishSessionEvent(id, "user", { type: "user", content: "hello" }, "outbound");
const secondChunk = await reader.read();
const frame = new TextDecoder().decode(secondChunk.value!);
expect(frame).toContain("event: client_event");
expect(frame).toContain("\"payload\":{\"type\":\"user\",\"content\":\"hello\",\"message\":{\"content\":\"hello\"}}");
reader.cancel();
});
test("GET /v1/code/sessions/:id/worker/events/stream — normalizes web permission approvals to control_response", async () => {
const createRes = await app.request("/web/sessions?uuid=user-1", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { id } = await createRes.json();
const streamRes = await app.request(`/v1/code/sessions/${id}/worker/events/stream`, {
headers: AUTH_HEADERS,
});
expect(streamRes.status).toBe(200);
const reader = streamRes.body?.getReader();
expect(reader).toBeTruthy();
if (!reader) return;
await reader.read(); // initial keepalive
const controlRes = await app.request(`/web/sessions/${id}/control?uuid=user-1`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "permission_response",
approved: true,
request_id: "req-1",
}),
});
expect(controlRes.status).toBe(200);
const chunk = await reader.read();
const frame = new TextDecoder().decode(chunk.value!);
expect(frame).toContain("event: client_event");
expect(frame).toContain("\"event_type\":\"permission_response\"");
expect(frame).toContain("\"payload\":{\"type\":\"control_response\"");
expect(frame).toContain("\"request_id\":\"req-1\"");
expect(frame).toContain("\"behavior\":\"allow\"");
reader.cancel();
});
test("GET /v1/code/sessions/:id/worker/events/stream — normalizes web plan rejection feedback to deny control_response", async () => {
const createRes = await app.request("/web/sessions?uuid=user-1", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { id } = await createRes.json();
const streamRes = await app.request(`/v1/code/sessions/${id}/worker/events/stream`, {
headers: AUTH_HEADERS,
});
expect(streamRes.status).toBe(200);
const reader = streamRes.body?.getReader();
expect(reader).toBeTruthy();
if (!reader) return;
await reader.read(); // initial keepalive
const controlRes = await app.request(`/web/sessions/${id}/control?uuid=user-1`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "permission_response",
approved: false,
request_id: "req-2",
message: "Need more detail",
}),
});
expect(controlRes.status).toBe(200);
const chunk = await reader.read();
const frame = new TextDecoder().decode(chunk.value!);
expect(frame).toContain("event: client_event");
expect(frame).toContain("\"event_type\":\"permission_response\"");
expect(frame).toContain("\"payload\":{\"type\":\"control_response\"");
expect(frame).toContain("\"request_id\":\"req-2\"");
expect(frame).toContain("\"subtype\":\"error\"");
expect(frame).toContain("\"behavior\":\"deny\"");
expect(frame).toContain("\"message\":\"Need more detail\"");
reader.cancel();
});
test("GET /v1/code/sessions/:id/worker/events/stream — normalizes web interrupts to control_request", async () => {
const createRes = await app.request("/web/sessions?uuid=user-1", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { id } = await createRes.json();
const streamRes = await app.request(`/v1/code/sessions/${id}/worker/events/stream`, {
headers: AUTH_HEADERS,
});
expect(streamRes.status).toBe(200);
const reader = streamRes.body?.getReader();
expect(reader).toBeTruthy();
if (!reader) return;
await reader.read(); // initial keepalive
const interruptRes = await app.request(`/web/sessions/${id}/interrupt?uuid=user-1`, {
method: "POST",
headers: { "Content-Type": "application/json" },
});
expect(interruptRes.status).toBe(200);
const chunk = await reader.read();
const frame = new TextDecoder().decode(chunk.value!);
expect(frame).toContain("event: client_event");
expect(frame).toContain("\"event_type\":\"interrupt\"");
expect(frame).toContain("\"payload\":{\"type\":\"control_request\"");
expect(frame).toContain("\"subtype\":\"interrupt\"");
reader.cancel();
});
test("PUT /v1/code/sessions/:id/worker/state — updates session status", async () => { test("PUT /v1/code/sessions/:id/worker/state — updates session status", async () => {
const sessRes = await app.request("/v1/sessions", { const sessRes = await app.request("/v1/sessions", {
method: "POST", method: "POST",
@@ -1538,20 +903,4 @@ describe("V2 Worker Events Routes", () => {
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
}); });
test("POST /v1/code/sessions/:id/worker/events/delivery — batch no-op", async () => {
const sessRes = await app.request("/v1/code/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { session: { id } } = await sessRes.json();
const res = await app.request(`/v1/code/sessions/${id}/worker/events/delivery`, {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({ worker_epoch: 1, updates: [{ event_id: "evt123", status: "received" }] }),
});
expect(res.status).toBe(200);
});
}); });

View File

@@ -345,22 +345,6 @@ describe("Transport Service", () => {
expect(result.message).toEqual(msg); expect(result.message).toEqual(msg);
}); });
test("preserves uuid field", () => {
const result = normalizePayload("user", {
uuid: "msg_123",
content: "hi",
});
expect(result.uuid).toBe("msg_123");
});
test("preserves isSynthetic field", () => {
const result = normalizePayload("user", {
content: "scheduled job: refresh analytics cache",
isSynthetic: true,
});
expect(result.isSynthetic).toBe(true);
});
test("uses name as tool_name fallback", () => { test("uses name as tool_name fallback", () => {
const result = normalizePayload("tool", { name: "Read" }); const result = normalizePayload("tool", { name: "Read" });
expect(result.tool_name).toBe("Read"); expect(result.tool_name).toBe("Read");
@@ -378,28 +362,6 @@ describe("Transport Service", () => {
expect(result.content).toBe(""); expect(result.content).toBe("");
}); });
test("preserves task_state fields", () => {
const result = normalizePayload("task_state", {
task_list_id: "team-alpha",
tasks: [{ id: "1", subject: "Task 1", status: "pending" }],
});
expect(result.task_list_id).toBe("team-alpha");
expect(result.tasks).toEqual([
{ id: "1", subject: "Task 1", status: "pending" },
]);
});
test("preserves status metadata for conversation reset events", () => {
const result = normalizePayload("status", {
status: "conversation_cleared",
subtype: "status",
message: "conversation_cleared",
});
expect(result.status).toBe("conversation_cleared");
expect(result.subtype).toBe("status");
expect(result.message).toBe("conversation_cleared");
});
test("handles undefined payload", () => { test("handles undefined payload", () => {
const result = normalizePayload("user", undefined); const result = normalizePayload("user", undefined);
expect(result.content).toBe(""); expect(result.content).toBe("");

View File

@@ -1,188 +0,0 @@
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();
});
});

View File

@@ -69,19 +69,6 @@ describe("ws-handler", () => {
expect((events[0] as any).direction).toBe("inbound"); expect((events[0] as any).direction).toBe("inbound");
}); });
test("preserves synthetic flag on inbound user messages", () => {
const bus = getEventBus("s1");
const events: unknown[] = [];
bus.subscribe((e) => events.push(e));
ingestBridgeMessage("s1", {
message: { role: "user", content: "scheduled job: refresh analytics cache" },
uuid: "u_synth",
isSynthetic: true,
});
expect(events).toHaveLength(1);
expect((events[0] as any).payload.isSynthetic).toBe(true);
});
test("derives type from message.role for assistant messages", () => { test("derives type from message.role for assistant messages", () => {
const bus = getEventBus("s1"); const bus = getEventBus("s1");
const events: unknown[] = []; const events: unknown[] = [];
@@ -176,24 +163,6 @@ describe("ws-handler", () => {
expect(msg.type).toBe("user"); expect(msg.type).toBe("user");
}); });
test("replays synthetic user metadata back to the bridge", () => {
const bus = getEventBus("s3");
bus.publish({
id: "e1",
sessionId: "s3",
type: "user",
payload: { content: "scheduled job: refresh analytics cache", isSynthetic: true },
direction: "outbound",
});
const ws = createMockWs();
handleWebSocketOpen(ws, "s3");
const msg = JSON.parse(ws.getSentData()[0]);
expect(msg.type).toBe("user");
expect(msg.isSynthetic).toBe(true);
});
test("replaces existing connection for same session", () => { test("replaces existing connection for same session", () => {
const ws1 = createMockWs(); const ws1 = createMockWs();
const ws2 = createMockWs(); const ws2 = createMockWs();
@@ -367,26 +336,6 @@ describe("ws-handler", () => {
expect(lastMsg.message.content).toBe("hello world"); expect(lastMsg.message.content).toBe("hello world");
}); });
test("preserves payload uuid for outbound user events", () => {
const bus = getEventBus("um2");
const ws = createMockWs();
handleWebSocketOpen(ws, "um2");
bus.publish({
id: "internal-event-id",
sessionId: "um2",
type: "user",
payload: { uuid: "web-message-uuid", content: "hello from web" },
direction: "outbound",
});
const sent = ws.getSentData();
const lastMsg = JSON.parse(sent[sent.length - 1]);
expect(lastMsg.type).toBe("user");
expect(lastMsg.uuid).toBe("web-message-uuid");
expect(lastMsg.message.content).toBe("hello from web");
});
test("converts generic event type", () => { test("converts generic event type", () => {
const bus = getEventBus("gen1"); const bus = getEventBus("gen1");
const ws = createMockWs(); const ws = createMockWs();

View File

@@ -8,13 +8,6 @@ export const config = {
heartbeatInterval: parseInt(process.env.RCS_HEARTBEAT_INTERVAL || "20"), heartbeatInterval: parseInt(process.env.RCS_HEARTBEAT_INTERVAL || "20"),
jwtExpiresIn: parseInt(process.env.RCS_JWT_EXPIRES_IN || "3600"), jwtExpiresIn: parseInt(process.env.RCS_JWT_EXPIRES_IN || "3600"),
disconnectTimeout: parseInt(process.env.RCS_DISCONNECT_TIMEOUT || "300"), disconnectTimeout: parseInt(process.env.RCS_DISCONNECT_TIMEOUT || "300"),
/** Bun WebSocket idle timeout (seconds). Bun sends protocol-level pings after
* this many seconds of no received data. Must be shorter than any reverse
* proxy's idle timeout (nginx default 60s, Cloudflare 100s). Default 30s. */
wsIdleTimeout: parseInt(process.env.RCS_WS_IDLE_TIMEOUT || "30"),
/** Server→client keep_alive data-frame interval (seconds). Keeps reverse
* proxies from closing idle connections. Default 20s. */
wsKeepaliveInterval: parseInt(process.env.RCS_WS_KEEPALIVE_INTERVAL || "20"),
} as const; } as const;
export function getBaseUrl(): string { export function getBaseUrl(): string {

View File

@@ -4,20 +4,15 @@ import { logger } from "hono/logger";
import { serveStatic } from "hono/bun"; import { serveStatic } from "hono/bun";
import { config } from "./config"; import { config } from "./config";
import { closeAllConnections } from "./transport/ws-handler"; import { closeAllConnections } from "./transport/ws-handler";
import { closeAllAcpConnections } from "./transport/acp-ws-handler";
import { closeAllRelayConnections } from "./transport/acp-relay-handler";
import { startDisconnectMonitor } from "./services/disconnect-monitor"; import { startDisconnectMonitor } from "./services/disconnect-monitor";
import { dirname, resolve } from "node:path"; import { dirname, resolve } from "node:path";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import acpRoutes from "./routes/acp";
// Routes // Routes
import v1Environments from "./routes/v1/environments"; import v1Environments from "./routes/v1/environments";
import v1EnvironmentsWork from "./routes/v1/environments.work"; import v1EnvironmentsWork from "./routes/v1/environments.work";
import v1Sessions from "./routes/v1/sessions"; import v1Sessions from "./routes/v1/sessions";
import v1SessionIngress from "./routes/v1/session-ingress"; import v1SessionIngress, { websocket } from "./routes/v1/session-ingress";
import { websocket } from "./transport/ws-shared";
import v2CodeSessions from "./routes/v2/code-sessions"; import v2CodeSessions from "./routes/v2/code-sessions";
import v2Worker from "./routes/v2/worker"; import v2Worker from "./routes/v2/worker";
import v2WorkerEventsStream from "./routes/v2/worker-events-stream"; import v2WorkerEventsStream from "./routes/v2/worker-events-stream";
@@ -38,11 +33,9 @@ app.use("/web/*", cors());
// Health check // Health check
app.get("/health", (c) => c.json({ status: "ok", version: config.version })); app.get("/health", (c) => c.json({ status: "ok", version: config.version }));
// Static files — serve built web UI under /code path // Static files — serve web/ directory under /code path
// Uses web/dist/ if it exists (production), otherwise falls back to web/ (dev/fallback)
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const distDir = resolve(__dirname, "../web/dist"); const webDir = resolve(__dirname, "../web");
const webDir = existsSync(resolve(distDir, "index.html")) ? distDir : resolve(__dirname, "../web");
const stripCodePrefix = (p: string) => p.replace(/^\/code/, ""); const stripCodePrefix = (p: string) => p.replace(/^\/code/, "");
@@ -77,10 +70,6 @@ app.route("/web", webSessions);
app.route("/web", webControl); app.route("/web", webControl);
app.route("/web", webEnvironments); app.route("/web", webEnvironments);
// ACP protocol routes
console.log("[RCS] ACP support enabled");
app.route("/acp", acpRoutes);
const port = config.port; const port = config.port;
const host = config.host; const host = config.host;
@@ -88,8 +77,6 @@ console.log(`[RCS] Remote Control Server starting on ${host}:${port}`);
console.log("[RCS] API key configuration loaded"); console.log("[RCS] API key configuration loaded");
console.log(`[RCS] Base URL: ${config.baseUrl || `http://localhost:${port}`}`); console.log(`[RCS] Base URL: ${config.baseUrl || `http://localhost:${port}`}`);
console.log(`[RCS] Disconnect timeout: ${config.disconnectTimeout}s`); console.log(`[RCS] Disconnect timeout: ${config.disconnectTimeout}s`);
console.log(`[RCS] WebSocket idle timeout: ${config.wsIdleTimeout}s (protocol-level pings)`);
console.log(`[RCS] WebSocket keepalive interval: ${config.wsKeepaliveInterval}s (data frames)`);
// Start disconnect monitor // Start disconnect monitor
startDisconnectMonitor(); startDisconnectMonitor();
@@ -100,17 +87,15 @@ export default {
fetch: app.fetch, fetch: app.fetch,
websocket: { websocket: {
...websocket, ...websocket,
idleTimeout: config.wsIdleTimeout, // Bun sends protocol pings after this many seconds of silence idleTimeout: 255, // WS idle timeout (seconds) — must be inside websocket object
}, },
idleTimeout: config.wsIdleTimeout, // HTTP server idle timeout (seconds) idleTimeout: 255, // HTTP server idle timeout (seconds) — needed for long-polling endpoints
}; };
// Graceful shutdown // Graceful shutdown
async function gracefulShutdown(signal: string) { async function gracefulShutdown(signal: string) {
console.log(`\n[RCS] Received ${signal}, shutting down...`); console.log(`\n[RCS] Received ${signal}, shutting down...`);
closeAllConnections(); closeAllConnections();
closeAllAcpConnections();
closeAllRelayConnections();
process.exit(0); process.exit(0);
} }

View File

@@ -1,214 +0,0 @@
import { Hono } from "hono";
import { upgradeWebSocket } from "../../transport/ws-shared";
import { apiKeyAuth } from "../../auth/middleware";
import { validateApiKey } from "../../auth/api-key";
import {
handleAcpWsOpen,
handleAcpWsMessage,
handleAcpWsClose,
} from "../../transport/acp-ws-handler";
import {
handleRelayOpen,
handleRelayMessage,
handleRelayClose,
} from "../../transport/acp-relay-handler";
import {
storeListAcpAgents,
storeListAcpAgentsByChannelGroup,
storeGetEnvironment,
} from "../../store";
import { createAcpSSEStream } from "../../transport/acp-sse-writer";
import { log, error as logError } from "../../logger";
const app = new Hono();
/** Maximum WebSocket message size: 10 MB */
const MAX_WS_MESSAGE_SIZE = 10 * 1024 * 1024;
/** Response shape for an ACP agent */
function toAcpAgentResponse(env: ReturnType<typeof storeGetEnvironment> & {}) {
if (!env) return null;
return {
id: env.id,
agent_name: env.machineName,
channel_group_id: env.bridgeId,
status: env.status === "active" ? "online" : "offline",
max_sessions: env.maxSessions,
last_seen_at: env.lastPollAt ? env.lastPollAt.getTime() / 1000 : null,
created_at: env.createdAt.getTime() / 1000,
};
}
/** GET /acp/agents — List all registered ACP agents (UUID or API key auth) */
app.get("/agents", async (c) => {
// Require at least UUID auth
const uuid = c.req.query("uuid");
const authHeader = c.req.header("Authorization");
const queryToken = c.req.query("token");
const token = authHeader?.replace("Bearer ", "") || queryToken;
if (!uuid && !(token && validateApiKey(token))) {
return c.json({ error: { type: "unauthorized", message: "Missing auth" } }, 401);
}
const agents = storeListAcpAgents();
return c.json(agents.map((a) => toAcpAgentResponse(a)).filter(Boolean));
});
/** GET /acp/channel-groups — List all channel groups with member agents (UUID or API key auth) */
app.get("/channel-groups", async (c) => {
const uuid = c.req.query("uuid");
const authHeader = c.req.header("Authorization");
const queryToken = c.req.query("token");
const token = authHeader?.replace("Bearer ", "") || queryToken;
if (!uuid && !(token && validateApiKey(token))) {
return c.json({ error: { type: "unauthorized", message: "Missing auth" } }, 401);
}
const agents = storeListAcpAgents();
const groupMap = new Map<string, typeof agents>();
for (const agent of agents) {
const groupId = agent.bridgeId || "default";
if (!groupMap.has(groupId)) {
groupMap.set(groupId, []);
}
groupMap.get(groupId)!.push(agent);
}
const groups = [...groupMap.entries()].map(([id, members]) => ({
channel_group_id: id,
member_count: members.length,
members: members.map((m) => toAcpAgentResponse(m)).filter(Boolean),
}));
return c.json(groups);
});
/** GET /acp/channel-groups/:id — Specific channel group detail (no auth for web UI) */
app.get("/channel-groups/:id", async (c) => {
const groupId = c.req.param("id")!;
const members = storeListAcpAgentsByChannelGroup(groupId);
if (members.length === 0) {
return c.json({ error: { type: "not_found", message: "Channel group not found" } }, 404);
}
return c.json({
channel_group_id: groupId,
member_count: members.length,
members: members.map((m) => toAcpAgentResponse(m)).filter(Boolean),
});
});
/** SSE /acp/channel-groups/:id/events — Event stream for external consumers (no auth for web UI) */
app.get("/channel-groups/:id/events", async (c) => {
const groupId = c.req.param("id")!;
// Support Last-Event-ID / from_sequence_num for reconnection
const lastEventId = c.req.header("Last-Event-ID");
const fromSeq = c.req.query("from_sequence_num");
const fromSeqNum = fromSeq ? parseInt(fromSeq) : lastEventId ? parseInt(lastEventId) : 0;
return createAcpSSEStream(c, groupId, fromSeqNum);
});
/** WS /acp/ws — WebSocket endpoint for acp-link connections */
app.get(
"/ws",
upgradeWebSocket(async (c) => {
// Authenticate via API key in query param or header
const authHeader = c.req.header("Authorization");
const queryToken = c.req.query("token");
const token = authHeader?.replace("Bearer ", "") || queryToken;
if (!token || !validateApiKey(token)) {
log("[ACP-WS] Upgrade rejected: unauthorized");
return {
onOpen(_evt: any, ws: any) {
ws.close(4003, "unauthorized");
},
};
}
// Generate unique wsId for this connection
const { v4: uuid } = await import("uuid");
const wsId = `acp_ws_${uuid().replace(/-/g, "")}`;
log(`[ACP-WS] Upgrade accepted: wsId=${wsId}`);
return {
onOpen(_evt: any, ws: any) {
handleAcpWsOpen(ws, wsId);
},
onMessage(evt: any, ws: any) {
const data =
typeof evt.data === "string"
? evt.data
: new TextDecoder().decode(evt.data as ArrayBuffer);
if (data.length > MAX_WS_MESSAGE_SIZE) {
logError(`[ACP-WS] Message too large on wsId=${wsId}: ${data.length} bytes`);
ws.close(1009, "message too large");
return;
}
handleAcpWsMessage(ws, wsId, data);
},
onClose(evt: any, ws: any) {
const closeEvt = evt as unknown as CloseEvent;
handleAcpWsClose(ws, wsId, closeEvt?.code, closeEvt?.reason);
},
onError(evt: any, ws: any) {
logError(`[ACP-WS] Error on wsId=${wsId}:`, evt);
handleAcpWsClose(ws, wsId, 1006, "websocket error");
},
};
}),
);
/** WS /acp/relay/:agentId — WebSocket relay for frontend to interact with an agent */
app.get(
"/relay/:agentId",
upgradeWebSocket(async (c) => {
// Authenticate via UUID (web frontend) or API key (legacy)
const clientUuid = c.req.query("uuid");
const authHeader = c.req.header("Authorization");
const queryToken = c.req.query("token");
const token = authHeader?.replace("Bearer ", "") || queryToken;
const hasUuid = !!clientUuid;
const hasApiKey = !!token && validateApiKey(token);
if (!hasUuid && !hasApiKey) {
log("[ACP-Relay] Upgrade rejected: unauthorized");
return {
onOpen(_evt: any, ws: any) {
ws.close(4003, "unauthorized");
},
};
}
const agentId = c.req.param("agentId")!;
const { v4: uuid } = await import("uuid");
const relayWsId = `relay_${uuid().replace(/-/g, "")}`;
log(`[ACP-Relay] Upgrade accepted: relayWsId=${relayWsId} agentId=${agentId}`);
return {
onOpen(_evt: any, ws: any) {
handleRelayOpen(ws, relayWsId, agentId);
},
onMessage(evt: any, ws: any) {
const data =
typeof evt.data === "string"
? evt.data
: new TextDecoder().decode(evt.data as ArrayBuffer);
if (data.length > MAX_WS_MESSAGE_SIZE) {
logError(`[ACP-Relay] Message too large on relayWsId=${relayWsId}: ${data.length} bytes`);
ws.close(1009, "message too large");
return;
}
handleRelayMessage(ws, relayWsId, data);
},
onClose(evt: any, ws: any) {
const closeEvt = evt as unknown as CloseEvent;
handleRelayClose(ws, relayWsId, closeEvt?.code, closeEvt?.reason);
},
onError(evt: any, ws: any) {
logError(`[ACP-Relay] Error on relayWsId=${relayWsId}:`, evt);
handleRelayClose(ws, relayWsId, 1006, "websocket error");
},
};
}),
);
export default app;

View File

@@ -14,14 +14,14 @@ app.post("/bridge", acceptCliHeaders, apiKeyAuth, async (c) => {
/** DELETE /v1/environments/bridge/:id — Deregister */ /** DELETE /v1/environments/bridge/:id — Deregister */
app.delete("/bridge/:id", acceptCliHeaders, apiKeyAuth, async (c) => { app.delete("/bridge/:id", acceptCliHeaders, apiKeyAuth, async (c) => {
const envId = c.req.param("id")!; const envId = c.req.param("id");
deregisterEnvironment(envId); deregisterEnvironment(envId);
return c.json({ status: "ok" }, 200); return c.json({ status: "ok" }, 200);
}); });
/** POST /v1/environments/:id/bridge/reconnect — Reconnect */ /** POST /v1/environments/:id/bridge/reconnect — Reconnect */
app.post("/:id/bridge/reconnect", acceptCliHeaders, apiKeyAuth, async (c) => { app.post("/:id/bridge/reconnect", acceptCliHeaders, apiKeyAuth, async (c) => {
const envId = c.req.param("id")!; const envId = c.req.param("id");
reconnectEnvironment(envId); reconnectEnvironment(envId);
const { reconnectWorkForEnvironment } = await import("../../services/work-dispatch"); const { reconnectWorkForEnvironment } = await import("../../services/work-dispatch");
await reconnectWorkForEnvironment(envId); await reconnectWorkForEnvironment(envId);

View File

@@ -7,7 +7,7 @@ const app = new Hono();
/** GET /v1/environments/:id/work/poll — Long-poll for work */ /** GET /v1/environments/:id/work/poll — Long-poll for work */
app.get("/:id/work/poll", acceptCliHeaders, apiKeyAuth, async (c) => { app.get("/:id/work/poll", acceptCliHeaders, apiKeyAuth, async (c) => {
const envId = c.req.param("id")!; const envId = c.req.param("id");
updatePollTime(envId); updatePollTime(envId);
const result = await pollWork(envId); const result = await pollWork(envId);
if (!result) { if (!result) {
@@ -19,21 +19,21 @@ app.get("/:id/work/poll", acceptCliHeaders, apiKeyAuth, async (c) => {
/** POST /v1/environments/:id/work/:workId/ack — Acknowledge work */ /** POST /v1/environments/:id/work/:workId/ack — Acknowledge work */
app.post("/:id/work/:workId/ack", acceptCliHeaders, apiKeyAuth, async (c) => { app.post("/:id/work/:workId/ack", acceptCliHeaders, apiKeyAuth, async (c) => {
const workId = c.req.param("workId")!; const workId = c.req.param("workId");
ackWork(workId); ackWork(workId);
return c.json({ status: "ok" }, 200); return c.json({ status: "ok" }, 200);
}); });
/** POST /v1/environments/:id/work/:workId/stop — Stop work */ /** POST /v1/environments/:id/work/:workId/stop — Stop work */
app.post("/:id/work/:workId/stop", acceptCliHeaders, apiKeyAuth, async (c) => { app.post("/:id/work/:workId/stop", acceptCliHeaders, apiKeyAuth, async (c) => {
const workId = c.req.param("workId")!; const workId = c.req.param("workId");
stopWork(workId); stopWork(workId);
return c.json({ status: "ok" }, 200); return c.json({ status: "ok" }, 200);
}); });
/** POST /v1/environments/:id/work/:workId/heartbeat — Heartbeat */ /** POST /v1/environments/:id/work/:workId/heartbeat — Heartbeat */
app.post("/:id/work/:workId/heartbeat", acceptCliHeaders, apiKeyAuth, async (c) => { app.post("/:id/work/:workId/heartbeat", acceptCliHeaders, apiKeyAuth, async (c) => {
const workId = c.req.param("workId")!; const workId = c.req.param("workId");
const result = heartbeatWork(workId); const result = heartbeatWork(workId);
return c.json(result, 200); return c.json(result, 200);
}); });

View File

@@ -1,6 +1,6 @@
import { log, error as logError } from "../../logger"; import { log, error as logError } from "../../logger";
import { Hono } from "hono"; import { Hono } from "hono";
import { upgradeWebSocket, websocket } from "../../transport/ws-shared"; import { createBunWebSocket } from "hono/bun";
import { validateApiKey } from "../../auth/api-key"; import { validateApiKey } from "../../auth/api-key";
import { verifyWorkerJwt } from "../../auth/jwt"; import { verifyWorkerJwt } from "../../auth/jwt";
import { import {
@@ -9,7 +9,9 @@ import {
handleWebSocketClose, handleWebSocketClose,
ingestBridgeMessage, ingestBridgeMessage,
} from "../../transport/ws-handler"; } from "../../transport/ws-handler";
import { getSession, resolveExistingSessionId } from "../../services/session"; import { getSession } from "../../services/session";
const { upgradeWebSocket, websocket } = createBunWebSocket();
const app = new Hono(); const app = new Hono();
@@ -42,8 +44,7 @@ function authenticateRequest(c: any, label: string, expectedSessionId?: string):
/** POST /v2/session_ingress/session/:sessionId/events — HTTP POST (HybridTransport writes) */ /** POST /v2/session_ingress/session/:sessionId/events — HTTP POST (HybridTransport writes) */
app.post("/session/:sessionId/events", async (c) => { app.post("/session/:sessionId/events", async (c) => {
const requestedSessionId = c.req.param("sessionId")!; const sessionId = c.req.param("sessionId")!;
const sessionId = resolveExistingSessionId(requestedSessionId) ?? requestedSessionId;
if (!authenticateRequest(c, `POST session/${sessionId}`, sessionId)) { if (!authenticateRequest(c, `POST session/${sessionId}`, sessionId)) {
return c.json({ error: { type: "unauthorized", message: "Invalid auth" } }, 401); return c.json({ error: { type: "unauthorized", message: "Invalid auth" } }, 401);
@@ -71,8 +72,7 @@ app.post("/session/:sessionId/events", async (c) => {
app.get( app.get(
"/ws/:sessionId", "/ws/:sessionId",
upgradeWebSocket(async (c) => { upgradeWebSocket(async (c) => {
const requestedSessionId = c.req.param("sessionId")!; const sessionId = c.req.param("sessionId")!;
const sessionId = resolveExistingSessionId(requestedSessionId) ?? requestedSessionId;
if (!authenticateRequest(c, `WS ${sessionId}`, sessionId)) { if (!authenticateRequest(c, `WS ${sessionId}`, sessionId)) {
return { return {

View File

@@ -5,7 +5,6 @@ import {
getSession, getSession,
updateSessionTitle, updateSessionTitle,
archiveSession, archiveSession,
resolveExistingSessionId,
} from "../../services/session"; } from "../../services/session";
import { createWorkItem } from "../../services/work-dispatch"; import { createWorkItem } from "../../services/work-dispatch";
import { apiKeyAuth, acceptCliHeaders } from "../../auth/middleware"; import { apiKeyAuth, acceptCliHeaders } from "../../auth/middleware";
@@ -40,8 +39,7 @@ app.post("/", acceptCliHeaders, apiKeyAuth, async (c) => {
/** GET /v1/sessions/:id — Get session */ /** GET /v1/sessions/:id — Get session */
app.get("/:id", acceptCliHeaders, apiKeyAuth, async (c) => { app.get("/:id", acceptCliHeaders, apiKeyAuth, async (c) => {
const sessionId = resolveExistingSessionId(c.req.param("id")!) ?? c.req.param("id")!; const session = getSession(c.req.param("id"));
const session = getSession(sessionId);
if (!session) { if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404); return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
} }
@@ -50,43 +48,27 @@ app.get("/:id", acceptCliHeaders, apiKeyAuth, async (c) => {
/** PATCH /v1/sessions/:id — Update session title */ /** PATCH /v1/sessions/:id — Update session title */
app.patch("/:id", acceptCliHeaders, apiKeyAuth, async (c) => { app.patch("/:id", acceptCliHeaders, apiKeyAuth, async (c) => {
const sessionId = resolveExistingSessionId(c.req.param("id")!) ?? c.req.param("id")!;
const existing = getSession(sessionId);
if (!existing) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
}
const body = await c.req.json(); const body = await c.req.json();
if (body.title) { if (body.title) {
updateSessionTitle(sessionId, body.title); updateSessionTitle(c.req.param("id"), body.title);
} }
const session = getSession(sessionId); const session = getSession(c.req.param("id"));
return c.json(session, 200); return c.json(session, 200);
}); });
/** POST /v1/sessions/:id/archive — Archive session */ /** POST /v1/sessions/:id/archive — Archive session */
app.post("/:id/archive", acceptCliHeaders, apiKeyAuth, async (c) => { app.post("/:id/archive", acceptCliHeaders, apiKeyAuth, async (c) => {
const sessionId = resolveExistingSessionId(c.req.param("id")!) ?? c.req.param("id")!;
const session = getSession(sessionId);
if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
}
try { try {
archiveSession(sessionId); archiveSession(c.req.param("id"));
} catch { } catch {
return c.json({ status: "ok" }, 409); return c.json({ status: "ok" }, 409);
} }
return c.json({ status: "ok" }, 200); return c.json({ status: "ok" }, 200);
}); });
/** POST /v1/sessions/:id/events — Send event to session */ /** POST /v1/sessions/:id/events — Send event to session */
app.post("/:id/events", acceptCliHeaders, apiKeyAuth, async (c) => { app.post("/:id/events", acceptCliHeaders, apiKeyAuth, async (c) => {
const sessionId = resolveExistingSessionId(c.req.param("id")!) ?? c.req.param("id")!; const sessionId = c.req.param("id");
const session = getSession(sessionId);
if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
}
const body = await c.req.json(); const body = await c.req.json();
const events = body.events const events = body.events

View File

@@ -15,7 +15,7 @@ app.post("/", acceptCliHeaders, apiKeyAuth, async (c) => {
/** POST /v1/code/sessions/:id/bridge — Get connection info + worker JWT */ /** POST /v1/code/sessions/:id/bridge — Get connection info + worker JWT */
app.post("/:id/bridge", acceptCliHeaders, apiKeyAuth, async (c) => { app.post("/:id/bridge", acceptCliHeaders, apiKeyAuth, async (c) => {
const sessionId = c.req.param("id")!; const sessionId = c.req.param("id");
const session = getSession(sessionId); const session = getSession(sessionId);
if (!session) { if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404); return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);

Some files were not shown because too many files have changed in this diff Show More