mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 21:05:51 +00:00
Compare commits
81 Commits
v1.8.0
...
feature/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a90b218c3 | ||
|
|
de9494c0a3 | ||
|
|
e7e1f7a34d | ||
|
|
9a5998eaef | ||
|
|
dff678924e | ||
|
|
4591432a1d | ||
|
|
901628b4d9 | ||
|
|
cf33c06021 | ||
|
|
e0ca1d054c | ||
|
|
6585d0f67c | ||
|
|
e4403ff010 | ||
|
|
9e61e7a90d | ||
|
|
d03af7bd4e | ||
|
|
e8ef955ff9 | ||
|
|
a8ed0cdce5 | ||
|
|
1c3b280c6a | ||
|
|
7a3cc24a00 | ||
|
|
2e7fc428cd | ||
|
|
ad09f38fd1 | ||
|
|
b0a3ef90dc | ||
|
|
c07ad4c738 | ||
|
|
e38d45460e | ||
|
|
e0c8e9dafc | ||
|
|
047c85fcbf | ||
|
|
da6d06365d | ||
|
|
8613d558a8 | ||
|
|
017c251f78 | ||
|
|
d4223abc34 | ||
|
|
5125a159d2 | ||
|
|
d09f363414 | ||
|
|
9d35f98ec7 | ||
|
|
eb833da33b | ||
|
|
eadd32ae47 | ||
|
|
3c55a8c83f | ||
|
|
5582bb47ef | ||
|
|
95bb191977 | ||
|
|
03811f973b | ||
|
|
02ab1a0307 | ||
|
|
2a5b263641 | ||
|
|
f2dd5142b3 | ||
|
|
4dcbaf1e66 | ||
|
|
0b304730d8 | ||
|
|
7a0dd3057e | ||
|
|
ca1c87f460 | ||
|
|
fc7a85f5c7 | ||
|
|
5bc12b00b2 | ||
|
|
792777d68c | ||
|
|
047634afe6 | ||
|
|
a92af99448 | ||
|
|
cfe1552ec9 | ||
|
|
9624f880e0 | ||
|
|
85e5a8cffb | ||
|
|
299953b0ee | ||
|
|
7a3fdf6e67 | ||
|
|
b642977afe | ||
|
|
781188862e | ||
|
|
b966eef5a9 | ||
|
|
c3d63c8fe2 | ||
|
|
7d4c4278c0 | ||
|
|
93bfdabff1 | ||
|
|
1173a62301 | ||
|
|
7ea69ca279 | ||
|
|
4e82fb5974 | ||
|
|
f43350e600 | ||
|
|
23fcbf9004 | ||
|
|
23bb09d240 | ||
|
|
d208855f07 | ||
|
|
7881cc617c | ||
|
|
c7e1c50b86 | ||
|
|
2247026bd5 | ||
|
|
eec961352b | ||
|
|
fb41513b32 | ||
|
|
94c4b37eed | ||
|
|
6c5df395c3 | ||
|
|
be97a0b010 | ||
|
|
59f8675fa3 | ||
|
|
c4775fff58 | ||
|
|
31b2fdd97a | ||
|
|
1837df5f88 | ||
|
|
04c7ed4250 | ||
|
|
711927f01b |
@@ -41,7 +41,8 @@ All teach-me data is stored under `.claude/skills/teach-me/records/`:
|
||||
.claude/skills/teach-me/records/
|
||||
├── learner-profile.md # Cross-topic notes (created on first session)
|
||||
└── {topic-slug}/
|
||||
└── session.md # Learning state: concepts, status, notes
|
||||
├── session.md # Learning state: concepts, status, notes
|
||||
└── {topic-slug}-notes.md # Learner-facing summary notes (generated at session end)
|
||||
```
|
||||
|
||||
**Slug**: Topic in kebab-case, 2-5 words. Example: "Python decorators" → `python-decorators`
|
||||
@@ -275,7 +276,8 @@ Update `session.md` after each round:
|
||||
When all concepts mastered or user ends session:
|
||||
|
||||
1. Update `session.md` with final state.
|
||||
2. Update `.claude/skills/teach-me/records/learner-profile.md` (keep under 30 lines):
|
||||
2. **Generate learner-facing notes** — write `{topic-slug}-notes.md` in the topic directory. This is a standalone reference document the learner can review later. See "Notes Generation" below for format.
|
||||
3. Update `.claude/skills/teach-me/records/learner-profile.md` (keep under 30 lines):
|
||||
|
||||
```markdown
|
||||
# Learner Profile
|
||||
@@ -293,7 +295,48 @@ Updated: {timestamp}
|
||||
- Python decorators (8/10 concepts, 2025-01-15)
|
||||
```
|
||||
|
||||
3. Give a brief text summary of what was covered, key insights, and areas for further study.
|
||||
4. Give a brief text summary of what was covered, key insights, and areas for further study.
|
||||
|
||||
## Notes Generation
|
||||
|
||||
At session end, generate a learner-facing notes file at `{topic-slug}/{topic-slug}-notes.md`. This file is **written for the learner to review later**, not for the tutor. It should be self-contained and organized as a quick-reference.
|
||||
|
||||
### Notes Structure
|
||||
|
||||
```markdown
|
||||
# {Topic} 核心笔记
|
||||
|
||||
## 1. {Section Name}
|
||||
{Key concept, mechanism, or principle}
|
||||
* **One-line summary**: {what it does / why it matters}
|
||||
* **Detail**: {brief explanation, 2-4 sentences max}
|
||||
* **Example** (if applicable): {code snippet, command, or concrete scenario}
|
||||
|
||||
---
|
||||
|
||||
## 2. {Section Name}
|
||||
...
|
||||
|
||||
---
|
||||
|
||||
## n. 实战参数 / Cheat Sheet (if applicable)
|
||||
{Practical commands, config, or quick-reference table}
|
||||
|
||||
| Parameter / Concept | What it does | Tuning tip |
|
||||
|---------------------|-------------|------------|
|
||||
| ... | ... | ... |
|
||||
```
|
||||
|
||||
### Notes Writing Rules
|
||||
|
||||
1. **Start with "what & why"** before "how". Each section should answer: what is this, why does it exist, what problem does it solve.
|
||||
2. **Use analogies sparingly but effectively**. Only include an analogy if it clarifies a non-obvious mechanism (e.g., "PagedAttention is like OS virtual memory paging").
|
||||
3. **Include trade-offs**. Every optimization or design choice has a cost. Always state it (e.g., "TP improves throughput but increases communication latency").
|
||||
4. **Code / command examples should be minimal**. Under 10 lines, self-contained, with comments explaining the key flags.
|
||||
5. **Organize by concept dependency**, not by chronological teaching order. Foundation concepts first, advanced ones last.
|
||||
6. **No quiz questions, no misconceptions, no tutor-side notes**. This is a clean reference document.
|
||||
7. **Language matches the session**. If the session was in Chinese, notes are in Chinese (technical terms can stay in English).
|
||||
8. **Keep it under 150 lines**. If it gets too long, the learner won't review it. Be ruthless about cutting fluff.
|
||||
|
||||
## Resuming Sessions
|
||||
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Type check
|
||||
run: bunx tsc --noEmit
|
||||
run: bun run typecheck
|
||||
|
||||
- name: Test with Coverage
|
||||
run: |
|
||||
|
||||
79
.github/workflows/publish-npm.yml
vendored
Normal file
79
.github/workflows/publish-npm.yml
vendored
Normal file
@@ -0,0 +1,79 @@
|
||||
name: Publish to npm
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: '版本号 (例如: v1.9.0)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.version || github.ref }}
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Type check
|
||||
run: bun run typecheck
|
||||
|
||||
- name: Run tests
|
||||
run: bun test
|
||||
|
||||
- name: Publish to npm
|
||||
run: npm publish --provenance --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Generate changelog
|
||||
id: changelog
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version || github.ref_name }}"
|
||||
PREV_TAG=$(git tag --sort=-version:refname | grep -v "^${VERSION#v}$" | head -1)
|
||||
|
||||
if [ -n "$PREV_TAG" ]; then
|
||||
COMMITS=$(git log "${PREV_TAG}..${VERSION}" --pretty=format:"- %s (%h)" --no-merges)
|
||||
else
|
||||
COMMITS=$(git log --pretty=format:"- %s (%h)" --no-merges -20)
|
||||
fi
|
||||
|
||||
{
|
||||
echo "commits<<EOF"
|
||||
echo "$COMMITS"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: ${{ github.event.inputs.version || github.ref_name }}
|
||||
body: |
|
||||
## What's Changed
|
||||
|
||||
${{ steps.changelog.outputs.commits }}
|
||||
|
||||
**Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ github.event.inputs.version || github.ref_name }}^...${{ github.event.inputs.version || github.ref_name }}
|
||||
draft: false
|
||||
prerelease: ${{ contains(github.event.inputs.version || github.ref_name, 'rc') || contains(github.event.inputs.version || github.ref_name, 'beta') || contains(github.event.inputs.version || github.ref_name, 'alpha') }}
|
||||
5
.github/workflows/update-contributors.yml
vendored
5
.github/workflows/update-contributors.yml
vendored
@@ -1,11 +1,8 @@
|
||||
name: Update Contributors
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
schedule:
|
||||
- cron: '0 0 * * *' # 每天更新一次
|
||||
- cron: '0 0 * * 1' # 每周一更新一次
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -19,6 +19,11 @@ src/utils/vendor/
|
||||
/*.png
|
||||
*.bmp
|
||||
|
||||
# Internal system prompt documents
|
||||
Claude-Opus-*.txt
|
||||
Claude-Sonnet-*.txt
|
||||
Claude-Haiku-*.txt
|
||||
|
||||
# Agent / tool state dirs
|
||||
.swarm/
|
||||
.agents/__pycache__/
|
||||
@@ -38,3 +43,5 @@ data
|
||||
.codex/skills/.system/**
|
||||
!.codex/prompts/
|
||||
!.codex/prompts/**
|
||||
teach-me
|
||||
credentials.json
|
||||
|
||||
357
AGENTS.md
Normal file
357
AGENTS.md
Normal file
@@ -0,0 +1,357 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) and other AI coding agents when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is a **reverse-engineered / decompiled** version of Anthropic's official Claude Code CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced — **`bunx tsc --noEmit` must pass with zero errors**.
|
||||
|
||||
## Git Commit Message Convention
|
||||
|
||||
使用 **Conventional Commits** 规范:
|
||||
|
||||
```
|
||||
<type>: <描述>
|
||||
```
|
||||
|
||||
常见 type:`feat`、`fix`、`docs`、`chore`、`refactor`
|
||||
|
||||
示例:
|
||||
- `feat: 添加模型 1M 上下文切换`
|
||||
- `fix: 修复初次登陆的校验问题`
|
||||
- `chore: remove prefetchOfficialMcpUrls call on startup`
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
bun install
|
||||
|
||||
# Dev mode (runs cli.tsx with MACRO defines injected via -d flags)
|
||||
bun run dev
|
||||
|
||||
# Dev mode with debugger (set BUN_INSPECT=9229 to pick port)
|
||||
bun run dev:inspect
|
||||
|
||||
# Pipe mode
|
||||
echo "say hello" | bun run src/entrypoints/cli.tsx -p
|
||||
|
||||
# Build (code splitting, outputs dist/cli.js + chunk files)
|
||||
bun run build
|
||||
|
||||
# Build with Vite (alternative build pipeline)
|
||||
bun run build:vite
|
||||
|
||||
# Test
|
||||
bun test # run all tests
|
||||
bun test src/utils/__tests__/hash.test.ts # run single file
|
||||
bun test --coverage # with coverage report
|
||||
|
||||
# Lint & Format (Biome)
|
||||
bun run lint # check only
|
||||
bun run lint:fix # auto-fix
|
||||
bun run format # format all src/
|
||||
|
||||
# Health check
|
||||
bun run health
|
||||
|
||||
# Check unused exports
|
||||
bun run check:unused
|
||||
|
||||
# Full check (typecheck + lint + test) — run after completing any task
|
||||
bun run test:all
|
||||
bun run typecheck
|
||||
|
||||
# Remote Control Server
|
||||
bun run rcs
|
||||
|
||||
# Docs dev server (Mintlify)
|
||||
bun run docs:dev
|
||||
```
|
||||
|
||||
详细的测试规范、覆盖状态和改进计划见 `docs/testing-spec.md`。
|
||||
|
||||
## Architecture
|
||||
|
||||
### Runtime & Build
|
||||
|
||||
- **Runtime**: Bun (not Node.js). All imports, builds, and execution use Bun APIs.
|
||||
- **Build**: `build.ts` 执行 `Bun.build()` with `splitting: true`,入口 `src/entrypoints/cli.tsx`,输出 `dist/cli.js` + chunk files。Build 默认启用 19 个 feature(见下方 Feature Flag 段)。构建后自动替换 `import.meta.require` 为 Node.js 兼容版本(产物 bun/node 都可运行)。
|
||||
- **Dev mode**: `scripts/dev.ts` 通过 Bun `-d` flag 注入 `MACRO.*` defines,运行 `src/entrypoints/cli.tsx`。默认启用全部 feature。
|
||||
- **Module system**: ESM (`"type": "module"`), TSX with `react-jsx` transform.
|
||||
- **Monorepo**: Bun workspaces — 15 个 workspace packages + 若干辅助目录 in `packages/` resolved via `workspace:*`。
|
||||
- **Lint/Format**: Biome (`biome.json`)。`bun run lint` / `bun run lint:fix` / `bun run format`。
|
||||
- **Defines**: 集中管理在 `scripts/defines.ts`。当前版本 `2.1.888`。
|
||||
- **CI**: GitHub Actions — `ci.yml`(构建+测试)、`release-rcs.yml`(RCS 发布)、`update-contributors.yml`(自动更新贡献者)。
|
||||
|
||||
### Entry & Bootstrap
|
||||
|
||||
1. **`src/entrypoints/cli.tsx`** — True entrypoint。`main()` 函数按优先级处理多条快速路径:
|
||||
- `--version` / `-v` — 零模块加载
|
||||
- `--dump-system-prompt` — feature-gated (DUMP_SYSTEM_PROMPT)
|
||||
- `--claude-in-chrome-mcp` / `--chrome-native-host`
|
||||
- `--computer-use-mcp` — 独立 MCP server 模式
|
||||
- `--daemon-worker=<kind>` — feature-gated (DAEMON)
|
||||
- `remote-control` / `rc` / `remote` / `sync` / `bridge` — feature-gated (BRIDGE_MODE)
|
||||
- `daemon` [subcommand] — feature-gated (DAEMON)
|
||||
- `ps` / `logs` / `attach` / `kill` / `--bg` — feature-gated (BG_SESSIONS)
|
||||
- `new` / `list` / `reply` — Template job commands
|
||||
- `environment-runner` / `self-hosted-runner` — BYOC runner
|
||||
- `--tmux` + `--worktree` 组合
|
||||
- 默认路径:加载 `main.tsx` 启动完整 CLI
|
||||
2. **`src/main.tsx`** (~6981 行) — Commander.js CLI definition。注册大量 subcommands:`mcp` (serve/add/remove/list...)、`server`、`ssh`、`open`、`auth`、`plugin`、`agents`、`auto-mode`、`doctor`、`update` 等。主 `.action()` 处理器负责权限、MCP、会话恢复、REPL/Headless 模式分发。
|
||||
3. **`src/entrypoints/init.ts`** — One-time initialization (telemetry, config, trust dialog)。
|
||||
|
||||
### Core Loop
|
||||
|
||||
- **`src/query.ts`** — The main API query function. Sends messages to Claude API, handles streaming responses, processes tool calls, and manages the conversation turn loop.
|
||||
- **`src/QueryEngine.ts`** — Higher-level orchestrator wrapping `query()`. Manages conversation state, compaction, file history snapshots, attribution, and turn-level bookkeeping. Used by the REPL screen.
|
||||
- **`src/screens/REPL.tsx`** — The interactive REPL screen (React/Ink component). Handles user input, message display, tool permission prompts, and keyboard shortcuts.
|
||||
|
||||
### API Layer
|
||||
|
||||
- **`src/services/api/claude.ts`** — Core API client. Builds request params (system prompt, messages, tools, betas), calls the Anthropic SDK streaming endpoint, and processes `BetaRawMessageStreamEvent` events.
|
||||
- **7 providers**: `firstParty` (Anthropic direct), `bedrock` (AWS), `vertex` (Google Cloud), `foundry`, `openai`, `gemini`, `grok` (xAI)。
|
||||
- Provider selection in `src/utils/model/providers.ts`。优先级:modelType 参数 > 环境变量 > 默认 firstParty。
|
||||
|
||||
### Tool System
|
||||
|
||||
- **`src/Tool.ts`** — Tool interface definition (`Tool` type) and utilities (`findToolByName`, `toolMatchesName`).
|
||||
- **`src/tools.ts`** — Tool registry. Assembles the tool list; tools are imported from `@claude-code-best/builtin-tools` package. Some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`.
|
||||
- **`packages/builtin-tools/src/tools/`** — 59 个子目录(含 shared/testing 等工具目录),通过 `@claude-code-best/builtin-tools` 包导出。主要分类:
|
||||
- **文件操作**: FileEditTool, FileReadTool, FileWriteTool, GlobTool, GrepTool
|
||||
- **Shell/执行**: BashTool, PowerShellTool, REPLTool
|
||||
- **Agent 系统**: AgentTool, TaskCreateTool, TaskUpdateTool, TaskListTool, TaskGetTool
|
||||
- **规划**: EnterPlanModeTool, ExitPlanModeV2Tool, VerifyPlanExecutionTool
|
||||
- **Web/MCP**: WebFetchTool, WebSearchTool, MCPTool, McpAuthTool
|
||||
- **调度**: CronCreateTool, CronDeleteTool, CronListTool
|
||||
- **其他**: LSPTool, ConfigTool, SkillTool, EnterWorktreeTool, ExitWorktreeTool 等
|
||||
- **`src/tools/shared/`** / **`packages/builtin-tools/src/tools/shared/`** — Tool 共享工具函数。
|
||||
|
||||
### UI Layer (Ink)
|
||||
|
||||
- **`src/ink.ts`** — Ink render wrapper with ThemeProvider injection.
|
||||
- **`packages/@ant/ink/`** — Custom Ink framework(forked/internal),包含 components、core、hooks、keybindings、theme、utils。注意:不是 `src/ink/`。
|
||||
- **`src/components/`** — 149 个组件目录/文件,渲染于终端 Ink 环境中。关键组件:
|
||||
- `App.tsx` — Root provider (AppState, Stats, FpsMetrics)
|
||||
- `Messages.tsx` / `MessageRow.tsx` — Conversation message rendering
|
||||
- `PromptInput/` — User input handling
|
||||
- `permissions/` — Tool permission approval UI
|
||||
- `design-system/` — 复用 UI 组件(Dialog, FuzzyPicker, ProgressBar, ThemeProvider 等)
|
||||
- Components use React Compiler runtime (`react/compiler-runtime`) — decompiled output has `_c()` memoization calls throughout.
|
||||
|
||||
### State Management
|
||||
|
||||
- **`src/state/AppState.tsx`** — Central app state type and context provider. Contains messages, tools, permissions, MCP connections, etc.
|
||||
- **`src/state/AppStateStore.ts`** — Default state and store factory.
|
||||
- **`src/state/store.ts`** — Zustand-style store for AppState (`createStore`).
|
||||
- **`src/state/selectors.ts`** — State selectors.
|
||||
- **`src/bootstrap/state.ts`** — Module-level singletons for session-global state (session ID, CWD, project root, token counts, model overrides, client type, permission mode).
|
||||
|
||||
### Workspace Packages
|
||||
|
||||
| Package | 说明 |
|
||||
|---------|------|
|
||||
| `packages/@ant/ink/` | Forked Ink 框架(components、hooks、keybindings、theme) |
|
||||
| `packages/@ant/computer-use-mcp/` | Computer Use MCP server(截图/键鼠/剪贴板/应用管理) |
|
||||
| `packages/@ant/computer-use-input/` | 键鼠模拟(dispatcher + darwin/win32/linux backend) |
|
||||
| `packages/@ant/computer-use-swift/` | 截图 + 应用管理(dispatcher + per-platform backend) |
|
||||
| `packages/@ant/claude-for-chrome-mcp/` | Chrome 浏览器控制(通过 `--chrome` 启用) |
|
||||
| `packages/@ant/model-provider/` | Model provider 抽象层 |
|
||||
| `packages/builtin-tools/` | 内置工具集(60 个 tool 实现,通过 `@claude-code-best/builtin-tools` 导出) |
|
||||
| `packages/agent-tools/` | Agent 工具集 |
|
||||
| `packages/acp-link/` | ACP 代理服务器(WebSocket → ACP agent 桥接) |
|
||||
| `packages/cc-knowledge/` | Claude Code 知识库(非 workspace 包) |
|
||||
| `packages/langfuse-dashboard/` | Langfuse 可观测性面板(非 workspace 包) |
|
||||
| `packages/mcp-client/` | MCP 客户端库 |
|
||||
| `packages/mcp-server/` | MCP 服务端库(非 workspace 包) |
|
||||
| `packages/remote-control-server/` | 自托管 Remote Control Server(Docker 部署,含 Web UI)— Web UI 已重构为 React + Vite + Radix UI,支持 ACP agent 接入 |
|
||||
| `packages/swarm/` | Swarm 解耦模块(非 workspace 包) |
|
||||
| `packages/shell/` | Shell 抽象(非 workspace 包) |
|
||||
| `packages/audio-capture-napi/` | 原生音频捕获(已恢复) |
|
||||
| `packages/color-diff-napi/` | 颜色差异计算(完整实现,11 tests) |
|
||||
| `packages/image-processor-napi/` | 图像处理(已恢复) |
|
||||
| `packages/modifiers-napi/` | 键盘修饰键检测(macOS FFI 实现) |
|
||||
| `packages/url-handler-napi/` | URL scheme 处理(环境变量 + CLI 参数读取) |
|
||||
|
||||
### Bridge / Remote Control
|
||||
|
||||
- **`src/bridge/`** — Remote Control / Bridge 模式。feature-gated by `BRIDGE_MODE`。包含 bridge API、会话管理、JWT 认证、消息传输、权限回调等。Entry: `bridgeMain.ts`。
|
||||
- **`packages/remote-control-server/`** — 自托管 RCS,支持 Docker 部署,含 Web UI 控制面板(React 19 + Vite + Radix UI)。支持 ACP agent 通过 acp-link 接入(ACP WebSocket handler、relay handler、SSE event stream)。通过 `bun run rcs` 启动。
|
||||
- CLI 快速路径: `claude remote-control` / `claude rc` / `claude bridge`。
|
||||
- 详见 `docs/features/remote-control-self-hosting.md`。
|
||||
|
||||
### ACP Protocol (Agent Client Protocol)
|
||||
|
||||
- **`src/services/acp/`** — ACP agent 实现,包含 `agent.ts`(AcpAgent 类)、`bridge.ts`(Claude Code ↔ ACP 桥接)、`permissions.ts`(权限处理)、`entry.ts`(入口)。
|
||||
- **`packages/acp-link/`** — ACP 代理服务器,将 WebSocket 客户端桥接到 ACP agent。提供 `acp-link` CLI 命令,支持自定义端口/HTTPS/认证/会话管理、RCS 集成(REST 注册 + WS identify 两步流程)、权限模式透传(fallback: 客户端传值 > config > `ACP_PERMISSION_MODE` 环境变量)。
|
||||
- ACP 权限管道改进:`createAcpCanUseTool` 统一权限流水线,`applySessionMode` 模式同步,`bypassPermissions` 可用性检测(非 root/sandbox 环境)。
|
||||
- ACP Plan 可视化已支持 `session/update plan` 类型的消息展示(PlanView 组件,含进度条/状态图标/优先级标签)。
|
||||
|
||||
### Daemon Mode
|
||||
|
||||
- **`src/daemon/`** — Daemon 模式(长驻 supervisor)。feature-gated by `DAEMON`。包含 `main.ts`(entry)和 `workerRegistry.ts`(worker 管理)。
|
||||
|
||||
### Context & System Prompt
|
||||
|
||||
- **`src/context.ts`** — Builds system/user context for the API call (git status, date, CLAUDE.md contents, memory files).
|
||||
- **`src/utils/claudemd.ts`** — Discovers and loads CLAUDE.md files from project hierarchy.
|
||||
|
||||
### Feature Flag System
|
||||
|
||||
Feature flags control which functionality is enabled at runtime. 代码中统一通过 `import { feature } from 'bun:bundle'` 导入,调用 `feature('FLAG_NAME')` 返回 `boolean`。
|
||||
|
||||
**启用方式**: 环境变量 `FEATURE_<FLAG_NAME>=1`。例如 `FEATURE_BUDDY=1 bun run dev`。
|
||||
|
||||
**Build 默认 features**(19 个,见 `build.ts`):
|
||||
- 基础: `BUDDY`, `TRANSCRIPT_CLASSIFIER`, `BRIDGE_MODE`, `AGENT_TRIGGERS_REMOTE`, `CHICAGO_MCP`, `VOICE_MODE`
|
||||
- 统计/缓存: `SHOT_STATS`, `PROMPT_CACHE_BREAK_DETECTION`, `TOKEN_BUDGET`
|
||||
- P0 本地: `AGENT_TRIGGERS`, `ULTRATHINK`, `BUILTIN_EXPLORE_PLAN_AGENTS`, `LODESTONE`
|
||||
- P1 API 依赖: `EXTRACT_MEMORIES`, `VERIFICATION_AGENT`, `KAIROS_BRIEF`, `AWAY_SUMMARY`, `ULTRAPLAN`
|
||||
- P2: `DAEMON`
|
||||
|
||||
**Dev mode 默认**: 全部启用(见 `scripts/dev.ts`)。
|
||||
|
||||
**类型声明**: `src/types/internal-modules.d.ts` 中声明了 `bun:bundle` 模块的 `feature` 函数签名。
|
||||
|
||||
**新增功能的正确做法**: 保留 `import { feature } from 'bun:bundle'` + `feature('FLAG_NAME')` 的标准模式,在运行时通过环境变量或配置控制,不要绕过 feature flag 直接 import。
|
||||
|
||||
### Multi-API 兼容层
|
||||
|
||||
所有兼容层均采用流适配器模式:将第三方 API 格式转为 Anthropic 内部格式,下游代码完全不改。通过 `/login` 命令配置。
|
||||
|
||||
#### OpenAI 兼容层
|
||||
|
||||
通过 `CLAUDE_CODE_USE_OPENAI=1` 启用,支持 Ollama/DeepSeek/vLLM 等任意 OpenAI Chat Completions 协议端点。含 DeepSeek thinking mode 支持。
|
||||
|
||||
- **`src/services/api/openai/`** — client、消息/工具转换、流适配、模型映射
|
||||
- 关键环境变量:`CLAUDE_CODE_USE_OPENAI`、`OPENAI_API_KEY`、`OPENAI_BASE_URL`、`OPENAI_MODEL`
|
||||
|
||||
#### Gemini 兼容层
|
||||
|
||||
通过 `CLAUDE_CODE_USE_GEMINI=1` 启用。独立环境变量体系。
|
||||
|
||||
- **`src/services/api/gemini/`** — client、模型映射、类型定义
|
||||
- 关键环境变量:`GEMINI_API_KEY`(必填)、`GEMINI_MODEL`(直接指定)、`GEMINI_DEFAULT_SONNET_MODEL`/`GEMINI_DEFAULT_OPUS_MODEL`(按能力映射)
|
||||
- 模型映射优先级:`GEMINI_MODEL` > `GEMINI_DEFAULT_*_MODEL` > `ANTHROPIC_DEFAULT_*_MODEL`(已废弃) > 原样返回
|
||||
|
||||
#### Grok 兼容层
|
||||
|
||||
通过 `CLAUDE_CODE_USE_GROK=1` 启用。自定义模型映射支持 xAI Grok API。
|
||||
|
||||
- **`src/services/api/grok/`** — client、模型映射
|
||||
|
||||
详见各兼容层的 docs 文档。
|
||||
|
||||
### 穷鬼模式(Budget Mode)
|
||||
|
||||
- 通过 `/poor` 命令切换,持久化到 `settings.json`。
|
||||
- 启用后跳过 `extract_memories`、`prompt_suggestion` 和 `verification_agent`,显著减少 token 消耗。
|
||||
- 实现在 `src/commands/poor/poorMode.ts`。
|
||||
|
||||
### Stubbed/Deleted Modules
|
||||
|
||||
| Module | Status |
|
||||
|--------|--------|
|
||||
| Computer Use (`@ant/*`) | Restored — macOS + Windows + Linux(后端完整度不一) |
|
||||
| `*-napi` packages | 全部已恢复/实现:`audio-capture-napi`、`image-processor-napi` 已恢复;`color-diff-napi` 完整;`modifiers-napi`(macOS FFI);`url-handler-napi`(环境变量+CLI) |
|
||||
| Voice Mode | Restored — Push-to-Talk 语音输入(需 Anthropic OAuth) |
|
||||
| OpenAI/Gemini/Grok 兼容层 | Restored |
|
||||
| Remote Control Server | Restored — 自托管 RCS + Web UI |
|
||||
| Analytics / GrowthBook / Sentry | Empty implementations |
|
||||
| Magic Docs / LSP Server | Restored — Magic Docs 自动更新 + LSP 服务器管理器 |
|
||||
| Plugins / Marketplace | Restored — 插件安装/卸载/启用/禁用 + Marketplace 浏览 |
|
||||
| MCP OAuth | Simplified |
|
||||
|
||||
### Key Type Files
|
||||
|
||||
- **`src/types/global.d.ts`** — Declares `MACRO`, `BUILD_TARGET`, `BUILD_ENV` and internal Anthropic-only identifiers.
|
||||
- **`src/types/internal-modules.d.ts`** — Type declarations for `bun:bundle`, `bun:ffi`, `@anthropic-ai/mcpb`.
|
||||
- **`src/types/message.ts`** — Message type hierarchy (UserMessage, AssistantMessage, SystemMessage, etc.).
|
||||
- **`src/types/permissions.ts`** — Permission mode and result types.
|
||||
|
||||
## Testing
|
||||
|
||||
- **框架**: `bun:test`(内置断言 + mock)
|
||||
- **单元测试**: 就近放置于 `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")`,英文
|
||||
- **包测试**: `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`、第三方网络库。
|
||||
|
||||
**`log.ts` 和 `debug.ts` 使用共享 mock**(`tests/mocks/log.ts` / `tests/mocks/debug.ts`),不要在测试文件中内联 mock 定义。使用方式:
|
||||
|
||||
```ts
|
||||
import { logMock } from "../../../tests/mocks/log";
|
||||
mock.module("src/utils/log.ts", logMock);
|
||||
|
||||
import { debugMock } from "../../../../tests/mocks/debug";
|
||||
mock.module("src/utils/debug.ts", debugMock);
|
||||
```
|
||||
|
||||
源文件导出变更时只需更新 `tests/mocks/` 下的对应文件,不需要逐个修改测试。
|
||||
|
||||
不要 mock:纯函数模块(`errors.ts`、`stringUtils.js`)、mock 值与真实实现相同的模块、mock 路径与实际 import 不匹配的模块。
|
||||
|
||||
路径规则:统一用 `.ts` 扩展名 + `src/*` 别名路径,禁止双重 mock 同一模块。
|
||||
|
||||
### 类型检查
|
||||
|
||||
项目使用 TypeScript strict 模式,**tsc 必须零错误**。每次修改后运行:
|
||||
|
||||
```bash
|
||||
bun run typecheck
|
||||
```
|
||||
|
||||
**类型规范**:
|
||||
- 生产代码禁止 `as any`;测试文件中 mock 数据可用 `as any`
|
||||
- 类型不匹配优先用 `as unknown as SpecificType` 双重断言,或补充 interface
|
||||
- 未知结构对象用 `Record<string, unknown>` 替代 `any`
|
||||
- 联合类型用类型守卫(type guard)收窄,不要强转
|
||||
- `msg.request` 属性访问:`const req = msg.request as Record<string, unknown>`
|
||||
- Ink `color` prop:用 `as keyof Theme` 而非 `as any`
|
||||
|
||||
## Working with This Codebase
|
||||
|
||||
- **tsc must pass** — `bun run typecheck` 必须零错误,任何修改都不能引入新的类型错误。
|
||||
- **Feature flags** — 默认全部关闭(`feature()` 返回 `false`)。Dev/build 各有自己的默认启用列表。不要在 `cli.tsx` 中重定义 `feature` 函数。
|
||||
- **React Compiler output** — Components have decompiled memoization boilerplate (`const $ = _c(N)`). This is normal.
|
||||
- **`bun:bundle` import** — `import { feature } from 'bun:bundle'` 是 Bun 内置模块,由运行时/构建器解析。不要用自定义函数替代它。**`feature()` 只能直接用在 `if` 语句或三元表达式的条件位置**(Bun 编译器限制),不能赋值给变量、不能放在箭头函数体里、不能作为 `&&` 链的一部分。正确:`if (feature('X')) {}` 或 `feature('X') ? a : b`。
|
||||
- **`src/` path alias** — tsconfig maps `src/*` to `./src/*`. Imports like `import { ... } from 'src/utils/...'` are valid.
|
||||
- **MACRO defines** — 集中管理在 `scripts/defines.ts`。Dev mode 通过 `bun -d` 注入,build 通过 `Bun.build({ define })` 注入。修改版本号等常量只改这个文件。
|
||||
- **构建产物兼容 Node.js** — `build.ts` 会自动后处理 `import.meta.require`,产物可直接用 `node dist/cli.js` 运行。
|
||||
- **Biome 配置** — 大量 lint 规则被关闭(decompiled 代码不适合严格 lint)。`.tsx` 文件用 120 行宽 + 强制分号;其他文件 80 行宽 + 按需分号。
|
||||
- **Ink 框架在 `packages/@ant/ink/`** — 不是 `src/ink/`(该目录不存在)。Ink 相关的组件、hooks、keybindings 都在 packages 中。
|
||||
- **Provider 优先级** — `modelType` 参数 > 环境变量 > 默认 `firstParty`。新增 provider 需在 `src/utils/model/providers.ts` 注册。
|
||||
|
||||
## Design Context
|
||||
|
||||
Impeccable 设计上下文保存在 `.impeccable.md` 中。设计 Web UI(RCS 控制面板、文档站、着陆页)时必须参考该文件。
|
||||
|
||||
### 核心设计原则
|
||||
|
||||
1. **Considered over clever** — 每个设计选择都应感觉有意为之,而非追逐潮流
|
||||
2. **Warmth through subtlety** — 通过橙色色调的中性色、留白布局、有温度的文案来传达温暖
|
||||
3. **Density with clarity** — 技术用户需要信息密度,但不能混乱
|
||||
4. **Community voice** — 设计应感觉是由使用者创造的,而非遥远的设计团队
|
||||
5. **Anthropic's shadow** — 遵循 Anthropic 的设计直觉:干净的布局、充足的间距、温暖的色温
|
||||
|
||||
### 品牌色
|
||||
|
||||
- 主色:Claude Orange `#D77757`(terra cotta)
|
||||
- 辅色:Claude Blue `#5769F7`
|
||||
- 暗色模式使用温暖的深色表面(非冷蓝黑色)
|
||||
|
||||
### 目标用户
|
||||
|
||||
技术团队/企业,在专业工作流中使用 AI 辅助编程。友好的开源社区氛围,非企业 SaaS 风格。
|
||||
|
||||
### 视觉参考
|
||||
|
||||
Anthropic 公司的设计风格 — 干净、考究、温暖的底色。大量留白,以排版为核心。避免 AI 产品常见的设计套路(渐变文字、玻璃态、霓虹色)。
|
||||
70
CLAUDE.md
70
CLAUDE.md
@@ -1,10 +1,10 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
This file provides guidance to Claude Code (claude.ai/code) and other AI coding agents when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is a **reverse-engineered / decompiled** version of Anthropic's official Claude Code CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced(见 Working with This Codebase 段的 tsc 要求)。
|
||||
This is a **reverse-engineered / decompiled** version of Anthropic's official Claude Code CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced — **`bunx tsc --noEmit` must pass with zero errors**.
|
||||
|
||||
## Git Commit Message Convention
|
||||
|
||||
@@ -43,9 +43,9 @@ bun run build
|
||||
bun run build:vite
|
||||
|
||||
# Test
|
||||
bun test # run all tests (3175 tests / 207 files / 0 fail)
|
||||
bun test # run all tests
|
||||
bun test src/utils/__tests__/hash.test.ts # run single file
|
||||
bun test --coverage # with coverage report
|
||||
bun test --coverage # with coverage report
|
||||
|
||||
# Lint & Format (Biome)
|
||||
bun run lint # check only
|
||||
@@ -60,7 +60,6 @@ bun run check:unused
|
||||
|
||||
# Full check (typecheck + lint + test) — run after completing any task
|
||||
bun run test:all
|
||||
|
||||
bun run typecheck
|
||||
|
||||
# Remote Control Server
|
||||
@@ -77,7 +76,9 @@ bun run docs:dev
|
||||
### Runtime & Build
|
||||
|
||||
- **Runtime**: Bun (not Node.js). All imports, builds, and execution use Bun APIs.
|
||||
- **Build**: `build.ts` 执行 `Bun.build()` with `splitting: true`,入口 `src/entrypoints/cli.tsx`,输出 `dist/cli.js` + chunk files。Build 默认启用 19 个 feature(见下方 Feature Flag 段)。构建后自动替换 `import.meta.require` 为 Node.js 兼容版本(产物 bun/node 都可运行)。
|
||||
- **Build**: `build.ts` 执行 `Bun.build()` with `splitting: true`,入口 `src/entrypoints/cli.tsx`,输出 `dist/cli.js` + chunk files。Build 默认启用 19 个 feature(见下方 Feature Flag 段)。构建后自动替换 `import.meta.require` 为 Node.js 兼容版本(产物 bun/node 都可运行)。构建时会将 `vendor/audio-capture/` 和 `src/utils/vendor/ripgrep/` 复制到 `dist/vendor/` 下。
|
||||
- **Build (Vite)**: `vite.config.ts` + `scripts/post-build.ts`,chunk 输出到 `dist/chunks/`。post-build 同样复制 vendor 文件到 `dist/vendor/`。
|
||||
- **Vendor 路径解析**: 构建后 chunk 文件位于 `dist/` 或 `dist/chunks/` 下,vendor 二进制在 `dist/vendor/`。`src/utils/ripgrep.ts` 和 `packages/audio-capture-napi/src/index.ts` 均通过 `import.meta.url` 路径中 `lastIndexOf('dist')` 定位 dist 根目录,再拼接 `vendor/` 子路径,确保不同构建产物层级下路径一致。
|
||||
- **Dev mode**: `scripts/dev.ts` 通过 Bun `-d` flag 注入 `MACRO.*` defines,运行 `src/entrypoints/cli.tsx`。默认启用全部 feature。
|
||||
- **Module system**: ESM (`"type": "module"`), TSX with `react-jsx` transform.
|
||||
- **Monorepo**: Bun workspaces — 15 个 workspace packages + 若干辅助目录 in `packages/` resolved via `workspace:*`。
|
||||
@@ -87,7 +88,7 @@ bun run docs:dev
|
||||
|
||||
### Entry & Bootstrap
|
||||
|
||||
1. **`src/entrypoints/cli.tsx`** (373 行) — True entrypoint。`main()` 函数按优先级处理多条快速路径:
|
||||
1. **`src/entrypoints/cli.tsx`** — True entrypoint。`main()` 函数按优先级处理多条快速路径:
|
||||
- `--version` / `-v` — 零模块加载
|
||||
- `--dump-system-prompt` — feature-gated (DUMP_SYSTEM_PROMPT)
|
||||
- `--claude-in-chrome-mcp` / `--chrome-native-host`
|
||||
@@ -118,7 +119,7 @@ bun run docs:dev
|
||||
### Tool System
|
||||
|
||||
- **`src/Tool.ts`** — Tool interface definition (`Tool` type) and utilities (`findToolByName`, `toolMatchesName`).
|
||||
- **`src/tools.ts`** (392 行) — Tool registry. Assembles the tool list; tools are imported from `@claude-code-best/builtin-tools` package. Some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`.
|
||||
- **`src/tools.ts`** — Tool registry. Assembles the tool list; tools are imported from `@claude-code-best/builtin-tools` package. Some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`.
|
||||
- **`packages/builtin-tools/src/tools/`** — 59 个子目录(含 shared/testing 等工具目录),通过 `@claude-code-best/builtin-tools` 包导出。主要分类:
|
||||
- **文件操作**: FileEditTool, FileReadTool, FileWriteTool, GlobTool, GrepTool
|
||||
- **Shell/执行**: BashTool, PowerShellTool, REPLTool
|
||||
@@ -127,6 +128,7 @@ bun run docs:dev
|
||||
- **Web/MCP**: WebFetchTool, WebSearchTool, MCPTool, McpAuthTool
|
||||
- **调度**: CronCreateTool, CronDeleteTool, CronListTool
|
||||
- **其他**: LSPTool, ConfigTool, SkillTool, EnterWorktreeTool, ExitWorktreeTool 等
|
||||
- **`src/tools/shared/`** / **`packages/builtin-tools/src/tools/shared/`** — Tool 共享工具函数。
|
||||
|
||||
### UI Layer (Ink)
|
||||
|
||||
@@ -171,12 +173,12 @@ bun run docs:dev
|
||||
| `packages/audio-capture-napi/` | 原生音频捕获(已恢复) |
|
||||
| `packages/color-diff-napi/` | 颜色差异计算(完整实现,11 tests) |
|
||||
| `packages/image-processor-napi/` | 图像处理(已恢复) |
|
||||
| `packages/modifiers-napi/` | 键盘修饰键检测(stub) |
|
||||
| `packages/url-handler-napi/` | URL scheme 处理(stub) |
|
||||
| `packages/modifiers-napi/` | 键盘修饰键检测(macOS FFI 实现) |
|
||||
| `packages/url-handler-napi/` | URL scheme 处理(环境变量 + CLI 参数读取) |
|
||||
|
||||
### Bridge / Remote Control
|
||||
|
||||
- **`src/bridge/`** (~38 files) — Remote Control / Bridge 模式。feature-gated by `BRIDGE_MODE`。包含 bridge API、会话管理、JWT 认证、消息传输、权限回调等。Entry: `bridgeMain.ts`。
|
||||
- **`src/bridge/`** — Remote Control / Bridge 模式。feature-gated by `BRIDGE_MODE`。包含 bridge API、会话管理、JWT 认证、消息传输、权限回调等。Entry: `bridgeMain.ts`。
|
||||
- **`packages/remote-control-server/`** — 自托管 RCS,支持 Docker 部署,含 Web UI 控制面板(React 19 + Vite + Radix UI)。支持 ACP agent 通过 acp-link 接入(ACP WebSocket handler、relay handler、SSE event stream)。通过 `bun run rcs` 启动。
|
||||
- CLI 快速路径: `claude remote-control` / `claude rc` / `claude bridge`。
|
||||
- 详见 `docs/features/remote-control-self-hosting.md`。
|
||||
@@ -218,7 +220,30 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
|
||||
|
||||
### Multi-API 兼容层
|
||||
|
||||
支持 OpenAI、Gemini、Grok 三种第三方 API,通过 `/login` 命令配置,均采用流适配器模式转为 Anthropic 内部格式。详见各兼容层的 docs 文档。
|
||||
所有兼容层均采用流适配器模式:将第三方 API 格式转为 Anthropic 内部格式,下游代码完全不改。通过 `/login` 命令配置。
|
||||
|
||||
#### OpenAI 兼容层
|
||||
|
||||
通过 `CLAUDE_CODE_USE_OPENAI=1` 启用,支持 Ollama/DeepSeek/vLLM 等任意 OpenAI Chat Completions 协议端点。含 DeepSeek thinking mode 支持。
|
||||
|
||||
- **`src/services/api/openai/`** — client、消息/工具转换、流适配、模型映射
|
||||
- 关键环境变量:`CLAUDE_CODE_USE_OPENAI`、`OPENAI_API_KEY`、`OPENAI_BASE_URL`、`OPENAI_MODEL`
|
||||
|
||||
#### Gemini 兼容层
|
||||
|
||||
通过 `CLAUDE_CODE_USE_GEMINI=1` 启用。独立环境变量体系。
|
||||
|
||||
- **`src/services/api/gemini/`** — client、模型映射、类型定义
|
||||
- 关键环境变量:`GEMINI_API_KEY`(必填)、`GEMINI_MODEL`(直接指定)、`GEMINI_DEFAULT_SONNET_MODEL`/`GEMINI_DEFAULT_OPUS_MODEL`(按能力映射)
|
||||
- 模型映射优先级:`GEMINI_MODEL` > `GEMINI_DEFAULT_*_MODEL` > `ANTHROPIC_DEFAULT_*_MODEL`(已废弃) > 原样返回
|
||||
|
||||
#### Grok 兼容层
|
||||
|
||||
通过 `CLAUDE_CODE_USE_GROK=1` 启用。自定义模型映射支持 xAI Grok API。
|
||||
|
||||
- **`src/services/api/grok/`** — client、模型映射
|
||||
|
||||
详见各兼容层的 docs 文档。
|
||||
|
||||
### 穷鬼模式(Budget Mode)
|
||||
|
||||
@@ -231,13 +256,13 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
|
||||
| Module | Status |
|
||||
|--------|--------|
|
||||
| Computer Use (`@ant/*`) | Restored — macOS + Windows + Linux(后端完整度不一) |
|
||||
| `*-napi` packages | `audio-capture-napi`、`image-processor-napi` 已恢复;`color-diff-napi` 完整;`modifiers-napi`、`url-handler-napi` 仍为 stub |
|
||||
| `*-napi` packages | 全部已恢复/实现:`audio-capture-napi`、`image-processor-napi` 已恢复;`color-diff-napi` 完整;`modifiers-napi`(macOS FFI);`url-handler-napi`(环境变量+CLI) |
|
||||
| Voice Mode | Restored — Push-to-Talk 语音输入(需 Anthropic OAuth) |
|
||||
| OpenAI/Gemini/Grok 兼容层 | Restored |
|
||||
| Remote Control Server | Restored — 自托管 RCS + Web UI |
|
||||
| Analytics / GrowthBook / Sentry | Empty implementations |
|
||||
| Magic Docs / LSP Server | Removed |
|
||||
| Plugins / Marketplace | Removed |
|
||||
| Magic Docs / LSP Server | Restored — Magic Docs 自动更新 + LSP 服务器管理器 |
|
||||
| Plugins / Marketplace | Restored — 插件安装/卸载/启用/禁用 + Marketplace 浏览 |
|
||||
| MCP OAuth | Simplified |
|
||||
|
||||
### Key Type Files
|
||||
@@ -250,7 +275,6 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
|
||||
## Testing
|
||||
|
||||
- **框架**: `bun:test`(内置断言 + mock)
|
||||
- **当前状态**: 3175 tests / 207 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/)
|
||||
@@ -263,6 +287,18 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
|
||||
|
||||
被迫 mock 的根源:`log.ts` / `debug.ts` → `bootstrap/state.ts`(模块级 `realpathSync` / `randomUUID` 副作用)。必须 mock 的模块:`log.ts`、`debug.ts`、`bun:bundle`、`settings/settings.js`、`config.ts`、`auth.ts`、第三方网络库。
|
||||
|
||||
**`log.ts` 和 `debug.ts` 使用共享 mock**(`tests/mocks/log.ts` / `tests/mocks/debug.ts`),不要在测试文件中内联 mock 定义。使用方式:
|
||||
|
||||
```ts
|
||||
import { logMock } from "../../../tests/mocks/log";
|
||||
mock.module("src/utils/log.ts", logMock);
|
||||
|
||||
import { debugMock } from "../../../../tests/mocks/debug";
|
||||
mock.module("src/utils/debug.ts", debugMock);
|
||||
```
|
||||
|
||||
源文件导出变更时只需更新 `tests/mocks/` 下的对应文件,不需要逐个修改测试。
|
||||
|
||||
不要 mock:纯函数模块(`errors.ts`、`stringUtils.js`)、mock 值与真实实现相同的模块、mock 路径与实际 import 不匹配的模块。
|
||||
|
||||
路径规则:统一用 `.ts` 扩展名 + `src/*` 别名路径,禁止双重 mock 同一模块。
|
||||
@@ -272,7 +308,7 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
|
||||
项目使用 TypeScript strict 模式,**tsc 必须零错误**。每次修改后运行:
|
||||
|
||||
```bash
|
||||
bun run typecheck # equivalent to bun run typecheck
|
||||
bun run typecheck
|
||||
```
|
||||
|
||||
**类型规范**:
|
||||
|
||||
67
README.md
67
README.md
@@ -12,20 +12,22 @@
|
||||
|
||||
牢 A (Anthropic) 官方 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI 工具的源码反编译/逆向还原项目。目标是将 Claude Code 大部分功能及工程化能力复现 (问就是老佛爷已经付过钱了)。虽然很难绷, 但是它叫做 CCB(踩踩背)... 而且, 我们实现了企业版或者需要登陆 Claude 账号才能使用的特性, 实现技术普惠
|
||||
|
||||
> 我们将会在五一期间进行整个代码仓库的 lint 规范化, 这个期间提交的 PR 可能会有非常多的冲突, 所以大的功能请尽量在这之前提交哈
|
||||
|
||||
[文档在这里, 支持投稿 PR](https://ccb.agent-aura.top/) | [留影文档在这里](./Friends.md) | [Discord 群组](https://discord.gg/uApuzJWGKX)
|
||||
|
||||
|
||||
| 特性 | 说明 | 文档 |
|
||||
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Claude 群控技术** | Pipe IPC 多实例协作:同机 main/sub 自动编排 + LAN 跨机器零配置发现与通讯,`/pipes` 选择面板 + `Shift+↓` 交互 + 消息广播路由 | [Pipe IPC](https://ccb.agent-aura.top/docs/features/pipes-and-lan) / [LAN](https://ccb.agent-aura.top/docs/features/lan-pipes) |
|
||||
| **Claude 群控技术** | Pipe IPC 多实例协作:同机 main/sub 自动编排 + LAN 跨机器零配置发现与通讯,`/pipes` 选择面板 + `Shift+↓` 交互 + 消息广播路由 | [Pipe IPC](https://ccb.agent-aura.top/docs/features/uds-inbox) / [LAN](https://ccb.agent-aura.top/docs/features/lan-pipes) |
|
||||
| **ACP 协议一等一支持** | 支持接入 Zed、Cursor 等 IDE,支持会话恢复、Skills、权限桥接 | [文档](https://ccb.agent-aura.top/docs/features/acp-zed) |
|
||||
| **Remote Control 私有部署** | Docker 自托管远程界面, 可以手机上看 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 可以开关 |
|
||||
| **Channels 频道通知** | MCP 服务器推送外部消息到会话(飞书/Slack/Discord/微信等),`--channels plugin:name@marketplace` 启用 | [文档](https://ccb.agent-aura.top/docs/features/channels) |
|
||||
| **自定义模型供应商** | 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) |
|
||||
| **自定义模型供应商** | OpenAI/Anthropic/Gemini/Grok 兼容 (`/login`) | [文档](https://ccb.agent-aura.top/docs/features/all-features-guide) |
|
||||
| Voice Mode | 语音输入,支持豆包语言输入(`/voice doubao`) | [文档](https://ccb.agent-aura.top/docs/features/voice-mode) |
|
||||
| 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) |
|
||||
@@ -60,11 +62,66 @@ CLAUDE_BRIDGE_BASE_URL=https://remote-control.claude-code-best.win/ CLAUDE_BRIDG
|
||||
一定要最新版本的 bun 啊, 不然一堆奇奇怪怪的 BUG!!! bun upgrade!!!
|
||||
|
||||
- 📦 [Bun](https://bun.sh/) >= 1.3.11
|
||||
|
||||
**安装 Bun:**
|
||||
|
||||
```bash
|
||||
# Linux 和 macOS
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
|
||||
# Windows (PowerShell)
|
||||
powershell -c "irm bun.sh/install.ps1 | iex"
|
||||
```
|
||||
|
||||
**安装后的操作:**
|
||||
|
||||
1. **让当前终端识别 `bun` 命令**
|
||||
|
||||
安装脚本会把 `~/.bun/bin` 写入对应的 shell 配置文件。macOS 默认 zsh 环境通常会看到:
|
||||
|
||||
```text
|
||||
Added "~/.bun/bin" to $PATH in "~/.zshrc"
|
||||
```
|
||||
|
||||
可以按安装脚本提示重启当前 shell:
|
||||
|
||||
```bash
|
||||
exec /bin/zsh
|
||||
```
|
||||
|
||||
如果你使用 bash,重新加载 bash 配置:
|
||||
|
||||
```bash
|
||||
source ~/.bashrc
|
||||
```
|
||||
|
||||
Windows PowerShell 用户关闭并重新打开 PowerShell 即可。
|
||||
|
||||
2. **验证 Bun 是否可用**
|
||||
|
||||
```bash
|
||||
bun --help
|
||||
bun --version
|
||||
```
|
||||
|
||||
3. **如果已经安装过 Bun,更新到最新版本**
|
||||
|
||||
```bash
|
||||
bun upgrade
|
||||
```
|
||||
|
||||
- ⚙️ 常规的配置 CC 的方式, 各大提供商都有自己的配置方式
|
||||
|
||||
### 📍 命令执行位置
|
||||
|
||||
- 安装或检查 Bun 的命令可以在任意目录执行:
|
||||
`curl -fsSL https://bun.sh/install | bash`、`bun --help`、`bun --version`、`bun upgrade`
|
||||
- 安装本项目依赖、启动开发模式、构建项目时,必须先进入本仓库根目录,也就是包含 `package.json` 的目录。
|
||||
|
||||
### 📥 安装
|
||||
|
||||
```bash
|
||||
cd /path/to/claude-code
|
||||
bun install
|
||||
```
|
||||
|
||||
@@ -176,6 +233,10 @@ TUI (REPL) 模式需要真实终端,无法直接通过 VS Code launch 启动
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## 致谢
|
||||
|
||||
- [doubaoime-asr](https://github.com/starccy/doubaoime-asr) — 豆包 ASR 语音识别 SDK,为 Voice Mode 提供无需 Anthropic OAuth 的语音输入方案
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目仅供学习研究用途。Claude Code 的所有权利归 [Anthropic](https://www.anthropic.com/) 所有。
|
||||
|
||||
55
README_EN.md
55
README_EN.md
@@ -48,11 +48,64 @@ Sponsor placeholder.
|
||||
Make sure you're on the latest version of Bun, otherwise you'll run into all sorts of weird bugs. Run `bun upgrade`!
|
||||
|
||||
- [Bun](https://bun.sh/) >= 1.3.11
|
||||
|
||||
**Install Bun:**
|
||||
|
||||
```bash
|
||||
# Linux and macOS
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
|
||||
# Windows (PowerShell)
|
||||
powershell -c "irm bun.sh/install.ps1 | iex"
|
||||
```
|
||||
|
||||
**Post-installation steps:**
|
||||
|
||||
1. **Make `bun` available in the current terminal**
|
||||
|
||||
The installer adds `~/.bun/bin` to the matching shell configuration file. On macOS with the default zsh shell, you may see:
|
||||
|
||||
```text
|
||||
Added "~/.bun/bin" to $PATH in "~/.zshrc"
|
||||
```
|
||||
|
||||
Restart the current shell as the installer suggests:
|
||||
|
||||
```bash
|
||||
exec /bin/zsh
|
||||
```
|
||||
|
||||
If you use bash, reload the bash configuration:
|
||||
|
||||
```bash
|
||||
source ~/.bashrc
|
||||
```
|
||||
|
||||
Windows PowerShell users can close and reopen PowerShell.
|
||||
|
||||
2. **Verify that Bun is available:**
|
||||
```bash
|
||||
bun --help
|
||||
bun --version
|
||||
```
|
||||
|
||||
3. **Update to latest version (if already installed):**
|
||||
```bash
|
||||
bun upgrade
|
||||
```
|
||||
|
||||
- Standard Claude Code configuration — each provider has its own setup method
|
||||
|
||||
### Command Execution Location
|
||||
|
||||
- Bun installation and checking commands can be run from any directory:
|
||||
`curl -fsSL https://bun.sh/install | bash`, `bun --help`, `bun --version`, `bun upgrade`
|
||||
- Project dependency installation, development mode, and builds must be run from this repository root, the directory containing `package.json`.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
cd /path/to/claude-code
|
||||
bun install
|
||||
```
|
||||
|
||||
@@ -135,7 +188,7 @@ The TUI (REPL) mode requires a real terminal and cannot be launched directly via
|
||||
## Documentation & Links
|
||||
|
||||
- **Online docs (Mintlify)**: [ccb.agent-aura.top](https://ccb.agent-aura.top/) — source in [`docs/`](docs/), PR contributions welcome
|
||||
- **DeepWiki**: <https://deepwiki.com/claude-code-best/claude-code>
|
||||
- **DeepWiki**: https://deepwiki.com/claude-code-best/claude-code
|
||||
|
||||
## Contributors
|
||||
|
||||
|
||||
12
build.ts
12
build.ts
@@ -75,10 +75,14 @@ console.log(
|
||||
`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)
|
||||
const vendorDir = join(outdir, 'vendor', 'audio-capture')
|
||||
await cp('vendor/audio-capture', vendorDir, { recursive: true })
|
||||
console.log(`Copied vendor/audio-capture/ → ${vendorDir}/`)
|
||||
// Step 4: Copy native .node addon files (audio-capture) and vendored binaries (ripgrep)
|
||||
const audioCaptureDir = join(outdir, 'vendor', 'audio-capture')
|
||||
await cp('vendor/audio-capture', audioCaptureDir, { recursive: true })
|
||||
console.log(`Copied vendor/audio-capture/ → ${audioCaptureDir}/`)
|
||||
|
||||
const ripgrepDir = join(outdir, 'vendor', 'ripgrep')
|
||||
await cp('src/utils/vendor/ripgrep', ripgrepDir, { recursive: true })
|
||||
console.log(`Copied src/utils/vendor/ripgrep/ → ${ripgrepDir}/`)
|
||||
|
||||
// Step 5: Generate cli-bun and cli-node executable entry points
|
||||
const cliBun = join(outdir, 'cli-bun.js')
|
||||
|
||||
19
bun.lock
19
bun.lock
@@ -145,6 +145,9 @@
|
||||
"yaml": "^2.8.3",
|
||||
"zod": "^4.3.6",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"doubaoime-asr": "^0.1.0",
|
||||
},
|
||||
},
|
||||
"packages/@ant/claude-for-chrome-mcp": {
|
||||
"name": "@ant/claude-for-chrome-mcp",
|
||||
@@ -264,10 +267,6 @@
|
||||
"name": "modifiers-napi",
|
||||
"version": "1.0.0",
|
||||
},
|
||||
"packages/pokemon": {
|
||||
"name": "@claude-code-best/pokemon",
|
||||
"version": "1.0.0",
|
||||
},
|
||||
"packages/remote-control-server": {
|
||||
"name": "@anthropic/remote-control-server",
|
||||
"version": "0.1.0",
|
||||
@@ -577,8 +576,6 @@
|
||||
|
||||
"@claude-code-best/mcp-client": ["@claude-code-best/mcp-client@workspace:packages/mcp-client"],
|
||||
|
||||
"@claude-code-best/pokemon": ["@claude-code-best/pokemon@workspace:packages/pokemon"],
|
||||
|
||||
"@claude-code-best/weixin": ["@claude-code-best/weixin@workspace:packages/weixin"],
|
||||
|
||||
"@commander-js/extra-typings": ["@commander-js/extra-typings@14.0.0", "https://registry.npmmirror.com/@commander-js/extra-typings/-/extra-typings-14.0.0.tgz", { "peerDependencies": { "commander": "~14.0.0" } }, "sha512-hIn0ncNaJRLkZrxBIp5AsW/eXEHNKYQBh0aPdoUqNgD+Io3NIykQqpKFyKcuasZhicGaEZJX/JBSIkZ4e5x8Dg=="],
|
||||
@@ -1797,6 +1794,8 @@
|
||||
|
||||
"dompurify": ["dompurify@3.4.0", "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.0.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg=="],
|
||||
|
||||
"doubaoime-asr": ["doubaoime-asr@0.1.0", "", { "dependencies": { "opus-encdec": "^0.1.1", "protobufjs": "^8.0.0", "ws": "^8.18.0" }, "bin": { "doubaoime-asr": "bin/doubaoime-asr.mjs" } }, "sha512-HYUfHkTxNdOoztXwS18e6GBRLY9dSDWX43K4WvPvEmO6+RevO6WbawMMoUfHKPb4ySQn461un7XyN5l4UGejwg=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "https://registry.npmmirror.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
|
||||
@@ -2349,6 +2348,8 @@
|
||||
|
||||
"openai": ["openai@6.34.0", "https://registry.npmmirror.com/openai/-/openai-6.34.0.tgz", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-yEr2jdGf4tVFYG6ohmr3pF6VJuveP0EA/sS8TBx+4Eq5NT10alu5zg2dmxMXMgqpihRDQlFGpRt2XwsGj+Fyxw=="],
|
||||
|
||||
"opus-encdec": ["opus-encdec@0.1.1", "", {}, "sha512-TDzyGqYqrwn5UEUNaLsfLGu8Ma+HRNrgLYj7Vx5wfTnafAA21G6Bnm/qTIa3orQi/yZPZYmkdpO/gez4nfA1Rw=="],
|
||||
|
||||
"os-tmpdir": ["os-tmpdir@1.0.2", "https://registry.npmmirror.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz", {}, "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g=="],
|
||||
|
||||
"oxc-parser": ["oxc-parser@0.121.0", "https://registry.npmmirror.com/oxc-parser/-/oxc-parser-0.121.0.tgz", { "dependencies": { "@oxc-project/types": "^0.121.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.121.0", "@oxc-parser/binding-android-arm64": "0.121.0", "@oxc-parser/binding-darwin-arm64": "0.121.0", "@oxc-parser/binding-darwin-x64": "0.121.0", "@oxc-parser/binding-freebsd-x64": "0.121.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.121.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.121.0", "@oxc-parser/binding-linux-arm64-gnu": "0.121.0", "@oxc-parser/binding-linux-arm64-musl": "0.121.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.121.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.121.0", "@oxc-parser/binding-linux-riscv64-musl": "0.121.0", "@oxc-parser/binding-linux-s390x-gnu": "0.121.0", "@oxc-parser/binding-linux-x64-gnu": "0.121.0", "@oxc-parser/binding-linux-x64-musl": "0.121.0", "@oxc-parser/binding-openharmony-arm64": "0.121.0", "@oxc-parser/binding-wasm32-wasi": "0.121.0", "@oxc-parser/binding-win32-arm64-msvc": "0.121.0", "@oxc-parser/binding-win32-ia32-msvc": "0.121.0", "@oxc-parser/binding-win32-x64-msvc": "0.121.0" } }, "sha512-ek9o58+SCv6AV7nchiAcUJy1DNE2CC5WRdBcO0mF+W4oRjNQfPO7b3pLjTHSFECpHkKGOZSQxx3hk8viIL5YCg=="],
|
||||
@@ -2441,7 +2442,7 @@
|
||||
|
||||
"property-information": ["property-information@7.1.0", "https://registry.npmmirror.com/property-information/-/property-information-7.1.0.tgz", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
|
||||
|
||||
"protobufjs": ["protobufjs@7.5.4", "https://registry.npmmirror.com/protobufjs/-/protobufjs-7.5.4.tgz", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
|
||||
"protobufjs": ["protobufjs@8.0.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-NWWCCscLjs+cOKF/s/XVNFRW7Yih0fdH+9brffR5NZCy8k42yRdl5KlWKMVXuI1vfCoy4o1z80XR/W/QUb3V3w=="],
|
||||
|
||||
"proxy-addr": ["proxy-addr@2.0.7", "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||
|
||||
@@ -3035,6 +3036,8 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@grpc/proto-loader/protobufjs": ["protobufjs@7.5.4", "https://registry.npmmirror.com/protobufjs/-/protobufjs-7.5.4.tgz", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@hono/node-ws/@hono/node-server": ["@hono/node-server@1.19.13", "https://registry.npmmirror.com/@hono/node-server/-/node-server-1.19.13.tgz", { "peerDependencies": { "hono": "^4" } }, "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ=="],
|
||||
@@ -3129,6 +3132,8 @@
|
||||
|
||||
"@opentelemetry/otlp-transformer/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.6.1", "https://registry.npmmirror.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.1.tgz", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw=="],
|
||||
|
||||
"@opentelemetry/otlp-transformer/protobufjs": ["protobufjs@7.5.4", "https://registry.npmmirror.com/protobufjs/-/protobufjs-7.5.4.tgz", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
|
||||
|
||||
"@opentelemetry/sdk-logs/@opentelemetry/core": ["@opentelemetry/core@2.6.1", "https://registry.npmmirror.com/@opentelemetry/core/-/core-2.6.1.tgz", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="],
|
||||
|
||||
"@opentelemetry/sdk-logs/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "https://registry.npmmirror.com/@opentelemetry/resources/-/resources-2.6.1.tgz", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,160 +0,0 @@
|
||||
# Feature Flags 审查报告 — Codex 复核
|
||||
|
||||
> 审查日期: 2026-04-05
|
||||
> 审查工具: Codex CLI v0.118.0 (本地, full-auto mode)
|
||||
> 消耗 tokens: 240,306
|
||||
> 审查范围: docs/feature-flags-audit-complete.md 中标记为 COMPLETE 的 22 个编译时 feature flag
|
||||
|
||||
---
|
||||
|
||||
## 审查背景
|
||||
|
||||
原始审计报告 (`docs/feature-flags-audit-complete.md`) 声称 22 个 feature flag 被标记为 "COMPLETE",只需在 `build.ts` / `scripts/dev.ts` 中启用即可工作。
|
||||
|
||||
Claude Code 团队通过 6 个并行子代理实际读取源码后初步发现大量误判,随后将分析结果传递给 Codex CLI 进行独立二次验证。
|
||||
|
||||
---
|
||||
|
||||
## Codex 发现摘要
|
||||
|
||||
### High 级发现
|
||||
|
||||
1. **`CONTEXT_COLLAPSE` 不是 COMPLETE**
|
||||
- `src/services/contextCollapse/index.ts:43` — `isContextCollapseEnabled()` 硬编码为 `false`
|
||||
- `src/services/contextCollapse/index.ts:47` — `applyCollapsesIfNeeded()` 只是原样返回消息
|
||||
- `src/services/contextCollapse/index.ts:59` — `recoverFromOverflow()` 也是 no-op
|
||||
- `src/services/contextCollapse/operations.ts:3` 和 `persist.ts:3` 同样是 stub
|
||||
- 审计报告把 UI/命令文件算进去了,但真正被查询循环消费的是 stub 后端
|
||||
|
||||
2. **原分类"真正只需编译开关"的 7 个 flag,只有 3 个准确**
|
||||
- ✅ `SHOT_STATS` — 零额外门控,compile-only
|
||||
- ✅ `PROMPT_CACHE_BREAK_DETECTION` — 有 try-catch 兜底,compile-only
|
||||
- ✅ `TOKEN_BUDGET` — 纯本地计算,compile-only
|
||||
- ❌ `TEAMMEM` — 还要求 AutoMem + GrowthBook `tengu_herring_clock` + GitHub repo (`teamMemPaths.ts:73`, `watcher.ts:256`, `watcher.ts:259`)
|
||||
- ❌ `AGENT_TRIGGERS` — 受 `isKairosCronEnabled()` GrowthBook 控制 (`useScheduledTasks.ts:61`, `useScheduledTasks.ts:119`)
|
||||
- ❌ `EXTRACT_MEMORIES` — 受 `tengu_passport_quail` + AutoMem + 非 remote 限制 (`extractMemories.ts:536`, `:545`, `:550`)
|
||||
- ❌ `KAIROS_BRIEF` — 受 `tengu_kairos_brief` + opt-in/kairosActive 限制 (`BriefTool.ts:95`, `:126`, `:132`)
|
||||
|
||||
### Medium 级发现
|
||||
|
||||
3. **`BG_SESSIONS` 和 `BASH_CLASSIFIER` 不适合简单归为"全 stub"**
|
||||
- `BG_SESSIONS` — 会话注册/清理是真实现 (`concurrentSessions.ts:44`, `:55`),但任务摘要核心是 stub (`taskSummary.ts:2`)
|
||||
- `BASH_CLASSIFIER` — 权限编排很大一块是真实现 (`bashPermissions.ts` 2621行),但分类后端 `bashClassifier.ts:24` 永远返回 disabled
|
||||
|
||||
4. **审计口径问题**
|
||||
- 把"代码量/周边 UI 很多"误当成"可独立启用"
|
||||
- `PROACTIVE` — `index.ts:3` 只有 state stub,`commands.ts:64` 和 `REPL.tsx:415` 引用缺失文件
|
||||
- `REACTIVE_COMPACT` — `reactiveCompact.ts:13` 整块是 stub
|
||||
- `CACHED_MICROCOMPACT` — `cachedMicrocompact.ts:22` 全部 stub
|
||||
|
||||
---
|
||||
|
||||
## Codex 修正后的分类
|
||||
|
||||
### 第一类:真正 compile-only(3 个)
|
||||
|
||||
| Flag | 说明 | Crash 风险 |
|
||||
|------|------|-----------|
|
||||
| **SHOT_STATS** | 纯本地 shot 分布统计,ant-only 数据路径 | 低 |
|
||||
| **PROMPT_CACHE_BREAK_DETECTION** | 本地 cache key 变化检测,写 diff 有兜底 | 低 |
|
||||
| **TOKEN_BUDGET** | 本地 token 预算追踪,纯计算逻辑 | 低 |
|
||||
|
||||
### 第二类:compile + 运行时条件(7 个)
|
||||
|
||||
| Flag | 条件 | Crash 风险 |
|
||||
|------|------|-----------|
|
||||
| **TEAMMEM** | AutoMem + GrowthBook `tengu_herring_clock` + GitHub repo | 低 (clean no-op) |
|
||||
| **AGENT_TRIGGERS** | GrowthBook `isKairosCronEnabled()` | 低 (clean no-op) |
|
||||
| **EXTRACT_MEMORIES** | `tengu_passport_quail` + AutoMem + 非 remote | 低 (clean no-op) |
|
||||
| **KAIROS_BRIEF** | `tengu_kairos_brief` + opt-in/kairosActive,可用 `CLAUDE_CODE_BRIEF=1` 绕过 | 低 |
|
||||
| **COORDINATOR_MODE** | 需 `CLAUDE_CODE_COORDINATOR_MODE=1`,`workerAgent.ts` 是 stub 但不阻塞 | 低 |
|
||||
| **COMMIT_ATTRIBUTION** | 仅对 `isInternal=true` 的 repo 生效 | 低 |
|
||||
| **VERIFICATION_AGENT** | 受 GrowthBook `tengu_hive_evidence` 双重门控 | 低 |
|
||||
|
||||
### 第三类:混合型 — 部分实现 + stub 核心(5 个)
|
||||
|
||||
| Flag | 真实现部分 | Stub 核心 |
|
||||
|------|-----------|----------|
|
||||
| **BG_SESSIONS** | 会话注册/清理 (`concurrentSessions.ts`) | `bg.ts`/`taskSummary.ts`/`udsClient.ts` 全 stub + 依赖 tmux |
|
||||
| **BASH_CLASSIFIER** | 权限编排 (`bashPermissions.ts` 2621行) | `bashClassifier.ts` 分类后端 stub + 需 API beta |
|
||||
| **PROACTIVE** | REPL/命令注册框架 | `index.ts` stub + 3 文件缺失 |
|
||||
| **REACTIVE_COMPACT** | 调用点已在主查询环路 | `reactiveCompact.ts` 22行全 no-op |
|
||||
| **CACHED_MICROCOMPACT** | 调用点已布线 | `cachedMicrocompact.ts` 全 stub + 需未公开 API |
|
||||
|
||||
### 第四类:纯 stub(1 个)
|
||||
|
||||
| Flag | 问题 |
|
||||
|------|------|
|
||||
| **CONTEXT_COLLAPSE** | 3 核心文件全 stub + CtxInspectTool 目录不存在 |
|
||||
|
||||
### 第五类:依赖远程服务(3 个)
|
||||
|
||||
| Flag | 依赖 |
|
||||
|------|------|
|
||||
| **ULTRAPLAN** | CCR 远程 agent 基础设施 + OAuth |
|
||||
| **CCR_REMOTE_SETUP** | claude.ai OAuth + GitHub CLI + CCR 后端 |
|
||||
| **BRIDGE_MODE** (build端) | claude.ai 订阅 + GrowthBook + WebSocket 后端 |
|
||||
|
||||
---
|
||||
|
||||
## 第三类恢复优先级建议
|
||||
|
||||
Codex 推荐的恢复顺序:
|
||||
|
||||
1. **REACTIVE_COMPACT** — 收益最直接,调用点在主查询环路,改完最容易立刻见效
|
||||
2. **BG_SESSIONS** — 已有会话注册基础,补齐摘要和后台运行链路的 ROI 高
|
||||
3. **PROACTIVE** — 产品面大,但缺文件比 stub 更严重,范围比前两项大
|
||||
4. **CONTEXT_COLLAPSE** — collapse engine 全 stub,恢复成本和设计不确定性都高
|
||||
5. **BASH_CLASSIFIER** — 若无 API beta 能力不值得优先;若有则升到第 2
|
||||
6. **CACHED_MICROCOMPACT** — 受未公开 API 约束,最后做
|
||||
|
||||
---
|
||||
|
||||
## 审计报告分类标准修正建议
|
||||
|
||||
Codex 建议将原来的单轴分类(COMPLETE/PARTIAL/STUB)改为**三轴**:
|
||||
|
||||
| 轴 | 取值 | 说明 |
|
||||
|----|------|------|
|
||||
| **实现完整度** | `full` / `mixed` / `stub` | 活跃调用链上的核心模块是否有真实现 |
|
||||
| **激活条件** | `compile-only` / `compile+env` / `compile+GrowthBook` / `compile+remote` / `compile+private API` | 启用需要什么 |
|
||||
| **运行风险** | `safe no-op` / `background IO` / `startup critical` | 启用后条件不满足时的行为 |
|
||||
|
||||
**COMPLETE 的最低标准应满足:**
|
||||
1. 活跃调用链上的核心模块不能是 stub
|
||||
2. "可启用"不能只看编译 flag,还要单列运行时 gate
|
||||
|
||||
按此标准,`CONTEXT_COLLAPSE`、`BG_SESSIONS`、`BASH_CLASSIFIER`、`PROACTIVE`、`REACTIVE_COMPACT`、`CACHED_MICROCOMPACT` 都应从 COMPLETE 降级。
|
||||
|
||||
---
|
||||
|
||||
## 已采取的行动
|
||||
|
||||
基于审查结果,已将以下 3 个确认安全的 flag 加入默认构建:
|
||||
|
||||
**build.ts:**
|
||||
```typescript
|
||||
const DEFAULT_BUILD_FEATURES = [
|
||||
"AGENT_TRIGGERS_REMOTE", "CHICAGO_MCP", "VOICE_MODE",
|
||||
"SHOT_STATS", "PROMPT_CACHE_BREAK_DETECTION", "TOKEN_BUDGET"
|
||||
];
|
||||
```
|
||||
|
||||
**scripts/dev.ts:**
|
||||
```typescript
|
||||
const DEFAULT_FEATURES = [
|
||||
"BUDDY", "TRANSCRIPT_CLASSIFIER", "BRIDGE_MODE",
|
||||
"AGENT_TRIGGERS_REMOTE", "CHICAGO_MCP", "VOICE_MODE",
|
||||
"SHOT_STATS", "PROMPT_CACHE_BREAK_DETECTION", "TOKEN_BUDGET"
|
||||
];
|
||||
```
|
||||
|
||||
### 验证结果
|
||||
|
||||
| 项目 | 结果 |
|
||||
|------|------|
|
||||
| `bun run build` | ✅ 成功 (475 files) |
|
||||
| `bun test` | ✅ 无新增失败 (23 fail 为已有问题) |
|
||||
| SHOT_STATS 代码路径 | ✅ 完整 — stats 面板显示 shot 分布 |
|
||||
| TOKEN_BUDGET 代码路径 | ✅ 完整 — 支持 `+500k` 语法,带进度条 |
|
||||
| PROMPT_CACHE_BREAK_DETECTION 代码路径 | ✅ 完整 — 内部诊断,debug 模式可见 |
|
||||
@@ -145,8 +145,8 @@ M 键(或 ← / →)用于在两种路由模式之间切换,**无需展开
|
||||
|
||||
```
|
||||
/pipes — 显示所有实例 + 切换选择面板
|
||||
/pipes select <name> — 选中某实例(消息会广播到它)
|
||||
/pipes deselect <name> — 取消选中
|
||||
/pipes select <name> — 选中某实例(消息会广播到它)
|
||||
/pipes deselect <name> — 取消选中
|
||||
/pipes all — 全选
|
||||
/pipes none — 全部取消
|
||||
```
|
||||
@@ -169,7 +169,7 @@ LAN Peers:
|
||||
Selected: cli-da029538
|
||||
```
|
||||
|
||||
### /attach <name>
|
||||
### /attach <name>
|
||||
|
||||
手动 attach 到一个实例,使其成为你的 slave。
|
||||
|
||||
@@ -179,7 +179,7 @@ Selected: cli-da029538
|
||||
|
||||
attach 后,对方变为 slave,你变为 master。可以向它发送 prompt。通常不需要手动 attach——heartbeat 会自动发现并连接。
|
||||
|
||||
### /detach <name>
|
||||
### /detach <name>
|
||||
|
||||
断开与某个 slave 的连接。
|
||||
|
||||
@@ -187,7 +187,7 @@ attach 后,对方变为 slave,你变为 master。可以向它发送 prompt
|
||||
/detach cli-04d67950
|
||||
```
|
||||
|
||||
### /send <name> <message>
|
||||
### /send <name> <message>
|
||||
|
||||
向指定 pipe 发送消息(不依赖选择状态,直接指定目标)。
|
||||
|
||||
|
||||
426
docs/features/ssh-remote.md
Normal file
426
docs/features/ssh-remote.md
Normal file
@@ -0,0 +1,426 @@
|
||||
# SSH Remote — 远程主机运行 Claude Code
|
||||
|
||||
## 概述
|
||||
|
||||
SSH Remote 提供两种方式在远程 Linux 主机上运行 Claude Code:
|
||||
|
||||
1. **SSH Remote 模块**(`ccb ssh <host>`)— 本地 REPL + 远程工具执行,自动部署二进制 + 认证隧道
|
||||
2. **直接 SSH 运行**(`ssh <host> -t ccb`)— 远程已安装 ccb,直接启动交互式会话
|
||||
|
||||
## 架构
|
||||
|
||||
### 方式一:SSH Remote 模块(完整模式)
|
||||
|
||||
适用场景:远端没有 API 凭据或没有安装 ccb。
|
||||
|
||||
```
|
||||
┌──────────────── 本地 Windows/Mac/Linux ───────────┐
|
||||
│ │
|
||||
│ ccb ssh <host> [dir] │
|
||||
│ │ │
|
||||
│ ├── 1. SSHProbe: 探测远端平台/架构/已有二进制 │
|
||||
│ ├── 2. SSHDeploy: 部署 dist/ 到远端 │
|
||||
│ ├── 3. SSHAuthProxy: 启动本地认证代理 │
|
||||
│ │ ├─ Unix Socket (Linux/Mac) │
|
||||
│ │ └─ TCP 127.0.0.1:<port> (Windows) │
|
||||
│ │ │
|
||||
│ └── 4. SSH -R 反向隧道 + 启动远端 CLI │
|
||||
│ ssh -R <remote>:<local> <host> \ │
|
||||
│ ANTHROPIC_BASE_URL=... \ │
|
||||
│ ANTHROPIC_AUTH_NONCE=... \ │
|
||||
│ ccb --output-format stream-json │
|
||||
│ │
|
||||
│ ┌─────── 本地 REPL (Ink TUI) ───────┐ │
|
||||
│ │ 用户输入 → NDJSON → SSH stdin │ │
|
||||
│ │ SSH stdout → NDJSON → 渲染消息 │ │
|
||||
│ │ 工具权限请求 → 本地审批 → 回传 │ │
|
||||
│ └────────────────────────────────────┘ │
|
||||
└────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ SSH 连接 (加密通道)
|
||||
│
|
||||
┌───────────────── 远端 Linux ──────────────────────┐
|
||||
│ │
|
||||
│ ccb (自动部署或已存在) │
|
||||
│ ├── --output-format stream-json │
|
||||
│ ├── --input-format stream-json │
|
||||
│ ├── --verbose -p │
|
||||
│ │ │
|
||||
│ ├── API 请求 → ANTHROPIC_BASE_URL │
|
||||
│ │ → SSH 反向隧道 → 本地 AuthProxy │
|
||||
│ │ → 注入真实凭据 → api.anthropic.com │
|
||||
│ │ │
|
||||
│ └── 工具执行 (Bash/Read/Write/...) │
|
||||
│ 直接在远端文件系统上操作 │
|
||||
└────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 方式二:直接 SSH 运行(简单模式)
|
||||
|
||||
适用场景:远端已安装 ccb 且已有 API 凭据(订阅或 API Key)。
|
||||
|
||||
```
|
||||
┌─────── 本地终端 ───────┐ ┌──────── 远端 Linux ────────┐
|
||||
│ │ SSH │ │
|
||||
│ ssh <host> -t ccb │ ──────→ │ ccb (全局安装) │
|
||||
│ │ │ ├── 使用远端自身凭据 │
|
||||
│ 终端直接显示远端 TUI │ ←────── │ ├── 远端文件系统操作 │
|
||||
│ │ TTY │ └── API 直连 Anthropic │
|
||||
└─────────────────────────┘ └─────────────────────────────┘
|
||||
```
|
||||
|
||||
### 适用场景对比
|
||||
|
||||
| | SSH Remote 模块 | 直接 SSH 运行 |
|
||||
|---|---|---|
|
||||
| 远端需要安装 ccb | 不需要(自动部署) | 需要 |
|
||||
| 远端需要 API 凭据 | 不需要(本地隧道) | 需要 |
|
||||
| 本地需要安装 ccb | 需要 | 不需要(任何终端) |
|
||||
| 斜杠命令 | 本地处理 | 远端处理 |
|
||||
| 网络延迟敏感 | 高(NDJSON 双向) | 低(仅 TTY) |
|
||||
| 推荐场景 | 远端无凭据/无安装 | 远端已配置完整 |
|
||||
|
||||
---
|
||||
|
||||
## 前置准备:SSH 密钥配置
|
||||
|
||||
两种方式都依赖 SSH 免密连接。以下是完整的密钥配置步骤。
|
||||
|
||||
### 1. 生成 SSH 密钥对(本地)
|
||||
|
||||
```bash
|
||||
# 生成 Ed25519 密钥(推荐)
|
||||
ssh-keygen -t ed25519 -C "your-email@example.com" -f ~/.ssh/id_remote
|
||||
|
||||
# 或 RSA 4096 位
|
||||
ssh-keygen -t rsa -b 4096 -C "your-email@example.com" -f ~/.ssh/id_remote
|
||||
```
|
||||
|
||||
生成两个文件:
|
||||
- `~/.ssh/id_remote` — 私钥(不可泄露)
|
||||
- `~/.ssh/id_remote.pub` — 公钥(部署到远端)
|
||||
|
||||
### 2. 将公钥部署到远端
|
||||
|
||||
```bash
|
||||
# 方式 A:ssh-copy-id(推荐)
|
||||
ssh-copy-id -i ~/.ssh/id_remote.pub user@remote-host
|
||||
|
||||
# 方式 B:手动复制
|
||||
cat ~/.ssh/id_remote.pub | ssh user@remote-host "mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
|
||||
```
|
||||
|
||||
### 3. 配置 SSH Config(本地)
|
||||
|
||||
编辑 `~/.ssh/config`(不存在则创建):
|
||||
|
||||
```
|
||||
Host my-server
|
||||
HostName 192.168.1.100 # 远端 IP 或域名
|
||||
User root # 远端用户名
|
||||
IdentityFile ~/.ssh/id_remote # 私钥路径
|
||||
ServerAliveInterval 60 # 防止连接超时断开
|
||||
ServerAliveCountMax 3
|
||||
```
|
||||
|
||||
配置后可直接用别名连接:
|
||||
|
||||
```bash
|
||||
ssh my-server # 等同于 ssh -i ~/.ssh/id_remote root@192.168.1.100
|
||||
```
|
||||
|
||||
### 4. 文件权限设置
|
||||
|
||||
#### Linux / macOS
|
||||
|
||||
```bash
|
||||
chmod 700 ~/.ssh
|
||||
chmod 600 ~/.ssh/config
|
||||
chmod 600 ~/.ssh/id_remote
|
||||
chmod 644 ~/.ssh/id_remote.pub
|
||||
```
|
||||
|
||||
#### Windows(OpenSSH 强制 ACL 检查)
|
||||
|
||||
```powershell
|
||||
# 重置 .ssh 目录权限:仅允许当前用户 + SYSTEM
|
||||
icacls "$env:USERPROFILE\.ssh" /inheritance:r /grant:r "$($env:USERNAME):(OI)(CI)F" /grant "SYSTEM:(OI)(CI)F"
|
||||
|
||||
# 修复 config 文件权限
|
||||
icacls "$env:USERPROFILE\.ssh\config" /inheritance:r /grant:r "$($env:USERNAME):F" /grant "SYSTEM:F"
|
||||
|
||||
# 修复私钥权限
|
||||
icacls "$env:USERPROFILE\.ssh\id_remote" /inheritance:r /grant:r "$($env:USERNAME):F" /grant "SYSTEM:F"
|
||||
```
|
||||
|
||||
> **Windows 常见错误**:如果 `icacls` 显示 `UNKNOWN\UNKNOWN` ACL 条目,需要先移除再重新授权。权限错误会导致 SSH 拒绝使用密钥。
|
||||
|
||||
### 5. 验证免密连接
|
||||
|
||||
```bash
|
||||
ssh my-server "echo 'SSH connection OK'"
|
||||
# 应直接输出 "SSH connection OK",不要求输入密码
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 使用方式
|
||||
|
||||
### 方式一:SSH Remote 模块
|
||||
|
||||
```bash
|
||||
# 基本用法 — 自动探测、部署、启动
|
||||
ccb ssh user@remote-host
|
||||
|
||||
# 使用 SSH Config 别名
|
||||
ccb ssh my-server
|
||||
|
||||
# 指定远端工作目录
|
||||
ccb ssh my-server /home/user/project
|
||||
|
||||
# 使用自定义远端二进制(跳过探测/部署)
|
||||
ccb ssh my-server --remote-bin "bun /opt/ccb/dist/cli.js"
|
||||
|
||||
# 权限控制
|
||||
ccb ssh my-server --permission-mode auto
|
||||
ccb ssh my-server --dangerously-skip-permissions
|
||||
|
||||
# 恢复远端会话
|
||||
ccb ssh my-server --continue
|
||||
ccb ssh my-server --resume <session-uuid>
|
||||
|
||||
# 选择模型
|
||||
ccb ssh my-server --model claude-sonnet-4-6-20250514
|
||||
|
||||
# 本地测试模式(不连接远端,测试 auth proxy 管道)
|
||||
ccb ssh localhost --local
|
||||
```
|
||||
|
||||
### 方式二:直接 SSH 运行
|
||||
|
||||
```bash
|
||||
# 启动交互式会话
|
||||
ssh my-server -t ccb
|
||||
|
||||
# 指定工作目录
|
||||
ssh my-server -t "ccb --cwd /home/user/project"
|
||||
|
||||
# 使用特定模型
|
||||
ssh my-server -t "ccb --model claude-sonnet-4-6-20250514"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 构建与部署
|
||||
|
||||
### 构建产物
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
bun install
|
||||
|
||||
# 构建(输出到 dist/)
|
||||
bun run build
|
||||
```
|
||||
|
||||
产物说明:
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `dist/cli.js` | Bun 入口(`#!/usr/bin/env bun`) |
|
||||
| `dist/cli-node.js` | Node.js 入口(`#!/usr/bin/env node` → `import ./cli.js`) |
|
||||
| `dist/cli-bun.js` | Bun 专用入口 |
|
||||
| `dist/chunk-*.js` | 代码分割 chunk 文件(约 668 个) |
|
||||
|
||||
### 运行方式
|
||||
|
||||
```bash
|
||||
# 方式 A:通过 bun 直接运行(开发/调试)
|
||||
bun run dev
|
||||
|
||||
# 方式 B:运行构建产物(bun 运行时)
|
||||
bun dist/cli.js
|
||||
|
||||
# 方式 C:运行构建产物(node 运行时)
|
||||
node dist/cli-node.js
|
||||
|
||||
# 方式 D:全局安装后使用命令名
|
||||
ccb
|
||||
```
|
||||
|
||||
### 全局安装
|
||||
|
||||
在项目根目录执行:
|
||||
|
||||
```bash
|
||||
# bun 全局安装(推荐)
|
||||
bun install -g .
|
||||
|
||||
# 创建的命令:
|
||||
# ccb → dist/cli-node.js
|
||||
# ccb-bun → dist/cli-bun.js
|
||||
# claude-code-best → dist/cli-node.js
|
||||
|
||||
# 安装位置:~/.bun/bin/ccb
|
||||
```
|
||||
|
||||
或使用 npm:
|
||||
|
||||
```bash
|
||||
npm install -g .
|
||||
```
|
||||
|
||||
验证:
|
||||
|
||||
```bash
|
||||
ccb --version
|
||||
# → x.x.x (Claude Code)
|
||||
```
|
||||
|
||||
### 远端部署(全流程)
|
||||
|
||||
```bash
|
||||
# 1. 登录远端
|
||||
ssh my-server
|
||||
|
||||
# 2. 克隆或同步项目代码
|
||||
git clone <repo-url> ~/ccb-project
|
||||
cd ~/ccb-project
|
||||
|
||||
# 3. 安装运行时(如果没有 bun)
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
source ~/.bashrc
|
||||
|
||||
# 4. 安装依赖 + 构建
|
||||
bun install
|
||||
bun run build
|
||||
|
||||
# 5. 全局安装
|
||||
bun install -g .
|
||||
|
||||
# 6. 确保非交互式 SSH 可访问 ccb 命令
|
||||
# bun install -g 安装到 ~/.bun/bin/,但非交互式 SSH 不加载 .bashrc,
|
||||
# 所以 PATH 中不包含 ~/.bun/bin/
|
||||
# 解决方式(任选其一):
|
||||
|
||||
# 方式 A:符号链接到系统 PATH(推荐)
|
||||
ln -sf ~/.bun/bin/ccb /usr/local/bin/ccb
|
||||
|
||||
# 方式 B:添加到 /etc/profile.d/(所有用户生效)
|
||||
echo 'export PATH="$HOME/.bun/bin:$PATH"' > /etc/profile.d/bun-path.sh
|
||||
|
||||
# 方式 C:添加到 ~/.bash_profile(当前用户,ssh -t 时生效)
|
||||
echo 'export PATH="$HOME/.bun/bin:$PATH"' >> ~/.bash_profile
|
||||
|
||||
# 7. 验证
|
||||
ccb --version
|
||||
|
||||
# 8. 从本地测试
|
||||
# (在本地终端)
|
||||
ssh my-server -t ccb
|
||||
```
|
||||
|
||||
### SSH Remote 自动部署
|
||||
|
||||
使用 `ccb ssh <host>` 时,模块自动处理:
|
||||
|
||||
1. **SSHProbe** 探测远端 `~/.local/bin/claude` 或 `command -v claude`
|
||||
2. 若二进制不存在或版本不匹配,**SSHDeploy** 通过 `scp` 传输 `dist/` 目录
|
||||
3. 在远端创建 wrapper 脚本(`~/.local/bin/claude`)
|
||||
4. 无需手动安装
|
||||
|
||||
---
|
||||
|
||||
## 模块结构
|
||||
|
||||
```
|
||||
src/ssh/
|
||||
├── createSSHSession.ts — 会话工厂:编排 probe → deploy → proxy → spawn
|
||||
├── SSHSessionManager.ts — 双向 NDJSON 通信管理 + 权限转发 + 重连
|
||||
├── SSHAuthProxy.ts — 本地认证代理(API 凭据隧道)
|
||||
├── SSHProbe.ts — 远端主机探测(平台/架构/已有二进制)
|
||||
├── SSHDeploy.ts — 远端二进制部署(scp + wrapper 脚本)
|
||||
└── __tests__/
|
||||
└── SSHSessionManager.test.ts — 17 个单元测试
|
||||
```
|
||||
|
||||
## 关键技术细节
|
||||
|
||||
### 认证隧道
|
||||
|
||||
- **AuthProxy** 在本地监听(Unix socket 或 TCP),接收远端 CLI 的 API 请求
|
||||
- 通过 SSH `-R` 反向端口转发隧道到远端
|
||||
- AuthProxy 注入本地真实凭据(API key 或 OAuth token),转发到 `api.anthropic.com`
|
||||
- `ANTHROPIC_AUTH_NONCE` header 防止未授权访问(nonce 通过环境变量传递给远端 CLI,远端 CLI 在每个 API 请求中携带此 header)
|
||||
|
||||
### waitForInit vs 存活检查
|
||||
|
||||
- **标准模式**:`waitForInit` 等待远端 CLI 发送 `{type:'system', subtype:'init'}` JSON 消息
|
||||
- **`--remote-bin` 模式**:跳过 `waitForInit`(print+stream-json 模式下 init 只在首次查询后发送),改用 3 秒进程存活检查
|
||||
|
||||
### 重连机制
|
||||
|
||||
- `SSHSessionManager` 检测 SSH 连接断开后自动重连
|
||||
- 重连时在远端 CLI 命令中追加 `--continue` 恢复会话
|
||||
- 指数退避重试(最多 5 次,间隔 1s → 2s → 4s → 8s → 16s)
|
||||
|
||||
## Feature Flag
|
||||
|
||||
SSH Remote 功能受 `SSH_REMOTE` feature flag 控制:
|
||||
|
||||
- **Dev 模式**:默认启用
|
||||
- **Build 模式**:需在 `build.ts` 的 `DEFAULT_BUILD_FEATURES` 中添加 `'SSH_REMOTE'`
|
||||
- **运行时**:`FEATURE_SSH_REMOTE=1` 环境变量
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### `ccb: command not found`(SSH 远程执行时)
|
||||
|
||||
非交互式 SSH 不加载 `.bashrc`,`~/.bun/bin` 不在 PATH 中。
|
||||
|
||||
```bash
|
||||
# 解决:创建符号链接
|
||||
ln -sf ~/.bun/bin/ccb /usr/local/bin/ccb
|
||||
```
|
||||
|
||||
### SSH 密钥被拒绝
|
||||
|
||||
```
|
||||
Permission denied (publickey)
|
||||
```
|
||||
|
||||
1. 确认公钥已添加到远端 `~/.ssh/authorized_keys`
|
||||
2. 确认本地私钥文件权限正确(`chmod 600`)
|
||||
3. 确认 `~/.ssh/config` 中 `IdentityFile` 路径正确
|
||||
4. Windows 用户检查 ACL 权限(见上方 Windows 权限设置)
|
||||
|
||||
### SSH 连接超时
|
||||
|
||||
```
|
||||
ssh: connect to host x.x.x.x port 22: Connection timed out
|
||||
```
|
||||
|
||||
1. 确认远端 SSH 服务正在运行:`systemctl status sshd`
|
||||
2. 确认防火墙允许 22 端口
|
||||
3. 确认 IP 地址/域名正确
|
||||
4. 在 `~/.ssh/config` 中添加 `ConnectTimeout 10`
|
||||
|
||||
### 403 Forbidden(SSH Remote 模块)
|
||||
|
||||
AuthProxy 的 nonce 验证失败。确认:
|
||||
1. 远端 CLI 版本包含 nonce header 注入修复
|
||||
2. `ANTHROPIC_AUTH_NONCE` 环境变量正确传递到远端
|
||||
3. `src/services/api/client.ts` 中 `x-auth-nonce` header 已启用
|
||||
|
||||
### 远端 CLI 启动后立即退出
|
||||
|
||||
```
|
||||
Remote process exited immediately (code 1)
|
||||
```
|
||||
|
||||
1. 确认远端 `bun` / `node` 运行时可用
|
||||
2. 手动在远端执行 `ccb --version` 验证安装
|
||||
3. 检查 `--remote-bin` 路径是否正确
|
||||
4. 查看 stderr 输出获取详细错误信息
|
||||
@@ -1,27 +1,32 @@
|
||||
# VOICE_MODE — 语音输入
|
||||
|
||||
> Feature Flag: `FEATURE_VOICE_MODE=1`
|
||||
> 实现状态:完整可用(需要 Anthropic OAuth)
|
||||
> 实现状态:完整可用(双后端:Anthropic OAuth / 豆包 ASR)
|
||||
> 引用数:46
|
||||
|
||||
## 一、功能概述
|
||||
|
||||
VOICE_MODE 实现"按键说话"(Push-to-Talk)语音输入。用户按住空格键录音,音频通过 WebSocket 流式传输到 Anthropic STT 端点(Nova 3),实时转录显示在终端中。
|
||||
VOICE_MODE 实现"按键说话"(Push-to-Talk)语音输入。用户按住空格键录音,音频流式传输到 STT 后端,实时转录显示在终端中。支持两个后端:
|
||||
|
||||
- **Anthropic STT(默认)**:通过 WebSocket 流式传输到 Nova 3 端点,需要 Anthropic OAuth
|
||||
- **豆包 ASR(Doubao)**:通过 `doubaoime-asr` 包的 AsyncGenerator 协议流式识别,使用独立凭证文件,无需 Anthropic OAuth
|
||||
|
||||
### 核心特性
|
||||
|
||||
- **Push-to-Talk**:长按空格键录音,释放后自动发送
|
||||
- **流式转录**:录音过程中实时显示中间转录结果
|
||||
- **无缝集成**:转录文本直接作为用户消息提交到对话
|
||||
- **双后端切换**:通过 `/voice` 命令参数选择 STT 后端,持久化到 settings.json
|
||||
|
||||
## 二、用户交互
|
||||
|
||||
| 操作 | 行为 |
|
||||
|------|------|
|
||||
| 长按空格 | 开始录音,显示录音状态 |
|
||||
| 释放空格 | 停止录音,等待最终转录 |
|
||||
| 转录完成 | 自动插入到输入框并提交 |
|
||||
| `/voice` 命令 | 切换语音模式开关 |
|
||||
| 释放空格 | 停止录音,转录结果自动提交 |
|
||||
| `/voice` | 切换语音模式开关(默认使用 Anthropic 后端) |
|
||||
| `/voice doubao` | 启用语音模式并使用豆包 ASR 后端 |
|
||||
| `/voice anthropic` | 切换回 Anthropic STT 后端 |
|
||||
|
||||
### UI 反馈
|
||||
|
||||
@@ -35,26 +40,37 @@ VOICE_MODE 实现"按键说话"(Push-to-Talk)语音输入。用户按住空
|
||||
|
||||
文件:`src/voice/voiceModeEnabled.ts`
|
||||
|
||||
三层检查:
|
||||
两层检查函数:
|
||||
|
||||
```ts
|
||||
// Anthropic 后端(需要 OAuth)
|
||||
isVoiceModeEnabled() = hasVoiceAuth() && isVoiceGrowthBookEnabled()
|
||||
|
||||
// 豆包后端 / 通用可用性检查(不需要 OAuth)
|
||||
isVoiceAvailable() = isVoiceGrowthBookEnabled()
|
||||
```
|
||||
|
||||
1. **Feature Flag**:`feature('VOICE_MODE')` — 编译时/运行时开关
|
||||
2. **GrowthBook Kill-Switch**:`!getFeatureValue_CACHED_MAY_BE_STALE('tengu_amber_quartz_disabled', false)` — 紧急关闭开关(默认 false = 未禁用)
|
||||
3. **Auth 检查**:`hasVoiceAuth()` — 需要 Anthropic OAuth token(非 API key)
|
||||
3. **Auth 检查(仅 Anthropic)**:`hasVoiceAuth()` — 需要 Anthropic OAuth token(非 API key)
|
||||
4. **Provider 检查**:`voiceProvider` 设置决定使用哪个后端,豆包后端跳过 OAuth 检查
|
||||
|
||||
### 3.2 核心模块
|
||||
|
||||
| 模块 | 职责 |
|
||||
|------|------|
|
||||
| `src/voice/voiceModeEnabled.ts` | Feature flag + GrowthBook + Auth 三层门控 |
|
||||
| `src/hooks/useVoice.ts` | React hook 管理录音状态和 WebSocket 连接 |
|
||||
| `src/services/voiceStreamSTT.ts` | WebSocket 流式传输到 Anthropic STT |
|
||||
| `src/hooks/useVoice.ts` | React hook 管理录音状态和后端连接 |
|
||||
| `src/services/voiceStreamSTT.ts` | Anthropic WebSocket 流式 STT |
|
||||
| `src/services/doubaoSTT.ts` | 豆包 ASR 适配器(AsyncGenerator → VoiceStreamConnection) |
|
||||
| `src/commands/voice/voice.ts` | `/voice` 命令实现,处理后端选择和持久化 |
|
||||
| `src/hooks/useVoiceEnabled.ts` | 语音启用状态 hook,根据 provider 决定是否跳过 OAuth |
|
||||
| `src/utils/settings/types.ts` | `voiceProvider: 'anthropic' | 'doubao'` 设置类型定义 |
|
||||
|
||||
### 3.3 数据流
|
||||
|
||||
#### Anthropic 后端
|
||||
|
||||
```
|
||||
用户按下空格键
|
||||
│
|
||||
@@ -79,20 +95,108 @@ WebSocket 连接到 Anthropic STT 端点
|
||||
转录文本 → 插入输入框 → 自动提交
|
||||
```
|
||||
|
||||
#### 豆包 ASR 后端
|
||||
|
||||
```
|
||||
用户按下空格键
|
||||
│
|
||||
▼
|
||||
useVoice hook 激活(检测到 voiceProvider === 'doubao')
|
||||
│
|
||||
▼
|
||||
macOS 原生音频 / SoX 开始录音
|
||||
│
|
||||
▼
|
||||
connectDoubaoStream() 创建 AudioChunkQueue + VoiceStreamConnection
|
||||
│
|
||||
├──→ onReady 立即触发(无需等待握手)
|
||||
│
|
||||
▼
|
||||
音频数据通过 AudioChunkQueue 传入 transcribeRealtime()
|
||||
│
|
||||
├──→ INTERIM_RESULT → 实时显示中间转录
|
||||
├──→ FINAL_RESULT → 显示最终转录
|
||||
│
|
||||
▼
|
||||
用户释放空格键
|
||||
│
|
||||
▼
|
||||
finalize() 立即返回(豆包在录音过程中已返回结果,无需等待)
|
||||
│
|
||||
▼
|
||||
转录文本 → 插入输入框 → 自动提交
|
||||
```
|
||||
|
||||
### 3.4 音频录制
|
||||
|
||||
支持两种音频后端:
|
||||
支持两种音频后端(两个 STT 后端共享):
|
||||
- **macOS 原生音频**:优先使用,低延迟
|
||||
- **SoX(Sound eXchange)**:回退方案,跨平台
|
||||
|
||||
音频流通过 WebSocket 发送到 Anthropic 的 Nova 3 STT 模型。
|
||||
### 3.5 豆包 ASR 适配器设计
|
||||
|
||||
文件:`src/services/doubaoSTT.ts`
|
||||
|
||||
豆包后端使用适配器模式,将 `doubaoime-asr` 的 AsyncGenerator 协议桥接到 `VoiceStreamConnection` 接口:
|
||||
|
||||
**AudioChunkQueue** — push 式异步队列:
|
||||
- 实现 `AsyncIterable<Uint8Array>` 接口
|
||||
- `push(chunk)` 将音频数据入队,`push(null)` 发送结束信号
|
||||
- 内部维护等待者(waiting)和缓冲队列(chunks)两个状态
|
||||
|
||||
**connectDoubaoStream()** — 连接入口:
|
||||
- 动态导入 `doubaoime-asr`(optionalDependencies)
|
||||
- 从 `~/.claude/tts/doubao/credentials.json` 加载凭证
|
||||
- 创建 AudioChunkQueue 和 VoiceStreamConnection
|
||||
- 立即触发 `onReady`(避免与 useVoice 的音频缓冲死锁)
|
||||
- `finalize()` 立即返回(豆包在录音过程中已返回结果)
|
||||
- 后台 async IIFE 消费 `transcribeRealtime` generator,映射响应类型到回调
|
||||
|
||||
**响应类型映射**:
|
||||
|
||||
| doubaoime-asr ResponseType | 回调映射 |
|
||||
|----------------------------|----------|
|
||||
| SESSION_STARTED | 日志记录 |
|
||||
| VAD_START | 日志记录 |
|
||||
| INTERIM_RESULT | `onTranscript(text, false)` |
|
||||
| FINAL_RESULT | `onTranscript(text, true)` |
|
||||
| ERROR | `onError(errorMsg)` |
|
||||
| SESSION_FINISHED | 日志记录 |
|
||||
|
||||
### 3.6 后端选择逻辑
|
||||
|
||||
文件:`src/hooks/useVoice.ts`
|
||||
|
||||
```ts
|
||||
// 判断当前 provider
|
||||
isDoubaoProvider() → 读取 settings.voiceProvider
|
||||
|
||||
// handleKeyEvent 中的可用性检查
|
||||
const sttAvailable = isDoubaoProvider()
|
||||
? isDoubaoAvailableSync() // 乐观检查(首次返回 true)
|
||||
: isVoiceStreamAvailable() // Anthropic WebSocket 检查
|
||||
|
||||
// attemptConnect 中的连接函数选择
|
||||
const connectFn = isDoubaoProvider()
|
||||
? connectDoubaoStream
|
||||
: connectVoiceStream
|
||||
```
|
||||
|
||||
豆包后端的特殊处理:
|
||||
- 跳过 `getVoiceKeyterms()` 调用(豆包无需关键词提示)
|
||||
- 跳过 Focus Mode(`if (!enabled || !focusMode || isDoubaoProvider())`)
|
||||
|
||||
## 四、关键设计决策
|
||||
|
||||
1. **OAuth 独占**:语音模式使用 `voice_stream` 端点(claude.ai),仅 Anthropic OAuth 用户可用。API key、Bedrock、Vertex 用户无法使用
|
||||
2. **GrowthBook 负向门控**:`tengu_amber_quartz_disabled` 默认 `false`,新安装自动可用(无需等 GrowthBook 初始化)
|
||||
3. **Keychain 缓存**:`getClaudeAIOAuthTokens()` 首次调用访问 macOS keychain(~20-50ms),后续缓存命中
|
||||
4. **独立于主 feature flag**:`isVoiceGrowthBookEnabled()` 在 feature flag 关闭时短路返回 `false`,不触发任何模块加载
|
||||
1. **双后端共存**:豆包后端作为独立适配器与 Anthropic 后端并存,不替换原有流程,通过 `voiceProvider` 设置切换
|
||||
2. **设置持久化**:`voiceProvider` 存储在 `settings.json`,通过 `/voice` 命令修改,跨会话生效
|
||||
3. **OAuth 独占(Anthropic)**:Anthropic 后端使用 `voice_stream` 端点(claude.ai),仅 OAuth 用户可用
|
||||
4. **豆包无需 OAuth**:豆包后端使用独立凭证文件,不依赖 Anthropic 认证,通过 `isVoiceAvailable()` 放宽门控
|
||||
5. **GrowthBook 负向门控**:`tengu_amber_quartz_disabled` 默认 `false`,新安装自动可用
|
||||
6. **onReady 立即触发**:豆包后端在连接建立后立即触发 `onReady`,避免与 useVoice 音频缓冲的时序死锁(Anthropic 需要等待 WebSocket 握手)
|
||||
7. **finalize() 立即返回**:豆包在录音过程中已返回所有结果,用户抬手时无需等待处理
|
||||
8. **乐观可用性检查**:`isDoubaoAvailableSync()` 在首次调用时返回 `true`,实际导入错误在 `connectDoubaoStream` 中处理
|
||||
9. **optionalDependencies**:`doubaoime-asr` 作为可选依赖,安装失败不影响 Anthropic 后端
|
||||
|
||||
## 五、使用方式
|
||||
|
||||
@@ -100,26 +204,60 @@ WebSocket 连接到 Anthropic STT 端点
|
||||
# 启用 feature
|
||||
FEATURE_VOICE_MODE=1 bun run dev
|
||||
|
||||
# 在 REPL 中使用
|
||||
# 在 REPL 中使用 Anthropic 后端
|
||||
# 1. 确保已通过 OAuth 登录(claude.ai 订阅)
|
||||
# 2. 按住空格键说话
|
||||
# 3. 释放空格键等待转录
|
||||
# 4. 或使用 /voice 命令切换开关
|
||||
# 2. 输入 /voice 启用
|
||||
# 3. 按住空格键说话
|
||||
# 4. 释放空格键等待转录
|
||||
|
||||
# 在 REPL 中使用豆包 ASR 后端
|
||||
# 1. 确保 doubaoime-asr 已安装(bun add doubaoime-asr)
|
||||
# 2. 配置凭证文件:~/.claude/tts/doubao/credentials.json
|
||||
# 3. 输入 /voice doubao 启用
|
||||
# 4. 按住空格键说话
|
||||
# 5. 释放空格键,转录结果即刻显示
|
||||
|
||||
# 切换后端
|
||||
/voice doubao # 切换到豆包 ASR
|
||||
/voice anthropic # 切换回 Anthropic STT
|
||||
/voice # 关闭语音模式
|
||||
```
|
||||
|
||||
### 豆包凭证配置
|
||||
|
||||
凭证文件路径:`~/.claude/tts/doubao/credentials.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"deviceId": "...",
|
||||
"installId": "...",
|
||||
"cdid": "...",
|
||||
"openudid": "...",
|
||||
"clientudid": "...",
|
||||
"token": "..."
|
||||
}
|
||||
```
|
||||
|
||||
## 六、外部依赖
|
||||
|
||||
| 依赖 | 说明 |
|
||||
|------|------|
|
||||
| Anthropic OAuth | claude.ai 订阅登录,非 API key |
|
||||
| GrowthBook | `tengu_amber_quartz_disabled` 紧急关闭 |
|
||||
| macOS 原生音频 或 SoX | 音频录制 |
|
||||
| Nova 3 STT | 语音转文本模型 |
|
||||
| 依赖 | 说明 | 适用后端 |
|
||||
|------|------|----------|
|
||||
| Anthropic OAuth | claude.ai 订阅登录,非 API key | Anthropic |
|
||||
| GrowthBook | `tengu_amber_quartz_disabled` 紧急关闭 | 通用 |
|
||||
| macOS 原生音频 或 SoX | 音频录制 | 通用 |
|
||||
| Nova 3 STT | Anthropic 语音转文本模型 | Anthropic |
|
||||
| doubaoime-asr | 豆包 ASR SDK(optionalDependencies) | 豆包 |
|
||||
| 凭证文件 | `~/.claude/tts/doubao/credentials.json` | 豆包 |
|
||||
|
||||
## 七、文件索引
|
||||
|
||||
| 文件 | 行数 | 职责 |
|
||||
|------|------|------|
|
||||
| `src/voice/voiceModeEnabled.ts` | 54 | 三层门控逻辑 |
|
||||
| `src/hooks/useVoice.ts` | — | React hook(录音状态 + WebSocket) |
|
||||
| `src/services/voiceStreamSTT.ts` | — | STT WebSocket 流式传输 |
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `src/voice/voiceModeEnabled.ts` | 三层门控逻辑 + `isVoiceAvailable()` |
|
||||
| `src/hooks/useVoice.ts` | React hook(录音状态 + 后端选择 + 连接管理) |
|
||||
| `src/hooks/useVoiceEnabled.ts` | 语音启用状态 hook(按 provider 决定 OAuth 检查) |
|
||||
| `src/services/voiceStreamSTT.ts` | Anthropic STT WebSocket 流式传输 |
|
||||
| `src/services/doubaoSTT.ts` | 豆包 ASR 适配器(AudioChunkQueue + connectDoubaoStream) |
|
||||
| `src/commands/voice/voice.ts` | `/voice` 命令(开关 + 后端选择) |
|
||||
| `src/commands/voice/index.ts` | 命令注册(去除 availability 限制) |
|
||||
| `src/utils/settings/types.ts` | `voiceProvider` 类型定义 |
|
||||
|
||||
@@ -200,9 +200,9 @@ LSP 服务器通过插件提供。插件的 `manifest.json` 中可以声明 LSP
|
||||
|------|------|------|------|
|
||||
| `command` | string | 是 | LSP 服务器可执行命令(不含空格) |
|
||||
| `args` | string[] | 否 | 命令行参数 |
|
||||
| `extensionToLanguage` | Record<string, string> | 是 | 文件扩展名到语言 ID 的映射(至少一个) |
|
||||
| `extensionToLanguage` | `Record<string, string>` | 是 | 文件扩展名到语言 ID 的映射(至少一个) |
|
||||
| `transport` | `"stdio"` \| `"socket"` | 否 | 通信方式,默认 `stdio` |
|
||||
| `env` | Record<string, string> | 否 | 启动服务器时设置的环境变量 |
|
||||
| `env` | `Record<string, string>` | 否 | 启动服务器时设置的环境变量 |
|
||||
| `initializationOptions` | unknown | 否 | 传给服务器的初始化选项 |
|
||||
| `settings` | unknown | 否 | 通过 `workspace/didChangeConfiguration` 传递的设置 |
|
||||
| `workspaceFolder` | string | 否 | 工作区目录路径 |
|
||||
|
||||
@@ -175,7 +175,7 @@ F. getCompletedResults() → 空
|
||||
|
||||
---
|
||||
|
||||
#### #8 stream_event (input_json_delta: '{"file_path":')
|
||||
#### #8 stream_event (input_json_delta: `'{"file_path":'`)
|
||||
|
||||
```
|
||||
D. yield message ✅ → REPL 追加工具输入 JSON 碎片
|
||||
|
||||
12
package.json
12
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-code-best",
|
||||
"version": "1.8.0",
|
||||
"version": "1.10.2",
|
||||
"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>",
|
||||
@@ -47,12 +47,15 @@
|
||||
"build:bun": "bun run build.ts",
|
||||
"dev": "bun run scripts/dev.ts",
|
||||
"dev:inspect": "bun run scripts/dev-debug.ts",
|
||||
"prepublishOnly": "bun run build",
|
||||
"prepublishOnly": "bun run build:vite",
|
||||
"lint": "biome lint src/",
|
||||
"lint:fix": "biome lint --fix src/",
|
||||
"format": "biome format --write src/",
|
||||
"prepare": "git config core.hooksPath .githooks",
|
||||
"test": "bun test",
|
||||
"test:production": "bun run scripts/production-test.ts",
|
||||
"test:production:offline": "bun run scripts/production-test.ts --offline",
|
||||
"test:production:verbose": "bun run scripts/production-test.ts --verbose",
|
||||
"test:production:bun": "bun run scripts/production-test.ts --bun",
|
||||
"check:bundle": "bun run scripts/check-bundle-integrity.ts",
|
||||
"check:unused": "knip-bun",
|
||||
"health": "bun run scripts/health-check.ts",
|
||||
@@ -202,5 +205,8 @@
|
||||
"xss": "^1.0.15",
|
||||
"yaml": "^2.8.3",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"doubaoime-asr": "^0.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,6 +286,15 @@ export default class App extends PureComponent<Props, State> {
|
||||
// ignore calling setRawMode on an handle stdin it cannot be called
|
||||
if (this.isRawModeSupported()) {
|
||||
this.handleSetRawMode(false)
|
||||
} else {
|
||||
// Even when raw mode was never enabled (e.g. non-TTY stdin on
|
||||
// Windows Node.js), ensure stdin is unref'd so the process can
|
||||
// exit. earlyInput may have called ref() before Ink mounted.
|
||||
try {
|
||||
this.props.stdin.unref()
|
||||
} catch {
|
||||
// stdin may already be destroyed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,26 +21,22 @@ function makeAssistantMsg(content: string | any[]): AssistantMessage {
|
||||
|
||||
describe('anthropicMessagesToOpenAI', () => {
|
||||
test('converts system prompt to system message', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg('hello')],
|
||||
['You are helpful.'] as any,
|
||||
)
|
||||
const result = anthropicMessagesToOpenAI([makeUserMsg('hello')], [
|
||||
'You are helpful.',
|
||||
] as any)
|
||||
expect(result[0]).toEqual({ role: 'system', content: 'You are helpful.' })
|
||||
})
|
||||
|
||||
test('joins multiple system prompt strings', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg('hi')],
|
||||
['Part 1', 'Part 2'] as any,
|
||||
)
|
||||
const result = anthropicMessagesToOpenAI([makeUserMsg('hi')], [
|
||||
'Part 1',
|
||||
'Part 2',
|
||||
] as any)
|
||||
expect(result[0]).toEqual({ role: 'system', content: 'Part 1\n\nPart 2' })
|
||||
})
|
||||
|
||||
test('skips empty system prompt', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg('hi')],
|
||||
[] as any,
|
||||
)
|
||||
const result = anthropicMessagesToOpenAI([makeUserMsg('hi')], [] as any)
|
||||
expect(result[0].role).toBe('user')
|
||||
})
|
||||
|
||||
@@ -54,10 +50,12 @@ describe('anthropicMessagesToOpenAI', () => {
|
||||
|
||||
test('converts user message with content array', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg([
|
||||
{ type: 'text', text: 'line 1' },
|
||||
{ type: 'text', text: 'line 2' },
|
||||
])],
|
||||
[
|
||||
makeUserMsg([
|
||||
{ type: 'text', text: 'line 1' },
|
||||
{ type: 'text', text: 'line 2' },
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
expect(result).toEqual([{ role: 'user', content: 'line 1\nline 2' }])
|
||||
@@ -73,55 +71,67 @@ describe('anthropicMessagesToOpenAI', () => {
|
||||
|
||||
test('converts assistant message with tool_use', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeAssistantMsg([
|
||||
{ type: 'text', text: 'Let me help.' },
|
||||
{
|
||||
type: 'tool_use' as const,
|
||||
id: 'toolu_123',
|
||||
name: 'bash',
|
||||
input: { command: 'ls' },
|
||||
},
|
||||
])],
|
||||
[
|
||||
makeAssistantMsg([
|
||||
{ type: 'text', text: 'Let me help.' },
|
||||
{
|
||||
type: 'tool_use' as const,
|
||||
id: 'toolu_123',
|
||||
name: 'bash',
|
||||
input: { command: 'ls' },
|
||||
},
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
expect(result).toEqual([{
|
||||
role: 'assistant',
|
||||
content: 'Let me help.',
|
||||
tool_calls: [{
|
||||
id: 'toolu_123',
|
||||
type: 'function',
|
||||
function: { name: 'bash', arguments: '{"command":"ls"}' },
|
||||
}],
|
||||
}])
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Let me help.',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'toolu_123',
|
||||
type: 'function',
|
||||
function: { name: 'bash', arguments: '{"command":"ls"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('converts tool_result to tool message', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg([
|
||||
{
|
||||
type: 'tool_result' as const,
|
||||
tool_use_id: 'toolu_123',
|
||||
content: 'file1.txt\nfile2.txt',
|
||||
},
|
||||
])],
|
||||
[
|
||||
makeUserMsg([
|
||||
{
|
||||
type: 'tool_result' as const,
|
||||
tool_use_id: 'toolu_123',
|
||||
content: 'file1.txt\nfile2.txt',
|
||||
},
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
expect(result).toEqual([{
|
||||
role: 'tool',
|
||||
tool_call_id: 'toolu_123',
|
||||
content: 'file1.txt\nfile2.txt',
|
||||
}])
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: 'tool',
|
||||
tool_call_id: 'toolu_123',
|
||||
content: 'file1.txt\nfile2.txt',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('strips thinking blocks', () => {
|
||||
test('preserves thinking blocks as reasoning_content', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'internal thoughts...' },
|
||||
{ type: 'text', text: 'visible response' },
|
||||
])],
|
||||
[
|
||||
makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'internal thoughts...' },
|
||||
{ type: 'text', text: 'visible response' },
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
expect(result).toEqual([{ role: 'assistant', content: 'visible response' }])
|
||||
expect(result).toEqual([{ role: 'assistant', content: 'visible response', reasoning_content: 'internal thoughts...' }] as any)
|
||||
})
|
||||
|
||||
test('handles full conversation with tools', () => {
|
||||
@@ -157,91 +167,105 @@ describe('anthropicMessagesToOpenAI', () => {
|
||||
|
||||
test('converts base64 image to image_url', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg([
|
||||
{ type: 'text', text: 'what is this?' },
|
||||
{
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'image/png',
|
||||
data: 'iVBORw0KGgo=',
|
||||
[
|
||||
makeUserMsg([
|
||||
{ type: 'text', text: 'what is this?' },
|
||||
{
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'image/png',
|
||||
data: 'iVBORw0KGgo=',
|
||||
},
|
||||
},
|
||||
},
|
||||
])],
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
expect(result).toEqual([{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'what is this?' },
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'data:image/png;base64,iVBORw0KGgo=' },
|
||||
},
|
||||
],
|
||||
}])
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'what is this?' },
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'data:image/png;base64,iVBORw0KGgo=' },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('converts url image to image_url', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg([
|
||||
{
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'url',
|
||||
url: 'https://example.com/img.png',
|
||||
[
|
||||
makeUserMsg([
|
||||
{
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'url',
|
||||
url: 'https://example.com/img.png',
|
||||
},
|
||||
},
|
||||
},
|
||||
])],
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
expect(result).toEqual([{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'https://example.com/img.png' },
|
||||
},
|
||||
],
|
||||
}])
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'https://example.com/img.png' },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('converts image-only message without text', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg([
|
||||
{
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'image/jpeg',
|
||||
data: '/9j/4AAQ',
|
||||
[
|
||||
makeUserMsg([
|
||||
{
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'image/jpeg',
|
||||
data: '/9j/4AAQ',
|
||||
},
|
||||
},
|
||||
},
|
||||
])],
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
expect(result).toEqual([{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'data:image/jpeg;base64,/9j/4AAQ' },
|
||||
},
|
||||
],
|
||||
}])
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'data:image/jpeg;base64,/9j/4AAQ' },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('defaults to image/png when media_type is missing', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg([
|
||||
{
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'base64',
|
||||
data: 'ABC123',
|
||||
[
|
||||
makeUserMsg([
|
||||
{
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'base64',
|
||||
data: 'ABC123',
|
||||
},
|
||||
},
|
||||
},
|
||||
])],
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
expect((result[0].content as any[])[0].image_url.url).toBe(
|
||||
@@ -253,10 +277,16 @@ describe('anthropicMessagesToOpenAI', () => {
|
||||
describe('DeepSeek thinking mode (enableThinking)', () => {
|
||||
test('preserves thinking block as reasoning_content when enabled', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg('question'), makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'Let me reason about this...' },
|
||||
{ type: 'text', text: 'The answer is 42.' },
|
||||
])],
|
||||
[
|
||||
makeUserMsg('question'),
|
||||
makeAssistantMsg([
|
||||
{
|
||||
type: 'thinking' as const,
|
||||
thinking: 'Let me reason about this...',
|
||||
},
|
||||
{ type: 'text', text: 'The answer is 42.' },
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
{ enableThinking: true },
|
||||
)
|
||||
@@ -269,17 +299,19 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
|
||||
expect(assistant.reasoning_content).toBe('Let me reason about this...')
|
||||
})
|
||||
|
||||
test('drops thinking block when enableThinking is false (default)', () => {
|
||||
test('preserves thinking block as reasoning_content even without enableThinking', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'internal thoughts...' },
|
||||
{ type: 'text', text: 'visible response' },
|
||||
])],
|
||||
[
|
||||
makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'internal thoughts...' },
|
||||
{ type: 'text', text: 'visible response' },
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
const assistant = result[0] as any
|
||||
expect(assistant.content).toBe('visible response')
|
||||
expect(assistant.reasoning_content).toBeUndefined()
|
||||
expect(assistant.reasoning_content).toBe('internal thoughts...')
|
||||
})
|
||||
|
||||
test('preserves reasoning_content with tool_calls in same turn', () => {
|
||||
@@ -287,7 +319,10 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
|
||||
[
|
||||
makeUserMsg('what is the weather?'),
|
||||
makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'I need to call the weather tool.' },
|
||||
{
|
||||
type: 'thinking' as const,
|
||||
thinking: 'I need to call the weather tool.',
|
||||
},
|
||||
{ type: 'text', text: '' },
|
||||
{
|
||||
type: 'tool_use' as const,
|
||||
@@ -317,7 +352,7 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
|
||||
expect(assistant.tool_calls[0].function.name).toBe('get_weather')
|
||||
})
|
||||
|
||||
test('strips reasoning_content from previous turns', () => {
|
||||
test('always preserves reasoning_content from all turns', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[
|
||||
// Turn 1: user → assistant (with thinking)
|
||||
@@ -326,7 +361,8 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
|
||||
{ type: 'thinking' as const, thinking: 'Turn 1 reasoning...' },
|
||||
{ type: 'text', text: 'Turn 1 answer' },
|
||||
]),
|
||||
// Turn 2: new user message → previous reasoning should be stripped
|
||||
// Turn 2: new user message → reasoning should still be preserved
|
||||
// (DeepSeek requires reasoning_content to be passed back when tool calls are involved)
|
||||
makeUserMsg('question 2'),
|
||||
makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'Turn 2 reasoning...' },
|
||||
@@ -338,10 +374,9 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
|
||||
)
|
||||
|
||||
const assistants = result.filter(m => m.role === 'assistant')
|
||||
// Turn 1 assistant: reasoning should be stripped (previous turn)
|
||||
expect((assistants[0] as any).reasoning_content).toBeUndefined()
|
||||
// Both turns preserve reasoning_content (DeepSeek API requires it for tool calls)
|
||||
expect((assistants[0] as any).reasoning_content).toBe('Turn 1 reasoning...')
|
||||
expect((assistants[0] as any).content).toBe('Turn 1 answer')
|
||||
// Turn 2 assistant: reasoning should be preserved (current turn)
|
||||
expect((assistants[1] as any).reasoning_content).toBe('Turn 2 reasoning...')
|
||||
expect((assistants[1] as any).content).toBe('Turn 2 answer')
|
||||
})
|
||||
@@ -399,18 +434,27 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
|
||||
const assistants = result.filter(m => m.role === 'assistant')
|
||||
expect(assistants.length).toBe(3)
|
||||
// All iterations within the same turn preserve reasoning
|
||||
expect((assistants[0] as any).reasoning_content).toBe('I need the date first.')
|
||||
expect((assistants[1] as any).reasoning_content).toBe('Now I can get the weather.')
|
||||
expect((assistants[2] as any).reasoning_content).toBe('I have the info now.')
|
||||
expect((assistants[0] as any).reasoning_content).toBe(
|
||||
'I need the date first.',
|
||||
)
|
||||
expect((assistants[1] as any).reasoning_content).toBe(
|
||||
'Now I can get the weather.',
|
||||
)
|
||||
expect((assistants[2] as any).reasoning_content).toBe(
|
||||
'I have the info now.',
|
||||
)
|
||||
})
|
||||
|
||||
test('handles multiple thinking blocks in single assistant message', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg('question'), makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'First thought.' },
|
||||
{ type: 'thinking' as const, thinking: 'Second thought.' },
|
||||
{ type: 'text', text: 'Final answer.' },
|
||||
])],
|
||||
[
|
||||
makeUserMsg('question'),
|
||||
makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'First thought.' },
|
||||
{ type: 'thinking' as const, thinking: 'Second thought.' },
|
||||
{ type: 'text', text: 'Final answer.' },
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
{ enableThinking: true },
|
||||
)
|
||||
@@ -420,10 +464,13 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
|
||||
|
||||
test('skips empty thinking blocks', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg('question'), makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: '' },
|
||||
{ type: 'text', text: 'Answer.' },
|
||||
])],
|
||||
[
|
||||
makeUserMsg('question'),
|
||||
makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: '' },
|
||||
{ type: 'text', text: 'Answer.' },
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
{ enableThinking: true },
|
||||
)
|
||||
@@ -481,15 +528,18 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
|
||||
|
||||
test('sets content to null when only thinking and tool_calls present', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg('question'), makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'Reasoning only.' },
|
||||
{
|
||||
type: 'tool_use' as const,
|
||||
id: 'toolu_001',
|
||||
name: 'bash',
|
||||
input: { command: 'ls' },
|
||||
},
|
||||
])],
|
||||
[
|
||||
makeUserMsg('question'),
|
||||
makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'Reasoning only.' },
|
||||
{
|
||||
type: 'tool_use' as const,
|
||||
id: 'toolu_001',
|
||||
name: 'bash',
|
||||
input: { command: 'ls' },
|
||||
},
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
{ enableThinking: true },
|
||||
)
|
||||
|
||||
@@ -18,25 +18,29 @@ describe('anthropicToolsToOpenAI', () => {
|
||||
|
||||
const result = anthropicToolsToOpenAI(tools as any)
|
||||
|
||||
expect(result).toEqual([{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'bash',
|
||||
description: 'Run a bash command',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { command: { type: 'string' } },
|
||||
required: ['command'],
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'bash',
|
||||
description: 'Run a bash command',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { command: { type: 'string' } },
|
||||
required: ['command'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}])
|
||||
])
|
||||
})
|
||||
|
||||
test('uses empty schema when input_schema missing', () => {
|
||||
const tools = [{ type: 'custom', name: 'noop', description: 'no-op' }]
|
||||
const result = anthropicToolsToOpenAI(tools as any)
|
||||
|
||||
expect((result[0] as { function: { parameters: unknown } }).function.parameters).toEqual({ type: 'object', properties: {} })
|
||||
expect(
|
||||
(result[0] as { function: { parameters: unknown } }).function.parameters,
|
||||
).toEqual({ type: 'object', properties: {} })
|
||||
})
|
||||
|
||||
test('strips Anthropic-specific fields', () => {
|
||||
@@ -76,7 +80,8 @@ describe('anthropicToolsToOpenAI', () => {
|
||||
},
|
||||
]
|
||||
const result = anthropicToolsToOpenAI(tools as any)
|
||||
const props = (result[0] as { function: { parameters: any } }).function.parameters as any
|
||||
const props = (result[0] as { function: { parameters: any } }).function
|
||||
.parameters as any
|
||||
expect(props.properties.mode).toEqual({ enum: ['read'] })
|
||||
expect(props.properties.mode.const).toBeUndefined()
|
||||
expect(props.properties.name).toEqual({ type: 'string' })
|
||||
@@ -110,8 +115,11 @@ describe('anthropicToolsToOpenAI', () => {
|
||||
},
|
||||
]
|
||||
const result = anthropicToolsToOpenAI(tools as any)
|
||||
const params = (result[0] as { function: { parameters: any } }).function.parameters as any
|
||||
expect(params.properties.outer.properties.inner).toEqual({ enum: ['fixed'] })
|
||||
const params = (result[0] as { function: { parameters: any } }).function
|
||||
.parameters as any
|
||||
expect(params.properties.outer.properties.inner).toEqual({
|
||||
enum: ['fixed'],
|
||||
})
|
||||
expect(params.definitions.MyType.properties.field).toEqual({ enum: [42] })
|
||||
})
|
||||
|
||||
@@ -125,18 +133,17 @@ describe('anthropicToolsToOpenAI', () => {
|
||||
type: 'object',
|
||||
properties: {
|
||||
val: {
|
||||
anyOf: [
|
||||
{ const: 'a' },
|
||||
{ const: 'b' },
|
||||
{ type: 'string' },
|
||||
],
|
||||
anyOf: [{ const: 'a' }, { const: 'b' }, { type: 'string' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
const result = anthropicToolsToOpenAI(tools as any)
|
||||
const anyOf = ((result[0] as { function: { parameters: any } }).function.parameters as any).properties.val.anyOf
|
||||
const anyOf = (
|
||||
(result[0] as { function: { parameters: any } }).function
|
||||
.parameters as any
|
||||
).properties.val.anyOf
|
||||
expect(anyOf[0]).toEqual({ enum: ['a'] })
|
||||
expect(anyOf[1]).toEqual({ enum: ['b'] })
|
||||
expect(anyOf[2]).toEqual({ type: 'string' })
|
||||
|
||||
@@ -26,16 +26,16 @@ export interface ConvertMessagesOptions {
|
||||
* - system prompt → role: "system" message prepended
|
||||
* - tool_use blocks → tool_calls[] on assistant message
|
||||
* - tool_result blocks → role: "tool" messages
|
||||
* - thinking blocks → silently dropped (or preserved as reasoning_content when enableThinking=true)
|
||||
* - thinking blocks → preserved as reasoning_content (DeepSeek requires passing it back)
|
||||
* - cache_control → stripped
|
||||
*/
|
||||
export function anthropicMessagesToOpenAI(
|
||||
messages: (UserMessage | AssistantMessage)[],
|
||||
systemPrompt: SystemPrompt,
|
||||
options?: ConvertMessagesOptions,
|
||||
// options retained for API compatibility; thinking blocks are now always preserved
|
||||
_options?: ConvertMessagesOptions,
|
||||
): ChatCompletionMessageParam[] {
|
||||
const result: ChatCompletionMessageParam[] = []
|
||||
const enableThinking = options?.enableThinking ?? false
|
||||
|
||||
// Prepend system prompt as system message
|
||||
const systemText = systemPromptToText(systemPrompt)
|
||||
@@ -46,50 +46,13 @@ export function anthropicMessagesToOpenAI(
|
||||
} satisfies ChatCompletionSystemMessageParam)
|
||||
}
|
||||
|
||||
// When thinking mode is on, detect turn boundaries so that reasoning_content
|
||||
// from *previous* user turns is stripped (saves bandwidth; DeepSeek ignores it).
|
||||
// A "new turn" starts when a user text message appears after at least one assistant response.
|
||||
const turnBoundaries = new Set<number>()
|
||||
if (enableThinking) {
|
||||
let hasSeenAssistant = false
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i]
|
||||
if (msg.type === 'assistant') {
|
||||
hasSeenAssistant = true
|
||||
}
|
||||
if (msg.type === 'user' && hasSeenAssistant) {
|
||||
const content = msg.message.content
|
||||
// A user message starts a new turn if it contains any non-tool_result content
|
||||
// (text, image, or other media). Tool results alone do NOT start a new turn
|
||||
// because they are continuations of the previous assistant tool call.
|
||||
const startsNewUserTurn = typeof content === 'string'
|
||||
? content.length > 0
|
||||
: Array.isArray(content) && content.some(
|
||||
(b: any) =>
|
||||
typeof b === 'string' ||
|
||||
(b &&
|
||||
typeof b === 'object' &&
|
||||
'type' in b &&
|
||||
b.type !== 'tool_result'),
|
||||
)
|
||||
if (startsNewUserTurn) {
|
||||
turnBoundaries.add(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i]
|
||||
for (const msg of messages) {
|
||||
switch (msg.type) {
|
||||
case 'user':
|
||||
result.push(...convertInternalUserMessage(msg))
|
||||
break
|
||||
case 'assistant':
|
||||
// Preserve reasoning_content unless we're before a turn boundary
|
||||
// (i.e., from a previous user Q&A round)
|
||||
const preserveReasoning = enableThinking && !isBeforeAnyTurnBoundary(i, turnBoundaries)
|
||||
result.push(...convertInternalAssistantMessage(msg, preserveReasoning))
|
||||
result.push(...convertInternalAssistantMessage(msg))
|
||||
break
|
||||
default:
|
||||
break
|
||||
@@ -101,20 +64,7 @@ export function anthropicMessagesToOpenAI(
|
||||
|
||||
function systemPromptToText(systemPrompt: SystemPrompt): string {
|
||||
if (!systemPrompt || systemPrompt.length === 0) return ''
|
||||
return systemPrompt
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if index `i` falls before any turn boundary (i.e. it belongs to a previous turn).
|
||||
* A message at index i is "before" a boundary if there exists a boundary j where i < j.
|
||||
*/
|
||||
function isBeforeAnyTurnBoundary(i: number, boundaries: Set<number>): boolean {
|
||||
for (const b of boundaries) {
|
||||
if (i < b) return true
|
||||
}
|
||||
return false
|
||||
return systemPrompt.filter(Boolean).join('\n\n')
|
||||
}
|
||||
|
||||
function convertInternalUserMessage(
|
||||
@@ -131,7 +81,8 @@ function convertInternalUserMessage(
|
||||
} else if (Array.isArray(content)) {
|
||||
const textParts: string[] = []
|
||||
const toolResults: BetaToolResultBlockParam[] = []
|
||||
const imageParts: Array<{ type: 'image_url'; image_url: { url: string } }> = []
|
||||
const imageParts: Array<{ type: 'image_url'; image_url: { url: string } }> =
|
||||
[]
|
||||
|
||||
for (const block of content) {
|
||||
if (typeof block === 'string') {
|
||||
@@ -141,7 +92,9 @@ function convertInternalUserMessage(
|
||||
} else if (block.type === 'tool_result') {
|
||||
toolResults.push(block as BetaToolResultBlockParam)
|
||||
} else if (block.type === 'image') {
|
||||
const imagePart = convertImageBlockToOpenAI(block as unknown as Record<string, unknown>)
|
||||
const imagePart = convertImageBlockToOpenAI(
|
||||
block as unknown as Record<string, unknown>,
|
||||
)
|
||||
if (imagePart) {
|
||||
imageParts.push(imagePart)
|
||||
}
|
||||
@@ -158,7 +111,10 @@ function convertInternalUserMessage(
|
||||
|
||||
// 如果有图片,构建多模态 content 数组
|
||||
if (imageParts.length > 0) {
|
||||
const multiContent: Array<{ type: 'text'; text: string } | { type: 'image_url'; image_url: { url: string } }> = []
|
||||
const multiContent: Array<
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'image_url'; image_url: { url: string } }
|
||||
> = []
|
||||
if (textParts.length > 0) {
|
||||
multiContent.push({ type: 'text', text: textParts.join('\n') })
|
||||
}
|
||||
@@ -206,7 +162,6 @@ function convertToolResult(
|
||||
|
||||
function convertInternalAssistantMessage(
|
||||
msg: AssistantMessage,
|
||||
preserveReasoning = false,
|
||||
): ChatCompletionMessageParam[] {
|
||||
const content = msg.message.content
|
||||
|
||||
@@ -229,7 +184,9 @@ function convertInternalAssistantMessage(
|
||||
}
|
||||
|
||||
const textParts: string[] = []
|
||||
const toolCalls: NonNullable<ChatCompletionAssistantMessageParam['tool_calls']> = []
|
||||
const toolCalls: NonNullable<
|
||||
ChatCompletionAssistantMessageParam['tool_calls']
|
||||
> = []
|
||||
const reasoningParts: string[] = []
|
||||
|
||||
for (const block of content) {
|
||||
@@ -248,9 +205,12 @@ function convertInternalAssistantMessage(
|
||||
typeof tu.input === 'string' ? tu.input : JSON.stringify(tu.input),
|
||||
},
|
||||
})
|
||||
} else if (block.type === 'thinking' && preserveReasoning) {
|
||||
// DeepSeek thinking mode: preserve reasoning_content for tool call iterations
|
||||
const thinkingText = (block as unknown as Record<string, unknown>).thinking
|
||||
} else if (block.type === 'thinking') {
|
||||
// DeepSeek thinking mode: always preserve reasoning_content.
|
||||
// DeepSeek requires reasoning_content to be passed back in subsequent requests,
|
||||
// especially when tool calls are involved (returns 400 if missing).
|
||||
const thinkingText = (block as unknown as Record<string, unknown>)
|
||||
.thinking
|
||||
if (typeof thinkingText === 'string' && thinkingText) {
|
||||
reasoningParts.push(thinkingText)
|
||||
}
|
||||
@@ -262,7 +222,9 @@ function convertInternalAssistantMessage(
|
||||
role: 'assistant',
|
||||
content: textParts.length > 0 ? textParts.join('\n') : null,
|
||||
...(toolCalls.length > 0 && { tool_calls: toolCalls }),
|
||||
...(reasoningParts.length > 0 && { reasoning_content: reasoningParts.join('\n') }),
|
||||
...(reasoningParts.length > 0 && {
|
||||
reasoning_content: reasoningParts.join('\n'),
|
||||
}),
|
||||
}
|
||||
|
||||
return [result]
|
||||
|
||||
@@ -16,21 +16,27 @@ export function anthropicToolsToOpenAI(
|
||||
.filter(tool => {
|
||||
// Only convert standard tools (skip server tools like computer_use, etc.)
|
||||
const toolType = (tool as unknown as { type?: string }).type
|
||||
return tool.type === 'custom' || !('type' in tool) || toolType !== 'server'
|
||||
return (
|
||||
tool.type === 'custom' || !('type' in tool) || toolType !== 'server'
|
||||
)
|
||||
})
|
||||
.map(tool => {
|
||||
// Handle the various tool shapes from Anthropic SDK
|
||||
const anyTool = tool as unknown as Record<string, unknown>
|
||||
const name = (anyTool.name as string) || ''
|
||||
const description = (anyTool.description as string) || ''
|
||||
const inputSchema = anyTool.input_schema as Record<string, unknown> | undefined
|
||||
const inputSchema = anyTool.input_schema as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
|
||||
return {
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name,
|
||||
description,
|
||||
parameters: sanitizeJsonSchema(inputSchema || { type: 'object', properties: {} }),
|
||||
parameters: sanitizeJsonSchema(
|
||||
inputSchema || { type: 'object', properties: {} },
|
||||
),
|
||||
},
|
||||
} satisfies ChatCompletionTool
|
||||
})
|
||||
@@ -43,7 +49,9 @@ export function anthropicToolsToOpenAI(
|
||||
* support the `const` keyword in JSON Schema. Convert it to `enum` with a
|
||||
* single-element array, which is semantically equivalent.
|
||||
*/
|
||||
function sanitizeJsonSchema(schema: Record<string, unknown>): Record<string, unknown> {
|
||||
function sanitizeJsonSchema(
|
||||
schema: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
if (!schema || typeof schema !== 'object') return schema
|
||||
|
||||
const result = { ...schema }
|
||||
@@ -55,20 +63,37 @@ function sanitizeJsonSchema(schema: Record<string, unknown>): Record<string, unk
|
||||
}
|
||||
|
||||
// Recursively process nested schemas
|
||||
const objectKeys = ['properties', 'definitions', '$defs', 'patternProperties'] as const
|
||||
const objectKeys = [
|
||||
'properties',
|
||||
'definitions',
|
||||
'$defs',
|
||||
'patternProperties',
|
||||
] as const
|
||||
for (const key of objectKeys) {
|
||||
const nested = result[key]
|
||||
if (nested && typeof nested === 'object') {
|
||||
const sanitized: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(nested as Record<string, unknown>)) {
|
||||
sanitized[k] = v && typeof v === 'object' ? sanitizeJsonSchema(v as Record<string, unknown>) : v
|
||||
sanitized[k] =
|
||||
v && typeof v === 'object'
|
||||
? sanitizeJsonSchema(v as Record<string, unknown>)
|
||||
: v
|
||||
}
|
||||
result[key] = sanitized
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively process single-schema keys
|
||||
const singleKeys = ['items', 'additionalProperties', 'not', 'if', 'then', 'else', 'contains', 'propertyNames'] as const
|
||||
const singleKeys = [
|
||||
'items',
|
||||
'additionalProperties',
|
||||
'not',
|
||||
'if',
|
||||
'then',
|
||||
'else',
|
||||
'contains',
|
||||
'propertyNames',
|
||||
] as const
|
||||
for (const key of singleKeys) {
|
||||
const nested = result[key]
|
||||
if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
|
||||
@@ -82,7 +107,9 @@ function sanitizeJsonSchema(schema: Record<string, unknown>): Record<string, unk
|
||||
const nested = result[key]
|
||||
if (Array.isArray(nested)) {
|
||||
result[key] = nested.map(item =>
|
||||
item && typeof item === 'object' ? sanitizeJsonSchema(item as Record<string, unknown>) : item
|
||||
item && typeof item === 'object'
|
||||
? sanitizeJsonSchema(item as Record<string, unknown>)
|
||||
: item,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,10 @@ export async function* adaptOpenAIStreamToAnthropic(
|
||||
let currentContentIndex = -1
|
||||
|
||||
// Track tool_use blocks: tool_calls index → { contentIndex, id, name, arguments }
|
||||
const toolBlocks = new Map<number, { contentIndex: number; id: string; name: string; arguments: string }>()
|
||||
const toolBlocks = new Map<
|
||||
number,
|
||||
{ contentIndex: number; id: string; name: string; arguments: string }
|
||||
>()
|
||||
|
||||
// Track thinking block state
|
||||
let thinkingBlockOpen = false
|
||||
@@ -197,7 +200,8 @@ export async function* adaptOpenAIStreamToAnthropic(
|
||||
|
||||
// Start new tool_use block
|
||||
currentContentIndex++
|
||||
const toolId = tc.id || `toolu_${randomUUID().replace(/-/g, '').slice(0, 24)}`
|
||||
const toolId =
|
||||
tc.id || `toolu_${randomUUID().replace(/-/g, '').slice(0, 24)}`
|
||||
const toolName = tc.function?.name || ''
|
||||
|
||||
toolBlocks.set(tcIndex, {
|
||||
|
||||
@@ -1,10 +1,33 @@
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
import { dirname, resolve, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
// createRequire works in both Bun and Node.js ESM contexts.
|
||||
// Needed because this package is "type": "module" but uses require() for
|
||||
// loading native .node addons — bare require is not available in Node.js ESM.
|
||||
const nodeRequire = createRequire(import.meta.url)
|
||||
|
||||
/**
|
||||
* Resolve the "vendor root" directory where native .node binaries live.
|
||||
*
|
||||
* - Dev mode: import.meta.url → packages/audio-capture-napi/src/index.ts
|
||||
* → vendor root = <project>/vendor/
|
||||
* - Bun build: import.meta.url → dist/chunk-xxx.js
|
||||
* → vendor root = <project>/dist/vendor/
|
||||
* - Vite build: import.meta.url → dist/chunks/chunk-xxx.js
|
||||
* → vendor root = <project>/dist/vendor/
|
||||
*/
|
||||
function getVendorRoot(): string {
|
||||
const filePath = fileURLToPath(import.meta.url)
|
||||
const dir = dirname(filePath)
|
||||
const parts = dir.split(sep)
|
||||
const distIdx = parts.lastIndexOf('dist')
|
||||
if (distIdx !== -1) {
|
||||
return parts.slice(0, distIdx + 1).join(sep) + sep + 'vendor'
|
||||
}
|
||||
// Dev mode — go up from packages/audio-capture-napi/src/ to project root
|
||||
return resolve(dir, '..', '..', '..', 'vendor')
|
||||
}
|
||||
|
||||
type AudioCaptureNapi = {
|
||||
startRecording(
|
||||
onData: (data: Buffer) => void,
|
||||
@@ -56,15 +79,18 @@ function loadModule(): AudioCaptureNapi | null {
|
||||
}
|
||||
}
|
||||
|
||||
// Candidates 2-4: npm-install, dev/source, and workspace layouts.
|
||||
// In bundled output, require() resolves relative to cli.js at the package root.
|
||||
// In dev, it resolves relative to this file. When loaded from a workspace
|
||||
// package (packages/audio-capture-napi/src/), we need an absolute path fallback.
|
||||
// Candidates 2-5: resolved vendor path + relative fallbacks.
|
||||
// The primary candidate uses getVendorRoot() to find the correct dist root
|
||||
// regardless of chunk nesting depth. Relative fallbacks cover edge cases.
|
||||
const platformDir = `${process.arch}-${platform}`
|
||||
const binaryRel = `audio-capture/${platformDir}/audio-capture.node`
|
||||
const vendorRoot = getVendorRoot()
|
||||
const fallbacks = [
|
||||
`./vendor/audio-capture/${platformDir}/audio-capture.node`,
|
||||
`../audio-capture/${platformDir}/audio-capture.node`,
|
||||
`${process.cwd()}/vendor/audio-capture/${platformDir}/audio-capture.node`,
|
||||
resolve(vendorRoot, binaryRel),
|
||||
`./vendor/${binaryRel}`,
|
||||
`../vendor/${binaryRel}`,
|
||||
`../../vendor/${binaryRel}`,
|
||||
`${process.cwd()}/vendor/${binaryRel}`,
|
||||
]
|
||||
for (const p of fallbacks) {
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
import { debugMock } from "../../../../../../tests/mocks/debug";
|
||||
|
||||
// ─── Mocks for agentToolUtils.ts dependencies ───
|
||||
// Only mock modules that are truly unavailable or cause side effects.
|
||||
@@ -87,20 +88,7 @@ mock.module("src/tasks/LocalAgentTask/LocalAgentTask.js", () => ({
|
||||
updateProgressFromMessage: noop,
|
||||
}));
|
||||
|
||||
mock.module("src/utils/debug.ts", () => ({
|
||||
getMinDebugLogLevel: () => "warn",
|
||||
isDebugMode: () => false,
|
||||
enableDebugLogging: () => false,
|
||||
getDebugFilter: () => null,
|
||||
isDebugToStdErr: () => false,
|
||||
getDebugFilePath: () => null,
|
||||
setHasFormattedOutput: noop,
|
||||
getHasFormattedOutput: () => false,
|
||||
flushDebugLogs: async () => {},
|
||||
logForDebugging: noop,
|
||||
getDebugLogPath: () => "",
|
||||
logAntError: noop,
|
||||
}));
|
||||
mock.module("src/utils/debug.ts", debugMock);
|
||||
|
||||
mock.module("src/utils/errors.js", () => ({
|
||||
ClaudeError: class extends Error {},
|
||||
|
||||
@@ -394,6 +394,7 @@ export const getAgentDefinitionsWithOverrides = memoize(
|
||||
|
||||
export function clearAgentDefinitionsCache(): void {
|
||||
getAgentDefinitionsWithOverrides.cache.clear?.()
|
||||
loadMarkdownFilesForSubdir.cache?.clear?.()
|
||||
clearPluginAgentCache()
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,12 @@ 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 { tokenCountWithEstimation } from 'src/utils/tokens.js'
|
||||
import {
|
||||
getStats,
|
||||
isContextCollapseEnabled,
|
||||
} from 'src/services/contextCollapse/index.js'
|
||||
import { isSessionMemoryInitialized } from 'src/services/SessionMemory/sessionMemoryUtils.js'
|
||||
|
||||
const CTX_INSPECT_TOOL_NAME = 'CtxInspect'
|
||||
|
||||
@@ -19,6 +25,10 @@ type CtxInput = z.infer<InputSchema>
|
||||
type CtxOutput = {
|
||||
total_tokens: number
|
||||
message_count: number
|
||||
context_window_model: string
|
||||
prompt_caching_enabled: boolean
|
||||
session_memory_enabled: boolean
|
||||
context_collapse_enabled: boolean
|
||||
summary: string
|
||||
}
|
||||
|
||||
@@ -67,13 +77,45 @@ Use this to understand your context budget before deciding whether to snip old m
|
||||
}
|
||||
},
|
||||
|
||||
async call() {
|
||||
// Context inspection is wired into the context collapse system.
|
||||
async call(input: CtxInput, context) {
|
||||
const messages = context.messages ?? []
|
||||
const model = context.options?.mainLoopModel ?? 'unknown'
|
||||
const totalTokens = tokenCountWithEstimation(messages)
|
||||
const collapseEnabled = isContextCollapseEnabled()
|
||||
const collapseStats = getStats()
|
||||
const focused = input.query?.trim()
|
||||
|
||||
const sessionMemoryEnabled = isSessionMemoryInitialized()
|
||||
// Prompt caching is an API-level feature controlled by the provider, not
|
||||
// a user-facing toggle. Report as enabled only for providers known to
|
||||
// support Anthropic-style prompt caching (first-party, Bedrock, Vertex).
|
||||
const promptCachingEnabled = !model.startsWith('openai/') &&
|
||||
!model.startsWith('grok/') &&
|
||||
!model.startsWith('gemini/')
|
||||
|
||||
const summaryParts = [
|
||||
focused ? `Focus: ${focused}` : 'Overall context summary',
|
||||
`Model context: ${model}`,
|
||||
`Prompt caching: ${promptCachingEnabled ? 'enabled' : 'disabled'}`,
|
||||
`Session memory: ${sessionMemoryEnabled ? 'enabled' : 'disabled'}`,
|
||||
`Context collapse: ${collapseEnabled ? 'enabled' : 'disabled'}`,
|
||||
]
|
||||
|
||||
if (collapseEnabled) {
|
||||
summaryParts.push(
|
||||
`Collapse spans: ${collapseStats.collapsedSpans} committed, ${collapseStats.stagedSpans} staged, ${collapseStats.collapsedMessages} messages summarized`,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
total_tokens: 0,
|
||||
message_count: 0,
|
||||
summary: 'Context inspection requires the CONTEXT_COLLAPSE runtime.',
|
||||
total_tokens: totalTokens,
|
||||
message_count: messages.length,
|
||||
context_window_model: model,
|
||||
prompt_caching_enabled: promptCachingEnabled,
|
||||
session_memory_enabled: sessionMemoryEnabled,
|
||||
context_collapse_enabled: collapseEnabled,
|
||||
summary: summaryParts.join('\n'),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import { logMock } from '../../../../../../tests/mocks/log'
|
||||
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
|
||||
mock.module('src/services/tokenEstimation.ts', () => ({
|
||||
roughTokenCountEstimation: (text: string) => Math.ceil(text.length / 4),
|
||||
roughTokenCountEstimationForMessages: (msgs: unknown[]) => msgs.length * 64,
|
||||
roughTokenCountEstimationForMessage: () => 64,
|
||||
roughTokenCountEstimationForFileType: () => 64,
|
||||
bytesPerTokenForFileType: () => 4,
|
||||
countTokensWithAPI: async () => 0,
|
||||
countMessagesTokensWithAPI: async () => 0,
|
||||
countTokensViaHaikuFallback: async () => 0,
|
||||
}))
|
||||
|
||||
let sessionMemoryInitialized = false
|
||||
mock.module('src/services/SessionMemory/sessionMemoryUtils.ts', () => ({
|
||||
isSessionMemoryInitialized: () => sessionMemoryInitialized,
|
||||
waitForSessionMemoryExtraction: async () => {},
|
||||
getLastSummarizedMessageId: () => undefined,
|
||||
getSessionMemoryContent: async () => null,
|
||||
setLastSummarizedMessageId: () => {},
|
||||
markExtractionStarted: () => {},
|
||||
markExtractionCompleted: () => {},
|
||||
setSessionMemoryConfig: () => {},
|
||||
getSessionMemoryConfig: () => ({}),
|
||||
recordExtractionTokenCount: () => {},
|
||||
markSessionMemoryInitialized: () => {},
|
||||
hasMetInitializationThreshold: () => false,
|
||||
hasMetUpdateThreshold: () => false,
|
||||
getToolCallsBetweenUpdates: () => 0,
|
||||
resetSessionMemoryState: () => {},
|
||||
DEFAULT_SESSION_MEMORY_CONFIG: {},
|
||||
}))
|
||||
|
||||
mock.module('src/utils/slowOperations.ts', () => ({
|
||||
jsonStringify: JSON.stringify,
|
||||
jsonParse: JSON.parse,
|
||||
slowLogging: { enabled: false },
|
||||
clone: (value: unknown) => structuredClone(value),
|
||||
cloneDeep: (value: unknown) => structuredClone(value),
|
||||
callerFrame: () => '',
|
||||
SLOW_OPERATION_THRESHOLD_MS: 100,
|
||||
writeFileSync_DEPRECATED: () => {},
|
||||
}))
|
||||
|
||||
const { initContextCollapse, resetContextCollapse } = await import(
|
||||
'src/services/contextCollapse/index.js'
|
||||
)
|
||||
const { tokenCountWithEstimation } = await import('src/utils/tokens.js')
|
||||
const { CtxInspectTool } = await import('../CtxInspectTool.js')
|
||||
|
||||
function makeUserMessage(text: string) {
|
||||
return {
|
||||
type: 'user' as const,
|
||||
uuid: `user-${text}`,
|
||||
message: { role: 'user' as const, content: text },
|
||||
}
|
||||
}
|
||||
|
||||
function makeAssistantMessage(text: string) {
|
||||
return {
|
||||
type: 'assistant' as const,
|
||||
uuid: `assistant-${text}`,
|
||||
message: {
|
||||
role: 'assistant' as const,
|
||||
content: [{ type: 'text' as const, text }],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function makeContext(messages: unknown[], mainLoopModel = 'claude-sonnet-4-6') {
|
||||
return {
|
||||
messages,
|
||||
options: {
|
||||
mainLoopModel,
|
||||
},
|
||||
getAppState: () => ({}),
|
||||
} as any
|
||||
}
|
||||
|
||||
const allowTool = async (input: Record<string, unknown>) => ({
|
||||
behavior: 'allow' as const,
|
||||
updatedInput: input,
|
||||
})
|
||||
|
||||
const parentMessage = makeAssistantMessage('Parent tool call')
|
||||
|
||||
beforeEach(() => {
|
||||
resetContextCollapse()
|
||||
sessionMemoryInitialized = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetContextCollapse()
|
||||
sessionMemoryInitialized = false
|
||||
})
|
||||
|
||||
describe('CtxInspectTool', () => {
|
||||
test('tool exports and metadata remain stable', async () => {
|
||||
expect(CtxInspectTool).toBeDefined()
|
||||
expect(CtxInspectTool.name).toBe('CtxInspect')
|
||||
expect(typeof CtxInspectTool.call).toBe('function')
|
||||
expect(await CtxInspectTool.description()).toContain('context')
|
||||
expect(CtxInspectTool.userFacingName()).toBe('CtxInspect')
|
||||
expect(CtxInspectTool.isReadOnly()).toBe(true)
|
||||
expect(CtxInspectTool.isConcurrencySafe()).toBe(true)
|
||||
})
|
||||
|
||||
test('formats tool results for transcript rendering', () => {
|
||||
const block = CtxInspectTool.mapToolResultToToolResultBlockParam(
|
||||
{
|
||||
total_tokens: 192,
|
||||
message_count: 3,
|
||||
context_window_model: 'claude-sonnet-4-6',
|
||||
prompt_caching_enabled: true,
|
||||
session_memory_enabled: true,
|
||||
context_collapse_enabled: false,
|
||||
summary: 'Context collapse: disabled',
|
||||
},
|
||||
'tool-use-id',
|
||||
)
|
||||
|
||||
expect(block.tool_use_id).toBe('tool-use-id')
|
||||
expect(block.content).toContain('192 tokens')
|
||||
expect(block.content).toContain('3 messages')
|
||||
expect(block.content).toContain('Context collapse: disabled')
|
||||
})
|
||||
|
||||
test('returns live context counts and mechanism state', async () => {
|
||||
const messages = [
|
||||
makeUserMessage('Inspect the current context budget.'),
|
||||
makeAssistantMessage('Looking at the current conversation state.'),
|
||||
]
|
||||
const context = makeContext(messages, 'claude-sonnet-4-6')
|
||||
|
||||
const result = await (CtxInspectTool as any).call(
|
||||
{},
|
||||
context,
|
||||
allowTool,
|
||||
parentMessage,
|
||||
)
|
||||
|
||||
expect(Object.keys(result.data).sort()).toEqual([
|
||||
'context_collapse_enabled',
|
||||
'context_window_model',
|
||||
'message_count',
|
||||
'prompt_caching_enabled',
|
||||
'session_memory_enabled',
|
||||
'summary',
|
||||
'total_tokens',
|
||||
])
|
||||
expect(result.data.message_count).toBe(messages.length)
|
||||
expect(result.data.total_tokens).toBe(tokenCountWithEstimation(messages as any))
|
||||
expect(result.data.context_window_model).toBe('claude-sonnet-4-6')
|
||||
expect(result.data.prompt_caching_enabled).toBe(true)
|
||||
expect(result.data.session_memory_enabled).toBe(false)
|
||||
expect(result.data.context_collapse_enabled).toBe(false)
|
||||
expect(result.data.summary).toContain('Overall context summary')
|
||||
expect(result.data.summary).toContain('Session memory: disabled')
|
||||
expect(result.data.summary).toContain('Context collapse: disabled')
|
||||
})
|
||||
|
||||
test('query input focuses summary and collapse runtime changes the reported state', async () => {
|
||||
const messages = [
|
||||
makeUserMessage('Show me tool usage pressure in this thread.'),
|
||||
makeAssistantMessage('Summarizing tool-heavy context now.'),
|
||||
]
|
||||
const context = makeContext(messages, 'claude-sonnet-4-6')
|
||||
|
||||
const disabledResult = await (CtxInspectTool as any).call(
|
||||
{ query: 'tool usage' },
|
||||
context,
|
||||
allowTool,
|
||||
parentMessage,
|
||||
)
|
||||
|
||||
initContextCollapse()
|
||||
|
||||
const enabledResult = await (CtxInspectTool as any).call(
|
||||
{ query: 'tool usage' },
|
||||
context,
|
||||
allowTool,
|
||||
parentMessage,
|
||||
)
|
||||
|
||||
expect(disabledResult.data.message_count).toBe(messages.length)
|
||||
expect(enabledResult.data.message_count).toBe(messages.length)
|
||||
expect(disabledResult.data.total_tokens).toBe(
|
||||
tokenCountWithEstimation(messages as any),
|
||||
)
|
||||
expect(enabledResult.data.total_tokens).toBe(
|
||||
tokenCountWithEstimation(messages as any),
|
||||
)
|
||||
expect(disabledResult.data.summary).toContain('Focus: tool usage')
|
||||
expect(disabledResult.data.context_collapse_enabled).toBe(false)
|
||||
expect(enabledResult.data.context_collapse_enabled).toBe(true)
|
||||
expect(enabledResult.data.summary).toContain('Context collapse: enabled')
|
||||
expect(enabledResult.data.summary).toContain('Collapse spans:')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
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 {
|
||||
DISCOVER_SKILLS_TOOL_NAME,
|
||||
DESCRIPTION,
|
||||
DISCOVER_SKILLS_PROMPT,
|
||||
} from './prompt.js'
|
||||
|
||||
const inputSchema = lazySchema(() =>
|
||||
z.strictObject({
|
||||
description: z
|
||||
.string()
|
||||
.describe(
|
||||
'Description of what you want to do. Be specific — e.g. "deploy a Next.js app to Cloudflare Workers" rather than just "deploy".',
|
||||
),
|
||||
limit: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Maximum number of results to return (default: 5)'),
|
||||
}),
|
||||
)
|
||||
type InputSchema = ReturnType<typeof inputSchema>
|
||||
type DiscoverInput = z.infer<InputSchema>
|
||||
|
||||
type DiscoverOutput = {
|
||||
results: Array<{ name: string; description: string; score: number }>
|
||||
count: number
|
||||
}
|
||||
|
||||
export const DiscoverSkillsTool = buildTool({
|
||||
name: DISCOVER_SKILLS_TOOL_NAME,
|
||||
searchHint: 'find search discover skills commands tools capabilities',
|
||||
maxResultSizeChars: 10_000,
|
||||
strict: true,
|
||||
|
||||
get inputSchema(): InputSchema {
|
||||
return inputSchema()
|
||||
},
|
||||
|
||||
async description() {
|
||||
return DESCRIPTION
|
||||
},
|
||||
async prompt() {
|
||||
return DISCOVER_SKILLS_PROMPT
|
||||
},
|
||||
|
||||
isConcurrencySafe() {
|
||||
return true
|
||||
},
|
||||
isReadOnly() {
|
||||
return true
|
||||
},
|
||||
|
||||
userFacingName() {
|
||||
return 'Discover Skills'
|
||||
},
|
||||
|
||||
renderToolUseMessage(input: Partial<DiscoverInput>) {
|
||||
return `Searching skills: ${input.description?.slice(0, 80) ?? '...'}`
|
||||
},
|
||||
|
||||
mapToolResultToToolResultBlockParam(
|
||||
content: DiscoverOutput,
|
||||
toolUseID: string,
|
||||
): ToolResultBlockParam {
|
||||
if (content.count === 0) {
|
||||
return {
|
||||
tool_use_id: toolUseID,
|
||||
type: 'tool_result',
|
||||
content: 'No matching skills found for that description.',
|
||||
}
|
||||
}
|
||||
const lines = content.results.map(
|
||||
(r, i) =>
|
||||
`${i + 1}. **${r.name}** (score: ${r.score.toFixed(2)})\n ${r.description}`,
|
||||
)
|
||||
return {
|
||||
tool_use_id: toolUseID,
|
||||
type: 'tool_result',
|
||||
content: `Found ${content.count} relevant skill(s):\n\n${lines.join('\n\n')}`,
|
||||
}
|
||||
},
|
||||
|
||||
async call(input: DiscoverInput, context) {
|
||||
const { getSkillIndex, searchSkills } = await import(
|
||||
'src/services/skillSearch/localSearch.js'
|
||||
)
|
||||
const { getCwd } = await import('src/utils/cwd.js')
|
||||
const cwd = getCwd()
|
||||
|
||||
const index = await getSkillIndex(cwd)
|
||||
const results = searchSkills(input.description, index, input.limit ?? 5)
|
||||
|
||||
return {
|
||||
data: {
|
||||
results: results.map(r => ({
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
score: r.score,
|
||||
})),
|
||||
count: results.length,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { DISCOVER_SKILLS_TOOL_NAME } from '../prompt.js'
|
||||
|
||||
describe('DiscoverSkillsTool', () => {
|
||||
test('DISCOVER_SKILLS_TOOL_NAME is not empty', () => {
|
||||
expect(DISCOVER_SKILLS_TOOL_NAME).toBe('DiscoverSkills')
|
||||
expect(DISCOVER_SKILLS_TOOL_NAME.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('tool exports are functions', async () => {
|
||||
const { DiscoverSkillsTool } = await import('../DiscoverSkillsTool.js')
|
||||
expect(DiscoverSkillsTool).toBeDefined()
|
||||
expect(DiscoverSkillsTool.name).toBe('DiscoverSkills')
|
||||
expect(typeof DiscoverSkillsTool.call).toBe('function')
|
||||
})
|
||||
|
||||
test('tool has correct metadata', async () => {
|
||||
const { DiscoverSkillsTool } = await import('../DiscoverSkillsTool.js')
|
||||
expect(await DiscoverSkillsTool.description()).toContain('skill')
|
||||
expect(DiscoverSkillsTool.userFacingName()).toBe('Discover Skills')
|
||||
expect(DiscoverSkillsTool.isReadOnly()).toBe(true)
|
||||
expect(DiscoverSkillsTool.isConcurrencySafe()).toBe(true)
|
||||
})
|
||||
|
||||
test('renderToolUseMessage formats input', async () => {
|
||||
const { DiscoverSkillsTool } = await import('../DiscoverSkillsTool.js')
|
||||
const msg = DiscoverSkillsTool.renderToolUseMessage({
|
||||
description: 'deploy to cloudflare',
|
||||
})
|
||||
expect(msg).toContain('deploy to cloudflare')
|
||||
})
|
||||
|
||||
test('mapToolResultToToolResultBlockParam formats empty results', async () => {
|
||||
const { DiscoverSkillsTool } = await import('../DiscoverSkillsTool.js')
|
||||
const result = DiscoverSkillsTool.mapToolResultToToolResultBlockParam(
|
||||
{ results: [], count: 0 },
|
||||
'test-id',
|
||||
)
|
||||
expect(result.content).toContain('No matching skills')
|
||||
})
|
||||
|
||||
test('mapToolResultToToolResultBlockParam formats results', async () => {
|
||||
const { DiscoverSkillsTool } = await import('../DiscoverSkillsTool.js')
|
||||
const result = DiscoverSkillsTool.mapToolResultToToolResultBlockParam(
|
||||
{
|
||||
results: [{ name: 'test-skill', description: 'A test skill', score: 0.85 }],
|
||||
count: 1,
|
||||
},
|
||||
'test-id',
|
||||
)
|
||||
expect(result.content).toContain('test-skill')
|
||||
expect(result.content).toContain('0.85')
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,13 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export const DISCOVER_SKILLS_TOOL_NAME: string = '';
|
||||
export const DISCOVER_SKILLS_TOOL_NAME = 'DiscoverSkills'
|
||||
|
||||
export const DESCRIPTION =
|
||||
'Search for relevant skills by describing what you want to do'
|
||||
|
||||
export const DISCOVER_SKILLS_PROMPT = `Search for skills relevant to a task description. Returns matching skills ranked by relevance.
|
||||
|
||||
Use this when:
|
||||
- The auto-surfaced skills don't cover your current task
|
||||
- You're pivoting to a different kind of work mid-conversation
|
||||
- You want to find specialized skills for an unusual workflow
|
||||
|
||||
The search uses TF-IDF keyword matching against all registered skills (bundled, user-defined, and MCP-provided). Results include skill name, description, and relevance score.`
|
||||
|
||||
@@ -273,18 +273,6 @@ export const FileEditTool = buildTool({
|
||||
}
|
||||
|
||||
const readTimestamp = toolUseContext.readFileState.get(fullFilePath)
|
||||
if (!readTimestamp || readTimestamp.isPartialView) {
|
||||
return {
|
||||
result: false,
|
||||
behavior: 'ask',
|
||||
message:
|
||||
'File has not been read yet. Read it first before writing to it.',
|
||||
meta: {
|
||||
isFilePathAbsolute: String(isAbsolute(file_path)),
|
||||
},
|
||||
errorCode: 6,
|
||||
}
|
||||
}
|
||||
|
||||
// Check if file exists and get its last modified time
|
||||
if (readTimestamp) {
|
||||
|
||||
@@ -186,14 +186,6 @@ export function renderToolUseErrorMessage(
|
||||
extractTag(result, 'tool_use_error')
|
||||
) {
|
||||
const errorMessage = extractTag(result, 'tool_use_error')
|
||||
// Show a less scary message for intended behavior
|
||||
if (errorMessage?.includes('File has not been read yet')) {
|
||||
return (
|
||||
<MessageResponse>
|
||||
<Text dimColor>File must be read first</Text>
|
||||
</MessageResponse>
|
||||
)
|
||||
}
|
||||
if (errorMessage?.includes(FILE_NOT_FOUND_CWD_NOTE)) {
|
||||
return (
|
||||
<MessageResponse>
|
||||
|
||||
@@ -1,22 +1,8 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
import { logMock } from "../../../../../../tests/mocks/log";
|
||||
|
||||
// Mock log.ts to cut the heavy dependency chain
|
||||
mock.module("src/utils/log.ts", () => ({
|
||||
logError: () => {},
|
||||
logToFile: () => {},
|
||||
getLogDisplayTitle: () => "",
|
||||
logEvent: () => {},
|
||||
logMCPError: () => {},
|
||||
logMCPDebug: () => {},
|
||||
dateToFilename: (d: Date) => d.toISOString().replace(/[:.]/g, "-"),
|
||||
getLogFilePath: () => "/tmp/mock-log",
|
||||
attachErrorLogSink: () => {},
|
||||
getInMemoryErrors: () => [],
|
||||
loadErrorLogs: async () => [],
|
||||
getErrorLogByIndex: async () => null,
|
||||
captureAPIRequest: () => {},
|
||||
_resetErrorLogForTesting: () => {},
|
||||
}));
|
||||
mock.module("src/utils/log.ts", logMock);
|
||||
|
||||
const {
|
||||
normalizeQuotes,
|
||||
|
||||
@@ -196,25 +196,18 @@ export const FileWriteTool = buildTool({
|
||||
}
|
||||
|
||||
const readTimestamp = toolUseContext.readFileState.get(fullFilePath)
|
||||
if (!readTimestamp || readTimestamp.isPartialView) {
|
||||
return {
|
||||
result: false,
|
||||
message:
|
||||
'File has not been read yet. Read it first before writing to it.',
|
||||
errorCode: 2,
|
||||
}
|
||||
}
|
||||
|
||||
// Reuse mtime from the stat above — avoids a redundant statSync via
|
||||
// getFileModificationTime. The readTimestamp guard above ensures this
|
||||
// block is always reached when the file exists.
|
||||
const lastWriteTime = Math.floor(fileMtimeMs)
|
||||
if (lastWriteTime > readTimestamp.timestamp) {
|
||||
return {
|
||||
result: false,
|
||||
message:
|
||||
'File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.',
|
||||
errorCode: 3,
|
||||
// getFileModificationTime.
|
||||
if (readTimestamp) {
|
||||
const lastWriteTime = Math.floor(fileMtimeMs)
|
||||
if (lastWriteTime > readTimestamp.timestamp) {
|
||||
return {
|
||||
result: false,
|
||||
message:
|
||||
'File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.',
|
||||
errorCode: 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
import { debugMock } from "../../../../../../tests/mocks/debug";
|
||||
|
||||
mock.module("src/utils/debug.ts", () => ({
|
||||
logForDebugging: () => {},
|
||||
isDebugMode: () => false,
|
||||
}));
|
||||
mock.module("src/utils/debug.ts", debugMock);
|
||||
|
||||
const {
|
||||
formatGoToDefinitionResult,
|
||||
|
||||
@@ -421,7 +421,7 @@ export const PowerShellTool = buildTool({
|
||||
isSearch: boolean
|
||||
isRead: boolean
|
||||
} {
|
||||
if (!input.command) {
|
||||
if (!input?.command) {
|
||||
return { isSearch: false, isRead: false }
|
||||
}
|
||||
return isSearchOrReadPowerShellCommand(input.command)
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getClaudeAIOAuthTokens,
|
||||
} from 'src/utils/auth.js'
|
||||
import { lazySchema } from 'src/utils/lazySchema.js'
|
||||
import { appendRemoteTriggerAuditRecord } from 'src/utils/remoteTriggerAudit.js'
|
||||
import { jsonStringify } from 'src/utils/slowOperations.js'
|
||||
import { DESCRIPTION, PROMPT, REMOTE_TRIGGER_TOOL_NAME } from './prompt.js'
|
||||
import { renderToolResultMessage, renderToolUseMessage } from './UI.js'
|
||||
@@ -36,6 +37,7 @@ const outputSchema = lazySchema(() =>
|
||||
z.object({
|
||||
status: z.number(),
|
||||
json: z.string(),
|
||||
audit_id: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
type OutputSchema = ReturnType<typeof outputSchema>
|
||||
@@ -76,77 +78,96 @@ export const RemoteTriggerTool = buildTool({
|
||||
return PROMPT
|
||||
},
|
||||
async call(input: Input, context: ToolUseContext) {
|
||||
await checkAndRefreshOAuthTokenIfNeeded()
|
||||
const accessToken = getClaudeAIOAuthTokens()?.accessToken
|
||||
if (!accessToken) {
|
||||
throw new Error(
|
||||
'Not authenticated with a claude.ai account. Run /login and try again.',
|
||||
)
|
||||
}
|
||||
const orgUUID = await getOrganizationUUID()
|
||||
if (!orgUUID) {
|
||||
throw new Error('Unable to resolve organization UUID.')
|
||||
const auditBase = {
|
||||
action: input.action,
|
||||
...(input.trigger_id ? { triggerId: input.trigger_id } : {}),
|
||||
}
|
||||
try {
|
||||
await checkAndRefreshOAuthTokenIfNeeded()
|
||||
const accessToken = getClaudeAIOAuthTokens()?.accessToken
|
||||
if (!accessToken) {
|
||||
throw new Error(
|
||||
'Not authenticated with a claude.ai account. Run /login and try again.',
|
||||
)
|
||||
}
|
||||
const orgUUID = await getOrganizationUUID()
|
||||
if (!orgUUID) {
|
||||
throw new Error('Unable to resolve organization UUID.')
|
||||
}
|
||||
|
||||
const base = `${getOauthConfig().BASE_API_URL}/v1/code/triggers`
|
||||
const headers = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
'anthropic-beta': TRIGGERS_BETA,
|
||||
'x-organization-uuid': orgUUID,
|
||||
}
|
||||
const base = `${getOauthConfig().BASE_API_URL}/v1/code/triggers`
|
||||
const headers = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
'anthropic-beta': TRIGGERS_BETA,
|
||||
'x-organization-uuid': orgUUID,
|
||||
}
|
||||
|
||||
const { action, trigger_id, body } = input
|
||||
let method: 'GET' | 'POST'
|
||||
let url: string
|
||||
let data: unknown
|
||||
switch (action) {
|
||||
case 'list':
|
||||
method = 'GET'
|
||||
url = base
|
||||
break
|
||||
case 'get':
|
||||
if (!trigger_id) throw new Error('get requires trigger_id')
|
||||
method = 'GET'
|
||||
url = `${base}/${trigger_id}`
|
||||
break
|
||||
case 'create':
|
||||
if (!body) throw new Error('create requires body')
|
||||
method = 'POST'
|
||||
url = base
|
||||
data = body
|
||||
break
|
||||
case 'update':
|
||||
if (!trigger_id) throw new Error('update requires trigger_id')
|
||||
if (!body) throw new Error('update requires body')
|
||||
method = 'POST'
|
||||
url = `${base}/${trigger_id}`
|
||||
data = body
|
||||
break
|
||||
case 'run':
|
||||
if (!trigger_id) throw new Error('run requires trigger_id')
|
||||
method = 'POST'
|
||||
url = `${base}/${trigger_id}/run`
|
||||
data = {}
|
||||
break
|
||||
}
|
||||
const { action, trigger_id, body } = input
|
||||
let method: 'GET' | 'POST'
|
||||
let url: string
|
||||
let data: unknown
|
||||
switch (action) {
|
||||
case 'list':
|
||||
method = 'GET'
|
||||
url = base
|
||||
break
|
||||
case 'get':
|
||||
if (!trigger_id) throw new Error('get requires trigger_id')
|
||||
method = 'GET'
|
||||
url = `${base}/${trigger_id}`
|
||||
break
|
||||
case 'create':
|
||||
if (!body) throw new Error('create requires body')
|
||||
method = 'POST'
|
||||
url = base
|
||||
data = body
|
||||
break
|
||||
case 'update':
|
||||
if (!trigger_id) throw new Error('update requires trigger_id')
|
||||
if (!body) throw new Error('update requires body')
|
||||
method = 'POST'
|
||||
url = `${base}/${trigger_id}`
|
||||
data = body
|
||||
break
|
||||
case 'run':
|
||||
if (!trigger_id) throw new Error('run requires trigger_id')
|
||||
method = 'POST'
|
||||
url = `${base}/${trigger_id}/run`
|
||||
data = {}
|
||||
break
|
||||
}
|
||||
|
||||
const res = await axios.request({
|
||||
method,
|
||||
url,
|
||||
headers,
|
||||
data,
|
||||
timeout: 20_000,
|
||||
signal: context.abortController.signal,
|
||||
validateStatus: () => true,
|
||||
})
|
||||
|
||||
return {
|
||||
data: {
|
||||
const res = await axios.request({
|
||||
method,
|
||||
url,
|
||||
headers,
|
||||
data,
|
||||
timeout: 20_000,
|
||||
signal: context.abortController.signal,
|
||||
validateStatus: () => true,
|
||||
})
|
||||
const audit = await appendRemoteTriggerAuditRecord({
|
||||
...auditBase,
|
||||
ok: res.status >= 200 && res.status < 300,
|
||||
status: res.status,
|
||||
json: jsonStringify(res.data),
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
data: {
|
||||
status: res.status,
|
||||
json: jsonStringify(res.data),
|
||||
audit_id: audit.auditId,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
await appendRemoteTriggerAuditRecord({
|
||||
...auditBase,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
throw error
|
||||
}
|
||||
},
|
||||
mapToolResultToToolResultBlockParam(output, toolUseID) {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import { mkdir, readFile, rm } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import {
|
||||
resetStateForTests,
|
||||
setOriginalCwd,
|
||||
setProjectRoot,
|
||||
} from 'src/bootstrap/state.js'
|
||||
|
||||
let requestStatus = 200
|
||||
|
||||
mock.module('axios', () => ({
|
||||
default: {
|
||||
request: async () => ({
|
||||
status: requestStatus,
|
||||
data: { ok: requestStatus >= 200 && requestStatus < 300 },
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module('src/utils/auth.js', () => ({
|
||||
checkAndRefreshOAuthTokenIfNeeded: async () => {},
|
||||
getClaudeAIOAuthTokens: () => ({ accessToken: 'token' }),
|
||||
}))
|
||||
|
||||
mock.module('src/services/oauth/client.js', () => ({
|
||||
getOrganizationUUID: async () => 'org',
|
||||
}))
|
||||
|
||||
mock.module('src/constants/oauth.js', () => ({
|
||||
getOauthConfig: () => ({ BASE_API_URL: 'https://example.test' }),
|
||||
}))
|
||||
|
||||
let cwd = ''
|
||||
let previousCwd = ''
|
||||
|
||||
beforeEach(async () => {
|
||||
requestStatus = 200
|
||||
previousCwd = process.cwd()
|
||||
cwd = join(tmpdir(), `remote-trigger-tool-${Date.now()}-${Math.random().toString(16).slice(2)}`)
|
||||
await mkdir(cwd, { recursive: true })
|
||||
process.chdir(cwd)
|
||||
resetStateForTests()
|
||||
setOriginalCwd(cwd)
|
||||
setProjectRoot(cwd)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
resetStateForTests()
|
||||
process.chdir(previousCwd)
|
||||
await rm(cwd, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('RemoteTriggerTool audit', () => {
|
||||
test('writes an audit record for successful remote calls', async () => {
|
||||
const { RemoteTriggerTool } = await import('../RemoteTriggerTool')
|
||||
const result = await RemoteTriggerTool.call(
|
||||
{ action: 'run', trigger_id: 'trigger-1' },
|
||||
{ abortController: new AbortController() } as any,
|
||||
)
|
||||
|
||||
expect(result.data.audit_id).toBeString()
|
||||
const raw = await readFile(
|
||||
join(cwd, '.claude', 'remote-trigger-audit.jsonl'),
|
||||
'utf-8',
|
||||
)
|
||||
expect(raw).toContain('"action":"run"')
|
||||
expect(raw).toContain('"triggerId":"trigger-1"')
|
||||
expect(raw).toContain('"ok":true')
|
||||
})
|
||||
|
||||
test('writes an audit record before rethrowing validation failures', async () => {
|
||||
const { RemoteTriggerTool } = await import('../RemoteTriggerTool')
|
||||
|
||||
await expect(
|
||||
RemoteTriggerTool.call(
|
||||
{ action: 'run' },
|
||||
{ abortController: new AbortController() } as any,
|
||||
),
|
||||
).rejects.toThrow('run requires trigger_id')
|
||||
|
||||
const raw = await readFile(
|
||||
join(cwd, '.claude', 'remote-trigger-audit.jsonl'),
|
||||
'utf-8',
|
||||
)
|
||||
expect(raw).toContain('"action":"run"')
|
||||
expect(raw).toContain('"ok":false')
|
||||
expect(raw).toContain('run requires trigger_id')
|
||||
})
|
||||
})
|
||||
@@ -14,11 +14,26 @@ import {
|
||||
} from 'src/utils/swarm/teamHelpers.js'
|
||||
import { clearTeammateColors } from 'src/utils/swarm/teammateLayoutManager.js'
|
||||
import { clearLeaderTeamName } from 'src/utils/tasks.js'
|
||||
import { ensureBackendsRegistered, getBackendByType, getInProcessBackend } from 'src/utils/swarm/backends/registry.js'
|
||||
import { createPaneBackendExecutor } from 'src/utils/swarm/backends/PaneBackendExecutor.js'
|
||||
import { isPaneBackend } from 'src/utils/swarm/backends/types.js'
|
||||
import { sleep } from 'src/utils/sleep.js'
|
||||
import { TEAM_DELETE_TOOL_NAME } from './constants.js'
|
||||
import { getPrompt } from './prompt.js'
|
||||
import { renderToolResultMessage, renderToolUseMessage } from './UI.js'
|
||||
|
||||
const inputSchema = lazySchema(() => z.strictObject({}))
|
||||
const inputSchema = lazySchema(() =>
|
||||
z.strictObject({
|
||||
wait_ms: z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(30_000)
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional time to wait for active teammates to acknowledge shutdown before cleanup.',
|
||||
),
|
||||
}),
|
||||
)
|
||||
type InputSchema = ReturnType<typeof inputSchema>
|
||||
|
||||
export type Output = {
|
||||
@@ -68,7 +83,7 @@ export const TeamDeleteTool: Tool<InputSchema, Output> = buildTool({
|
||||
}
|
||||
},
|
||||
|
||||
async call(_input, context) {
|
||||
async call(input, context) {
|
||||
const { setAppState, getAppState } = context
|
||||
const appState = getAppState()
|
||||
const teamName = appState.teamContext?.teamName
|
||||
@@ -87,13 +102,82 @@ export const TeamDeleteTool: Tool<InputSchema, Output> = buildTool({
|
||||
const activeMembers = nonLeadMembers.filter(m => m.isActive !== false)
|
||||
|
||||
if (activeMembers.length > 0) {
|
||||
const memberNames = activeMembers.map(m => m.name).join(', ')
|
||||
return {
|
||||
data: {
|
||||
success: false,
|
||||
message: `Cannot cleanup team with ${activeMembers.length} active member(s): ${memberNames}. Use requestShutdown to gracefully terminate teammates first.`,
|
||||
team_name: teamName,
|
||||
},
|
||||
const requested: string[] = []
|
||||
for (const member of activeMembers) {
|
||||
let sent = false
|
||||
if (member.backendType === 'in-process') {
|
||||
const executor = getInProcessBackend()
|
||||
executor.setContext?.(context)
|
||||
sent = await executor.terminate(
|
||||
member.agentId,
|
||||
'Team cleanup requested by team lead',
|
||||
)
|
||||
} else if (member.backendType && isPaneBackend(member.backendType)) {
|
||||
await ensureBackendsRegistered()
|
||||
const executor = createPaneBackendExecutor(
|
||||
getBackendByType(member.backendType),
|
||||
)
|
||||
executor.setContext?.(context)
|
||||
sent = await executor.terminate(
|
||||
member.agentId,
|
||||
'Team cleanup requested by team lead',
|
||||
)
|
||||
}
|
||||
if (sent) {
|
||||
requested.push(member.name)
|
||||
}
|
||||
}
|
||||
const waitMs = input.wait_ms ?? 0
|
||||
if (waitMs > 0 && requested.length > 0) {
|
||||
const deadline = Date.now() + waitMs
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(Math.min(250, Math.max(0, deadline - Date.now())))
|
||||
const refreshed = readTeamFile(teamName)
|
||||
const stillActive =
|
||||
refreshed?.members.filter(
|
||||
m => m.name !== TEAM_LEAD_NAME && m.isActive !== false,
|
||||
) ?? []
|
||||
if (stillActive.length === 0) {
|
||||
break
|
||||
}
|
||||
}
|
||||
const refreshed = readTeamFile(teamName)
|
||||
const stillActive =
|
||||
refreshed?.members.filter(
|
||||
m => m.name !== TEAM_LEAD_NAME && m.isActive !== false,
|
||||
) ?? []
|
||||
if (stillActive.length === 0) {
|
||||
// Fall through to cleanup with the refreshed team file state.
|
||||
} else {
|
||||
const memberNames = stillActive.map(m => m.name).join(', ')
|
||||
return {
|
||||
data: {
|
||||
success: false,
|
||||
message: `Shutdown requested for active teammate(s): ${requested.join(', ')}. Cleanup is still blocked after waiting ${waitMs}ms: ${memberNames}.`,
|
||||
team_name: teamName,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
const latestTeamFile = readTeamFile(teamName)
|
||||
const latestActiveMembers =
|
||||
latestTeamFile?.members.filter(
|
||||
m => m.name !== TEAM_LEAD_NAME && m.isActive !== false,
|
||||
) ?? []
|
||||
if (latestActiveMembers.length === 0) {
|
||||
// Continue to cleanup below.
|
||||
} else {
|
||||
const memberNames = latestActiveMembers.map(m => m.name).join(', ')
|
||||
return {
|
||||
data: {
|
||||
success: false,
|
||||
message:
|
||||
requested.length > 0
|
||||
? `Shutdown requested for active teammate(s): ${requested.join(', ')}. Cleanup is blocked until they exit: ${memberNames}.`
|
||||
: `Cannot cleanup team with ${latestActiveMembers.length} active member(s): ${memberNames}. Use requestShutdown to gracefully terminate teammates first.`,
|
||||
team_name: teamName,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,19 +9,11 @@ const inputSchema = lazySchema(() =>
|
||||
z.strictObject({
|
||||
url: z
|
||||
.string()
|
||||
.describe('URL to navigate to in the browser.'),
|
||||
.describe('URL to fetch and extract content from.'),
|
||||
action: z
|
||||
.enum(['navigate', 'screenshot', 'click', 'type', 'scroll'])
|
||||
.enum(['navigate', 'screenshot'])
|
||||
.optional()
|
||||
.describe('Browser action to perform. Defaults to "navigate".'),
|
||||
selector: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('CSS selector for click/type actions.'),
|
||||
text: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Text to type when action is "type".'),
|
||||
.describe('Action to perform. "navigate" fetches page content (default). "screenshot" returns a text snapshot of the page.'),
|
||||
}),
|
||||
)
|
||||
type InputSchema = ReturnType<typeof inputSchema>
|
||||
@@ -45,16 +37,24 @@ export const WebBrowserTool = buildTool({
|
||||
},
|
||||
|
||||
async description() {
|
||||
return 'Browse the web using an embedded browser'
|
||||
return 'Fetch and read web page content via HTTP'
|
||||
},
|
||||
async prompt() {
|
||||
return `Open and interact with web pages in an embedded browser. Supports navigation, screenshots, clicking, typing, and scrolling.
|
||||
return `Fetch web pages via HTTP and extract their text content. This is a lightweight browser tool (HTTP fetch, not a full browser engine).
|
||||
|
||||
Supported actions:
|
||||
- navigate: Fetch a URL and extract page title + text content
|
||||
- screenshot: Same as navigate (returns text snapshot, not a visual screenshot)
|
||||
|
||||
Limitations:
|
||||
- No JavaScript execution — only sees server-rendered HTML
|
||||
- click/type/scroll require a full browser runtime (not available)
|
||||
- For full browser interaction, use the Claude-in-Chrome MCP tools instead
|
||||
|
||||
Use this for:
|
||||
- Viewing web pages and their content
|
||||
- Taking screenshots of UI
|
||||
- Interacting with web applications
|
||||
- Testing web endpoints with full browser rendering`
|
||||
- Reading web page content and documentation
|
||||
- Checking API endpoints that return HTML
|
||||
- Quick page title/content extraction`
|
||||
},
|
||||
|
||||
isConcurrencySafe() {
|
||||
@@ -85,12 +85,84 @@ Use this for:
|
||||
},
|
||||
|
||||
async call(input: BrowserInput) {
|
||||
// Browser integration requires the WEB_BROWSER_TOOL runtime (Bun WebView).
|
||||
const action = input.action ?? 'navigate'
|
||||
|
||||
if (action === 'navigate' || action === 'screenshot') {
|
||||
// Fetch the page content via HTTP
|
||||
try {
|
||||
const response = await fetch(input.url, {
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
Accept:
|
||||
'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
},
|
||||
redirect: 'follow',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
data: {
|
||||
title: `HTTP ${response.status}`,
|
||||
url: input.url,
|
||||
content: `Error: ${response.status} ${response.statusText}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const html = await response.text()
|
||||
|
||||
// Extract title
|
||||
const titleMatch = html.match(/<title[^>]*>([^<]*)<\/title>/i)
|
||||
const title = titleMatch?.[1]?.trim() ?? ''
|
||||
|
||||
// Extract text content (strip HTML tags, scripts, styles)
|
||||
let textContent = html
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
|
||||
// Truncate to reasonable size
|
||||
if (textContent.length > 50_000) {
|
||||
textContent = textContent.slice(0, 50_000) + '\n[truncated]'
|
||||
}
|
||||
|
||||
if (action === 'screenshot') {
|
||||
return {
|
||||
data: {
|
||||
title,
|
||||
url: response.url,
|
||||
content: `[Text snapshot — visual screenshots require Chrome browser tools]\n\n${textContent}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
title,
|
||||
url: response.url,
|
||||
content: textContent,
|
||||
},
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
data: {
|
||||
title: 'Error',
|
||||
url: input.url,
|
||||
content: `Failed to fetch: ${err instanceof Error ? err.message : String(err)}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unreachable — schema only allows navigate/screenshot
|
||||
return {
|
||||
data: {
|
||||
title: '',
|
||||
url: input.url,
|
||||
content: 'Web browser requires the WEB_BROWSER_TOOL runtime.',
|
||||
content: `Unknown action "${action}".`,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test'
|
||||
|
||||
// Mock fetch directly — avoids flaky dependency on external hosts AND
|
||||
// pollution by other tests that call setGlobalDispatcher (proxy agents make
|
||||
// localhost fetches return 500 in the full-suite run).
|
||||
const realFetch = globalThis.fetch
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.fetch = (async (
|
||||
input: string | URL | Request,
|
||||
_init?: RequestInit,
|
||||
) => {
|
||||
const url = typeof input === 'string' ? input : input.toString()
|
||||
if (url === 'not-a-url' || !url.startsWith('http')) {
|
||||
throw new TypeError('Failed to fetch')
|
||||
}
|
||||
const body =
|
||||
'<!doctype html><html><head><title>Example Domain</title></head>' +
|
||||
'<body><h1>Example Domain</h1><p>Sample content.</p></body></html>'
|
||||
const res = new Response(body, {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/html' },
|
||||
})
|
||||
// Make response.url match the request URL so tests can assert on it.
|
||||
Object.defineProperty(res, 'url', { value: url, configurable: true })
|
||||
return res
|
||||
}) as typeof fetch
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
globalThis.fetch = realFetch
|
||||
})
|
||||
|
||||
describe('WebBrowserTool', () => {
|
||||
test('tool exports and metadata', async () => {
|
||||
const { WebBrowserTool } = await import('../WebBrowserTool.js')
|
||||
expect(WebBrowserTool).toBeDefined()
|
||||
expect(WebBrowserTool.name).toBe('WebBrowser')
|
||||
expect(typeof WebBrowserTool.call).toBe('function')
|
||||
expect(WebBrowserTool.userFacingName()).toBe('Browser')
|
||||
expect(WebBrowserTool.isReadOnly()).toBe(true)
|
||||
})
|
||||
|
||||
test('description reflects browser-lite', async () => {
|
||||
const { WebBrowserTool } = await import('../WebBrowserTool.js')
|
||||
const desc = await WebBrowserTool.description()
|
||||
expect(desc).toContain('HTTP')
|
||||
expect(desc).not.toContain('embedded browser')
|
||||
})
|
||||
|
||||
test('prompt mentions limitations', async () => {
|
||||
const { WebBrowserTool } = await import('../WebBrowserTool.js')
|
||||
const prompt = await WebBrowserTool.prompt()
|
||||
expect(prompt).toContain('Limitations')
|
||||
expect(prompt).toContain('No JavaScript')
|
||||
expect(prompt).toContain('Claude-in-Chrome')
|
||||
})
|
||||
|
||||
test('navigate fetches URL', async () => {
|
||||
const { WebBrowserTool } = await import('../WebBrowserTool.js')
|
||||
const result = await WebBrowserTool.call({
|
||||
url: 'https://example.com',
|
||||
} as any)
|
||||
expect(result.data.title).toBe('Example Domain')
|
||||
expect(result.data.url).toContain('example.com')
|
||||
expect(result.data.content).toContain('Example Domain')
|
||||
}, 15000)
|
||||
|
||||
test('screenshot returns text snapshot', async () => {
|
||||
const { WebBrowserTool } = await import('../WebBrowserTool.js')
|
||||
const result = await WebBrowserTool.call({
|
||||
url: 'https://example.com',
|
||||
action: 'screenshot',
|
||||
} as any)
|
||||
expect(result.data.content).toContain('Text snapshot')
|
||||
expect(result.data.content).toContain('Example Domain')
|
||||
}, 15000)
|
||||
|
||||
test('schema only allows navigate and screenshot', async () => {
|
||||
const { WebBrowserTool } = await import('../WebBrowserTool.js')
|
||||
const schema = WebBrowserTool.inputSchema
|
||||
const parseResult = schema.safeParse({
|
||||
url: 'https://example.com',
|
||||
action: 'click',
|
||||
})
|
||||
expect(parseResult.success).toBe(false)
|
||||
})
|
||||
|
||||
test('invalid URL returns error', async () => {
|
||||
const { WebBrowserTool } = await import('../WebBrowserTool.js')
|
||||
const result = await WebBrowserTool.call({ url: 'not-a-url' } as any)
|
||||
expect(result.data.content).toContain('Failed to fetch')
|
||||
})
|
||||
})
|
||||
@@ -23,6 +23,26 @@ const inputSchema = lazySchema(() =>
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe('Never include search results from these domains'),
|
||||
num_results: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Number of search results to return (default: 8)'),
|
||||
livecrawl: z
|
||||
.enum(['fallback', 'preferred'])
|
||||
.optional()
|
||||
.describe(
|
||||
"Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
|
||||
),
|
||||
search_type: z
|
||||
.enum(['auto', 'fast', 'deep'])
|
||||
.optional()
|
||||
.describe(
|
||||
"Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search",
|
||||
),
|
||||
context_max_characters: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Maximum characters for context string optimized for LLMs (default: 10000)'),
|
||||
}),
|
||||
)
|
||||
type InputSchema = ReturnType<typeof inputSchema>
|
||||
@@ -148,6 +168,10 @@ export const WebSearchTool = buildTool({
|
||||
const adapterResults = await adapter.search(query, {
|
||||
allowedDomains: input.allowed_domains,
|
||||
blockedDomains: input.blocked_domains,
|
||||
numResults: input.num_results,
|
||||
livecrawl: input.livecrawl,
|
||||
searchType: input.search_type,
|
||||
contextMaxCharacters: input.context_max_characters,
|
||||
signal: context.abortController.signal,
|
||||
onProgress(progress) {
|
||||
if (onProgress) {
|
||||
|
||||
@@ -52,10 +52,10 @@ describe('createAdapter', () => {
|
||||
expect(createAdapter().constructor.name).toBe('ApiSearchAdapter')
|
||||
})
|
||||
|
||||
test('selects the Bing adapter for third-party Anthropic base URLs', () => {
|
||||
test('selects the Exa adapter for third-party Anthropic base URLs', () => {
|
||||
delete process.env.WEB_SEARCH_ADAPTER
|
||||
isFirstPartyBaseUrl = false
|
||||
|
||||
expect(createAdapter().constructor.name).toBe('BingSearchAdapter')
|
||||
expect(createAdapter().constructor.name).toBe('ExaSearchAdapter')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import { afterEach, 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)
|
||||
|
||||
describe('ExaSearchAdapter.search', () => {
|
||||
const createAdapter = async () => {
|
||||
const { ExaSearchAdapter } = await import('../adapters/exaAdapter')
|
||||
return new ExaSearchAdapter()
|
||||
}
|
||||
|
||||
// Exa MCP returns SSE lines like: data: {"result":{"content":[{"type":"text","text":"..."}]}}
|
||||
const buildSseResponse = (text: string) => `data: ${JSON.stringify({ result: { content: [{ type: 'text', text }] } })}\n`
|
||||
|
||||
const STRUCTURED_TEXT = [
|
||||
'Title: Example Result 1',
|
||||
'URL: https://example.com/page1',
|
||||
'Content: This is the content snippet for page 1.',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'Title: Example Result 2',
|
||||
'URL: https://example.com/page2',
|
||||
'Content: This is the content snippet for page 2.',
|
||||
].join('\n')
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
test('parses structured Title/URL/Content blocks from SSE response', async () => {
|
||||
mock.module('axios', () => ({
|
||||
default: {
|
||||
post: mock(() => Promise.resolve({ data: buildSseResponse(STRUCTURED_TEXT) })),
|
||||
isCancel: () => false,
|
||||
},
|
||||
}))
|
||||
|
||||
const adapter = await createAdapter()
|
||||
const results = await adapter.search('test query', {})
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results[0]).toEqual({
|
||||
title: 'Example Result 1',
|
||||
url: 'https://example.com/page1',
|
||||
snippet: 'This is the content snippet for page 1.',
|
||||
})
|
||||
expect(results[1]).toEqual({
|
||||
title: 'Example Result 2',
|
||||
url: 'https://example.com/page2',
|
||||
snippet: 'This is the content snippet for page 2.',
|
||||
})
|
||||
})
|
||||
|
||||
test('parses markdown link fallback when no structured blocks', async () => {
|
||||
const markdownText = '- [React Docs](https://react.dev/docs)\n- [React Hooks](https://react.dev/hooks)'
|
||||
mock.module('axios', () => ({
|
||||
default: {
|
||||
post: mock(() => Promise.resolve({ data: buildSseResponse(markdownText) })),
|
||||
isCancel: () => false,
|
||||
},
|
||||
}))
|
||||
|
||||
const adapter = await createAdapter()
|
||||
const results = await adapter.search('react', {})
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results[0]).toEqual({
|
||||
title: 'React Docs',
|
||||
url: 'https://react.dev/docs',
|
||||
snippet: undefined,
|
||||
})
|
||||
expect(results[1].url).toBe('https://react.dev/hooks')
|
||||
})
|
||||
|
||||
test('parses plain URL fallback', async () => {
|
||||
const plainUrlText = 'https://example.com/page1\nhttps://example.com/page2'
|
||||
mock.module('axios', () => ({
|
||||
default: {
|
||||
post: mock(() => Promise.resolve({ data: buildSseResponse(plainUrlText) })),
|
||||
isCancel: () => false,
|
||||
},
|
||||
}))
|
||||
|
||||
const adapter = await createAdapter()
|
||||
const results = await adapter.search('test', {})
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results[0].url).toBe('https://example.com/page1')
|
||||
})
|
||||
|
||||
test('returns empty array for empty response', async () => {
|
||||
mock.module('axios', () => ({
|
||||
default: {
|
||||
post: mock(() => Promise.resolve({ data: '' })),
|
||||
isCancel: () => false,
|
||||
},
|
||||
}))
|
||||
|
||||
const adapter = await createAdapter()
|
||||
const results = await adapter.search('test', {})
|
||||
|
||||
expect(results).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('parses direct JSON response (non-SSE fallback)', async () => {
|
||||
const jsonResponse = JSON.stringify({
|
||||
result: { content: [{ type: 'text', text: STRUCTURED_TEXT }] },
|
||||
})
|
||||
mock.module('axios', () => ({
|
||||
default: {
|
||||
post: mock(() => Promise.resolve({ data: jsonResponse })),
|
||||
isCancel: () => false,
|
||||
},
|
||||
}))
|
||||
|
||||
const adapter = await createAdapter()
|
||||
const results = await adapter.search('test', {})
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results[0].url).toBe('https://example.com/page1')
|
||||
})
|
||||
|
||||
test('calls onProgress with query_update and search_results_received', async () => {
|
||||
mock.module('axios', () => ({
|
||||
default: {
|
||||
post: mock(() => Promise.resolve({ data: buildSseResponse(STRUCTURED_TEXT) })),
|
||||
isCancel: () => false,
|
||||
},
|
||||
}))
|
||||
|
||||
const progressCalls: any[] = []
|
||||
const onProgress = (p: any) => progressCalls.push(p)
|
||||
|
||||
const adapter = await createAdapter()
|
||||
await adapter.search('test', { onProgress })
|
||||
|
||||
expect(progressCalls).toHaveLength(2)
|
||||
expect(progressCalls[0]).toEqual({ type: 'query_update', query: 'test' })
|
||||
expect(progressCalls[1]).toEqual({
|
||||
type: 'search_results_received',
|
||||
resultCount: 2,
|
||||
query: 'test',
|
||||
})
|
||||
})
|
||||
|
||||
test('filters results by allowedDomains', async () => {
|
||||
const mixedText = [
|
||||
'Title: Allowed',
|
||||
'URL: https://allowed.com/a',
|
||||
'---',
|
||||
'Title: Blocked',
|
||||
'URL: https://blocked.com/b',
|
||||
].join('\n')
|
||||
|
||||
mock.module('axios', () => ({
|
||||
default: {
|
||||
post: mock(() => Promise.resolve({ data: buildSseResponse(mixedText) })),
|
||||
isCancel: () => false,
|
||||
},
|
||||
}))
|
||||
|
||||
const adapter = await createAdapter()
|
||||
const results = await adapter.search('test', { allowedDomains: ['allowed.com'] })
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].url).toBe('https://allowed.com/a')
|
||||
})
|
||||
|
||||
test('filters results by blockedDomains', async () => {
|
||||
const mixedText = [
|
||||
'Title: Good',
|
||||
'URL: https://good.com/a',
|
||||
'---',
|
||||
'Title: Spam',
|
||||
'URL: https://spam.com/b',
|
||||
].join('\n')
|
||||
|
||||
mock.module('axios', () => ({
|
||||
default: {
|
||||
post: mock(() => Promise.resolve({ data: buildSseResponse(mixedText) })),
|
||||
isCancel: () => false,
|
||||
},
|
||||
}))
|
||||
|
||||
const adapter = await createAdapter()
|
||||
const results = await adapter.search('test', { blockedDomains: ['spam.com'] })
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].url).toBe('https://good.com/a')
|
||||
})
|
||||
|
||||
test('filters subdomains with allowedDomains', async () => {
|
||||
const text = [
|
||||
'Title: Subdomain',
|
||||
'URL: https://docs.example.com/page',
|
||||
'---',
|
||||
'Title: Other',
|
||||
'URL: https://other.com/page',
|
||||
].join('\n')
|
||||
|
||||
mock.module('axios', () => ({
|
||||
default: {
|
||||
post: mock(() => Promise.resolve({ data: buildSseResponse(text) })),
|
||||
isCancel: () => false,
|
||||
},
|
||||
}))
|
||||
|
||||
const adapter = await createAdapter()
|
||||
const results = await adapter.search('test', { allowedDomains: ['example.com'] })
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].url).toBe('https://docs.example.com/page')
|
||||
})
|
||||
|
||||
test('throws AbortError when signal is already aborted', async () => {
|
||||
mock.module('axios', () => ({
|
||||
default: {
|
||||
post: mock(() => Promise.resolve({ data: buildSseResponse(STRUCTURED_TEXT) })),
|
||||
isCancel: () => false,
|
||||
},
|
||||
}))
|
||||
|
||||
const adapter = await createAdapter()
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
|
||||
const { AbortError } = await import('src/utils/errors')
|
||||
await expect(
|
||||
adapter.search('test', { signal: controller.signal }),
|
||||
).rejects.toThrow(AbortError)
|
||||
})
|
||||
|
||||
test('re-throws non-abort axios errors', async () => {
|
||||
const networkError = new Error('Network error')
|
||||
mock.module('axios', () => ({
|
||||
default: {
|
||||
post: mock(() => Promise.reject(networkError)),
|
||||
isCancel: () => false,
|
||||
},
|
||||
}))
|
||||
|
||||
const adapter = await createAdapter()
|
||||
await expect(adapter.search('test', {})).rejects.toThrow('Network error')
|
||||
})
|
||||
|
||||
test('sends correct MCP request payload to Exa endpoint', async () => {
|
||||
const axiosPost = mock(() => Promise.resolve({ data: buildSseResponse(STRUCTURED_TEXT) }))
|
||||
mock.module('axios', () => ({
|
||||
default: {
|
||||
post: axiosPost,
|
||||
isCancel: () => false,
|
||||
},
|
||||
}))
|
||||
|
||||
const adapter = await createAdapter()
|
||||
await adapter.search('hello world', {})
|
||||
|
||||
expect(axiosPost.mock.calls).toHaveLength(1)
|
||||
const [url, body, config] = (axiosPost.mock.calls as any[][])[0]
|
||||
expect(url).toBe('https://mcp.exa.ai/mcp')
|
||||
expect(body.jsonrpc).toBe('2.0')
|
||||
expect(body.method).toBe('tools/call')
|
||||
expect(body.params.name).toBe('web_search_exa')
|
||||
expect(body.params.arguments.query).toBe('hello world')
|
||||
expect(body.params.arguments.type).toBe('auto')
|
||||
expect(body.params.arguments.numResults).toBe(8)
|
||||
expect(body.params.arguments.livecrawl).toBe('fallback')
|
||||
expect(body.params.arguments.contextMaxCharacters).toBe(10000)
|
||||
expect(config.headers.Accept).toBe('application/json, text/event-stream')
|
||||
})
|
||||
|
||||
test('passes custom search options to MCP request', async () => {
|
||||
const axiosPost = mock(() => Promise.resolve({ data: buildSseResponse(STRUCTURED_TEXT) }))
|
||||
mock.module('axios', () => ({
|
||||
default: {
|
||||
post: axiosPost,
|
||||
isCancel: () => false,
|
||||
},
|
||||
}))
|
||||
|
||||
const adapter = await createAdapter()
|
||||
await adapter.search('test', {
|
||||
numResults: 15,
|
||||
livecrawl: 'preferred',
|
||||
searchType: 'deep',
|
||||
contextMaxCharacters: 20000,
|
||||
})
|
||||
|
||||
const [, body] = (axiosPost.mock.calls as any[][])[0]
|
||||
expect(body.params.arguments.numResults).toBe(15)
|
||||
expect(body.params.arguments.livecrawl).toBe('preferred')
|
||||
expect(body.params.arguments.type).toBe('deep')
|
||||
expect(body.params.arguments.contextMaxCharacters).toBe(20000)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Exa AI-based search adapter — uses MCP protocol to call Exa's web search API.
|
||||
*
|
||||
* Ported from kilocode's production-validated implementation (mcp-exa.ts + websearch.ts).
|
||||
* Key improvements over previous version:
|
||||
* - Passes through numResults/livecrawl/type/contextMaxCharacters from options
|
||||
* - Cleaner SSE parsing matching kilocode's approach
|
||||
* - Proper content snippet extraction from Exa responses
|
||||
*/
|
||||
|
||||
import axios from 'axios'
|
||||
import { AbortError } from 'src/utils/errors.js'
|
||||
import type { SearchResult, SearchOptions, WebSearchAdapter } from './types.js'
|
||||
|
||||
const EXA_MCP_URL = 'https://mcp.exa.ai/mcp'
|
||||
const FETCH_TIMEOUT_MS = 25_000
|
||||
|
||||
export class ExaSearchAdapter implements WebSearchAdapter {
|
||||
async search(
|
||||
query: string,
|
||||
options: SearchOptions,
|
||||
): Promise<SearchResult[]> {
|
||||
const { signal, onProgress, allowedDomains, blockedDomains } = options
|
||||
|
||||
if (signal?.aborted) {
|
||||
throw new AbortError()
|
||||
}
|
||||
|
||||
onProgress?.({ type: 'query_update', query })
|
||||
|
||||
const abortController = new AbortController()
|
||||
if (signal) {
|
||||
signal.addEventListener('abort', () => abortController.abort(), { once: true })
|
||||
}
|
||||
|
||||
// Use options to derive search params — matches kilocode websearch.ts defaults
|
||||
const numResults = options.numResults ?? 8
|
||||
const livecrawl = options.livecrawl ?? 'fallback'
|
||||
const searchType = options.searchType ?? 'auto'
|
||||
const contextMaxCharacters = options.contextMaxCharacters ?? 10000
|
||||
|
||||
let responseText: string
|
||||
try {
|
||||
const response = await axios.post(
|
||||
EXA_MCP_URL,
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'web_search_exa',
|
||||
arguments: {
|
||||
query,
|
||||
type: searchType,
|
||||
numResults,
|
||||
livecrawl,
|
||||
contextMaxCharacters,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
signal: abortController.signal,
|
||||
timeout: FETCH_TIMEOUT_MS,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
},
|
||||
responseType: 'text',
|
||||
},
|
||||
)
|
||||
responseText = response.data as string
|
||||
} catch (e) {
|
||||
if (axios.isCancel(e) || abortController.signal.aborted) {
|
||||
throw new AbortError()
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
if (abortController.signal.aborted) {
|
||||
throw new AbortError()
|
||||
}
|
||||
|
||||
const searchText = this.parseSse(responseText)
|
||||
|
||||
if (abortController.signal.aborted) {
|
||||
throw new AbortError()
|
||||
}
|
||||
|
||||
// Parse the Exa results from the text response
|
||||
const results = this.parseResults(searchText)
|
||||
|
||||
// Client-side domain filtering
|
||||
const filteredResults = results.filter((r) => {
|
||||
if (!r.url) return false
|
||||
try {
|
||||
const hostname = new URL(r.url).hostname
|
||||
if (allowedDomains?.length && !allowedDomains.some(d => hostname === d || hostname.endsWith('.' + d))) {
|
||||
return false
|
||||
}
|
||||
if (blockedDomains?.length && blockedDomains.some(d => hostname === d || hostname.endsWith('.' + d))) {
|
||||
return false
|
||||
}
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
onProgress?.({
|
||||
type: 'search_results_received',
|
||||
resultCount: filteredResults.length,
|
||||
query,
|
||||
})
|
||||
|
||||
return filteredResults
|
||||
}
|
||||
|
||||
private parseSse(body: string): string | undefined {
|
||||
// SSE format: lines starting with "data: " containing JSON
|
||||
// Matches kilocode mcp-exa.ts parseSse implementation
|
||||
for (const line of body.split('\n')) {
|
||||
if (!line.startsWith('data: ')) continue
|
||||
const data = line.substring(6).trim()
|
||||
if (!data || data === '[DONE]' || data === 'null') continue
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
const content = parsed?.result?.content
|
||||
if (Array.isArray(content) && content[0]?.text) {
|
||||
return content[0].text
|
||||
}
|
||||
} catch {
|
||||
// Continue to next line
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try parsing as direct JSON response (non-SSE)
|
||||
try {
|
||||
const parsed = JSON.parse(body)
|
||||
const content = parsed?.result?.content
|
||||
if (Array.isArray(content) && content[0]?.text) {
|
||||
return content[0].text
|
||||
}
|
||||
} catch {
|
||||
// Not JSON
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
private parseResults(text: string | undefined): SearchResult[] {
|
||||
if (!text) return []
|
||||
|
||||
const results: SearchResult[] = []
|
||||
|
||||
// Exa returns structured text with "Title:", "URL:", and "Content:" fields
|
||||
// separated by "---" between entries
|
||||
const blocks = text.split(/\n---\n/g)
|
||||
|
||||
for (const block of blocks) {
|
||||
const titleMatch = block.match(/^Title:\s*(.+)$/m)
|
||||
const urlMatch = block.match(/^URL:\s*(https?:\/\/[^\s]+)$/m)
|
||||
const contentMatch = block.match(/^Content:\s*([\s\S]+?)(?=\n(?:Title:|URL:|---)|$)/m)
|
||||
|
||||
if (urlMatch) {
|
||||
results.push({
|
||||
title: titleMatch?.[1]?.trim() ?? urlMatch[1],
|
||||
url: urlMatch[1].trim(),
|
||||
snippet: contentMatch?.[1]?.trim().slice(0, 300),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: markdown links
|
||||
if (results.length === 0) {
|
||||
const markdownLinkRegex = /\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = markdownLinkRegex.exec(text)) !== null) {
|
||||
results.push({
|
||||
title: match[1].trim(),
|
||||
url: match[2].trim(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: plain URLs
|
||||
if (results.length === 0) {
|
||||
const urlRegex = /^https?:\/\/[^\s<>"\]]+/gm
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = urlRegex.exec(text)) !== null) {
|
||||
results.push({
|
||||
title: match[0],
|
||||
url: match[0],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { isFirstPartyAnthropicBaseUrl } from 'src/utils/model/providers.js'
|
||||
import { ApiSearchAdapter } from './apiAdapter.js'
|
||||
import { BingSearchAdapter } from './bingAdapter.js'
|
||||
import { BraveSearchAdapter } from './braveAdapter.js'
|
||||
import { ExaSearchAdapter } from './exaAdapter.js'
|
||||
import type { WebSearchAdapter } from './types.js'
|
||||
|
||||
export type {
|
||||
@@ -16,17 +17,37 @@ export type {
|
||||
WebSearchAdapter,
|
||||
} from './types.js'
|
||||
|
||||
/**
|
||||
* Check if the current session uses a third-party (non-Anthropic) API provider.
|
||||
* These providers don't support Anthropic's server_tools (server-side web search),
|
||||
* so they must fall back to the Bing scraper adapter.
|
||||
*/
|
||||
function isThirdPartyProvider(): boolean {
|
||||
return !!(
|
||||
process.env.CLAUDE_CODE_USE_OPENAI ||
|
||||
process.env.CLAUDE_CODE_USE_GEMINI ||
|
||||
process.env.CLAUDE_CODE_USE_GROK
|
||||
)
|
||||
}
|
||||
|
||||
let cachedAdapter: WebSearchAdapter | null = null
|
||||
let cachedAdapterKey: 'api' | 'bing' | 'brave' | null = null
|
||||
let cachedAdapterKey: 'api' | 'bing' | 'brave' | 'exa' | null = null
|
||||
|
||||
export function createAdapter(): WebSearchAdapter {
|
||||
const envAdapter = process.env.WEB_SEARCH_ADAPTER
|
||||
// Priority:
|
||||
// 1. Explicit env override (WEB_SEARCH_ADAPTER=api|bing|brave)
|
||||
// 2. Third-party provider (OpenAI/Gemini/Grok) → bing (no server_tools support)
|
||||
// 3. First-party Anthropic API → api (server-side web search + connector_text)
|
||||
// 4. Fallback → bing
|
||||
const adapterKey =
|
||||
envAdapter === 'api' || envAdapter === 'bing' || envAdapter === 'brave'
|
||||
envAdapter === 'api' || envAdapter === 'bing' || envAdapter === 'brave' || envAdapter === 'exa'
|
||||
? envAdapter
|
||||
: isFirstPartyAnthropicBaseUrl()
|
||||
? 'api'
|
||||
: 'bing'
|
||||
: isThirdPartyProvider()
|
||||
? 'bing'
|
||||
: isFirstPartyAnthropicBaseUrl()
|
||||
? 'api'
|
||||
: 'exa'
|
||||
|
||||
if (cachedAdapter && cachedAdapterKey === adapterKey) return cachedAdapter
|
||||
|
||||
@@ -36,9 +57,14 @@ export function createAdapter(): WebSearchAdapter {
|
||||
return cachedAdapter
|
||||
}
|
||||
if (adapterKey === 'brave') {
|
||||
cachedAdapter = new BraveSearchAdapter()
|
||||
cachedAdapterKey = 'brave'
|
||||
return cachedAdapter
|
||||
cachedAdapter = new BraveSearchAdapter()
|
||||
cachedAdapterKey = 'brave'
|
||||
return cachedAdapter
|
||||
}
|
||||
if (adapterKey === 'exa') {
|
||||
cachedAdapter = new ExaSearchAdapter()
|
||||
cachedAdapterKey = 'exa'
|
||||
return cachedAdapter
|
||||
}
|
||||
|
||||
cachedAdapter = new BingSearchAdapter()
|
||||
|
||||
@@ -9,6 +9,14 @@ export interface SearchOptions {
|
||||
blockedDomains?: string[]
|
||||
signal?: AbortSignal
|
||||
onProgress?: (progress: SearchProgress) => void
|
||||
/** Number of search results to return (default: 8) */
|
||||
numResults?: number
|
||||
/** Live crawl mode (default: 'fallback') */
|
||||
livecrawl?: 'fallback' | 'preferred'
|
||||
/** Search type (default: 'auto') */
|
||||
searchType?: 'auto' | 'fast' | 'deep'
|
||||
/** Maximum characters for context string (default: 10000) */
|
||||
contextMaxCharacters?: number
|
||||
}
|
||||
|
||||
export interface SearchProgress {
|
||||
|
||||
@@ -1,18 +1,358 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { mkdir, readdir, readFile, writeFile } from 'fs/promises'
|
||||
import { join, parse } from 'path'
|
||||
import { z } from 'zod/v4'
|
||||
import type { ToolResultBlockParam } from 'src/Tool.js'
|
||||
import { buildTool } from 'src/Tool.js'
|
||||
import { truncate } from 'src/utils/format.js'
|
||||
import { WORKFLOW_TOOL_NAME } from './constants.js'
|
||||
import { safeParseJSON } from 'src/utils/json.js'
|
||||
import {
|
||||
WORKFLOW_DIR_NAME,
|
||||
WORKFLOW_FILE_EXTENSIONS,
|
||||
WORKFLOW_TOOL_NAME,
|
||||
} from './constants.js'
|
||||
|
||||
const WORKFLOW_RUNS_DIR = '.claude/workflow-runs'
|
||||
|
||||
const inputSchema = z.object({
|
||||
workflow: z.string().describe('Name of the workflow to execute'),
|
||||
args: z.string().optional().describe('Arguments to pass to the workflow'),
|
||||
action: z
|
||||
.enum(['start', 'status', 'advance', 'cancel', 'list'])
|
||||
.optional()
|
||||
.describe('Workflow action. Defaults to start.'),
|
||||
run_id: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Workflow run id for status, advance, or cancel.'),
|
||||
})
|
||||
type Input = typeof inputSchema
|
||||
type WorkflowInput = z.infer<Input>
|
||||
|
||||
type WorkflowStepStatus = 'pending' | 'running' | 'completed' | 'cancelled'
|
||||
|
||||
type WorkflowStep = {
|
||||
name: string
|
||||
prompt: string
|
||||
status: WorkflowStepStatus
|
||||
startedAt?: number
|
||||
completedAt?: number
|
||||
}
|
||||
|
||||
type WorkflowRun = {
|
||||
runId: string
|
||||
workflow: string
|
||||
args?: string
|
||||
status: 'running' | 'completed' | 'cancelled'
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
currentStepIndex: number
|
||||
steps: WorkflowStep[]
|
||||
}
|
||||
|
||||
type WorkflowOutput = { output: string }
|
||||
|
||||
async function findWorkflowFile(
|
||||
workflowDir: string,
|
||||
workflow: string,
|
||||
): Promise<{ path: string; content: string } | null> {
|
||||
for (const ext of WORKFLOW_FILE_EXTENSIONS) {
|
||||
const path = join(workflowDir, `${workflow}${ext}`)
|
||||
try {
|
||||
return { path, content: await readFile(path, 'utf-8') }
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function listAvailableWorkflows(workflowDir: string): Promise<string[]> {
|
||||
try {
|
||||
const files = await readdir(workflowDir)
|
||||
return files
|
||||
.filter(f => WORKFLOW_FILE_EXTENSIONS.includes(parse(f).ext.toLowerCase()))
|
||||
.map(f => parse(f).name)
|
||||
.sort()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function workflowRunPath(cwd: string, runId: string): string {
|
||||
return join(cwd, WORKFLOW_RUNS_DIR, `${runId}.json`)
|
||||
}
|
||||
|
||||
async function readWorkflowRun(
|
||||
cwd: string,
|
||||
runId: string,
|
||||
): Promise<WorkflowRun | null> {
|
||||
try {
|
||||
const parsed = safeParseJSON(
|
||||
await readFile(workflowRunPath(cwd, runId), 'utf-8'),
|
||||
false,
|
||||
) as Partial<WorkflowRun> | null
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed.runId !== 'string' ||
|
||||
typeof parsed.workflow !== 'string' ||
|
||||
!Array.isArray(parsed.steps)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return parsed as WorkflowRun
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function writeWorkflowRun(cwd: string, run: WorkflowRun): Promise<void> {
|
||||
await mkdir(join(cwd, WORKFLOW_RUNS_DIR), { recursive: true })
|
||||
await writeFile(
|
||||
workflowRunPath(cwd, run.runId),
|
||||
JSON.stringify(run, null, 2) + '\n',
|
||||
'utf-8',
|
||||
)
|
||||
}
|
||||
|
||||
async function listWorkflowRuns(cwd: string): Promise<WorkflowRun[]> {
|
||||
let files: string[]
|
||||
try {
|
||||
files = await readdir(join(cwd, WORKFLOW_RUNS_DIR))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
const runs = await Promise.all(
|
||||
files
|
||||
.filter(f => f.endsWith('.json'))
|
||||
.map(f => readWorkflowRun(cwd, f.slice(0, -'.json'.length))),
|
||||
)
|
||||
return runs
|
||||
.filter((run): run is WorkflowRun => run !== null)
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
}
|
||||
|
||||
function parseMarkdownSteps(content: string): WorkflowStep[] {
|
||||
const steps: WorkflowStep[] = []
|
||||
for (const rawLine of content.split('\n')) {
|
||||
const line = rawLine.trim()
|
||||
const taskMatch = line.match(/^[-*]\s+\[[ xX]\]\s+(.+)$/)
|
||||
const bulletMatch = line.match(/^[-*]\s+(.+)$/)
|
||||
const numberedMatch = line.match(/^\d+[.)]\s+(.+)$/)
|
||||
const text = taskMatch?.[1] ?? bulletMatch?.[1] ?? numberedMatch?.[1]
|
||||
if (!text) continue
|
||||
steps.push({ name: text.slice(0, 80), prompt: text, status: 'pending' })
|
||||
}
|
||||
return steps
|
||||
}
|
||||
|
||||
function parseYamlSteps(content: string): WorkflowStep[] {
|
||||
const steps: WorkflowStep[] = []
|
||||
let current: Partial<WorkflowStep> | null = null
|
||||
const flush = () => {
|
||||
if (!current) return
|
||||
const prompt = current.prompt ?? current.name
|
||||
if (current.name && prompt) {
|
||||
steps.push({
|
||||
name: current.name,
|
||||
prompt,
|
||||
status: 'pending',
|
||||
})
|
||||
}
|
||||
current = null
|
||||
}
|
||||
|
||||
for (const rawLine of content.split('\n')) {
|
||||
const line = rawLine.trim()
|
||||
const stepText = line.match(/^-\s+(.+)$/)?.[1]
|
||||
if (stepText) {
|
||||
flush()
|
||||
const inlineName = stepText.match(/^name:\s*(.+)$/)?.[1]
|
||||
current = {
|
||||
name: inlineName ?? stepText,
|
||||
prompt: inlineName ? undefined : stepText,
|
||||
}
|
||||
continue
|
||||
}
|
||||
const name = line.match(/^name:\s*(.+)$/)?.[1]
|
||||
if (name) {
|
||||
if (!current) current = {}
|
||||
current.name = name
|
||||
continue
|
||||
}
|
||||
const prompt = line.match(/^(prompt|run|command):\s*(.+)$/)?.[2]
|
||||
if (prompt) {
|
||||
if (!current) current = {}
|
||||
current.prompt = prompt
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return steps
|
||||
}
|
||||
|
||||
function parseWorkflowSteps(filePath: string, content: string): WorkflowStep[] {
|
||||
const ext = parse(filePath).ext.toLowerCase()
|
||||
const steps =
|
||||
ext === '.md' ? parseMarkdownSteps(content) : parseYamlSteps(content)
|
||||
if (steps.length > 0) {
|
||||
return steps
|
||||
}
|
||||
return [
|
||||
{
|
||||
name: 'Execute workflow',
|
||||
prompt: content.trim(),
|
||||
status: 'pending',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function formatStep(step: WorkflowStep, index: number): string {
|
||||
return `Step ${index + 1}: ${step.name}\n${step.prompt}`
|
||||
}
|
||||
|
||||
function formatRunStatus(run: WorkflowRun): string {
|
||||
const lines = [
|
||||
`Workflow run: ${run.runId}`,
|
||||
`Workflow: ${run.workflow}`,
|
||||
`Status: ${run.status}`,
|
||||
`Current step: ${run.steps[run.currentStepIndex]?.name ?? 'none'}`,
|
||||
`Steps: ${run.steps.length}`,
|
||||
]
|
||||
for (let i = 0; i < run.steps.length; i += 1) {
|
||||
const step = run.steps[i]!
|
||||
lines.push(` ${i + 1}. [${step.status}] ${step.name}`)
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
async function startWorkflow(
|
||||
input: WorkflowInput,
|
||||
cwd: string,
|
||||
): Promise<WorkflowOutput> {
|
||||
const workflowDir = join(cwd, WORKFLOW_DIR_NAME)
|
||||
const found = await findWorkflowFile(workflowDir, input.workflow)
|
||||
if (!found) {
|
||||
const available = await listAvailableWorkflows(workflowDir)
|
||||
const hint =
|
||||
available.length > 0
|
||||
? `\nAvailable workflows: ${available.join(', ')}`
|
||||
: `\nNo workflows found in ${WORKFLOW_DIR_NAME}/. Create .md or .yaml files there.`
|
||||
return { output: `Error: Workflow "${input.workflow}" not found.${hint}` }
|
||||
}
|
||||
|
||||
const steps = parseWorkflowSteps(found.path, found.content)
|
||||
const now = Date.now()
|
||||
steps[0] = { ...steps[0]!, status: 'running', startedAt: now }
|
||||
const run: WorkflowRun = {
|
||||
runId: randomUUID(),
|
||||
workflow: input.workflow,
|
||||
...(input.args ? { args: input.args } : {}),
|
||||
status: 'running',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
currentStepIndex: 0,
|
||||
steps,
|
||||
}
|
||||
await writeWorkflowRun(cwd, run)
|
||||
|
||||
const argsSection = input.args ? `\n\nArguments:\n${input.args}` : ''
|
||||
return {
|
||||
output: [
|
||||
`Workflow run started`,
|
||||
`run_id: ${run.runId}`,
|
||||
`workflow: ${run.workflow}`,
|
||||
'',
|
||||
formatStep(steps[0]!, 0),
|
||||
argsSection,
|
||||
'',
|
||||
`When this step is complete, call Workflow with action="advance" and run_id="${run.runId}".`,
|
||||
].join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
async function getRunOrError(
|
||||
cwd: string,
|
||||
runId: string | undefined,
|
||||
): Promise<{ run?: WorkflowRun; output?: string }> {
|
||||
if (!runId) return { output: 'Error: run_id is required for this action.' }
|
||||
const run = await readWorkflowRun(cwd, runId)
|
||||
if (!run) return { output: `Error: Workflow run "${runId}" not found.` }
|
||||
return { run }
|
||||
}
|
||||
|
||||
async function advanceWorkflow(
|
||||
cwd: string,
|
||||
runId: string | undefined,
|
||||
): Promise<WorkflowOutput> {
|
||||
const found = await getRunOrError(cwd, runId)
|
||||
if (!found.run) return { output: found.output! }
|
||||
const run = found.run
|
||||
const now = Date.now()
|
||||
const current = run.steps[run.currentStepIndex]
|
||||
if (current && current.status === 'running') {
|
||||
current.status = 'completed'
|
||||
current.completedAt = now
|
||||
}
|
||||
const nextIndex = run.currentStepIndex + 1
|
||||
if (nextIndex >= run.steps.length) {
|
||||
run.status = 'completed'
|
||||
run.updatedAt = now
|
||||
await writeWorkflowRun(cwd, run)
|
||||
return { output: `Workflow completed\nrun_id: ${run.runId}` }
|
||||
}
|
||||
run.currentStepIndex = nextIndex
|
||||
run.steps[nextIndex] = {
|
||||
...run.steps[nextIndex]!,
|
||||
status: 'running',
|
||||
startedAt: now,
|
||||
}
|
||||
run.updatedAt = now
|
||||
await writeWorkflowRun(cwd, run)
|
||||
return {
|
||||
output: [
|
||||
`Next workflow step`,
|
||||
`run_id: ${run.runId}`,
|
||||
'',
|
||||
formatStep(run.steps[nextIndex]!, nextIndex),
|
||||
'',
|
||||
`When this step is complete, call Workflow with action="advance" and run_id="${run.runId}".`,
|
||||
].join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelWorkflow(
|
||||
cwd: string,
|
||||
runId: string | undefined,
|
||||
): Promise<WorkflowOutput> {
|
||||
const found = await getRunOrError(cwd, runId)
|
||||
if (!found.run) return { output: found.output! }
|
||||
const run = found.run
|
||||
const now = Date.now()
|
||||
run.status = 'cancelled'
|
||||
run.updatedAt = now
|
||||
for (const step of run.steps) {
|
||||
if (step.status === 'pending' || step.status === 'running') {
|
||||
step.status = 'cancelled'
|
||||
}
|
||||
}
|
||||
await writeWorkflowRun(cwd, run)
|
||||
return { output: `Workflow cancelled\nrun_id: ${run.runId}` }
|
||||
}
|
||||
|
||||
async function listWorkflowRunsForOutput(cwd: string): Promise<WorkflowOutput> {
|
||||
const runs = await listWorkflowRuns(cwd)
|
||||
if (runs.length === 0) return { output: 'No workflow runs recorded.' }
|
||||
return {
|
||||
output: runs
|
||||
.slice(0, 20)
|
||||
.map(
|
||||
run =>
|
||||
`${run.runId} | ${run.workflow} | ${run.status} | step=${run.steps[run.currentStepIndex]?.name ?? 'none'} | updated=${new Date(run.updatedAt).toLocaleString()}`,
|
||||
)
|
||||
.join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
export const WorkflowTool = buildTool({
|
||||
name: WORKFLOW_TOOL_NAME,
|
||||
searchHint: 'execute user-defined workflow scripts',
|
||||
@@ -22,21 +362,25 @@ export const WorkflowTool = buildTool({
|
||||
inputSchema,
|
||||
|
||||
async description() {
|
||||
return 'Execute a user-defined workflow script from .claude/workflows/'
|
||||
return 'Execute and track a user-defined workflow from .claude/workflows/'
|
||||
},
|
||||
async prompt() {
|
||||
return `Use the Workflow tool to execute user-defined workflow scripts located in .claude/workflows/. Workflows are YAML or Markdown files that define a sequence of steps for common development tasks.
|
||||
return `Use the Workflow tool to run user-defined workflows located in .claude/workflows/. Workflows may be Markdown checklists/lists or YAML files with steps.
|
||||
|
||||
Guidelines:
|
||||
- Specify the workflow name to execute (must match a file in .claude/workflows/)
|
||||
- Optionally pass arguments that the workflow can use
|
||||
- Workflows run in the context of the current project`
|
||||
Actions:
|
||||
- start (default): create a persisted workflow run and return the first step to execute
|
||||
- advance: mark the current step complete and return the next step
|
||||
- status: inspect a workflow run by run_id
|
||||
- cancel: cancel a workflow run
|
||||
- list: list recent workflow runs
|
||||
|
||||
Workflow run state is persisted in .claude/workflow-runs/.`
|
||||
},
|
||||
userFacingName() {
|
||||
return 'Workflow'
|
||||
},
|
||||
isReadOnly() {
|
||||
return false
|
||||
isReadOnly(input) {
|
||||
return input.action === 'status' || input.action === 'list'
|
||||
},
|
||||
isEnabled() {
|
||||
return true
|
||||
@@ -44,10 +388,10 @@ Guidelines:
|
||||
|
||||
renderToolUseMessage(input: Partial<WorkflowInput>) {
|
||||
const name = input.workflow ?? 'unknown'
|
||||
if (input.args) {
|
||||
return `Workflow: ${name} ${input.args}`
|
||||
}
|
||||
return `Workflow: ${name}`
|
||||
const action = input.action ?? 'start'
|
||||
return input.args
|
||||
? `Workflow: ${action} ${name} ${input.args}`
|
||||
: `Workflow: ${action} ${name}`
|
||||
},
|
||||
|
||||
mapToolResultToToolResultBlockParam(
|
||||
@@ -61,14 +405,26 @@ Guidelines:
|
||||
}
|
||||
},
|
||||
|
||||
async call(_input: WorkflowInput, _context, _progress) {
|
||||
// Workflow execution is wired by the WORKFLOW_SCRIPTS feature bootstrap.
|
||||
// Without it, this tool is not functional.
|
||||
return {
|
||||
data: {
|
||||
output:
|
||||
'Error: Workflow execution requires the WORKFLOW_SCRIPTS runtime.',
|
||||
},
|
||||
async call(input: WorkflowInput) {
|
||||
const cwd = process.cwd()
|
||||
const action = input.action ?? 'start'
|
||||
switch (action) {
|
||||
case 'start':
|
||||
return { data: await startWorkflow(input, cwd) }
|
||||
case 'status': {
|
||||
const found = await getRunOrError(cwd, input.run_id)
|
||||
return {
|
||||
data: {
|
||||
output: found.run ? formatRunStatus(found.run) : found.output!,
|
||||
},
|
||||
}
|
||||
}
|
||||
case 'advance':
|
||||
return { data: await advanceWorkflow(cwd, input.run_id) }
|
||||
case 'cancel':
|
||||
return { data: await cancelWorkflow(cwd, input.run_id) }
|
||||
case 'list':
|
||||
return { data: await listWorkflowRunsForOutput(cwd) }
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { WorkflowTool } from '../WorkflowTool'
|
||||
|
||||
let cwd: string
|
||||
let previousCwd: string
|
||||
|
||||
beforeEach(async () => {
|
||||
previousCwd = process.cwd()
|
||||
cwd = join(tmpdir(), `workflow-tool-${Date.now()}-${Math.random().toString(16).slice(2)}`)
|
||||
await mkdir(join(cwd, '.claude', 'workflows'), { recursive: true })
|
||||
process.chdir(cwd)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
process.chdir(previousCwd)
|
||||
await rm(cwd, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('WorkflowTool', () => {
|
||||
test('starts a workflow run and persists step state', async () => {
|
||||
await writeFile(
|
||||
join(cwd, '.claude', 'workflows', 'release.md'),
|
||||
[
|
||||
'# Release',
|
||||
'',
|
||||
'- [ ] Run tests',
|
||||
'- [ ] Build package',
|
||||
].join('\n'),
|
||||
)
|
||||
|
||||
const result = await WorkflowTool.call({ workflow: 'release' })
|
||||
|
||||
expect(result.data.output).toContain('Workflow run started')
|
||||
expect(result.data.output).toContain('Run tests')
|
||||
const match = result.data.output.match(/run_id: ([a-f0-9-]+)/)
|
||||
expect(match?.[1]).toBeString()
|
||||
|
||||
const raw = await readFile(
|
||||
join(cwd, '.claude', 'workflow-runs', `${match![1]}.json`),
|
||||
'utf-8',
|
||||
)
|
||||
const run = JSON.parse(raw)
|
||||
expect(run.workflow).toBe('release')
|
||||
expect(run.status).toBe('running')
|
||||
expect(run.steps).toHaveLength(2)
|
||||
expect(run.steps[0].status).toBe('running')
|
||||
expect(run.steps[1].status).toBe('pending')
|
||||
})
|
||||
|
||||
test('advances a workflow run through completion', async () => {
|
||||
await writeFile(
|
||||
join(cwd, '.claude', 'workflows', 'audit.yaml'),
|
||||
[
|
||||
'steps:',
|
||||
' - name: Inspect',
|
||||
' prompt: Inspect the code',
|
||||
' - name: Verify',
|
||||
' prompt: Run focused tests',
|
||||
].join('\n'),
|
||||
)
|
||||
|
||||
const started = await WorkflowTool.call({ workflow: 'audit' })
|
||||
const runId = started.data.output.match(/run_id: ([a-f0-9-]+)/)![1]!
|
||||
|
||||
const next = await WorkflowTool.call(
|
||||
{ workflow: 'audit', action: 'advance', run_id: runId },
|
||||
)
|
||||
expect(next.data.output).toContain('Next workflow step')
|
||||
expect(next.data.output).toContain('Run focused tests')
|
||||
|
||||
const done = await WorkflowTool.call(
|
||||
{ workflow: 'audit', action: 'advance', run_id: runId },
|
||||
)
|
||||
expect(done.data.output).toContain('Workflow completed')
|
||||
})
|
||||
|
||||
test('lists and cancels workflow runs', async () => {
|
||||
await writeFile(
|
||||
join(cwd, '.claude', 'workflows', 'cleanup.md'),
|
||||
'- Remove stale files',
|
||||
)
|
||||
|
||||
const started = await WorkflowTool.call({ workflow: 'cleanup' })
|
||||
const runId = started.data.output.match(/run_id: ([a-f0-9-]+)/)![1]!
|
||||
|
||||
const listed = await WorkflowTool.call(
|
||||
{ workflow: 'cleanup', action: 'list' },
|
||||
)
|
||||
expect(listed.data.output).toContain(runId)
|
||||
|
||||
const cancelled = await WorkflowTool.call(
|
||||
{ workflow: 'cleanup', action: 'cancel', run_id: runId },
|
||||
)
|
||||
expect(cancelled.data.output).toContain('Workflow cancelled')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { spawnTeammate } from '../spawnMultiAgent'
|
||||
|
||||
let tempHome: string
|
||||
let previousConfigDir: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
previousConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
tempHome = join(tmpdir(), `spawn-multi-agent-${Date.now()}-${Math.random().toString(16).slice(2)}`)
|
||||
process.env.CLAUDE_CONFIG_DIR = tempHome
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (previousConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
|
||||
}
|
||||
rmSync(tempHome, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('spawnTeammate', () => {
|
||||
test('fails before spawn side effects when the team file is missing', async () => {
|
||||
let setAppStateCalled = false
|
||||
const context = {
|
||||
getAppState: () => ({
|
||||
teamContext: undefined,
|
||||
}),
|
||||
setAppState: () => {
|
||||
setAppStateCalled = true
|
||||
},
|
||||
options: {
|
||||
agentDefinitions: {
|
||||
activeAgents: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
await expect(
|
||||
spawnTeammate(
|
||||
{
|
||||
name: 'worker',
|
||||
prompt: 'do work',
|
||||
team_name: 'missing-team',
|
||||
},
|
||||
context as any,
|
||||
),
|
||||
).rejects.toThrow('Team "missing-team" does not exist')
|
||||
expect(setAppStateCalled).toBe(false)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,32 +17,21 @@
|
||||
* getSyntaxTheme always returns the default for the given Claude theme.
|
||||
*/
|
||||
|
||||
import { createRequire } from 'node:module'
|
||||
import { diffArrays } from 'diff'
|
||||
import type * as hljsNamespace from 'highlight.js'
|
||||
import hljs from 'highlight.js'
|
||||
import { basename, extname } from 'path'
|
||||
|
||||
// createRequire works in both Bun and Node.js ESM contexts.
|
||||
// Needed because this package is "type": "module" but uses require() for
|
||||
// lazy loading — bare require is not available in Node.js ESM.
|
||||
const nodeRequire = createRequire(import.meta.url)
|
||||
|
||||
// Lazy: defers loading highlight.js until first render. The full bundle
|
||||
// registers 190+ language grammars at require time (~50MB, 100-200ms on
|
||||
// macOS, several× that on Windows). With a top-level import, any caller
|
||||
// chunk that reaches this module — including test/preload.ts via
|
||||
// StructuredDiff.tsx → colorDiff.ts — pays that cost at module-eval time
|
||||
// and carries the heap for the rest of the process. On Windows CI this
|
||||
// pushed later tests in the same shard into GC-pause territory and a
|
||||
// beforeEach/afterEach hook timeout (officialRegistry.test.ts, PR #24150).
|
||||
// Same lazy pattern the NAPI wrapper used for dlopen.
|
||||
type HLJSApi = typeof hljsNamespace.default
|
||||
// Static import — createRequire(import.meta.url) fails in Bun --compile mode
|
||||
// because the resolved path points to the internal bunfs binary path where
|
||||
// node_modules cannot be found. A top-level import ensures the module is
|
||||
// bundled and accessible at runtime.
|
||||
type HLJSApi = typeof hljs
|
||||
let cachedHljs: HLJSApi | null = null
|
||||
function hljs(): HLJSApi {
|
||||
function hljsApi(): HLJSApi {
|
||||
if (cachedHljs) return cachedHljs
|
||||
const mod = nodeRequire('highlight.js')
|
||||
// highlight.js uses `export =` (CJS). Under bun/ESM the interop wraps it
|
||||
// in .default; under node CJS the module IS the API. Check at runtime.
|
||||
const mod = hljs as HLJSApi & { default?: HLJSApi }
|
||||
cachedHljs = 'default' in mod && mod.default ? mod.default : mod
|
||||
return cachedHljs!
|
||||
}
|
||||
@@ -441,9 +430,9 @@ function detectLanguage(
|
||||
// Filename-based lookup (handles Dockerfile, Makefile, CMakeLists.txt, etc.)
|
||||
const stem = base.split('.')[0] ?? ''
|
||||
const byName = FILENAME_LANGS[base] ?? FILENAME_LANGS[stem]
|
||||
if (byName && hljs().getLanguage(byName)) return byName
|
||||
if (byName && hljsApi().getLanguage(byName)) return byName
|
||||
if (ext) {
|
||||
const lang = hljs().getLanguage(ext)
|
||||
const lang = hljsApi().getLanguage(ext)
|
||||
if (lang) return ext
|
||||
}
|
||||
// Shebang / first-line detection (strip UTF-8 BOM)
|
||||
@@ -525,7 +514,7 @@ function highlightLine(
|
||||
}
|
||||
let result
|
||||
try {
|
||||
result = hljs().highlight(code, {
|
||||
result = hljsApi().highlight(code, {
|
||||
language: state.lang,
|
||||
ignoreIllegals: true,
|
||||
})
|
||||
|
||||
112
packages/modifiers-napi/src/__tests__/index.test.ts
Normal file
112
packages/modifiers-napi/src/__tests__/index.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
|
||||
let ffiShouldThrow = false
|
||||
let nativeFlags = 0
|
||||
let dlopenCalls = 0
|
||||
|
||||
mock.module('bun:ffi', () => ({
|
||||
FFIType: {
|
||||
i32: 0,
|
||||
u64: 0,
|
||||
},
|
||||
dlopen: () => {
|
||||
dlopenCalls++
|
||||
if (ffiShouldThrow) {
|
||||
throw new Error('ffi load failed')
|
||||
}
|
||||
return {
|
||||
symbols: {
|
||||
CGEventSourceFlagsState: () => nativeFlags,
|
||||
},
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
const originalPlatform = process.platform
|
||||
|
||||
async function loadModule() {
|
||||
return import(`../index.ts?case=${Math.random()}`)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
ffiShouldThrow = false
|
||||
nativeFlags = 0
|
||||
dlopenCalls = 0
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
describe('modifiers-napi', () => {
|
||||
test('returns false for non-darwin platforms', async () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'win32',
|
||||
configurable: true,
|
||||
})
|
||||
const mod = await loadModule()
|
||||
|
||||
await mod.prewarm()
|
||||
expect(dlopenCalls).toBe(0)
|
||||
expect(mod.isModifierPressed('shift')).toBe(false)
|
||||
expect(mod.isModifierPressed('command')).toBe(false)
|
||||
})
|
||||
|
||||
test('prewarm is idempotent on darwin', async () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'darwin',
|
||||
configurable: true,
|
||||
})
|
||||
const mod = await loadModule()
|
||||
|
||||
await mod.prewarm()
|
||||
await mod.prewarm()
|
||||
|
||||
expect(dlopenCalls).toBe(1)
|
||||
})
|
||||
|
||||
test('returns false when ffi loading fails on darwin', async () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'darwin',
|
||||
configurable: true,
|
||||
})
|
||||
ffiShouldThrow = true
|
||||
const mod = await loadModule()
|
||||
|
||||
await mod.prewarm()
|
||||
expect(mod.isModifierPressed('shift')).toBe(false)
|
||||
})
|
||||
|
||||
test('returns false for unknown modifier names on darwin', async () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'darwin',
|
||||
configurable: true,
|
||||
})
|
||||
nativeFlags = 0x20000
|
||||
const mod = await loadModule()
|
||||
|
||||
await mod.prewarm()
|
||||
expect(mod.isModifierPressed('unknown')).toBe(false)
|
||||
})
|
||||
|
||||
test('uses native flag bits for known modifiers on darwin', async () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'darwin',
|
||||
configurable: true,
|
||||
})
|
||||
nativeFlags = 0x20000 | 0x40000
|
||||
const mod = await loadModule()
|
||||
|
||||
await mod.prewarm()
|
||||
expect(mod.isModifierPressed('shift')).toBe(true)
|
||||
expect(mod.isModifierPressed('control')).toBe(true)
|
||||
expect(mod.isModifierPressed('option')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -14,14 +14,16 @@ const modifierFlags: Record<string, number> = {
|
||||
const kCGEventSourceStateCombinedSessionState = 0;
|
||||
|
||||
let cgEventSourceFlagsState: ((stateID: number) => number) | null = null;
|
||||
let ffiLoadAttempted = false;
|
||||
|
||||
function loadFFI(): void {
|
||||
if (cgEventSourceFlagsState !== null || process.platform !== "darwin") {
|
||||
async function loadFFI(): Promise<void> {
|
||||
if (ffiLoadAttempted || process.platform !== "darwin") {
|
||||
return;
|
||||
}
|
||||
ffiLoadAttempted = true;
|
||||
|
||||
try {
|
||||
const ffi = require("bun:ffi") as typeof import("bun:ffi");
|
||||
const ffi = await import("bun:ffi");
|
||||
const lib = ffi.dlopen(
|
||||
`/System/Library/Frameworks/Carbon.framework/Carbon`,
|
||||
{
|
||||
@@ -35,13 +37,12 @@ function loadFFI(): void {
|
||||
return Number(lib.symbols.CGEventSourceFlagsState(stateID));
|
||||
};
|
||||
} catch {
|
||||
// If loading fails, keep the function null so isModifierPressed returns false
|
||||
cgEventSourceFlagsState = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function prewarm(): void {
|
||||
loadFFI();
|
||||
export async function prewarm(): Promise<void> {
|
||||
await loadFFI();
|
||||
}
|
||||
|
||||
export function isModifierPressed(modifier: string): boolean {
|
||||
@@ -49,8 +50,6 @@ export function isModifierPressed(modifier: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
loadFFI();
|
||||
|
||||
if (cgEventSourceFlagsState === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -98,22 +98,6 @@ export function storeDeleteToken(token: string): boolean {
|
||||
|
||||
// ---------- Environment ----------
|
||||
|
||||
/** Find an active or offline environment by machineName (optionally filtered by workerType).
|
||||
* Includes "offline" so ACP agents can be reused on reconnect. */
|
||||
export function storeFindEnvironmentByMachineName(
|
||||
machineName: string,
|
||||
workerType?: string,
|
||||
): EnvironmentRecord | undefined {
|
||||
for (const rec of environments.values()) {
|
||||
if (rec.machineName === machineName && (rec.status === "active" || rec.status === "offline")) {
|
||||
if (!workerType || rec.workerType === workerType) {
|
||||
return rec;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function storeCreateEnvironment(req: {
|
||||
secret: string;
|
||||
machineName?: string;
|
||||
@@ -126,23 +110,6 @@ export function storeCreateEnvironment(req: {
|
||||
username?: string;
|
||||
capabilities?: Record<string, unknown>;
|
||||
}): EnvironmentRecord {
|
||||
// ACP: reuse existing active record by machineName
|
||||
if (req.workerType === "acp" && req.machineName) {
|
||||
const existing = storeFindEnvironmentByMachineName(req.machineName, "acp");
|
||||
if (existing) {
|
||||
Object.assign(existing, {
|
||||
status: "active",
|
||||
lastPollAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
maxSessions: req.maxSessions ?? existing.maxSessions,
|
||||
bridgeId: req.bridgeId ?? existing.bridgeId,
|
||||
capabilities: req.capabilities ?? existing.capabilities,
|
||||
username: req.username ?? existing.username,
|
||||
});
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
const id = `env_${uuid().replace(/-/g, "")}`;
|
||||
const now = new Date();
|
||||
const record: EnvironmentRecord = {
|
||||
|
||||
50
packages/url-handler-napi/src/__tests__/index.test.ts
Normal file
50
packages/url-handler-napi/src/__tests__/index.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import { waitForUrlEvent } from '../index'
|
||||
|
||||
const originalEnv = {
|
||||
CLAUDE_CODE_URL_EVENT: process.env.CLAUDE_CODE_URL_EVENT,
|
||||
CLAUDE_CODE_DEEP_LINK_URL: process.env.CLAUDE_CODE_DEEP_LINK_URL,
|
||||
CLAUDE_CODE_URL: process.env.CLAUDE_CODE_URL,
|
||||
}
|
||||
const originalArgv = process.argv.slice()
|
||||
|
||||
afterEach(() => {
|
||||
for (const [key, value] of Object.entries(originalEnv)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
process.argv = originalArgv.slice()
|
||||
})
|
||||
|
||||
describe('waitForUrlEvent', () => {
|
||||
test('resolves to null without a timeout', async () => {
|
||||
await expect(waitForUrlEvent()).resolves.toBeNull()
|
||||
})
|
||||
|
||||
test('resolves to null with an explicit timeout', async () => {
|
||||
await expect(waitForUrlEvent(1)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
test('returns a Claude URL from environment variables', async () => {
|
||||
process.env.CLAUDE_CODE_URL_EVENT = 'claude-cli://prompt?q=hello'
|
||||
|
||||
await expect(waitForUrlEvent()).resolves.toBe(
|
||||
'claude-cli://prompt?q=hello',
|
||||
)
|
||||
})
|
||||
|
||||
test('returns a Claude URL from argv', async () => {
|
||||
process.argv = [...originalArgv, 'claude://prompt?q=hello']
|
||||
|
||||
await expect(waitForUrlEvent()).resolves.toBe('claude://prompt?q=hello')
|
||||
})
|
||||
|
||||
test('rejects URLs exceeding the maximum length', async () => {
|
||||
process.env.CLAUDE_CODE_URL_EVENT = `claude-cli://${'x'.repeat(2048)}`
|
||||
|
||||
await expect(waitForUrlEvent()).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,48 @@
|
||||
const MAX_URL_LENGTH = 2048
|
||||
|
||||
/**
|
||||
* Check for a pending URL event from environment variables or CLI arguments.
|
||||
*
|
||||
* This is a synchronous snapshot check, not an event listener. The optional
|
||||
* timeout parameter is retained for API compatibility but has no practical
|
||||
* effect since process.env and process.argv do not change at runtime.
|
||||
* Callers that need to wait for an OS-level deep link activation should use
|
||||
* an IPC channel or platform-specific event listener instead.
|
||||
*/
|
||||
export async function waitForUrlEvent(timeoutMs?: number): Promise<string | null> {
|
||||
return null
|
||||
return findUrlEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks three env var sources (set by the OS URL scheme handler or installer)
|
||||
* and then CLI arguments for a claude:// deep link URL.
|
||||
*
|
||||
* Priority order:
|
||||
* 1. CLAUDE_CODE_URL_EVENT — set by the OS URL scheme handler on activation
|
||||
* 2. CLAUDE_CODE_DEEP_LINK_URL — set by the desktop app launcher
|
||||
* 3. CLAUDE_CODE_URL — legacy / manual override
|
||||
* 4. CLI arguments — e.g. `claude claude://...`
|
||||
*/
|
||||
function findUrlEvent(): string | null {
|
||||
for (const key of [
|
||||
'CLAUDE_CODE_URL_EVENT',
|
||||
'CLAUDE_CODE_DEEP_LINK_URL',
|
||||
'CLAUDE_CODE_URL',
|
||||
]) {
|
||||
const value = process.env[key]
|
||||
if (isClaudeUrl(value)) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
const arg = process.argv.find(isClaudeUrl)
|
||||
return arg ?? null
|
||||
}
|
||||
|
||||
function isClaudeUrl(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length <= MAX_URL_LENGTH &&
|
||||
(value.startsWith('claude-cli://') || value.startsWith('claude://'))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,41 +27,52 @@ export function getMacroDefines(): Record<string, string> {
|
||||
* - scripts/dev.ts (bun run dev)
|
||||
*/
|
||||
export const DEFAULT_BUILD_FEATURES = [
|
||||
'BUDDY', 'TRANSCRIPT_CLASSIFIER', 'BRIDGE_MODE',
|
||||
'AGENT_TRIGGERS_REMOTE',
|
||||
'CHICAGO_MCP',
|
||||
'VOICE_MODE',
|
||||
'SHOT_STATS',
|
||||
'PROMPT_CACHE_BREAK_DETECTION',
|
||||
'TOKEN_BUDGET',
|
||||
'BUDDY', // 陪伴宠物角色(Squirtle Waddles)
|
||||
'TRANSCRIPT_CLASSIFIER', // 对话分类器,用于标注会话类型
|
||||
'BRIDGE_MODE', // Remote Control / Bridge 模式,远程控制会话
|
||||
'AGENT_TRIGGERS_REMOTE', // sessionIngress 模块级 Map 累积(非 GB 级主因)
|
||||
'CHICAGO_MCP', // Chicago MCP 集成(内部代号)
|
||||
'VOICE_MODE', // Push-to-Talk 语音输入模式
|
||||
'SHOT_STATS', // 单次请求统计信息收集
|
||||
'PROMPT_CACHE_BREAK_DETECTION', // 检测 prompt cache 是否被打破(有 10 条上限,可控)
|
||||
'TOKEN_BUDGET', // Token 预算管理与控制
|
||||
// P0: local features
|
||||
'AGENT_TRIGGERS',
|
||||
'ULTRATHINK',
|
||||
'BUILTIN_EXPLORE_PLAN_AGENTS',
|
||||
'LODESTONE',
|
||||
// P1: API-dependent features
|
||||
'EXTRACT_MEMORIES',
|
||||
'VERIFICATION_AGENT',
|
||||
'KAIROS_BRIEF',
|
||||
'AWAY_SUMMARY',
|
||||
'ULTRAPLAN',
|
||||
// P2: daemon + remote control server
|
||||
'DAEMON',
|
||||
// ACP (Agent Client Protocol) agent mode
|
||||
'ACP',
|
||||
// PR-package restored features
|
||||
'WORKFLOW_SCRIPTS',
|
||||
'HISTORY_SNIP',
|
||||
'CONTEXT_COLLAPSE',
|
||||
'MONITOR_TOOL',
|
||||
'FORK_SUBAGENT',
|
||||
// 'UDS_INBOX',
|
||||
'KAIROS',
|
||||
'COORDINATOR_MODE',
|
||||
'LAN_PIPES',
|
||||
'BG_SESSIONS',
|
||||
'TEMPLATES',
|
||||
// 'REVIEW_ARTIFACT', // API 请求无响应,需进一步排查 schema 兼容性
|
||||
// P3: poor mode (disable extract_memories + prompt_suggestion)
|
||||
'POOR',
|
||||
] as const;
|
||||
'AGENT_TRIGGERS', // 本地 Agent 触发器(工具调用时启动子代理)
|
||||
'ULTRATHINK', // 超深度思考模式,增加推理链长度
|
||||
'BUILTIN_EXPLORE_PLAN_AGENTS', // 内置 Explore/Plan 子代理类型
|
||||
'LODESTONE', // 上下文锚点,优化长对话的相关性检索
|
||||
'EXTRACT_MEMORIES', // 每次 turn 结束 fork 完整消息历史(非 GB 级主因)
|
||||
'VERIFICATION_AGENT', // 任务完成后 fork 完整消息(非 GB 级主因)
|
||||
'KAIROS_BRIEF', // Kairos 定时摘要(定时汇报当前状态)
|
||||
'AWAY_SUMMARY', // 离线摘要(用户离开后生成总结)
|
||||
'ULTRAPLAN', // 超级规划模式,深度分析后生成实施计划
|
||||
'DAEMON', // 守护进程模式,长驻 supervisor 管理后台 worker(非 GB 级主因)
|
||||
'ACP', // ACP 代理协议,支持外部 agent 接入
|
||||
'WORKFLOW_SCRIPTS', // 工作流脚本(.claude/workflows/ 中的 YAML/MD)
|
||||
'HISTORY_SNIP', // 历史消息裁剪,压缩上下文窗口
|
||||
'CONTEXT_COLLAPSE', // 上下文折叠,自动压缩旧消息
|
||||
'MONITOR_TOOL', // Monitor 工具,流式监控后台进程输出
|
||||
'FORK_SUBAGENT', // Fork 子代理,在隔离上下文中并行执行任务
|
||||
'UDS_INBOX', // inbox 数组只增不减(非 GB 级主因)
|
||||
'KAIROS', // Kairos 定时任务系统核心
|
||||
// 'COORDINATOR_MODE', // 已禁用:AgentSummary 30s fork 循环,GB 级泄露主因
|
||||
'LAN_PIPES', // 依赖 UDS_INBOX(已随 UDS_INBOX 恢复)
|
||||
'BG_SESSIONS', // 后台会话管理(ps/logs/attach/kill)
|
||||
'TEMPLATES', // 模板任务(new/list/reply 子命令)
|
||||
// 'REVIEW_ARTIFACT', // 代码审查产物(API 请求无响应,待排查 schema 兼容性)
|
||||
// API content block types
|
||||
'CONNECTOR_TEXT', // Connector 文本块类型,扩展 API 内容格式
|
||||
// Attribution tracking
|
||||
'COMMIT_ATTRIBUTION', // Git 提交归属追踪(记录 AI 辅助贡献)
|
||||
// Server mode (claude server / claude open)
|
||||
'DIRECT_CONNECT', // 直连模式(claude server / claude open)
|
||||
// Skill search & learning
|
||||
'EXPERIMENTAL_SKILL_SEARCH', // 实验性技能搜索(DiscoverSkills)
|
||||
'SKILL_LEARNING', // projectContext cache 无淘汰机制(非 GB 级主因)
|
||||
// P3: poor mode
|
||||
'POOR', // 穷鬼模式,跳过 extract_memories/prompt_suggestion 减少消耗
|
||||
// Team Memory
|
||||
// 'TEAMMEM', // 已禁用:依赖 COORDINATOR_MODE,邮箱文件无限增长
|
||||
// SSH Remote
|
||||
'SSH_REMOTE', // SSH 远程连接,本地 REPL + 远端工具执行
|
||||
]as const;
|
||||
|
||||
191
scripts/dump-prompt.ts
Normal file
191
scripts/dump-prompt.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* dump-prompt.ts — 生成完整 system prompt 用于人工检查格式和内容。
|
||||
* Usage: bun run scripts/dump-prompt.ts
|
||||
*/
|
||||
import { mock } from 'bun:test'
|
||||
|
||||
// --- Mock chain (block side-effects) ---
|
||||
mock.module('src/bootstrap/state.js', () => ({
|
||||
getIsNonInteractiveSession: () => false,
|
||||
sessionId: 'test-session',
|
||||
getCwd: () => '/test/project',
|
||||
}))
|
||||
mock.module('src/utils/cwd.js', () => ({ getCwd: () => '/test/project' }))
|
||||
mock.module('src/utils/git.js', () => ({ getIsGit: async () => true }))
|
||||
mock.module('src/utils/worktree.js', () => ({
|
||||
getCurrentWorktreeSession: () => null,
|
||||
}))
|
||||
mock.module('src/constants/common.js', () => ({
|
||||
getSessionStartDate: () => '2026-04-22',
|
||||
}))
|
||||
mock.module('src/utils/settings/settings.js', () => ({
|
||||
getInitialSettings: () => ({ language: undefined }),
|
||||
}))
|
||||
mock.module('src/commands/poor/poorMode.js', () => ({
|
||||
isPoorModeActive: () => false,
|
||||
}))
|
||||
mock.module('src/utils/env.js', () => ({ env: { platform: 'linux' } }))
|
||||
mock.module('src/utils/envUtils.js', () => ({ isEnvTruthy: () => false }))
|
||||
mock.module('src/utils/model/model.js', () => ({
|
||||
getCanonicalName: (id: string) => id,
|
||||
getMarketingNameForModel: (id: string) => {
|
||||
if (id.includes('opus-4-7')) return 'Claude Opus 4.7'
|
||||
if (id.includes('opus-4-6')) return 'Claude Opus 4.6'
|
||||
if (id.includes('sonnet-4-6')) return 'Claude Sonnet 4.6'
|
||||
return null
|
||||
},
|
||||
}))
|
||||
mock.module('src/commands.js', () => ({
|
||||
getSkillToolCommands: async () => [],
|
||||
}))
|
||||
mock.module('src/constants/outputStyles.js', () => ({
|
||||
getOutputStyleConfig: async () => null,
|
||||
}))
|
||||
mock.module('src/utils/embeddedTools.js', () => ({
|
||||
hasEmbeddedSearchTools: () => false,
|
||||
}))
|
||||
mock.module('src/utils/permissions/filesystem.js', () => ({
|
||||
isScratchpadEnabled: () => false,
|
||||
getScratchpadDir: () => '/tmp/scratchpad',
|
||||
}))
|
||||
mock.module('src/utils/betas.js', () => ({
|
||||
shouldUseGlobalCacheScope: () => false,
|
||||
}))
|
||||
mock.module('src/utils/undercover.js', () => ({ isUndercover: () => false }))
|
||||
mock.module('src/utils/model/antModels.js', () => ({
|
||||
getAntModelOverrideConfig: () => null,
|
||||
}))
|
||||
mock.module('src/utils/mcpInstructionsDelta.js', () => ({
|
||||
isMcpInstructionsDeltaEnabled: () => false,
|
||||
}))
|
||||
mock.module('src/memdir/memdir.js', () => ({
|
||||
loadMemoryPrompt: async () => null,
|
||||
}))
|
||||
mock.module('src/utils/debug.js', () => ({ logForDebugging: () => {} }))
|
||||
mock.module('src/services/analytics/growthbook.js', () => ({
|
||||
getFeatureValue_CACHED_MAY_BE_STALE: () => false,
|
||||
}))
|
||||
mock.module('bun:bundle', () => ({ feature: (_name: string) => false }))
|
||||
mock.module('src/constants/systemPromptSections.js', () => ({
|
||||
systemPromptSection: (_name: string, fn: () => any) => ({
|
||||
__deferred: true,
|
||||
fn,
|
||||
}),
|
||||
DANGEROUS_uncachedSystemPromptSection: (
|
||||
_name: string,
|
||||
fn: () => any,
|
||||
) => ({ __deferred: true, fn }),
|
||||
resolveSystemPromptSections: async (sections: any[]) => {
|
||||
const results = await Promise.all(
|
||||
sections.map((s: any) => (s?.__deferred ? s.fn() : s)),
|
||||
)
|
||||
return results.filter((s: any) => s !== null)
|
||||
},
|
||||
}))
|
||||
|
||||
// Tool name mocks
|
||||
mock.module(
|
||||
'@claude-code-best/builtin-tools/tools/BashTool/toolName.js',
|
||||
() => ({ BASH_TOOL_NAME: 'Bash' }),
|
||||
)
|
||||
mock.module(
|
||||
'@claude-code-best/builtin-tools/tools/FileReadTool/prompt.js',
|
||||
() => ({ FILE_READ_TOOL_NAME: 'Read' }),
|
||||
)
|
||||
mock.module(
|
||||
'@claude-code-best/builtin-tools/tools/FileEditTool/constants.js',
|
||||
() => ({ FILE_EDIT_TOOL_NAME: 'Edit' }),
|
||||
)
|
||||
mock.module(
|
||||
'@claude-code-best/builtin-tools/tools/FileWriteTool/prompt.js',
|
||||
() => ({ FILE_WRITE_TOOL_NAME: 'Write' }),
|
||||
)
|
||||
mock.module(
|
||||
'@claude-code-best/builtin-tools/tools/GlobTool/prompt.js',
|
||||
() => ({ GLOB_TOOL_NAME: 'Glob' }),
|
||||
)
|
||||
mock.module(
|
||||
'@claude-code-best/builtin-tools/tools/GrepTool/prompt.js',
|
||||
() => ({ GREP_TOOL_NAME: 'Grep' }),
|
||||
)
|
||||
mock.module(
|
||||
'@claude-code-best/builtin-tools/tools/AgentTool/constants.js',
|
||||
() => ({ AGENT_TOOL_NAME: 'Agent', VERIFICATION_AGENT_TYPE: 'verification' }),
|
||||
)
|
||||
mock.module(
|
||||
'@claude-code-best/builtin-tools/tools/AgentTool/forkSubagent.js',
|
||||
() => ({ isForkSubagentEnabled: () => false }),
|
||||
)
|
||||
mock.module(
|
||||
'@claude-code-best/builtin-tools/tools/AgentTool/builtInAgents.js',
|
||||
() => ({ areExplorePlanAgentsEnabled: () => false }),
|
||||
)
|
||||
mock.module(
|
||||
'@claude-code-best/builtin-tools/tools/AgentTool/built-in/exploreAgent.js',
|
||||
() => ({
|
||||
EXPLORE_AGENT: { agentType: 'explore' },
|
||||
EXPLORE_AGENT_MIN_QUERIES: 5,
|
||||
}),
|
||||
)
|
||||
mock.module(
|
||||
'@claude-code-best/builtin-tools/tools/AskUserQuestionTool/prompt.js',
|
||||
() => ({ ASK_USER_QUESTION_TOOL_NAME: 'AskUserQuestion' }),
|
||||
)
|
||||
mock.module(
|
||||
'@claude-code-best/builtin-tools/tools/TodoWriteTool/constants.js',
|
||||
() => ({ TODO_WRITE_TOOL_NAME: 'TodoWrite' }),
|
||||
)
|
||||
mock.module(
|
||||
'@claude-code-best/builtin-tools/tools/TaskCreateTool/constants.js',
|
||||
() => ({ TASK_CREATE_TOOL_NAME: 'TaskCreate' }),
|
||||
)
|
||||
mock.module(
|
||||
'@claude-code-best/builtin-tools/tools/DiscoverSkillsTool/prompt.js',
|
||||
() => ({ DISCOVER_SKILLS_TOOL_NAME: 'DiscoverSkills' }),
|
||||
)
|
||||
mock.module(
|
||||
'@claude-code-best/builtin-tools/tools/SkillTool/constants.js',
|
||||
() => ({ SKILL_TOOL_NAME: 'Skill' }),
|
||||
)
|
||||
mock.module(
|
||||
'@claude-code-best/builtin-tools/tools/SleepTool/prompt.js',
|
||||
() => ({ SLEEP_TOOL_NAME: 'Sleep' }),
|
||||
)
|
||||
mock.module(
|
||||
'@claude-code-best/builtin-tools/tools/REPLTool/constants.js',
|
||||
() => ({ isReplModeEnabled: () => false }),
|
||||
)
|
||||
|
||||
// MACRO globals
|
||||
;(globalThis as any).MACRO = {
|
||||
VERSION: '2.1.888',
|
||||
BUILD_TIME: '2026-04-22T00:00:00Z',
|
||||
FEEDBACK_CHANNEL: '',
|
||||
ISSUES_EXPLAINER: 'report issues on GitHub',
|
||||
NATIVE_PACKAGE_URL: '',
|
||||
PACKAGE_URL: '',
|
||||
VERSION_CHANGELOG: '',
|
||||
}
|
||||
|
||||
// --- Import and dump ---
|
||||
const { getSystemPrompt } = await import('src/constants/prompts.js')
|
||||
|
||||
const tools = [
|
||||
{ name: 'Bash' },
|
||||
{ name: 'Read' },
|
||||
{ name: 'Edit' },
|
||||
{ name: 'Write' },
|
||||
{ name: 'Glob' },
|
||||
{ name: 'Grep' },
|
||||
{ name: 'Agent' },
|
||||
{ name: 'AskUserQuestion' },
|
||||
{ name: 'TaskCreate' },
|
||||
] as any
|
||||
|
||||
const sections = await getSystemPrompt(tools, 'claude-opus-4-7')
|
||||
const full = sections.join('\n\n')
|
||||
|
||||
const outputPath = 'scripts/system-prompt-dump.txt'
|
||||
await Bun.write(outputPath, full)
|
||||
console.log(`Written to ${outputPath}`)
|
||||
console.log(`Sections: ${sections.length} | Chars: ${full.length} | Lines: ${full.split('\n').length}`)
|
||||
@@ -36,9 +36,13 @@ async function postBuild() {
|
||||
}
|
||||
|
||||
// Step 2: Copy native addon files
|
||||
const vendorDir = join(outdir, "vendor", "audio-capture");
|
||||
await cp("vendor/audio-capture", vendorDir, { recursive: true } as never);
|
||||
console.log(`Copied vendor/audio-capture/ → ${vendorDir}/`);
|
||||
const audioCaptureDir = join(outdir, "vendor", "audio-capture");
|
||||
await cp("vendor/audio-capture", audioCaptureDir, { recursive: true } as never);
|
||||
console.log(`Copied vendor/audio-capture/ → ${audioCaptureDir}/`);
|
||||
|
||||
const ripgrepDir = join(outdir, "vendor", "ripgrep");
|
||||
await cp("src/utils/vendor/ripgrep", ripgrepDir, { recursive: true } as never);
|
||||
console.log(`Copied src/utils/vendor/ripgrep/ → ${ripgrepDir}/`);
|
||||
|
||||
// Step 3: Generate dual entry points
|
||||
const cliBun = join(outdir, "cli-bun.js");
|
||||
|
||||
@@ -86,9 +86,13 @@ import {
|
||||
|
||||
// Lazy: MessageSelector.tsx pulls React/ink; only needed for message filtering at query time
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const messageSelector =
|
||||
(): typeof import('src/components/MessageSelector.js') =>
|
||||
require('src/components/MessageSelector.js')
|
||||
const messageSelector = (): typeof import('src/components/MessageSelector.js') | null => {
|
||||
try {
|
||||
return require('src/components/MessageSelector.js')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
import {
|
||||
localCommandOutputToSDKAssistantMessage,
|
||||
@@ -466,12 +470,13 @@ export class QueryEngine {
|
||||
}
|
||||
|
||||
// Filter messages that should be acknowledged after transcript
|
||||
const _selector = messageSelector()
|
||||
const replayableMessages = messagesFromUserInput.filter(
|
||||
msg =>
|
||||
(msg.type === 'user' &&
|
||||
!msg.isMeta && // Skip synthetic caveat messages
|
||||
!msg.toolUseResult && // Skip tool results (they'll be acked from query)
|
||||
messageSelector().selectableUserMessagesFilter(msg)) || // Skip non-user-authored messages (task notifications, etc.)
|
||||
(_selector?.selectableUserMessagesFilter(msg) ?? true)) || // Skip non-user-authored messages (task notifications, etc.)
|
||||
(msg.type === 'system' && msg.subtype === 'compact_boundary'), // Always ack compact boundaries
|
||||
)
|
||||
const messagesToAck = replayUserMessages ? replayableMessages : []
|
||||
@@ -643,8 +648,10 @@ export class QueryEngine {
|
||||
}
|
||||
|
||||
if (fileHistoryEnabled() && persistSession) {
|
||||
const _sel = messageSelector()
|
||||
const _filter = _sel?.selectableUserMessagesFilter ?? ((_msg: unknown) => true)
|
||||
messagesFromUserInput
|
||||
.filter(messageSelector().selectableUserMessagesFilter)
|
||||
.filter(_filter)
|
||||
.forEach(message => {
|
||||
void fileHistoryMakeSnapshot(
|
||||
(updater: (prev: FileHistoryState) => FileHistoryState) => {
|
||||
|
||||
59
src/assistant/__tests__/index.test.ts
Normal file
59
src/assistant/__tests__/index.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
resetStateForTests,
|
||||
setCwdState,
|
||||
setOriginalCwd,
|
||||
} from '../../bootstrap/state'
|
||||
import { getTaskListId } from '../../utils/tasks'
|
||||
import { getTeamFilePath } from '../../utils/swarm/teamHelpers'
|
||||
import { initializeAssistantTeam } from '../index'
|
||||
|
||||
let tempDir = ''
|
||||
let previousConfigDir: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
previousConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
tempDir = join(
|
||||
tmpdir(),
|
||||
`assistant-team-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
)
|
||||
process.env.CLAUDE_CONFIG_DIR = join(tempDir, 'config')
|
||||
resetStateForTests()
|
||||
setOriginalCwd(tempDir)
|
||||
setCwdState(tempDir)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
resetStateForTests()
|
||||
if (previousConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
|
||||
}
|
||||
await rm(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('initializeAssistantTeam', () => {
|
||||
test('creates a session-scoped in-process team context and task list', async () => {
|
||||
const context = await initializeAssistantTeam()
|
||||
expect(context).toBeDefined()
|
||||
const teamContext = context!
|
||||
|
||||
expect(teamContext.teamName).toStartWith('assistant-')
|
||||
expect(teamContext.isLeader).toBe(true)
|
||||
expect(teamContext.selfAgentName).toBe('team-lead')
|
||||
expect(
|
||||
teamContext.teammates[teamContext.leadAgentId]?.tmuxSessionName,
|
||||
).toBe('in-process')
|
||||
expect(getTaskListId()).toBe(teamContext.teamName)
|
||||
|
||||
const raw = await readFile(getTeamFilePath(teamContext.teamName), 'utf-8')
|
||||
const teamFile = JSON.parse(raw)
|
||||
expect(teamFile.leadAgentId).toBe(teamContext.leadAgentId)
|
||||
expect(teamFile.members[0].backendType).toBe('in-process')
|
||||
expect(teamFile.members[0].agentType).toBe('assistant')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,24 @@
|
||||
import { readFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { getKairosActive } from '../bootstrap/state.js'
|
||||
import { getKairosActive, getSessionId } from '../bootstrap/state.js'
|
||||
import type { AppState } from '../state/AppState.js'
|
||||
import { formatAgentId } from '../utils/agentId.js'
|
||||
import { getCwd } from '../utils/cwd.js'
|
||||
import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
|
||||
import { TEAM_LEAD_NAME } from '../utils/swarm/constants.js'
|
||||
import {
|
||||
getTeamFilePath,
|
||||
registerTeamForSessionCleanup,
|
||||
sanitizeName,
|
||||
writeTeamFileAsync,
|
||||
type TeamFile,
|
||||
} from '../utils/swarm/teamHelpers.js'
|
||||
import { assignTeammateColor } from '../utils/swarm/teammateLayoutManager.js'
|
||||
import {
|
||||
ensureTasksDir,
|
||||
resetTaskList,
|
||||
setLeaderTeamName,
|
||||
} from '../utils/tasks.js'
|
||||
|
||||
let _assistantForced = false
|
||||
|
||||
@@ -29,13 +46,67 @@ export function isAssistantForced(): boolean {
|
||||
* Pre-create an in-process team so Agent(name) can spawn teammates
|
||||
* without TeamCreate.
|
||||
*
|
||||
* Phase 1: returns undefined so main.tsx's `assistantTeamContext ?? computeInitialTeamContext()`
|
||||
* correctly falls back. Returning {} would bypass the ?? operator since {} is truthy.
|
||||
*
|
||||
* Phase 2: should return a full team context object matching AppState.teamContext shape.
|
||||
* Creates a session-scoped assistant team file and returns a full team
|
||||
* context object matching AppState.teamContext.
|
||||
*/
|
||||
export async function initializeAssistantTeam(): Promise<undefined> {
|
||||
return undefined
|
||||
export async function initializeAssistantTeam(): Promise<
|
||||
AppState['teamContext']
|
||||
> {
|
||||
const sessionId = getSessionId()
|
||||
const teamName = sanitizeName(`assistant-${sessionId.slice(0, 8)}`)
|
||||
const leadAgentId = formatAgentId(TEAM_LEAD_NAME, teamName)
|
||||
const teamFilePath = getTeamFilePath(teamName)
|
||||
const now = Date.now()
|
||||
const cwd = getCwd()
|
||||
const color = assignTeammateColor(leadAgentId)
|
||||
|
||||
const teamFile: TeamFile = {
|
||||
name: teamName,
|
||||
description: 'Assistant mode in-process team',
|
||||
createdAt: now,
|
||||
leadAgentId,
|
||||
leadSessionId: sessionId,
|
||||
members: [
|
||||
{
|
||||
agentId: leadAgentId,
|
||||
name: TEAM_LEAD_NAME,
|
||||
agentType: 'assistant',
|
||||
color,
|
||||
joinedAt: now,
|
||||
tmuxPaneId: '',
|
||||
cwd,
|
||||
subscriptions: [],
|
||||
backendType: 'in-process',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
await writeTeamFileAsync(teamName, teamFile)
|
||||
registerTeamForSessionCleanup(teamName)
|
||||
await resetTaskList(teamName)
|
||||
await ensureTasksDir(teamName)
|
||||
setLeaderTeamName(teamName)
|
||||
|
||||
return {
|
||||
teamName,
|
||||
teamFilePath,
|
||||
leadAgentId,
|
||||
selfAgentId: leadAgentId,
|
||||
selfAgentName: TEAM_LEAD_NAME,
|
||||
isLeader: true,
|
||||
selfAgentColor: color,
|
||||
teammates: {
|
||||
[leadAgentId]: {
|
||||
name: TEAM_LEAD_NAME,
|
||||
agentType: 'assistant',
|
||||
color,
|
||||
tmuxSessionName: 'in-process',
|
||||
tmuxPaneId: 'leader',
|
||||
cwd,
|
||||
spawnedAt: now,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type HookEvent = any;
|
||||
export type ModelUsage = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type AgentColorName = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type HookCallbackMatcher = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type SessionId = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type randomUUID = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type ModelSetting = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type ModelStrings = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type SettingSource = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type resetSettingsCache = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type PluginHookMatcher = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type createSignal = any;
|
||||
@@ -235,11 +235,6 @@ type State = {
|
||||
// microcompact is first enabled, keep sending the header so mid-session
|
||||
// GrowthBook/settings toggles don't bust the prompt cache.
|
||||
cacheEditingHeaderLatched: boolean | null
|
||||
// Sticky-on latch for clearing thinking from prior tool loops. Triggered
|
||||
// when >1h since last API call (confirmed cache miss — no cache-hit
|
||||
// benefit to keeping thinking). Once latched, stays on so the newly-warmed
|
||||
// thinking-cleared cache isn't busted by flipping back to keep:'all'.
|
||||
thinkingClearLatched: boolean | null
|
||||
// Current prompt ID (UUID) correlating a user prompt with subsequent OTel events
|
||||
promptId: string | null
|
||||
// Last API requestId for the main conversation chain (not subagents).
|
||||
@@ -414,7 +409,6 @@ function getInitialState(): State {
|
||||
afkModeHeaderLatched: null,
|
||||
fastModeHeaderLatched: null,
|
||||
cacheEditingHeaderLatched: null,
|
||||
thinkingClearLatched: null,
|
||||
// Current prompt ID
|
||||
promptId: null,
|
||||
lastMainRequestId: undefined,
|
||||
@@ -1729,14 +1723,6 @@ export function setCacheEditingHeaderLatched(v: boolean): void {
|
||||
STATE.cacheEditingHeaderLatched = v
|
||||
}
|
||||
|
||||
export function getThinkingClearLatched(): boolean | null {
|
||||
return STATE.thinkingClearLatched
|
||||
}
|
||||
|
||||
export function setThinkingClearLatched(v: boolean): void {
|
||||
STATE.thinkingClearLatched = v
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset beta header latches to null. Called on /clear and /compact so a
|
||||
* fresh conversation gets fresh header evaluation.
|
||||
@@ -1745,7 +1731,6 @@ export function clearBetaHeaderLatches(): void {
|
||||
STATE.afkModeHeaderLatched = null
|
||||
STATE.fastModeHeaderLatched = null
|
||||
STATE.cacheEditingHeaderLatched = null
|
||||
STATE.thinkingClearLatched = null
|
||||
}
|
||||
|
||||
export function getPromptId(): string | null {
|
||||
|
||||
@@ -1963,7 +1963,6 @@ NOTES
|
||||
- You must be logged in with a Claude account that has a subscription
|
||||
- Run \`claude\` first in the directory to accept the workspace trust dialog
|
||||
${serverNote}`
|
||||
// biome-ignore lint/suspicious/noConsole: intentional help output
|
||||
console.log(help)
|
||||
}
|
||||
|
||||
@@ -2002,7 +2001,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
return
|
||||
}
|
||||
if (parsed.error) {
|
||||
// biome-ignore lint/suspicious/noConsole: intentional error output
|
||||
console.error(`Error: ${parsed.error}`)
|
||||
// eslint-disable-next-line custom-rules/no-process-exit
|
||||
process.exit(1)
|
||||
@@ -2041,7 +2039,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
const { PERMISSION_MODES } = await import('../types/permissions.js')
|
||||
const valid: readonly string[] = PERMISSION_MODES
|
||||
if (!valid.includes(permissionMode)) {
|
||||
// biome-ignore lint/suspicious/noConsole: intentional error output
|
||||
console.error(
|
||||
`Error: Invalid permission mode '${permissionMode}'. Valid modes: ${valid.join(', ')}`,
|
||||
)
|
||||
@@ -2084,7 +2081,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
Promise.all([shutdown1PEventLogging(), shutdownDatadog()]),
|
||||
sleep(500, undefined, { unref: true }),
|
||||
]).catch(() => {})
|
||||
// biome-ignore lint/suspicious/noConsole: intentional error output
|
||||
console.error(
|
||||
'Error: Multi-session Remote Control is not enabled for your account yet.',
|
||||
)
|
||||
@@ -2101,7 +2097,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
// The bridge bypasses main.tsx (which renders the interactive TrustDialog via showSetupScreens),
|
||||
// so we must verify trust was previously established by a normal `claude` session.
|
||||
if (!checkHasTrustDialogAccepted()) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.error(
|
||||
`Error: Workspace not trusted. Please run \`claude\` in ${dir} first to review and accept the workspace trust dialog.`,
|
||||
)
|
||||
@@ -2118,7 +2113,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
|
||||
const bridgeToken = getBridgeAccessToken()
|
||||
if (!bridgeToken) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.error(BRIDGE_LOGIN_ERROR)
|
||||
// eslint-disable-next-line custom-rules/no-process-exit
|
||||
process.exit(1)
|
||||
@@ -2137,7 +2131,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
})
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(
|
||||
'\nRemote Control lets you access this CLI session from the web (claude.ai/code)\nor the Claude app, so you can pick up where you left off on any device.\n\nYou can disconnect remote access anytime by running /remote-control again.\n',
|
||||
)
|
||||
@@ -2169,7 +2162,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
)
|
||||
const found = await readBridgePointerAcrossWorktrees(dir)
|
||||
if (!found) {
|
||||
// biome-ignore lint/suspicious/noConsole: intentional error output
|
||||
console.error(
|
||||
`Error: No recent session found in this directory or its worktrees. Run \`claude remote-control\` to start a new one.`,
|
||||
)
|
||||
@@ -2180,7 +2172,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
const ageMin = Math.round(pointer.ageMs / 60_000)
|
||||
const ageStr = ageMin < 60 ? `${ageMin}m` : `${Math.round(ageMin / 60)}h`
|
||||
const fromWt = pointerDir !== dir ? ` from worktree ${pointerDir}` : ''
|
||||
// biome-ignore lint/suspicious/noConsole: intentional info output
|
||||
console.error(
|
||||
`Resuming session ${pointer.sessionId} (${ageStr} ago)${fromWt}\u2026`,
|
||||
)
|
||||
@@ -2201,7 +2192,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
!baseUrl.includes('localhost') &&
|
||||
!baseUrl.includes('127.0.0.1')
|
||||
) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.error(
|
||||
'Error: Remote Control base URL uses HTTP. Only HTTPS or localhost HTTP is allowed.',
|
||||
)
|
||||
@@ -2237,7 +2227,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
? getCurrentProjectConfig().remoteControlSpawnMode
|
||||
: undefined
|
||||
if (savedSpawnMode === 'worktree' && !worktreeAvailable) {
|
||||
// biome-ignore lint/suspicious/noConsole: intentional warning output
|
||||
console.error(
|
||||
'Warning: Saved spawn mode is worktree but this directory is not a git repository. Falling back to same-dir.',
|
||||
)
|
||||
@@ -2264,7 +2253,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
})
|
||||
// biome-ignore lint/suspicious/noConsole: intentional dialog output
|
||||
console.log(
|
||||
`\nClaude Remote Control is launching in spawn mode which lets you create new sessions in this project from Claude Code on Web or your Mobile app. Learn more here: https://code.claude.com/docs/en/remote-control\n\n` +
|
||||
`Spawn mode for this project:\n` +
|
||||
@@ -2343,7 +2331,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
// Only reachable via explicit --spawn=worktree (default is same-dir);
|
||||
// saved worktree pref was already guarded above.
|
||||
if (spawnMode === 'worktree' && !worktreeAvailable) {
|
||||
// biome-ignore lint/suspicious/noConsole: intentional error output
|
||||
console.error(
|
||||
`Error: Worktree mode requires a git repository or WorktreeCreate hooks configured. Use --spawn=session for single-session mode.`,
|
||||
)
|
||||
@@ -2378,7 +2365,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
try {
|
||||
validateBridgeId(resumeSessionId, 'sessionId')
|
||||
} catch {
|
||||
// biome-ignore lint/suspicious/noConsole: intentional error output
|
||||
console.error(
|
||||
`Error: Invalid session ID "${resumeSessionId}". Session IDs must not contain unsafe characters.`,
|
||||
)
|
||||
@@ -2404,7 +2390,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
const { clearBridgePointer } = await import('./bridgePointer.js')
|
||||
await clearBridgePointer(resumePointerDir)
|
||||
}
|
||||
// biome-ignore lint/suspicious/noConsole: intentional error output
|
||||
console.error(
|
||||
`Error: Session ${resumeSessionId} not found. It may have been archived or expired, or your login may have lapsed (run \`claude /login\`).`,
|
||||
)
|
||||
@@ -2416,7 +2401,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
const { clearBridgePointer } = await import('./bridgePointer.js')
|
||||
await clearBridgePointer(resumePointerDir)
|
||||
}
|
||||
// biome-ignore lint/suspicious/noConsole: intentional error output
|
||||
console.error(
|
||||
`Error: Session ${resumeSessionId} has no environment_id. It may never have been attached to a bridge.`,
|
||||
)
|
||||
@@ -2470,7 +2454,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
status: err instanceof BridgeFatalError ? err.status : undefined,
|
||||
})
|
||||
// Registration failures are fatal — print a clean message instead of a stack trace.
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.error(
|
||||
err instanceof BridgeFatalError && err.status === 404
|
||||
? 'Remote Control environments are not available for your account.'
|
||||
@@ -2495,7 +2478,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
`Bridge resume env mismatch: requested ${reuseEnvironmentId}, backend returned ${environmentId}. Falling back to fresh session.`,
|
||||
),
|
||||
)
|
||||
// biome-ignore lint/suspicious/noConsole: intentional warning output
|
||||
console.warn(
|
||||
`Warning: Could not resume session ${resumeSessionId} — its environment has expired. Creating a fresh session instead.`,
|
||||
)
|
||||
@@ -2546,7 +2528,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
const { clearBridgePointer } = await import('./bridgePointer.js')
|
||||
await clearBridgePointer(resumePointerDir)
|
||||
}
|
||||
// biome-ignore lint/suspicious/noConsole: intentional error output
|
||||
console.error(
|
||||
isFatal
|
||||
? `Error: ${errorMessage(err)}`
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type StdoutMessage = any;
|
||||
@@ -17,7 +17,6 @@
|
||||
|
||||
/** Write an error message to stderr (if given) and exit with code 1. */
|
||||
export function cliError(msg?: string): never {
|
||||
// biome-ignore lint/suspicious/noConsole: centralized CLI error output
|
||||
if (msg) console.error(msg)
|
||||
process.exit(1)
|
||||
return undefined as never
|
||||
|
||||
132
src/cli/handlers/__tests__/autonomy.test.ts
Normal file
132
src/cli/handlers/__tests__/autonomy.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { mkdir, rm, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import {
|
||||
resetStateForTests,
|
||||
setOriginalCwd,
|
||||
setProjectRoot,
|
||||
} from '../../../bootstrap/state'
|
||||
import { createAutonomyQueuedPrompt } from '../../../utils/autonomyRuns'
|
||||
import {
|
||||
cancelAutonomyFlowText,
|
||||
getAutonomyDeepSectionText,
|
||||
getAutonomyFlowText,
|
||||
getAutonomyFlowsText,
|
||||
getAutonomyStatusText,
|
||||
resumeAutonomyFlowText,
|
||||
} from '../autonomy'
|
||||
import {
|
||||
listAutonomyFlows,
|
||||
startManagedAutonomyFlow,
|
||||
} from '../../../utils/autonomyFlows'
|
||||
|
||||
let tempDir: string
|
||||
let previousConfigDir: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
previousConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
tempDir = join(
|
||||
tmpdir(),
|
||||
`autonomy-cli-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
)
|
||||
await mkdir(tempDir, { recursive: true })
|
||||
process.env.CLAUDE_CONFIG_DIR = join(tempDir, 'config')
|
||||
resetStateForTests()
|
||||
setOriginalCwd(tempDir)
|
||||
setProjectRoot(tempDir)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
resetStateForTests()
|
||||
if (previousConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
|
||||
}
|
||||
await rm(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('autonomy CLI handler', () => {
|
||||
test('prints the same basic status surfaces as the slash command', async () => {
|
||||
await createAutonomyQueuedPrompt({
|
||||
basePrompt: 'scheduled prompt',
|
||||
trigger: 'scheduled-task',
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
sourceLabel: 'nightly',
|
||||
})
|
||||
|
||||
const output = await getAutonomyStatusText()
|
||||
|
||||
expect(output).toContain('Autonomy runs: 1')
|
||||
expect(output).toContain('Queued: 1')
|
||||
expect(output).toContain('Autonomy flows: 0')
|
||||
})
|
||||
|
||||
test('prints deep status for CLI status --deep', async () => {
|
||||
await mkdir(join(tempDir, '.claude'), { recursive: true })
|
||||
await writeFile(
|
||||
join(tempDir, '.claude', 'remote-trigger-audit.jsonl'),
|
||||
`${JSON.stringify({
|
||||
auditId: 'audit-1',
|
||||
createdAt: 1,
|
||||
action: 'list',
|
||||
ok: true,
|
||||
status: 200,
|
||||
})}\n`,
|
||||
)
|
||||
|
||||
const output = await getAutonomyStatusText({ deep: true })
|
||||
|
||||
expect(output).toContain('# Autonomy Deep Status')
|
||||
expect(output).toContain('## Workflow Runs')
|
||||
expect(output).toContain('## Pipes')
|
||||
expect(output).toContain('## Remote Control')
|
||||
expect(output).toContain('## RemoteTrigger')
|
||||
})
|
||||
|
||||
test('prints individual deep status sections for panel actions', async () => {
|
||||
const pipes = await getAutonomyDeepSectionText('pipes')
|
||||
const remoteControl = await getAutonomyDeepSectionText('remote-control')
|
||||
|
||||
expect(pipes).toContain('# Pipes')
|
||||
expect(pipes).toContain('Pipe registry:')
|
||||
expect(remoteControl).toContain('# Remote Control')
|
||||
expect(remoteControl).toContain('Remote Control:')
|
||||
})
|
||||
|
||||
test('lists, inspects, cancels, and resumes flows from CLI handlers', async () => {
|
||||
await startManagedAutonomyFlow({
|
||||
trigger: 'proactive-tick',
|
||||
goal: 'ship managed flow',
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
steps: [
|
||||
{
|
||||
name: 'wait',
|
||||
prompt: 'Wait for manual signal',
|
||||
waitFor: 'manual',
|
||||
},
|
||||
{
|
||||
name: 'run',
|
||||
prompt: 'Run the next step',
|
||||
},
|
||||
],
|
||||
})
|
||||
const [waitingFlow] = await listAutonomyFlows(tempDir)
|
||||
|
||||
expect(await getAutonomyFlowsText()).toContain(waitingFlow!.flowId)
|
||||
expect(await getAutonomyFlowText(waitingFlow!.flowId)).toContain(
|
||||
'Current step: wait',
|
||||
)
|
||||
|
||||
const resumed = await resumeAutonomyFlowText(waitingFlow!.flowId)
|
||||
expect(resumed).toContain('Prepared the next managed step')
|
||||
expect(resumed).toContain('Prompt:')
|
||||
expect(resumed).toContain('Wait for manual signal')
|
||||
|
||||
const cancelled = await cancelAutonomyFlowText(waitingFlow!.flowId)
|
||||
expect(cancelled).toContain('Cancelled flow')
|
||||
})
|
||||
})
|
||||
@@ -59,12 +59,9 @@ export async function agentsHandler(): Promise<void> {
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log('No agents found.')
|
||||
} else {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`${totalActive} active agents\n`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(lines.join('\n').trimEnd())
|
||||
}
|
||||
}
|
||||
|
||||
213
src/cli/handlers/autonomy.ts
Normal file
213
src/cli/handlers/autonomy.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
formatAutonomyFlowDetail,
|
||||
formatAutonomyFlowsList,
|
||||
formatAutonomyFlowsStatus,
|
||||
getAutonomyFlowById,
|
||||
listAutonomyFlows,
|
||||
requestManagedAutonomyFlowCancel,
|
||||
} from '../../utils/autonomyFlows.js'
|
||||
import {
|
||||
formatAutonomyRunsList,
|
||||
formatAutonomyRunsStatus,
|
||||
listAutonomyRuns,
|
||||
markAutonomyRunCancelled,
|
||||
resumeManagedAutonomyFlowPrompt,
|
||||
} from '../../utils/autonomyRuns.js'
|
||||
import {
|
||||
formatAutonomyDeepStatus,
|
||||
formatAutonomyDeepStatusSections,
|
||||
type AutonomyDeepStatusSectionId,
|
||||
} from '../../utils/autonomyStatus.js'
|
||||
import {
|
||||
AUTONOMY_USAGE,
|
||||
parseAutonomyArgs,
|
||||
} from '../../utils/autonomyCommandSpec.js'
|
||||
import {
|
||||
enqueuePendingNotification,
|
||||
removeByFilter,
|
||||
} from '../../utils/messageQueueManager.js'
|
||||
|
||||
export function parseAutonomyLimit(raw?: string | number): number {
|
||||
const parsed = typeof raw === 'number' ? raw : Number.parseInt(raw ?? '', 10)
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return 10
|
||||
}
|
||||
return Math.min(parsed, 50)
|
||||
}
|
||||
|
||||
export async function getAutonomyStatusText(options?: {
|
||||
deep?: boolean
|
||||
}): Promise<string> {
|
||||
const [runs, flows] = await Promise.all([
|
||||
listAutonomyRuns(),
|
||||
listAutonomyFlows(),
|
||||
])
|
||||
|
||||
if (options?.deep) {
|
||||
return formatAutonomyDeepStatus({ runs, flows })
|
||||
}
|
||||
|
||||
return [
|
||||
formatAutonomyRunsStatus(runs),
|
||||
formatAutonomyFlowsStatus(flows),
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export async function getAutonomyDeepSectionText(
|
||||
sectionId: AutonomyDeepStatusSectionId,
|
||||
): Promise<string> {
|
||||
const [runs, flows] = await Promise.all([
|
||||
listAutonomyRuns(),
|
||||
listAutonomyFlows(),
|
||||
])
|
||||
const sections = await formatAutonomyDeepStatusSections({ runs, flows })
|
||||
const section = sections.find(item => item.id === sectionId)
|
||||
if (!section) {
|
||||
return `Autonomy deep status section not found: ${sectionId}`
|
||||
}
|
||||
return [`# ${section.title}`, section.content].join('\n')
|
||||
}
|
||||
|
||||
export async function autonomyStatusHandler(options?: {
|
||||
deep?: boolean
|
||||
}): Promise<void> {
|
||||
process.stdout.write(`${await getAutonomyStatusText(options)}\n`)
|
||||
}
|
||||
|
||||
export async function getAutonomyRunsText(
|
||||
limit?: string | number,
|
||||
): Promise<string> {
|
||||
return formatAutonomyRunsList(
|
||||
await listAutonomyRuns(),
|
||||
parseAutonomyLimit(limit),
|
||||
)
|
||||
}
|
||||
|
||||
export async function autonomyRunsHandler(
|
||||
limit?: string | number,
|
||||
): Promise<void> {
|
||||
process.stdout.write(`${await getAutonomyRunsText(limit)}\n`)
|
||||
}
|
||||
|
||||
export async function getAutonomyFlowsText(
|
||||
limit?: string | number,
|
||||
): Promise<string> {
|
||||
return formatAutonomyFlowsList(
|
||||
await listAutonomyFlows(),
|
||||
parseAutonomyLimit(limit),
|
||||
)
|
||||
}
|
||||
|
||||
export async function autonomyFlowsHandler(
|
||||
limit?: string | number,
|
||||
): Promise<void> {
|
||||
process.stdout.write(`${await getAutonomyFlowsText(limit)}\n`)
|
||||
}
|
||||
|
||||
export async function getAutonomyFlowText(flowId: string): Promise<string> {
|
||||
return formatAutonomyFlowDetail(await getAutonomyFlowById(flowId))
|
||||
}
|
||||
|
||||
export async function autonomyFlowHandler(flowId: string): Promise<void> {
|
||||
process.stdout.write(`${await getAutonomyFlowText(flowId)}\n`)
|
||||
}
|
||||
|
||||
export async function cancelAutonomyFlowText(
|
||||
flowId: string,
|
||||
options?: {
|
||||
removeQueuedInMemory?: boolean
|
||||
},
|
||||
): Promise<string> {
|
||||
const cancelled = await requestManagedAutonomyFlowCancel({ flowId })
|
||||
if (!cancelled) {
|
||||
return 'Autonomy flow not found.'
|
||||
}
|
||||
if (!cancelled.accepted) {
|
||||
return `Autonomy flow ${flowId} is already terminal (${cancelled.flow.status}).`
|
||||
}
|
||||
|
||||
let removedCount = 0
|
||||
if (options?.removeQueuedInMemory) {
|
||||
const removed = removeByFilter(cmd => cmd.autonomy?.flowId === flowId)
|
||||
removedCount = removed.length
|
||||
for (const command of removed) {
|
||||
if (command.autonomy?.runId) {
|
||||
await markAutonomyRunCancelled(command.autonomy.runId)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const runId of cancelled.queuedRunIds) {
|
||||
await markAutonomyRunCancelled(runId)
|
||||
}
|
||||
removedCount = cancelled.queuedRunIds.length
|
||||
}
|
||||
|
||||
return cancelled.flow.status === 'running'
|
||||
? `Cancellation requested for flow ${flowId}. The current step is still running, and no new steps will be started.`
|
||||
: `Cancelled flow ${flowId}. Removed ${removedCount} queued step(s).`
|
||||
}
|
||||
|
||||
export async function autonomyFlowCancelHandler(flowId: string): Promise<void> {
|
||||
process.stdout.write(`${await cancelAutonomyFlowText(flowId)}\n`)
|
||||
}
|
||||
|
||||
export async function resumeAutonomyFlowText(
|
||||
flowId: string,
|
||||
options?: {
|
||||
enqueueInMemory?: boolean
|
||||
},
|
||||
): Promise<string> {
|
||||
const command = await resumeManagedAutonomyFlowPrompt({ flowId })
|
||||
if (!command) {
|
||||
return 'Autonomy flow is not waiting or was not found.'
|
||||
}
|
||||
|
||||
if (options?.enqueueInMemory) {
|
||||
enqueuePendingNotification(command)
|
||||
return `Queued the next managed step for flow ${flowId}.`
|
||||
}
|
||||
|
||||
const runId = command.autonomy?.runId ?? 'unknown'
|
||||
return [
|
||||
`Prepared the next managed step for flow ${flowId}.`,
|
||||
`Run ID: ${runId}`,
|
||||
'',
|
||||
'Prompt:',
|
||||
typeof command.value === 'string' ? command.value : String(command.value),
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export async function autonomyFlowResumeHandler(flowId: string): Promise<void> {
|
||||
process.stdout.write(`${await resumeAutonomyFlowText(flowId)}\n`)
|
||||
}
|
||||
|
||||
export async function getAutonomyCommandText(
|
||||
args: string,
|
||||
options?: {
|
||||
enqueueInMemory?: boolean
|
||||
removeQueuedInMemory?: boolean
|
||||
},
|
||||
): Promise<string> {
|
||||
const parsed = parseAutonomyArgs(args)
|
||||
|
||||
switch (parsed.type) {
|
||||
case 'status':
|
||||
return getAutonomyStatusText({ deep: parsed.deep })
|
||||
case 'runs':
|
||||
return getAutonomyRunsText(parsed.limit)
|
||||
case 'flows':
|
||||
return getAutonomyFlowsText(parsed.limit)
|
||||
case 'flow-detail':
|
||||
return getAutonomyFlowText(parsed.flowId)
|
||||
case 'flow-cancel':
|
||||
return cancelAutonomyFlowText(parsed.flowId, {
|
||||
removeQueuedInMemory: options?.removeQueuedInMemory,
|
||||
})
|
||||
case 'flow-resume':
|
||||
return resumeAutonomyFlowText(parsed.flowId, {
|
||||
enqueueInMemory: options?.enqueueInMemory,
|
||||
})
|
||||
case 'usage':
|
||||
return AUTONOMY_USAGE
|
||||
}
|
||||
}
|
||||
@@ -190,12 +190,10 @@ export async function mcpListHandler(): Promise<void> {
|
||||
logEvent('tengu_mcp_list', {})
|
||||
const { servers: configs } = await getAllMcpConfigs()
|
||||
if (Object.keys(configs).length === 0) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(
|
||||
'No MCP servers configured. Use `claude mcp add` to add a server.',
|
||||
)
|
||||
} else {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log('Checking MCP server health...\n')
|
||||
|
||||
// Check servers concurrently
|
||||
@@ -213,18 +211,14 @@ export async function mcpListHandler(): Promise<void> {
|
||||
for (const { name, server, status } of results) {
|
||||
// Intentionally excluding sse-ide servers here since they're internal
|
||||
if (server.type === 'sse') {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`${name}: ${server.url} (SSE) - ${status}`)
|
||||
} else if (server.type === 'http') {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`${name}: ${server.url} (HTTP) - ${status}`)
|
||||
} else if (server.type === 'claudeai-proxy') {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`${name}: ${server.url} - ${status}`)
|
||||
} else if (!server.type || server.type === 'stdio') {
|
||||
const stdioServer = server as { command: string; args: string[]; type?: string }
|
||||
const args = Array.isArray(stdioServer.args) ? stdioServer.args : []
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`${name}: ${stdioServer.command} ${args.join(' ')} - ${status}`)
|
||||
}
|
||||
}
|
||||
@@ -244,27 +238,20 @@ export async function mcpGetHandler(name: string): Promise<void> {
|
||||
cliError(`No MCP server found with name: ${name}`)
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`${name}:`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Scope: ${getScopeLabel(server.scope)}`)
|
||||
|
||||
// Check server health
|
||||
const status = await checkMcpServerHealth(name, server)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Status: ${status}`)
|
||||
|
||||
// Intentionally excluding sse-ide servers here since they're internal
|
||||
if (server.type === 'sse') {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Type: sse`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` URL: ${server.url}`)
|
||||
if (server.headers) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(' Headers:')
|
||||
for (const [key, value] of Object.entries(server.headers)) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` ${key}: ${value}`)
|
||||
}
|
||||
}
|
||||
@@ -277,19 +264,14 @@ export async function mcpGetHandler(name: string): Promise<void> {
|
||||
}
|
||||
if (server.oauth.callbackPort)
|
||||
parts.push(`callback_port ${server.oauth.callbackPort}`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` OAuth: ${parts.join(', ')}`)
|
||||
}
|
||||
} else if (server.type === 'http') {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Type: http`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` URL: ${server.url}`)
|
||||
if (server.headers) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(' Headers:')
|
||||
for (const [key, value] of Object.entries(server.headers)) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` ${key}: ${value}`)
|
||||
}
|
||||
}
|
||||
@@ -302,27 +284,20 @@ export async function mcpGetHandler(name: string): Promise<void> {
|
||||
}
|
||||
if (server.oauth.callbackPort)
|
||||
parts.push(`callback_port ${server.oauth.callbackPort}`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` OAuth: ${parts.join(', ')}`)
|
||||
}
|
||||
} else if (server.type === 'stdio') {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Type: stdio`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Command: ${server.command}`)
|
||||
const args = Array.isArray(server.args) ? server.args : []
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Args: ${args.join(' ')}`)
|
||||
if (server.env) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(' Environment:')
|
||||
for (const [key, value] of Object.entries(server.env)) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` ${key}=${value}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(
|
||||
`\nTo remove this server, run: claude mcp remove "${name}" -s ${server.scope}`,
|
||||
)
|
||||
|
||||
@@ -72,27 +72,21 @@ export function handleMarketplaceError(error: unknown, action: string): never {
|
||||
|
||||
function printValidationResult(result: ValidationResult): void {
|
||||
if (result.errors.length > 0) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(
|
||||
`${figures.cross} Found ${result.errors.length} ${plural(result.errors.length, 'error')}:\n`,
|
||||
)
|
||||
result.errors.forEach(error => {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` ${figures.pointer} ${error.path}: ${error.message}`)
|
||||
})
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log('')
|
||||
}
|
||||
if (result.warnings.length > 0) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(
|
||||
`${figures.warning} Found ${result.warnings.length} ${plural(result.warnings.length, 'warning')}:\n`,
|
||||
)
|
||||
result.warnings.forEach(warning => {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` ${figures.pointer} ${warning.path}: ${warning.message}`)
|
||||
})
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log('')
|
||||
}
|
||||
}
|
||||
@@ -106,7 +100,6 @@ export async function pluginValidateHandler(
|
||||
try {
|
||||
const result = await validateManifest(manifestPath)
|
||||
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`Validating ${result.fileType} manifest: ${result.filePath}\n`)
|
||||
printValidationResult(result)
|
||||
|
||||
@@ -120,7 +113,6 @@ export async function pluginValidateHandler(
|
||||
if (basename(manifestDir) === '.claude-plugin') {
|
||||
contentResults = await validatePluginContents(dirname(manifestDir))
|
||||
for (const r of contentResults) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`Validating ${r.fileType}: ${r.filePath}\n`)
|
||||
printValidationResult(r)
|
||||
}
|
||||
@@ -139,13 +131,11 @@ export async function pluginValidateHandler(
|
||||
: `${figures.tick} Validation passed`,
|
||||
)
|
||||
} else {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`${figures.cross} Validation failed`)
|
||||
process.exit(1)
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.error(
|
||||
`${figures.cross} Unexpected error during validation: ${errorMessage(error)}`,
|
||||
)
|
||||
@@ -358,7 +348,6 @@ export async function pluginListHandler(options: {
|
||||
}
|
||||
|
||||
if (pluginIds.length > 0) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log('Installed plugins:\n')
|
||||
}
|
||||
|
||||
@@ -383,25 +372,18 @@ export async function pluginListHandler(options: {
|
||||
const version = installation.version || 'unknown'
|
||||
const scope = installation.scope
|
||||
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` ${figures.pointer} ${pluginId}`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Version: ${version}`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Scope: ${scope}`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Status: ${status}`)
|
||||
for (const error of pluginErrors) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Error: ${getPluginErrorMessage(error)}`)
|
||||
}
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log('')
|
||||
}
|
||||
}
|
||||
|
||||
if (inlinePlugins.length > 0 || inlineLoadErrors.length > 0) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log('Session-only plugins (--plugin-dir):\n')
|
||||
for (const p of inlinePlugins) {
|
||||
// Same dirName≠manifestName fallback as the JSON path above — error
|
||||
@@ -413,19 +395,13 @@ export async function pluginListHandler(options: {
|
||||
pErrors.length > 0
|
||||
? `${figures.cross} loaded with errors`
|
||||
: `${figures.tick} loaded`
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` ${figures.pointer} ${p.source}`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Version: ${p.manifest.version ?? 'unknown'}`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Path: ${p.path}`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Status: ${status}`)
|
||||
for (const e of pErrors) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Error: ${getPluginErrorMessage(e)}`)
|
||||
}
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log('')
|
||||
}
|
||||
// Path-level failures: no LoadedPlugin object exists. Show them so
|
||||
@@ -433,7 +409,6 @@ export async function pluginListHandler(options: {
|
||||
for (const e of inlineLoadErrors.filter(e =>
|
||||
e.source.startsWith('inline['),
|
||||
)) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(
|
||||
` ${figures.pointer} ${e.source}: ${figures.cross} ${getPluginErrorMessage(e)}\n`,
|
||||
)
|
||||
@@ -489,12 +464,10 @@ export async function marketplaceAddHandler(
|
||||
}
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log('Adding marketplace...')
|
||||
|
||||
const { name, alreadyMaterialized, resolvedSource } =
|
||||
await addMarketplaceSource(marketplaceSource, message => {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(message)
|
||||
})
|
||||
|
||||
@@ -555,33 +528,25 @@ export async function marketplaceListHandler(options: {
|
||||
cliOk('No marketplaces configured')
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log('Configured marketplaces:\n')
|
||||
names.forEach(name => {
|
||||
const marketplace = config[name]
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` ${figures.pointer} ${name}`)
|
||||
|
||||
if (marketplace?.source) {
|
||||
const src = marketplace.source
|
||||
if (src.source === 'github') {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Source: GitHub (${src.repo})`)
|
||||
} else if (src.source === 'git') {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Source: Git (${src.url})`)
|
||||
} else if (src.source === 'url') {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Source: URL (${src.url})`)
|
||||
} else if (src.source === 'directory') {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Source: Directory (${src.path})`)
|
||||
} else if (src.source === 'file') {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Source: File (${src.path})`)
|
||||
}
|
||||
}
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log('')
|
||||
})
|
||||
|
||||
@@ -620,11 +585,9 @@ export async function marketplaceUpdateHandler(
|
||||
if (options.cowork) setUseCoworkPlugins(true)
|
||||
try {
|
||||
if (name) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`Updating marketplace: ${name}...`)
|
||||
|
||||
await refreshMarketplace(name, message => {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(message)
|
||||
})
|
||||
|
||||
@@ -644,7 +607,6 @@ export async function marketplaceUpdateHandler(
|
||||
cliOk('No marketplaces configured')
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`Updating ${marketplaceNames.length} marketplace(s)...`)
|
||||
|
||||
await refreshAllMarketplaces()
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type ask = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type installOAuthTokens = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type RemoteIO = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type StructuredIO = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type collectContextData = any;
|
||||
@@ -1,14 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type SDKStatus = any;
|
||||
export type ModelInfo = any;
|
||||
export type SDKMessage = any;
|
||||
export type SDKUserMessage = any;
|
||||
export type SDKUserMessageReplay = any;
|
||||
export type PermissionResult = any;
|
||||
export type McpServerConfigForProcessTransport = any;
|
||||
export type McpServerStatus = any;
|
||||
export type RewindFilesResult = any;
|
||||
export type HookEvent = any;
|
||||
export type HookInput = any;
|
||||
export type HookJSONOutput = any;
|
||||
export type PermissionUpdate = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type SDKControlElicitationResponseSchema = any;
|
||||
@@ -1,9 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type StdoutMessage = any;
|
||||
export type SDKControlInitializeRequest = any;
|
||||
export type SDKControlInitializeResponse = any;
|
||||
export type SDKControlRequest = any;
|
||||
export type SDKControlResponse = any;
|
||||
export type SDKControlMcpSetServersResponse = any;
|
||||
export type SDKControlReloadPluginsResponse = any;
|
||||
export type StdinMessage = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type CanUseToolFn = any;
|
||||
@@ -1,5 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type tryGenerateSuggestion = any;
|
||||
export type logSuggestionOutcome = any;
|
||||
export type logSuggestionSuppressed = any;
|
||||
export type PromptVariant = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getFeatureValue_CACHED_MAY_BE_STALE = any;
|
||||
@@ -1,3 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type logEvent = any;
|
||||
export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = any;
|
||||
@@ -1,3 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type isQualifiedForGrove = any;
|
||||
export type checkGroveForNonInteractive = any;
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type EMPTY_USAGE = any;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user