mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 13:55:50 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c1db0e543 | ||
|
|
1171f487ca | ||
|
|
49869ffa3e | ||
|
|
40b5e4452d | ||
|
|
6fb36390b1 | ||
|
|
962ed75f4b | ||
|
|
920a7ffd9d | ||
|
|
aa0f868790 | ||
|
|
3c4fa38b19 | ||
|
|
e601557716 | ||
|
|
a36ab55ff9 | ||
|
|
46593d952a | ||
|
|
3c9112f969 | ||
|
|
67a77ba327 | ||
|
|
befcd2bafa | ||
|
|
e458d6391d | ||
|
|
ac0ca4a481 | ||
|
|
a99375b03d | ||
|
|
670cad66ad | ||
|
|
a3fbcb31c0 |
14
.github/workflows/ci.yml
vendored
14
.github/workflows/ci.yml
vendored
@@ -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:vite
|
||||||
|
|||||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -13,8 +13,9 @@ src/utils/vendor/
|
|||||||
# AI tool runtime directories
|
# AI tool runtime directories
|
||||||
.agents/
|
.agents/
|
||||||
.claude/
|
.claude/
|
||||||
|
.codex/
|
||||||
.omx/
|
.omx/
|
||||||
.docs/task/
|
|
||||||
# Binary / screenshot files (root only)
|
# Binary / screenshot files (root only)
|
||||||
/*.png
|
/*.png
|
||||||
*.bmp
|
*.bmp
|
||||||
@@ -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/**
|
|
||||||
|
|||||||
@@ -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`
|
|
||||||
113
CLAUDE.md
113
CLAUDE.md
@@ -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
|
||||||
|
|
||||||
@@ -77,14 +74,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 — 15 个 workspace packages + 若干辅助目录 in `packages/` resolved via `workspace:*`。
|
- **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`。
|
- **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 +94,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 +112,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 +121,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 +152,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 Server(Docker 部署,含 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 Server(Docker 部署,含 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 +163,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 +198,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 +247,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 +273,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 +283,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 UI(RCS 控制面板、文档站、着陆页)时必须参考该文件。
|
|
||||||
|
|
||||||
### 核心设计原则
|
|
||||||
|
|
||||||
1. **Considered over clever** — 每个设计选择都应感觉有意为之,而非追逐潮流
|
|
||||||
2. **Warmth through subtlety** — 通过橙色色调的中性色、留白布局、有温度的文案来传达温暖
|
|
||||||
3. **Density with clarity** — 技术用户需要信息密度,但不能混乱
|
|
||||||
4. **Community voice** — 设计应感觉是由使用者创造的,而非遥远的设计团队
|
|
||||||
5. **Anthropic's shadow** — 遵循 Anthropic 的设计直觉:干净的布局、充足的间距、温暖的色温
|
|
||||||
|
|
||||||
### 品牌色
|
|
||||||
|
|
||||||
- 主色:Claude Orange `#D77757`(terra cotta)
|
|
||||||
- 辅色:Claude Blue `#5769F7`
|
|
||||||
- 暗色模式使用温暖的深色表面(非冷蓝黑色)
|
|
||||||
|
|
||||||
### 目标用户
|
|
||||||
|
|
||||||
技术团队/企业,在专业工作流中使用 AI 辅助编程。友好的开源社区氛围,非企业 SaaS 风格。
|
|
||||||
|
|
||||||
### 视觉参考
|
|
||||||
|
|
||||||
Anthropic 公司的设计风格 — 干净、考究、温暖的底色。大量留白,以排版为核心。避免 AI 产品常见的设计套路(渐变文字、玻璃态、霓虹色)。
|
|
||||||
|
|||||||
19
README.md
19
README.md
@@ -10,25 +10,28 @@
|
|||||||
|
|
||||||
> 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) |
|
| ACP 协议一等一支持 | 支持接入 Zed、Cursor 等 IDE,支持会话恢复、Skills、权限桥接 | [文档](https://ccb.agent-aura.top/docs/features/acp-zed) |
|
||||||
| **Remote Control 私有部署** | Docker 自托管远程界面, 可以手机上看 CC | [文档](https://ccb.agent-aura.top/docs/features/remote-control-self-hosting) |
|
| Remote Control 私有部署 | Docker 自托管 RCS + Web UI | [文档](https://ccb.agent-aura.top/docs/features/remote-control-self-hosting) |
|
||||||
| **Langfuse 监控** | 企业级 Agent 监控, 可以清晰看到每次 agent loop 细节, 可以一键转化为数据集 | [文档](https://ccb.agent-aura.top/docs/features/langfuse-monitoring) |
|
| /dream 记忆整理 | 自动整理和优化记忆文件 | [文档](https://ccb.agent-aura.top/docs/features/auto-dream) |
|
||||||
| **Web Search** | 内置网页搜索工具, 支持 bing 和 brave 搜索 | [文档](https://ccb.agent-aura.top/docs/features/web-browser-tool) |
|
| Web Search | 内置网页搜索工具 | [文档](https://ccb.agent-aura.top/docs/features/web-browser-tool) |
|
||||||
| **Poor Mode** | 穷鬼模式,关闭记忆提取和键入建议,大幅度减少并发请求 | /poor 可以开关 |
|
| 自定义模型供应商 | OpenAI/Anthropic/Gemini/Grok 兼容 | [文档](https://ccb.agent-aura.top/docs/features/custom-platform-login) |
|
||||||
| **自定义模型供应商** | 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 | 浏览器自动化、表单填写、数据抓取 | [自托管](https://ccb.agent-aura.top/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-调试)
|
||||||
|
|||||||
2
build.ts
2
build.ts
@@ -42,8 +42,6 @@ const DEFAULT_BUILD_FEATURES = [
|
|||||||
'KAIROS',
|
'KAIROS',
|
||||||
'COORDINATOR_MODE',
|
'COORDINATOR_MODE',
|
||||||
'LAN_PIPES',
|
'LAN_PIPES',
|
||||||
'BG_SESSIONS',
|
|
||||||
'TEMPLATES',
|
|
||||||
// 'REVIEW_ARTIFACT', // API 请求无响应,需进一步排查 schema 兼容性
|
// 'REVIEW_ARTIFACT', // API 请求无响应,需进一步排查 schema 兼容性
|
||||||
// P3: poor mode (disable extract_memories + prompt_suggestion)
|
// P3: poor mode (disable extract_memories + prompt_suggestion)
|
||||||
'POOR',
|
'POOR',
|
||||||
|
|||||||
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 |
@@ -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` | 最大会话数 |
|
|
||||||
@@ -1,318 +0,0 @@
|
|||||||
# Daemon 重构设计方案
|
|
||||||
|
|
||||||
> 分支: `feat/integrate-5-branches`
|
|
||||||
> 基于: `f41745cb` (= main `11bb3f62` 内容)
|
|
||||||
> 日期: 2026-04-13
|
|
||||||
|
|
||||||
## 一、问题概述
|
|
||||||
|
|
||||||
### 1.1 命令结构散乱
|
|
||||||
|
|
||||||
当前后台进程相关的命令分布在三个不同的位置,没有统一的命名空间:
|
|
||||||
|
|
||||||
| 命令 | 注册位置 | 入口 |
|
|
||||||
|------|---------|------|
|
|
||||||
| `claude daemon start/status/stop` | `cli.tsx` 快速路径 L203 | `daemon/main.ts` |
|
|
||||||
| `claude ps` | `cli.tsx` 快速路径 L220 | `cli/bg.ts` |
|
|
||||||
| `claude logs <x>` | `cli.tsx` 快速路径 L232 | `cli/bg.ts` |
|
|
||||||
| `claude attach <x>` | `cli.tsx` 快速路径 L236 | `cli/bg.ts` |
|
|
||||||
| `claude kill <x>` | `cli.tsx` 快速路径 L238 | `cli/bg.ts` |
|
|
||||||
| `claude --bg` | `cli.tsx` 快速路径 L244 | `cli/bg.ts` |
|
|
||||||
| `claude new/list/reply` | `cli.tsx` 快速路径 L250 | `cli/handlers/templateJobs.ts` |
|
|
||||||
| `claude rollback` | `main.tsx` Commander.js L6525 | `cli/rollback.ts` |
|
|
||||||
| `claude up` | `main.tsx` Commander.js L6511 | `cli/up.ts` |
|
|
||||||
|
|
||||||
**问题**:
|
|
||||||
- `ps/logs/attach/kill` 与 `daemon` 逻辑上都是后台进程管理,但互不关联
|
|
||||||
- 这些命令都**只有 CLI 入口**,REPL 里输入 `/daemon` 或 `/ps` 不存在
|
|
||||||
- `new/list/reply` 是模板任务系统的顶级命令,容易与其他命令冲突(特别是 `list`)
|
|
||||||
|
|
||||||
### 1.2 Windows 不支持
|
|
||||||
|
|
||||||
`--bg` 和 `attach` 硬依赖 tmux:
|
|
||||||
- `bg.ts:handleBgFlag()` 第一步就检查 tmux,不可用直接报错退出
|
|
||||||
- `bg.ts:attachHandler()` 用 `tmux attach-session`,无 tmux 替代方案
|
|
||||||
- Windows (包括 VS Code 终端) 完全无法使用后台会话功能
|
|
||||||
|
|
||||||
### 1.3 无 REPL 入口
|
|
||||||
|
|
||||||
对比 `/mcp` 的双注册模式:
|
|
||||||
- **CLI**: `claude mcp serve/add/remove/list` (Commander.js, `main.tsx:5760`)
|
|
||||||
- **REPL**: `/mcp enable/disable/reconnect` (slash command, `commands/mcp/index.ts`)
|
|
||||||
|
|
||||||
`daemon`/`bg`/`job` 系列只有 CLI 快速路径,REPL 中完全不可用。
|
|
||||||
|
|
||||||
## 二、目标
|
|
||||||
|
|
||||||
1. **层级化命令结构**: 参照 `/mcp` 模式,将后台管理收归 `/daemon`,模板任务收归 `/job`
|
|
||||||
2. **跨平台后台会话**: Windows / macOS / Linux 都能启动、附着、终止后台会话
|
|
||||||
3. **双注册**: CLI (`claude daemon ...`) + REPL (`/daemon ...`) 同时可用
|
|
||||||
4. **向后兼容**: 旧命令保留但输出 deprecation 提示
|
|
||||||
|
|
||||||
## 三、命令结构设计
|
|
||||||
|
|
||||||
### 3.1 `/daemon` — 后台进程管理
|
|
||||||
|
|
||||||
合并 daemon supervisor + bg sessions 为统一命名空间:
|
|
||||||
|
|
||||||
```
|
|
||||||
claude daemon <subcommand> ← CLI 入口 (cli.tsx 快速路径)
|
|
||||||
/daemon <subcommand> ← REPL 入口 (slash command, local-jsx)
|
|
||||||
|
|
||||||
子命令:
|
|
||||||
status 综合状态面板 (daemon + 所有会话)
|
|
||||||
start [--dir <path>] 启动 daemon supervisor
|
|
||||||
stop 停止 daemon
|
|
||||||
bg [args...] 启动后台会话
|
|
||||||
attach [target] 附着到后台会话
|
|
||||||
logs [target] 查看会话日志
|
|
||||||
kill [target] 终止会话
|
|
||||||
(无参数) 等同于 status
|
|
||||||
```
|
|
||||||
|
|
||||||
**CLI 快速路径路由** (`cli.tsx`):
|
|
||||||
```typescript
|
|
||||||
// 新: 统一入口
|
|
||||||
if (feature('DAEMON') && args[0] === 'daemon') {
|
|
||||||
const sub = args[1] || 'status'
|
|
||||||
switch (sub) {
|
|
||||||
case 'start': case 'stop': case 'status':
|
|
||||||
await daemonMain([sub, ...args.slice(2)])
|
|
||||||
break
|
|
||||||
case 'bg':
|
|
||||||
await bg.handleBgStart(args.slice(2))
|
|
||||||
break
|
|
||||||
case 'attach': case 'logs': case 'kill':
|
|
||||||
await bg[`${sub}Handler`](args[2])
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 向后兼容 (deprecated)
|
|
||||||
if (feature('BG_SESSIONS') && ['ps','logs','attach','kill'].includes(args[0])) {
|
|
||||||
console.warn(`[deprecated] Use: claude daemon ${args[0] === 'ps' ? 'status' : args[0]}`)
|
|
||||||
// ... delegate to daemon subcommand
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**REPL 斜杠命令** (`commands/daemon/index.ts`):
|
|
||||||
```typescript
|
|
||||||
const daemon = {
|
|
||||||
type: 'local-jsx',
|
|
||||||
name: 'daemon',
|
|
||||||
description: 'Manage background sessions and daemon',
|
|
||||||
argumentHint: '[status|start|stop|bg|attach|logs|kill]',
|
|
||||||
isEnabled: () => feature('DAEMON') || feature('BG_SESSIONS'),
|
|
||||||
load: () => import('./daemon.js'),
|
|
||||||
} satisfies Command
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 `/job` — 模板任务管理
|
|
||||||
|
|
||||||
```
|
|
||||||
claude job <subcommand> ← CLI 入口
|
|
||||||
/job <subcommand> ← REPL 入口
|
|
||||||
|
|
||||||
子命令:
|
|
||||||
list 列出模板和活跃任务
|
|
||||||
new <template> [args] 从模板创建任务
|
|
||||||
reply <id> <text> 回复任务
|
|
||||||
status <id> 查看任务状态
|
|
||||||
(无参数) 等同于 list
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.3 独立命令 (不变)
|
|
||||||
|
|
||||||
```
|
|
||||||
claude up 保持顶级 (简短的 bootstrap 命令)
|
|
||||||
claude rollback [target] 保持顶级 (低频运维命令)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 四、跨平台后台引擎
|
|
||||||
|
|
||||||
### 4.1 引擎抽象
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/cli/bg/engine.ts
|
|
||||||
export interface BgEngine {
|
|
||||||
readonly name: string
|
|
||||||
|
|
||||||
/** 当前平台是否可用 */
|
|
||||||
available(): Promise<boolean>
|
|
||||||
|
|
||||||
/** 启动后台会话 */
|
|
||||||
start(opts: BgStartOptions): Promise<BgStartResult>
|
|
||||||
|
|
||||||
/** 附着到后台会话(blocking) */
|
|
||||||
attach(session: SessionEntry): Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BgStartOptions {
|
|
||||||
sessionName: string
|
|
||||||
args: string[]
|
|
||||||
env: Record<string, string | undefined>
|
|
||||||
logPath: string
|
|
||||||
cwd: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BgStartResult {
|
|
||||||
pid: number
|
|
||||||
sessionName: string
|
|
||||||
logPath: string
|
|
||||||
engineUsed: string
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 三种引擎实现
|
|
||||||
|
|
||||||
| 引擎 | 平台 | 启动方式 | attach 方式 |
|
|
||||||
|------|------|---------|------------|
|
|
||||||
| TmuxEngine | macOS/Linux (有 tmux) | `tmux new-session -d` | `tmux attach-session` |
|
|
||||||
| DetachedEngine | Windows / 无 tmux 的 macOS/Linux | `spawn({ detached, stdio→logFile })` | `tail -f` 日志文件 |
|
|
||||||
|
|
||||||
#### DetachedEngine 详细设计
|
|
||||||
|
|
||||||
**启动 (`start`)**:
|
|
||||||
```typescript
|
|
||||||
// 1. 打开日志文件 fd
|
|
||||||
const logFd = fs.openSync(logPath, 'a')
|
|
||||||
// 2. detached spawn, stdout/stderr 重定向到日志
|
|
||||||
const child = spawn(process.execPath, execArgs, {
|
|
||||||
detached: true,
|
|
||||||
stdio: ['ignore', logFd, logFd],
|
|
||||||
env,
|
|
||||||
cwd,
|
|
||||||
})
|
|
||||||
child.unref()
|
|
||||||
fs.closeSync(logFd)
|
|
||||||
// 3. 写 sessions/<PID>.json
|
|
||||||
```
|
|
||||||
|
|
||||||
**附着 (`attach`)**:
|
|
||||||
```typescript
|
|
||||||
// 跨平台 tail -f 实现
|
|
||||||
// 1. 读取已有日志内容输出到 stdout
|
|
||||||
// 2. fs.watch(logPath) 监听变化
|
|
||||||
// 3. 每次变化读取新增内容
|
|
||||||
// 4. Ctrl+C 退出 tail(不杀后台进程)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 引擎选择逻辑
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/cli/bg/engines/index.ts
|
|
||||||
export async function selectEngine(): Promise<BgEngine> {
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
return new DetachedEngine()
|
|
||||||
}
|
|
||||||
|
|
||||||
const tmux = new TmuxEngine()
|
|
||||||
if (await tmux.available()) {
|
|
||||||
return tmux
|
|
||||||
}
|
|
||||||
|
|
||||||
return new DetachedEngine()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.3 SessionEntry 扩展
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface SessionEntry {
|
|
||||||
// ... 现有字段
|
|
||||||
engine: 'tmux' | 'detached' // 新增: 记录使用的引擎
|
|
||||||
tmuxSessionName?: string // tmux 引擎才有
|
|
||||||
logPath?: string // 两种引擎都有
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`attach` 时根据 `session.engine` 选择对应的 attach 策略。
|
|
||||||
|
|
||||||
## 五、文件变更清单
|
|
||||||
|
|
||||||
### 新增文件 (10 个)
|
|
||||||
|
|
||||||
```
|
|
||||||
src/cli/bg/engine.ts BgEngine 接口定义
|
|
||||||
src/cli/bg/engines/tmux.ts TmuxEngine (从 bg.ts 提取)
|
|
||||||
src/cli/bg/engines/detached.ts DetachedEngine (新实现)
|
|
||||||
src/cli/bg/engines/index.ts 引擎选择 + re-export
|
|
||||||
src/cli/bg/tail.ts 跨平台日志 tail (用于 detached attach)
|
|
||||||
src/commands/daemon/index.ts /daemon REPL 斜杠命令注册
|
|
||||||
src/commands/daemon/daemon.tsx /daemon 子命令路由 + status UI
|
|
||||||
src/commands/job/index.ts /job REPL 斜杠命令注册
|
|
||||||
src/commands/job/job.tsx /job 子命令路由 + UI
|
|
||||||
docs/features/daemon-restructure-design.md 本设计文档
|
|
||||||
```
|
|
||||||
|
|
||||||
### 修改文件 (6 个)
|
|
||||||
|
|
||||||
```
|
|
||||||
src/cli/bg.ts 重构: handler 函数改为调用 BgEngine
|
|
||||||
src/entrypoints/cli.tsx 快速路径: daemon 统一入口 + 向后兼容
|
|
||||||
src/commands.ts 注册 /daemon 和 /job 斜杠命令
|
|
||||||
src/daemon/main.ts daemonMain() 增加 bg/ps/logs 子命令分发
|
|
||||||
src/main.tsx Commander.js: 可选注册 daemon/job 子命令
|
|
||||||
src/cli/handlers/templateJobs.ts 适配 /job 入口 (可能不需改)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 不动的文件
|
|
||||||
|
|
||||||
```
|
|
||||||
src/daemon/state.ts daemon PID 状态管理 (无需改)
|
|
||||||
src/jobs/state.ts job 状态管理 (无需改)
|
|
||||||
src/jobs/templates.ts 模板发现 (无需改)
|
|
||||||
src/jobs/classifier.ts 任务分类器 (无需改)
|
|
||||||
src/cli/rollback.ts 保持顶级命令 (无需改)
|
|
||||||
src/cli/up.ts 保持顶级命令 (无需改)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 六、可行性分析
|
|
||||||
|
|
||||||
### 6.1 风险评估
|
|
||||||
|
|
||||||
| 风险 | 级别 | 缓解措施 |
|
|
||||||
|------|------|---------|
|
|
||||||
| cli.tsx 快速路径修改影响启动性能 | 低 | 仅改路由逻辑,import 仍然 lazy |
|
|
||||||
| DetachedEngine 的 attach 在 Windows 上 fs.watch 不可靠 | 中 | 使用轮询 fallback (setInterval + fs.stat) |
|
|
||||||
| 向后兼容的 deprecation 可能破坏脚本 | 低 | 旧命令保持可用,仅输出 stderr 警告 |
|
|
||||||
| REPL 中 /daemon bg 需要 spawn 子进程 | 中 | 参考 /assistant 的 NewInstallWizard (已有 spawn 先例) |
|
|
||||||
| tsc 类型兼容 | 低 | 接口定义清晰,不引入 any |
|
|
||||||
|
|
||||||
### 6.2 工作量估计
|
|
||||||
|
|
||||||
| Task | 文件数 | 复杂度 |
|
|
||||||
|------|--------|--------|
|
|
||||||
| Task 013: BgEngine 抽象 + 引擎实现 | 5 新增 + 1 修改 | 中 |
|
|
||||||
| Task 014: /daemon 命令层级化 | 3 新增 + 3 修改 | 中 |
|
|
||||||
| Task 015: /job 命令层级化 | 2 新增 + 2 修改 | 低 |
|
|
||||||
| Task 016: 向后兼容 + 测试 | 0 新增 + 2 修改 | 低 |
|
|
||||||
|
|
||||||
### 6.3 依赖关系
|
|
||||||
|
|
||||||
```
|
|
||||||
Task 013 (BgEngine) ← 无依赖,可独立开发
|
|
||||||
Task 014 (/daemon) ← 依赖 Task 013 (引擎选择)
|
|
||||||
Task 015 (/job) ← 无依赖,可与 013 并行
|
|
||||||
Task 016 (兼容) ← 依赖 Task 014 + 015
|
|
||||||
```
|
|
||||||
|
|
||||||
## 七、设计决策记录
|
|
||||||
|
|
||||||
### D1: 为什么 daemon + bg sessions 合为一个命名空间?
|
|
||||||
|
|
||||||
用户视角:都是"后台运行的东西"。分开会导致 `claude daemon status` 看 supervisor + `claude ps` 看会话,割裂感强。合并后 `claude daemon status` 一次性展示 supervisor 状态 + 所有会话列表。
|
|
||||||
|
|
||||||
### D2: 为什么 rollback/up 不收入 daemon?
|
|
||||||
|
|
||||||
它们本质是**版本管理/环境初始化**,不是后台进程管理。`claude up` 是同步阻塞的 setup 脚本,不涉及 daemon 或后台会话。保持顶级更直观。
|
|
||||||
|
|
||||||
### D3: 为什么 DetachedEngine 的 attach 用 tail 而不是 IPC?
|
|
||||||
|
|
||||||
1. 日志文件是最简单的跨平台方案,无需额外依赖
|
|
||||||
2. UDS Pipe IPC 系统 (usePipeIpc) 设计用于实例间通信,不是终端附着
|
|
||||||
3. tmux attach 的体验(完整 PTY)无法在纯 detached 模式下复制,tail 是最诚实的替代
|
|
||||||
|
|
||||||
### D4: 为什么不用 Windows Terminal 的 tab/pane API?
|
|
||||||
|
|
||||||
Windows Terminal 的 `wt.exe` 新窗口/标签功能不够通用——用户可能在 VS Code、ConEmu、cmder 等终端中。detached + log 是唯一跨终端方案。
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# KAIROS — 常驻助手模式
|
# KAIROS — 常驻助手模式
|
||||||
|
|
||||||
> Feature Flag: `FEATURE_KAIROS=1`(及子 Feature)
|
> Feature Flag: `FEATURE_KAIROS=1`(及子 Feature)
|
||||||
> 实现状态:核心框架完整,部分子模块为 stub;proactive/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 选择 UI(stub) |
|
| `src/assistant/AssistantSessionChooser.ts` | — | Session 选择 UI(stub) |
|
||||||
| `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 核心(stub,KAIROS 共享) |
|
||||||
| `src/utils/sessionState.ts` | — | 向 bridge/CCR 暴露 automation 状态 |
|
|
||||||
|
|||||||
@@ -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 hook(REPL 引用但不存在) |
|
||||||
|
|
||||||
## 四、关键设计决策
|
## 四、关键设计决策
|
||||||
|
|
||||||
@@ -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 状态 |
|
||||||
|
|||||||
@@ -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 事件
|
|
||||||
|
|
||||||
## 前置条件
|
## 前置条件
|
||||||
|
|
||||||
@@ -174,70 +169,15 @@ claude bridge
|
|||||||
|
|
||||||
## Web UI 控制面板
|
## Web UI 控制面板
|
||||||
|
|
||||||
通过 `/remote-control` 命令获取 URL 后,在浏览器打开即可使用。
|
通过 `/remote-control` 命令获取 URL 后,在浏览器打开即可使用。功能:
|
||||||
|
|
||||||
### 技术栈(v2,2026-04-18 重构)
|
- 查看已注册的运行环境(environment 模式)
|
||||||
|
|
||||||
Web UI 已从原生 JS 重构为 **React + Vite + Radix UI**:
|
|
||||||
|
|
||||||
- **框架**: React 19 + Vite 构建,TypeScript
|
|
||||||
- **UI 组件**: Radix UI primitives(Dialog、Tabs、Select、Popover 等)
|
|
||||||
- **聊天组件**: 完整的 ACP 聊天界面,支持 Plan 可视化、工具调用展示、权限审批
|
|
||||||
- **AI Elements**: 独立的 AI 交互组件库(message、reasoning、tool、code-block、prompt-input 等)
|
|
||||||
- **ACP 直连**: 支持 QR 码扫描自动跳转 ACP 直连视图(`ACPDirectView`)
|
|
||||||
- **主题系统**: 暗色/亮色主题切换,遵循 Impeccable 设计系统
|
|
||||||
|
|
||||||
### 功能
|
|
||||||
|
|
||||||
- 查看已注册的运行环境(environment 模式),区分 ACP Agent 和 Claude Code 类型
|
|
||||||
- 创建和管理会话
|
- 创建和管理会话
|
||||||
- 实时查看对话消息和工具调用
|
- 实时查看对话消息和工具调用
|
||||||
- 查看 Autopilot 状态(`standby` / `sleeping`)和自动运行指示
|
|
||||||
- 查看 authoritative task snapshots 驱动的 Tasks 面板
|
|
||||||
- 审批 Claude Code 的工具权限请求
|
- 审批 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 +215,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 +224,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 无法连接
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,310 +0,0 @@
|
|||||||
# Stub 恢复设计 1-4
|
|
||||||
|
|
||||||
> 日期:2026-04-12
|
|
||||||
> 目标:基于当前代码边界,为下一阶段 4 个 stub/半 stub 命令面给出可实施的设计方案。
|
|
||||||
> 排序原则:按建议实施顺序排序,不按问题严重性排序。
|
|
||||||
|
|
||||||
## 设计原则
|
|
||||||
|
|
||||||
- 先做能独立闭环、收益明确、改动边界清晰的项。
|
|
||||||
- 大项拆成 `MVP` 和 `Phase 2+`,避免一次性掉进大范围恢复。
|
|
||||||
- 优先复用已有状态、传输层、日志与配置能力,不重造协议。
|
|
||||||
- 设计以当前仓库实际代码为准,不以旧文档的理想状态为准。
|
|
||||||
|
|
||||||
## 1. `claude daemon status` / `claude daemon stop`
|
|
||||||
|
|
||||||
### 现状
|
|
||||||
|
|
||||||
- `start` 路径已有完整 supervisor + worker 生命周期:
|
|
||||||
[src/daemon/main.ts](</e:/Source_code/Claude-code-bast/src/daemon/main.ts:1>)
|
|
||||||
[src/daemon/workerRegistry.ts](</e:/Source_code/Claude-code-bast/src/daemon/workerRegistry.ts:1>)
|
|
||||||
- `status` / `stop` 目前只是占位输出:
|
|
||||||
[src/daemon/main.ts](</e:/Source_code/Claude-code-bast/src/daemon/main.ts:49>)
|
|
||||||
- `/remote-control-server` 有自己的命令内 UI 状态,但只维护当前进程内的 `daemonProcess`,并不适合作为跨进程 CLI 管理基础:
|
|
||||||
[src/commands/remoteControlServer/remoteControlServer.tsx](</e:/Source_code/Claude-code-bast/src/commands/remoteControlServer/remoteControlServer.tsx:32>)
|
|
||||||
|
|
||||||
### 目标
|
|
||||||
|
|
||||||
- 让 `claude daemon status` 和 `claude daemon stop` 在另一个 CLI 进程中也能正确工作。
|
|
||||||
- 不依赖 TUI 内存态,不要求当前命令进程就是启动 daemon 的那个进程。
|
|
||||||
|
|
||||||
### MVP 方案
|
|
||||||
|
|
||||||
- 新增 daemon 状态文件,例如:
|
|
||||||
`~/.claude/daemon/remote-control.json`
|
|
||||||
- `start` 时写入:
|
|
||||||
- supervisor pid
|
|
||||||
- cwd
|
|
||||||
- startedAt
|
|
||||||
- worker kinds
|
|
||||||
- 最近状态
|
|
||||||
- `status`:
|
|
||||||
- 读取状态文件
|
|
||||||
- 用现有进程探测能力验证 pid 是否存活
|
|
||||||
- 输出 `running / stopped / stale`
|
|
||||||
- stale 时自动清理状态文件
|
|
||||||
- `stop`:
|
|
||||||
- 读取 pid
|
|
||||||
- 发送 `SIGTERM`
|
|
||||||
- 等待退出
|
|
||||||
- 超时后 `SIGKILL`
|
|
||||||
- 清理状态文件
|
|
||||||
|
|
||||||
### 代码范围
|
|
||||||
|
|
||||||
- 新增 `src/daemon/state.ts`
|
|
||||||
- 修改 [src/daemon/main.ts](</e:/Source_code/Claude-code-bast/src/daemon/main.ts:1>)
|
|
||||||
- 轻量修改 [src/commands/remoteControlServer/remoteControlServer.tsx](</e:/Source_code/Claude-code-bast/src/commands/remoteControlServer/remoteControlServer.tsx:32>),让 UI 尽量读取同一份状态文件
|
|
||||||
|
|
||||||
### 验证
|
|
||||||
|
|
||||||
1. `claude daemon start`
|
|
||||||
2. 新开终端执行 `claude daemon status`
|
|
||||||
3. 执行 `claude daemon stop`
|
|
||||||
4. 再次执行 `claude daemon status`,确认返回 `stopped` 或清晰的 `stale cleaned`
|
|
||||||
|
|
||||||
### 风险
|
|
||||||
|
|
||||||
- Windows 信号模型和 Unix 不同,`stop` 需要超时兜底。
|
|
||||||
- 当前设计默认单 supervisor,不处理多实例并发。
|
|
||||||
|
|
||||||
### 工作量判断
|
|
||||||
|
|
||||||
- 小
|
|
||||||
- 适合作为下一步的首选实现项
|
|
||||||
|
|
||||||
## 2. `BG_SESSIONS`
|
|
||||||
|
|
||||||
### 现状
|
|
||||||
|
|
||||||
- fast-path 已接好:
|
|
||||||
[src/entrypoints/cli.tsx](</e:/Source_code/Claude-code-bast/src/entrypoints/cli.tsx:218>)
|
|
||||||
- session registry 已有真实实现:
|
|
||||||
[src/utils/concurrentSessions.ts](</e:/Source_code/Claude-code-bast/src/utils/concurrentSessions.ts:1>)
|
|
||||||
- `exit` 在 bg session 内已会 `tmux detach-client`:
|
|
||||||
[src/commands/exit/exit.tsx](</e:/Source_code/Claude-code-bast/src/commands/exit/exit.tsx:20>)
|
|
||||||
- 但 CLI handler 仍全空:
|
|
||||||
[src/cli/bg.ts](</e:/Source_code/Claude-code-bast/src/cli/bg.ts:1>)
|
|
||||||
- task summary 仍然是 stub:
|
|
||||||
[src/utils/taskSummary.ts](</e:/Source_code/Claude-code-bast/src/utils/taskSummary.ts:1>)
|
|
||||||
|
|
||||||
### 目标
|
|
||||||
|
|
||||||
- 先把 `ps` / `logs` / `kill` 做成真正有用的 session 管理命令。
|
|
||||||
- 不在第一阶段就强行补完 `attach` / `--bg`。
|
|
||||||
|
|
||||||
### Phase 2A:MVP
|
|
||||||
|
|
||||||
- 实现 `ps`
|
|
||||||
- 从 registry 读取 live sessions
|
|
||||||
- 展示 pid、kind、sessionId、cwd、name、startedAt、bridgeSessionId
|
|
||||||
- 如果有 activity/status,则一并展示
|
|
||||||
- 实现 `logs`
|
|
||||||
- 支持按 `sessionId / pid / name` 查找
|
|
||||||
- 优先复用本地 transcript/log 读取能力
|
|
||||||
- 如果 registry 里存在 `logPath`,支持 tail 文件
|
|
||||||
- 实现 `kill`
|
|
||||||
- 解析目标 session
|
|
||||||
- 发退出信号
|
|
||||||
- 清理 stale registry
|
|
||||||
|
|
||||||
### Phase 2B:后续
|
|
||||||
|
|
||||||
- 实现 `attach`
|
|
||||||
- 实现 `--bg`
|
|
||||||
- 实现 `taskSummary` 的中途状态更新
|
|
||||||
|
|
||||||
### 为什么要拆
|
|
||||||
|
|
||||||
- 现有 registry 记录了 `pid / sessionId / name / logPath`
|
|
||||||
- 但没有可靠的 tmux attach target
|
|
||||||
- 所以 `attach` 和 `--bg` 不是简单补 handler,而是需要补启动/附着元数据设计
|
|
||||||
|
|
||||||
### 代码范围
|
|
||||||
|
|
||||||
- 修改 [src/cli/bg.ts](</e:/Source_code/Claude-code-bast/src/cli/bg.ts:1>)
|
|
||||||
- 修改 [src/utils/concurrentSessions.ts](</e:/Source_code/Claude-code-bast/src/utils/concurrentSessions.ts:1>) 以便后续 attach/--bg 扩展
|
|
||||||
- 修改 [src/utils/taskSummary.ts](</e:/Source_code/Claude-code-bast/src/utils/taskSummary.ts:1>)
|
|
||||||
- 复用:
|
|
||||||
[src/utils/sessionStorage.ts](</e:/Source_code/Claude-code-bast/src/utils/sessionStorage.ts:3870>)
|
|
||||||
[src/utils/udsClient.ts](</e:/Source_code/Claude-code-bast/src/utils/udsClient.ts:1>)
|
|
||||||
|
|
||||||
### 验证
|
|
||||||
|
|
||||||
1. `ps` 能列出 live sessions
|
|
||||||
2. `logs <sessionId|pid|name>` 能输出对应日志
|
|
||||||
3. `kill <sessionId|pid|name>` 能结束目标 session
|
|
||||||
|
|
||||||
### 风险
|
|
||||||
|
|
||||||
- `attach` / `--bg` 第二阶段需要 tmux 元数据设计
|
|
||||||
- Windows 下 tmux 路径需要明确降级策略
|
|
||||||
|
|
||||||
### 工作量判断
|
|
||||||
|
|
||||||
- `ps/logs/kill` 中等
|
|
||||||
- `attach/--bg` 明显更大,应分阶段
|
|
||||||
|
|
||||||
## 3. `TEMPLATES`
|
|
||||||
|
|
||||||
### 现状
|
|
||||||
|
|
||||||
- 命令入口只有 fast-path:
|
|
||||||
[src/entrypoints/cli.tsx](</e:/Source_code/Claude-code-bast/src/entrypoints/cli.tsx:249>)
|
|
||||||
- handler 是空的:
|
|
||||||
[src/cli/handlers/templateJobs.ts](</e:/Source_code/Claude-code-bast/src/cli/handlers/templateJobs.ts:1>)
|
|
||||||
- `markdownConfigLoader` 已把 `templates` 纳入配置目录:
|
|
||||||
[src/utils/markdownConfigLoader.ts](</e:/Source_code/Claude-code-bast/src/utils/markdownConfigLoader.ts:29>)
|
|
||||||
- `query / stopHooks` 已预留 job classifier 链路:
|
|
||||||
[src/query/stopHooks.ts](</e:/Source_code/Claude-code-bast/src/query/stopHooks.ts:103>)
|
|
||||||
- `jobs/classifier.ts` 仍是 stub:
|
|
||||||
[src/jobs/classifier.ts](</e:/Source_code/Claude-code-bast/src/jobs/classifier.ts:1>)
|
|
||||||
|
|
||||||
### 目标
|
|
||||||
|
|
||||||
- 把 `new / list / reply` 做成可用的模板任务系统。
|
|
||||||
- 第一阶段不碰复杂的自动分类与自动执行。
|
|
||||||
|
|
||||||
### MVP 方案
|
|
||||||
|
|
||||||
- 模板来源:
|
|
||||||
`.claude/templates/*.md`
|
|
||||||
- 模板格式:
|
|
||||||
复用现有 markdown + frontmatter 解析,不另外设计 DSL
|
|
||||||
- `list`
|
|
||||||
- 列出所有模板
|
|
||||||
- 显示模板名、description、路径
|
|
||||||
- `new <template> [args...]`
|
|
||||||
- 解析模板
|
|
||||||
- 在 `~/.claude/jobs/<job-id>/` 下创建 job 目录
|
|
||||||
- 写入 `template.md`、`input.txt`、`state.json`
|
|
||||||
- 返回 job id 与目录
|
|
||||||
- `reply <job-id> <text>`
|
|
||||||
- 将回复写入 `replies.jsonl` 或 `input.txt`
|
|
||||||
- 更新 `state.json`
|
|
||||||
|
|
||||||
### Phase 2
|
|
||||||
|
|
||||||
- 恢复 [src/jobs/classifier.ts](</e:/Source_code/Claude-code-bast/src/jobs/classifier.ts:1>)
|
|
||||||
- 让带 `CLAUDE_JOB_DIR` 的 job session 在 turn 完成后自动更新 `state.json`
|
|
||||||
- 再决定是否补自动 job runner
|
|
||||||
|
|
||||||
### 为什么要拆
|
|
||||||
|
|
||||||
- 当前证据表明这是“template job commands”,不是单纯模板列表
|
|
||||||
- 但自动 job 运行链路没有足够现成实现,先做文件系统 job lifecycle 更稳
|
|
||||||
|
|
||||||
### 代码范围
|
|
||||||
|
|
||||||
- 修改 [src/cli/handlers/templateJobs.ts](</e:/Source_code/Claude-code-bast/src/cli/handlers/templateJobs.ts:1>)
|
|
||||||
- 新增 `src/jobs/state.ts`
|
|
||||||
- 新增 `src/jobs/templates.ts`
|
|
||||||
- Phase 2 再改 [src/jobs/classifier.ts](</e:/Source_code/Claude-code-bast/src/jobs/classifier.ts:1>)
|
|
||||||
|
|
||||||
### 验证
|
|
||||||
|
|
||||||
1. `list` 能列出 `.claude/templates`
|
|
||||||
2. `new` 能创建 job 目录和状态文件
|
|
||||||
3. `reply` 能更新 job 内容和状态
|
|
||||||
4. Phase 2 再验证 classifier 写状态
|
|
||||||
|
|
||||||
### 风险
|
|
||||||
|
|
||||||
- frontmatter schema 需要先定义最小字段集
|
|
||||||
- 一旦扩展到“自动运行 job”,范围会明显膨胀
|
|
||||||
|
|
||||||
### 工作量判断
|
|
||||||
|
|
||||||
- MVP 中等
|
|
||||||
- 完整 job 系统偏大
|
|
||||||
|
|
||||||
## 4. `assistant [sessionId]`
|
|
||||||
|
|
||||||
### 现状
|
|
||||||
|
|
||||||
- attach 主流程其实已经存在:
|
|
||||||
[src/main.tsx](</e:/Source_code/Claude-code-bast/src/main.tsx:4708>)
|
|
||||||
- 远端 viewer 所需基础模块已存在:
|
|
||||||
[src/remote/RemoteSessionManager.ts](</e:/Source_code/Claude-code-bast/src/remote/RemoteSessionManager.ts:1>)
|
|
||||||
[src/hooks/useAssistantHistory.ts](</e:/Source_code/Claude-code-bast/src/hooks/useAssistantHistory.ts:1>)
|
|
||||||
[src/assistant/sessionHistory.ts](</e:/Source_code/Claude-code-bast/src/assistant/sessionHistory.ts:1>)
|
|
||||||
- 真正 stub 的主要是:
|
|
||||||
[src/assistant/sessionDiscovery.ts](</e:/Source_code/Claude-code-bast/src/assistant/sessionDiscovery.ts:1>)
|
|
||||||
[src/assistant/AssistantSessionChooser.ts](</e:/Source_code/Claude-code-bast/src/assistant/AssistantSessionChooser.ts:1>)
|
|
||||||
[src/commands/assistant/assistant.ts](</e:/Source_code/Claude-code-bast/src/commands/assistant/assistant.ts:7>)
|
|
||||||
[src/assistant/index.ts](</e:/Source_code/Claude-code-bast/src/assistant/index.ts:1>)
|
|
||||||
|
|
||||||
### 目标
|
|
||||||
|
|
||||||
- 不一次性恢复整个 KAIROS 助手系统。
|
|
||||||
- 先做“明确 sessionId 的 viewer attach 可用”,再逐步补 discovery / chooser / install。
|
|
||||||
|
|
||||||
### Phase 4A:MVP
|
|
||||||
|
|
||||||
- 只支持 `claude assistant <sessionId>`
|
|
||||||
- 对 `claude assistant` 无参数模式,先返回明确提示:
|
|
||||||
- 当前版本需要显式 `sessionId`
|
|
||||||
- discovery 尚未启用
|
|
||||||
- 这样可以直接复用现有 attach 分支,不必先恢复 chooser/install wizard
|
|
||||||
|
|
||||||
### Phase 4B
|
|
||||||
|
|
||||||
- 恢复 `discoverAssistantSessions()`
|
|
||||||
- 数据来源优先复用现有 sessions / bridge / teleport API,而不是新协议
|
|
||||||
- 让 `claude assistant` 无参数时能拿到候选 session 列表
|
|
||||||
|
|
||||||
### Phase 4C
|
|
||||||
|
|
||||||
- 恢复 `AssistantSessionChooser`
|
|
||||||
- 多 session 时可交互选择
|
|
||||||
|
|
||||||
### Phase 4D
|
|
||||||
|
|
||||||
- 最后考虑 install wizard 辅助函数
|
|
||||||
- 这部分属于“没有 session 时如何引导”,不是 attach 核心路径
|
|
||||||
|
|
||||||
### 为什么要拆
|
|
||||||
|
|
||||||
- attach 渲染层与远端消息通道大部分已经在
|
|
||||||
- 真正缺的是“如何发现目标 session”和“如何交互选择”
|
|
||||||
- 如果把 `src/assistant/index.ts` 的整套 KAIROS 正常模式也一起拉进来,范围会失控
|
|
||||||
|
|
||||||
### 代码范围
|
|
||||||
|
|
||||||
- Phase 4A:
|
|
||||||
- [src/main.tsx](</e:/Source_code/Claude-code-bast/src/main.tsx:4708>)
|
|
||||||
- [src/commands/assistant/index.ts](</e:/Source_code/Claude-code-bast/src/commands/assistant/index.ts:1>)
|
|
||||||
- Phase 4B:
|
|
||||||
- [src/assistant/sessionDiscovery.ts](</e:/Source_code/Claude-code-bast/src/assistant/sessionDiscovery.ts:1>)
|
|
||||||
- Phase 4C:
|
|
||||||
- [src/assistant/AssistantSessionChooser.ts](</e:/Source_code/Claude-code-bast/src/assistant/AssistantSessionChooser.ts:1>)
|
|
||||||
- Phase 4D:
|
|
||||||
- [src/commands/assistant/assistant.ts](</e:/Source_code/Claude-code-bast/src/commands/assistant/assistant.ts:7>)
|
|
||||||
|
|
||||||
### 验证
|
|
||||||
|
|
||||||
1. `claude assistant <sessionId>` 能进入 remote viewer
|
|
||||||
2. 历史懒加载工作正常
|
|
||||||
3. 无参数模式先给出明确提示
|
|
||||||
4. 后续阶段再分别验证 discovery / chooser / install
|
|
||||||
|
|
||||||
### 风险
|
|
||||||
|
|
||||||
- 这是四项里范围最大的
|
|
||||||
- 一旦把 KAIROS 正常模式整体拉入,会从“viewer attach”膨胀成“完整 assistant mode 恢复”
|
|
||||||
|
|
||||||
### 工作量判断
|
|
||||||
|
|
||||||
- Phase 4A 中等
|
|
||||||
- 4A-4D 全做完很大
|
|
||||||
|
|
||||||
## 建议执行顺序
|
|
||||||
|
|
||||||
1. `claude daemon status` / `claude daemon stop`
|
|
||||||
2. `BG_SESSIONS` 先做 `ps/logs/kill`
|
|
||||||
3. `TEMPLATES` 先做 job 文件系统 MVP
|
|
||||||
4. `assistant [sessionId]` 先做显式 sessionId attach,再补 discovery/chooser/install
|
|
||||||
|
|
||||||
## 简短结论
|
|
||||||
|
|
||||||
这四项里,最适合立刻实现的是 `daemon status/stop`。`BG_SESSIONS` 和 `TEMPLATES` 适合按 MVP 先补 handler 与文件系统闭环。`assistant [sessionId]` 不能整块硬上,应该按“attach → discovery → chooser → install”拆开恢复。
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
# Task 001: daemon status / stop
|
|
||||||
|
|
||||||
> 来源: [stub-recovery-design-1-4.md](../features/stub-recovery-design-1-4.md) 第 1 项
|
|
||||||
> 优先级: P0 (首选实现项)
|
|
||||||
> 工作量: 小
|
|
||||||
> 状态: DONE
|
|
||||||
|
|
||||||
## 目标
|
|
||||||
|
|
||||||
让 `claude daemon status` 和 `claude daemon stop` 在任意 CLI 进程中都能正确工作,不依赖 TUI 内存态。
|
|
||||||
|
|
||||||
## 背景
|
|
||||||
|
|
||||||
- `start` 路径已有完整 supervisor + worker 生命周期 (`src/daemon/main.ts`, `src/daemon/workerRegistry.ts`)
|
|
||||||
- `status` / `stop` 目前只是占位输出 (`src/daemon/main.ts:49`)
|
|
||||||
- `/remote-control-server` 有自己的命令内 UI 状态,但只维护当前进程内的 `daemonProcess`,不适合跨进程管理
|
|
||||||
|
|
||||||
## 实现方案
|
|
||||||
|
|
||||||
### 新增文件
|
|
||||||
|
|
||||||
| 文件 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `src/daemon/state.ts` | daemon 状态文件读写模块 |
|
|
||||||
|
|
||||||
### 修改文件
|
|
||||||
|
|
||||||
| 文件 | 改动 |
|
|
||||||
|------|------|
|
|
||||||
| `src/daemon/main.ts` | `start` 写入状态文件;`status`/`stop` 调用 state 模块 |
|
|
||||||
| `src/commands/remoteControlServer/remoteControlServer.tsx` | 读取同一份状态文件(轻量改动) |
|
|
||||||
|
|
||||||
### 状态文件
|
|
||||||
|
|
||||||
路径: `~/.claude/daemon/remote-control.json`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"pid": 12345,
|
|
||||||
"cwd": "/path/to/project",
|
|
||||||
"startedAt": "2026-04-12T10:00:00Z",
|
|
||||||
"workerKinds": ["bridge", "rcs"],
|
|
||||||
"lastStatus": "running"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### status 逻辑
|
|
||||||
|
|
||||||
1. 读取状态文件
|
|
||||||
2. 用进程探测验证 pid 是否存活
|
|
||||||
3. 输出 `running` / `stopped` / `stale`
|
|
||||||
4. stale 时自动清理状态文件
|
|
||||||
|
|
||||||
### stop 逻辑
|
|
||||||
|
|
||||||
1. 读取 pid
|
|
||||||
2. 发送 `SIGTERM`
|
|
||||||
3. 等待退出(超时兜底)
|
|
||||||
4. 超时后 `SIGKILL`
|
|
||||||
5. 清理状态文件
|
|
||||||
|
|
||||||
## 验证步骤
|
|
||||||
|
|
||||||
- [ ] `claude daemon start` 正常启动并写入状态文件
|
|
||||||
- [ ] 新开终端执行 `claude daemon status`,显示 `running`
|
|
||||||
- [ ] 执行 `claude daemon stop`,daemon 正常退出
|
|
||||||
- [ ] 再次执行 `claude daemon status`,返回 `stopped` 或 `stale cleaned`
|
|
||||||
- [ ] Windows 下 stop 超时兜底正常工作
|
|
||||||
|
|
||||||
## 风险
|
|
||||||
|
|
||||||
- Windows 信号模型和 Unix 不同,`stop` 需要超时兜底
|
|
||||||
- 当前设计默认单 supervisor,不处理多实例并发
|
|
||||||
|
|
||||||
## 依赖
|
|
||||||
|
|
||||||
无外部依赖,可独立实施。
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
# Task 002: BG_SESSIONS — ps / logs / kill
|
|
||||||
|
|
||||||
> 来源: [stub-recovery-design-1-4.md](../features/stub-recovery-design-1-4.md) 第 2 项
|
|
||||||
> 优先级: P1
|
|
||||||
> 工作量: 中等
|
|
||||||
> 状态: DONE
|
|
||||||
> 阶段: Phase 2A (MVP)
|
|
||||||
|
|
||||||
## 目标
|
|
||||||
|
|
||||||
把 `ps` / `logs` / `kill` 做成真正有用的 session 管理命令。不在第一阶段补完 `attach` / `--bg`。
|
|
||||||
|
|
||||||
## 背景
|
|
||||||
|
|
||||||
- fast-path 已接好 (`src/entrypoints/cli.tsx:218`)
|
|
||||||
- session registry 已有真实实现 (`src/utils/concurrentSessions.ts`)
|
|
||||||
- `exit` 在 bg session 内已会 `tmux detach-client` (`src/commands/exit/exit.tsx:20`)
|
|
||||||
- CLI handler 仍全空 (`src/cli/bg.ts`)
|
|
||||||
- task summary 仍然是 stub (`src/utils/taskSummary.ts`)
|
|
||||||
|
|
||||||
## 实现方案
|
|
||||||
|
|
||||||
### 修改文件
|
|
||||||
|
|
||||||
| 文件 | 改动 |
|
|
||||||
|------|------|
|
|
||||||
| `src/cli/bg.ts` | 实现 `ps` / `logs` / `kill` handler |
|
|
||||||
| `src/utils/concurrentSessions.ts` | 扩展以便后续 attach/--bg 使用 |
|
|
||||||
| `src/utils/taskSummary.ts` | 补充基础实现 |
|
|
||||||
|
|
||||||
### 复用模块
|
|
||||||
|
|
||||||
- `src/utils/sessionStorage.ts` — session 存储
|
|
||||||
- `src/utils/udsClient.ts` — UDS 通信
|
|
||||||
|
|
||||||
### ps 命令
|
|
||||||
|
|
||||||
- 从 registry 读取 live sessions
|
|
||||||
- 展示: pid, kind, sessionId, cwd, name, startedAt, bridgeSessionId
|
|
||||||
- 如果有 activity/status,一并展示
|
|
||||||
|
|
||||||
### logs 命令
|
|
||||||
|
|
||||||
- 支持按 `sessionId` / `pid` / `name` 查找
|
|
||||||
- 优先复用本地 transcript/log 读取能力
|
|
||||||
- 如果 registry 里存在 `logPath`,支持 tail 文件
|
|
||||||
|
|
||||||
### kill 命令
|
|
||||||
|
|
||||||
- 解析目标 session
|
|
||||||
- 发退出信号
|
|
||||||
- 清理 stale registry
|
|
||||||
|
|
||||||
## 验证步骤
|
|
||||||
|
|
||||||
- [ ] `ps` 能列出当前 live sessions
|
|
||||||
- [ ] `logs <sessionId|pid|name>` 能输出对应日志
|
|
||||||
- [ ] `kill <sessionId|pid|name>` 能结束目标 session 并清理 registry
|
|
||||||
- [ ] 无 live session 时各命令有明确提示
|
|
||||||
|
|
||||||
## Phase 2B (后续)
|
|
||||||
|
|
||||||
- [ ] 实现 `attach`
|
|
||||||
- [ ] 实现 `--bg`
|
|
||||||
- [ ] 实现 `taskSummary` 的中途状态更新
|
|
||||||
|
|
||||||
### 为什么拆分
|
|
||||||
|
|
||||||
- 现有 registry 记录了 `pid / sessionId / name / logPath`
|
|
||||||
- 但没有可靠的 tmux attach target
|
|
||||||
- `attach` 和 `--bg` 需要补启动/附着元数据设计,不是简单补 handler
|
|
||||||
|
|
||||||
## 风险
|
|
||||||
|
|
||||||
- `attach` / `--bg` 第二阶段需要 tmux 元数据设计
|
|
||||||
- Windows 下 tmux 路径需要明确降级策略
|
|
||||||
|
|
||||||
## 依赖
|
|
||||||
|
|
||||||
- Task 001 (daemon 状态管理可复用模式,但非硬性依赖)
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
# Task 003: TEMPLATES — job 文件系统 MVP
|
|
||||||
|
|
||||||
> 来源: [stub-recovery-design-1-4.md](../features/stub-recovery-design-1-4.md) 第 3 项
|
|
||||||
> 优先级: P2
|
|
||||||
> 工作量: 中等
|
|
||||||
> 状态: DONE
|
|
||||||
> 阶段: MVP
|
|
||||||
|
|
||||||
## 目标
|
|
||||||
|
|
||||||
把 `new` / `list` / `reply` 做成可用的模板任务系统。第一阶段不碰复杂的自动分类与自动执行。
|
|
||||||
|
|
||||||
## 背景
|
|
||||||
|
|
||||||
- 命令入口只有 fast-path (`src/entrypoints/cli.tsx:249`)
|
|
||||||
- handler 是空的 (`src/cli/handlers/templateJobs.ts`)
|
|
||||||
- `markdownConfigLoader` 已把 `templates` 纳入配置目录 (`src/utils/markdownConfigLoader.ts:29`)
|
|
||||||
- `query/stopHooks` 已预留 job classifier 链路 (`src/query/stopHooks.ts:103`)
|
|
||||||
- `jobs/classifier.ts` 仍是 stub (`src/jobs/classifier.ts`)
|
|
||||||
|
|
||||||
## 实现方案
|
|
||||||
|
|
||||||
### 新增文件
|
|
||||||
|
|
||||||
| 文件 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `src/jobs/state.ts` | job 状态管理 |
|
|
||||||
| `src/jobs/templates.ts` | 模板解析与列表 |
|
|
||||||
|
|
||||||
### 修改文件
|
|
||||||
|
|
||||||
| 文件 | 改动 |
|
|
||||||
|------|------|
|
|
||||||
| `src/cli/handlers/templateJobs.ts` | 实现 `new` / `list` / `reply` handler |
|
|
||||||
|
|
||||||
### 模板来源
|
|
||||||
|
|
||||||
`.claude/templates/*.md`
|
|
||||||
|
|
||||||
### 模板格式
|
|
||||||
|
|
||||||
复用现有 markdown + frontmatter 解析,不另外设计 DSL。
|
|
||||||
|
|
||||||
### list 命令
|
|
||||||
|
|
||||||
- 列出所有模板
|
|
||||||
- 显示: 模板名, description, 路径
|
|
||||||
|
|
||||||
### new 命令
|
|
||||||
|
|
||||||
- 解析模板
|
|
||||||
- 在 `~/.claude/jobs/<job-id>/` 下创建 job 目录
|
|
||||||
- 写入 `template.md`, `input.txt`, `state.json`
|
|
||||||
- 返回 job id 与目录路径
|
|
||||||
|
|
||||||
### reply 命令
|
|
||||||
|
|
||||||
- 将回复写入 `replies.jsonl` 或 `input.txt`
|
|
||||||
- 更新 `state.json`
|
|
||||||
|
|
||||||
## 验证步骤
|
|
||||||
|
|
||||||
- [ ] `list` 能列出 `.claude/templates` 下的所有模板
|
|
||||||
- [ ] `new <template> [args...]` 能创建 job 目录和状态文件
|
|
||||||
- [ ] `reply <job-id> <text>` 能更新 job 内容和状态
|
|
||||||
- [ ] frontmatter schema 最小字段集已定义
|
|
||||||
|
|
||||||
## Phase 2 (后续)
|
|
||||||
|
|
||||||
- [ ] 恢复 `src/jobs/classifier.ts`
|
|
||||||
- [ ] 让带 `CLAUDE_JOB_DIR` 的 job session 在 turn 完成后自动更新 `state.json`
|
|
||||||
- [ ] 再决定是否补自动 job runner
|
|
||||||
|
|
||||||
### 为什么拆分
|
|
||||||
|
|
||||||
- 当前是 "template job commands",不是单纯模板列表
|
|
||||||
- 自动 job 运行链路没有足够现成实现
|
|
||||||
- 先做文件系统 job lifecycle 更稳
|
|
||||||
|
|
||||||
## 风险
|
|
||||||
|
|
||||||
- frontmatter schema 需要先定义最小字段集
|
|
||||||
- 一旦扩展到"自动运行 job",范围会明显膨胀
|
|
||||||
|
|
||||||
## 依赖
|
|
||||||
|
|
||||||
无硬性依赖,可独立实施。
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
# Task 004: assistant [sessionId] — 分阶段恢复
|
|
||||||
|
|
||||||
> 来源: [stub-recovery-design-1-4.md](../features/stub-recovery-design-1-4.md) 第 4 项
|
|
||||||
> 优先级: P3
|
|
||||||
> 工作量: Phase 4A 中等,4A-4D 全做完很大
|
|
||||||
> 状态: Phase 4A DONE, 4B-4D TODO
|
|
||||||
|
|
||||||
## 目标
|
|
||||||
|
|
||||||
不一次性恢复整个 KAIROS 助手系统。先做"明确 sessionId 的 viewer attach 可用",再逐步补 discovery / chooser / install。
|
|
||||||
|
|
||||||
## 背景
|
|
||||||
|
|
||||||
- attach 主流程已存在 (`src/main.tsx:4708`)
|
|
||||||
- 远端 viewer 所需基础模块已存在:
|
|
||||||
- `src/remote/RemoteSessionManager.ts`
|
|
||||||
- `src/hooks/useAssistantHistory.ts`
|
|
||||||
- `src/assistant/sessionHistory.ts`
|
|
||||||
- 真正 stub 的主要是:
|
|
||||||
- `src/assistant/sessionDiscovery.ts`
|
|
||||||
- `src/assistant/AssistantSessionChooser.ts`
|
|
||||||
- `src/commands/assistant/assistant.ts:7`
|
|
||||||
- `src/assistant/index.ts`
|
|
||||||
|
|
||||||
## 分阶段实现
|
|
||||||
|
|
||||||
### Phase 4A: MVP — 显式 sessionId attach
|
|
||||||
|
|
||||||
**修改文件:**
|
|
||||||
|
|
||||||
| 文件 | 改动 |
|
|
||||||
|------|------|
|
|
||||||
| `src/main.tsx` | 确保 attach 分支可用 |
|
|
||||||
| `src/commands/assistant/index.ts` | 实现显式 sessionId 参数入口 |
|
|
||||||
|
|
||||||
**行为:**
|
|
||||||
- `claude assistant <sessionId>` — 进入 remote viewer
|
|
||||||
- `claude assistant` (无参数) — 返回明确提示: 当前版本需要显式 sessionId,discovery 尚未启用
|
|
||||||
|
|
||||||
**验证:**
|
|
||||||
- [ ] `claude assistant <sessionId>` 能进入 remote viewer
|
|
||||||
- [ ] 历史懒加载工作正常
|
|
||||||
- [ ] 无参数模式给出明确提示
|
|
||||||
|
|
||||||
### Phase 4B: session discovery
|
|
||||||
|
|
||||||
**修改文件:**
|
|
||||||
|
|
||||||
| 文件 | 改动 |
|
|
||||||
|------|------|
|
|
||||||
| `src/assistant/sessionDiscovery.ts` | 恢复 `discoverAssistantSessions()` |
|
|
||||||
|
|
||||||
**行为:**
|
|
||||||
- 数据来源优先复用现有 sessions / bridge / teleport API,不新增协议
|
|
||||||
- `claude assistant` 无参数时能拿到候选 session 列表
|
|
||||||
|
|
||||||
**验证:**
|
|
||||||
- [ ] 无参数调用能列出可用 sessions
|
|
||||||
- [ ] 数据来源复用现有通道
|
|
||||||
|
|
||||||
### Phase 4C: session chooser
|
|
||||||
|
|
||||||
**修改文件:**
|
|
||||||
|
|
||||||
| 文件 | 改动 |
|
|
||||||
|------|------|
|
|
||||||
| `src/assistant/AssistantSessionChooser.ts` | 恢复交互式选择器 |
|
|
||||||
|
|
||||||
**行为:**
|
|
||||||
- 多 session 时可交互选择
|
|
||||||
|
|
||||||
**验证:**
|
|
||||||
- [ ] 多个 session 时弹出选择器
|
|
||||||
- [ ] 选择后正确 attach
|
|
||||||
|
|
||||||
### Phase 4D: install wizard
|
|
||||||
|
|
||||||
**修改文件:**
|
|
||||||
|
|
||||||
| 文件 | 改动 |
|
|
||||||
|------|------|
|
|
||||||
| `src/commands/assistant/assistant.ts` | 恢复 install wizard 辅助函数 |
|
|
||||||
|
|
||||||
**行为:**
|
|
||||||
- 没有 session 时如何引导用户
|
|
||||||
|
|
||||||
**验证:**
|
|
||||||
- [ ] 无可用 session 时引导用户创建/连接
|
|
||||||
|
|
||||||
## 为什么拆分
|
|
||||||
|
|
||||||
- attach 渲染层与远端消息通道大部分已在
|
|
||||||
- 真正缺的是"如何发现目标 session"和"如何交互选择"
|
|
||||||
- 如果把 `src/assistant/index.ts` 的整套 KAIROS 正常模式也一起拉进来,范围会失控
|
|
||||||
|
|
||||||
## 风险
|
|
||||||
|
|
||||||
- 这是四项里范围最大的
|
|
||||||
- 一旦把 KAIROS 正常模式整体拉入,会从"viewer attach"膨胀成"完整 assistant mode 恢复"
|
|
||||||
|
|
||||||
## 依赖
|
|
||||||
|
|
||||||
- Task 002 的 session registry 模式可复用
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
# Task 013: BgEngine 跨平台后台引擎抽象
|
|
||||||
|
|
||||||
> 设计文档: [daemon-restructure-design.md](../features/daemon-restructure-design.md) § 四
|
|
||||||
> 依赖: 无
|
|
||||||
> 分支: `feat/integrate-5-branches`
|
|
||||||
|
|
||||||
## 目标
|
|
||||||
|
|
||||||
将 `src/cli/bg.ts` 中硬编码的 tmux 逻辑提取为引擎抽象层,实现 TmuxEngine + DetachedEngine,使后台会话功能在 Windows / macOS / Linux 上都能工作。
|
|
||||||
|
|
||||||
## 背景
|
|
||||||
|
|
||||||
当前 `bg.ts` 中 `handleBgFlag()` 和 `attachHandler()` 直接调用 tmux 命令。Windows 上 `--bg` 直接报错退出。需要一个引擎抽象层,根据平台和可用工具自动选择最佳方案。
|
|
||||||
|
|
||||||
## 文件清单
|
|
||||||
|
|
||||||
### 新增
|
|
||||||
|
|
||||||
| 文件 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `src/cli/bg/engine.ts` | BgEngine 接口 + BgStartOptions/BgStartResult 类型 |
|
|
||||||
| `src/cli/bg/engines/tmux.ts` | TmuxEngine: 从 `bg.ts` 提取 tmux 相关逻辑 |
|
|
||||||
| `src/cli/bg/engines/detached.ts` | DetachedEngine: spawn({ detached }) + logFile 重定向 |
|
|
||||||
| `src/cli/bg/engines/index.ts` | selectEngine() 自动选择 + re-export |
|
|
||||||
| `src/cli/bg/tail.ts` | 跨平台日志 tail: fs.watch + 轮询 fallback |
|
|
||||||
|
|
||||||
### 修改
|
|
||||||
|
|
||||||
| 文件 | 变更 |
|
|
||||||
|------|------|
|
|
||||||
| `src/cli/bg.ts` | `handleBgFlag()` 改为调用 `selectEngine().start()`;`attachHandler()` 改为调用 `engine.attach()` |
|
|
||||||
|
|
||||||
## 实现方案
|
|
||||||
|
|
||||||
### 1. BgEngine 接口 (`src/cli/bg/engine.ts`)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export interface BgEngine {
|
|
||||||
readonly name: string
|
|
||||||
available(): Promise<boolean>
|
|
||||||
start(opts: BgStartOptions): Promise<BgStartResult>
|
|
||||||
attach(session: SessionEntry): Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BgStartOptions {
|
|
||||||
sessionName: string
|
|
||||||
args: string[] // CLI args (去除 --bg)
|
|
||||||
env: Record<string, string | undefined>
|
|
||||||
logPath: string
|
|
||||||
cwd: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BgStartResult {
|
|
||||||
pid: number
|
|
||||||
sessionName: string
|
|
||||||
logPath: string
|
|
||||||
engineUsed: 'tmux' | 'detached'
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. TmuxEngine (`src/cli/bg/engines/tmux.ts`)
|
|
||||||
|
|
||||||
从 `bg.ts:handleBgFlag()` 和 `bg.ts:attachHandler()` 提取:
|
|
||||||
- `available()`: `execFileNoThrow('tmux', ['-V'])` 返回 code === 0
|
|
||||||
- `start()`: `tmux new-session -d -s <name> <cmd>`
|
|
||||||
- `attach()`: `tmux attach-session -t <session.tmuxSessionName>`
|
|
||||||
|
|
||||||
### 3. DetachedEngine (`src/cli/bg/engines/detached.ts`)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export class DetachedEngine implements BgEngine {
|
|
||||||
readonly name = 'detached'
|
|
||||||
|
|
||||||
async available(): Promise<boolean> {
|
|
||||||
return true // 总是可用
|
|
||||||
}
|
|
||||||
|
|
||||||
async start(opts: BgStartOptions): Promise<BgStartResult> {
|
|
||||||
const logFd = openSync(opts.logPath, 'a')
|
|
||||||
const child = spawn(process.execPath, [process.argv[1]!, ...opts.args], {
|
|
||||||
detached: true,
|
|
||||||
stdio: ['ignore', logFd, logFd],
|
|
||||||
env: opts.env,
|
|
||||||
cwd: opts.cwd,
|
|
||||||
})
|
|
||||||
child.unref()
|
|
||||||
closeSync(logFd)
|
|
||||||
|
|
||||||
return {
|
|
||||||
pid: child.pid!,
|
|
||||||
sessionName: opts.sessionName,
|
|
||||||
logPath: opts.logPath,
|
|
||||||
engineUsed: 'detached',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async attach(session: SessionEntry): Promise<void> {
|
|
||||||
// 委托给 tail.ts
|
|
||||||
await tailLog(session.logPath!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 日志 Tail (`src/cli/bg/tail.ts`)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
/**
|
|
||||||
* 跨平台实时日志输出。Ctrl+C 退出,不杀后台进程。
|
|
||||||
*
|
|
||||||
* 策略:
|
|
||||||
* 1. 读取已有内容输出
|
|
||||||
* 2. fs.watch() 监听文件变化 (主方案)
|
|
||||||
* 3. 如果 fs.watch 不可靠 (某些 Windows 网络驱动器),fallback 到 500ms 轮询
|
|
||||||
*/
|
|
||||||
export async function tailLog(logPath: string): Promise<void>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. 引擎选择 (`src/cli/bg/engines/index.ts`)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export async function selectEngine(): Promise<BgEngine> {
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
return new DetachedEngine()
|
|
||||||
}
|
|
||||||
const tmux = new TmuxEngine()
|
|
||||||
if (await tmux.available()) {
|
|
||||||
return tmux
|
|
||||||
}
|
|
||||||
return new DetachedEngine()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. bg.ts 重构
|
|
||||||
|
|
||||||
`handleBgFlag()` 改名为 `handleBgStart()`,内部逻辑:
|
|
||||||
```typescript
|
|
||||||
export async function handleBgStart(args: string[]): Promise<void> {
|
|
||||||
const engine = await selectEngine()
|
|
||||||
const sessionName = `claude-bg-${randomUUID().slice(0, 8)}`
|
|
||||||
const logPath = join(getClaudeConfigHomeDir(), 'sessions', 'logs', `${sessionName}.log`)
|
|
||||||
|
|
||||||
const result = await engine.start({
|
|
||||||
sessionName,
|
|
||||||
args: filteredArgs,
|
|
||||||
env: { ...process.env, CLAUDE_CODE_SESSION_KIND: 'bg', ... },
|
|
||||||
logPath,
|
|
||||||
cwd: process.cwd(),
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log(`Background session started: ${result.sessionName}`)
|
|
||||||
console.log(` Engine: ${result.engineUsed}`)
|
|
||||||
console.log(` Log: ${result.logPath}`)
|
|
||||||
console.log(` Use \`claude daemon attach ${result.sessionName}\` to reconnect.`)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`attachHandler()` 根据 `session.engine` 字段选择引擎:
|
|
||||||
```typescript
|
|
||||||
export async function attachHandler(target: string | undefined): Promise<void> {
|
|
||||||
// ... 找到 session
|
|
||||||
if (session.engine === 'tmux' && session.tmuxSessionName) {
|
|
||||||
const tmux = new TmuxEngine()
|
|
||||||
await tmux.attach(session)
|
|
||||||
} else {
|
|
||||||
const detached = new DetachedEngine()
|
|
||||||
await detached.attach(session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## SessionEntry 扩展
|
|
||||||
|
|
||||||
`sessions/<PID>.json` 新增 `engine` 字段:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"pid": 12345,
|
|
||||||
"engine": "detached",
|
|
||||||
"logPath": "~/.claude/sessions/logs/claude-bg-a1b2c3d4.log",
|
|
||||||
"sessionId": "...",
|
|
||||||
"cwd": "..."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
兼容旧格式: 如果 `engine` 字段缺失,检查 `tmuxSessionName` 存在则为 `tmux`,否则为 `detached`。
|
|
||||||
|
|
||||||
## 验证清单
|
|
||||||
|
|
||||||
- [ ] Windows: `claude daemon bg` 启动后台会话,无 tmux 依赖
|
|
||||||
- [ ] Windows: `claude daemon attach <name>` 以 tail 模式附着,Ctrl+C 退出不杀进程
|
|
||||||
- [ ] macOS/Linux (有 tmux): 行为与当前一致
|
|
||||||
- [ ] macOS/Linux (无 tmux): 自动 fallback 到 detached 引擎
|
|
||||||
- [ ] `claude daemon status` 正确显示 engine 类型
|
|
||||||
- [ ] 旧格式 session JSON (无 engine 字段) 兼容
|
|
||||||
- [ ] tsc --noEmit 零错误
|
|
||||||
- [ ] bun test 通过
|
|
||||||
@@ -1,275 +0,0 @@
|
|||||||
# Task 014: /daemon 命令层级化
|
|
||||||
|
|
||||||
> 设计文档: [daemon-restructure-design.md](../features/daemon-restructure-design.md) § 三.1
|
|
||||||
> 依赖: Task 013 (BgEngine 抽象)
|
|
||||||
> 分支: `feat/integrate-5-branches`
|
|
||||||
|
|
||||||
## 目标
|
|
||||||
|
|
||||||
将散落的 `daemon start/stop/status` + `ps/logs/attach/kill` + `--bg` 统一收归 `/daemon` 命名空间,实现 CLI + REPL 双注册。
|
|
||||||
|
|
||||||
## 背景
|
|
||||||
|
|
||||||
当前这些命令注册在两个互不关联的位置:
|
|
||||||
- `cli.tsx:203-212`: `daemon [start|status|stop]` → `daemon/main.ts`
|
|
||||||
- `cli.tsx:217-246`: `ps|logs|attach|kill|--bg` → `cli/bg.ts`
|
|
||||||
|
|
||||||
需要合并为统一的 `claude daemon <subcommand>` 入口,并新增 REPL `/daemon` 斜杠命令。
|
|
||||||
|
|
||||||
## 文件清单
|
|
||||||
|
|
||||||
### 新增
|
|
||||||
|
|
||||||
| 文件 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `src/commands/daemon/index.ts` | `/daemon` REPL 斜杠命令注册 (type: local-jsx) |
|
|
||||||
| `src/commands/daemon/daemon.tsx` | `/daemon` 子命令路由 + status UI 组件 |
|
|
||||||
|
|
||||||
### 修改
|
|
||||||
|
|
||||||
| 文件 | 变更 |
|
|
||||||
|------|------|
|
|
||||||
| `src/entrypoints/cli.tsx` | 统一 daemon 快速路径: `daemon <sub>` 路由到对应 handler。旧命令 `ps/logs/attach/kill` 保留但输出 deprecation 警告后代理 |
|
|
||||||
| `src/commands.ts` | 注册 `/daemon` 斜杠命令 (feature-gated: DAEMON \|\| BG_SESSIONS) |
|
|
||||||
| `src/daemon/main.ts` | `daemonMain()` 扩展: 支持 `bg/attach/logs/kill/ps` 子命令 (委托给 bg.ts handlers) |
|
|
||||||
|
|
||||||
## 实现方案
|
|
||||||
|
|
||||||
### 1. CLI 快速路径统一 (`cli.tsx`)
|
|
||||||
|
|
||||||
**改前** (两段独立路由):
|
|
||||||
```typescript
|
|
||||||
// 段 1: daemon
|
|
||||||
if (feature('DAEMON') && args[0] === 'daemon') {
|
|
||||||
await daemonMain(args.slice(1))
|
|
||||||
}
|
|
||||||
// 段 2: bg sessions
|
|
||||||
if (feature('BG_SESSIONS') && ['ps','logs','attach','kill'].includes(args[0])) {
|
|
||||||
// ...switch/case
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**改后** (统一入口):
|
|
||||||
```typescript
|
|
||||||
// 统一 daemon 入口 — 合并 daemon supervisor + bg sessions
|
|
||||||
if (
|
|
||||||
(feature('DAEMON') || feature('BG_SESSIONS')) &&
|
|
||||||
args[0] === 'daemon'
|
|
||||||
) {
|
|
||||||
profileCheckpoint('cli_daemon_path')
|
|
||||||
const { enableConfigs } = await import('../utils/config.js')
|
|
||||||
enableConfigs()
|
|
||||||
const { initSinks } = await import('../utils/sinks.js')
|
|
||||||
initSinks()
|
|
||||||
const { daemonMain } = await import('../daemon/main.js')
|
|
||||||
await daemonMain(args.slice(1))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// --bg 快捷方式 → daemon bg
|
|
||||||
if (
|
|
||||||
feature('BG_SESSIONS') &&
|
|
||||||
(args.includes('--bg') || args.includes('--background'))
|
|
||||||
) {
|
|
||||||
profileCheckpoint('cli_daemon_path')
|
|
||||||
const { enableConfigs } = await import('../utils/config.js')
|
|
||||||
enableConfigs()
|
|
||||||
const bg = await import('../cli/bg.js')
|
|
||||||
await bg.handleBgStart(args.filter(a => a !== '--bg' && a !== '--background'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 向后兼容: ps/logs/attach/kill → daemon <sub> (deprecated)
|
|
||||||
if (
|
|
||||||
feature('BG_SESSIONS') &&
|
|
||||||
['ps', 'logs', 'attach', 'kill'].includes(args[0] ?? '')
|
|
||||||
) {
|
|
||||||
const mapped = args[0] === 'ps' ? 'status' : args[0]
|
|
||||||
console.error(`[deprecated] Use: claude daemon ${mapped} ${args.slice(1).join(' ')}`.trim())
|
|
||||||
const { enableConfigs } = await import('../utils/config.js')
|
|
||||||
enableConfigs()
|
|
||||||
const { daemonMain } = await import('../daemon/main.js')
|
|
||||||
await daemonMain([args[0]!, ...args.slice(1)])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. daemonMain 扩展 (`daemon/main.ts`)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export async function daemonMain(args: string[]): Promise<void> {
|
|
||||||
const subcommand = args[0] || 'status'
|
|
||||||
|
|
||||||
switch (subcommand) {
|
|
||||||
// --- Supervisor 管理 ---
|
|
||||||
case 'start':
|
|
||||||
await runSupervisor(args.slice(1))
|
|
||||||
break
|
|
||||||
case 'stop':
|
|
||||||
await handleDaemonStop()
|
|
||||||
break
|
|
||||||
|
|
||||||
// --- 会话管理 (委托给 bg.ts) ---
|
|
||||||
case 'status':
|
|
||||||
case 'ps':
|
|
||||||
await showUnifiedStatus() // 新: daemon 状态 + 会话列表
|
|
||||||
break
|
|
||||||
case 'bg':
|
|
||||||
const bg = await import('../cli/bg.js')
|
|
||||||
await bg.handleBgStart(args.slice(1))
|
|
||||||
break
|
|
||||||
case 'attach':
|
|
||||||
const bg2 = await import('../cli/bg.js')
|
|
||||||
await bg2.attachHandler(args[1])
|
|
||||||
break
|
|
||||||
case 'logs':
|
|
||||||
const bg3 = await import('../cli/bg.js')
|
|
||||||
await bg3.logsHandler(args[1])
|
|
||||||
break
|
|
||||||
case 'kill':
|
|
||||||
const bg4 = await import('../cli/bg.js')
|
|
||||||
await bg4.killHandler(args[1])
|
|
||||||
break
|
|
||||||
|
|
||||||
case '--help': case '-h': case 'help':
|
|
||||||
printHelp()
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
console.error(`Unknown daemon subcommand: ${subcommand}`)
|
|
||||||
printHelp()
|
|
||||||
process.exitCode = 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 统一状态面板 (`showUnifiedStatus`)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
async function showUnifiedStatus(): Promise<void> {
|
|
||||||
// 1. Daemon supervisor 状态
|
|
||||||
const daemonResult = queryDaemonStatus()
|
|
||||||
console.log('=== Daemon Supervisor ===')
|
|
||||||
switch (daemonResult.status) {
|
|
||||||
case 'running':
|
|
||||||
console.log(` Status: running (PID: ${daemonResult.state!.pid})`)
|
|
||||||
console.log(` Workers: ${daemonResult.state!.workerKinds.join(', ')}`)
|
|
||||||
break
|
|
||||||
case 'stopped':
|
|
||||||
console.log(' Status: stopped')
|
|
||||||
break
|
|
||||||
case 'stale':
|
|
||||||
console.log(' Status: stale (cleaned up)')
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 后台会话列表
|
|
||||||
console.log('\n=== Background Sessions ===')
|
|
||||||
const bg = await import('../cli/bg.js')
|
|
||||||
await bg.psHandler([])
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. REPL 斜杠命令注册
|
|
||||||
|
|
||||||
**`src/commands/daemon/index.ts`**:
|
|
||||||
```typescript
|
|
||||||
import type { Command } from '../../commands.js'
|
|
||||||
import { feature } from 'bun:bundle'
|
|
||||||
|
|
||||||
const daemon = {
|
|
||||||
type: 'local-jsx',
|
|
||||||
name: 'daemon',
|
|
||||||
description: 'Manage background sessions and daemon',
|
|
||||||
argumentHint: '[status|start|stop|bg|attach|logs|kill]',
|
|
||||||
isEnabled: () => {
|
|
||||||
if (feature('DAEMON')) return true
|
|
||||||
if (feature('BG_SESSIONS')) return true
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
load: () => import('./daemon.js'),
|
|
||||||
} satisfies Command
|
|
||||||
|
|
||||||
export default daemon
|
|
||||||
```
|
|
||||||
|
|
||||||
**`src/commands/daemon/daemon.tsx`**:
|
|
||||||
```typescript
|
|
||||||
export async function call(
|
|
||||||
onDone: LocalJSXCommandOnDone,
|
|
||||||
context: LocalJSXCommandContext,
|
|
||||||
args: string,
|
|
||||||
): Promise<React.ReactNode> {
|
|
||||||
const parts = args.trim().split(/\s+/)
|
|
||||||
const sub = parts[0] || 'status'
|
|
||||||
|
|
||||||
switch (sub) {
|
|
||||||
case 'status':
|
|
||||||
case 'ps':
|
|
||||||
// 调用 showUnifiedStatus,捕获输出
|
|
||||||
// 返回文本结果
|
|
||||||
break
|
|
||||||
case 'bg':
|
|
||||||
// REPL 中启动后台会话
|
|
||||||
break
|
|
||||||
case 'start':
|
|
||||||
case 'stop':
|
|
||||||
case 'attach':
|
|
||||||
case 'logs':
|
|
||||||
case 'kill':
|
|
||||||
// 委托给对应 handler
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
onDone(`Unknown: ${sub}. Use: status|start|stop|bg|attach|logs|kill`)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**`src/commands.ts`** 添加:
|
|
||||||
```typescript
|
|
||||||
// 条件导入
|
|
||||||
const daemonCmd =
|
|
||||||
feature('DAEMON') || feature('BG_SESSIONS')
|
|
||||||
? require('./commands/daemon/index.js').default
|
|
||||||
: null
|
|
||||||
|
|
||||||
// COMMANDS 数组中添加
|
|
||||||
...(daemonCmd ? [daemonCmd] : []),
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. 更新 help 文本 (`daemon/main.ts`)
|
|
||||||
|
|
||||||
```
|
|
||||||
Claude Code Daemon — background process management
|
|
||||||
|
|
||||||
USAGE
|
|
||||||
claude daemon [subcommand]
|
|
||||||
|
|
||||||
SUBCOMMANDS
|
|
||||||
status Show daemon and session status (default)
|
|
||||||
start Start the daemon supervisor
|
|
||||||
stop Stop the daemon
|
|
||||||
bg Start a background session
|
|
||||||
attach Attach to a background session
|
|
||||||
logs Show session logs
|
|
||||||
kill Kill a session
|
|
||||||
help Show this help
|
|
||||||
|
|
||||||
REPL
|
|
||||||
/daemon [subcommand] Same commands available in interactive mode
|
|
||||||
```
|
|
||||||
|
|
||||||
## 验证清单
|
|
||||||
|
|
||||||
- [ ] `claude daemon` (无参数) 显示统一状态面板
|
|
||||||
- [ ] `claude daemon status` 显示 supervisor + 会话列表
|
|
||||||
- [ ] `claude daemon start/stop` 与当前行为一致
|
|
||||||
- [ ] `claude daemon bg` 启动后台会话 (调用 BgEngine)
|
|
||||||
- [ ] `claude daemon attach/logs/kill <target>` 功能正常
|
|
||||||
- [ ] `claude ps` 输出 deprecation 警告 + 正常工作
|
|
||||||
- [ ] `claude logs/attach/kill` 同上
|
|
||||||
- [ ] `claude --bg` 快捷方式正常
|
|
||||||
- [ ] REPL 中 `/daemon` 可用,tab 补全显示
|
|
||||||
- [ ] REPL 中 `/daemon status` 显示状态信息
|
|
||||||
- [ ] tsc --noEmit 零错误
|
|
||||||
- [ ] bun test 通过
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
# Task 015: /job 命令层级化
|
|
||||||
|
|
||||||
> 设计文档: [daemon-restructure-design.md](../features/daemon-restructure-design.md) § 三.2
|
|
||||||
> 依赖: 无 (可与 Task 013 并行)
|
|
||||||
> 分支: `feat/integrate-5-branches`
|
|
||||||
|
|
||||||
## 目标
|
|
||||||
|
|
||||||
将 `claude new/list/reply` 收归 `/job` 命名空间,实现 CLI + REPL 双注册。
|
|
||||||
|
|
||||||
## 背景
|
|
||||||
|
|
||||||
当前 `new`, `list`, `reply` 是顶级 CLI 命令 (`cli.tsx:250-261`),容易与其他命令冲突(特别是 `list` 这种通用词)。需要收归 `claude job <subcommand>` 并新增 REPL `/job` 入口。
|
|
||||||
|
|
||||||
## 文件清单
|
|
||||||
|
|
||||||
### 新增
|
|
||||||
|
|
||||||
| 文件 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `src/commands/job/index.ts` | `/job` REPL 斜杠命令注册 |
|
|
||||||
| `src/commands/job/job.tsx` | `/job` 子命令路由 |
|
|
||||||
|
|
||||||
### 修改
|
|
||||||
|
|
||||||
| 文件 | 变更 |
|
|
||||||
|------|------|
|
|
||||||
| `src/entrypoints/cli.tsx` | 新增 `job` 快速路径 + 旧 `new/list/reply` deprecation 代理 |
|
|
||||||
| `src/commands.ts` | 注册 `/job` 斜杠命令 |
|
|
||||||
|
|
||||||
### 不动
|
|
||||||
|
|
||||||
| 文件 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `src/cli/handlers/templateJobs.ts` | 内部 handler 不变,只是被调用方式变了 |
|
|
||||||
| `src/jobs/state.ts` | job 状态管理不变 |
|
|
||||||
| `src/jobs/templates.ts` | 模板发现不变 |
|
|
||||||
| `src/jobs/classifier.ts` | 任务分类器不变 |
|
|
||||||
|
|
||||||
## 实现方案
|
|
||||||
|
|
||||||
### 1. CLI 快速路径 (`cli.tsx`)
|
|
||||||
|
|
||||||
**改后**:
|
|
||||||
```typescript
|
|
||||||
// 新: claude job <subcommand>
|
|
||||||
if (
|
|
||||||
feature('TEMPLATES') &&
|
|
||||||
args[0] === 'job'
|
|
||||||
) {
|
|
||||||
profileCheckpoint('cli_templates_path')
|
|
||||||
const { templatesMain } = await import('../cli/handlers/templateJobs.js')
|
|
||||||
await templatesMain(args.slice(1))
|
|
||||||
process.exit(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 向后兼容 (deprecated)
|
|
||||||
if (
|
|
||||||
feature('TEMPLATES') &&
|
|
||||||
(args[0] === 'new' || args[0] === 'list' || args[0] === 'reply')
|
|
||||||
) {
|
|
||||||
console.error(`[deprecated] Use: claude job ${args[0]} ${args.slice(1).join(' ')}`.trim())
|
|
||||||
profileCheckpoint('cli_templates_path')
|
|
||||||
const { templatesMain } = await import('../cli/handlers/templateJobs.js')
|
|
||||||
await templatesMain(args)
|
|
||||||
process.exit(0)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. templateJobs.ts 新增 status 子命令
|
|
||||||
|
|
||||||
在现有 `switch` 中增加:
|
|
||||||
```typescript
|
|
||||||
case 'status':
|
|
||||||
handleStatus(args.slice(1))
|
|
||||||
break
|
|
||||||
```
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
function handleStatus(args: string[]): void {
|
|
||||||
const jobId = args[0]
|
|
||||||
if (!jobId) {
|
|
||||||
console.error('Usage: claude job status <job-id>')
|
|
||||||
process.exitCode = 1
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const state = readJobState(jobId)
|
|
||||||
if (!state) {
|
|
||||||
console.error(`Job not found: ${jobId}`)
|
|
||||||
process.exitCode = 1
|
|
||||||
return
|
|
||||||
}
|
|
||||||
console.log(`Job: ${state.jobId}`)
|
|
||||||
console.log(` Template: ${state.templateName}`)
|
|
||||||
console.log(` Status: ${state.status}`)
|
|
||||||
console.log(` Created: ${state.createdAt}`)
|
|
||||||
console.log(` Updated: ${state.updatedAt}`)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. REPL 斜杠命令
|
|
||||||
|
|
||||||
**`src/commands/job/index.ts`**:
|
|
||||||
```typescript
|
|
||||||
import type { Command } from '../../commands.js'
|
|
||||||
import { feature } from 'bun:bundle'
|
|
||||||
|
|
||||||
const job = {
|
|
||||||
type: 'local-jsx',
|
|
||||||
name: 'job',
|
|
||||||
description: 'Manage template jobs',
|
|
||||||
argumentHint: '[list|new|reply|status]',
|
|
||||||
isEnabled: () => {
|
|
||||||
if (feature('TEMPLATES')) return true
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
load: () => import('./job.js'),
|
|
||||||
} satisfies Command
|
|
||||||
|
|
||||||
export default job
|
|
||||||
```
|
|
||||||
|
|
||||||
**`src/commands/job/job.tsx`**:
|
|
||||||
```typescript
|
|
||||||
export async function call(
|
|
||||||
onDone: LocalJSXCommandOnDone,
|
|
||||||
_context: LocalJSXCommandContext,
|
|
||||||
args: string,
|
|
||||||
): Promise<React.ReactNode> {
|
|
||||||
const parts = args.trim().split(/\s+/)
|
|
||||||
const sub = parts[0] || 'list'
|
|
||||||
|
|
||||||
// 委托给 templatesMain
|
|
||||||
const { templatesMain } = await import('../../cli/handlers/templateJobs.js')
|
|
||||||
|
|
||||||
// 捕获 console.log 输出作为结果返回给 REPL
|
|
||||||
const lines: string[] = []
|
|
||||||
const origLog = console.log
|
|
||||||
const origError = console.error
|
|
||||||
console.log = (...a: unknown[]) => lines.push(a.join(' '))
|
|
||||||
console.error = (...a: unknown[]) => lines.push(a.join(' '))
|
|
||||||
|
|
||||||
try {
|
|
||||||
await templatesMain([sub, ...parts.slice(1)])
|
|
||||||
} finally {
|
|
||||||
console.log = origLog
|
|
||||||
console.error = origError
|
|
||||||
}
|
|
||||||
|
|
||||||
onDone(lines.join('\n') || 'Done.', { display: 'system' })
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. commands.ts 注册
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const jobCmd = feature('TEMPLATES')
|
|
||||||
? require('./commands/job/index.js').default
|
|
||||||
: null
|
|
||||||
|
|
||||||
// COMMANDS 数组:
|
|
||||||
...(jobCmd ? [jobCmd] : []),
|
|
||||||
```
|
|
||||||
|
|
||||||
## 验证清单
|
|
||||||
|
|
||||||
- [ ] `claude job list` 列出模板
|
|
||||||
- [ ] `claude job new <template>` 创建任务
|
|
||||||
- [ ] `claude job reply <id> <text>` 回复任务
|
|
||||||
- [ ] `claude job status <id>` 显示任务状态
|
|
||||||
- [ ] `claude job` (无参数) 等同于 `claude job list`
|
|
||||||
- [ ] `claude new/list/reply` 输出 deprecation 警告 + 正常工作
|
|
||||||
- [ ] REPL 中 `/job` 可用
|
|
||||||
- [ ] REPL 中 `/job list` 显示模板列表
|
|
||||||
- [ ] tsc --noEmit 零错误
|
|
||||||
- [ ] bun test 通过
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
# Task 016: 向后兼容 + 测试
|
|
||||||
|
|
||||||
> 设计文档: [daemon-restructure-design.md](../features/daemon-restructure-design.md) § 五
|
|
||||||
> 依赖: Task 014, Task 015
|
|
||||||
> 分支: `feat/integrate-5-branches`
|
|
||||||
|
|
||||||
## 目标
|
|
||||||
|
|
||||||
确保旧命令向后兼容 (deprecation 警告 + 正常代理),并为重构后的命令结构编写测试。
|
|
||||||
|
|
||||||
## 文件清单
|
|
||||||
|
|
||||||
### 新增
|
|
||||||
|
|
||||||
| 文件 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `src/daemon/__tests__/daemonMain.test.ts` | daemonMain 子命令路由测试 |
|
|
||||||
| `src/cli/bg/__tests__/engine.test.ts` | BgEngine 选择逻辑测试 |
|
|
||||||
| `src/cli/bg/__tests__/detached.test.ts` | DetachedEngine 启动/停止测试 |
|
|
||||||
| `src/cli/bg/__tests__/tail.test.ts` | 日志 tail 功能测试 |
|
|
||||||
|
|
||||||
### 修改
|
|
||||||
|
|
||||||
| 文件 | 变更 |
|
|
||||||
|------|------|
|
|
||||||
| `src/entrypoints/cli.tsx` | 确认 deprecation 路径正确代理 |
|
|
||||||
|
|
||||||
## 实现方案
|
|
||||||
|
|
||||||
### 1. 向后兼容矩阵
|
|
||||||
|
|
||||||
| 旧命令 | 新命令 | 处理方式 |
|
|
||||||
|--------|--------|---------|
|
|
||||||
| `claude ps` | `claude daemon status` | stderr 输出 `[deprecated] Use: claude daemon status`,然后执行 |
|
|
||||||
| `claude logs <x>` | `claude daemon logs <x>` | 同上 |
|
|
||||||
| `claude attach <x>` | `claude daemon attach <x>` | 同上 |
|
|
||||||
| `claude kill <x>` | `claude daemon kill <x>` | 同上 |
|
|
||||||
| `claude --bg` | `claude daemon bg` | 保留为快捷方式,**不** deprecate (太常用) |
|
|
||||||
| `claude new <t>` | `claude job new <t>` | stderr deprecation + 执行 |
|
|
||||||
| `claude list` | `claude job list` | stderr deprecation + 执行 |
|
|
||||||
| `claude reply <id>` | `claude job reply <id>` | stderr deprecation + 执行 |
|
|
||||||
|
|
||||||
**关键**: deprecation 输出到 stderr 而非 stdout,不影响脚本管道。
|
|
||||||
|
|
||||||
### 2. 测试计划
|
|
||||||
|
|
||||||
#### 2.1 daemonMain 路由测试
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
describe('daemonMain', () => {
|
|
||||||
test('无参数默认 status', async () => { ... })
|
|
||||||
test('start 调用 runSupervisor', async () => { ... })
|
|
||||||
test('stop 调用 handleDaemonStop', async () => { ... })
|
|
||||||
test('bg 委托给 bg.handleBgStart', async () => { ... })
|
|
||||||
test('attach 委托给 bg.attachHandler', async () => { ... })
|
|
||||||
test('logs 委托给 bg.logsHandler', async () => { ... })
|
|
||||||
test('kill 委托给 bg.killHandler', async () => { ... })
|
|
||||||
test('未知子命令设置 exitCode=1', async () => { ... })
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2.2 引擎选择测试
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
describe('selectEngine', () => {
|
|
||||||
test('win32 返回 DetachedEngine', async () => { ... })
|
|
||||||
test('darwin + tmux 可用返回 TmuxEngine', async () => { ... })
|
|
||||||
test('darwin + tmux 不可用返回 DetachedEngine', async () => { ... })
|
|
||||||
test('linux + tmux 可用返回 TmuxEngine', async () => { ... })
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2.3 DetachedEngine 测试
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
describe('DetachedEngine', () => {
|
|
||||||
test('available 始终返回 true', async () => { ... })
|
|
||||||
test('start 创建 detached 子进程并写入日志', async () => { ... })
|
|
||||||
test('start 返回的 PID 文件存在', async () => { ... })
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2.4 Tail 测试
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
describe('tailLog', () => {
|
|
||||||
test('输出已有日志内容', async () => { ... })
|
|
||||||
test('追加内容时实时输出', async () => { ... })
|
|
||||||
test('SIGINT 退出 tail', async () => { ... })
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 集成验证脚本
|
|
||||||
|
|
||||||
可选: 在 `scripts/` 下添加一个手动验证脚本:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
#!/bin/bash
|
|
||||||
# scripts/verify-daemon-restructure.sh
|
|
||||||
echo "=== 1. claude daemon status ==="
|
|
||||||
bun run dev -- daemon status
|
|
||||||
|
|
||||||
echo "=== 2. claude daemon bg (should start) ==="
|
|
||||||
bun run dev -- daemon bg --help
|
|
||||||
|
|
||||||
echo "=== 3. claude ps (deprecated) ==="
|
|
||||||
bun run dev -- ps 2>&1 | head -1
|
|
||||||
|
|
||||||
echo "=== 4. claude job list ==="
|
|
||||||
bun run dev -- job list
|
|
||||||
|
|
||||||
echo "=== 5. claude list (deprecated) ==="
|
|
||||||
bun run dev -- list 2>&1 | head -1
|
|
||||||
```
|
|
||||||
|
|
||||||
## 验证清单
|
|
||||||
|
|
||||||
- [ ] 旧命令全部正常工作 (仅多一行 stderr 警告)
|
|
||||||
- [ ] `--bg` 保持无警告
|
|
||||||
- [ ] 所有新增测试通过
|
|
||||||
- [ ] 现有 2695 个测试无回归
|
|
||||||
- [ ] tsc --noEmit 零错误
|
|
||||||
- [ ] 手动在 Windows + macOS/Linux 上验证关键路径
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
# OpenClaw Autonomy Baseline Test Spec
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
This test spec locks the current behavior of the existing trigger and context layers before any formal autonomy-subsystem implementation begins.
|
|
||||||
|
|
||||||
At this stage, production code is read-only. Only test files, fixtures, and planning documents may change.
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Establish a stable baseline around the parts of `Claude-code-bast` that later autonomy work is most likely to touch:
|
|
||||||
|
|
||||||
- proactive state handling
|
|
||||||
- cron task storage semantics
|
|
||||||
- cron scheduler helper semantics
|
|
||||||
- user-context cache and `CLAUDE.md` injection behavior
|
|
||||||
|
|
||||||
## Out of Scope for This Baseline Round
|
|
||||||
|
|
||||||
- New authority behavior (`AGENTS.md` / `HEARTBEAT.md`)
|
|
||||||
- New detached-run ledger behavior
|
|
||||||
- New flow behavior
|
|
||||||
- UI redesign
|
|
||||||
|
|
||||||
## Files Under Baseline Protection
|
|
||||||
|
|
||||||
- `src/proactive/index.ts`
|
|
||||||
- `src/utils/cronTasks.ts`
|
|
||||||
- `src/utils/cronScheduler.ts`
|
|
||||||
- `src/context.ts`
|
|
||||||
|
|
||||||
## Test Files Added In This Round
|
|
||||||
|
|
||||||
- `src/proactive/__tests__/state.baseline.test.ts`
|
|
||||||
- `src/commands/__tests__/proactive.baseline.test.ts`
|
|
||||||
- `src/utils/__tests__/cronTasks.baseline.test.ts`
|
|
||||||
- `src/utils/__tests__/cronScheduler.baseline.test.ts`
|
|
||||||
- `src/__tests__/context.baseline.test.ts`
|
|
||||||
|
|
||||||
## Baseline Assertions
|
|
||||||
|
|
||||||
### Proactive state
|
|
||||||
|
|
||||||
1. Activating proactive mode sets active state and activation source.
|
|
||||||
2. Pausing proactive mode suppresses `shouldTick()` and clears `nextTickAt`.
|
|
||||||
3. Blocking context suppresses `shouldTick()` and clears `nextTickAt`.
|
|
||||||
4. Subscribers are notified on state transitions.
|
|
||||||
5. The `/proactive` command enables proactive mode and emits the expected hidden reminder.
|
|
||||||
6. The `/proactive` command disables proactive mode on the second invocation.
|
|
||||||
|
|
||||||
### Cron task storage
|
|
||||||
|
|
||||||
1. Session-only cron tasks remain in memory only.
|
|
||||||
2. Durable cron tasks are persisted to `.claude/scheduled_tasks.json`.
|
|
||||||
3. Daemon-style `dir`-scoped reads exclude session-only cron tasks.
|
|
||||||
4. `removeCronTasks()` without `dir` can remove session-only tasks.
|
|
||||||
5. `removeCronTasks()` with `dir` does not mutate session-only task storage.
|
|
||||||
|
|
||||||
### Cron scheduler helpers
|
|
||||||
|
|
||||||
1. `isRecurringTaskAged()` preserves current aging semantics.
|
|
||||||
2. `buildMissedTaskNotification()` preserves the current AskUserQuestion safety wording.
|
|
||||||
3. `buildMissedTaskNotification()` preserves code-fence hardening for prompt bodies that contain backticks.
|
|
||||||
|
|
||||||
### User context caching
|
|
||||||
|
|
||||||
1. `getUserContext()` includes `currentDate`.
|
|
||||||
2. `getUserContext()` includes mocked `claudeMd` content when memory loading is enabled.
|
|
||||||
3. `CLAUDE_CODE_DISABLE_CLAUDE_MDS` suppresses `claudeMd`.
|
|
||||||
4. `setSystemPromptInjection()` clears the memoized user-context cache.
|
|
||||||
5. `getSystemContext()` reflects the injection after cache invalidation.
|
|
||||||
|
|
||||||
## Remaining Baseline Gaps
|
|
||||||
|
|
||||||
The following areas are intentionally deferred because they require higher-cost harnessing and should still avoid production-code changes:
|
|
||||||
|
|
||||||
1. `useScheduledTasks.ts` hook-level runtime behavior
|
|
||||||
2. `src/cli/print.ts` full headless scheduler loop behavior
|
|
||||||
3. `useProactive.ts` hook timer behavior
|
|
||||||
4. end-to-end queue interaction between proactive ticks and `SleepTool`
|
|
||||||
|
|
||||||
## Acceptance
|
|
||||||
|
|
||||||
This baseline round is complete when:
|
|
||||||
|
|
||||||
1. The four new test files pass.
|
|
||||||
2. No production source files are modified.
|
|
||||||
3. The tests are stable enough to serve as a pre-implementation guardrail.
|
|
||||||
@@ -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"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-code-best",
|
"name": "claude-code-best",
|
||||||
"version": "1.4.2",
|
"version": "1.3.7",
|
||||||
"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>",
|
||||||
@@ -54,19 +54,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",
|
"typecheck": "tsc --noEmit",
|
||||||
"rcs": "bun run scripts/rcs.ts"
|
"rcs": "bun run scripts/rcs.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.19.0",
|
"@agentclientprotocol/sdk": "^0.19.0",
|
||||||
"@claude-code-best/mcp-chrome-bridge": "^2.0.8",
|
"@claude-code-best/mcp-chrome-bridge": "^2.0.7",
|
||||||
"ws": "^8.20.0"
|
"ws": "^8.20.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@alcalzone/ansi-tokenize": "^0.3.0",
|
"@alcalzone/ansi-tokenize": "^0.3.0",
|
||||||
"@ant/model-provider": "workspace:*",
|
"@anthropic-ai/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:*",
|
||||||
|
|||||||
@@ -37,21 +37,16 @@
|
|||||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
/** Detect actual image MIME type from base64 data by decoding the magic bytes. */
|
/** Detect actual image MIME type from base64 data using magic bytes. */
|
||||||
function detectMimeFromBase64(b64: string): string {
|
function detectMimeFromBase64(b64: string): string {
|
||||||
// Decode first 12 raw bytes (16 base64 chars is enough) and check standard magic bytes.
|
// First byte is enough to distinguish PNG (0x89) from JPEG (0xFF)
|
||||||
// PNG: 89 50 4E 47
|
const c = b64.charCodeAt(0);
|
||||||
// JPEG: FF D8 FF
|
if (c === 0x89) return "image/png";
|
||||||
// RIFF+WEBP: "RIFF" at 0..3 + "WEBP" at 8..11
|
if (c === 0xFF) return "image/jpeg";
|
||||||
// GIF: "GIF" at 0..2
|
// RIFF = WebP
|
||||||
const raw = Buffer.from(b64.slice(0, 16), "base64");
|
if (c === 0x52) return "image/webp";
|
||||||
if (raw[0] === 0x89 && raw[1] === 0x50 && raw[2] === 0x4e && raw[3] === 0x47) return "image/png";
|
// GIF
|
||||||
if (raw[0] === 0xff && raw[1] === 0xd8 && raw[2] === 0xff) return "image/jpeg";
|
if (c === 0x47) return "image/gif";
|
||||||
if (
|
|
||||||
raw[0] === 0x52 && raw[1] === 0x49 && raw[2] === 0x46 && raw[3] === 0x46 && // RIFF
|
|
||||||
raw[8] === 0x57 && raw[9] === 0x45 && raw[10] === 0x42 && raw[11] === 0x50 // WEBP
|
|
||||||
) return "image/webp";
|
|
||||||
if (raw[0] === 0x47 && raw[1] === 0x49 && raw[2] === 0x46) return "image/gif";
|
|
||||||
return "image/png";
|
return "image/png";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "@ant/model-provider",
|
"name": "@anthropic-ai/model-provider",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// @ant/model-provider
|
// @anthropic-ai/model-provider
|
||||||
// Model provider abstraction layer for Claude Code
|
// Model provider abstraction layer for Claude Code
|
||||||
//
|
//
|
||||||
// This package owns the model calling logic and provides:
|
// This package owns the model calling logic and provides:
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// Type definitions for @ant/model-provider
|
// Type definitions for @anthropic-ai/model-provider
|
||||||
|
|
||||||
export * from './message.js'
|
export * from './message.js'
|
||||||
export * from './usage.js'
|
export * from './usage.js'
|
||||||
34
packages/acp-link/.gitignore
vendored
34
packages/acp-link/.gitignore
vendored
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
@@ -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());
|
|
||||||
|
|
||||||
@@ -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 });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import type { CommandContext } from "@stricli/core";
|
|
||||||
|
|
||||||
export interface LocalContext extends CommandContext {}
|
|
||||||
|
|
||||||
export function buildContext(): LocalContext {
|
|
||||||
return {
|
|
||||||
process,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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__"]
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { feature } from 'bun:bundle'
|
|
||||||
import { z } from 'zod/v4'
|
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 { logForDebugging } from 'src/utils/debug.js'
|
|
||||||
|
|
||||||
const PUSH_NOTIFICATION_TOOL_NAME = 'PushNotification'
|
const PUSH_NOTIFICATION_TOOL_NAME = 'PushNotification'
|
||||||
|
|
||||||
@@ -76,58 +74,14 @@ Requires Remote Control to be configured. Respects user notification settings (t
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async call(input: PushInput, context) {
|
async call(_input: PushInput) {
|
||||||
const appState = context.getAppState()
|
// Push delivery is handled by the Remote Control / KAIROS transport layer.
|
||||||
|
// Without the KAIROS runtime, this tool is not available.
|
||||||
// Try bridge delivery first (for remote/mobile viewers)
|
return {
|
||||||
if (appState.replBridgeEnabled) {
|
data: {
|
||||||
if (feature('BRIDGE_MODE')) {
|
sent: false,
|
||||||
try {
|
error: 'PushNotification requires the KAIROS transport layer.',
|
||||||
const { getBridgeAccessToken, getBridgeBaseUrl } = await import(
|
},
|
||||||
'src/bridge/bridgeConfig.js'
|
|
||||||
)
|
|
||||||
const { getSessionId } = await import('src/bootstrap/state.js')
|
|
||||||
const token = getBridgeAccessToken()
|
|
||||||
const sessionId = getSessionId()
|
|
||||||
if (token && sessionId) {
|
|
||||||
const baseUrl = getBridgeBaseUrl()
|
|
||||||
const axios = (await import('axios')).default
|
|
||||||
const response = await axios.post(
|
|
||||||
`${baseUrl}/v1/sessions/${sessionId}/events`,
|
|
||||||
{
|
|
||||||
events: [
|
|
||||||
{
|
|
||||||
type: 'push_notification',
|
|
||||||
title: input.title,
|
|
||||||
body: input.body,
|
|
||||||
priority: input.priority ?? 'normal',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'anthropic-version': '2023-06-01',
|
|
||||||
},
|
|
||||||
timeout: 10_000,
|
|
||||||
validateStatus: (s: number) => s < 500,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (response.status >= 200 && response.status < 300) {
|
|
||||||
logForDebugging(`[PushNotification] delivered via bridge session=${sessionId}`)
|
|
||||||
return { data: { sent: true } }
|
|
||||||
}
|
|
||||||
logForDebugging(`[PushNotification] bridge delivery failed: status=${response.status}`)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
logForDebugging(`[PushNotification] bridge delivery error: ${e}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: no bridge available, push was not delivered to a remote device.
|
|
||||||
logForDebugging(`[PushNotification] no bridge available, not delivered: ${input.title}`)
|
|
||||||
return { data: { sent: false, error: 'No Remote Control bridge configured. Notification not delivered.' } }
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -70,51 +70,14 @@ Guidelines:
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async call(input: SendUserFileInput, context) {
|
async call(_input: SendUserFileInput) {
|
||||||
const { file_path } = input
|
// File transfer is handled by the KAIROS assistant transport layer.
|
||||||
const { stat } = await import('fs/promises')
|
// Without the KAIROS runtime, this tool is not available.
|
||||||
|
|
||||||
// Verify file exists and is readable
|
|
||||||
let fileSize: number
|
|
||||||
try {
|
|
||||||
const fileStat = await stat(file_path)
|
|
||||||
if (!fileStat.isFile()) {
|
|
||||||
return {
|
|
||||||
data: { sent: false, file_path, error: 'Path is not a file.' },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fileSize = fileStat.size
|
|
||||||
} catch {
|
|
||||||
return {
|
|
||||||
data: { sent: false, file_path, error: 'File does not exist or is not readable.' },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attempt bridge upload if available (so web viewers can download)
|
|
||||||
const appState = context.getAppState()
|
|
||||||
let fileUuid: string | undefined
|
|
||||||
if (appState.replBridgeEnabled) {
|
|
||||||
try {
|
|
||||||
const { uploadBriefAttachment } = await import(
|
|
||||||
'@claude-code-best/builtin-tools/tools/BriefTool/upload.js'
|
|
||||||
)
|
|
||||||
fileUuid = await uploadBriefAttachment(file_path, fileSize, {
|
|
||||||
replBridgeEnabled: true,
|
|
||||||
signal: context.abortController.signal,
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
// Best-effort upload — local path is always available
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const delivered = !appState.replBridgeEnabled || Boolean(fileUuid)
|
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
sent: delivered,
|
sent: false,
|
||||||
file_path,
|
file_path: _input.file_path,
|
||||||
size: fileSize,
|
error: 'SendUserFile requires the KAIROS assistant transport layer.',
|
||||||
...(fileUuid ? { file_uuid: fileUuid } : {}),
|
|
||||||
...(!delivered ? { error: 'Bridge upload failed. File available at local path.' } : {}),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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')
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 升级:
|
||||||
|
|||||||
@@ -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": {}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -678,44 +678,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,35 +704,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 () => {
|
test("GET /web/sessions/:id and history — supports compat code session IDs", async () => {
|
||||||
const codeSession = storeCreateSession({ idPrefix: "cse_" });
|
const codeSession = storeCreateSession({ idPrefix: "cse_" });
|
||||||
storeBindSession(codeSession.id, "user-1");
|
storeBindSession(codeSession.id, "user-1");
|
||||||
@@ -1285,15 +1218,7 @@ describe("V2 Worker Events Routes", () => {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
worker_epoch: 1,
|
worker_epoch: 1,
|
||||||
worker_status: "running",
|
worker_status: "running",
|
||||||
external_metadata: {
|
external_metadata: { permission_mode: "default" },
|
||||||
permission_mode: "default",
|
|
||||||
automation_state: {
|
|
||||||
enabled: true,
|
|
||||||
phase: "sleeping",
|
|
||||||
next_tick_at: null,
|
|
||||||
sleep_until: 123456,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
expect(putRes.status).toBe(200);
|
expect(putRes.status).toBe(200);
|
||||||
@@ -1305,21 +1230,6 @@ describe("V2 Worker Events Routes", () => {
|
|||||||
const body = await getRes.json();
|
const body = await getRes.json();
|
||||||
expect(body.worker.worker_status).toBe("running");
|
expect(body.worker.worker_status).toBe("running");
|
||||||
expect(body.worker.external_metadata.permission_mode).toBe("default");
|
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 () => {
|
test("POST /v1/code/sessions/:id/worker/heartbeat — updates heartbeat", async () => {
|
||||||
@@ -1374,123 +1284,6 @@ describe("V2 Worker Events Routes", () => {
|
|||||||
reader.cancel();
|
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",
|
||||||
|
|||||||
@@ -353,14 +353,6 @@ describe("Transport Service", () => {
|
|||||||
expect(result.uuid).toBe("msg_123");
|
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 +370,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("");
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
/** Thin logging wrapper — silent in test environment, uses console in production. */
|
|
||||||
const isTest = process.env.NODE_ENV === "test" || (typeof Bun !== "undefined" && !!Bun.env.BUN_TEST);
|
|
||||||
|
|
||||||
export function log(...args: unknown[]): void {
|
|
||||||
if (!isTest) console.log(...args);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function error(...args: unknown[]): void {
|
|
||||||
if (!isTest) console.error(...args);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
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 {
|
||||||
@@ -11,6 +10,8 @@ import {
|
|||||||
} from "../../transport/ws-handler";
|
} from "../../transport/ws-handler";
|
||||||
import { getSession, resolveExistingSessionId } from "../../services/session";
|
import { getSession, resolveExistingSessionId } from "../../services/session";
|
||||||
|
|
||||||
|
const { upgradeWebSocket, websocket } = createBunWebSocket();
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
/** Authenticate via API key or worker JWT in Authorization header or ?token= query param */
|
/** Authenticate via API key or worker JWT in Authorization header or ?token= query param */
|
||||||
@@ -29,14 +30,14 @@ function authenticateRequest(c: any, label: string, expectedSessionId?: string):
|
|||||||
const payload = verifyWorkerJwt(token);
|
const payload = verifyWorkerJwt(token);
|
||||||
if (payload) {
|
if (payload) {
|
||||||
if (expectedSessionId && payload.session_id !== expectedSessionId) {
|
if (expectedSessionId && payload.session_id !== expectedSessionId) {
|
||||||
log(`[Auth] ${label}: FAILED — JWT session_id mismatch`);
|
console.log(`[Auth] ${label}: FAILED — JWT session_id mismatch`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log(`[Auth] ${label}: FAILED — no valid API key or JWT`);
|
console.log(`[Auth] ${label}: FAILED — no valid API key or JWT`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +85,7 @@ app.get(
|
|||||||
|
|
||||||
const session = getSession(sessionId);
|
const session = getSession(sessionId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
log(`[WS] Upgrade rejected: session ${sessionId} not found`);
|
console.log(`[WS] Upgrade rejected: session ${sessionId} not found`);
|
||||||
return {
|
return {
|
||||||
onOpen(_evt, ws) {
|
onOpen(_evt, ws) {
|
||||||
ws.close(4001, "session not found");
|
ws.close(4001, "session not found");
|
||||||
@@ -92,7 +93,7 @@ app.get(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
log(`[WS] Upgrade accepted: session=${sessionId}`);
|
console.log(`[WS] Upgrade accepted: session=${sessionId}`);
|
||||||
return {
|
return {
|
||||||
onOpen(_evt, ws) {
|
onOpen(_evt, ws) {
|
||||||
handleWebSocketOpen(ws as any, sessionId);
|
handleWebSocketOpen(ws as any, sessionId);
|
||||||
@@ -109,7 +110,7 @@ app.get(
|
|||||||
handleWebSocketClose(ws as any, sessionId, closeEvt?.code, closeEvt?.reason);
|
handleWebSocketClose(ws as any, sessionId, closeEvt?.code, closeEvt?.reason);
|
||||||
},
|
},
|
||||||
onError(evt, ws) {
|
onError(evt, ws) {
|
||||||
logError(`[WS] Error on session=${sessionId}:`, evt);
|
console.error(`[WS] Error on session=${sessionId}:`, evt);
|
||||||
handleWebSocketClose(ws as any, sessionId, 1006, "websocket error");
|
handleWebSocketClose(ws as any, sessionId, 1006, "websocket error");
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { log, error as logError } from "../../logger";
|
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import {
|
import {
|
||||||
createSession,
|
createSession,
|
||||||
@@ -24,7 +23,7 @@ app.post("/", acceptCliHeaders, apiKeyAuth, async (c) => {
|
|||||||
try {
|
try {
|
||||||
await createWorkItem(body.environment_id, session.id);
|
await createWorkItem(body.environment_id, session.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError(`[RCS] Failed to create work item: ${(err as Error).message}`);
|
console.error(`[RCS] Failed to create work item: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { getSession, incrementEpoch, touchSession, updateSessionStatus } from "../../services/session";
|
import { getSession, incrementEpoch, touchSession, updateSessionStatus } from "../../services/session";
|
||||||
import {
|
|
||||||
automationStatesEqual,
|
|
||||||
getAutomationStateEventPayload,
|
|
||||||
} from "../../services/automationState";
|
|
||||||
import { apiKeyAuth, acceptCliHeaders, sessionIngressAuth } from "../../auth/middleware";
|
import { apiKeyAuth, acceptCliHeaders, sessionIngressAuth } from "../../auth/middleware";
|
||||||
import { getEventBus } from "../../transport/event-bus";
|
|
||||||
import { storeGetSessionWorker, storeUpsertSessionWorker } from "../../store";
|
import { storeGetSessionWorker, storeUpsertSessionWorker } from "../../store";
|
||||||
import { v4 as uuid } from "uuid";
|
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -39,9 +33,6 @@ app.put("/:id/worker", acceptCliHeaders, sessionIngressAuth, async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
const prevAutomationState = getAutomationStateEventPayload(
|
|
||||||
storeGetSessionWorker(sessionId)?.externalMetadata,
|
|
||||||
);
|
|
||||||
if (body.worker_status) {
|
if (body.worker_status) {
|
||||||
updateSessionStatus(sessionId, body.worker_status);
|
updateSessionStatus(sessionId, body.worker_status);
|
||||||
} else {
|
} else {
|
||||||
@@ -53,17 +44,6 @@ app.put("/:id/worker", acceptCliHeaders, sessionIngressAuth, async (c) => {
|
|||||||
externalMetadata: body.external_metadata,
|
externalMetadata: body.external_metadata,
|
||||||
requiresActionDetails: body.requires_action_details,
|
requiresActionDetails: body.requires_action_details,
|
||||||
});
|
});
|
||||||
const nextAutomationState = getAutomationStateEventPayload(worker.externalMetadata);
|
|
||||||
|
|
||||||
if (!automationStatesEqual(prevAutomationState, nextAutomationState)) {
|
|
||||||
getEventBus(sessionId).publish({
|
|
||||||
id: uuid(),
|
|
||||||
sessionId,
|
|
||||||
type: "automation_state",
|
|
||||||
payload: nextAutomationState,
|
|
||||||
direction: "inbound",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
status: "ok",
|
status: "ok",
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { log, error as logError } from "../../logger";
|
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { uuidAuth } from "../../auth/middleware";
|
import { uuidAuth } from "../../auth/middleware";
|
||||||
import { getSession, isSessionClosedStatus, resolveOwnedWebSessionId, updateSessionStatus } from "../../services/session";
|
import { getSession, isSessionClosedStatus, resolveOwnedWebSessionId, updateSessionStatus } from "../../services/session";
|
||||||
@@ -45,9 +44,9 @@ app.post("/sessions/:id/events", uuidAuth, async (c) => {
|
|||||||
|
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
const eventType = body.type || "user";
|
const eventType = body.type || "user";
|
||||||
log(`[RC-DEBUG] web -> server: POST /web/sessions/${sessionId}/events type=${eventType} content=${JSON.stringify(body).slice(0, 200)}`);
|
console.log(`[RC-DEBUG] web -> server: POST /web/sessions/${sessionId}/events type=${eventType} content=${JSON.stringify(body).slice(0, 200)}`);
|
||||||
const event = publishSessionEvent(sessionId, eventType, body, "outbound");
|
const event = publishSessionEvent(sessionId, eventType, body, "outbound");
|
||||||
log(`[RC-DEBUG] web -> server: published outbound event id=${event.id} type=${event.type} direction=${event.direction} subscribers=${getEventBus(sessionId).subscriberCount()}`);
|
console.log(`[RC-DEBUG] web -> server: published outbound event id=${event.id} type=${event.type} direction=${event.direction} subscribers=${getEventBus(sessionId).subscriberCount()}`);
|
||||||
return c.json({ status: "ok", event }, 200);
|
return c.json({ status: "ok", event }, 200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { log, error as logError } from "../../logger";
|
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { uuidAuth } from "../../auth/middleware";
|
import { uuidAuth } from "../../auth/middleware";
|
||||||
import { getAutomationStateSnapshot } from "../../services/automationState";
|
|
||||||
import {
|
import {
|
||||||
createSession,
|
createSession,
|
||||||
getSession,
|
getSession,
|
||||||
@@ -11,7 +9,7 @@ import {
|
|||||||
resolveOwnedWebSessionId,
|
resolveOwnedWebSessionId,
|
||||||
toWebSessionResponse,
|
toWebSessionResponse,
|
||||||
} from "../../services/session";
|
} from "../../services/session";
|
||||||
import { storeBindSession, storeGetSessionWorker } from "../../store";
|
import { storeBindSession } from "../../store";
|
||||||
import { createWorkItem } from "../../services/work-dispatch";
|
import { createWorkItem } from "../../services/work-dispatch";
|
||||||
import { createSSEStream } from "../../transport/sse-writer";
|
import { createSSEStream } from "../../transport/sse-writer";
|
||||||
import { getEventBus } from "../../transport/event-bus";
|
import { getEventBus } from "../../transport/event-bus";
|
||||||
@@ -37,7 +35,7 @@ app.post("/sessions", uuidAuth, async (c) => {
|
|||||||
try {
|
try {
|
||||||
await createWorkItem(body.environment_id, session.id);
|
await createWorkItem(body.environment_id, session.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError(`[RCS] Failed to create work item: ${(err as Error).message}`);
|
console.error(`[RCS] Failed to create work item: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,13 +67,7 @@ app.get("/sessions/:id", uuidAuth, async (c) => {
|
|||||||
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);
|
||||||
}
|
}
|
||||||
const worker = storeGetSessionWorker(sessionId);
|
return c.json(toWebSessionResponse(session), 200);
|
||||||
const automationState = getAutomationStateSnapshot(worker?.externalMetadata);
|
|
||||||
const response = toWebSessionResponse(session);
|
|
||||||
return c.json(
|
|
||||||
automationState === undefined ? response : { ...response, automation_state: automationState },
|
|
||||||
200,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/** GET /web/sessions/:id/history — Historical events for session */
|
/** GET /web/sessions/:id/history — Historical events for session */
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
import type { AutomationStateResponse } from "../types/api";
|
|
||||||
|
|
||||||
const DISABLED_AUTOMATION_STATE: AutomationStateResponse = Object.freeze({
|
|
||||||
enabled: false,
|
|
||||||
phase: null,
|
|
||||||
next_tick_at: null,
|
|
||||||
sleep_until: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
function cloneAutomationState(state: AutomationStateResponse): AutomationStateResponse {
|
|
||||||
return { ...state };
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeAutomationState(raw: unknown): AutomationStateResponse {
|
|
||||||
if (!raw || typeof raw !== "object") {
|
|
||||||
return cloneAutomationState(DISABLED_AUTOMATION_STATE);
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = raw as Record<string, unknown>;
|
|
||||||
return {
|
|
||||||
enabled: state.enabled === true,
|
|
||||||
phase: state.phase === "standby" || state.phase === "sleeping" ? state.phase : null,
|
|
||||||
next_tick_at: typeof state.next_tick_at === "number" ? state.next_tick_at : null,
|
|
||||||
sleep_until: typeof state.sleep_until === "number" ? state.sleep_until : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function readAutomationStateValue(metadata: Record<string, unknown> | null | undefined): unknown {
|
|
||||||
if (!metadata || typeof metadata !== "object") {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (!Object.prototype.hasOwnProperty.call(metadata, "automation_state")) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return metadata.automation_state;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAutomationStateSnapshot(
|
|
||||||
metadata: Record<string, unknown> | null | undefined,
|
|
||||||
): AutomationStateResponse | undefined {
|
|
||||||
const raw = readAutomationStateValue(metadata);
|
|
||||||
if (raw === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return normalizeAutomationState(raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAutomationStateEventPayload(
|
|
||||||
metadata: Record<string, unknown> | null | undefined,
|
|
||||||
): AutomationStateResponse {
|
|
||||||
return getAutomationStateSnapshot(metadata) ?? cloneAutomationState(DISABLED_AUTOMATION_STATE);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function automationStatesEqual(
|
|
||||||
a: AutomationStateResponse,
|
|
||||||
b: AutomationStateResponse,
|
|
||||||
): boolean {
|
|
||||||
return (
|
|
||||||
a.enabled === b.enabled &&
|
|
||||||
a.phase === b.phase &&
|
|
||||||
a.next_tick_at === b.next_tick_at &&
|
|
||||||
a.sleep_until === b.sleep_until
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { log, error as logError } from "../logger";
|
import { storeListActiveEnvironments, storeUpdateEnvironment } from "../store";
|
||||||
import { storeListActiveEnvironments, storeUpdateEnvironment, storeMarkAcpAgentOffline } from "../store";
|
|
||||||
import { storeListSessions } from "../store";
|
import { storeListSessions } from "../store";
|
||||||
import { config } from "../config";
|
import { config } from "../config";
|
||||||
import { updateSessionStatus } from "./session";
|
import { updateSessionStatus } from "./session";
|
||||||
@@ -10,16 +9,8 @@ export function runDisconnectMonitorSweep(now = Date.now()) {
|
|||||||
// Check environment heartbeat timeout
|
// Check environment heartbeat timeout
|
||||||
const envs = storeListActiveEnvironments();
|
const envs = storeListActiveEnvironments();
|
||||||
for (const env of envs) {
|
for (const env of envs) {
|
||||||
// Skip ACP agents — they use WS keepalive, not polling
|
|
||||||
if (env.workerType === "acp") {
|
|
||||||
if (env.lastPollAt && now - env.lastPollAt.getTime() > timeoutMs) {
|
|
||||||
log(`[RCS] ACP agent ${env.id} timed out (no activity for ${Math.round((now - env.lastPollAt.getTime()) / 1000)}s)`);
|
|
||||||
storeMarkAcpAgentOffline(env.id);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (env.lastPollAt && now - env.lastPollAt.getTime() > timeoutMs) {
|
if (env.lastPollAt && now - env.lastPollAt.getTime() > timeoutMs) {
|
||||||
log(`[RCS] Environment ${env.id} timed out (no poll for ${Math.round((now - env.lastPollAt.getTime()) / 1000)}s)`);
|
console.log(`[RCS] Environment ${env.id} timed out (no poll for ${Math.round((now - env.lastPollAt.getTime()) / 1000)}s)`);
|
||||||
storeUpdateEnvironment(env.id, { status: "disconnected" });
|
storeUpdateEnvironment(env.id, { status: "disconnected" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -30,7 +21,7 @@ export function runDisconnectMonitorSweep(now = Date.now()) {
|
|||||||
if (session.status === "running" || session.status === "idle") {
|
if (session.status === "running" || session.status === "idle") {
|
||||||
const elapsed = now - session.updatedAt.getTime();
|
const elapsed = now - session.updatedAt.getTime();
|
||||||
if (elapsed > timeoutMs * 2) {
|
if (elapsed > timeoutMs * 2) {
|
||||||
log(`[RCS] Session ${session.id} marked inactive (no update for ${Math.round(elapsed / 1000)}s)`);
|
console.log(`[RCS] Session ${session.id} marked inactive (no update for ${Math.round(elapsed / 1000)}s)`);
|
||||||
updateSessionStatus(session.id, "inactive");
|
updateSessionStatus(session.id, "inactive");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { config } from "../config";
|
import { config } from "../config";
|
||||||
import {
|
import {
|
||||||
storeCreateEnvironment,
|
storeCreateEnvironment,
|
||||||
storeCreateSession,
|
|
||||||
storeGetEnvironment,
|
storeGetEnvironment,
|
||||||
storeUpdateEnvironment,
|
storeUpdateEnvironment,
|
||||||
storeListActiveEnvironments,
|
storeListActiveEnvironments,
|
||||||
@@ -19,8 +18,6 @@ function toResponse(row: EnvironmentRecord): EnvironmentResponse {
|
|||||||
status: row.status,
|
status: row.status,
|
||||||
username: row.username,
|
username: row.username,
|
||||||
last_poll_at: row.lastPollAt ? row.lastPollAt.getTime() / 1000 : null,
|
last_poll_at: row.lastPollAt ? row.lastPollAt.getTime() / 1000 : null,
|
||||||
worker_type: row.workerType,
|
|
||||||
capabilities: row.capabilities,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,21 +34,9 @@ export function registerEnvironment(req: RegisterEnvironmentRequest & { metadata
|
|||||||
workerType,
|
workerType,
|
||||||
bridgeId: req.bridge_id,
|
bridgeId: req.bridge_id,
|
||||||
username: req.username,
|
username: req.username,
|
||||||
capabilities: req.capabilities,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let sessionId: string | undefined;
|
return { environment_id: record.id, environment_secret: record.secret, status: record.status as "active" };
|
||||||
// ACP agents: auto-create a session so they appear in the dashboard sessions list
|
|
||||||
if (workerType === "acp") {
|
|
||||||
const session = storeCreateSession({
|
|
||||||
environmentId: record.id,
|
|
||||||
title: req.machine_name || "ACP Agent",
|
|
||||||
source: "acp",
|
|
||||||
});
|
|
||||||
sessionId = session.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { environment_id: record.id, environment_secret: record.secret, status: record.status as "active", session_id: sessionId };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deregisterEnvironment(envId: string) {
|
export function deregisterEnvironment(envId: string) {
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import {
|
|||||||
storeCreateSession,
|
storeCreateSession,
|
||||||
storeGetSession,
|
storeGetSession,
|
||||||
storeIsSessionOwner,
|
storeIsSessionOwner,
|
||||||
storeGetSessionOwners,
|
|
||||||
storeBindSession,
|
|
||||||
storeUpdateSession,
|
storeUpdateSession,
|
||||||
storeListSessions,
|
storeListSessions,
|
||||||
storeListSessionsByUsername,
|
storeListSessionsByUsername,
|
||||||
@@ -108,16 +106,6 @@ export function resolveOwnedWebSessionId(sessionId: string, uuid: string): strin
|
|||||||
return compatibleCodeSessionId;
|
return compatibleCodeSessionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-bind: if the session exists but has no owner, claim it for the requesting user
|
|
||||||
const existingId = resolveExistingSessionId(sessionId);
|
|
||||||
if (existingId) {
|
|
||||||
const owners = storeGetSessionOwners(existingId);
|
|
||||||
if (!owners || owners.size === 0) {
|
|
||||||
storeBindSession(existingId, uuid);
|
|
||||||
return existingId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user