Compare commits

...

26 Commits

Author SHA1 Message Date
claude-code-best
d2b66d9d2c docs: update contributors 2026-04-17 12:45:56 +00:00
claude-code-best
d70e7f7f05 feat: 支持 langfuse 工具调用映射 2026-04-17 20:45:14 +08:00
Cheng Zi Feng
72a2093cd6 feat(remote-control): 优化 Web 展示、状态同步与桥接控制流程 (#288)
Co-authored-by: chengzifeng <chengzifeng@meituan.com>
2026-04-17 16:21:27 +08:00
claude-code-best
b5c299f5d2 build: CI 添加通过过滤 2026-04-17 11:33:57 +08:00
claude-code-best
ac42ce2d67 fix: 解决 node 下 loading 按钮计算错误问题 2026-04-17 10:42:40 +08:00
claude-code-best
c659912517 docs: 更新说明 2026-04-17 10:22:56 +08:00
claude-code-best
a14b7f352b test: 修正 mock 的滥用情况 2026-04-17 10:13:09 +08:00
claude-code-best
c5ab83a3fc fix: 修复 linux 端的安装问题 2026-04-17 09:51:59 +08:00
claude-code-best
03b7f9b453 chore: 1.4.1 2026-04-17 09:45:36 +08:00
claude-code-best
bddd146f25 feat: 重构供应商层次 (#286)
* refactor: 创建 @anthropic-ai/model-provider 包骨架与类型定义

- 新建 workspace 包 packages/@anthropic-ai/model-provider
- 定义 ModelProviderHooks 接口(依赖注入:分析、成本、日志等)
- 定义 ClientFactories 接口(Anthropic/OpenAI/Gemini/Grok 客户端工厂)
- 搬入核心类型:Message 体系、NonNullableUsage、EMPTY_USAGE、SystemPrompt、错误常量
- 主项目 src/types/message.ts 等改为 re-export,保持向后兼容

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 提升 OpenAI 转换器和模型映射到 model-provider 包

- 搬入 OpenAI 消息转换(convertMessages)、工具转换(convertTools)、流适配(streamAdapter)
- 搬入 OpenAI 和 Grok 模型映射(resolveOpenAIModel、resolveGrokModel)
- 主项目文件改为 thin re-export proxy

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 搬入 Gemini 兼容层到 model-provider 包

- 搬入 Gemini 类型定义、消息转换、工具转换、流适配、模型映射
- 主项目 gemini/ 目录下文件改为 thin re-export proxy

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 搬入 errorUtils 并迁移消费者导入到 model-provider

- 搬入 formatAPIError、extractConnectionErrorDetails 等 errorUtils
- 迁移 10 个消费者文件直接从 @anthropic-ai/model-provider 导入
- 更新 emptyUsage、sdkUtilityTypes、systemPromptType 为 re-export proxy

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: compact 模型降级为 -1 模式(Opus→Sonnet, Sonnet→Haiku)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: 添加 agent-loop 绘图

* Revert "feat: compact 模型降级为 -1 模式(Opus→Sonnet, Sonnet→Haiku)"

This reverts commit e458d6391d.

* docs: 添加简化版 agent loop

* fix: 修复 n 快捷键导致关闭的问题

* fix: 修复 node 下 ws 没打包问题

* docs: 修复链接

* test: 添加测试支持

* fix: 修复类型问题(#267) (#271)

* fix: 修复 Bun 的 polyfill 问题

* fix: 类型修复完成

* feat: 统一所有包的类型文件

* fix: 修复构建问题

* test: 修复类型校验 (#279)

* fix: 修复 Bun 的 polyfill 问题

* fix: 类型修复完成

* feat: 统一所有包的类型文件

* fix: 修复构建问题

* fix(remote-control): harden self-hosted session flows (#278)

Co-authored-by: chengzifeng <chengzifeng@meituan.com>

* docs: update contributors

* build: 新增 vite 构建流程

* feat: 添加环境变量支持以覆盖 max_tokens 设置

* feat(langfuse): LLM generation 记录工具定义

将 Anthropic 格式的工具定义转换为 Langfuse 兼容的 OpenAI 格式,
并在 generation 的 input 中以 { messages, tools } 结构传入,
以便在 Langfuse UI 中查看完整的工具定义信息。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: 添加对 ACP 协议的支持 (#284)

* feat: 适配 zed acp 协议

* docs: 完善 acp 文档

* chore: 1.4.0

* conflict: 解决冲突

* feat: 添加测试覆盖率上报

* style: 改名加移动文件夹位置

* refactor: 移动测试用例及实现

* test: 修复测试用例完成

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Cheng Zi Feng <1154238323@qq.com>
Co-authored-by: chengzifeng <chengzifeng@meituan.com>
Co-authored-by: claude-code-best <272536312+claude-code-best@users.noreply.github.com>
2026-04-17 09:33:14 +08:00
claude-code-best
c8d08d235b Feat/integrate lint preview (#285)
* feat: 适配 zed acp 协议

* docs: 完善 acp 文档

* feat: integrate feature branches + daemon/job 命令层级化 + 跨平台后台引擎

Cherry-picked from origin/lint/preview (637c908), excluding lint-only changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: correct detectMimeFromBase64 to decode raw bytes from base64

Cherry-picked from origin/lint/preview (ee36954).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: daemon 子进程 spawn 跨平台修复 + CliLaunchSpec 集中化重构

Cherry-picked from origin/lint/preview (c5f52cd), excluding lint-only formatting changes.

- 新建 src/utils/cliLaunch.ts: 集中化 CLI 子进程启动层
- 修复 --daemon-worker=kind 等号格式解析
- 修复 daemon/bg fast path 缺少 setShellIfWindows()
- 修复 checkPathExists 用 existsSync 替代 execSync('dir')
- 7 个 spawn 站点迁移到 CliLaunchSpec

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: merge tsconfig.base.json into tsconfig.json with full compiler options

The cherry-pick from 637c908 dropped jsx/strict/etc settings when removing
tsconfig.base.json. This commit restores them in a single tsconfig.json.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: merge tsconfig.base.json into tsconfig.json with full compiler options

The cherry-pick from 637c908 dropped jsx/strict/etc settings when removing
tsconfig.base.json. This commit restores them in a single tsconfig.json.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 20:59:29 +08:00
claude-code-best
a02dc0bded chore: 1.4.0 2026-04-16 20:48:09 +08:00
claude-code-best
3cb1e50b25 feat: 添加对 ACP 协议的支持 (#284)
* feat: 适配 zed acp 协议

* docs: 完善 acp 文档
2026-04-16 20:31:50 +08:00
claude-code-best
cfab161e28 feat(langfuse): LLM generation 记录工具定义
将 Anthropic 格式的工具定义转换为 Langfuse 兼容的 OpenAI 格式,
并在 generation 的 input 中以 { messages, tools } 结构传入,
以便在 Langfuse UI 中查看完整的工具定义信息。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 15:31:18 +08:00
claude-code-best
90027279e6 feat: 添加环境变量支持以覆盖 max_tokens 设置 2026-04-16 13:01:07 +08:00
claude-code-best
3470783ced build: 新增 vite 构建流程 2026-04-16 12:39:19 +08:00
claude-code-best
8169b96250 docs: update contributors 2026-04-16 02:46:47 +00:00
Cheng Zi Feng
fe08cacf8d fix(remote-control): harden self-hosted session flows (#278)
Co-authored-by: chengzifeng <chengzifeng@meituan.com>
2026-04-16 10:46:31 +08:00
claude-code-best
5a4c820e1d test: 修复类型校验 (#279)
* fix: 修复 Bun 的 polyfill 问题

* fix: 类型修复完成

* feat: 统一所有包的类型文件

* fix: 修复构建问题
2026-04-16 10:28:47 +08:00
claude-code-best
1a4e9702c2 fix: 修复类型问题(#267) (#271)
* fix: 修复 Bun 的 polyfill 问题

* fix: 类型修复完成

* feat: 统一所有包的类型文件

* fix: 修复构建问题
2026-04-15 10:54:00 +08:00
claude-code-best
2273a0bcfe docs: 修复链接 2026-04-14 21:19:36 +08:00
claude-code-best
b80483c23e fix: 修复 node 下 ws 没打包问题 2026-04-14 21:19:25 +08:00
claude-code-best
8442aaadd2 fix: 修复 n 快捷键导致关闭的问题 2026-04-14 21:18:36 +08:00
claude-code-best
dad3ad2b8d docs: 添加浏览器说明支持 2026-04-13 21:22:41 +08:00
claude-code-best
b5b81dfe49 chore: 更改 chrome 的依赖 2026-04-13 20:55:04 +08:00
claude-code-best
ecbd5a93e4 fix: 修复 Bun.hash 不存在的问题 2026-04-13 20:21:14 +08:00
316 changed files with 24291 additions and 2700 deletions

View File

@@ -8,7 +8,7 @@ on:
jobs:
ci:
runs-on: macos-latest
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -23,8 +23,16 @@ jobs:
- name: Type check
run: bunx tsc --noEmit
- name: Test
run: bun test
- name: Test with Coverage
run: |
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
run: bun run build
run: bun run build:vite

12
.gitignore vendored
View File

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

View File

@@ -55,6 +55,8 @@ bun run health
# Check unused exports
bun run check:unused
bun run typecheck
# Remote Control Server
bun run rcs
@@ -245,14 +247,23 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
## Testing
- **框架**: `bun:test`(内置断言 + mock
- **当前状态**: 2472 tests / 138 files / 0 fail
- **当前状态**: 2992 tests / 188 files / 0 fail
- **单元测试**: 就近放置于 `src/**/__tests__/`,文件名 `<module>.test.ts`
- **集成测试**: `tests/integration/` — 4 个文件cli-arguments, context-build, message-pipeline, tool-chain
- **共享 mock/fixture**: `tests/mocks/`api-responses, file-system, fixtures/
- **命名**: `describe("functionName")` + `test("behavior description")`,英文
- **Mock 模式**: 对重依赖模块使用 `mock.module()` + `await import()` 解锁(必须内联在测试文件中,不能从共享 helper 导入)
- **包测试**: `packages/` 下各包也有独立测试(如 `color-diff-napi` 11 tests
### 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 必须零错误**。每次修改后运行:

View File

@@ -10,25 +10,25 @@
> 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(踩踩背)...
牢 A (Anthropic) 官方 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI 工具的源码反编译/逆向还原项目。目标是将 Claude Code 大部分功能及工程化能力复现 (问就是老佛爷已经付过钱了)。虽然很难绷, 但是它叫做 CCB(踩踩背)... 而且, 我们实现了企业版或者需要登陆 Claude 账号才能使用的特性, 实现技术普惠
[文档在这里, 支持投稿 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) |
| Remote Control 私有部署 | Docker 自托管 RCS + Web UI | [文档](https://ccb.agent-aura.top/docs/features/remote-control-self-hosting) |
| /dream 记忆整理 | 自动整理和优化记忆文件 | [文档](https://ccb.agent-aura.top/docs/features/auto-dream) |
| Web Search | 内置网页搜索工具 | [文档](https://ccb.agent-aura.top/docs/features/web-browser-tool) |
| 自定义模型供应商 | OpenAI/Anthropic/Gemini/Grok 兼容 | [文档](https://ccb.agent-aura.top/docs/features/custom-platform-login) |
| **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) |
| **Langfuse 监控** | 企业级 Agent 监控, 可以清晰看到每次 agent loop 细节, 可以一键转化为数据集 | [文档](https://ccb.agent-aura.top/docs/features/langfuse-monitoring) |
| **Web Search** | 内置网页搜索工具, 支持 bing 和 brave 搜索 | [文档](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) |
| Voice Mode | Push-to-Talk 语音输入 | [文档](https://ccb.agent-aura.top/docs/features/voice-mode) |
| Computer Use / Chrome Use | 截图、键鼠控制、浏览器操控 | [Computer Use](https://ccb.agent-aura.top/docs/features/computer-use)<br>[Chrome Use](https://ccb.agent-aura.top/docs/features/claude-in-chrome-mcp) |
| Sentry / GrowthBook 企业监控 | 企业级错误追踪与特性开关 | [Sentry](https://ccb.agent-aura.top/docs/internals/sentry-setup)<br>[GrowthBook](https://ccb.agent-aura.top/docs/internals/growthbook-adapter) |
| Langfuse 监控 | LLM 调用/工具执行/多 Agent 全链路追踪 | [文档](https://ccb.agent-aura.top/docs/features/langfuse-monitoring) |
| Poor Mode | 穷鬼模式,关闭记忆提取和键入建议 | /poor 可以开关 |
- 🔮 [ ] V6 — 大规模重构石山代码全面模块分包全新分支main 封存为历史版本)
| 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) |
| Sentry | 企业级错误追踪 | [文档](https://ccb.agent-aura.top/docs/internals/sentry-setup) |
| GrowthBook | 企业级特性开关 | [文档](https://ccb.agent-aura.top/docs/internals/growthbook-adapter) |
| /dream 记忆整理 | 自动整理和优化记忆文件 | [文档](https://ccb.agent-aura.top/docs/features/auto-dream) |
- 🚀 [想要启动项目](#快速开始源码版)
- 🐛 [想要调试项目](#vs-code-调试)

View File

@@ -30,6 +30,8 @@ const DEFAULT_BUILD_FEATURES = [
'ULTRAPLAN',
// P2: daemon + remote control server
'DAEMON',
// ACP (Agent Client Protocol) agent mode
'ACP',
// PR-package restored features
'WORKFLOW_SCRIPTS',
'HISTORY_SNIP',
@@ -40,6 +42,8 @@ const DEFAULT_BUILD_FEATURES = [
'KAIROS',
'COORDINATOR_MODE',
'LAN_PIPES',
'BG_SESSIONS',
'TEMPLATES',
// 'REVIEW_ARTIFACT', // API 请求无响应,需进一步排查 schema 兼容性
// P3: poor mode (disable extract_memories + prompt_suggestion)
'POOR',
@@ -88,8 +92,27 @@ for (const file of files) {
}
}
// Also patch unguarded globalThis.Bun destructuring from third-party deps
// (e.g. @anthropic-ai/sandbox-runtime) so Node.js doesn't crash at import time.
let bunPatched = 0
const BUN_DESTRUCTURE = /var \{([^}]+)\} = globalThis\.Bun;?/g
const BUN_DESTRUCTURE_SAFE = 'var {$1} = typeof globalThis.Bun !== "undefined" ? globalThis.Bun : {};'
for (const file of files) {
if (!file.endsWith('.js')) continue
const filePath = join(outdir, file)
const content = await readFile(filePath, 'utf-8')
if (BUN_DESTRUCTURE.test(content)) {
await writeFile(
filePath,
content.replace(BUN_DESTRUCTURE, BUN_DESTRUCTURE_SAFE),
)
bunPatched++
}
}
BUN_DESTRUCTURE.lastIndex = 0
console.log(
`Bundled ${result.outputs.length} files to ${outdir}/ (patched ${patched} for Node.js compat)`,
`Bundled ${result.outputs.length} files to ${outdir}/ (patched ${patched} for import.meta.require, ${bunPatched} for Bun destructure)`,
)
// Step 4: Copy native .node addon files (audio-capture)
@@ -119,38 +142,7 @@ const cliNode = join(outdir, 'cli-node.js')
await writeFile(cliBun, '#!/usr/bin/env bun\nimport "./cli.js"\n')
// Node.js entry needs a Bun API polyfill because Bun.build({ target: 'bun' })
// emits globalThis.Bun references (e.g. Bun.$ shell tag in computer-use-input,
// Bun.which in chunk-ys6smqg9) that crash at import time under plain Node.js.
const NODE_BUN_POLYFILL = `#!/usr/bin/env node
// Bun API polyfill for Node.js runtime
if (typeof globalThis.Bun === "undefined") {
const { execFileSync } = await import("child_process");
const { resolve, delimiter } = await import("path");
const { accessSync, constants: { X_OK } } = await import("fs");
function which(bin) {
const isWin = process.platform === "win32";
const pathExt = isWin ? (process.env.PATHEXT || ".EXE").split(";") : [""];
for (const dir of (process.env.PATH || "").split(delimiter)) {
for (const ext of pathExt) {
const candidate = resolve(dir, bin + ext);
try { accessSync(candidate, X_OK); return candidate; } catch {}
}
}
return null;
}
// Bun.$ is the shell template tag (e.g. $\`osascript ...\`). Only used by
// computer-use-input/darwin — stub it so the top-level destructuring
// \`var { $ } = globalThis.Bun\` doesn't crash.
function $(parts, ...args) {
throw new Error("Bun.$ shell API is not available in Node.js. Use Bun runtime for this feature.");
}
globalThis.Bun = { which, $ };
}
import "./cli.js"
`
await writeFile(cliNode, NODE_BUN_POLYFILL)
// NOTE: when new Bun-specific globals appear in bundled output, add them here.
await writeFile(cliNode, '#!/usr/bin/env node\nimport "./cli.js"\n')
// Make both executable
const { chmodSync } = await import('fs')

119
bun.lock
View File

@@ -5,7 +5,9 @@
"": {
"name": "claude-code-best",
"dependencies": {
"@claude-code-best/mcp-chrome-bridge": "^2.0.6",
"@agentclientprotocol/sdk": "^0.19.0",
"@claude-code-best/mcp-chrome-bridge": "^2.0.8",
"ws": "^8.20.0",
},
"devDependencies": {
"@alcalzone/ansi-tokenize": "^0.3.0",
@@ -13,6 +15,7 @@
"@ant/computer-use-input": "workspace:*",
"@ant/computer-use-mcp": "workspace:*",
"@ant/computer-use-swift": "workspace:*",
"@ant/model-provider": "workspace:*",
"@anthropic-ai/bedrock-sdk": "^0.26.4",
"@anthropic-ai/claude-agent-sdk": "^0.2.87",
"@anthropic-ai/foundry-sdk": "^0.2.3",
@@ -57,10 +60,11 @@
"@sentry/node": "^10.47.0",
"@smithy/core": "^3.23.13",
"@smithy/node-http-handler": "^4.5.1",
"@types/bun": "^1.3.11",
"@types/bun": "^1.3.12",
"@types/cacache": "^20.0.1",
"@types/he": "^1.2.3",
"@types/lodash-es": "^4.17.12",
"@types/node": "^25.6.0",
"@types/picomatch": "^4.0.3",
"@types/plist": "^3.0.5",
"@types/proper-lockfile": "^4.1.4",
@@ -116,6 +120,7 @@
"react": "^19.2.4",
"react-compiler-runtime": "^1.0.0",
"react-reconciler": "^0.33.0",
"rollup": "^4.60.1",
"semver": "^7.7.4",
"sharp": "^0.34.5",
"shell-quote": "^1.8.3",
@@ -130,11 +135,11 @@
"undici": "^7.24.6",
"url-handler-napi": "workspace:*",
"usehooks-ts": "^3.1.1",
"vite": "^8.0.8",
"vscode-jsonrpc": "^8.2.1",
"vscode-languageserver-protocol": "^3.17.5",
"vscode-languageserver-types": "^3.17.5",
"wrap-ansi": "^10.0.0",
"ws": "^8.20.0",
"xss": "^1.0.15",
"yaml": "^2.8.3",
"zod": "^4.3.6",
@@ -179,6 +184,14 @@
"wrap-ansi": "^10.0.0",
},
},
"packages/@ant/model-provider": {
"name": "@ant/model-provider",
"version": "1.0.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.80.0",
"openai": "^6.33.0",
},
},
"packages/agent-tools": {
"name": "@claude-code-best/agent-tools",
"version": "1.0.0",
@@ -253,6 +266,8 @@
},
},
"packages": {
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.19.0", "https://registry.npmmirror.com/@agentclientprotocol/sdk/-/sdk-0.19.0.tgz", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-U9I8ws9WTOk6jCBAWpXefGSDgVXn14/kV6HFzwWGcstQ02mOQgClMAROHmoIn9GqZbDBDEOkdIbP4P4TEMQdug=="],
"@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.3.0", "https://registry.npmmirror.com/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.3.0.tgz", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA=="],
"@ant/claude-for-chrome-mcp": ["@ant/claude-for-chrome-mcp@workspace:packages/@ant/claude-for-chrome-mcp"],
@@ -263,6 +278,8 @@
"@ant/computer-use-swift": ["@ant/computer-use-swift@workspace:packages/@ant/computer-use-swift"],
"@ant/model-provider": ["@ant/model-provider@workspace:packages/@ant/model-provider"],
"@anthropic-ai/bedrock-sdk": ["@anthropic-ai/bedrock-sdk@0.26.4", "https://registry.npmmirror.com/@anthropic-ai/bedrock-sdk/-/bedrock-sdk-0.26.4.tgz", { "dependencies": { "@anthropic-ai/sdk": ">=0.50.3 <1", "@aws-crypto/sha256-js": "^4.0.0", "@aws-sdk/client-bedrock-runtime": "^3.797.0", "@aws-sdk/credential-providers": "^3.796.0", "@smithy/eventstream-serde-node": "^2.0.10", "@smithy/fetch-http-handler": "^5.0.4", "@smithy/protocol-http": "^3.0.6", "@smithy/signature-v4": "^3.1.1", "@smithy/smithy-client": "^2.1.9", "@smithy/types": "^2.3.4", "@smithy/util-base64": "^2.0.0" } }, "sha512-0Z2NY3T2wnzT9esRit6BiWpQXvL+F2b3z3Z9in3mXh7MDf122rVi2bcPowQHmo9ITXAPJmv/3H3t0V1z3Fugfw=="],
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.104", "https://registry.npmmirror.com/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.104.tgz", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-lVm+nS79r6WWlDnv5AgRzTtAlbP8O6M6kkWmDZAWE3nt9agmngxls9frJFvH55uzws2+6l0yyup/JYspfijkzw=="],
@@ -443,7 +460,7 @@
"@claude-code-best/builtin-tools": ["@claude-code-best/builtin-tools@workspace:packages/builtin-tools"],
"@claude-code-best/mcp-chrome-bridge": ["@claude-code-best/mcp-chrome-bridge@2.0.6", "", { "dependencies": { "@fastify/cors": "^11.0.1", "@modelcontextprotocol/sdk": "^1.11.0", "chalk": "^5.4.1", "chrome-mcp-shared": "^1.0.2", "commander": "^13.1.0", "fastify": "^5.3.2", "is-admin": "^4.0.0", "pino": "^9.6.0", "uuid": "^11.1.0" }, "bin": { "mcp-chrome-bridge": "dist/cli.js", "mcp-chrome-stdio": "dist/mcp/mcp-server-stdio.js" } }, "sha512-eKHXl+prvuNgU6NFti9qpYD1/jnddNiNjNSyLhcvsNhBIn695cZn3KJG65yV+e8OXbjYIf9DeIMGYGVwqrClqA=="],
"@claude-code-best/mcp-chrome-bridge": ["@claude-code-best/mcp-chrome-bridge@2.0.8", "https://registry.npmmirror.com/@claude-code-best/mcp-chrome-bridge/-/mcp-chrome-bridge-2.0.8.tgz", { "dependencies": { "@fastify/cors": "^11.0.1", "@modelcontextprotocol/sdk": "^1.11.0", "chalk": "^5.4.1", "chrome-mcp-shared": "^1.0.2", "commander": "^13.1.0", "fastify": "^5.3.2", "is-admin": "^4.0.0", "pino": "^9.6.0", "uuid": "^11.1.0" }, "bin": { "mcp-chrome-bridge": "dist/cli.js", "mcp-chrome-stdio": "dist/mcp/mcp-server-stdio.js" } }, "sha512-f7J1e4PQ6qxXzdHwL7QRrMZ4lPfD/L1MWxWDbyHmHY7jaW2GL6WcArKpk/fApg3V/q0racqUWzXHQdpE/HJZqg=="],
"@claude-code-best/mcp-client": ["@claude-code-best/mcp-client@workspace:packages/mcp-client"],
@@ -507,21 +524,21 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@fastify/ajv-compiler": ["@fastify/ajv-compiler@4.0.5", "", { "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0" } }, "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A=="],
"@fastify/ajv-compiler": ["@fastify/ajv-compiler@4.0.5", "https://registry.npmmirror.com/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", { "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0" } }, "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A=="],
"@fastify/cors": ["@fastify/cors@11.2.0", "", { "dependencies": { "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw=="],
"@fastify/cors": ["@fastify/cors@11.2.0", "https://registry.npmmirror.com/@fastify/cors/-/cors-11.2.0.tgz", { "dependencies": { "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw=="],
"@fastify/error": ["@fastify/error@4.2.0", "", {}, "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ=="],
"@fastify/error": ["@fastify/error@4.2.0", "https://registry.npmmirror.com/@fastify/error/-/error-4.2.0.tgz", {}, "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ=="],
"@fastify/fast-json-stringify-compiler": ["@fastify/fast-json-stringify-compiler@5.0.3", "", { "dependencies": { "fast-json-stringify": "^6.0.0" } }, "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ=="],
"@fastify/fast-json-stringify-compiler": ["@fastify/fast-json-stringify-compiler@5.0.3", "https://registry.npmmirror.com/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", { "dependencies": { "fast-json-stringify": "^6.0.0" } }, "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ=="],
"@fastify/forwarded": ["@fastify/forwarded@3.0.1", "", {}, "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw=="],
"@fastify/forwarded": ["@fastify/forwarded@3.0.1", "https://registry.npmmirror.com/@fastify/forwarded/-/forwarded-3.0.1.tgz", {}, "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw=="],
"@fastify/merge-json-schemas": ["@fastify/merge-json-schemas@0.2.1", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A=="],
"@fastify/merge-json-schemas": ["@fastify/merge-json-schemas@0.2.1", "https://registry.npmmirror.com/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A=="],
"@fastify/otel": ["@fastify/otel@0.18.0", "https://registry.npmmirror.com/@fastify/otel/-/otel-0.18.0.tgz", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.212.0", "@opentelemetry/semantic-conventions": "^1.28.0", "minimatch": "^10.2.4" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0" } }, "sha512-3TASCATfw+ctICSb4ymrv7iCm0qJ0N9CarB+CZ7zIJ7KqNbwI5JjyDL1/sxoC0ccTO1Zyd1iQ+oqncPg5FJXaA=="],
"@fastify/proxy-addr": ["@fastify/proxy-addr@5.1.0", "", { "dependencies": { "@fastify/forwarded": "^3.0.0", "ipaddr.js": "^2.1.0" } }, "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw=="],
"@fastify/proxy-addr": ["@fastify/proxy-addr@5.1.0", "https://registry.npmmirror.com/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", { "dependencies": { "@fastify/forwarded": "^3.0.0", "ipaddr.js": "^2.1.0" } }, "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw=="],
"@growthbook/growthbook": ["@growthbook/growthbook@1.6.5", "https://registry.npmmirror.com/@growthbook/growthbook/-/growthbook-1.6.5.tgz", { "dependencies": { "dom-mutator": "^0.6.0" } }, "sha512-mUaMsgeUTpRIUOTn33EUXHRK6j7pxBjwqH4WpQyq+pukjd1AIzWlEa6w7i6bInJUcweGgP2beXZmaP6b6UPn7A=="],
@@ -841,6 +858,36 @@
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "https://registry.npmmirror.com/@protobufjs/utf8/-/utf8-1.1.0.tgz", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", { "os": "android", "cpu": "arm64" }, "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", { "os": "linux", "cpu": "arm" }, "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", { "os": "linux", "cpu": "x64" }, "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", { "os": "linux", "cpu": "x64" }, "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", { "os": "none", "cpu": "arm64" }, "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.3" }, "cpu": "none" }, "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", { "os": "win32", "cpu": "x64" }, "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="],
@@ -1099,7 +1146,7 @@
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.12", "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.12.tgz", {}, "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg=="],
"abstract-logging": ["abstract-logging@2.0.1", "", {}, "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="],
"abstract-logging": ["abstract-logging@2.0.1", "https://registry.npmmirror.com/abstract-logging/-/abstract-logging-2.0.1.tgz", {}, "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="],
"accepts": ["accepts@2.0.0", "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
@@ -1133,7 +1180,7 @@
"auto-bind": ["auto-bind@5.0.1", "https://registry.npmmirror.com/auto-bind/-/auto-bind-5.0.1.tgz", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="],
"avvio": ["avvio@9.2.0", "", { "dependencies": { "@fastify/error": "^4.0.0", "fastq": "^1.17.1" } }, "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ=="],
"avvio": ["avvio@9.2.0", "https://registry.npmmirror.com/avvio/-/avvio-9.2.0.tgz", { "dependencies": { "@fastify/error": "^4.0.0", "fastq": "^1.17.1" } }, "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ=="],
"axios": ["axios@1.15.0", "https://registry.npmmirror.com/axios/-/axios-1.15.0.tgz", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q=="],
@@ -1247,7 +1294,7 @@
"depd": ["depd@2.0.0", "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"dequal": ["dequal@2.0.3", "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"detect-libc": ["detect-libc@2.1.2", "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@@ -1305,15 +1352,15 @@
"external-editor": ["external-editor@3.1.0", "https://registry.npmmirror.com/external-editor/-/external-editor-3.1.0.tgz", { "dependencies": { "chardet": "^0.7.0", "iconv-lite": "^0.4.24", "tmp": "^0.0.33" } }, "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew=="],
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "https://registry.npmmirror.com/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-glob": ["fast-glob@3.3.3", "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fast-json-stringify": ["fast-json-stringify@6.3.0", "", { "dependencies": { "@fastify/merge-json-schemas": "^0.2.0", "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0", "json-schema-ref-resolver": "^3.0.0", "rfdc": "^1.2.0" } }, "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA=="],
"fast-json-stringify": ["fast-json-stringify@6.3.0", "https://registry.npmmirror.com/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz", { "dependencies": { "@fastify/merge-json-schemas": "^0.2.0", "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0", "json-schema-ref-resolver": "^3.0.0", "rfdc": "^1.2.0" } }, "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA=="],
"fast-querystring": ["fast-querystring@1.1.2", "", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="],
"fast-querystring": ["fast-querystring@1.1.2", "https://registry.npmmirror.com/fast-querystring/-/fast-querystring-1.1.2.tgz", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="],
"fast-uri": ["fast-uri@3.1.0", "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.0.tgz", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
@@ -1321,9 +1368,9 @@
"fast-xml-parser": ["fast-xml-parser@5.5.8", "https://registry.npmmirror.com/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="],
"fastify": ["fastify@5.8.4", "", { "dependencies": { "@fastify/ajv-compiler": "^4.0.5", "@fastify/error": "^4.0.0", "@fastify/fast-json-stringify-compiler": "^5.0.0", "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", "avvio": "^9.0.0", "fast-json-stringify": "^6.0.0", "find-my-way": "^9.0.0", "light-my-request": "^6.0.0", "pino": "^9.14.0 || ^10.1.0", "process-warning": "^5.0.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", "semver": "^7.6.0", "toad-cache": "^3.7.0" } }, "sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ=="],
"fastify": ["fastify@5.8.4", "https://registry.npmmirror.com/fastify/-/fastify-5.8.4.tgz", { "dependencies": { "@fastify/ajv-compiler": "^4.0.5", "@fastify/error": "^4.0.0", "@fastify/fast-json-stringify-compiler": "^5.0.0", "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", "avvio": "^9.0.0", "fast-json-stringify": "^6.0.0", "find-my-way": "^9.0.0", "light-my-request": "^6.0.0", "pino": "^9.14.0 || ^10.1.0", "process-warning": "^5.0.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", "semver": "^7.6.0", "toad-cache": "^3.7.0" } }, "sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ=="],
"fastify-plugin": ["fastify-plugin@5.1.0", "", {}, "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw=="],
"fastify-plugin": ["fastify-plugin@5.1.0", "https://registry.npmmirror.com/fastify-plugin/-/fastify-plugin-5.1.0.tgz", {}, "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw=="],
"fastq": ["fastq@1.20.1", "https://registry.npmmirror.com/fastq/-/fastq-1.20.1.tgz", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
@@ -1341,7 +1388,7 @@
"finalhandler": ["finalhandler@2.1.1", "https://registry.npmmirror.com/finalhandler/-/finalhandler-2.1.1.tgz", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"find-my-way": ["find-my-way@9.5.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ=="],
"find-my-way": ["find-my-way@9.5.0", "https://registry.npmmirror.com/find-my-way/-/find-my-way-9.5.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ=="],
"find-up": ["find-up@4.1.0", "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
@@ -1483,7 +1530,7 @@
"json-bigint": ["json-bigint@1.0.0", "https://registry.npmmirror.com/json-bigint/-/json-bigint-1.0.0.tgz", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
"json-schema-ref-resolver": ["json-schema-ref-resolver@3.0.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A=="],
"json-schema-ref-resolver": ["json-schema-ref-resolver@3.0.0", "https://registry.npmmirror.com/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A=="],
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "https://registry.npmmirror.com/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
@@ -1505,7 +1552,7 @@
"knip": ["knip@6.4.0", "https://registry.npmmirror.com/knip/-/knip-6.4.0.tgz", { "dependencies": { "@nodelib/fs.walk": "^1.2.3", "fast-glob": "^3.3.3", "formatly": "^0.3.0", "get-tsconfig": "4.13.7", "jiti": "^2.6.0", "minimist": "^1.2.8", "oxc-parser": "^0.121.0", "oxc-resolver": "^11.19.1", "picocolors": "^1.1.1", "picomatch": "^4.0.1", "smol-toml": "^1.6.1", "strip-json-comments": "5.0.3", "unbash": "^2.2.0", "yaml": "^2.8.2", "zod": "^4.1.11" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-SAEeggehgkPdoLZWVEcFKzPw+vNlnrUBDqcX8cOcHGydRInSn5pnn9LN3dDJ8SkDHKXR7xYzNq3HtRJaYmxOHg=="],
"light-my-request": ["light-my-request@6.6.0", "", { "dependencies": { "cookie": "^1.0.1", "process-warning": "^4.0.0", "set-cookie-parser": "^2.6.0" } }, "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A=="],
"light-my-request": ["light-my-request@6.6.0", "https://registry.npmmirror.com/light-my-request/-/light-my-request-6.6.0.tgz", { "dependencies": { "cookie": "^1.0.1", "process-warning": "^4.0.0", "set-cookie-parser": "^2.6.0" } }, "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A=="],
"lightningcss": ["lightningcss@1.32.0", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
@@ -1745,13 +1792,15 @@
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "https://registry.npmmirror.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="],
"ret": ["ret@0.5.0", "https://registry.npmmirror.com/ret/-/ret-0.5.0.tgz", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="],
"retry": ["retry@0.12.0", "https://registry.npmmirror.com/retry/-/retry-0.12.0.tgz", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
"reusify": ["reusify@1.1.0", "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
"rfdc": ["rfdc@1.4.1", "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
"rolldown": ["rolldown@1.0.0-rc.15", "https://registry.npmmirror.com/rolldown/-/rolldown-1.0.0-rc.15.tgz", { "dependencies": { "@oxc-project/types": "=0.124.0", "@rolldown/pluginutils": "1.0.0-rc.15" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-x64": "1.0.0-rc.15", "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g=="],
"rollup": ["rollup@4.60.1", "https://registry.npmmirror.com/rollup/-/rollup-4.60.1.tgz", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="],
@@ -1763,7 +1812,7 @@
"safe-buffer": ["safe-buffer@5.2.1", "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safe-regex2": ["safe-regex2@5.1.0", "", { "dependencies": { "ret": "~0.5.0" }, "bin": { "safe-regex2": "bin/safe-regex2.js" } }, "sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw=="],
"safe-regex2": ["safe-regex2@5.1.0", "https://registry.npmmirror.com/safe-regex2/-/safe-regex2-5.1.0.tgz", { "dependencies": { "ret": "~0.5.0" }, "bin": { "safe-regex2": "bin/safe-regex2.js" } }, "sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw=="],
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "https://registry.npmmirror.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
@@ -1771,7 +1820,7 @@
"scheduler": ["scheduler@0.27.0", "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="],
"secure-json-parse": ["secure-json-parse@4.1.0", "https://registry.npmmirror.com/secure-json-parse/-/secure-json-parse-4.1.0.tgz", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="],
"semver": ["semver@7.7.4", "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
@@ -1781,7 +1830,7 @@
"set-blocking": ["set-blocking@2.0.0", "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
"set-cookie-parser": ["set-cookie-parser@2.7.2", "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
"setprototypeof": ["setprototypeof@1.2.0", "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
@@ -1851,7 +1900,7 @@
"to-regex-range": ["to-regex-range@5.0.1", "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"toad-cache": ["toad-cache@3.7.0", "", {}, "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw=="],
"toad-cache": ["toad-cache@3.7.0", "https://registry.npmmirror.com/toad-cache/-/toad-cache-3.7.0.tgz", {}, "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw=="],
"toidentifier": ["toidentifier@1.0.1", "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
@@ -1893,7 +1942,7 @@
"vary": ["vary@1.1.2", "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"vite": ["vite@6.4.2", "https://registry.npmmirror.com/vite/-/vite-6.4.2.tgz", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],
"vite": ["vite@8.0.8", "https://registry.npmmirror.com/vite/-/vite-8.0.8.tgz", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw=="],
"vscode-jsonrpc": ["vscode-jsonrpc@8.2.1", "https://registry.npmmirror.com/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", {}, "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ=="],
@@ -1961,6 +2010,8 @@
"@anthropic/remote-control-server/typescript": ["typescript@5.9.3", "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"@anthropic/remote-control-server/vite": ["vite@6.4.2", "https://registry.npmmirror.com/vite/-/vite-6.4.2.tgz", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],
"@aws-crypto/crc32/@aws-crypto/util": ["@aws-crypto/util@5.2.0", "https://registry.npmmirror.com/@aws-crypto/util/-/util-5.2.0.tgz", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
"@aws-crypto/sha256-browser/@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "https://registry.npmmirror.com/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="],
@@ -2127,7 +2178,7 @@
"@fastify/otel/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.212.0", "https://registry.npmmirror.com/@opentelemetry/instrumentation/-/instrumentation-0.212.0.tgz", { "dependencies": { "@opentelemetry/api-logs": "0.212.0", "import-in-the-middle": "^2.0.6", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg=="],
"@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
"@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-2.3.0.tgz", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
"@grpc/proto-loader/yargs": ["yargs@17.7.2", "https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
@@ -2295,9 +2346,9 @@
"is-admin/execa": ["execa@5.1.1", "https://registry.npmmirror.com/execa/-/execa-5.1.1.tgz", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
"light-my-request/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"light-my-request/cookie": ["cookie@1.1.1", "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"light-my-request/process-warning": ["process-warning@4.0.1", "", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="],
"light-my-request/process-warning": ["process-warning@4.0.1", "https://registry.npmmirror.com/process-warning/-/process-warning-4.0.1.tgz", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="],
"micromatch/picomatch": ["picomatch@2.3.2", "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
@@ -2313,6 +2364,10 @@
"qrcode/yargs": ["yargs@15.4.1", "https://registry.npmmirror.com/yargs/-/yargs-15.4.1.tgz", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
"rolldown/@oxc-project/types": ["@oxc-project/types@0.124.0", "https://registry.npmmirror.com/@oxc-project/types/-/types-0.124.0.tgz", {}, "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg=="],
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="],
"vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "https://registry.npmmirror.com/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="],
"xss/commander": ["commander@2.20.3", "https://registry.npmmirror.com/commander/-/commander-2.20.3.tgz", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

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

View File

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

189
docs/features/acp-zed.md Normal file
View File

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

View File

@@ -0,0 +1,30 @@
# Chrome Use — 浏览器自动化快速指南
让 Claude Code 直接控制你的 Chrome 浏览器,用自然语言完成网页操作。
## 快速开始3 分钟)
### 第一步:安装 Chrome 扩展
1. 下载扩展https://github.com/hangwin/mcp-chrome/releases
2. 解压 zip 文件
3. 打开 Chrome 访问 `chrome://extensions/`
4. 开启右上角「开发者模式」
5. 点击「加载已解压的扩展程序」,选择解压后的文件夹
### 第二步:启动 Claude Code
```bash
bun run dev
ccb # 或者 ccb 安装版也行
```
### 第三步:启用 Chrome MCP
1. 在 REPL 中输入 `/mcp` 打开 MCP 面板
2. 找到 `mcp-chrome`,按空格键启用
3. 按 Enter 确认
## 相关文档
- GitHub 仓库https://github.com/hangwin/mcp-chrome

View File

@@ -0,0 +1,318 @@
# 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 是唯一跨终端方案。

View File

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

View File

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

View File

@@ -138,13 +138,19 @@ bun run dist/cli.js
/remote-control
```
CLI 会向 RCS 注册环境,注册成功后在终端显示连接 URL
环境型 Remote Control例如 `claude remote-control` 子命令)会向 RCS 注册环境,注册成功后在终端显示连接 URL
```
https://rcs.example.com/code?bridge=<environmentId>
```
同时支持 QR 码扫码打开。该 URL 即 Web UI 控制面板入口,在浏览器中打开即可远程操控当前会话。
交互式 REPL 方式(`--remote-control``/remote-control`)在某些桥接模式下也可能直接给出会话 URL
```
https://rcs.example.com/code/session_<id>
```
两种 URL 都可以直接在浏览器打开并远程操控当前会话;只有 environment 模式才会出现在 Web UI 的环境列表中。
若已连接,再次执行 `/remote-control` 会显示对话框,包含以下选项:
- **Disconnect this session** — 断开远程连接
@@ -165,9 +171,11 @@ claude bridge
通过 `/remote-control` 命令获取 URL 后,在浏览器打开即可使用。功能:
- 查看已注册的运行环境
- 查看已注册的运行环境environment 模式)
- 创建和管理会话
- 实时查看对话消息和工具调用
- 查看 Autopilot 状态(`standby` / `sleeping`)和自动运行指示
- 查看 authoritative task snapshots 驱动的 Tasks 面板
- 审批 Claude Code 的工具权限请求
Web UI 使用 UUID 认证(无需用户账户),适合受信任网络环境。
@@ -209,6 +217,7 @@ Web UI 使用 UUID 认证(无需用户账户),适合受信任网络环境
9. 双向通信
CLI ──消息/工具调用结果──► RCS ──► Browser
CLI ◄──权限审批/指令───── RCS ◄──── Browser
CLI ──automation_state / task_state──► RCS ──► Browser
10. 心跳保活(每 20 秒)
CLI ──POST /v1/environments/:id/work/:workId/heartbeat──► RCS
@@ -218,6 +227,13 @@ Web UI 使用 UUID 认证(无需用户账户),适合受信任网络环境
## 故障排查
### Web UI 看不到当前 Autopilot 状态
- `standby`proactive 已开启,正在等待下一个 tick
- `sleeping`:模型正在 `SleepTool` 等待窗口中
这两个状态通过 worker `external_metadata.automation_state` 上报。如果页面只显示普通 working spinner优先检查 CLI 和 RCS 之间的 worker metadata PUT 是否成功。
### CLI 无法连接
```
@@ -275,4 +291,3 @@ curl https://rcs.example.com/health
| 依赖 | claude.ai 订阅 + OAuth | 仅需 API Key |
自托管模式的核心优势是:设置 `CLAUDE_BRIDGE_BASE_URL` 后,代码自动调用 `isSelfHostedBridge()` 返回 `true`,跳过所有 GrowthBook 和订阅检查,无需 claude.ai 账户即可使用。

View File

@@ -0,0 +1,310 @@
# 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 2AMVP
- 实现 `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 4AMVP
- 只支持 `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”拆开恢复。

View File

@@ -0,0 +1,77 @@
# 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不处理多实例并发
## 依赖
无外部依赖,可独立实施。

View File

@@ -0,0 +1,80 @@
# 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 状态管理可复用模式,但非硬性依赖)

View File

@@ -0,0 +1,87 @@
# 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",范围会明显膨胀
## 依赖
无硬性依赖,可独立实施。

View File

@@ -0,0 +1,103 @@
# 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` (无参数) — 返回明确提示: 当前版本需要显式 sessionIddiscovery 尚未启用
**验证:**
- [ ] `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 模式可复用

View File

@@ -0,0 +1,196 @@
# 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 通过

View File

@@ -0,0 +1,275 @@
# 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 通过

View File

@@ -0,0 +1,177 @@
# 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 通过

View File

@@ -0,0 +1,123 @@
# 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 上验证关键路径

View File

@@ -0,0 +1,88 @@
# 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.

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,16 +37,21 @@
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { randomUUID } from "node:crypto";
/** Detect actual image MIME type from base64 data using magic bytes. */
/** Detect actual image MIME type from base64 data by decoding the magic bytes. */
function detectMimeFromBase64(b64: string): string {
// First byte is enough to distinguish PNG (0x89) from JPEG (0xFF)
const c = b64.charCodeAt(0);
if (c === 0x89) return "image/png";
if (c === 0xFF) return "image/jpeg";
// RIFF = WebP
if (c === 0x52) return "image/webp";
// GIF
if (c === 0x47) return "image/gif";
// Decode first 12 raw bytes (16 base64 chars is enough) and check standard magic bytes.
// PNG: 89 50 4E 47
// JPEG: FF D8 FF
// RIFF+WEBP: "RIFF" at 0..3 + "WEBP" at 8..11
// GIF: "GIF" at 0..2
const raw = Buffer.from(b64.slice(0, 16), "base64");
if (raw[0] === 0x89 && raw[1] === 0x50 && raw[2] === 0x4e && raw[3] === 0x47) return "image/png";
if (raw[0] === 0xff && raw[1] === 0xd8 && raw[2] === 0xff) return "image/jpeg";
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";
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test'
import type {
AssistantMessage,
UserMessage,
} from '../../../../types/message.js'
} from '../../../types/message.js'
import { anthropicMessagesToGemini } from '../convertMessages.js'
function makeUserMsg(content: string | any[]): UserMessage {

View File

@@ -2,9 +2,8 @@ import type {
BetaToolResultBlockParam,
BetaToolUseBlock,
} from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
import type { AssistantMessage, UserMessage } from '../../../types/message.js'
import { safeParseJSON } from '../../../utils/json.js'
import type { SystemPrompt } from '../../../utils/systemPromptType.js'
import type { AssistantMessage, UserMessage } from '../../types/message.js'
import type { SystemPrompt } from '../../types/systemPrompt.js'
import {
GEMINI_THOUGHT_SIGNATURE_FIELD,
type GeminiContent,
@@ -12,6 +11,16 @@ import {
type GeminiPart,
} from './types.js'
// Simple JSON parse utility (replaces safeParseJSON from main project)
function safeParseJSON(json: string | null | undefined): unknown {
if (!json) return null
try {
return JSON.parse(json)
} catch {
return null
}
}
export function anthropicMessagesToGemini(
messages: (UserMessage | AssistantMessage)[],
systemPrompt: SystemPrompt,
@@ -113,7 +122,7 @@ function convertUserContentBlockToGeminiParts(
]
}
// Anthropic image 块转换为 Gemini inlineData
// Convert Anthropic image blocks to Gemini inlineData
if (block.type === 'image') {
const source = block.source as Record<string, unknown> | undefined
if (source?.type === 'base64' && typeof source.data === 'string') {
@@ -127,7 +136,7 @@ function convertUserContentBlockToGeminiParts(
},
]
}
// url 类型的图片Gemini 不直接支持,转为文本描述
// URL images not directly supported by Gemini, convert to text description
if (source?.type === 'url' && typeof source.url === 'string') {
return createTextGeminiParts(`[image: ${source.url}]`)
}

View File

@@ -17,14 +17,12 @@ export function resolveGeminiModel(anthropicModel: string): string {
return cleanModel
}
// First, try Gemini-specific DEFAULT variables (separated from Anthropic)
const geminiEnvVar = `GEMINI_DEFAULT_${family.toUpperCase()}_MODEL`
const geminiModel = process.env[geminiEnvVar]
if (geminiModel) {
return geminiModel
}
// Fallback to Anthropic DEFAULT variables for backward compatibility
const sharedEnvVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL`
const resolvedModel = process.env[sharedEnvVar]
if (resolvedModel) {

View File

@@ -2,8 +2,7 @@
* Default mapping from Anthropic model names to Grok model names.
*
* Users can override per-family via GROK_DEFAULT_{FAMILY}_MODEL env vars,
* or override the entire mapping via GROK_MODEL_MAP env var (JSON string):
* GROK_MODEL_MAP='{"opus":"grok-4","sonnet":"grok-3","haiku":"grok-3-mini-fast"}'
* or override the entire mapping via GROK_MODEL_MAP env var (JSON string).
*/
const DEFAULT_MODEL_MAP: Record<string, string> = {
'claude-sonnet-4-20250514': 'grok-3-mini-fast',
@@ -19,9 +18,6 @@ const DEFAULT_MODEL_MAP: Record<string, string> = {
'claude-3-5-sonnet-20241022': 'grok-3-mini-fast',
}
/**
* Family-level mapping defaults (used by GROK_MODEL_MAP).
*/
const DEFAULT_FAMILY_MAP: Record<string, string> = {
opus: 'grok-4.20-reasoning',
sonnet: 'grok-3-mini-fast',
@@ -35,10 +31,6 @@ function getModelFamily(model: string): 'haiku' | 'sonnet' | 'opus' | null {
return null
}
/**
* Parse user-provided model map from GROK_MODEL_MAP env var.
* Accepts JSON like: {"opus":"grok-4","sonnet":"grok-3","haiku":"grok-3-mini-fast"}
*/
function getUserModelMap(): Record<string, string> | null {
const raw = process.env.GROK_MODEL_MAP
if (!raw) return null
@@ -55,18 +47,8 @@ function getUserModelMap(): Record<string, string> | null {
/**
* Resolve the Grok model name for a given Anthropic model.
*
* Priority:
* 1. GROK_MODEL env var (override all)
* 2. GROK_MODEL_MAP env var JSON family map (e.g. {"opus":"grok-4"})
* 3. GROK_DEFAULT_{FAMILY}_MODEL env var (e.g. GROK_DEFAULT_OPUS_MODEL)
* 4. ANTHROPIC_DEFAULT_{FAMILY}_MODEL env var (backward compat)
* 5. DEFAULT_MODEL_MAP lookup
* 6. Family-level default
* 7. Pass through original model name
*/
export function resolveGrokModel(anthropicModel: string): string {
// 1. Global override
if (process.env.GROK_MODEL) {
return process.env.GROK_MODEL
}
@@ -74,34 +56,28 @@ export function resolveGrokModel(anthropicModel: string): string {
const cleanModel = anthropicModel.replace(/\[1m\]$/, '')
const family = getModelFamily(cleanModel)
// 2. User-provided model map
const userMap = getUserModelMap()
if (userMap && family && userMap[family]) {
return userMap[family]
}
if (family) {
// 3. Grok-specific family override
const grokEnvVar = `GROK_DEFAULT_${family.toUpperCase()}_MODEL`
const grokOverride = process.env[grokEnvVar]
if (grokOverride) return grokOverride
// 4. Anthropic env var (backward compat)
const anthropicEnvVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL`
const anthropicOverride = process.env[anthropicEnvVar]
if (anthropicOverride) return anthropicOverride
}
// 5. Exact model name lookup
if (DEFAULT_MODEL_MAP[cleanModel]) {
return DEFAULT_MODEL_MAP[cleanModel]
}
// 6. Family-level default
if (family && DEFAULT_FAMILY_MAP[family]) {
return DEFAULT_FAMILY_MAP[family]
}
// 7. Pass through
return cleanModel
}

View File

@@ -16,9 +16,6 @@ const DEFAULT_MODEL_MAP: Record<string, string> = {
'claude-3-5-sonnet-20241022': 'gpt-4o',
}
/**
* Determine the model family (haiku / sonnet / opus) from an Anthropic model ID.
*/
function getModelFamily(model: string): 'haiku' | 'sonnet' | 'opus' | null {
if (/haiku/i.test(model)) return 'haiku'
if (/opus/i.test(model)) return 'opus'
@@ -37,23 +34,18 @@ function getModelFamily(model: string): 'haiku' | 'sonnet' | 'opus' | null {
* 5. Pass through original model name
*/
export function resolveOpenAIModel(anthropicModel: string): string {
// Highest priority: explicit override
if (process.env.OPENAI_MODEL) {
return process.env.OPENAI_MODEL
}
// Strip [1m] suffix if present (Claude-specific modifier)
const cleanModel = anthropicModel.replace(/\[1m\]$/, '')
// Check family-specific overrides
const family = getModelFamily(cleanModel)
if (family) {
// OpenAI-specific family override (preferred for openai provider)
const openaiEnvVar = `OPENAI_DEFAULT_${family.toUpperCase()}_MODEL`
const openaiOverride = process.env[openaiEnvVar]
if (openaiOverride) return openaiOverride
// Anthropic env var (backward compatibility)
const anthropicEnvVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL`
const anthropicOverride = process.env[anthropicEnvVar]
if (anthropicOverride) return anthropicOverride

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from 'bun:test'
import { anthropicMessagesToOpenAI } from '../convertMessages.js'
import type { UserMessage, AssistantMessage } from '../../../../types/message.js'
import { anthropicMessagesToOpenAI } from '../openaiConvertMessages.js'
import type { UserMessage, AssistantMessage } from '../../types/message.js'
// Helpers to create internal-format messages
function makeUserMsg(content: string | any[]): UserMessage {
@@ -396,10 +396,6 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
{ enableThinking: true },
)
// All 3 assistant messages are in the current turn (after last user msg is the last tool_result,
// but the "last user message" boundary logic finds the last user-typed message).
// Actually, tool_result messages are also UserMessage type, so the last user message
// is the one with tool_result for toolu_002. All assistant messages after that should have reasoning.
const assistants = result.filter(m => m.role === 'assistant')
expect(assistants.length).toBe(3)
// All iterations within the same turn preserve reasoning
@@ -435,6 +431,54 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
expect(assistant.reasoning_content).toBeUndefined()
})
// ── fix: reorder tool and user messages for OpenAI API compatibility (#168) ──
test('tool messages come BEFORE user text when mixed in same turn', () => {
// OpenAI requires: assistant(tool_calls) → tool → user
// Bug: previously user text was emitted before tool messages
const result = anthropicMessagesToOpenAI(
[
makeUserMsg('run ls'),
makeAssistantMsg([
{ type: 'tool_use' as const, id: 'toolu_1', name: 'bash', input: { command: 'ls' } },
]),
makeUserMsg([
{ type: 'tool_result' as const, tool_use_id: 'toolu_1', content: 'file.txt' },
{ type: 'text' as const, text: 'looks good' },
]),
],
[] as any,
)
// Find the tool message and the user text message
const toolIdx = result.findIndex(m => m.role === 'tool')
const userTextIdx = result.findIndex(
m => m.role === 'user' && typeof m.content === 'string' && m.content.includes('looks good'),
)
expect(toolIdx).toBeGreaterThanOrEqual(0)
expect(userTextIdx).toBeGreaterThanOrEqual(0)
// Tool MUST come before user text
expect(toolIdx).toBeLessThan(userTextIdx)
})
test('tool message immediately follows assistant tool_calls (no user message in between)', () => {
const result = anthropicMessagesToOpenAI(
[
makeUserMsg('do something'),
makeAssistantMsg([
{ type: 'tool_use' as const, id: 'toolu_2', name: 'bash', input: { command: 'pwd' } },
]),
makeUserMsg([
{ type: 'tool_result' as const, tool_use_id: 'toolu_2', content: '/home/user' },
]),
],
[] as any,
)
const assistantIdx = result.findIndex(m => m.role === 'assistant' && (m as any).tool_calls)
const toolIdx = result.findIndex(m => m.role === 'tool')
expect(assistantIdx).toBeGreaterThanOrEqual(0)
expect(toolIdx).toBe(assistantIdx + 1)
})
test('sets content to null when only thinking and tool_calls present', () => {
const result = anthropicMessagesToOpenAI(
[makeUserMsg('question'), makeAssistantMsg([

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test'
import { anthropicToolsToOpenAI, anthropicToolChoiceToOpenAI } from '../convertTools.js'
import { anthropicToolsToOpenAI, anthropicToolChoiceToOpenAI } from '../openaiConvertTools.js'
describe('anthropicToolsToOpenAI', () => {
test('converts basic tool', () => {

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from 'bun:test'
import { adaptOpenAIStreamToAnthropic } from '../streamAdapter.js'
import type { ChatCompletionChunk } from 'openai/resources/chat/completions/completions.mjs'
import { adaptOpenAIStreamToAnthropic } from '../openaiStreamAdapter.js'
/** Helper to create a mock async iterable from chunk array */
function mockStream(chunks: ChatCompletionChunk[]): AsyncIterable<ChatCompletionChunk> {

View File

@@ -10,8 +10,8 @@ import type {
ChatCompletionToolMessageParam,
ChatCompletionUserMessageParam,
} from 'openai/resources/chat/completions/completions.mjs'
import type { AssistantMessage, UserMessage } from '../../../types/message.js'
import type { SystemPrompt } from '../../../utils/systemPromptType.js'
import type { AssistantMessage, UserMessage } from '../types/message.js'
import type { SystemPrompt } from '../types/systemPrompt.js'
export interface ConvertMessagesOptions {
/** When true, preserve thinking blocks as reasoning_content on assistant messages
@@ -152,7 +152,6 @@ function convertInternalUserMessage(
// OpenAI API requires that a tool message immediately follows the assistant
// message with tool_calls. If we emit a user message first, the API will
// reject the request with "insufficient tool messages following tool_calls".
// See: https://github.com/anthropics/claude-code/issues/xxx
for (const tr of toolResults) {
result.push(convertToolResult(tr))
}

View File

@@ -51,10 +51,6 @@ export async function* adaptOpenAIStreamToAnthropic(
let textBlockOpen = false
// Track usage — all four Anthropic fields, populated from OpenAI usage fields:
// prompt_tokens → input_tokens
// completion_tokens → output_tokens
// prompt_tokens_details.cached_tokens → cache_read_input_tokens
// (no standard OpenAI equivalent) → cache_creation_input_tokens (always 0)
let inputTokens = 0
let outputTokens = 0
let cachedReadTokens = 0
@@ -62,10 +58,7 @@ export async function* adaptOpenAIStreamToAnthropic(
// Track all open content block indices (for cleanup)
const openBlockIndices = new Set<number>()
// Deferred finish state: populated when finish_reason is encountered so that
// message_delta / message_stop are emitted AFTER the stream loop ends.
// This ensures usage chunks that arrive after the finish_reason chunk are
// captured before we emit the final token counts.
// Deferred finish state
let pendingFinishReason: string | null = null
let pendingHasToolCalls = false
@@ -74,16 +67,9 @@ export async function* adaptOpenAIStreamToAnthropic(
const delta = choice?.delta
// Extract usage from any chunk that carries it.
// Many OpenAI-compatible endpoints (e.g. DeepSeek) send usage in a separate
// final chunk that arrives AFTER the finish_reason chunk. Reading it here
// (before emitting message_delta) ensures the token counts are available
// when we later emit message_delta.
if (chunk.usage) {
inputTokens = chunk.usage.prompt_tokens ?? inputTokens
outputTokens = chunk.usage.completion_tokens ?? outputTokens
// OpenAI prompt caching: prompt_tokens_details.cached_tokens
// → Anthropic cache_read_input_tokens
// Note: OpenAI has no equivalent for cache_creation_input_tokens.
const details = (chunk.usage as any).prompt_tokens_details
if (details?.cached_tokens != null) {
cachedReadTokens = details.cached_tokens
@@ -118,7 +104,6 @@ export async function* adaptOpenAIStreamToAnthropic(
if (!delta) continue
// Handle reasoning_content → Anthropic thinking block
// DeepSeek and compatible providers send delta.reasoning_content
const reasoningContent = (delta as any).reasoning_content
if (reasoningContent != null && reasoningContent !== '') {
if (!thinkingBlockOpen) {
@@ -150,7 +135,7 @@ export async function* adaptOpenAIStreamToAnthropic(
// Handle text content
if (delta.content != null && delta.content !== '') {
if (!textBlockOpen) {
// Close thinking block if still open (reasoning done, now generating answer)
// Close thinking block if still open
if (thinkingBlockOpen) {
yield {
type: 'content_block_stop',
@@ -251,12 +236,8 @@ export async function* adaptOpenAIStreamToAnthropic(
}
}
// Handle finish: close all open content blocks and record the finish_reason.
// message_delta + message_stop are emitted AFTER the stream loop so that any
// trailing usage chunk (sent after the finish chunk by some endpoints)
// is captured first — ensuring token counts are non-zero.
// Handle finish
if (choice?.finish_reason) {
// Close thinking block if still open
if (thinkingBlockOpen) {
yield {
type: 'content_block_stop',
@@ -266,7 +247,6 @@ export async function* adaptOpenAIStreamToAnthropic(
thinkingBlockOpen = false
}
// Close text block if still open
if (textBlockOpen) {
yield {
type: 'content_block_stop',
@@ -276,7 +256,6 @@ export async function* adaptOpenAIStreamToAnthropic(
textBlockOpen = false
}
// Close all tool blocks that haven't been closed yet
for (const [, block] of toolBlocks) {
if (openBlockIndices.has(block.contentIndex)) {
yield {
@@ -287,14 +266,12 @@ export async function* adaptOpenAIStreamToAnthropic(
}
}
// Defer message_delta / message_stop until after the loop so that any
// trailing usage chunk is processed before we emit the final token counts.
pendingFinishReason = choice.finish_reason
pendingHasToolCalls = toolBlocks.size > 0
}
}
// Safety: close any remaining open blocks if stream ended without finish_reason
// Safety: close any remaining open blocks
for (const idx of openBlockIndices) {
yield {
type: 'content_block_stop',
@@ -302,15 +279,8 @@ export async function* adaptOpenAIStreamToAnthropic(
} as BetaRawMessageStreamEvent
}
// Emit message_delta + message_stop now that the stream is fully consumed.
// Usage values (inputTokens / outputTokens) reflect all chunks including any
// trailing usage-only chunk sent after the finish_reason chunk.
// Emit message_delta + message_stop
if (pendingFinishReason !== null) {
// Map finish_reason to Anthropic stop_reason.
// CRITICAL: When finish_reason is 'length' (token budget exhausted), always
// report 'max_tokens' regardless of whether partial tool calls were received.
// Otherwise the query loop would try to execute tool calls with incomplete
// JSON arguments instead of triggering the max_tokens retry/recovery path.
const stopReason =
pendingFinishReason === 'length'
? 'max_tokens'
@@ -324,19 +294,6 @@ export async function* adaptOpenAIStreamToAnthropic(
stop_reason: stopReason,
stop_sequence: null,
},
// Carry all four Anthropic usage fields so queryModelOpenAI's message_delta
// handler (which spreads this into the accumulated usage object) can override
// every field that message_start emitted as 0. For endpoints that send usage
// in a trailing chunk (e.g. DeepSeek), message_start is emitted on the first
// content chunk before the trailing usage chunk arrives, so all four fields
// start at 0. By the time we reach here (post-loop) the trailing chunk has
// been processed and all values reflect the real counts.
//
// OpenAI → Anthropic field mapping:
// prompt_tokens → input_tokens
// completion_tokens → output_tokens
// prompt_tokens_details.cached_tokens → cache_read_input_tokens
// (no OpenAI equivalent) → cache_creation_input_tokens (stays 0)
usage: {
input_tokens: inputTokens,
output_tokens: outputTokens,
@@ -353,11 +310,6 @@ export async function* adaptOpenAIStreamToAnthropic(
/**
* Map OpenAI finish_reason to Anthropic stop_reason.
*
* stop end_turn
* tool_calls tool_use
* length max_tokens
* content_filter end_turn
*/
function mapFinishReason(reason: string): string {
switch (reason) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,4 @@
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(" "),
}));
import { describe, expect, test } from "bun:test";
const { interpretCommandResult } = await import("../commandSemantics");

View File

@@ -1,19 +1,10 @@
import { mock, describe, expect, test } from "bun:test";
mock.module("src/utils/debug.js", () => ({
mock.module("src/utils/debug.ts", () => ({
logForDebugging: () => {},
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 {
formatGoToDefinitionResult,
formatFindReferencesResult,

View File

@@ -7,6 +7,18 @@ mock.module("src/utils/cwd.js", () => ({
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");
describe("isGitInternalPathPS", () => {

View File

@@ -32,6 +32,58 @@ 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
const { powershellCommandIsSafe } = await import("../powershellSecurity.js");

View File

@@ -1,7 +1,9 @@
import { feature } from 'bun:bundle'
import { z } from 'zod/v4'
import type { ToolResultBlockParam } from 'src/Tool.js'
import { buildTool } from 'src/Tool.js'
import { lazySchema } from 'src/utils/lazySchema.js'
import { logForDebugging } from 'src/utils/debug.js'
const PUSH_NOTIFICATION_TOOL_NAME = 'PushNotification'
@@ -74,14 +76,58 @@ Requires Remote Control to be configured. Respects user notification settings (t
}
},
async call(_input: PushInput) {
// Push delivery is handled by the Remote Control / KAIROS transport layer.
// Without the KAIROS runtime, this tool is not available.
return {
data: {
sent: false,
error: 'PushNotification requires the KAIROS transport layer.',
},
async call(input: PushInput, context) {
const appState = context.getAppState()
// Try bridge delivery first (for remote/mobile viewers)
if (appState.replBridgeEnabled) {
if (feature('BRIDGE_MODE')) {
try {
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.' } }
},
})

View File

@@ -70,14 +70,51 @@ Guidelines:
}
},
async call(_input: SendUserFileInput) {
// File transfer is handled by the KAIROS assistant transport layer.
// Without the KAIROS runtime, this tool is not available.
async call(input: SendUserFileInput, context) {
const { file_path } = input
const { stat } = await import('fs/promises')
// 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 {
data: {
sent: false,
file_path: _input.file_path,
error: 'SendUserFile requires the KAIROS assistant transport layer.',
sent: delivered,
file_path,
size: fileSize,
...(fileUuid ? { file_uuid: fileUuid } : {}),
...(!delivered ? { error: 'Bridge upload failed. File available at local path.' } : {}),
},
}
},

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,14 @@
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'
// ---------------------------------------------------------------------------

View File

@@ -1,5 +1,17 @@
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 originalBraveApiKey = process.env.BRAVE_API_KEY

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -345,6 +345,22 @@ describe("Transport Service", () => {
expect(result.message).toEqual(msg);
});
test("preserves uuid field", () => {
const result = normalizePayload("user", {
uuid: "msg_123",
content: "hi",
});
expect(result.uuid).toBe("msg_123");
});
test("preserves isSynthetic field", () => {
const result = normalizePayload("user", {
content: "scheduled job: refresh analytics cache",
isSynthetic: true,
});
expect(result.isSynthetic).toBe(true);
});
test("uses name as tool_name fallback", () => {
const result = normalizePayload("tool", { name: "Read" });
expect(result.tool_name).toBe("Read");
@@ -362,6 +378,28 @@ describe("Transport Service", () => {
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", () => {
const result = normalizePayload("user", undefined);
expect(result.content).toBe("");

View File

@@ -69,6 +69,19 @@ describe("ws-handler", () => {
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", () => {
const bus = getEventBus("s1");
const events: unknown[] = [];
@@ -163,6 +176,24 @@ describe("ws-handler", () => {
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", () => {
const ws1 = createMockWs();
const ws2 = createMockWs();
@@ -336,6 +367,26 @@ describe("ws-handler", () => {
expect(lastMsg.message.content).toBe("hello world");
});
test("preserves payload uuid for outbound user events", () => {
const bus = getEventBus("um2");
const ws = createMockWs();
handleWebSocketOpen(ws, "um2");
bus.publish({
id: "internal-event-id",
sessionId: "um2",
type: "user",
payload: { uuid: "web-message-uuid", content: "hello from web" },
direction: "outbound",
});
const sent = ws.getSentData();
const lastMsg = JSON.parse(sent[sent.length - 1]);
expect(lastMsg.type).toBe("user");
expect(lastMsg.uuid).toBe("web-message-uuid");
expect(lastMsg.message.content).toBe("hello from web");
});
test("converts generic event type", () => {
const bus = getEventBus("gen1");
const ws = createMockWs();

View File

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

View File

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

View File

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

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