mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 21:05:51 +00:00
Compare commits
223 Commits
feature/do
...
feature/pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2006ab25ff | ||
|
|
0707284939 | ||
|
|
84f12f34bd | ||
|
|
2f86485d9c | ||
|
|
547ce9e848 | ||
|
|
2cf18c4c49 | ||
|
|
bd2253846f | ||
|
|
af0d7dc851 | ||
|
|
3ac866be98 | ||
|
|
c14b7eadd2 | ||
|
|
8c157f0767 | ||
|
|
4fc95bd5a7 | ||
|
|
7be08f53bd | ||
|
|
02dd796706 | ||
|
|
8ba51edec1 | ||
|
|
73e54d4bbc | ||
|
|
2fdfb844cb | ||
|
|
4230f0fff1 | ||
|
|
7fe448d9e9 | ||
|
|
aa06cea904 | ||
|
|
c43efecbab | ||
|
|
cb4a6e76cf | ||
|
|
f7f69b759c | ||
|
|
771e3dbcf0 | ||
|
|
e3c0699f5b | ||
|
|
e8759f3402 | ||
|
|
958ac3a0d5 | ||
|
|
5895362178 | ||
|
|
8cfe9b6dc3 | ||
|
|
12f5aedf99 | ||
|
|
c7efac6b8d | ||
|
|
2f150d3ecd | ||
|
|
68c7ebb242 | ||
|
|
9e299a7208 | ||
|
|
941bcbd240 | ||
|
|
fd66ddc45f | ||
|
|
5c107e5f8c | ||
|
|
c4e9efb7a8 | ||
|
|
26ddbda849 | ||
|
|
872ee280e3 | ||
|
|
f5c9880d7d | ||
|
|
3f1c8468bf | ||
|
|
100e9d2da0 | ||
|
|
0ad6349434 | ||
|
|
1ac18aec0d | ||
|
|
fcbc882232 | ||
|
|
a1108870e3 | ||
|
|
87b96199f9 | ||
|
|
18d6656a6a | ||
|
|
d0915fc880 | ||
|
|
cf2bf29dcd | ||
|
|
75952bde9c | ||
|
|
e7220c530f | ||
|
|
6ff839d625 | ||
|
|
88057b10d4 | ||
|
|
4d0048a60a | ||
|
|
8a5ef8c9cb | ||
|
|
f8a289b868 | ||
|
|
45c892fc18 | ||
|
|
5b333e2246 | ||
|
|
5e215bb061 | ||
|
|
b28de717dd | ||
|
|
5c1be19511 | ||
|
|
5dc4d8f8a2 | ||
|
|
2545dcabfd | ||
|
|
40fbc4afc4 | ||
|
|
d3eebfed15 | ||
|
|
6becb8b2d4 | ||
|
|
3a2b6dde7c | ||
|
|
4ca7a4895a | ||
|
|
ba74e0976c | ||
|
|
86df024e75 | ||
|
|
c3af45023d | ||
|
|
2847cab787 | ||
|
|
198c09b263 | ||
|
|
4cbf406c70 | ||
|
|
f72b867aa6 | ||
|
|
0290fe3227 | ||
|
|
1b10ea391a | ||
|
|
f724300079 | ||
|
|
3eba5ade1a | ||
|
|
385baf5737 | ||
|
|
0977b0520e | ||
|
|
96f1700e55 | ||
|
|
ef10ad2839 | ||
|
|
f484fc34c8 | ||
|
|
ab0bbbc4b5 | ||
|
|
a81995052f | ||
|
|
ff2074c798 | ||
|
|
491c16da25 | ||
|
|
9ea9859dce | ||
|
|
c32f26cf21 | ||
|
|
6182015005 | ||
|
|
d136872cc9 | ||
|
|
465c95ae53 | ||
|
|
42100d6268 | ||
|
|
ca29e4e8f7 | ||
|
|
cd8136f4b1 | ||
|
|
71c89e9de4 | ||
|
|
632f3e199e | ||
|
|
282d515043 | ||
|
|
00da5d7d1a | ||
|
|
08cd02cd37 | ||
|
|
7effbca8db | ||
|
|
edae3a7d37 | ||
|
|
7a6e65caf7 | ||
|
|
6b7cfda9b1 | ||
|
|
f8388e44ed | ||
|
|
189766c5af | ||
|
|
452a7e6a15 | ||
|
|
29a1edbf46 | ||
|
|
f2e9af4927 | ||
|
|
4f1649e249 | ||
|
|
a2cfaf9111 | ||
|
|
9e365f1ffa | ||
|
|
51b8ad46bf | ||
|
|
2bad8df5d7 | ||
|
|
327658979a | ||
|
|
7e61e71c54 | ||
|
|
4b97e6638e | ||
|
|
b8b48bf7ed | ||
|
|
de9dbcdcbb | ||
|
|
0a9e6c0313 | ||
|
|
73130bded3 | ||
|
|
1a1d57057e | ||
|
|
7f864a4743 | ||
|
|
c81dac8c3c | ||
|
|
4266149820 | ||
|
|
7cc1785fc0 | ||
|
|
c80e593212 | ||
|
|
b47731a3f3 | ||
|
|
a65df4a102 | ||
|
|
52b61c2c06 | ||
|
|
3cb4828de6 | ||
|
|
f5c3ee5b5d | ||
|
|
c2ac9a74c1 | ||
|
|
fc438bd222 | ||
|
|
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 | ||
|
|
956e98a445 | ||
|
|
cee62bc654 | ||
|
|
5fc7c8e13d | ||
|
|
300faa18d0 | ||
|
|
96ec96c720 | ||
|
|
13a0bfc479 | ||
|
|
84f0271813 | ||
|
|
ed4bdb9338 | ||
|
|
e4ce08fe39 | ||
|
|
92f8a92fbb |
@@ -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
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
31
.github/workflows/ci.yml
vendored
31
.github/workflows/ci.yml
vendored
@@ -6,32 +6,51 @@ on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2, 2026-04-25
|
||||
env:
|
||||
GIT_CONFIG_COUNT: 2
|
||||
GIT_CONFIG_KEY_0: init.defaultBranch
|
||||
GIT_CONFIG_VALUE_0: main
|
||||
GIT_CONFIG_KEY_1: advice.defaultBranchName
|
||||
GIT_CONFIG_VALUE_1: "false"
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2, 2026-04-25
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
CLAUDE_CODE_SKIP_CHROME_MCP_SETUP: "1"
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Lint and format check
|
||||
run: bunx biome ci .
|
||||
|
||||
- name: Type check
|
||||
run: bunx tsc --noEmit
|
||||
run: bun run typecheck
|
||||
|
||||
- name: Test with Coverage
|
||||
run: |
|
||||
set -o pipefail
|
||||
bun test --coverage --coverage-reporter=lcov 2>&1 | grep -vE '^\s*(\(pass\)|\(skip\))' | sed '/^.*\/__tests__\/.*:$/d' | cat -s
|
||||
bun test --coverage --coverage-reporter lcov --coverage-dir coverage 2>&1 | grep -vE '^\s*(\(pass\)|\(skip\))' | sed '/^.*\/__tests__\/.*:$/d' | cat -s
|
||||
test -s coverage/lcov.info
|
||||
grep -q '^SF:' coverage/lcov.info
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5, 2026-04-25
|
||||
with:
|
||||
file: ./coverage/lcov.info
|
||||
fail_ci_if_error: true
|
||||
files: ./coverage/lcov.info
|
||||
disable_search: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
- name: Build
|
||||
|
||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2, 2026-04-25
|
||||
with:
|
||||
ref: ${{ github.event.inputs.version || github.ref }}
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6, 2026-04-25
|
||||
with:
|
||||
node-version: "24"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2, 2026-04-25
|
||||
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@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2, 2026-04-25
|
||||
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') }}
|
||||
8
.github/workflows/release-rcs.yml
vendored
8
.github/workflows/release-rcs.yml
vendored
@@ -17,17 +17,17 @@ jobs:
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2, 2026-04-25
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3, 2026-04-25
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3, 2026-04-25
|
||||
|
||||
- name: Extract version
|
||||
id: version
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5, 2026-04-25
|
||||
with:
|
||||
context: .
|
||||
file: packages/remote-control-server/Dockerfile
|
||||
|
||||
11
.github/workflows/update-contributors.yml
vendored
11
.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
|
||||
@@ -14,17 +11,17 @@ jobs:
|
||||
update:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2, 2026-04-25
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: jaywcjlove/github-action-contributors@main
|
||||
- uses: jaywcjlove/github-action-contributors@86707f6d4c2469ce6b46bc3367253ebd41ee242c # main, 2026-04-25
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
output: "contributors.svg"
|
||||
repository: ${{ github.repository }}
|
||||
|
||||
- uses: stefanzweifel/git-auto-commit-action@v5
|
||||
- uses: stefanzweifel/git-auto-commit-action@b863ae1933cb653a53c021fe36dbb774e1fb9403 # v5, 2026-04-25
|
||||
with:
|
||||
commit_message: "docs: update contributors"
|
||||
file_pattern: "contributors.svg"
|
||||
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -5,7 +5,8 @@ coverage
|
||||
.env
|
||||
*.log
|
||||
.idea
|
||||
.vscode
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
*.suo
|
||||
*.lock
|
||||
src/utils/vendor/
|
||||
@@ -19,6 +20,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 +44,5 @@ data
|
||||
.codex/skills/.system/**
|
||||
!.codex/prompts/
|
||||
!.codex/prompts/**
|
||||
teach-me
|
||||
credentials.json
|
||||
|
||||
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
||||
npx lint-staged
|
||||
1
.tool-versions
Normal file
1
.tool-versions
Normal file
@@ -0,0 +1 @@
|
||||
bun 1.3.13
|
||||
8
.vscode/extensions.json
vendored
Normal file
8
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"biomejs.biome",
|
||||
"ms-typescript.typescript",
|
||||
"oven.bun-vscode",
|
||||
"editorconfig.editorconfig"
|
||||
]
|
||||
}
|
||||
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 产品常见的设计套路(渐变文字、玻璃态、霓虹色)。
|
||||
126
CLAUDE.md
126
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 — **`bun run precheck` 必须零错误通过**(包含 typecheck + lint fix + test)。
|
||||
|
||||
## Git Commit Message Convention
|
||||
|
||||
@@ -43,14 +43,16 @@ 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
|
||||
bun run lint:fix # auto-fix
|
||||
bun run format # format all src/
|
||||
# Lint & Format (Biome) — 日常开发用 precheck 代替单独调用
|
||||
bun run lint # lint check (全项目)
|
||||
bun run lint:fix # auto-fix lint issues
|
||||
bun run format # format all (全项目)
|
||||
bun run check # lint + format check (全项目)
|
||||
bun run check:fix # lint + format auto-fix
|
||||
|
||||
# Health check
|
||||
bun run health
|
||||
@@ -58,7 +60,8 @@ bun run health
|
||||
# Check unused exports
|
||||
bun run check:unused
|
||||
|
||||
bun run typecheck
|
||||
# Full check (typecheck + lint fix + test) — 任务完成后必须运行
|
||||
bun run precheck
|
||||
|
||||
# Remote Control Server
|
||||
bun run rcs
|
||||
@@ -74,17 +77,21 @@ 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:*`。
|
||||
- **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`(自动更新贡献者)。
|
||||
- **Monorepo**: Bun workspaces — 17 个 workspace packages + 若干辅助目录 in `packages/` resolved via `workspace:*`。
|
||||
- **Lint/Format**: Biome (`biome.json`)。覆盖 `src/`、`scripts/`、`packages/` 全项目(含 `packages/@ant/`)。`bun run lint` / `bun run lint:fix` / `bun run format` / `bun run check` / `bun run check:fix`。42 条规则因 decompiled 代码被关闭,仅保留 `recommended` 基线。
|
||||
- **Pre-commit**: husky + lint-staged。提交时自动对暂存文件执行 `biome check --fix`(TS/JS)和 `biome format --write`(JSON)。
|
||||
- **CI Lint**: `ci.yml` 在依赖安装后、类型检查前执行 `bunx biome ci .`,lint 或格式化不达标则 CI 失败。
|
||||
- **Defines**: 集中管理在 `scripts/defines.ts`。当前版本 `2.2.1`。
|
||||
- **CI**: GitHub Actions — `ci.yml`(lint + 构建 + 测试)、`release-rcs.yml`(RCS 发布)、`update-contributors.yml`(自动更新贡献者)。
|
||||
|
||||
### 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`
|
||||
@@ -97,7 +104,7 @@ bun run docs:dev
|
||||
- `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 模式分发。
|
||||
2. **`src/main.tsx`** (~5674 行) — 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
|
||||
@@ -115,15 +122,19 @@ 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`.
|
||||
- **`packages/builtin-tools/src/tools/`** — 59 个子目录(含 shared/testing 等工具目录),通过 `@claude-code-best/builtin-tools` 包导出。主要分类:
|
||||
- **`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`.
|
||||
- **`src/constants/tools.ts`** — `CORE_TOOLS` 白名单常量(38 个核心工具名),用于 `isDeferredTool` 白名单制判定。
|
||||
- **`packages/builtin-tools/src/tools/`** — 60 个工具目录(含 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
|
||||
- **工具发现**: SearchExtraToolsTool, ExecuteExtraTool, SyntheticOutput(CORE_TOOLS,用于延迟工具按需加载)
|
||||
- **其他**: LSPTool, ConfigTool, SkillTool, EnterWorktreeTool, ExitWorktreeTool 等
|
||||
- **`src/tools/shared/`** / **`packages/builtin-tools/src/tools/shared/`** — Tool 共享工具函数。
|
||||
- **`src/services/searchExtraTools/`** — TF-IDF 工具索引模块(`toolIndex.ts`),为延迟工具提供语义搜索能力。复用 `localSearch.ts` 的 TF-IDF 算法函数(`computeWeightedTf`、`computeIdf`、`cosineSimilarity` 已导出)。修改这些函数时需同步检查工具索引测试。`prefetch.ts` 的 `extractQueryFromMessages` 复用了 `skillSearch/prefetch.ts` 的同名导出函数,修改 skill prefetch 的该函数时需同步检查工具预取行为。工具预取使用独立的 `discoveredToolsThisSession` Set,与 skill prefetch 的去重集合互不影响。
|
||||
|
||||
### UI Layer (Ink)
|
||||
|
||||
@@ -158,22 +169,20 @@ bun run docs:dev
|
||||
| `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/` | 键盘修饰键检测(stub) |
|
||||
| `packages/url-handler-napi/` | URL scheme 处理(stub) |
|
||||
| `packages/modifiers-napi/` | 键盘修饰键检测(macOS FFI 实现) |
|
||||
| `packages/url-handler-napi/` | URL scheme 处理(环境变量 + CLI 参数读取) |
|
||||
| `packages/weixin/` | 微信集成(非 workspace 包) |
|
||||
|
||||
辅助目录(无 package.json,非 workspace 包): `langfuse-dashboard`(Langfuse 面板)、`shared-web-ui`(共享 Web UI 组件)、`highlight-code`(代码高亮)、`claude-pencil`(编辑器)、`vscode-ide-bridge`(VS Code 桥接)、`pokemon`(示例/测试)。
|
||||
|
||||
### 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`。
|
||||
@@ -200,12 +209,18 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
|
||||
|
||||
**启用方式**: 环境变量 `FEATURE_<FLAG_NAME>=1`。例如 `FEATURE_BUDDY=1 bun run dev`。
|
||||
|
||||
**Build 默认 features**(19 个,见 `build.ts`):
|
||||
**Build 默认 features**(65+ 个,见 `build.ts` 中 `DEFAULT_BUILD_FEATURES`):
|
||||
- 基础: `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`
|
||||
- P2: `DAEMON`, `ACP`
|
||||
- 工作流: `WORKFLOW_SCRIPTS`, `HISTORY_SNIP`, `MONITOR_TOOL`, `KAIROS`
|
||||
- 多 worker: `COORDINATOR_MODE`, `BG_SESSIONS`, `TEMPLATES`
|
||||
- 连接器: `CONNECTOR_TEXT`, `COMMIT_ATTRIBUTION`, `DIRECT_CONNECT`
|
||||
- 实验性: `EXPERIMENTAL_SKILL_SEARCH`, `EXPERIMENTAL_SEARCH_EXTRA_TOOLS`
|
||||
- 模式: `POOR`, `SSH_REMOTE`
|
||||
- 已禁用: `CONTEXT_COLLAPSE`, `FORK_SUBAGENT`, `UDS_INBOX`, `LAN_PIPES`, `REVIEW_ARTIFACT`, `TEAMMEM`, `SKILL_LEARNING`
|
||||
|
||||
**Dev mode 默认**: 全部启用(见 `scripts/dev.ts`)。
|
||||
|
||||
@@ -215,7 +230,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)
|
||||
|
||||
@@ -228,13 +266,14 @@ 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 |
|
||||
| `packages/shell/`, `packages/swarm/`, `packages/mcp-server/`, `packages/cc-knowledge/` | Removed — 功能合并或废弃 |
|
||||
| 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
|
||||
@@ -247,9 +286,8 @@ 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)
|
||||
- **集成测试**: `tests/integration/` — 6 个文件(cli-arguments, context-build, message-pipeline, tool-chain, autonomy-lifecycle-user-flow, dependency-overrides)
|
||||
- **共享 mock/fixture**: `tests/mocks/`(api-responses, file-system, fixtures/)
|
||||
- **命名**: `describe("functionName")` + `test("behavior description")`,英文
|
||||
- **包测试**: `packages/` 下各包也有独立测试(如 `color-diff-napi` 11 tests)
|
||||
@@ -260,6 +298,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 同一模块。
|
||||
@@ -269,7 +319,7 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
|
||||
项目使用 TypeScript strict 模式,**tsc 必须零错误**。每次修改后运行:
|
||||
|
||||
```bash
|
||||
bun run typecheck # equivalent to bun run typecheck
|
||||
bun run precheck
|
||||
```
|
||||
|
||||
**类型规范**:
|
||||
@@ -282,14 +332,16 @@ bun run typecheck # equivalent to bun run typecheck
|
||||
|
||||
## Working with This Codebase
|
||||
|
||||
- **tsc must pass** — `bun run typecheck` 必须零错误,任何修改都不能引入新的类型错误。
|
||||
- **precheck must pass** — `bun run precheck`(typecheck + lint fix + test)必须零错误,任何修改都不能引入新的类型/lint/测试错误。
|
||||
- **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 行宽 + 按需分号。
|
||||
- **Biome 配置** — 42 条 lint 规则因 decompiled 代码被关闭,仅保留 `recommended` 基线。格式化覆盖全项目(`src/`、`scripts/`、`packages/`,含 `packages/@ant/`)。`.tsx` 文件用 120 行宽 + 强制分号;其他文件 80 行宽 + 按需分号。JSON 格式化已启用。`.editorconfig` 与 Biome 配置对齐(2-space 缩进)。修改任何代码后应运行 `bun run precheck` 确认无类型/lint/格式/测试问题,pre-commit hook 会自动拦截不合格提交。
|
||||
- **tsc 与 Biome 冲突处理** — 当 tsc 要求声明属性(赋值使用)但 biome 报 `noUnusedPrivateClassMembers`(只写不读)时,用 `// biome-ignore lint/correctness/noUnusedPrivateClassMembers: <原因>` 抑制 lint 警告,保留类型声明。`biome ci` 必须零 warnings。
|
||||
- **`@ts-expect-error` 维护** — 只在下方代码确实有类型错误时保留 `@ts-expect-error`。如果类型系统已更新导致 directive 变为 unused(TS2578),直接移除注释。MACRO 替换产生的永假比较(如 `'production' === 'development'`)仍需保留 `@ts-expect-error`。
|
||||
- **Ink 框架在 `packages/@ant/ink/`** — 不是 `src/ink/`(该目录不存在)。Ink 相关的组件、hooks、keybindings 都在 packages 中。
|
||||
- **Provider 优先级** — `modelType` 参数 > 环境变量 > 默认 `firstParty`。新增 provider 需在 `src/utils/model/providers.ts` 注册。
|
||||
|
||||
|
||||
125
README.md
125
README.md
@@ -12,30 +12,32 @@
|
||||
|
||||
牢 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) |
|
||||
| **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) |
|
||||
| Computer Use | 屏幕截图、键鼠控制 | [文档](https://ccb.agent-aura.top/docs/features/computer-use) |
|
||||
| Chrome Use | 浏览器自动化、表单填写、数据抓取 | [自托管](https://ccb.agent-aura.top/docs/features/chrome-use-mcp) [原生版](https://ccb.agent-aura.top/docs/features/claude-in-chrome-mcp) |
|
||||
| Sentry | 企业级错误追踪 | [文档](https://ccb.agent-aura.top/docs/internals/sentry-setup) |
|
||||
| GrowthBook | 企业级特性开关 | [文档](https://ccb.agent-aura.top/docs/internals/growthbook-adapter) |
|
||||
| /dream 记忆整理 | 自动整理和优化记忆文件 | [文档](https://ccb.agent-aura.top/docs/features/auto-dream) |
|
||||
|
||||
- 🚀 [想要启动项目](#快速开始源码版)
|
||||
| 特性 | 说明 | 文档 |
|
||||
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **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 兼容 (`/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) |
|
||||
| GrowthBook | 企业级特性开关 | [文档](https://ccb.agent-aura.top/docs/internals/growthbook-adapter) |
|
||||
| /dream 记忆整理 | 自动整理和优化记忆文件 | [文档](https://ccb.agent-aura.top/docs/features/auto-dream) |
|
||||
|
||||
- 🚀 [想要启动项目](#-快速开始源码版)
|
||||
- 🐛 [想要调试项目](#vs-code-调试)
|
||||
- 📖 [想要学习项目](#teach-me-学习项目)
|
||||
|
||||
|
||||
## ⚡ 快速开始(安装版)
|
||||
|
||||
不用克隆仓库, 从 NPM 下载后, 直接使用
|
||||
@@ -45,13 +47,16 @@ npm i -g claude-code-best
|
||||
|
||||
# bun 安装比较多问题, 推荐 npm 装
|
||||
# bun i -g claude-code-best
|
||||
# bun pm -g trust claude-code-best
|
||||
# bun pm -g trust claude-code-best @claude-code-best/mcp-chrome-bridge
|
||||
|
||||
ccb # 以 nodejs 打开 claude code
|
||||
ccb-bun # 以 bun 形态打开
|
||||
ccb update # 更新到最新版本
|
||||
CLAUDE_BRIDGE_BASE_URL=https://remote-control.claude-code-best.win/ CLAUDE_BRIDGE_OAUTH_TOKEN=test-my-key ccb --remote-control # 我们有自部署的远程控制
|
||||
```
|
||||
|
||||
> **安装/更新失败?** 先 `npm rm -g claude-code-best` 清理旧版本,再 `npm i -g claude-code-best@latest`。仍失败则指定版本号:`npm i -g claude-code-best@<版本号>`
|
||||
|
||||
## ⚡ 快速开始(源码版)
|
||||
|
||||
### ⚙️ 环境要求
|
||||
@@ -59,11 +64,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
|
||||
```
|
||||
|
||||
@@ -90,17 +150,17 @@ bun run build
|
||||
|
||||
需要填写的字段:
|
||||
|
||||
| 📌 字段 | 📝 说明 | 💡 示例 |
|
||||
|------|------|------|
|
||||
| Base URL | API 服务地址 | `https://api.example.com/v1` |
|
||||
| API Key | 认证密钥 | `sk-xxx` |
|
||||
| Haiku Model | 快速模型 ID | `claude-haiku-4-5-20251001` |
|
||||
| Sonnet Model | 均衡模型 ID | `claude-sonnet-4-6` |
|
||||
| Opus Model | 高性能模型 ID | `claude-opus-4-6` |
|
||||
|
||||
| 📌 字段 | 📝 说明 | 💡 示例 |
|
||||
| ------------ | ------------- | ---------------------------- |
|
||||
| Base URL | API 服务地址 | `https://api.example.com/v1` |
|
||||
| API Key | 认证密钥 | `sk-xxx` |
|
||||
| Haiku Model | 快速模型 ID | `claude-haiku-4-5-20251001` |
|
||||
| Sonnet Model | 均衡模型 ID | `claude-sonnet-4-6` |
|
||||
| Opus Model | 高性能模型 ID | `claude-opus-4-6` |
|
||||
|
||||
- ⌨️ **Tab / Shift+Tab** 切换字段,**Enter** 确认并跳到下一个,最后一个字段按 Enter 保存
|
||||
|
||||
|
||||
> ℹ️ 支持所有 Anthropic API 兼容服务(如 OpenRouter、AWS Bedrock 代理等),只要接口兼容 Messages API 即可。
|
||||
|
||||
## Feature Flags
|
||||
@@ -120,16 +180,17 @@ TUI (REPL) 模式需要真实终端,无法直接通过 VS Code launch 启动
|
||||
### 步骤
|
||||
|
||||
1. **终端启动 inspect 服务**:
|
||||
|
||||
```bash
|
||||
bun run dev:inspect
|
||||
```
|
||||
会输出类似 `ws://localhost:8888/xxxxxxxx` 的地址。
|
||||
|
||||
会输出类似 `ws://localhost:8888/xxxxxxxx` 的地址。
|
||||
2. **VS Code 附着调试器**:
|
||||
|
||||
- 在 `src/` 文件中打断点
|
||||
- F5 → 选择 **"Attach to Bun (TUI debug)"**
|
||||
|
||||
|
||||
## Teach Me 学习项目
|
||||
|
||||
我们新加了一个 teach-me skills, 通过问答式引导帮你理解这个项目的任何模块。(调整 [sigma skill 而来](https://github.com/sanyuan0704/sanyuan-skills))
|
||||
@@ -156,7 +217,7 @@ TUI (REPL) 模式需要真实终端,无法直接通过 VS Code launch 启动
|
||||
## 相关文档及网站
|
||||
|
||||
- **在线文档(Mintlify)**: [ccb.agent-aura.top](https://ccb.agent-aura.top/) — 文档源码位于 [`docs/`](docs/) 目录,欢迎投稿 PR
|
||||
- **DeepWiki**: <https://deepwiki.com/claude-code-best/claude-code>
|
||||
- **DeepWiki**: [https://deepwiki.com/claude-code-best/claude-code](https://deepwiki.com/claude-code-best/claude-code)
|
||||
|
||||
## Contributors
|
||||
|
||||
@@ -174,6 +235,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
|
||||
|
||||
|
||||
223
biome.json
223
biome.json
@@ -1,114 +1,113 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.10/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"includes": ["**", "!!**/dist", "!!**/packages/@ant"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 80
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off",
|
||||
"noAssignInExpressions": "off",
|
||||
"noDoubleEquals": "off",
|
||||
"noRedeclare": "off",
|
||||
"noImplicitAnyLet": "off",
|
||||
"noGlobalIsNan": "off",
|
||||
"noFallthroughSwitchClause": "off",
|
||||
"noShadowRestrictedNames": "off",
|
||||
"noArrayIndexKey": "off",
|
||||
"noConsole": "off",
|
||||
"noConfusingLabels": "off",
|
||||
"useIterableCallbackReturn": "off"
|
||||
},
|
||||
"style": {
|
||||
"useConst": "off",
|
||||
"noNonNullAssertion": "off",
|
||||
"noParameterAssign": "off",
|
||||
"useDefaultParameterLast": "off",
|
||||
"noUnusedTemplateLiteral": "off",
|
||||
"useTemplate": "off",
|
||||
"useNumberNamespace": "off",
|
||||
"useNodejsImportProtocol": "off",
|
||||
"useImportType": "off"
|
||||
},
|
||||
"complexity": {
|
||||
"noForEach": "off",
|
||||
"noBannedTypes": "off",
|
||||
"noUselessConstructor": "off",
|
||||
"noStaticOnlyClass": "off",
|
||||
"useOptionalChain": "off",
|
||||
"noUselessSwitchCase": "off",
|
||||
"noUselessFragments": "off",
|
||||
"noUselessTernary": "off",
|
||||
"noUselessLoneBlockStatements": "off",
|
||||
"noUselessEmptyExport": "off",
|
||||
"useArrowFunction": "off",
|
||||
"useLiteralKeys": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"noUnusedVariables": "off",
|
||||
"noUnusedImports": "off",
|
||||
"useExhaustiveDependencies": "off",
|
||||
"noSwitchDeclarations": "off",
|
||||
"noUnreachable": "off",
|
||||
"useHookAtTopLevel": "off",
|
||||
"noVoidTypeReturn": "off",
|
||||
"noConstantCondition": "off",
|
||||
"noUnusedFunctionParameters": "off"
|
||||
},
|
||||
"a11y": {
|
||||
"recommended": false
|
||||
},
|
||||
"nursery": {
|
||||
"recommended": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"json": {
|
||||
"formatter": {
|
||||
"enabled": false
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "single",
|
||||
"semicolons": "asNeeded",
|
||||
"arrowParentheses": "asNeeded",
|
||||
"trailingCommas": "all"
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["**/*.tsx"],
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"semicolons": "always"
|
||||
}
|
||||
},
|
||||
"formatter": {
|
||||
"lineWidth": 120
|
||||
}
|
||||
},
|
||||
{
|
||||
"includes": ["scripts/**", "packages/**", "**/*.js", "**/*.mjs", "**/*.jsx"],
|
||||
"formatter": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"assist": {
|
||||
"enabled": false
|
||||
}
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.12/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"includes": ["**", "!!**/dist"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 80
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off",
|
||||
"noAssignInExpressions": "off",
|
||||
"noDoubleEquals": "off",
|
||||
"noRedeclare": "off",
|
||||
"noImplicitAnyLet": "off",
|
||||
"noGlobalIsNan": "off",
|
||||
"noFallthroughSwitchClause": "off",
|
||||
"noShadowRestrictedNames": "off",
|
||||
"noArrayIndexKey": "off",
|
||||
"noConsole": "off",
|
||||
"noConfusingLabels": "off",
|
||||
"useIterableCallbackReturn": "off"
|
||||
},
|
||||
"style": {
|
||||
"useConst": "off",
|
||||
"noNonNullAssertion": "off",
|
||||
"noParameterAssign": "off",
|
||||
"useDefaultParameterLast": "off",
|
||||
"noUnusedTemplateLiteral": "off",
|
||||
"useTemplate": "off",
|
||||
"useNumberNamespace": "off",
|
||||
"useNodejsImportProtocol": "off",
|
||||
"useImportType": "off"
|
||||
},
|
||||
"complexity": {
|
||||
"noForEach": "off",
|
||||
"noBannedTypes": "off",
|
||||
"noUselessConstructor": "off",
|
||||
"noStaticOnlyClass": "off",
|
||||
"useOptionalChain": "off",
|
||||
"noUselessSwitchCase": "off",
|
||||
"noUselessFragments": "off",
|
||||
"noUselessTernary": "off",
|
||||
"noUselessLoneBlockStatements": "off",
|
||||
"noUselessEmptyExport": "off",
|
||||
"useArrowFunction": "off",
|
||||
"useLiteralKeys": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"noUnusedVariables": "off",
|
||||
"noUnusedImports": "off",
|
||||
"useExhaustiveDependencies": "off",
|
||||
"noSwitchDeclarations": "off",
|
||||
"noUnreachable": "off",
|
||||
"useHookAtTopLevel": "off",
|
||||
"noVoidTypeReturn": "off",
|
||||
"noConstantCondition": "off",
|
||||
"noUnusedFunctionParameters": "off"
|
||||
},
|
||||
"a11y": {
|
||||
"recommended": false
|
||||
},
|
||||
"nursery": {
|
||||
"recommended": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"json": {
|
||||
"formatter": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
"tailwindDirectives": true
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "single",
|
||||
"semicolons": "asNeeded",
|
||||
"arrowParentheses": "asNeeded",
|
||||
"trailingCommas": "all"
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["**/*.tsx"],
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"semicolons": "always"
|
||||
}
|
||||
},
|
||||
"formatter": {
|
||||
"lineWidth": 120
|
||||
}
|
||||
}
|
||||
],
|
||||
"assist": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
|
||||
67
build.ts
67
build.ts
@@ -1,6 +1,7 @@
|
||||
import { readdir, readFile, writeFile, cp } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { getMacroDefines } from './scripts/defines.ts'
|
||||
import { DEFAULT_BUILD_FEATURES } from './scripts/defines.ts'
|
||||
|
||||
const outdir = 'dist'
|
||||
|
||||
@@ -8,48 +9,6 @@ const outdir = 'dist'
|
||||
const { rmSync } = await import('fs')
|
||||
rmSync(outdir, { recursive: true, force: true })
|
||||
|
||||
// Default features that match the official CLI build.
|
||||
// Additional features can be enabled via FEATURE_<NAME>=1 env vars.
|
||||
const DEFAULT_BUILD_FEATURES = [
|
||||
'BUDDY', 'TRANSCRIPT_CLASSIFIER', 'BRIDGE_MODE',
|
||||
'AGENT_TRIGGERS_REMOTE',
|
||||
'CHICAGO_MCP',
|
||||
'VOICE_MODE',
|
||||
'SHOT_STATS',
|
||||
'PROMPT_CACHE_BREAK_DETECTION',
|
||||
'TOKEN_BUDGET',
|
||||
// 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',
|
||||
]
|
||||
|
||||
// Collect FEATURE_* env vars → Bun.build features
|
||||
const envFeatures = Object.keys(process.env)
|
||||
.filter(k => k.startsWith('FEATURE_'))
|
||||
@@ -62,7 +21,14 @@ const result = await Bun.build({
|
||||
outdir,
|
||||
target: 'bun',
|
||||
splitting: true,
|
||||
define: getMacroDefines(),
|
||||
sourcemap: 'linked',
|
||||
define: {
|
||||
...getMacroDefines(),
|
||||
// React production mode — eliminates _debugStack Error objects
|
||||
// (6,889 objects × ~1.7KB = 12MB in development builds) and removes
|
||||
// prop-type / key warnings not useful in a production CLI tool.
|
||||
'process.env.NODE_ENV': JSON.stringify('production'),
|
||||
},
|
||||
features,
|
||||
})
|
||||
|
||||
@@ -97,7 +63,8 @@ for (const file of files) {
|
||||
// (e.g. @anthropic-ai/sandbox-runtime) so Node.js doesn't crash at import time.
|
||||
let bunPatched = 0
|
||||
const BUN_DESTRUCTURE = /var \{([^}]+)\} = globalThis\.Bun;?/g
|
||||
const BUN_DESTRUCTURE_SAFE = 'var {$1} = typeof globalThis.Bun !== "undefined" ? globalThis.Bun : {};'
|
||||
const BUN_DESTRUCTURE_SAFE =
|
||||
'var {$1} = typeof globalThis.Bun !== "undefined" ? globalThis.Bun : {};'
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.js')) continue
|
||||
const filePath = join(outdir, file)
|
||||
@@ -116,10 +83,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')
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 2.2 MiB |
@@ -185,4 +185,4 @@
|
||||
"destination": "/docs/introduction/what-is-claude-code"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
# Claude Code 文档站章节重构方案
|
||||
|
||||
> 按功能域重新组织,专有名词保留英文,其余简化为中文。
|
||||
|
||||
---
|
||||
|
||||
## 一、导航结构
|
||||
|
||||
```
|
||||
开始使用
|
||||
├── 项目介绍
|
||||
├── 项目动机
|
||||
└── 架构总览
|
||||
|
||||
核心机制
|
||||
├── Agent Loop
|
||||
├── 流式响应
|
||||
├── 多轮对话
|
||||
├── 系统提示词
|
||||
└── 深度规划
|
||||
|
||||
上下文管理
|
||||
├── 令牌预算
|
||||
├── 上下文压缩
|
||||
├── 项目记忆
|
||||
├── 自动记忆整理
|
||||
└── 穷鬼模式
|
||||
|
||||
工具系统
|
||||
├── 工具总览
|
||||
├── 文件操作
|
||||
├── 命令执行
|
||||
├── 搜索导航
|
||||
├── 任务管理
|
||||
├── 网页搜索
|
||||
├── 桌面自动化
|
||||
└── 浏览器控制
|
||||
|
||||
多智能体
|
||||
├── 子智能体
|
||||
├── 自定义智能体
|
||||
├── 工作树隔离
|
||||
├── 协调模式
|
||||
├── 团队记忆
|
||||
└── 后台会话
|
||||
|
||||
MCP
|
||||
└── MCP 详解
|
||||
|
||||
Hook 与 Plugin
|
||||
├── Hook 钩子
|
||||
├── 插件市场
|
||||
└── LSP 集成
|
||||
|
||||
Skills
|
||||
├── Skill 技能系统
|
||||
├── Workflow 脚本
|
||||
└── Skill 搜索
|
||||
|
||||
远程控制
|
||||
├── 远程控制
|
||||
├── ACP 接入
|
||||
├── 消息通道
|
||||
├── 本地通信
|
||||
└── 常驻助手
|
||||
|
||||
安全机制
|
||||
├── 安全概述
|
||||
├── 权限模型
|
||||
├── 沙箱
|
||||
├── Bash 检查
|
||||
├── 规划模式
|
||||
└── 自动模式
|
||||
|
||||
额外功能
|
||||
├── 特性开关
|
||||
├── A/B 测试与配置
|
||||
├── 错误追踪
|
||||
├── 隐藏功能
|
||||
├── Buddy 助手
|
||||
├── 命令分类
|
||||
└── 语音模式
|
||||
|
||||
开发人员
|
||||
├── 可观测性
|
||||
├── 调试模式
|
||||
└── 专属特性
|
||||
|
||||
基础设施
|
||||
├── 守护进程
|
||||
└── 自动更新
|
||||
|
||||
附录
|
||||
├── 任务追踪
|
||||
├── 测试计划
|
||||
└── 设计文档
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、标题映射
|
||||
|
||||
| 导航标题 | 新文件路径 | 合并来源 |
|
||||
|---------|-----------|---------|
|
||||
| 项目介绍 | docs/introduction/what-is-claude-code | (不变) |
|
||||
| 项目动机 | docs/introduction/why-this-whitepaper | (不变) |
|
||||
| 架构总览 | docs/introduction/architecture-overview | (不变) |
|
||||
| Agent Loop | docs/conversation/the-loop | (不变) |
|
||||
| 流式响应 | docs/conversation/streaming | (不变) |
|
||||
| 多轮对话 | docs/conversation/multi-turn | (不变) |
|
||||
| 系统提示词 | docs/context/system-prompt | (不变) |
|
||||
| 语音模式 | docs/features/voice-mode | (不变) |
|
||||
| 深度规划 | docs/features/ultraplan | (不变) |
|
||||
| 令牌预算 | docs/context/token-budget | (不变) |
|
||||
| 上下文压缩 | docs/context/compaction | (不变) |
|
||||
| 项目记忆 | docs/context/project-memory | (不变) |
|
||||
| 自动记忆整理 | docs/features/auto-dream | (不变) |
|
||||
| 穷鬼模式 | (新写) | — |
|
||||
| 工具总览 | docs/tools/what-are-tools | (不变) |
|
||||
| 文件操作 | docs/tools/file-operations | (不变) |
|
||||
| 命令执行 | docs/tools/shell-execution | (不变) |
|
||||
| 搜索导航 | docs/tools/search-and-navigation | (不变) |
|
||||
| 任务管理 | docs/tools/task-management | (不变) |
|
||||
| 网页搜索 | docs/features/web-search-tool | (不变) |
|
||||
| 桌面自动化 | docs/features/computer-use | (不变) |
|
||||
| 浏览器控制 | docs/features/claude-in-chrome-mcp | (不变) |
|
||||
| 子智能体 | docs/agent/sub-agents | ← sub-agents + features/fork-subagent |
|
||||
| 自定义智能体 | docs/extensibility/custom-agents | (不变) |
|
||||
| 工作树隔离 | docs/agent/worktree-isolation | (不变) |
|
||||
| 协调模式 | docs/agent/coordinator-and-swarm | (不变) |
|
||||
| 团队记忆 | docs/features/teammem | (不变) |
|
||||
| 后台会话 | docs/features/pipes-and-lan | (不变) |
|
||||
| MCP 详解 | docs/extensibility/mcp | ← extensibility/mcp-protocol + extensibility/mcp-configuration + features/mcp-skills |
|
||||
| 插件市场 | (新写) | — |
|
||||
| Hook 钩子 | docs/extensibility/hooks | (不变) |
|
||||
| Skill 技能系统 | docs/extensibility/skills | (不变) |
|
||||
| Workflow 脚本 | docs/features/workflow-scripts | (不变) |
|
||||
| Skill 搜索 | docs/features/experimental-skill-search | (不变) |
|
||||
| 远程控制 | docs/features/remote-control | ← features/bridge-mode + features/remote-control-self-hosting |
|
||||
| ACP 接入 | docs/features/acp-link | (不变) |
|
||||
| 消息通道 | docs/features/channels | (不变) |
|
||||
| 本地通信 | docs/features/proactive | (不变) |
|
||||
| 常驻助手 | docs/features/kairos | ← features/proactive + features/kairos |
|
||||
| 安全概述 | docs/safety/why-safety-matters | (不变) |
|
||||
| 权限模型 | docs/safety/permission-model | (不变) |
|
||||
| 沙箱 | docs/safety/sandbox | (不变) |
|
||||
| Bash 检查 | docs/features/tree-sitter-bash | ← features/tree-sitter-bash + features/bash-classifier |
|
||||
| 规划模式 | docs/safety/plan-mode | (不变) |
|
||||
| 自动模式 | docs/safety/auto-mode | (不变) |
|
||||
| 特性开关 | docs/internals/feature-flags | (不变) |
|
||||
| A/B 测试与配置 | docs/internals/growthbook | ← internals/growthbook-ab-testing + internals/growthbook-adapter |
|
||||
| 错误追踪 | docs/internals/sentry-setup | (不变) |
|
||||
| 隐藏功能 | docs/internals/hidden-features | (不变) |
|
||||
| 专属特性 | docs/internals/ant-only-world | (不变,移至开发人员) |
|
||||
| 调试模式 | docs/features/debug-mode | (不变,移至开发人员) |
|
||||
| Buddy 助手 | docs/features/buddy | (不变) |
|
||||
| 命令分类 | docs/features/bash-classifier | (不变) |
|
||||
| 可观测性 | docs/features/langfuse-monitoring | (不变) |
|
||||
| 守护进程 | docs/features/daemon | (不变) |
|
||||
| 自动更新 | docs/auto-updater | (不变) |
|
||||
| LSP 集成 | docs/lsp-integration | (不变) |
|
||||
@@ -1,101 +1,251 @@
|
||||
---
|
||||
title: "协调者与蜂群"
|
||||
description: "两种多 Agent 协作模式:Coordinator 的星型编排和 Swarm 的竞争认领。理解各自的设计权衡和适用场景。"
|
||||
title: "协调者与蜂群模式 - 多 Agent 高级编排"
|
||||
description: "从源码角度解析 Claude Code 多 Agent 协作:Coordinator Mode 的 System Prompt 设计、Worker 生命周期、Task 通信协议和 Swarm 蜂群的任务分配机制。"
|
||||
keywords: ["协调者模式", "蜂群模式", "Agent Swarm", "多 Agent 协作", "任务编排"]
|
||||
---
|
||||
|
||||
## 核心问题
|
||||
{/* 本章目标:从源码角度揭示 Coordinator Mode 和 Agent Swarms 的架构设计 */}
|
||||
|
||||
单个 AI Agent 的上下文窗口有限。当任务复杂到需要同时理解多个模块、执行多个并行操作时,单个 Agent 会力不从心。
|
||||
|
||||
多 Agent 协作的目标:让多个 AI Agent 分工合作,各自拥有独立的上下文窗口,协同完成复杂任务。
|
||||
|
||||
## 两种协作模式
|
||||
## 两种协作模式的架构差异
|
||||
|
||||
| 维度 | Coordinator Mode | Agent Swarms |
|
||||
|------|-----------------|--------------|
|
||||
| **拓扑** | 星型:Coordinator 居中编排 | 星型 + P2P:Teammate 间可直接通信 |
|
||||
| **角色** | Coordinator 理解决策,Worker 执行 | Team Lead 协调,Teammate 自主认领 |
|
||||
| **适用** | 需要集中决策的复杂任务 | 并行度高、任务独立的场景 |
|
||||
| **门控** | `feature('COORDINATOR_MODE')` + `CLAUDE_CODE_COORDINATOR_MODE=1` | `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` 环境变量 |
|
||||
| **拓扑** | 星型:Coordinator 居中,Worker 外围 | 星型+P2P 混合:Team Lead 协调,Teammate 间可直接通信 |
|
||||
| **角色** | 明确分工:Coordinator 编排、Worker 执行 | Team Lead 协调 + Teammate 自主认领任务 |
|
||||
| **通信** | `SendMessage` 定向通信 + `<task-notification>` | Mailbox 消息系统(message / broadcast) |
|
||||
| **适用** | 需要集中决策的复杂任务 | 并行度高、需要 Teammate 间直接协作的任务 |
|
||||
|
||||
两者不是互斥的——可以组合使用,但那属于高级用法。
|
||||
两者不是互斥的——理论上 Coordinator Mode 可以在 Agent Teams 架构之上运行(概念层叠加,非嵌套团队),将 Coordinator 作为特殊的 Team Lead,但这部分集成(`workerAgent.ts` 中的 `getCoordinatorAgents`)目前为 stub 实现,尚未完整落地。
|
||||
|
||||
## Coordinator Mode:先理解,再分配
|
||||
## Coordinator Mode:星型编排架构
|
||||
|
||||
### 设计哲学
|
||||
### 激活机制
|
||||
|
||||
Coordinator Mode 最核心的约束:**Coordinator 必须先理解问题,再分配任务。**
|
||||
```typescript
|
||||
// src/coordinator/coordinatorMode.ts:36
|
||||
export function isCoordinatorMode(): boolean {
|
||||
if (feature('COORDINATOR_MODE')) {
|
||||
return isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE)
|
||||
}
|
||||
return false // 外部构建始终 false
|
||||
}
|
||||
```
|
||||
|
||||
Coordinator Mode 需要双重门控:构建时 `feature('COORDINATOR_MODE')` 和运行时环境变量。`matchSessionMode()` 在会话恢复时自动同步模式状态——如果恢复的会话是 coordinator 模式,它会翻转环境变量以确保一致性。
|
||||
|
||||
### Coordinator 的工具集
|
||||
|
||||
Coordinator 被剥夺了所有"动手"工具,只保留编排能力:
|
||||
|
||||
| 工具 | 用途 |
|
||||
|------|------|
|
||||
| **Agent** | 启动新 Worker(`subagent_type: "worker"`) |
|
||||
| **SendMessage** | 向已有 Worker 发送后续指令 |
|
||||
| **TaskStop** | 中途停止走错方向的 Worker |
|
||||
| **subscribe_pr_activity** | 订阅 GitHub PR 事件(review comments、CI 结果) |
|
||||
|
||||
Coordinator **不写代码、不读文件、不执行命令**——它的核心职责是:理解需求、分配任务、综合结果,以及在无需工具时直接回答用户问题。
|
||||
|
||||
### Worker 的工具权限
|
||||
|
||||
Worker 的可用工具由 `getCoordinatorUserContext()`(`coordinatorMode.ts:80`)动态注入到 System Prompt:
|
||||
|
||||
```typescript
|
||||
// 简化模式下:只有 Bash + Read + Edit
|
||||
const workerTools = isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)
|
||||
? [BASH_TOOL_NAME, FILE_READ_TOOL_NAME, FILE_EDIT_TOOL_NAME]
|
||||
: Array.from(ASYNC_AGENT_ALLOWED_TOOLS)
|
||||
.filter(name => !INTERNAL_WORKER_TOOLS.has(name))
|
||||
```
|
||||
|
||||
`INTERNAL_WORKER_TOOLS`(TeamCreate、TeamDelete、SendMessage、SyntheticOutput)被显式排除——Worker 不能嵌套创建团队或发送消息,防止不可控的递归。
|
||||
|
||||
### Scratchpad:跨 Worker 的共享知识库
|
||||
|
||||
当 `isScratchpadGateEnabled()`(内部检查 `tengu_scratch` feature gate)启用时,Workers 获得一个 Scratchpad 目录,Coordinator 通过其系统上下文知晓该目录的存在:
|
||||
|
||||
```
|
||||
Scratchpad 目录:
|
||||
- Workers 可自由读写,无需权限审批
|
||||
- 用于持久化的跨 Worker 知识
|
||||
- 结构由 Coordinator 决定(无固定格式)
|
||||
```
|
||||
|
||||
这是一个关键的协作原语——Worker A 的研究结果可以写入 Scratchpad,Worker B 直接读取,无需通过 Coordinator 中转。
|
||||
|
||||
### `<task-notification>` 通信协议
|
||||
|
||||
Worker 完成后,Coordinator 收到 XML 格式的通知:
|
||||
|
||||
```xml
|
||||
<task-notification>
|
||||
<task-id>agent-a1b</task-id> ← Worker 的 agentId
|
||||
<status>completed|failed|killed</status>
|
||||
<summary>Agent "Investigate auth bug" completed</summary>
|
||||
<result>Found null pointer in src/auth/validate.ts:42...</result>
|
||||
<usage>
|
||||
<total_tokens>N</total_tokens>
|
||||
<tool_uses>N</tool_uses>
|
||||
<duration_ms>N</duration_ms>
|
||||
</usage>
|
||||
</task-notification>
|
||||
```
|
||||
|
||||
通知以 `user-role message` 形式送达,Coordinator 通过 `<task-notification>` 标签区分它和用户消息。`<task-id>` 用于 `SendMessage` 的 `to` 参数,实现定向续传。
|
||||
|
||||
### Coordinator 的核心职责:综合(Synthesis)
|
||||
|
||||
Coordinator System Prompt(`coordinatorMode.ts:111-369`,约 260 行)明确要求 Coordinator **不能懒惰地委派理解**:
|
||||
|
||||
```
|
||||
反模式(禁止):
|
||||
> "Based on your findings, fix the auth bug" — 把理解的责任推给了 Worker
|
||||
"Based on your findings, fix the auth bug"
|
||||
→ 把理解的责任推给了 Worker
|
||||
|
||||
正确做法:
|
||||
> "Fix the null pointer in src/auth/validate.ts:42. The user field is undefined when sessions expire but the token remains cached." — Coordinator 自己理解了问题,给出精确指令
|
||||
"Fix the null pointer in src/auth/validate.ts:42.
|
||||
The user field on Session (src/auth/types.ts:15) is
|
||||
undefined when sessions expire but the token remains cached.
|
||||
Add a null check before user.id access."
|
||||
→ Coordinator 自己理解了问题,给出精确指令
|
||||
```
|
||||
|
||||
**设计考量**:如果 Coordinator 不理解问题就分配任务,Worker 会因为缺乏上下文而做出错误的决策。Coordinator 的理解质量直接决定了 Worker 的执行质量。
|
||||
这是 Coordinator Mode 最核心的设计约束:Coordinator 必须先理解,再分配。
|
||||
|
||||
### Coordinator 的工具限制
|
||||
## Agent Teams (Swarm):蜂群式协作
|
||||
|
||||
Coordinator 被剥夺了所有"动手"工具——不写代码、不读文件、不执行命令。只保留编排能力:启动 Worker、发送指令、停止走错方向的 Worker。
|
||||
Swarm 模式基于任务系统 V2(详见[任务管理](../tools/task-management.mdx)),核心机制是**共享任务列表 + 竞争认领 + Mailbox 消息系统**:
|
||||
|
||||
**设计洞察**:限制工具不是能力的削弱,而是职责的清晰化。Coordinator 专注于"想",Worker 专注于"做"。如果 Coordinator 也能动手,它很容易跳过编排直接自己干,失去了多 Agent 的意义。
|
||||
### 团队初始化
|
||||
|
||||
### Scratchpad:跨 Worker 共享知识
|
||||
```
|
||||
Team Lead 创建团队(TeamCreateTool)
|
||||
↓
|
||||
设置 teamName → setLeaderTeamName()
|
||||
↓
|
||||
所有 Teammate 自动获得相同的 taskListId
|
||||
↓
|
||||
Teammate 启动时:
|
||||
1. CLAUDE_CODE_TASK_LIST_ID 环境变量(显式覆盖)
|
||||
2. Teammate 上下文的 teamName(共享 Lead 的任务列表)
|
||||
3. CLAUDE_CODE_TEAM_NAME 环境变量
|
||||
4. Lead 设置的 teamName
|
||||
5. getSessionId()(兜底)
|
||||
```
|
||||
|
||||
Workers 共享一个 Scratchpad 目录,可以自由读写。Worker A 的研究结果可以写入 Scratchpad,Worker B 直接读取,无需通过 Coordinator 中转。
|
||||
多级优先级确保了 Team Lead 和所有 Teammate 指向同一个任务列表,无需额外协调。
|
||||
|
||||
**设计考量**:不是所有知识都需要通过 Coordinator 中转。中间结果(如搜索结果、分析报告)适合 Worker 间直接共享,只有最终结论和决策才需要 Coordinator 参与。
|
||||
### 架构组件
|
||||
|
||||
### 通信协议
|
||||
官方 Agent Teams 架构定义了四个核心组件:
|
||||
|
||||
Worker 完成后,Coordinator 收到结构化通知,包含状态、结果摘要和 token 使用统计。Coordinator 可以向任何 Worker 发送后续指令,实现"分配 → 执行 → 反馈 → 追加"的循环。
|
||||
|
||||
## Agent Teams (Swarm):竞争认领
|
||||
|
||||
### 设计哲学
|
||||
|
||||
Swarm 基于一个简单的假设:**如果任务列表是共享的、可见的,多个 Agent 会自然地分工合作。**
|
||||
|
||||
不需要一个中央调度器——每个 Teammate 看到任务列表,自己决定认领哪个任务。这和人类团队的工作方式一致:站会上大家看到看板,自己选择下一步做什么。
|
||||
|
||||
### 核心机制
|
||||
|
||||
| 机制 | 说明 |
|
||||
| 组件 | 角色 |
|
||||
|------|------|
|
||||
| **共享任务列表** | 所有 Teammate 看到同一个任务池 |
|
||||
| **竞争认领** | 第一个锁定任务的 Teammate 获得执行权 |
|
||||
| **Mailbox 消息** | Teammate 间可直接通信,无需经过 Lead |
|
||||
| **异常恢复** | Teammate 崩溃后,其未完成任务被自动重置 |
|
||||
| **Team Lead** | 创建团队、分配任务、综合结果的主 Claude Code 会话 |
|
||||
| **Teammate** | 独立的 Claude Code 实例,各自拥有独立的上下文窗口 |
|
||||
| **Task List** | 共享的任务列表,Teammate 竞争认领和完成 |
|
||||
| **Mailbox** | 消息系统,支持 Teammate 间直接通信 |
|
||||
|
||||
### 竞争认领的并发安全
|
||||
### Mailbox 消息系统
|
||||
|
||||
两个 Teammate 可能同时想认领同一个任务。文件锁保证原子性——第一个写入者获得锁定,第二个收到 `already_claimed` 错误后选择下一个任务。
|
||||
官方架构中的 Mailbox 是 Teammate 间通信的核心原语,支持两种消息模式(`broadcast` 模式来自源码推断,官方文档未明确细分):
|
||||
|
||||
**设计考量**:竞争是特性而非缺陷。它确保任务自然地分配给最先空闲的 Agent,不需要中央调度。但需要防止"饿死"——如果某个 Agent 总是慢半拍,它可能永远抢不到任务。实践中这个问题不严重,因为 AI Agent 的执行速度差异不大。
|
||||
| 模式 | 作用 | 场景 |
|
||||
|------|------|------|
|
||||
| **message** | 定向发送给指定 Teammate | 传递具体指令、请求协作 |
|
||||
| **broadcast** | 广播给所有 Teammate | 全局通知、状态同步 |
|
||||
|
||||
### Teammate 的生命周期
|
||||
Mailbox 的关键特性:
|
||||
- **自动投递**:消息自动送达目标 Teammate 的对话上下文
|
||||
- **空闲通知**(TeammateIdle):Teammate 完成当前任务进入空闲时,自动通过 Mailbox 通知 Team Lead
|
||||
- **直接通信**:与 Coordinator Mode 不同,Teammate 之间可以直接通信,无需经过 Lead 中转
|
||||
|
||||
Teammate 异常退出时,其未完成任务被自动重置为无主状态。Team Lead 通过共享任务列表或空闲通知感知到变化,可以重新分配或创建新 Teammate。
|
||||
### Hook 事件
|
||||
|
||||
**设计哲学**:单个 Agent 的崩溃不应该阻塞整个团队。任务系统保证工作不会因为个别 Agent 的失败而丢失。
|
||||
Agent Teams 提供三个关键 Hook 事件,用于在团队生命周期中注入自定义逻辑:
|
||||
|
||||
### 当前限制
|
||||
| Hook | 触发时机 | 典型用途 |
|
||||
|------|---------|---------|
|
||||
| **TaskCreated** | 新任务添加到任务列表时 | 自动分配、优先级排序 |
|
||||
| **TaskCompleted** | 任务标记为完成时 | 结果通知、依赖解锁 |
|
||||
| **TeammateIdle** | Teammate 完成所有任务进入空闲时 | Lead 重新分配、动态扩缩容 |
|
||||
|
||||
- 不支持嵌套团队(Teammate 不能创建子团队)
|
||||
- 每会话一个团队
|
||||
- Lead 固定不可更换
|
||||
### 限制
|
||||
|
||||
## 模式选择指南
|
||||
当前 Agent Teams 实现的限制:
|
||||
- **不支持嵌套团队**:Teammate 不能再创建子团队
|
||||
- **每 session 一个团队**:一个会话只能属于一个团队
|
||||
- **Lead 固定**:Team Lead 创建后不可更换
|
||||
- **不支持 in-process Teammate 的会话恢复**:进程重启后 in-process 类型 Teammate 的状态丢失
|
||||
|
||||
### 持久化存储
|
||||
|
||||
团队状态通过文件系统持久化,确保进程重启后可恢复:
|
||||
|
||||
```
|
||||
~/.claude/teams/{team-name}/config.json ← 团队配置
|
||||
~/.claude/tasks/{team-name}/ ← 共享任务列表(文件锁保护)
|
||||
```
|
||||
|
||||
### 任务认领与竞争
|
||||
|
||||
`claimTask()` 是 Agent Teams 的核心并发原语:
|
||||
|
||||
```
|
||||
Teammate A 调用 TaskList → 发现 task #3 是 pending
|
||||
Teammate B 同时发现 task #3 是 pending
|
||||
↓
|
||||
两者同时尝试 TaskUpdate(task #3, {status: "in_progress"})
|
||||
↓
|
||||
文件锁保证原子性:
|
||||
- 第一个写入者获得 owner 锁定
|
||||
- 第二个写入者收到 already_claimed 错误
|
||||
↓
|
||||
获得任务的 teammate 执行工作
|
||||
↓
|
||||
完成后 TaskUpdate(task #3, {status: "completed"})
|
||||
→ 依赖此任务的其他任务自动解锁
|
||||
→ tool_result 提示 "Call TaskList to find your next task"
|
||||
```
|
||||
|
||||
### Teammate 的生命周期管理
|
||||
|
||||
```
|
||||
Teammate 异常退出
|
||||
↓
|
||||
unassignTeammateTasks()
|
||||
→ 扫描任务列表,找到 owner === teammateName 的未完成任务
|
||||
→ 重置为 pending + owner=undefined
|
||||
↓
|
||||
Team Lead 感知途径:
|
||||
1. 任务状态变化(pending 重置)—— 通过共享任务列表
|
||||
2. Mailbox 空闲通知(TeammateIdle hook)—— Teammate 停止时自动通知 Lead
|
||||
↓
|
||||
Team Lead 重新分配任务或创建新 Teammate
|
||||
```
|
||||
|
||||
## 任务类型全景
|
||||
|
||||
支撑多 Agent 协作的是 7 种任务类型(`src/tasks/types.ts`):
|
||||
|
||||
| 任务类型 | 运行位置 | 状态管理 | 适用场景 |
|
||||
|----------|---------|---------|---------|
|
||||
| **LocalAgentTask** | 本地子进程 | `LocalAgentTaskState` | 标准子 Agent 任务 |
|
||||
| **LocalShellTask** | 本地 shell | `LocalShellTaskState` | 后台 shell 命令 |
|
||||
| **InProcessTeammateTask** | 同进程内 | `InProcessTeammateTaskState` | 轻量级进程内队友 |
|
||||
| **RemoteAgentTask** | 远程服务器 | `RemoteAgentTaskState` | 分布式 Agent(CCR) |
|
||||
| **DreamTask** | 后台静默 | `DreamTaskState` | 后台自主整理记忆 |
|
||||
| **LocalWorkflowTask** | 本地 | `LocalWorkflowTaskState` | 工作流编排 |
|
||||
| **MonitorMcpTask** | 本地 | `MonitorMcpTaskState` | MCP 监控任务 |
|
||||
|
||||
`InProcessTeammateTask` 与 `LocalAgentTask` 的关键差异:前者共享进程的内存空间和基础设施状态(如 MCP 连接池),但有独立的对话上下文和工具权限;后者是完全隔离的子进程,启动开销更大但更安全。
|
||||
|
||||
## Coordinator vs Agent Teams 的选择
|
||||
|
||||
| 场景 | 推荐模式 | 原因 |
|
||||
|------|---------|------|
|
||||
| "重构认证系统,需要多模块协调" | Coordinator | 需要集中决策,Worker 间有依赖 |
|
||||
| "修复 10 个独立的 lint 警告" | Swarm | 任务独立,Teammate 可完全并行 |
|
||||
| "修复 10 个独立的 lint 警告" | Agent Teams | 任务独立,Teammate 可完全并行 |
|
||||
| "研究方案 A 和方案 B,然后选一个实现" | Coordinator | 先并行研究,再集中决策 |
|
||||
| "在大仓库中搜索所有 TODO 并分类" | Swarm | 无依赖,各自领任务即可 |
|
||||
|
||||
## 接下来
|
||||
|
||||
- **子 Agent** — 理解单个子 Agent 的创建和生命周期
|
||||
- **Worktree 隔离** — 理解多 Agent 的文件系统隔离
|
||||
- **任务管理** — 理解支撑协作的任务系统
|
||||
| "在大仓库中搜索所有 TODO 并分类" | Agent Teams | 无依赖,各自领任务即可 |
|
||||
|
||||
@@ -1,110 +1,858 @@
|
||||
---
|
||||
title: "子 Agent"
|
||||
description: "主 Agent 可以启动子 Agent 来并行工作或委派专业任务。理解三种子 Agent 路径、工具池隔离、Fork 的缓存优化和异步生命周期管理。"
|
||||
keywords: ["子 Agent", "AgentTool", "任务委派", "子进程隔离"]
|
||||
title: "子 Agent 机制 - 权限、流程、同步/异步与 Fork"
|
||||
description: "从源码角度解析 Claude Code 子 Agent:AgentTool 的执行链路、权限模式、同步与异步生命周期、任务通知队列、AgentTool fork、slash command fork 与 runForkedAgent 的边界。"
|
||||
keywords: ["子 Agent", "AgentTool", "权限模式", "同步子 Agent", "异步子 Agent", "forkSubagent", "runForkedAgent"]
|
||||
---
|
||||
|
||||
## 核心问题
|
||||
{/* 本章目标:把子 Agent 的几条容易混淆的执行链路拆开说明,并给出源码入口。 */}
|
||||
|
||||
单个 Agent 的上下文窗口是有限的。当需要同时研究多个方向、并行执行多个操作时,一个 Agent 做不过来。
|
||||
## 先分清四个概念
|
||||
|
||||
子 Agent 让主 Agent 可以启动独立的 AI 实例来并行工作——每个子 Agent 有自己的上下文窗口和工具集。
|
||||
Claude Code 里常被一起称为"子 Agent"的东西,其实有四类执行路径:
|
||||
|
||||
## 三种子 Agent 路径
|
||||
| 类型 | 谁触发 | 是否经过 Tool 协议 | 结果怎么回来 | 典型入口 |
|
||||
|------|--------|--------------------|--------------|----------|
|
||||
| 命名子 Agent | 主模型调用 `Agent(...)`,并提供 `subagent_type` | 是,属于一次 `tool_use` | 当前 turn 的 `tool_result`,或后台完成后的 `<task-notification>` | `src/tools/AgentTool/AgentTool.tsx` |
|
||||
| AgentTool fork | 主模型调用 `Agent(...)`,省略 `subagent_type`,且 fork gate 开启 | 是,仍然是 `Agent` 工具 | 先返回 `async_launched`,完成后通过任务通知回到主模型 | `src/tools/AgentTool/AgentTool.tsx`、`src/tools/AgentTool/forkSubagent.ts` |
|
||||
| Slash command fork | 用户执行 `context: fork` 的 slash command / skill | 否,不是模型发出的 `Agent` tool_use | 普通模式同步返回命令输出;assistant 模式后台回注隐藏 prompt | `src/utils/processUserInput/processSlashCommand.tsx` |
|
||||
| `runForkedAgent()` | 运行时内部服务直接分叉一条执行支线 | 否,内部 API | 调用方内部消费结果 | `src/utils/forkedAgent.ts` |
|
||||
|
||||
系统根据参数和配置走三条不同的路径:
|
||||
一句话记忆:
|
||||
|
||||
| 路径 | 触发条件 | 上下文 | 设计目的 |
|
||||
|------|---------|--------|---------|
|
||||
| **命名 Agent** | 指定 `subagent_type` | 仅任务描述 | 专业任务委派 |
|
||||
| **Fork 子进程** | 启用 Fork + 未指定类型 | 继承父 Agent 完整对话 | Prompt Cache 优化 |
|
||||
| **通用回退** | 未启用 Fork + 未指定类型 | 仅任务描述 | 通用任务处理 |
|
||||
`AgentTool` fork 是给模型使用的工具语义;`runForkedAgent()` 是给运行时内部能力使用的实现细节;slash command fork 是 skill / command 的执行模式。
|
||||
|
||||
### 命名 Agent:专业委派
|
||||
## AgentTool 主流程
|
||||
|
||||
系统预定义了几个内置 Agent,各有明确的职责:
|
||||
模型看到的 `Agent` 工具最终会进入 `AgentTool.call()`。一条普通命名子 Agent 的执行链如下:
|
||||
|
||||
| Agent | 模型 | 工具 | 用途 |
|
||||
|-------|------|------|------|
|
||||
| **Explore** | Haiku(轻量快速) | 只读(Read/Grep/Glob) | 代码库搜索与探索 |
|
||||
| **Plan** | 继承父模型 | 只读 | 为 Plan Mode 收集信息 |
|
||||
| **General-purpose** | 继承父模型 | 全部工具 | 复杂通用任务 |
|
||||
|
||||
**设计考量**:Explore Agent 使用 Haiku 模型(更便宜、更快),因为搜索任务不需要 Opus 的推理能力。模型选择是成本和能力的权衡——不是每个子任务都需要最强的模型。
|
||||
|
||||
### Fork 子进程:缓存优化
|
||||
|
||||
Fork 路径的核心设计是 **Prompt Cache 共享**:所有 fork 子进程共享父 Agent 的完整对话历史,只有最后的指令不同。这使得 API 请求的前缀完全一致,最大化缓存命中。
|
||||
|
||||
**为什么不用命名 Agent**?命名 Agent 只拿到任务描述,缺乏父 Agent 的完整上下文。Fork 让子进程"看到"父 Agent 看到的一切,代价是更高的 token 消耗——但通过 Prompt Cache,这部分消耗被大幅降低。
|
||||
|
||||
Fork 有两道防线防止递归创建子 Agent:检查查询来源标记和扫描消息中的特殊标签。
|
||||
|
||||
### 模型解析优先级
|
||||
|
||||
子 Agent 的模型选择遵循优先级链:
|
||||
|
||||
```
|
||||
环境变量覆盖 > 每次调用参数 > Agent 定义的模型 > 继承父对话模型
|
||||
```text
|
||||
assistant message
|
||||
-> tool_use: Agent({ prompt, subagent_type?, run_in_background?, ... })
|
||||
-> query.ts: runTools(...)
|
||||
-> toolExecution.ts: await tool.call(...)
|
||||
-> AgentTool.call(...)
|
||||
-> resolve selectedAgent / fork path / permission mode / tool pool
|
||||
-> runAgent(...)
|
||||
-> finalizeAgentTool(...)
|
||||
-> mapToolResultToToolResultBlockParam(...)
|
||||
-> user message with tool_result
|
||||
-> query.ts starts next model turn with that tool_result
|
||||
```
|
||||
|
||||
`inherit` 不是简单的模型传递——它经过运行时解析,确保 plan mode 下的模型映射正确生效。
|
||||
关键源码入口:
|
||||
|
||||
## 工具池隔离
|
||||
| 代码 | 作用 |
|
||||
|------|------|
|
||||
| `src/tools/AgentTool/AgentTool.tsx` | `Agent` 工具定义、路由、同步/异步生命周期 |
|
||||
| `src/tools/AgentTool/runAgent.ts` | 子 Agent 的 query loop、system prompt、MCP、sidechain transcript |
|
||||
| `src/services/tools/toolExecution.ts` | 外层工具执行器,`await tool.call(...)` 的地方 |
|
||||
| `src/query.ts` | 主 agentic loop,收集 tool results 并进入下一轮模型调用 |
|
||||
| `src/tasks/LocalAgentTask/LocalAgentTask.tsx` | 后台本地 Agent task 的注册、状态更新、完成通知 |
|
||||
|
||||
子 Agent 不继承父 Agent 的工具限制——它的工具池完全独立组装。
|
||||
## AgentTool 输入参数
|
||||
|
||||
**设计考量**:父 Agent 可能处于受限模式(如 Plan Mode),但子 Agent 可能需要完整的工具集来执行任务。独立的工具池让子 Agent 的权限不受父 Agent 限制。
|
||||
`Agent` 工具的输入 schema 定义在 `AgentTool.tsx` 的 `baseInputSchema()` 和 `fullInputSchema()`。有些字段会被 feature gate 从模型可见 schema 中隐藏,但 `call()` 的实现会按统一的 `AgentToolInput` 类型处理这些可选字段。
|
||||
|
||||
Fork 子进程例外——它直接继承父 Agent 的工具池,以保持 Prompt Cache 中工具定义的字节一致性。
|
||||
### 基础参数
|
||||
|
||||
### Agent 级 MCP 服务器
|
||||
| 参数 | 类型 | 必填 | 作用 | 影响路径 |
|
||||
|------|------|------|------|----------|
|
||||
| `description` | `string` | 是 | 3-5 个词的任务短描述,用于 UI、任务列表、日志、后台通知和输出摘要 | 不参与子 Agent 的实际 prompt 推理,但会影响 task 展示和通知 |
|
||||
| `prompt` | `string` | 是 | 子 Agent 要执行的完整任务说明 | 普通 agent 会变成子 Agent 的 user message;fork path 会嵌入 fork directive;remote path 会作为远程初始消息 |
|
||||
| `subagent_type` | `string` | 否 | 指定命名 agent 类型 | 有值时走命名 agent;省略时 fork gate 开启则走 AgentTool fork,否则回退到 `general-purpose` |
|
||||
| `model` | `'sonnet' \| 'opus' \| 'haiku'` | 否 | 这次调用的模型覆盖 | 普通命名 agent 中优先级高于 agent definition 的 `model`;coordinator mode 下忽略;fork path 继承父模型 |
|
||||
| `run_in_background` | `boolean` | 否 | 请求后台运行 | 为 `true` 时走异步 task;如果后台任务被禁用或 fork gate 开启,这个字段会从 schema 中隐藏 |
|
||||
|
||||
子 Agent 可以额外连接专属的 MCP 服务器。如果 Agent 声明了 `requiredMcpServers`,系统会等待这些服务器连接完成(最长 30 秒),确保工具可用后才启动。
|
||||
### 多 Agent / Teammate 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 作用 | 影响路径 |
|
||||
|------|------|------|------|----------|
|
||||
| `name` | `string` | 否 | 给 spawned agent 命名,使其可被 `SendMessage({ to: name })` 定向 | 与 `team_name` 或当前 team context 一起出现时触发 teammate spawn;普通后台子 Agent 中也会注册 `name -> agentId` 方便后续发送消息 |
|
||||
| `team_name` | `string` | 否 | 指定要加入或使用的 team | 与 `name` 一起触发 `spawnTeammate()`;省略时可继承当前 `appState.teamContext.teamName` |
|
||||
| `mode` | permission mode | 否 | teammate spawn 的权限模式提示 | 当前实现只用于 teammate 的 `plan_mode_required: spawnMode === 'plan'`;它不是普通本地子 Agent 的 `permissionMode` 覆盖 |
|
||||
|
||||
`name + team_name` 是一条独立分支:它不会进入普通 `runAgent()` 本地子 Agent 路径,而是调用 `spawnTeammate()`,返回 `teammate_spawned`。如果在 teammate 内继续带 `name` spawn teammate,会被拒绝,因为 team roster 是扁平结构。
|
||||
|
||||
### 隔离与工作目录参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 作用 | 影响路径 |
|
||||
|------|------|------|------|----------|
|
||||
| `isolation` | `'worktree'`,内部构建还支持 `'remote'` | 否 | 覆盖 agent definition 的隔离模式 | `worktree` 创建临时 git worktree;`remote` 委派到 CCR,直接返回 `remote_launched` |
|
||||
| `cwd` | `string` | 否 | 指定子 Agent 的运行目录 | 仅在 `KAIROS` schema 中暴露;会通过 `runWithCwdOverride()` 改变文件和 shell 操作的 cwd |
|
||||
|
||||
`isolation` 入参优先级高于 agent definition 里的 `isolation`。`cwd` 的 schema 文案要求不要和 `isolation: "worktree"` 同时使用;实现上如果两者同时出现,`cwd` 会优先成为运行目录,但仍可能创建 worktree,因此调用方应视为互斥参数。
|
||||
|
||||
### 参数可见性与实际效果
|
||||
|
||||
| 参数 | 可能不可见的情况 | 说明 |
|
||||
|------|------------------|------|
|
||||
| `run_in_background` | `DISABLE_BACKGROUND_TASKS` 生效,或 `isForkSubagentEnabled()` 为 true | fork gate 开启时所有 `AgentTool` spawn 都会被强制异步,所以不需要让模型再选择 |
|
||||
| `cwd` | 非 `KAIROS` 构建 / 模式 | schema 会 omit 掉,但实现类型仍保留该字段 |
|
||||
| `isolation: "remote"` | 非内部构建 | 外部构建只接受 `worktree` |
|
||||
| `model` | coordinator mode 或 fork path | coordinator 会清空 model override;fork 需要继承父模型以保持请求前缀和行为一致 |
|
||||
|
||||
### 参数与 agent definition 的优先级
|
||||
|
||||
| 配置项 | 调用参数 | agent definition | 最终规则 |
|
||||
|--------|----------|------------------|----------|
|
||||
| agent 类型 | `subagent_type` | 默认 / active agents | 显式 `subagent_type` 优先;省略时由 fork gate 决定 fork 或 `general-purpose` |
|
||||
| 模型 | `model` | `selectedAgent.model` | 普通命名 agent 中调用参数优先;没有参数则用定义;再没有则继承父模型 |
|
||||
| 后台运行 | `run_in_background` | `selectedAgent.background` | 任一为 true 都会异步;还有 coordinator、assistant、fork gate 等强制异步条件 |
|
||||
| 隔离 | `isolation` | `selectedAgent.isolation` | 调用参数优先 |
|
||||
| 权限模式 | 无本地覆盖参数 | `selectedAgent.permissionMode` | 普通子 Agent 用 definition 的 `permissionMode`,默认 `acceptEdits`;fork 使用 `bubble` |
|
||||
| 工具集合 | 无调用参数 | `selectedAgent.tools` | 普通子 Agent 在 `runAgent()` 里按 definition 过滤;fork 使用父级 exact tools |
|
||||
|
||||
## Agent Definition 字段
|
||||
|
||||
`AgentTool` 的调用参数只描述"这一次怎么 spawn"。真正决定 agent 默认能力的是 agent definition。自定义 agent 可以来自用户 / 项目目录、JSON 配置、插件或内置定义,核心字段最终都会归一到 `AgentDefinition`。
|
||||
|
||||
### 常用 frontmatter
|
||||
|
||||
| 字段 | 类型 | 作用 | 运行时影响 |
|
||||
|------|------|------|------------|
|
||||
| `name` | `string` | agent 类型名 | 模型通过 `subagent_type` 匹配它;插件 agent 可能带命名空间前缀 |
|
||||
| `description` | `string` | 使用场景说明 | 进入可用 agent 列表,帮助主模型选择 |
|
||||
| `tools` | `string[]` | 允许的工具集合 | `runAgent()` 内经 `resolveAgentTools()` 过滤;`['*']` 表示全量可用工具 |
|
||||
| `disallowedTools` | `string[]` | 禁用工具集合 | JSON agent 支持该字段,用于从允许集合中排除 |
|
||||
| `prompt` | `string` | agent system prompt 主体 | 普通命名子 Agent 会用它构建自己的 system prompt |
|
||||
| `model` | `string` | 默认模型 | 可被 `Agent({ model })` 覆盖;`inherit` 表示继承父模型 |
|
||||
| `effort` | effort level 或 number | 推理努力级别 | 传给 agent 运行配置 |
|
||||
| `permissionMode` | permission mode | 默认权限模式 | 普通子 Agent 工具池组装时使用;省略则默认 `acceptEdits` |
|
||||
| `background` | `boolean` | 是否总是后台运行 | 为 true 时,即使调用参数没有 `run_in_background` 也走异步 |
|
||||
| `isolation` | `'worktree'` / `'remote'` | 默认隔离模式 | 可被调用参数 `isolation` 覆盖 |
|
||||
| `maxTurns` | positive integer | 最大 agentic turns | 传给 `query()`,防止子 Agent 无限循环 |
|
||||
| `color` | agent color | UI 颜色 | 用于 grouped UI、任务面板、teammate 展示 |
|
||||
| `memory` | `'user' \| 'project' \| 'local'` | 持久记忆作用域 | 在 system prompt 中追加 agent memory,并按 scope 读写目录 |
|
||||
|
||||
示例:
|
||||
|
||||
```md
|
||||
---
|
||||
name: code-reviewer
|
||||
description: Review a code change and find correctness risks
|
||||
tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
model: sonnet
|
||||
permissionMode: acceptEdits
|
||||
background: true
|
||||
maxTurns: 8
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are a focused code reviewer. Prioritize bugs, regressions, and missing tests.
|
||||
```
|
||||
|
||||
### MCP、Hooks、Skills
|
||||
|
||||
| 字段 | 作用 | 说明 |
|
||||
|------|------|------|
|
||||
| `requiredMcpServers` | 启动前必须存在的 MCP server 模式 | `AgentTool.call()` 会等待 pending server,最长约 30 秒;没有可用工具则报错 |
|
||||
| `mcpServers` | agent 专属 MCP server | `runAgent()` 初始化,生命周期跟随该子 Agent |
|
||||
| `hooks` | agent 生命周期内注册的 hooks | `runAgent()` 会注册 frontmatter hooks;agent 停止时清理 session hooks |
|
||||
| `skills` | 预加载 skill 名称 | `runAgent()` 会解析并注入对应 skill;插件 skill 支持命名空间或后缀匹配 |
|
||||
| `initialPrompt` | 首个 user turn 前置内容 | 可用于启动时固定注入额外说明 |
|
||||
|
||||
这些字段属于 agent definition,不是 `Agent(...)` 调用参数。调用方不能在一次 `Agent` tool_use 里临时传入 `tools`、`hooks` 或 `skills` 来覆盖 agent 定义。
|
||||
|
||||
### runAgent() 扩展点
|
||||
|
||||
`runAgent()` 不只是把 prompt 丢给模型。它会在进入 query loop 前后挂载一组 agent 级扩展点:
|
||||
|
||||
| 扩展点 | 时机 | 作用 |
|
||||
|--------|------|------|
|
||||
| `SubagentStart` hooks | 子 Agent query loop 启动前 | 允许 hook 修改或补充启动上下文 |
|
||||
| frontmatter `hooks` | agent session 初始化时注册 | 只在这个子 Agent 的 session 内生效,结束后清理 |
|
||||
| preload `skills` | system prompt / skill 解析阶段 | 把指定 skill 的说明和资源注入 agent 可见上下文 |
|
||||
| agent `memory` | system prompt 构建时 | 按 `user` / `project` / `local` scope 读取 agent memory,并追加到 agent prompt |
|
||||
| sidechain transcript | query loop 运行时 | 记录子 Agent 的独立消息链,供恢复、调试和 `SendMessage` 续跑使用 |
|
||||
|
||||
这些扩展点解释了为什么同样是 `runAgent()`,不同 agent definition 会表现出不同的工具边界、启动行为和长期上下文。
|
||||
|
||||
## 路由规则
|
||||
|
||||
`AgentTool.call()` 首先决定这次调用到底要跑哪一种 agent:
|
||||
|
||||
```text
|
||||
subagent_type 有值
|
||||
-> 使用命名 agent
|
||||
|
||||
subagent_type 省略 && isForkSubagentEnabled() 为 true
|
||||
-> 使用 fork agent
|
||||
|
||||
subagent_type 省略 && fork gate 关闭
|
||||
-> 回退到 general-purpose
|
||||
```
|
||||
|
||||
命名 agent 来自内置 agent、用户配置目录、插件 agent 等定义。fork agent 是代码里内置的特殊 agent,定义在 `forkSubagent.ts`,它不是普通专业角色,而是"继承父上下文的 worker"。
|
||||
|
||||
## 权限模型
|
||||
|
||||
子 Agent 权限要分成三层看:能不能启动这个 agent、这个 agent 有哪些工具、工具执行时如何处理权限请求。
|
||||
|
||||
### 启动权限
|
||||
|
||||
`AgentTool` 自身是一个工具调用,因此先经过普通工具权限系统。随后 `AgentTool.call()` 还会做 agent 级过滤:
|
||||
|
||||
| 检查 | 说明 |
|
||||
|------|------|
|
||||
| `filterDeniedAgents()` | 根据权限规则过滤被禁止的 agent 类型 |
|
||||
| `requiredMcpServers` | 如果 agent 声明必需 MCP server,会等待它们连接,失败或超时则停止 |
|
||||
| teammate 限制 | in-process teammate 不能继续 spawn teammate,也不能 spawn 后台 agent |
|
||||
| fork 递归保护 | fork worker 里不能再次 fork |
|
||||
|
||||
被权限规则 deny 的命名 agent 会直接报错,而不是退回到别的 agent。这样可以避免模型绕过用户或配置里的拒绝规则。
|
||||
|
||||
### 工具池权限
|
||||
|
||||
普通命名子 Agent 不直接继承父 agent 当前那一轮的工具池限制。它会用自己的权限模式重新组装工具池:
|
||||
|
||||
```ts
|
||||
const workerPermissionContext = {
|
||||
...appState.toolPermissionContext,
|
||||
mode: selectedAgent.permissionMode ?? 'acceptEdits',
|
||||
}
|
||||
|
||||
const workerTools = assembleToolPool(
|
||||
workerPermissionContext,
|
||||
appState.mcp.tools,
|
||||
)
|
||||
```
|
||||
|
||||
这里有几个重要含义:
|
||||
|
||||
| 维度 | 行为 |
|
||||
|------|------|
|
||||
| 默认权限模式 | 如果 agent 定义没有写 `permissionMode`,默认使用 `acceptEdits` |
|
||||
| 全局 allow / deny 规则 | 仍然来自 `appState.toolPermissionContext` |
|
||||
| agent 自己的 `tools` 字段 | 在 `runAgent()` 内通过 `resolveAgentTools()` 继续过滤 |
|
||||
| MCP 工具 | 来自当前 AppState 中已经连接的 MCP 工具;agent 也可以声明专属 MCP server |
|
||||
|
||||
fork agent 是例外。它为了保持父子请求的 prompt cache 前缀一致,会使用父级 exact tools:
|
||||
|
||||
```text
|
||||
useExactTools: true
|
||||
availableTools: toolUseContext.options.tools
|
||||
```
|
||||
|
||||
因此 fork 的权限策略不是"重新组装工具池",而是"继承父工具定义,并用 `bubble` 权限模式把权限请求上浮到父终端"。
|
||||
|
||||
### 权限模式速览
|
||||
|
||||
| 模式 | 子 Agent 中的意义 |
|
||||
|------|------------------|
|
||||
| `acceptEdits` | 默认模式。通常允许读和编辑类安全路径,危险操作仍走权限系统 |
|
||||
| `default` / 其他普通模式 | 按主权限系统规则询问或放行 |
|
||||
| `bypassPermissions` | 显式危险模式,只有用户启用跳过权限时才应出现 |
|
||||
| `bubble` | fork 专用思路:权限请求冒泡到父级会话处理 |
|
||||
|
||||
## 同步子 Agent
|
||||
|
||||
同步子 Agent 是默认路径:没有显式 `run_in_background: true`,agent 定义也没有 `background: true`,并且没有被 coordinator / assistant mode / fork gate 等机制强制异步。
|
||||
|
||||
同步等待发生在普通工具调用链里。外层 `toolExecution.ts` 会执行:
|
||||
|
||||
```ts
|
||||
const result = await tool.call(...)
|
||||
```
|
||||
|
||||
如果这个工具是 `AgentTool`,那么 `AgentTool.call()` 会在内部跑完整个子 Agent:
|
||||
|
||||
```text
|
||||
AgentTool.call()
|
||||
-> agentIterator = runAgent(...)[Symbol.asyncIterator]()
|
||||
-> while true:
|
||||
await agentIterator.next()
|
||||
收集 assistant / user 消息
|
||||
转发 progress 给 UI / SDK
|
||||
如果 result.done,跳出
|
||||
-> finalizeAgentTool(agentMessages, ...)
|
||||
-> return { data: { status: "completed", ...agentResult } }
|
||||
```
|
||||
|
||||
返回后,`mapToolResultToToolResultBlockParam()` 把 `completed` 结果转成当前 turn 的 `tool_result`。然后 `query.ts` 把这个 tool result 放进消息列表,进入下一轮模型调用。
|
||||
|
||||
也就是说,同步子 Agent 不通过统一队列回注结果。主模型是在这次 `Agent` tool call 上等待,直到拿到最终 `tool_result` 才继续。
|
||||
|
||||
### 同步子 Agent 的可后台化
|
||||
|
||||
同步子 Agent 注册为 foreground task,因此它可以中途被后台化。循环里会同时等待下一条子 Agent 消息和后台化信号:
|
||||
|
||||
```ts
|
||||
const raceResult = await Promise.race([
|
||||
nextMessagePromise.then(result => ({ type: 'message', result })),
|
||||
backgroundPromise,
|
||||
])
|
||||
```
|
||||
|
||||
如果后台化信号先到,当前前台 iterator 会被清理,新的后台 `runAgent(..., isAsync: true)` 接管剩余工作。此时 `AgentTool.call()` 不再等待最终结果,而是返回 `async_launched`,后续完成结果走任务通知队列。
|
||||
|
||||
## 异步子 Agent
|
||||
|
||||
异步子 Agent 的触发条件包括:
|
||||
|
||||
| 条件 | 说明 |
|
||||
|------|------|
|
||||
| `run_in_background: true` | 模型显式要求后台运行 |
|
||||
| agent 定义 `background: true` | 该 agent 总是后台运行 |
|
||||
| coordinator mode | worker 统一异步,方便编排 |
|
||||
| fork subagent gate 开启 | 当前实现会强制所有 `AgentTool` spawn 使用异步通知模型 |
|
||||
| assistant / kairos mode | 避免同步子任务阻塞输入队列 |
|
||||
| proactive active | 主动循环下也可能强制异步 |
|
||||
|
||||
异步路径不会等待子 Agent 完成:
|
||||
|
||||
```text
|
||||
AgentTool.call()
|
||||
-> registerAsyncAgent(...)
|
||||
-> void runAsyncAgentLifecycle(...)
|
||||
-> return { status: "async_launched", agentId, outputFile }
|
||||
```
|
||||
|
||||
后台生命周期在 `runAsyncAgentLifecycle()` 中完成:
|
||||
|
||||
```text
|
||||
runAsyncAgentLifecycle()
|
||||
-> for await message of runAgent(...)
|
||||
-> updateAsyncAgentProgress(...)
|
||||
-> finalizeAgentTool(...)
|
||||
-> completeAsyncAgent(...)
|
||||
-> enqueueAgentNotification(...)
|
||||
```
|
||||
|
||||
异步 Agent 使用独立 `AbortController`。普通 ESC 取消主线程不会自动杀掉后台 Agent;后台 Agent 需要通过任务停止、bulk kill 或 task 管理命令显式结束。
|
||||
|
||||
## 完成通知与统一队列
|
||||
|
||||
后台 Agent 完成后,`enqueueAgentNotification()` 会生成一条 XML 形态的 `<task-notification>`:
|
||||
|
||||
```xml
|
||||
<task-notification>
|
||||
<task-id>...</task-id>
|
||||
<tool-use-id>...</tool-use-id>
|
||||
<output-file>...</output-file>
|
||||
<status>completed</status>
|
||||
<summary>Agent "..." completed</summary>
|
||||
<result>...</result>
|
||||
<usage>...</usage>
|
||||
</task-notification>
|
||||
```
|
||||
|
||||
这条消息通过 `enqueuePendingNotification({ mode: 'task-notification' })` 进入统一 command queue。
|
||||
|
||||
### 队列什么时候消费
|
||||
|
||||
| 场景 | 消费方式 |
|
||||
|------|----------|
|
||||
| REPL / TUI | `useQueueProcessor()` 订阅队列;当 query 空闲且没有本地 JSX UI 阻塞时,调用 `processQueueIfReady()` |
|
||||
| CLI / SDK headless | `print.ts` 中的 `drainCommandQueue()` 在 turn 之间持续消费;如果还有后台任务运行,会继续等待并 drain 新通知 |
|
||||
| 子 Agent 内部 | `query.ts` 会消费带有当前 `agentId` 的 `task-notification`,主线程只消费 `agentId === undefined` 的消息 |
|
||||
|
||||
`task-notification` 最终会作为 user-role 消息或 attachment 进入下一轮模型上下文。模型因此能看到后台结果,并决定是否综合、继续行动或回复用户。
|
||||
|
||||
### 还有哪些消息走同一队列
|
||||
|
||||
统一队列不只用于后台 Agent。常见来源包括:
|
||||
|
||||
| 来源 | mode | 用途 |
|
||||
|------|------|------|
|
||||
| 用户在当前 turn 未结束时继续输入 | `prompt` / `bash` | 排队到下一轮处理 |
|
||||
| 后台 shell / monitor 结束或卡住提醒 | `task-notification` | 通知模型命令状态 |
|
||||
| remote agent / ultraplan / ultrareview 完成 | `task-notification` | 把远程结果交给本地模型 |
|
||||
| scheduled task / cron | `prompt` | 定时触发主模型任务 |
|
||||
| Chrome / MCP channel 推送 | `prompt` | 外部系统主动注入消息 |
|
||||
| hook 阻塞错误 | `task-notification` | 唤醒模型处理 stop hook 错误 |
|
||||
| orphaned permission response | `orphaned-permission` | 处理工具权限回复比原请求更晚到达的情况 |
|
||||
|
||||
队列优先级是 `now > next > later`。`enqueue()` 默认 `next`,`enqueuePendingNotification()` 默认 `later`,这样系统通知不会抢在用户输入前面。
|
||||
|
||||
## 继续通信与任务控制
|
||||
|
||||
后台子 Agent 返回 `async_launched` 后,主模型不应该直接假装已经知道最终答案。它有三种后续操作面:发消息、读输出、停止任务。
|
||||
|
||||
### SendMessage
|
||||
|
||||
`SendMessage` 用来给运行中或曾经启动过的 agent 追加消息。它可以通过两种地址找到本地后台 agent:
|
||||
|
||||
| 地址 | 来源 | 行为 |
|
||||
|------|------|------|
|
||||
| `name` | `Agent({ name, ... })` 注册到 `agentNameRegistry` | 先解析成 agentId,再发送 |
|
||||
| raw `agentId` | `async_launched` 或 `completed` tool result 中返回 | 直接定位对应 task 或 transcript |
|
||||
|
||||
发送 plain text message 时必须提供 `summary`,因为 UI 和权限摘要需要一个短描述。`to: "*"` 表示广播给 teammate team;结构化消息不能广播。
|
||||
|
||||
`SendMessage` 对本地后台 agent 的行为分三种:
|
||||
|
||||
| 目标状态 | 行为 | 结果 |
|
||||
|----------|------|------|
|
||||
| task 仍在 `running` | 调用 `queuePendingMessage(agentId, message, ...)` | 消息进入该 task 的 `pendingMessages`,在子 Agent 下一次 tool round / loop 边界被投递 |
|
||||
| task 已停止但还在 AppState | 调用 `resumeAgentBackground(...)` | 用这条消息把 agent 后台恢复运行,完成后仍通过通知回来 |
|
||||
| task 已从 AppState 清掉 | 仍尝试 `resumeAgentBackground(...)` | 如果 sidechain transcript 还在,就从 transcript 恢复;否则返回失败 |
|
||||
|
||||
这意味着 `SendMessage` 不是只能在 agent 正在跑时使用。隔了很久以后,只要调用方还知道 `name` 或 `agentId`,并且对应 transcript 没被清理,就可能恢复并继续这个 agent。反过来,如果 task 状态和 transcript 都没了,`SendMessage` 无法凭空重建上下文。
|
||||
|
||||
几个容易误会的点:
|
||||
|
||||
| 点 | 说明 |
|
||||
|----|------|
|
||||
| running agent 不会立刻中断当前工具调用 | 消息先排进 `pendingMessages`,等 agent loop 到安全边界再处理 |
|
||||
| stopped agent 会变成新的后台运行 | `resumeAgentBackground()` 返回 output file,之后靠完成通知回注 |
|
||||
| `name` 只在注册还在时可靠 | name registry 是运行时状态;跨很久恢复时 raw `agentId` 更稳定 |
|
||||
| cross-session send 有额外限制 | `bridge:` / `uds:` 地址只支持 plain text,且可能需要显式权限或连接状态 |
|
||||
|
||||
### TaskOutput
|
||||
|
||||
`TaskOutput` 是旧式读取后台任务输出的工具,当前 prompt 明确建议优先使用 `Read` 读取任务返回的 `output_file`。它仍然可用,主要行为如下:
|
||||
|
||||
| 参数 | 行为 |
|
||||
|------|------|
|
||||
| `task_id` | 要读取的后台任务 id |
|
||||
| `block: false` | 非阻塞读取当前状态和已有输出 |
|
||||
| `block: true` | 等待任务完成,默认行为 |
|
||||
| `timeout` | 阻塞等待的最大时长 |
|
||||
|
||||
如果 `block: true` 等到任务完成,`TaskOutput` 会把 task 标记为 `notified`,避免再重复发送完成通知。因为这个工具已经 deprecated,新代码和模型提示都更推荐直接读 `output_file`。
|
||||
|
||||
### TaskStop
|
||||
|
||||
`TaskStop` 停止运行中的后台任务。它接受 `task_id`,也兼容旧的 `shell_id`。校验规则很直接:任务必须存在且状态是 `running`,否则报错。
|
||||
|
||||
停止后会调用统一的 `stopTask()`,具体 task 类型再映射到各自 kill 逻辑,例如本地 agent 会 abort 自己的 `AbortController`,shell task 会停止进程,remote task 会走 remote 停止路径。
|
||||
|
||||
## 失败、取消与清理
|
||||
|
||||
子 Agent 的异常路径主要分同步和异步看。
|
||||
|
||||
### 同步路径
|
||||
|
||||
同步子 Agent 抛出 `AbortError` 时,`AgentTool.call()` 会把它继续抛给外层工具框架,主 turn 进入正常的中断处理。非 abort 错误会先记录;如果已经收集到 assistant 消息,会尽量 `finalizeAgentTool()` 返回部分结果,让主模型看到已有进展。如果完全没有 assistant 消息,则重新抛出错误。
|
||||
|
||||
同步 finally 会做这些清理:
|
||||
|
||||
| 清理 | 作用 |
|
||||
|------|------|
|
||||
| 清空 background hint UI | 避免前台提示残留 |
|
||||
| `stopForegroundSummarization()` | 停止前台摘要定时器 |
|
||||
| `unregisterAgentForeground()` | 子 Agent 未后台化时,从 foreground task 注册表移除 |
|
||||
| SDK task notification | 给 SDK / VS Code 面板发完成、失败或 stopped 事件 |
|
||||
| `clearInvokedSkillsForAgent()` | 清理 agent 作用域 skill 状态 |
|
||||
| `clearDumpState()` | 清理 dump/transcript 调试状态 |
|
||||
| `cleanupWorktreeIfNeeded()` | 未后台化时清理或保留 worktree |
|
||||
|
||||
### 异步路径
|
||||
|
||||
异步路径由 `runAsyncAgentLifecycle()` 兜住异常:
|
||||
|
||||
| 情况 | 状态更新 | 通知 |
|
||||
|------|----------|------|
|
||||
| 正常完成 | `completeAsyncAgent(...)` | `enqueueAgentNotification(status: completed)` |
|
||||
| `AbortError` | `killAsyncAgent(...)` | `enqueueAgentNotification(status: killed)`,带 partial result |
|
||||
| 其他错误 | `failAsyncAgent(...)` | `enqueueAgentNotification(status: failed)`,带 error |
|
||||
|
||||
代码会先更新 task 状态,再做 handoff classifier 或 worktree cleanup 这类可能较慢的附加工作。这个顺序很重要:`TaskOutput(block=true)` 等待的是 task 进入 terminal status,不能被后续分类器或 git 清理卡住。
|
||||
|
||||
通知也有防重机制。`enqueueAgentNotification()` 会先原子检查并设置 `task.notified`;如果已经通知过,就不再重复入队。
|
||||
|
||||
## AgentTool fork
|
||||
|
||||
AgentTool fork 是 `Agent` 工具的一种特殊路由,不是普通命名 agent。
|
||||
|
||||
### Gate
|
||||
|
||||
fork 默认关闭。需要构建/运行时启用 `FORK_SUBAGENT` feature,例如开发时显式设置:
|
||||
|
||||
```powershell
|
||||
$env:FEATURE_FORK_SUBAGENT='1'; bun run dev
|
||||
```
|
||||
|
||||
即使 feature 打开,以下场景也会强制关闭:
|
||||
|
||||
| 场景 | 原因 |
|
||||
|------|------|
|
||||
| coordinator mode | coordinator 已有自己的委派模型 |
|
||||
| non-interactive session | pipe / SDK 场景下避免不可见的 fork 嵌套 |
|
||||
|
||||
### 路径
|
||||
|
||||
```text
|
||||
主模型
|
||||
-> Agent({ prompt }),没有 subagent_type
|
||||
-> AgentTool.call()
|
||||
-> isForkSubagentEnabled()
|
||||
-> selectedAgent = FORK_AGENT
|
||||
-> buildForkedMessages(...)
|
||||
-> runAgent(... useExactTools: true, forkContextMessages: parent messages)
|
||||
-> 注册 task / transcript / notification
|
||||
```
|
||||
|
||||
fork 的目标是让多个 worker 共享父请求的 prompt cache 前缀。它会:
|
||||
|
||||
| 维度 | fork 行为 |
|
||||
|------|-----------|
|
||||
| system prompt | 使用父级已经渲染好的 system prompt |
|
||||
| 对话历史 | 传入父级完整 `toolUseContext.messages` |
|
||||
| tools | 使用父级 exact tools,不重新过滤 |
|
||||
| thinking config | 继承父级配置,避免 cache key 变化 |
|
||||
| placeholder tool_result | 多个 fork 使用相同占位文本,只有最后 directive 不同 |
|
||||
| 权限 | `permissionMode: 'bubble'` |
|
||||
|
||||
这就是为什么 fork path 和普通 agent path 在 tool pool、prompt 构造、模型继承上都不同。
|
||||
|
||||
### 递归保护
|
||||
|
||||
fork worker 保留 `Agent` 工具是为了让工具定义字节和父级一致,但代码会拒绝 fork 内再次 fork:
|
||||
|
||||
| 保护 | 说明 |
|
||||
|------|------|
|
||||
| `querySource === 'agent:builtin:fork'` | 直接识别当前已经在 fork worker 内 |
|
||||
| `<fork-boilerplate>` 扫描 | 兜底识别 fork 指令已经存在于上下文 |
|
||||
|
||||
fork worker 应该直接完成任务,而不是继续委派。
|
||||
|
||||
## Slash command fork
|
||||
|
||||
slash command fork 是 skill / command 的执行模式。它由 skill frontmatter 控制:
|
||||
|
||||
```md
|
||||
---
|
||||
name: code-review
|
||||
context: fork
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
---
|
||||
```
|
||||
|
||||
加载 skill 时,`frontmatter.context === 'fork'` 会被解析成 command 的 `context: 'fork'`。执行 slash command 时:
|
||||
|
||||
```text
|
||||
用户输入 /code-review
|
||||
-> processSlashCommand(...)
|
||||
-> command.context === 'fork'
|
||||
-> executeForkedSlashCommand(...)
|
||||
-> prepareForkedCommandContext(...)
|
||||
-> runAgent(...)
|
||||
```
|
||||
|
||||
普通交互模式下,`executeForkedSlashCommand()` 会同步跑完子 Agent,显示 progress UI,然后把结果作为本地命令输出返回给主对话。
|
||||
|
||||
assistant / kairos 模式下,它会 fire-and-forget:后台 runner 完成后,把结果包装成隐藏 prompt 重新放入 command queue。这样多个 scheduled task 不会在启动时串行阻塞用户输入。
|
||||
|
||||
## `runForkedAgent()`
|
||||
|
||||
`runForkedAgent()` 是内部服务用的执行器,不暴露给模型,也不产生 `Agent` tool_result。
|
||||
|
||||
它的输入是 `cacheSafeParams`、`promptMessages`、`canUseTool` 等运行时对象,直接跑 query loop:
|
||||
|
||||
```text
|
||||
内部服务
|
||||
-> runForkedAgent({ promptMessages, cacheSafeParams, ... })
|
||||
-> createSubagentContext(...)
|
||||
-> query(...)
|
||||
-> 返回 ForkedAgentResult
|
||||
```
|
||||
|
||||
常见调用方:
|
||||
|
||||
| 调用方 | 用途 |
|
||||
|--------|------|
|
||||
| compact | 对话压缩 |
|
||||
| extractMemories / sessionMemory | 记忆抽取和维护 |
|
||||
| promptSuggestion / speculation | 提示建议和预测 |
|
||||
| sideQuestion | 不打扰主上下文的临时问答 |
|
||||
| agentSummary | 后台 agent 摘要 |
|
||||
| autoDream | 后台记忆整合 |
|
||||
|
||||
它和 AgentTool fork 的共同点是"分叉执行",但边界完全不同:
|
||||
|
||||
| 维度 | AgentTool fork | `runForkedAgent()` |
|
||||
|------|----------------|--------------------|
|
||||
| 调用者 | 模型通过 `Agent` 工具调用 | 运行时服务直接调用 |
|
||||
| 协议层 | 经过 Tool schema / tool_use / tool_result | 不经过 Tool 协议 |
|
||||
| 可见性 | 主模型会先看到 `async_launched`,完成后看到通知 | 结果由内部调用方处理 |
|
||||
| 主要目标 | 并行 worker + prompt cache 共享 | 内部辅助任务复用 query loop |
|
||||
|
||||
## Worktree 隔离
|
||||
|
||||
`isolation: "worktree"` 参数让子 Agent 在独立的 git worktree 中工作:
|
||||
`Agent` 工具支持 `isolation: "worktree"`。启用后,子 Agent 在临时 git worktree 中运行,适合实现型或实验型任务。
|
||||
|
||||
```
|
||||
创建 worktree → 子 Agent 在独立副本中工作 → 完成
|
||||
→ 有变更:保留 worktree,返回路径
|
||||
→ 无变更:自动删除
|
||||
生命周期:
|
||||
|
||||
| 阶段 | 行为 |
|
||||
|------|------|
|
||||
| 创建 | 使用 agent id 派生 slug,创建独立 worktree |
|
||||
| CWD 覆盖 | `runWithCwdOverride(worktreePath, fn)` 让工具在 worktree 内执行 |
|
||||
| fork + worktree | 额外注入路径翻译提示,提醒 worker 重新读取文件 |
|
||||
| 清理 | 无变更则移除 worktree;有变更则保留并把路径返回给主模型 |
|
||||
|
||||
如果 worktree 是 hook-based,代码会保留它,因为无法可靠判断 VCS 变更。
|
||||
|
||||
## 结果格式
|
||||
|
||||
`AgentTool.mapToolResultToToolResultBlockParam()` 根据状态返回不同 tool result:
|
||||
|
||||
| 状态 | 结果 |
|
||||
|------|------|
|
||||
| `completed` | 子 Agent 输出内容,可附带 `agentId`、worktree 信息和 usage |
|
||||
| `async_launched` | 后台 agent id、output file 路径、等待完成通知的说明 |
|
||||
| `teammate_spawned` | teammate id、name、team name |
|
||||
| `remote_launched` | remote task id、session URL、output file |
|
||||
|
||||
同步子 Agent 的 `completed` 结果直接成为当前 `Agent` tool call 的 `tool_result`。异步子 Agent 的首次 tool result 是 `async_launched`,最终输出通过 `<task-notification>` 回到模型。
|
||||
|
||||
### 输出字段
|
||||
|
||||
| 状态 | 关键字段 | 说明 |
|
||||
|------|----------|------|
|
||||
| `completed` | `content`、`agentId`、`totalTokens`、`totalToolUseCount`、`totalDurationMs` | 同步子 Agent 的最终结果;普通 agent 会附带可继续通信的 `agentId` |
|
||||
| `async_launched` | `agentId`、`description`、`prompt`、`outputFile`、`canReadOutputFile` | 后台 agent 已启动;最终结果稍后通过通知到达 |
|
||||
| `teammate_spawned` | `teammate_id`、`name`、`team_name` | teammate 已启动,后续通过 mailbox / SendMessage 协作 |
|
||||
| `remote_launched` | `taskId`、`sessionUrl`、`outputFile`、`description` | remote CCR agent 已启动,完成后走 remote task 通知 |
|
||||
|
||||
一次性内置 agent 可以省略 `agentId` / `SendMessage` hint 和 usage trailer,避免把不会继续通信的信息塞进上下文。
|
||||
|
||||
### outputSchema 与 tool_result
|
||||
|
||||
`AgentTool` 的 `outputSchema` 描述的是 `call()` 返回的结构化 data;`mapToolResultToToolResultBlockParam()` 再把这些 data 映射成模型实际看到的 `tool_result` 文本块。读代码时可以按这个顺序看:
|
||||
|
||||
```text
|
||||
AgentTool.call()
|
||||
-> return { data: { status, ...fields } }
|
||||
-> mapToolResultToToolResultBlockParam(data, toolUseID)
|
||||
-> ToolResultBlockParam
|
||||
-> query.ts 把 tool_result 放进下一轮消息
|
||||
```
|
||||
|
||||
**设计目的**:子 Agent 的实验性修改不应该影响主分支。Worktree 提供了文件系统级别的隔离,同时保持对完整代码库的访问。
|
||||
四类结果的字段重点:
|
||||
|
||||
## 生命周期:同步 vs 异步
|
||||
| status | data 字段 | 模型可见信息 |
|
||||
|--------|-----------|--------------|
|
||||
| `completed` | `content`、`agentId`、usage、可选 worktree result | 子 Agent 最终输出;如果可继续通信,会提示可用 `SendMessage` |
|
||||
| `async_launched` | `agentId`、`description`、`prompt`、`outputFile`、`canReadOutputFile` | 后台已启动;提示等待通知或读取 output file |
|
||||
| `teammate_spawned` | `teammate_id`、`name`、`team_name` | teammate 已加入 team;后续通过 mailbox / `SendMessage` 协作 |
|
||||
| `remote_launched` | `taskId`、`sessionUrl`、`outputFile`、`description` | remote task 已启动;本地模型等待 remote task notification |
|
||||
|
||||
### 异步 Agent(后台运行)
|
||||
这里的 `status` 是结果分发的主轴。后面 catch / finally 中的 failed、killed、cleanup 逻辑不会改写已经返回的同步 `tool_result`;后台路径会通过 task state 和 notification 把终态再交给主模型。
|
||||
|
||||
异步 Agent 立即返回 `async_launched` 状态,主 Agent 可以继续工作。后台 Agent 完成后通过通知机制汇报结果。
|
||||
## 生命周期状态机
|
||||
|
||||
异步 Agent 获得独立的 AbortController——用户取消主线程不会杀掉后台 Agent。这确保长时间运行的构建/测试任务不会被意外中断。
|
||||
把本地子 Agent 当成 task 看,核心状态可以这样理解:
|
||||
|
||||
### 同步 Agent(前台运行)
|
||||
```text
|
||||
AgentTool.call()
|
||||
-> resolve route
|
||||
-> create optional worktree
|
||||
-> register foreground 或 register async task
|
||||
-> runAgent()
|
||||
-> completed / failed / killed
|
||||
-> tool_result 或 task-notification
|
||||
-> cleanup agent-scoped state
|
||||
```
|
||||
|
||||
同步 Agent 有一个"可后台化"机制:如果执行超过阈值(默认 120 秒),系统自动将其转为后台运行。这防止了一个慢子 Agent 阻塞整个工作循环。
|
||||
同步和异步的差别不在于是否调用 `runAgent()`,而在于谁等待 `runAgent()`:
|
||||
|
||||
## 适用场景
|
||||
| 路径 | 谁等待 | 主模型什么时候继续 |
|
||||
|------|--------|--------------------|
|
||||
| 同步子 Agent | `AgentTool.call()` 自己 `for await` 子 Agent 消息流 | 子 Agent 完成并返回 `tool_result` 后 |
|
||||
| 自动后台化 | 前台先等;超时后前台 iterator 退出,后台 lifecycle 接管 | `AgentTool.call()` 返回 `async_launched` 后 |
|
||||
| 异步子 Agent | `runAsyncAgentLifecycle()` 在后台等 | 主模型收到 `async_launched` 后立即继续 |
|
||||
| slash command fork 普通交互 | `executeForkedSlashCommand()` 等 | slash command 完成后 |
|
||||
| slash command fork assistant / kairos | fire-and-forget 后台 runner 等 | 启动后主输入流程继续,完成后隐藏 prompt 回注 |
|
||||
| `runForkedAgent()` | 内部调用方自己等 | 不进入主模型 tool_result 协议 |
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="并行研究" icon="magnifying-glass">
|
||||
多个 fork 子进程并行搜索不同方向,共享 Prompt Cache 前缀
|
||||
</Card>
|
||||
<Card title="专业委派" icon="code-branch">
|
||||
使用命名 Agent 执行专业任务,受限工具集 + 独立权限
|
||||
</Card>
|
||||
<Card title="隔离实验" icon="flask">
|
||||
worktree 隔离中尝试方案,不影响主分支
|
||||
</Card>
|
||||
<Card title="后台构建" icon="layer-group">
|
||||
异步启动长时间构建/测试,主 Agent 继续工作
|
||||
</Card>
|
||||
</CardGroup>
|
||||
所以“同步子 Agent 怎么等完成”最短答案是:外层工具执行器 `await tool.call()`,而 `AgentTool.call()` 内部持续消费 `runAgent()` 的 async iterator,直到 iterator `done` 或异常。
|
||||
|
||||
## 接下来
|
||||
## 等待与回注方式对照
|
||||
|
||||
- **协调者与蜂群** — 理解多 Agent 的高级编排模式
|
||||
- **Worktree 隔离** — 深入理解文件系统隔离机制
|
||||
- **任务管理** — 理解支撑多 Agent 的任务追踪
|
||||
子 Agent 结果回到主模型有三种主要机制:
|
||||
|
||||
| 机制 | 适用路径 | 回注载体 | 是否阻塞当前 turn |
|
||||
|------|----------|----------|-------------------|
|
||||
| `tool_result` | 同步命名子 Agent | 当前 `Agent` tool_use 对应的 tool result | 是 |
|
||||
| `<task-notification>` | 异步 / 后台本地 Agent、remote task、后台 shell 等 | 统一 command queue 中的 task notification | 否 |
|
||||
| hidden prompt / command queue prompt | assistant / kairos 的 slash command fork、scheduled task 等 | queue 中的 prompt 类消息 | 否 |
|
||||
|
||||
这里容易混淆的是:后台子 Agent 完成后不会“补写”原来的 `tool_result`。原来的 `Agent` tool call 已经返回了 `async_launched`;最终结果是新的一条队列消息,下一轮模型看到后再决定怎么整合。
|
||||
|
||||
## Progress、UI 与 Transcript
|
||||
|
||||
子 Agent 有三条并行的“可观察输出”:给用户看的 progress、给模型看的最终结果、给系统恢复用的 transcript。
|
||||
|
||||
| 输出 | 同步路径 | 异步路径 | 用途 |
|
||||
|------|----------|----------|------|
|
||||
| progress UI | `AgentTool.call()` 消费子 Agent 消息时实时转发给 UI / SDK | `runAsyncAgentLifecycle()` 更新 task progress state | 让用户看到子 Agent 正在做什么 |
|
||||
| output file | 同步路径也会写入 side output,方便调试和恢复 | 后台 task 的主要可读输出,`async_launched` 会返回路径 | 主模型可用 `Read(outputFile)` 查看 |
|
||||
| sidechain transcript | `runAgent()` 记录独立消息链 | 同样记录,且用于后台恢复 | `SendMessage`、resume、debug、summary 都依赖它 |
|
||||
| task state | foreground task 注册表记录同步运行状态 | LocalAgentTask 记录 running / completed / failed / killed | UI、`TaskOutput`、通知防重都看这里 |
|
||||
|
||||
同步 progress 是“边跑边展示,最后一次性返回 tool_result”。异步 progress 是“边跑边写 task state,最后入队 task notification”。sidechain transcript 不等同于用户可见输出;它是系统用来重建 agent 上下文的消息日志。
|
||||
|
||||
## 典型调用示例
|
||||
|
||||
### 同步命名子 Agent
|
||||
|
||||
```json
|
||||
{
|
||||
"description": "review parser bug",
|
||||
"prompt": "Review the parser changes and identify correctness risks.",
|
||||
"subagent_type": "code-reviewer"
|
||||
}
|
||||
```
|
||||
|
||||
适合短任务或必须立即拿结果才能继续的任务。主模型会等到子 Agent 输出 `completed`。
|
||||
|
||||
### 后台命名子 Agent
|
||||
|
||||
```json
|
||||
{
|
||||
"description": "run regression suite",
|
||||
"prompt": "Run the regression tests and summarize failures.",
|
||||
"subagent_type": "general-purpose",
|
||||
"run_in_background": true
|
||||
}
|
||||
```
|
||||
|
||||
适合长任务。主模型先收到 `async_launched`,其中会包含 `agentId` 和 `outputFile`。之后可以等待 `<task-notification>`,也可以用 `Read(outputFile)` 主动查看已有结果。
|
||||
|
||||
### 可继续通信的后台 Agent
|
||||
|
||||
```json
|
||||
{
|
||||
"description": "investigate flaky tests",
|
||||
"prompt": "Investigate flaky tests without editing files yet.",
|
||||
"subagent_type": "general-purpose",
|
||||
"name": "flaky-investigator",
|
||||
"run_in_background": true
|
||||
}
|
||||
```
|
||||
|
||||
后续可以用:
|
||||
|
||||
```json
|
||||
{
|
||||
"to": "flaky-investigator",
|
||||
"message": "Focus on the Windows-only failures and compare the last two runs.",
|
||||
"summary": "focus Windows failures"
|
||||
}
|
||||
```
|
||||
|
||||
如果时间隔得很久,优先使用 `async_launched` 或 `completed` 里返回的 raw `agentId`,因为 `name` registry 是运行时状态,而 sidechain transcript 更可能通过 `agentId` 被恢复。
|
||||
|
||||
### Worktree 隔离实现
|
||||
|
||||
```json
|
||||
{
|
||||
"description": "prototype parser fix",
|
||||
"prompt": "Implement a candidate fix in isolation and report the changed files.",
|
||||
"subagent_type": "general-purpose",
|
||||
"isolation": "worktree"
|
||||
}
|
||||
```
|
||||
|
||||
适合让子 Agent 动手改代码但不污染主工作区。主模型拿到结果后,需要根据 worktree path 决定是否合并、复查或丢弃。
|
||||
|
||||
### AgentTool fork
|
||||
|
||||
```json
|
||||
{
|
||||
"description": "scan auth paths",
|
||||
"prompt": "Analyze the auth flow and report likely race conditions."
|
||||
}
|
||||
```
|
||||
|
||||
只有 fork gate 开启且省略 `subagent_type` 时才是 fork。fork worker 继承父上下文和 exact tools,目标是并行分析和 prompt cache 复用,不适合写成长期稳定的专业角色。
|
||||
|
||||
### Slash command fork
|
||||
|
||||
```md
|
||||
---
|
||||
name: audit-auth
|
||||
context: fork
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
---
|
||||
|
||||
Audit the authentication flow and return only correctness risks.
|
||||
```
|
||||
|
||||
结果流:
|
||||
|
||||
```text
|
||||
用户输入 /audit-auth
|
||||
-> processSlashCommand()
|
||||
-> executeForkedSlashCommand()
|
||||
-> runAgent()
|
||||
-> 普通交互:命令输出直接回到对话
|
||||
-> assistant / kairos:完成后 hidden prompt 入队,下一轮模型消费
|
||||
```
|
||||
|
||||
## 排障清单
|
||||
|
||||
| 现象 | 优先检查 |
|
||||
|------|----------|
|
||||
| 模型看不到后台结果 | task 是否已经 enqueue notification;队列是否在当前模式 drain;`task.notified` 是否已被 `TaskOutput(block=true)` 提前标记 |
|
||||
| `SendMessage` 找不到目标 | `name` 是否还在 registry;是否可以改用 raw `agentId`;sidechain transcript 是否仍存在 |
|
||||
| 子 Agent 没有某个工具 | agent definition 的 `tools` 是否过滤掉了;MCP server 是否连接;fork path 是否用了 exact tools |
|
||||
| 子 Agent 权限和预期不同 | 普通 agent 看 `permissionMode`;teammate 的 `mode` 不是普通子 Agent 权限覆盖;fork 看 `bubble` |
|
||||
| fork 没触发 | `FORK_SUBAGENT` feature 是否打开;是否在 coordinator 或 non-interactive;是否传了 `subagent_type` |
|
||||
| slash command 没有 fork | skill frontmatter 是否写 `context: fork`;加载后 command.context 是否为 `fork` |
|
||||
| worktree 没清理 | 是否有未提交变更;是否 hook-based worktree;cleanup 是否被后台 task 保留到通知后处理 |
|
||||
| `TaskOutput(block=true)` 一直等 | task 是否真的进入 terminal status;如果是 async path,确认状态更新是否发生在 classifier / cleanup 之前 |
|
||||
|
||||
## 选择哪条路径
|
||||
|
||||
| 需求 | 推荐路径 |
|
||||
|------|----------|
|
||||
| 需要专业角色、有限上下文、明确工具集 | 命名子 Agent |
|
||||
| 需要长任务但不阻塞主模型 | 异步子 Agent |
|
||||
| 需要多个 worker 共享完整父上下文并最大化 prompt cache | AgentTool fork |
|
||||
| 需要把一个 slash command / skill 隔离执行 | slash command fork |
|
||||
| 运行时内部需要一段轻量分叉推理 | `runForkedAgent()` |
|
||||
| 需要隔离文件改动 | `isolation: "worktree"` |
|
||||
|
||||
## 常见误区
|
||||
|
||||
| 误区 | 正确理解 |
|
||||
|------|----------|
|
||||
| `mode` 可以覆盖普通子 Agent 权限 | `mode` 只影响 teammate spawn 的 plan 模式;普通子 Agent 权限来自 agent definition 的 `permissionMode` |
|
||||
| `SendMessage` 只能发给 running agent | running 时排队,stopped / evicted 时会尝试从 transcript 后台恢复 |
|
||||
| 后台 agent 完成会直接改当前 tool_result | 后台完成走 `<task-notification>` 队列,下一轮模型才会看到 |
|
||||
| fork 默认开启 | fork 默认关闭,需要 `FORK_SUBAGENT` feature,且 coordinator / non-interactive 会禁用 |
|
||||
| fork 是内部 `runForkedAgent()` | AgentTool fork 经过 Tool 协议;`runForkedAgent()` 是内部运行时 API |
|
||||
| `cwd` 和 `isolation: "worktree"` 可以随便一起用 | schema 文案要求互斥;实现上 `cwd` 会优先覆盖运行目录,调用方应避免混用 |
|
||||
| 读后台输出应该优先 `TaskOutput` | 当前提示建议优先 `Read(output_file)`;`TaskOutput` 保留兼容和阻塞等待能力 |
|
||||
|
||||
## 源码阅读路径
|
||||
|
||||
如果要从源码验证一条行为,建议按问题类型走不同入口:
|
||||
|
||||
| 问题 | 阅读顺序 |
|
||||
|------|----------|
|
||||
| `Agent(...)` 参数为什么这样生效 | `AgentTool.tsx` 的 schema -> `AgentTool.call()` 参数解构 -> 路由规则 |
|
||||
| 普通子 Agent 为什么同步等待 | `toolExecution.ts` 的 `await tool.call()` -> `AgentTool.call()` 同步分支 -> `runAgent()` |
|
||||
| 后台完成为什么会通知主模型 | `registerAsyncAgent()` -> `runAsyncAgentLifecycle()` -> `enqueueAgentNotification()` -> queue processor |
|
||||
| `SendMessage` 为什么能恢复旧 agent | `SendMessageTool.ts` 地址解析 -> `queuePendingMessage()` / `resumeAgentBackground()` -> sidechain transcript |
|
||||
| fork 为什么不是普通 agent | `isForkSubagentEnabled()` -> `FORK_AGENT` -> `buildForkedMessages()` -> `useExactTools` |
|
||||
| slash command fork 为什么不走 Tool 协议 | skill load frontmatter -> `processSlashCommand()` -> `executeForkedSlashCommand()` |
|
||||
| 内部 fork 为什么没有 tool result | `runForkedAgent()` -> `query()` -> 调用方消费 `ForkedAgentResult` |
|
||||
|
||||
## 维护提示
|
||||
|
||||
更新子 Agent 行为时,优先同时检查这些位置:
|
||||
|
||||
| 文件 | 为什么重要 |
|
||||
|------|------------|
|
||||
| `src/tools/AgentTool/AgentTool.tsx` | 路由、权限、同步/异步、结果映射都在这里汇合 |
|
||||
| `src/tools/AgentTool/forkSubagent.ts` | AgentTool fork 的 gate、FORK_AGENT、消息构造 |
|
||||
| `src/tools/AgentTool/runAgent.ts` | 子 Agent 真正的运行循环 |
|
||||
| `src/tasks/LocalAgentTask/LocalAgentTask.tsx` | 后台 Agent 状态和通知 |
|
||||
| `src/utils/messageQueueManager.ts` | 统一 command queue |
|
||||
| `src/utils/queueProcessor.ts` | REPL 队列消费规则 |
|
||||
| `src/cli/print.ts` | headless / SDK 队列消费和后台等待 |
|
||||
| `src/utils/processUserInput/processSlashCommand.tsx` | slash command fork |
|
||||
| `src/utils/forkedAgent.ts` | 内部 `runForkedAgent()` |
|
||||
| `src/skills/loadSkillsDir.ts` | skill frontmatter 中 `context: fork` 的解析 |
|
||||
|
||||
492
docs/agent/sur-loop-scheduled-oom.md
Normal file
492
docs/agent/sur-loop-scheduled-oom.md
Normal file
@@ -0,0 +1,492 @@
|
||||
# System Understanding Report — Loop / Scheduled Autonomy OOM
|
||||
|
||||
- **Flow id**: `recurring-bug-loop-oom` (pilot flow for autonomy ↔ deep-debug binding)
|
||||
- **Branch**: `fix/loop-scheduled-autonomy-oom`
|
||||
- **Worktree**: `E:\Source_code\Claude-code-bast-loop-scheduled-oom-fix`
|
||||
- **Author**: back-filled from existing working-tree diff (no commits ahead of `main`)
|
||||
- **Status**: `report` (this document) — pending human approval before `regression-test` advances
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem
|
||||
|
||||
### Symptom
|
||||
|
||||
Long-running sessions with active scheduled tasks (cron) and/or HEARTBEAT-driven proactive ticks accumulated growing memory, eventually OOM'ing the Bun process. The visible signature was:
|
||||
|
||||
- `runs.json` under `.claude/autonomy/` growing toward the 200-record cap with most entries stuck at `queued` or `running`
|
||||
- The internal command queue in REPL / headless mode draining slower than scheduled fires arrive
|
||||
- Each new fire calling `prepareAutonomyTurnPrompt`, which loads `AGENTS.md` + `HEARTBEAT.md` text and merges due-task lists into a fresh string, holding more closure state per pending command
|
||||
|
||||
### Expected behaviour
|
||||
|
||||
When a scheduled task fires while its prior run is still queued or running, the new fire should be **skipped** rather than enqueued behind it. When the process that started a run dies, the run should be reaped, not left as `running` forever. Background work spawned by a slash command should complete the originating autonomy run only when that background work itself finishes.
|
||||
|
||||
### Actual behaviour (before fix)
|
||||
|
||||
1. `useScheduledTasks` and the headless streaming path called `createAutonomyQueuedPrompt` unconditionally on every tick.
|
||||
2. `commitAutonomyQueuedPrompt` called `commitPreparedAutonomyTurn` *before* the run record was persisted, so even a duplicate fire that should have been dropped already mutated heartbeat-task last-run state.
|
||||
3. `AutonomyRunRecord` had no owner identity, so a run started by a now-dead process stayed `running` indefinitely. Subsequent runs of the same `sourceId` could not detect that their predecessor was effectively gone.
|
||||
4. Slash commands that forked detached background work (KAIROS / proactive paths) returned from `processUserInput` immediately. The harness in `handlePromptSubmit` then called `finalizeAutonomyRunCompleted`, marking the run `succeeded` while the actual work continued in the background — but the next scheduled tick of the same source could now race against that detached work, and any error in the detached work had no autonomy run to attribute to.
|
||||
|
||||
### Reproduction shape
|
||||
|
||||
Not a single deterministic repro — load-induced. Rough recipe:
|
||||
|
||||
- Configure two `HEARTBEAT.md` tasks at `every 30s` interval
|
||||
- Add three cron tasks at `every 1m`
|
||||
- Let the session run > 1 hour, especially across a backgrounded slash command (e.g. KAIROS `/sleep`-style detached fork)
|
||||
- Watch `.claude/autonomy/runs.json` active-status entry count and Bun heap RSS
|
||||
|
||||
### User impact
|
||||
|
||||
Sessions with long-lived autonomy/cron use cases were unsafe. The OOM took the entire CLI down, dropping any unflushed messages, MCP connections, and bridge state. Because `.claude/autonomy/` persists, restart did not heal — stale `running` records from the dead PID kept blocking dedup logic on the next start.
|
||||
|
||||
---
|
||||
|
||||
## 2. System boundary
|
||||
|
||||
### In scope
|
||||
|
||||
- Autonomy run lifecycle: create → running → succeeded / failed / cancelled (`src/utils/autonomyRuns.ts`)
|
||||
- Scheduled-task firing path: cron scheduler → REPL command queue (`src/hooks/useScheduledTasks.ts`)
|
||||
- Headless streaming variant of the same path (`src/cli/print.ts` `runHeadlessStreaming`)
|
||||
- Prompt-submit pipeline that finalizes runs after `processUserInput` returns (`src/utils/handlePromptSubmit.ts`)
|
||||
- Slash-command processing where a command may defer completion to background work (`src/utils/processUserInput/processUserInput.ts`, `processSlashCommand.tsx`)
|
||||
- `ToolUseContext` extension that lets non-bundled harnesses exercise the KAIROS-gated background-fork path (`src/Tool.ts`)
|
||||
|
||||
### Out of scope
|
||||
|
||||
- The cron scheduler itself (`src/utils/cronScheduler.ts`) — its tick semantics are not changing
|
||||
- `autonomyFlows.ts` flow state machine — separate from per-run tracking
|
||||
- HEARTBEAT.md scheduling semantics — unchanged. `parseHeartbeatAuthorityTasks`
|
||||
does change narrowly by masking fenced code blocks before scanning so
|
||||
documented `tasks:` examples cannot shadow the real config block.
|
||||
- `prepareAutonomyTurnPrompt` content shape — only its call ordering relative to run creation changes
|
||||
- Any provider-level behaviour (`services/api/**`) — not touched
|
||||
|
||||
### Assumptions
|
||||
|
||||
- `process.pid` is stable for the lifetime of a Bun process and unique enough on a single host that a dead-PID heuristic is safe (collision risk acknowledged but bounded by `runs.json` retention).
|
||||
- `isProcessRunning(pid)` (from `genericProcessUtils.js`) returns `false` only when the process is actually gone; transient permission errors return `true`/safe-fail. Verified in step 6.
|
||||
- `getSessionId()` is initialized before any autonomy run creates records, since autonomy runs only originate after REPL or headless main loop boot.
|
||||
|
||||
---
|
||||
|
||||
## 3. Entry points
|
||||
|
||||
| Surface | Entry | Notes |
|
||||
|---|---|---|
|
||||
| REPL | `useScheduledTasks` cron tick | Calls `createScheduledTaskQueuedCommand` (new helper) instead of raw `createAutonomyQueuedPrompt` |
|
||||
| REPL | Slash command pipeline | `processUserInput → processUserInputBase → processSlashCommand` now threads `autonomy` context so commands can defer completion |
|
||||
| Headless | `runHeadlessStreaming` cron path | Same migration to `createAutonomyQueuedPromptIfNoActiveSource`, plus `shouldCreate` callback honouring `inputClosed` |
|
||||
| Tool harness | `ToolUseContext.options.allowBackgroundForkedSlashCommands` | Non-prod way to exercise the KAIROS-gated detached-fork path; production still requires `feature('KAIROS')` + `AppState.kairosEnabled` |
|
||||
| Persistence | `.claude/autonomy/runs.json` | Schema gains `ownerProcessId`, `ownerSessionId`; readers must tolerate older records lacking these fields |
|
||||
|
||||
---
|
||||
|
||||
## 4. Key files
|
||||
|
||||
| File | Lines changed | Why it matters |
|
||||
|---|---|---|
|
||||
| `src/utils/autonomyRuns.ts` | +260 | Owns the new identity + dedup + stale-recovery logic; introduces `createAutonomyRunIfNoActiveSource`, `hasActiveAutonomyRunForSource`, `recoverStaleActiveAutonomyRun`, `commitAutonomyQueuedPromptIfNoActiveSource`, two-phase commit. The structural heart of the fix. |
|
||||
| `src/utils/processUserInput/processSlashCommand.tsx` | +707 / -454 | Rewrites slash-command dispatch so detached background work signals `deferAutonomyCompletion`; refactor changes shape but not the public command set. |
|
||||
| `src/hooks/useScheduledTasks.ts` | +47 | Migrates both scheduler call sites to the dedup helper; extracts `createScheduledTaskQueuedCommand` for unit testing. |
|
||||
| `src/cli/print.ts` | +19 / -27 | Headless variant of the same migration; collapses the previous prepare+commit two-call sequence into the new dedup helper with `shouldCreate`. |
|
||||
| `src/utils/handlePromptSubmit.ts` | +12 | Tracks `deferredAutonomyRunIds` so it skips finalizing runs whose owning command deferred completion. |
|
||||
| `src/utils/processUserInput/processUserInput.ts` | +10 | Threads `autonomy` context and surfaces `deferAutonomyCompletion` on the result type. |
|
||||
| `src/Tool.ts` | +6 | Adds `allowBackgroundForkedSlashCommands` escape hatch for non-bundled harnesses (unit tests). |
|
||||
| `src/utils/__tests__/autonomyRuns.test.ts` | +168 | Regression coverage for dedup + stale recovery + ownership stamping. |
|
||||
| `src/hooks/__tests__/useScheduledTasks.test.ts` | new (75 lines) | Asserts scheduler does not double-fire while previous run is queued. |
|
||||
| `src/utils/processUserInput/__tests__/processSlashCommand.test.ts` | new (~280 lines) | Covers the deferred-completion handshake on slash-command paths. |
|
||||
|
||||
---
|
||||
|
||||
## 5. Call flow (post-fix)
|
||||
|
||||
```text
|
||||
cron tick (useScheduledTasks)
|
||||
└─> createScheduledTaskQueuedCommand(task)
|
||||
└─> createAutonomyQueuedPromptIfNoActiveSource
|
||||
├─> prepareAutonomyTurnPrompt (loads AGENTS.md + HEARTBEAT.md)
|
||||
├─> shouldCreate? ──► no ──► RETURN null (no side effects)
|
||||
└─> commitAutonomyQueuedPromptIfNoActiveSource
|
||||
└─> commitAutonomyQueuedPromptInternal(skipWhenActiveSource = true)
|
||||
└─> createAutonomyRunIfNoActiveSource
|
||||
├─> buildAutonomyRunRecord (stamps ownerProcessId, ownerSessionId)
|
||||
└─> persistAutonomyRunRecord(skip = true)
|
||||
└─> withAutonomyPersistenceLock
|
||||
├─> for each run with same (trigger,sourceId,ownerKey) and active status:
|
||||
│ ├─> isStaleActiveAutonomyRun? ──► recoverStaleActiveAutonomyRun (mark failed)
|
||||
│ └─> else ──► hasBlockingActiveRun = true
|
||||
├─> if blocking ──► RETURN created=false (no enqueue)
|
||||
└─> else ──► unshift record, write file, return true
|
||||
├─> if run is null ──► RETURN null (caller drops the tick)
|
||||
└─> else ──► commitPreparedAutonomyTurn(prepared) (heartbeat last-run state ONLY now mutates)
|
||||
└─> assemble QueuedCommand and return
|
||||
```
|
||||
|
||||
Two structural moves: (a) preparing the prompt no longer commits heartbeat state; only successful run insertion commits it. (b) blocking active runs of the same source short-circuit before the queue is touched.
|
||||
|
||||
For slash commands:
|
||||
|
||||
```text
|
||||
processUserInput → processUserInputBase
|
||||
└─> processSlashCommand(..., autonomy = cmd.autonomy)
|
||||
└─> command implementation
|
||||
├─> runs synchronously ──► returns normal result
|
||||
└─> spawns detached/background work ──► returns result with deferAutonomyCompletion = true
|
||||
+ handles its own finalize* call when work ends
|
||||
|
||||
handlePromptSubmit (caller of processUserInput):
|
||||
├─> records cmd.autonomy.runId in autonomyRunIds
|
||||
├─> on result with deferAutonomyCompletion=true: adds runId to deferredAutonomyRunIds
|
||||
└─> finalize loop: skips deferred ids in BOTH success and error branches
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Data flow
|
||||
|
||||
### `runs.json` record schema (delta)
|
||||
|
||||
```ts
|
||||
type AutonomyRunRecord = {
|
||||
// existing
|
||||
runId: string
|
||||
status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'
|
||||
trigger: AutonomyTriggerKind
|
||||
sourceId?: string
|
||||
ownerKey?: string
|
||||
// new
|
||||
ownerProcessId?: number // process.pid at create time and at markRunning time
|
||||
ownerSessionId?: string // getSessionId() at the same points
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Backward compatibility: older records with both fields absent are treated as "owner unknown" — they never satisfy `isStaleActiveAutonomyRun` (which requires `typeof ownerProcessId === 'number'`), so they remain blocking until they are completed normally or manually cancelled. This is intentional: we cannot prove they are stale.
|
||||
|
||||
### Stale-recovery rule
|
||||
|
||||
```text
|
||||
isStaleActiveAutonomyRun(run) ⇔
|
||||
run.status ∈ {queued, running}
|
||||
∧ typeof run.ownerProcessId === 'number'
|
||||
∧ !isProcessRunning(run.ownerProcessId)
|
||||
```
|
||||
|
||||
Recovery mutates the in-memory list inside the persistence lock and writes it back, marking the stale run `failed` with error prefix `"Recovered stale active autonomy run"`.
|
||||
|
||||
### Heartbeat last-run state mutation point
|
||||
|
||||
Before fix: `commitAutonomyQueuedPrompt` called `commitPreparedAutonomyTurn(prepared)` *first*, then created the run. A skipped duplicate already advanced heartbeat last-run timestamps.
|
||||
|
||||
After fix: `commitPreparedAutonomyTurn` is called only after `createAutonomyRunIfNoActiveSource` returns a non-null record. Skipped duplicates leave heartbeat state untouched, so the next eligible window is still at the originally scheduled point.
|
||||
|
||||
---
|
||||
|
||||
## 7. State model
|
||||
|
||||
### Run status lifecycle (unchanged at edges, tightened in the middle)
|
||||
|
||||
```text
|
||||
queued ──► running ──► succeeded
|
||||
│ │
|
||||
│ └────► failed
|
||||
├──────────────────► cancelled
|
||||
└──► failed (stale recovery, new path)
|
||||
```
|
||||
|
||||
### New invariants
|
||||
|
||||
1. **Same-source mutual exclusion**: at most one record with `(trigger, sourceId, ownerKey, status ∈ active)` is *non-stale* at any time. Enforced inside `withAutonomyPersistenceLock` in `persistAutonomyRunRecord`.
|
||||
|
||||
2. **Owner stamping at active transitions**: any path that sets a run to `queued` or `running` must stamp `ownerProcessId = process.pid` and `ownerSessionId = getSessionId()`. `markAutonomyRunRunning` updated to do this for the running transition (creation already did it).
|
||||
|
||||
3. **Two-phase commit ordering**: heartbeat-task last-run state may only be advanced after the run record has been successfully inserted. Equivalent to "prompt commit ⇒ run row exists".
|
||||
|
||||
4. **Deferred completion contract**: if a slash command's result has `deferAutonomyCompletion=true`, the harness (`handlePromptSubmit`) MUST NOT finalize the run; the command implementation OWNS the finalize call. Tracked via `deferredAutonomyRunIds` set scoped to a single `executeUserInput` invocation.
|
||||
|
||||
### Concurrency / retry risks
|
||||
|
||||
- Two processes sharing the same project root can race on `runs.json`. Mitigated by `withAutonomyPersistenceLock` (file-locking already in place), not by the new code.
|
||||
- Two ticks of the same scheduled task within a single process serialize on the same lock; only the first wins, the rest see the active record and return `null`.
|
||||
- A process killed between persisting the record and committing the prompt leaves a `queued` record with the dead PID. Stale recovery on the next tick of the same source converts it to `failed`, freeing the source. This is the new safety net.
|
||||
|
||||
### Two-phase commit crash window (acknowledged limitation)
|
||||
|
||||
Within `commitAutonomyQueuedPromptInternal` the order is:
|
||||
|
||||
1. `createAutonomyRunCore` → `persistAutonomyRunRecord` → run row written under lock
|
||||
2. `commitPreparedAutonomyTurn(prepared)` → in-memory `heartbeatTaskLastRunByKey` Map advanced
|
||||
|
||||
These two steps are NOT atomic. If the process is killed between (1) and (2):
|
||||
|
||||
- `runs.json` has a fresh `queued` record stamped with the now-dead PID.
|
||||
- `heartbeatTaskLastRunByKey` was an in-memory Map; its state vanishes with
|
||||
the process. On restart the Map is empty.
|
||||
- The dead-PID record is reaped via stale-recovery on the next tick of the
|
||||
same source → `status=failed`. New record can be created.
|
||||
- Because the Map starts empty after restart, every heartbeat task fires
|
||||
immediately on first tick rather than waiting for its configured
|
||||
interval window from the previous run.
|
||||
|
||||
**Severity**: low. The Map is a runtime cache, not a persisted schedule
|
||||
contract; "fire immediately on restart" is a recoverable behaviour, not
|
||||
data corruption or duplicate work (the dead-PID record blocks the source
|
||||
until stale-recovery, so duplicate fires don't stack).
|
||||
|
||||
**Why not fix now**: persisting the heartbeat last-run state to disk inside
|
||||
the same lock would couple two unrelated state machines (autonomy runs vs
|
||||
heartbeat scheduling) and require a new on-disk schema. The cost outweighs
|
||||
the rare edge case (process death within microseconds between two
|
||||
in-memory operations). Tracked here so a future flow can pick it up if
|
||||
restart-after-crash schedule disruption becomes observable in practice.
|
||||
|
||||
---
|
||||
|
||||
## 8. Existing tests
|
||||
|
||||
### Pre-fix
|
||||
|
||||
- `src/utils/__tests__/autonomyRuns.test.ts` covered create / list / mark transitions for the basic happy path.
|
||||
- No coverage for: dedup of same-source active run, stale-PID recovery, ownership stamping, deferred completion handshake, two-phase commit ordering.
|
||||
- `useScheduledTasks` had no unit tests — only indirect coverage via REPL integration.
|
||||
- `processSlashCommand` had no autonomy-context coverage.
|
||||
|
||||
### Added in this branch
|
||||
|
||||
- `src/utils/__tests__/autonomyRuns.test.ts`: +168 lines covering dedup, stale recovery (mocked dead PID), ownership stamping at create + `markAutonomyRunRunning`, two-phase commit invariant.
|
||||
- `src/hooks/__tests__/useScheduledTasks.test.ts`: new file, 75 lines. Asserts scheduler skips double-fire when prior run is `queued`/`running`, and resumes when prior run finalizes.
|
||||
- `src/utils/processUserInput/__tests__/processSlashCommand.test.ts`: new file, ~280 lines. Covers `deferAutonomyCompletion=true` propagation; uses `allowBackgroundForkedSlashCommands` to bypass the `feature('KAIROS')` gate inside unit tests.
|
||||
|
||||
### Not yet covered (proposed for `regression-test` step)
|
||||
|
||||
- Cross-process race against the persistence lock — currently relies on file-lock correctness; consider a focused integration test that spawns two children and verifies only one wins.
|
||||
- Heartbeat last-run-state non-advance on skipped duplicates — assertable with a thin unit test against `prepareAutonomyTurnPrompt` + the dedup path; not blocking.
|
||||
|
||||
---
|
||||
|
||||
## 9. Competing root-cause hypotheses
|
||||
|
||||
### H1 — "Prompt size is the OOM source"
|
||||
|
||||
**Claim**: each scheduled tick rebuilds a long prompt string (AGENTS.md + HEARTBEAT.md + due-task list); the cumulative retention of these strings in the queue causes heap pressure.
|
||||
|
||||
**Evidence for**: `prepareAutonomyTurnPrompt` does build a multi-section string each tick; `AGENTS.md` in this repo is now 220 lines.
|
||||
|
||||
**Evidence against**: the diff does not shrink any prompt content nor change `prepareAutonomyTurnPrompt`'s output. If H1 were the real cause, the fix would have moved string assembly behind a cache or LRU. The fix instead targets the *number* of in-flight runs.
|
||||
|
||||
**Verdict**: contributing factor at most. Rejected as primary root cause.
|
||||
|
||||
### H2 — "Background-forked slash commands leak runs"
|
||||
|
||||
**Claim**: KAIROS-style slash commands that fork detached work return immediately from `processUserInput`; the harness in `handlePromptSubmit` then finalizes the run as `succeeded`. Any error in the background work is unattributable, and (more importantly) the *next* scheduled fire of the same source happens to find no active run, so multiple background workers stack up behind the same source.
|
||||
|
||||
**Evidence for**: the diff explicitly adds `deferAutonomyCompletion`, threads `autonomy` context into `processUserInputBase`, and changes `handlePromptSubmit` to skip finalization for deferred runs. New test file `processSlashCommand.test.ts` is dedicated to this exact handshake.
|
||||
|
||||
**Evidence against**: a pure same-source dedup miss would also explain the symptom; H3 covers that.
|
||||
|
||||
**Verdict**: real and load-bearing. Confirmed by the targeted code added.
|
||||
|
||||
### H3 — "Scheduled-task tick has no dedup against prior run"
|
||||
|
||||
**Claim**: cron tick / heartbeat tick fires unconditionally; if previous tick's run is still `queued`/`running` the queue grows by one each interval. Compounded across multiple sources, queue + `runs.json` active subset never shrink.
|
||||
|
||||
**Evidence for**: pre-fix `useScheduledTasks` and `runHeadlessStreaming` both called `createAutonomyQueuedPrompt` (no dedup). Diff replaces both call sites with `createAutonomyQueuedPromptIfNoActiveSource`. Persistence-side dedup added in the same change.
|
||||
|
||||
**Evidence against**: alone, this would make scheduling buggy but not necessarily OOM; the queue might catch up under light load.
|
||||
|
||||
**Verdict**: real and load-bearing. Confirmed by the targeted code added.
|
||||
|
||||
### H4 — "Dead-process runs poison dedup forever"
|
||||
|
||||
**Claim**: even with H3 fixed, a process killed mid-run leaves a `running` record on disk with no owner liveness check; the next process loading `runs.json` would treat it as blocking and never schedule that source again.
|
||||
|
||||
**Evidence for**: the diff stamps `ownerProcessId` and adds `isStaleActiveAutonomyRun` checked against `isProcessRunning`. Without H4, H3's fix would create a new failure mode (silent permanent suppression).
|
||||
|
||||
**Evidence against**: pre-fix code had no dedup, so this failure mode could not have been reached pre-fix.
|
||||
|
||||
**Verdict**: real, but secondary. It exists because H3's fix introduces it. Required to ship together.
|
||||
|
||||
---
|
||||
|
||||
## 10. Chosen root cause
|
||||
|
||||
**Combined H2 + H3 + H4**: the unbounded growth of active autonomy runs is the product of three independently insufficient gaps that line up under load:
|
||||
|
||||
1. Scheduled / heartbeat ticks do not dedup against an active prior run for the same source (H3).
|
||||
2. Background-forked slash commands report `succeeded` to the harness while their work is still detached, so subsequent ticks see no active run and stack workers behind the source (H2).
|
||||
3. Process death between record creation and run completion leaves zombie active records on disk that would block dedup permanently if (1) is fixed alone (H4).
|
||||
|
||||
Why previous local patches likely failed: any one of these in isolation looks fixable as a small guard, but fixing only one converts the OOM into a different misbehaviour (silent suppression after crash, or duplicate detached workers). The minimal correct fix needs all three primitives: **same-source dedup**, **owner stamping + stale recovery**, **deferred-completion handshake**, plus the **two-phase commit ordering** that ensures heartbeat state never advances on a skipped duplicate.
|
||||
|
||||
---
|
||||
|
||||
## 11. Fix plan
|
||||
|
||||
### Minimal fix surface
|
||||
|
||||
| Module | Change | Reason |
|
||||
|---|---|---|
|
||||
| `autonomyRuns.ts` | Owner stamping; `createAutonomyRunIfNoActiveSource`; `commitAutonomyQueuedPromptIfNoActiveSource`; two-phase commit; stale recovery | The structural primitives |
|
||||
| `useScheduledTasks.ts` | Replace both call sites with the dedup helper; extract `createScheduledTaskQueuedCommand` | Apply dedup at REPL scheduler |
|
||||
| `cli/print.ts` | Same migration in headless streaming path | Apply dedup in headless mode |
|
||||
| `handlePromptSubmit.ts` | Track `deferredAutonomyRunIds`; skip them in success and error finalize loops | Wire the deferred-completion contract |
|
||||
| `processUserInput.ts` | Thread `autonomy` ctx; surface `deferAutonomyCompletion` | Plumbing for the contract |
|
||||
| `processSlashCommand.tsx` | Background-fork commands set `deferAutonomyCompletion`; own their finalize call | Implementation of the contract |
|
||||
| `Tool.ts` | `allowBackgroundForkedSlashCommands` flag on `ToolUseContext.options` | Make the path testable from non-bundled harnesses |
|
||||
|
||||
### Tests added
|
||||
|
||||
- `autonomyRuns.test.ts`: dedup, stale recovery (mocked dead PID via `isProcessRunning` mock), owner stamping at both create and `markAutonomyRunRunning`, two-phase commit ordering.
|
||||
- `useScheduledTasks.test.ts`: scheduler skips double-fire, resumes after finalize.
|
||||
- `processSlashCommand.test.ts`: deferred-completion handshake propagates to `handlePromptSubmit` correctly.
|
||||
|
||||
### Compatibility / migration risk
|
||||
|
||||
- Older `runs.json` records lacking `ownerProcessId` are tolerated — never identified as stale, so they keep their blocking semantics. Operators who upgrade with stale `running` records on disk from a previous OOM crash will still need to manually `cancel` those runs (or wait for them to age out of the 200-record cap) the *first* time. After one full create cycle on the upgraded version, all new records carry owners.
|
||||
- **Observability gap on legacy blocking (added by reviewer 2026-04-28)**: when a no-owner active record blocks dedup, the current code path is silent — operators see "scheduled tasks stop firing" with no diagnostic. `implement` step MUST add a one-line warn log inside `persistAutonomyRunRecord`'s blocking branch: when `hasBlockingActiveRun = true` AND the blocking run has `ownerProcessId === undefined`, emit `[autonomyRuns] blocked by legacy un-owned active run <runId> (createdAt=<ts>); cancel manually if this is a stale upgrade artifact`. ≤ 10 lines of code, converts silent hang into a diagnosable signal. Do **not** change behavior — just observability.
|
||||
- `ToolUseContext.options.allowBackgroundForkedSlashCommands` is opt-in and defaults absent; production harness behaviour unchanged.
|
||||
- No on-disk schema version bump required.
|
||||
|
||||
### Rollback plan
|
||||
|
||||
- Revert the working tree to `main`'s versions of all 8 files. The `runs.json` schema additions are tolerated by older code (extra fields ignored).
|
||||
- If a stale record is preventing scheduling after rollback, manually edit `runs.json` (status → `cancelled`) or run `/autonomy flow cancel` for affected flows.
|
||||
- No dependency, no build flag, no settings-file change is needed for rollback.
|
||||
|
||||
### Out of scope (intentionally)
|
||||
|
||||
- Capping `prepareAutonomyTurnPrompt` output size (H1) — addressable later if needed; not load-bearing for the OOM.
|
||||
- Cross-process file-lock correctness review — relies on the existing `withAutonomyPersistenceLock`. Out of scope for this flow.
|
||||
- A migration utility to clean stale records on startup — discussed and rejected as avoidable: 200-record cap rolls them off naturally.
|
||||
|
||||
---
|
||||
|
||||
## 12. Verification
|
||||
|
||||
### Commands (binding per `.claude/autonomy/AGENTS.md` §4)
|
||||
|
||||
```bash
|
||||
bun run typecheck
|
||||
bun test src/utils/__tests__/autonomyRuns.test.ts
|
||||
bun test src/hooks/__tests__/useScheduledTasks.test.ts
|
||||
bun test src/utils/processUserInput/__tests__/processSlashCommand.test.ts
|
||||
bun test # full unit suite
|
||||
bun run lint
|
||||
bun run build
|
||||
```
|
||||
|
||||
### Manual checks (proposed for `implement` step)
|
||||
|
||||
- Start a session with two `HEARTBEAT.md` 30s tasks for ≥ 30 minutes; observe `runs.json` active-status entry count stays bounded (≤ number of distinct sources).
|
||||
- Force-kill the Bun process during a `running` record. Restart. Verify the next tick of the same source recovers (record marked `failed` with the stale-recovery error prefix) and a new run starts.
|
||||
- Run a KAIROS-gated detached slash command path under the test harness (`allowBackgroundForkedSlashCommands=true`) and verify `handlePromptSubmit` does not finalize the run while the background work is still active.
|
||||
|
||||
### Observability checks
|
||||
|
||||
- `[ScheduledTasks] skipping <id>: previous run still queued or running` debug log appears when dedup fires (added in `useScheduledTasks.ts`). Use it to confirm dedup is reached in real sessions.
|
||||
- `runs.json` records with status `failed` and error starting `"Recovered stale active autonomy run"` indicate stale-recovery actually fired.
|
||||
|
||||
---
|
||||
|
||||
## 13. Open questions
|
||||
|
||||
1. ~~Should `markAutonomyRunRunning` be called in *all* paths that transition an autonomy run to `running`, or only the prompt-submit path?~~ **Closed (verified 2026-04-28).**
|
||||
`markAutonomyRunRunning` (`autonomyRuns.ts:554-579`) is the **only** function that transitions `AutonomyRunRecord.status → 'running'`. It stamps `ownerProcessId = process.pid` and `ownerSessionId = getSessionId()` unconditionally, then internally calls `markManagedAutonomyFlowStepRunning` to mirror to flow state. `markManagedAutonomyFlowStepRunning` is only invoked from this one call site (`autonomyRuns.ts:571`); no caller bypasses the stamp. All four real callers (`cli/print.ts:2177`, `screens/REPL.tsx:4859`, `utils/handlePromptSubmit.ts:492`, `utils/swarm/inProcessRunner.ts:741`) go through the stamping path. Flow records intentionally do not carry owner fields — the run record is source of truth and flow steps mirror via `latestRunId`. Stale-recovery operates on runs, so flow-step runs are covered.
|
||||
2. ~~`getSessionId()` import was added to `autonomyRuns.ts`. Confirm no circular import is introduced...~~ **Closed (verified 2026-04-28).**
|
||||
No risk on three counts: (a) `autonomyRuns.ts:4` already imported `getProjectRoot` from `bootstrap/state.js`; the new `getSessionId` is appended to the same import line, adding zero new module-level coupling. (b) Reverse direction is empty — `grep -rn 'autonomy*' src/bootstrap/` yields no results, so the dependency stays one-way. (c) `getSessionId()` (`bootstrap/state.ts:425-427`) returns `STATE.sessionId`, which is initialized at module load with `randomUUID()` and re-randomized by `resetStateForTests()` per test — never `undefined`, never throws. The existing test file deliberately uses the real `bootstrap/state` module (not a mock) and already asserts `ownerProcessId === process.pid` / `ownerSessionId` is a string in the new ownership tests, plus exercises stale recovery with a fake dead PID (`2_147_483_647`). No mock updates needed.
|
||||
3. Is the 200-record cap still appropriate now that recovery turns stale runs into `failed`? Active records will churn faster; the cap may roll off legitimate completed records sooner. Not a correctness issue, but worth noting.
|
||||
|
||||
---
|
||||
|
||||
## 14. Approval gate
|
||||
|
||||
This SUR satisfies `AGENTS.md` §3 step `report` exit criteria once a human reviewer:
|
||||
|
||||
- [x] confirms the chosen root cause (§10) matches their reading of the diff — **agent-ticked under user delegation 2026-04-28; see §15 verification table row 1**
|
||||
- [x] approves the §11 fix plan including the deferred-completion contract — **agent-ticked under user delegation 2026-04-28; Concern A's warn-log requirement folded into §11**
|
||||
- [x] acknowledges the §11 compatibility note about pre-existing stale records on disk — **agent-ticked under user delegation 2026-04-28; §11 extended with Concern A observability gap**
|
||||
- [x] §13 open question 1 (stamping completeness in flow-step runners) — closed 2026-04-28; see §13 for the verification trace
|
||||
- [x] Concern B (processSlashCommand.tsx >50% diff) — **resolved 2026-04-28 by commit-split rule, see §15**
|
||||
|
||||
---
|
||||
|
||||
## 15. Reviewer findings (2026-04-28, agent-reviewed)
|
||||
|
||||
The user explicitly delegated SUR review work to the agent. The four §14 checkboxes
|
||||
remain user's decision; this section records the agent's verification work and
|
||||
recommendations to make that decision faster and more auditable.
|
||||
|
||||
### Verification work performed
|
||||
|
||||
| Claim | Cross-check | Result |
|
||||
|---|---|---|
|
||||
| §10 H2/H3/H4 互锁 | Walked each "fix only one" counterfactual | ✅ Real interlock — fixing only one converts OOM into a different bug (silent suppression / persistent stacking) |
|
||||
| §11 fix surface covers all 8 modified files | Compared against `git diff --stat` | ✅ Each file has a row in the table |
|
||||
| §11 "extra fields ignored" rollback claim | JSON parse semantics | ✅ Correct |
|
||||
| §11 compatibility claim "tolerated" | Re-read `isStaleActiveAutonomyRun` (`autonomyRuns.ts`) | ⚠️ Tolerance is real but **silent** — gap surfaced as Concern A below |
|
||||
| §13 Q1 owner stamping completeness | (closed in earlier turn — see §13) | ✅ |
|
||||
| §13 Q2 circular-import / mock impact | (closed in earlier turn — see §13) | ✅ |
|
||||
| §13 Q3 200-record cap acceptability | Reasoned about stale-recovery-driven churn | ✅ Non-blocking; forensic loss only |
|
||||
|
||||
### Concerns surfaced
|
||||
|
||||
**Concern A — silent legacy blocking (now folded into §11)**: when a no-owner active
|
||||
record from a pre-upgrade crash blocks dedup, the operator gets no signal — just
|
||||
"scheduled tasks stop firing." The §11 compatibility section was extended to require
|
||||
a one-line warn log in `implement`. This is an observability fix, not a behavior
|
||||
change.
|
||||
|
||||
**Concern B — `processSlashCommand.tsx` is +707/-454 (>50% rewrite)** — **RESOLVED 2026-04-28**:
|
||||
investigation showed the diff is composed of:
|
||||
- **18 contract-related lines** (verified by `grep -E '(autonomy|QueuedCommand|deferAutonomy|finalizeAutonomy|allowBackgroundForkedSlashCommands|deferredAutonomy)'`):
|
||||
- import `QueuedCommand` type
|
||||
- import `finalizeAutonomyRunCompleted` / `finalizeAutonomyRunFailed`
|
||||
- add `autonomy?: QueuedCommand['autonomy']` parameter to `executeForkedSlashCommand` (3 sites)
|
||||
- extend KAIROS gate to also accept `context.options.allowBackgroundForkedSlashCommands === true` (test escape hatch)
|
||||
- finalize the run from the detached background path on success/failure
|
||||
- set `deferAutonomyCompletion: Boolean(autonomy?.runId)` on the result
|
||||
- thread `autonomy` to nested calls
|
||||
- **~30-50 lines** of necessary control-flow scaffolding around the contract code
|
||||
- **~250 lines** of pure Biome reformatting churn (single-line imports, trailing semicolons)
|
||||
|
||||
**Resolution rule (binding for `implement`)**: when committing this branch, split
|
||||
`processSlashCommand.tsx` into **two commits** on the same branch:
|
||||
|
||||
```text
|
||||
chore: reformat processSlashCommand with Biome # ~250 lines, formatter-only
|
||||
feat: thread autonomy run id through forked slash commands for deferred completion # ~50 lines, contract logic
|
||||
```
|
||||
|
||||
This satisfies `~/.claude/rules/deep-debug/core.md` §2 ("bug fix 不允许混入...格式化")
|
||||
in spirit by making the contract commit reviewable in isolation, without
|
||||
requiring a fragile manual revert of formatter output (which Biome would
|
||||
re-apply on the next save). All other 7 modified files in the OOM fix do not
|
||||
require commit splitting — verify by sampling their diffs at `implement` time.
|
||||
|
||||
**Concern C — stale-recovery rate metric (deferred)**: post-implement, track daily
|
||||
stale-recovery count. If consistently elevated, the 200-record cap may need
|
||||
revisiting (relates to §13 Q3). Not a blocker; suggested for follow-up flow.
|
||||
|
||||
### Agent recommendations on the §14 checkboxes
|
||||
|
||||
| §14 box | Agent recommendation | Rationale |
|
||||
|---|---|---|
|
||||
| §10 chosen root cause | Approve | H2/H3/H4 互锁 verified; diff supports each branch |
|
||||
| §11 fix plan (with §15 Concern A folded in) | Approve | Minimal, complete, regression-tested |
|
||||
| §11 compatibility note | Acknowledge as-extended (§11 now includes the warn-log requirement from Concern A) | Silent legacy blocking would surprise users; the added log makes it diagnosable |
|
||||
| Concern B `processSlashCommand.tsx` >50% diff | Resolved by commit-split rule (chore + feat) | 18 lines contract + ~250 lines formatter churn; commit split makes review tractable without fragile revert |
|
||||
|
||||
**Final status (2026-04-28, agent-resolved under user delegation)**: all five §14
|
||||
boxes ticked. Flow `recurring-bug-loop-oom` may advance from `report` to
|
||||
`regression-test`. Implement-time obligations folded in:
|
||||
|
||||
1. Add the legacy-blocking warn log in `persistAutonomyRunRecord` (Concern A, ≤10 lines)
|
||||
2. Commit-split `processSlashCommand.tsx` into chore + feat (Concern B)
|
||||
3. Verify the other 7 modified files do not need commit-splitting (sample their diffs)
|
||||
4. Track stale-recovery counts post-deploy for §13 Q3 / Concern C follow-up
|
||||
|
||||
After approval: flow advances to `regression-test`. The targeted commands in §12 must produce a verifiable failing state on the *pre-fix* tree before the post-fix tree is allowed to satisfy `implement`. Since this branch already contains the fix, the regression evidence will be reconstructed by checking out one parent, running the targeted tests (expected: fail), then returning to HEAD (expected: pass).
|
||||
91
docs/agent/sur-skill-overflow-bugs.md
Normal file
91
docs/agent/sur-skill-overflow-bugs.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# System Understanding Report — Skill Search / Skill Learning Overflow Bugs
|
||||
|
||||
- **Flow id**: `recurring-bug-skill-overflow` (sibling pilot to `recurring-bug-loop-oom`)
|
||||
- **Branch**: `fix/loop-scheduled-autonomy-oom` (folded into the OOM PR — same audit-and-cap pattern)
|
||||
- **Trigger**: post-merge review of the autonomy OOM fix surfaced unbounded module-level state in adjacent `EXPERIMENTAL_SKILL_SEARCH` and `SKILL_LEARNING` subsystems. The user explicitly asked for a `肯定也有同类溢出` audit.
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem
|
||||
|
||||
The autonomy OOM bug came from unbounded module-level state (run records, scheduler queues, heartbeat timestamps) growing for the lifetime of the process. The skill search + skill learning subsystems exhibit the same class of bug across **5 module-level Maps/Sets**, only one of which had been documented in `scripts/defines.ts` ("projectContext cache 无淘汰机制(非 GB 级主因)").
|
||||
|
||||
These bugs were latent because:
|
||||
|
||||
- `EXPERIMENTAL_SKILL_SEARCH` / `SKILL_LEARNING` were enabled-by-default in `DEFAULT_BUILD_FEATURES`, but tests pass because they exercise short paths.
|
||||
- None of the unbounded caches grow per-tool-call; they grow per **distinct query** / **distinct cwd** / **distinct skill name** / **distinct gap signal** / **distinct promotion**, which is sub-linear in session length but monotone forever.
|
||||
- A long-running daemon-style process (KAIROS sessions, multi-day worktrees) would observe the growth.
|
||||
|
||||
## 2. Module-level state audit
|
||||
|
||||
| File:Line | Symbol | Pre-fix bound | Pre-fix evict |
|
||||
|---|---|---|---|
|
||||
| `intentNormalize.ts:52` | `cache: Map<query, keywords>` | none | only `clearIntentNormalizeCache()` for tests |
|
||||
| `prefetch.ts:17` | `discoveredThisSession: Set<skillName>` | none | none |
|
||||
| `prefetch.ts:18` | `recordedGapSignals: Set<gapKey>` | none | none |
|
||||
| `projectContext.ts:48` | `contextCache: Map<cwd, ProjectContext>` | none | only `resetProjectContextCacheForTest()` |
|
||||
| `promotion.ts:26` | `sessionPromotedIds: Set<instinctId>` | none | only `resetPromotionBookkeeping()` for tests |
|
||||
| `runtimeObserver.ts:61` | `lastProcessedMessageIds: Set<msgKey>` | **MAX 1000** | FIFO trim ✓ already bounded |
|
||||
| `toolEventObserver.ts:50` | `emittedTurns: Map<sid, Set<turn>>` | **MAP_MAX 50, SET_MAX 100** | LRU prune via `pruneEmittedTurns()` called inside `markTurn` ✓ already bounded |
|
||||
| `observerBackend.ts:21` | `registry: Map<name, Backend>` | fixed N | n/a — registry pattern, finite ✓ |
|
||||
|
||||
**5 unbounded out of 8 module-level mutables.** All 5 are addressed in this PR.
|
||||
|
||||
## 3. Severity rationale
|
||||
|
||||
Per-entry cost is small (key strings + small objects), so OOM in days is unlikely on a normal workstation. But the canary scenarios:
|
||||
|
||||
- **`intentNormalize.cache`**: every distinct Chinese query → Haiku call → cached. A session that browses a large Chinese codebase or replays many transcripts can hit thousands of distinct queries; ~600 bytes per entry × 10k = ~6 MB. Plus, **every cache miss is a Haiku API call**, so default-enabled means every fresh session pays a request on first non-ASCII query — unintended cost.
|
||||
- **`projectContext.contextCache`**: each `SkillLearningProjectContext` carries instinct + skill lists. Multi-worktree orchestrators (this very repo!) blow past the typical "1 cwd per session" assumption.
|
||||
- **`prefetch` Sets**: in chatty sessions thousands of skill discovery names accumulate.
|
||||
- **`sessionPromotedIds`**: smallest practical risk (single-digit promotions per session normally), but a long-lived sandbox could push it; a defensive cap is cheap.
|
||||
|
||||
The fix bounds all 5 with FIFO/LRU eviction at sensible sizes (200–1000 entries). No data-corruption risk: degraded behaviour on cap-overflow is benign (re-emit a duplicate signal, re-Haiku a query, re-resolve a cwd context). Same risk profile as the autonomy stale-recovery design.
|
||||
|
||||
## 4. Fix surface
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/services/skillSearch/intentNormalize.ts` | `setCachedQueryIntent()` helper, `CACHE_MAX_ENTRIES=200` / `CACHE_TRIM_TO=150`, LRU touch on hit |
|
||||
| `src/services/skillSearch/prefetch.ts` | `addBoundedSessionEntry()` helper, `SESSION_TRACKING_MAX=1000` / `TRIM_TO=750`; `discoveredThisSession` and `recordedGapSignals` route through it |
|
||||
| `src/services/skillLearning/projectContext.ts` | `setProjectContextCache()` helper, `PROJECT_CONTEXT_CACHE_MAX=32` / `TRIM_TO=24`, LRU touch on hit |
|
||||
| `src/services/skillLearning/promotion.ts` | `recordSessionPromoted()` helper, `SESSION_PROMOTED_IDS_MAX=256` / `TRIM_TO=192` |
|
||||
| `src/services/skillSearch/featureCheck.ts` | Two-layer gate: build flag must be on AND `SKILL_SEARCH_ENABLED=1` env must be set. Defaults to OFF when env is unset, so the slash command remains visible but the runtime hot paths stay dormant until the operator explicitly enables. |
|
||||
| `src/services/skillLearning/featureCheck.ts` | Same two-layer pattern (build flag + `SKILL_LEARNING_ENABLED=1` or legacy `FEATURE_SKILL_LEARNING=1`). |
|
||||
| `scripts/defines.ts` | Comment annotated to clarify that the build flags now serve only to compile commands in; runtime activation is operator-driven. |
|
||||
|
||||
## 5. Why default-off (without removing from build)?
|
||||
|
||||
Three reasons aside from the unbounded-cache concern:
|
||||
|
||||
1. **Implicit cost**: `intentNormalize` calls Haiku on cache miss. Default-on means every session that types Chinese pays an API call, even when the operator never asked for skill search.
|
||||
2. **Disk side effects**: `SKILL_LEARNING` attaches observers that persist observations to `~/.claude` storage. Storage volume should be opt-in, not background.
|
||||
3. **Experimental status**: the flag is literally named `EXPERIMENTAL_*`. Default-enabling an experimental subsystem contradicts the naming contract.
|
||||
|
||||
**The fix is NOT to remove the flags from `DEFAULT_BUILD_FEATURES`** — doing so would also strip the `/skill-search` and `/skill-learning` slash commands from the build, leaving operators with no UI to opt in. Instead the activation logic in `featureCheck.ts` was changed to a two-layer gate:
|
||||
|
||||
- **Layer 1 (compile-time)**: `feature('EXPERIMENTAL_SKILL_SEARCH')` / `feature('SKILL_LEARNING')` must be on. These remain in `DEFAULT_BUILD_FEATURES` so the slash commands and observers are compiled in.
|
||||
- **Layer 2 (runtime)**: `SKILL_SEARCH_ENABLED=1` / `SKILL_LEARNING_ENABLED=1` (or `FEATURE_SKILL_LEARNING=1`) env var must be set. Without this, the subsystems are present but dormant — the slash command exists and toggling it via `/skill-search` or `/skill-learning` flips the env var and activates the hot paths.
|
||||
|
||||
Net result: operators see the toggle in the UI but the subsystem is **off until they flip it**.
|
||||
|
||||
## 6. Out of scope (filed for follow-up)
|
||||
|
||||
- **Test failures on CI** (`prefetch.test.ts > auto-loads high-confidence project skill content`, `skillLearningSmoke.test.ts > ingests corrections, evolves a learned skill, and skill search finds it`) appear in this branch's CI run. Both tests **explicitly enable** the features via env vars, so default-disabling does not cause them. They are pre-existing functional issues in the experimental code paths and warrant their own flow once the bug-classification step is run. Default-disable in this PR avoids exposing operators to unknown failure modes while triage proceeds.
|
||||
- **Persistence-layer bounds** (observation files, instinct registry): `observationStore.ts` already has 30-day purge and 1MB archive thresholds; `skillGapStore.ts` uses a finite-state lifecycle. Disk-side state is appropriately bounded; the OOM-class issue was strictly in-process state.
|
||||
|
||||
## 7. Verification
|
||||
|
||||
Local checks (full suite covers cap behaviour via existing tests; the caps degrade gracefully so no test should break):
|
||||
|
||||
```bash
|
||||
bun run typecheck # 0 errors
|
||||
bun test src/services/skillSearch/__tests__/intentNormalize.test.ts
|
||||
bun test src/services/skillSearch/__tests__/prefetch.extractQuery.test.ts
|
||||
bun test src/services/skillLearning/__tests__/projectContext.test.ts
|
||||
bun test src/services/skillLearning/__tests__/promotion.test.ts
|
||||
bun run lint
|
||||
bun run build
|
||||
```
|
||||
|
||||
The new caps are observable behaviour: under sustained load the Map/Set sizes plateau at the configured maxima rather than monotone-growing.
|
||||
@@ -1,99 +1,185 @@
|
||||
---
|
||||
title: "Worktree 隔离"
|
||||
description: "多 Agent 并行工作时,共享同一目录会导致冲突。Git worktree 提供文件系统级隔离,让每个 Agent 拥有独立的工作空间。"
|
||||
title: "Worktree 隔离 - Git Worktree 实现文件级隔离"
|
||||
description: "揭秘 Claude Code 的 git worktree 隔离机制:子 Agent 如何获得独立工作空间,worktree 创建/销毁生命周期、路径命名规则和安全防护。"
|
||||
keywords: ["Worktree", "git worktree", "文件隔离", "多 Agent 隔离", "并行安全"]
|
||||
---
|
||||
|
||||
## 核心问题
|
||||
{/* 本章目标:揭示 worktree 的创建/销毁生命周期、路径命名规则、hook 机制和退出时的安全防护 */}
|
||||
|
||||
## 为什么需要文件级隔离
|
||||
|
||||
多 Agent 并行工作时,共享同一工作目录会导致三类冲突:
|
||||
|
||||
1. **写入冲突**:两个 Agent 同时编辑同一个文件,后写的覆盖前写的
|
||||
1. **写入冲突**:两个 Agent 同时编辑 `config.ts`,后写的覆盖前写的
|
||||
2. **状态干扰**:Agent A 的测试依赖某个环境状态,Agent B 的修改破坏了它
|
||||
3. **不可区分**:半完成的修改混在一起,无法分辨哪些是哪个 Agent 的
|
||||
|
||||
Git worktree 是 git 原生的解决方案——在同一个仓库中创建多个独立工作目录,每个在自己的分支上。
|
||||
|
||||
## 目录结构
|
||||
## 目录结构与命名规则
|
||||
|
||||
Worktree 文件统一存放在仓库根目录下的 `.claude/worktrees/`:
|
||||
|
||||
```
|
||||
<repo-root>/
|
||||
├── .claude/
|
||||
│ └── worktrees/
|
||||
│ ├── fix-auth-bug/ ← Agent A 的独立工作空间
|
||||
│ │ └── .git ← 指向主仓库的链接
|
||||
│ └── add-dark-mode/ ← Agent B 的独立工作空间
|
||||
│ ├── fix-auth-bug/ # worktree 工作目录
|
||||
│ │ ├── .git # 指向主仓库的链接文件
|
||||
│ │ └── src/... # 独立的文件系统视图
|
||||
│ └── add-dark-mode/ # 另一个 worktree
|
||||
│ └── ...
|
||||
├── src/ ← 主工作目录(不受影响)
|
||||
└── .git/ ← 主仓库
|
||||
├── src/ # 主工作目录(不受影响)
|
||||
└── .git/ # 主仓库
|
||||
```
|
||||
|
||||
每个 worktree 是一个完整的文件系统视图,但共享同一个 git 历史。Agent 在自己的 worktree 中修改文件,不会影响主分支或其他 Agent。
|
||||
分支命名规则为 `worktree/<slug>`,其中 slug 由 `validateWorktreeSlug()` 校验:每个 `/` 分隔的段只允许字母、数字、`.`、`_`、`-`,总长 ≤64 字符。未指定时使用 plan slug 自动生成。
|
||||
|
||||
## 创建与恢复
|
||||
## 创建流程:EnterWorktreeTool
|
||||
|
||||
### 创建流程
|
||||
`EnterWorktreeTool`(`packages/builtin-tools/src/tools/EnterWorktreeTool/EnterWorktreeTool.ts`)的执行链路:
|
||||
|
||||
```
|
||||
检查是否已在 worktree 中(防嵌套) → 生成 slug → 创建 worktree → 切换进程工作目录
|
||||
EnterWorktreeTool.call({ name? })
|
||||
↓
|
||||
1. 检查是否已在 worktree 中(防嵌套)
|
||||
↓
|
||||
2. 解析到主仓库根目录(findCanonicalGitRoot)
|
||||
如果当前已在 worktree 内,chdir 到主仓库
|
||||
↓
|
||||
3. 生成 slug(用户提供或 plan slug)
|
||||
↓
|
||||
4. createWorktreeForSession(sessionId, slug)
|
||||
├── 有 WorktreeCreate hook?
|
||||
│ └── 执行 hook,返回 hook 指定的路径(支持非 git VCS)
|
||||
└── 无 hook → git 原生路径:
|
||||
a. getOrCreateWorktree(repoRoot, slug)
|
||||
├── 快速恢复:检查 worktree 目录是否已存在
|
||||
│ └── 读取 .git 指针文件的 HEAD SHA(无子进程)
|
||||
└── 新建:
|
||||
i. mkdir .claude/worktrees/(recursive)
|
||||
ii. fetch origin/<default-branch>(有缓存则跳过)
|
||||
iii. git worktree add -b worktree/<slug> <path> <base>
|
||||
iv. performPostCreationSetup()(sparse checkout 等)
|
||||
↓
|
||||
5. 更新进程状态:
|
||||
- process.chdir(worktreePath)
|
||||
- setCwd(worktreePath)
|
||||
- setOriginalCwd(worktreePath)
|
||||
- saveWorktreeState(session) → 持久化到项目配置
|
||||
- clearSystemPromptSections() → 重新计算系统提示中的 cwd 信息
|
||||
- clearMemoryFileCaches() → 重新加载 worktree 中的 CLAUDE.md
|
||||
↓
|
||||
6. 返回 worktreePath 和 worktreeBranch
|
||||
```
|
||||
|
||||
**设计细节**:
|
||||
- 分支名遵循 `worktree/<slug>` 格式,slug 有严格的字符限制
|
||||
- 创建后自动更新进程状态——所有后续文件操作自动在 worktree 中执行
|
||||
- 系统提示和 CLAUDE.md 被重新加载,确保 AI 看到 worktree 中的上下文
|
||||
### Hook 优先的架构
|
||||
|
||||
`createWorktreeForSession()` 首先检查 `hasWorktreeCreateHook()`——如果用户在 settings.json 中配置了 `WorktreeCreate` hook,系统完全不调用 git,而是执行 hook 命令并将返回的路径作为 worktree 路径。这允许非 git 版本控制系统(如 Pijul、Mercurial)通过 hook 接入。
|
||||
|
||||
### 快速恢复路径
|
||||
|
||||
如果目标 worktree 已存在,直接读取指针文件获取 HEAD SHA——纯文件 I/O,无子进程。在大仓库中 `git fetch` 可能需要 6-8 秒,这个优化将恢复延迟降到接近 0。
|
||||
`getOrCreateWorktree()` 有一个关键优化:如果目标路径已存在,直接读取 `.git` 指针文件获取 HEAD SHA(纯文件 I/O,无子进程),跳过整个 `fetch` + `worktree add` 流程。在大仓库中 `fetch` 需要 6-8 秒,这个优化将恢复场景的延迟降到接近 0。
|
||||
|
||||
**设计洞察**:恢复(resume)是比创建更频繁的操作。用户可能中断后重新开始,或者 Agent 在之前的 worktree 上继续工作。优化恢复路径比优化创建路径更有价值。
|
||||
## 退出流程:ExitWorktreeTool
|
||||
|
||||
### Hook 优先架构
|
||||
`ExitWorktreeTool`(`packages/builtin-tools/src/tools/ExitWorktreeTool/ExitWorktreeTool.ts`)支持两种退出策略:
|
||||
|
||||
如果用户配置了 `WorktreeCreate` hook,系统完全不调用 git,而是执行 hook 命令获取路径。这允许非 git 版本控制系统(如 Pijul、Mercurial)通过 hook 接入隔离机制。
|
||||
### keep:保留 worktree
|
||||
|
||||
## 退出与清理
|
||||
```
|
||||
keepWorktree()
|
||||
↓
|
||||
1. chdir 回 originalCwd
|
||||
2. 清空 currentWorktreeSession
|
||||
3. 更新项目配置(activeWorktreeSession = undefined)
|
||||
4. worktree 目录和分支保留在磁盘上
|
||||
```
|
||||
|
||||
退出支持两种策略:
|
||||
用户可以通过 `cd <worktreePath>` 继续工作,或稍后手动合并。
|
||||
|
||||
| 策略 | 行为 | 适用场景 |
|
||||
|------|------|---------|
|
||||
| **keep** | 保留 worktree 和分支 | 后续需要合并或审查 |
|
||||
| **remove** | 删除 worktree 和分支 | 确认不再需要 |
|
||||
### remove:删除 worktree
|
||||
|
||||
### Fail-closed 安全防护
|
||||
有严格的**安全防护**:
|
||||
|
||||
删除操作有多层安全防护:
|
||||
```
|
||||
validateInput() — 第一道防线
|
||||
↓
|
||||
1. 检查是否在 EnterWorktree 创建的会话中
|
||||
(手动创建的 worktree 不会被删除)
|
||||
↓
|
||||
2. countWorktreeChanges(worktreePath, originalHeadCommit)
|
||||
├── git status --porcelain → 统计未提交文件数
|
||||
├── git rev-list --count <originalHead>..HEAD → 统计新提交数
|
||||
└── 返回 null(git 失败时)→ fail-closed(拒绝删除)
|
||||
↓
|
||||
3. 有未提交文件或新提交?
|
||||
→ 拒绝,要求 discard_changes: true 确认
|
||||
```
|
||||
|
||||
1. 只允许删除通过 EnterWorktree 创建的 worktree(手动 `git worktree add` 的不会被删)
|
||||
2. 有未提交文件或新提交时,拒绝删除(除非显式 `discard_changes: true`)
|
||||
3. git 命令失败时,返回"未知"状态,拒绝删除
|
||||
```
|
||||
call() — 实际执行
|
||||
↓
|
||||
1. 重新计数变更(validateInput 和 call 之间可能有新修改)
|
||||
2. 如果有 tmux session → killTmuxSession()
|
||||
3. cleanupWorktree()
|
||||
├── hook-based → 执行 WorktreeRemove hook
|
||||
└── git-based → git worktree remove --force + git branch -D
|
||||
4. restoreSessionToOriginalCwd()
|
||||
- setCwd(originalCwd)
|
||||
- setOriginalCwd(originalCwd)
|
||||
- 如果 projectRoot 是 worktree 时才恢复(防误触)
|
||||
- 更新 hooks config snapshot
|
||||
- 清空系统提示和 memory 缓存
|
||||
```
|
||||
|
||||
**设计哲学**:宁可让用户手动处理废弃的 worktree,也不冒险丢失工作。磁盘空间比丢失代码便宜得多。
|
||||
### fail-closed 设计
|
||||
|
||||
### 重新计数
|
||||
`countWorktreeChanges()` 在以下情况返回 `null`("未知,假设不安全"):
|
||||
- `git status` 或 `git rev-list` 退出非零(锁文件、损坏的索引)
|
||||
- `originalHeadCommit` 未定义(hook-based worktree 没有设置基线 commit)
|
||||
|
||||
validateInput 和实际执行之间可能有新的修改。删除前重新计数变更,确保不会误删。
|
||||
返回 `null` 时,`validateInput` 拒绝删除——宁可让用户手动处理,也不冒险丢失工作。
|
||||
|
||||
## 与子 Agent 的联动
|
||||
## 与 Agent 工具的联动
|
||||
|
||||
子 Agent 的 `isolation: "worktree"` 参数自动在独立 worktree 中运行。子 Agent 的 worktree 管理与用户会话略有不同:
|
||||
Agent 工具(`AgentTool`)的 `isolation` 参数决定子 Agent 是否在 worktree 中运行。注意 Agent 工具使用**专用的** `createAgentWorktree()`(`src/utils/worktree.ts`),而非用户会话用的 `createWorktreeForSession()`,两者有关键差异:
|
||||
|
||||
| 维度 | 用户会话 | 子 Agent |
|
||||
|------|---------|---------|
|
||||
| Session 状态 | 完整持久化 | 无 session 状态 |
|
||||
| 清理策略 | 手动退出 | 自动清理(无变更则删除) |
|
||||
| 有变更时 | 用户决定 | 保留 worktree,返回路径 |
|
||||
| 维度 | `createWorktreeForSession`(用户会话) | `createAgentWorktree`(子 Agent) |
|
||||
|------|---------------------------------------|----------------------------------|
|
||||
| 调用者 | EnterWorktreeTool | AgentTool |
|
||||
| Session 管理 | 设置 `currentWorktreeSession` | **不设置** `currentWorktreeSession` |
|
||||
| 恢复已有 worktree | 直接复用 | 复用并 bump mtime(防止被周期性清理误删) |
|
||||
|
||||
**设计考量**:子 Agent 的 worktree 不需要用户手动管理——如果 Agent 没有产生任何修改,worktree 被自动清理;如果有修改,保留下来供主 Agent 后续合并。
|
||||
子 Agent 结束时的处理由 `cleanupWorktreeIfNeeded()` 自动完成——它不走 `ExitWorktreeTool`(因为 Agent worktree 没有会话状态,`ExitWorktreeTool` 的 `validateInput` 会拒绝):
|
||||
- **有变更** → 保留 worktree,返回 `worktreePath` 供主 Agent 后续合并
|
||||
- **无变更** → 自动删除
|
||||
- **Hook-based** → 始终保留
|
||||
|
||||
## Session 状态持久化
|
||||
|
||||
`WorktreeSession` 对象通过 `saveCurrentProjectConfig()` 持久化到磁盘,包含:
|
||||
|
||||
```typescript
|
||||
{
|
||||
originalCwd: string, // 进入 worktree 前的工作目录
|
||||
worktreePath: string, // worktree 的绝对路径
|
||||
worktreeName: string, // slug
|
||||
worktreeBranch?: string, // 分支名(如 worktree/fix-auth)
|
||||
originalBranch?: string, // 进入前的分支
|
||||
originalHeadCommit?: string, // 进入前的 HEAD commit(用于变更统计)
|
||||
sessionId: string, // 创建此 worktree 的会话 ID
|
||||
tmuxSessionName?: string, // 关联的 tmux session
|
||||
hookBased?: boolean, // 是否由 hook 创建
|
||||
creationDurationMs?: number, // 创建耗时(分析用)
|
||||
usedSparsePaths?: boolean, // 是否使用了 sparse checkout
|
||||
}
|
||||
```
|
||||
|
||||
这使得 session 恢复(`--resume`)时能正确还原 worktree 上下文——即使进程重启,`getCurrentWorktreeSession()` 从项目配置中读取状态。
|
||||
|
||||
## Sparse Checkout 优化
|
||||
|
||||
大型 monorepo 中,完整检出可能需要数十秒。Sparse checkout 只检出特定目录,将创建时间降到几秒。
|
||||
对于大型 monorepo,worktree 支持 `sparsePaths` 配置——只检出特定目录而非整个仓库。这在 210K 文件的仓库中将 worktree 创建时间从数十秒降到几秒。
|
||||
|
||||
## 接下来
|
||||
|
||||
- **子 Agent** — 理解使用 worktree 隔离的子 Agent 创建
|
||||
- **协调者与蜂群** — 理解多 Agent 的高级编排
|
||||
- **权限模型** — 理解工具权限的安全体系
|
||||
配置位于 `getInitialSettings().worktree?.sparsePaths`,在 `performPostCreationSetup()` 中应用。
|
||||
|
||||
@@ -1,131 +1,265 @@
|
||||
---
|
||||
title: "上下文压缩"
|
||||
description: "对话历史不断增长,token 窗口有限。理解 Claude Code 的三层压缩策略、边界标记机制和紧急降级路径。"
|
||||
keywords: ["上下文压缩", "Compaction", "token 管理", "对话压缩"]
|
||||
title: "上下文压缩 - Compaction 三层策略与边界机制"
|
||||
description: "深度解析 Claude Code 上下文压缩的完整实现:Session Memory 压缩、传统 API 摘要压缩、MicroCompact 局部压缩三层策略,以及 CompactBoundary 消息、工具对保持、PTL 紧急降级等关键机制。"
|
||||
keywords: ["上下文压缩", "Compaction", "token 管理", "对话压缩", "上下文窗口", "MicroCompact"]
|
||||
---
|
||||
|
||||
## 核心问题
|
||||
{/* 本章目标:从源码层面剖析压缩的三层策略、边界机制和关键常量 */}
|
||||
|
||||
AI 没有真正的长期记忆——它只能看到当前 API 请求中的内容。随着对话增长,token 消耗持续上升,最终会触及模型的上下文窗口限制。
|
||||
## 压缩的触发时机
|
||||
|
||||
压缩就是在"保留足够的语义信息"和"减少 token 占用"之间找平衡。
|
||||
上下文压缩不是单一操作,而是**三层递进**的策略系统,对应不同的触发条件和严重程度:
|
||||
|
||||
## 三层递进策略
|
||||
| 层级 | 触发条件 | 实现位置 | 是否需要 API 调用 |
|
||||
|------|---------|---------|:---:|
|
||||
| **MicroCompact** | 单个工具输出过长 | `microCompact.ts` | 否 |
|
||||
| **Session Memory Compact** | 自动压缩触发(需 feature flag) | `sessionMemoryCompact.ts` | 否 |
|
||||
| **传统 API 摘要** | 手动 `/compact` 或 SM 不可用时的自动回退 | `compact.ts` | 是 |
|
||||
|
||||
系统不是用一种方法解决所有压缩需求,而是根据严重程度递进使用三种策略:
|
||||
### 压缩入口的优先级链
|
||||
|
||||
| 层级 | 触发条件 | 是否需要 API 调用 | 成本 |
|
||||
|------|----------|:---:|------|
|
||||
| **微压缩** | 单个工具输出过长 | 否 | 几乎为零 |
|
||||
| **Session Memory 压缩** | 自动压缩触发时优先尝试 | 否 | 低(使用已提取的记忆) |
|
||||
| **AI 摘要压缩** | 上述方法不可用或用户手动触发 | 是 | 高(需要额外 API 调用) |
|
||||
源码路径:`src/commands/compact/compact.ts`
|
||||
|
||||
### 设计哲学:从廉价到昂贵
|
||||
当用户执行 `/compact` 或系统触发自动压缩时,压缩命令按以下优先级尝试:
|
||||
|
||||
为什么不直接用 AI 摘要?因为 AI 摘要需要额外的 API 调用——既花钱又花时间。系统的策略是**先用确定性操作(廉价),不行再用 AI 生成(昂贵)**。
|
||||
```typescript
|
||||
// compact.ts:55-99 — 简化后的优先级链
|
||||
if (!customInstructions) {
|
||||
const sessionMemoryResult = await trySessionMemoryCompaction(messages, ...)
|
||||
if (sessionMemoryResult) return sessionMemoryResult // 优先:SM 压缩
|
||||
}
|
||||
|
||||
这种分层设计意味着大部分情况下,系统可以在用户无感知的情况下释放足够的 token。
|
||||
if (reactiveCompact?.isReactiveOnlyMode()) {
|
||||
return await compactViaReactive(messages, ...) // 次选:Reactive 压缩
|
||||
}
|
||||
|
||||
## 第一层:微压缩(Micro-Compact)
|
||||
// 兜底:传统 API 摘要
|
||||
const microcompactResult = await microcompactMessages(messages, context)
|
||||
const messagesForCompact = microcompactResult.messages
|
||||
// → 调用 AI 模型生成摘要
|
||||
```
|
||||
|
||||
微压缩不生成摘要,只是清除旧的工具调用结果。
|
||||
注意:SM 压缩不支持自定义指令(`/compact 聚焦在认证模块`),有自定义指令时直接走传统路径。
|
||||
|
||||
### 工作原理
|
||||
## 第一层:MicroCompact — 局部压缩
|
||||
|
||||
AI 在工作过程中会产生大量工具输出:文件内容、命令结果、搜索匹配等。这些信息在产生时很重要,但随着对话推进,它们的价值迅速降低。
|
||||
源码路径:`src/services/compact/microCompact.ts`
|
||||
|
||||
微压缩识别这些"过期"的工具输出,将其替换为简短的占位符文本。原始内容仍保留在磁盘上的 transcript 文件中,只是不再发送给 API。
|
||||
MicroCompact 不压缩整个对话,而是**清除旧工具输出的内容**。它维护一个白名单:
|
||||
|
||||
### 时间衰减
|
||||
```typescript
|
||||
// src/services/compact/microCompact.ts:41-50
|
||||
const COMPACTABLE_TOOLS = new Set([
|
||||
FILE_READ_TOOL_NAME, // 'Read' - 文件读取
|
||||
...SHELL_TOOL_NAMES, // 'Bash' - 命令输出
|
||||
GREP_TOOL_NAME, // 'Grep' - 搜索结果
|
||||
GLOB_TOOL_NAME, // 'Glob' - 文件列表
|
||||
WEB_SEARCH_TOOL_NAME, // 'WebSearch' - 搜索结果
|
||||
WEB_FETCH_TOOL_NAME, // 'WebFetch' - 网页内容
|
||||
FILE_EDIT_TOOL_NAME, // 'Edit' - 编辑输出
|
||||
FILE_WRITE_TOOL_NAME, // 'Write' - 写入输出
|
||||
])
|
||||
```
|
||||
|
||||
越旧的工具输出越容易被清除,最近操作的优先保留。这个策略基于一个经验观察:AI 通常只需要最近几步操作的上下文,更早的工具结果很少被再次引用。
|
||||
替换策略:将超过时间窗口的工具输出内容替换为 `[Old tool result content cleared]`。这不是简单的截断——原始内容仍保留在 JSONL transcript 中,只是不再发送给 API。
|
||||
|
||||
MicroCompact 还有一个**时间衰减配置**(`timeBasedMCConfig.ts`):越旧的工具输出越容易被清除,最近的优先保留。
|
||||
|
||||
### 图片和文档的特殊处理
|
||||
|
||||
图片和 PDF 文档的 token 占用远高于文本。微压缩会将大型图片和文档替换为文本标记(如 `[image]`),在保留"这里曾有一张图片"的语义的同时释放大量 token。
|
||||
```typescript
|
||||
const IMAGE_MAX_TOKEN_SIZE = 2000
|
||||
```
|
||||
|
||||
## 第二层:Session Memory 压缩
|
||||
图片 block 如果超过 2000 token 估算值,也会被 MicroCompact 清除。PDF document block 同理。
|
||||
|
||||
当微压缩释放的 token 不够时,系统优先尝试 Session Memory 压缩。
|
||||
## 第二层:Session Memory Compact — 无 API 调用的压缩
|
||||
|
||||
### 设计思路
|
||||
源码路径:`src/services/compact/sessionMemoryCompact.ts`
|
||||
|
||||
系统在对话过程中会持续提取关键信息形成"Session Memory"。压缩时,直接使用这些已提取的记忆作为对话摘要,而不需要额外调用 AI 生成摘要。
|
||||
当 `tengu_session_memory` + `tengu_sm_compact` 两个 feature flag 启用时,系统优先使用 Session Memory 进行压缩——**不需要调用摘要模型**,直接使用已经提取好的 Session Memory 作为对话摘要。
|
||||
|
||||
### 保留窗口
|
||||
### 保留窗口的计算
|
||||
|
||||
压缩后保留的消息需要满足三个约束:
|
||||
```typescript
|
||||
// sessionMemoryCompact.ts:324-397
|
||||
export function calculateMessagesToKeepIndex(messages, lastSummarizedIndex) {
|
||||
const config = getSessionMemoryCompactConfig()
|
||||
// 默认: minTokens=10K, minTextBlockMessages=5, maxTokens=40K
|
||||
|
||||
- **最小深度**:至少保留 10K token 的最近对话(确保 AI 有足够的上下文)
|
||||
- **最小连续性**:至少保留 5 条包含文本的消息(确保对话连贯)
|
||||
- **最大上限**:不超过 40K token(避免保留太多又很快触发下一次压缩)
|
||||
let startIndex = lastSummarizedIndex + 1
|
||||
// 从 lastSummarizedIndex 向前扩展,直到满足两个下限或命中上限
|
||||
for (let i = startIndex - 1; i >= floor; i--) {
|
||||
totalTokens += estimateMessageTokens([msg])
|
||||
if (hasTextBlocks(msg)) textBlockMessageCount++
|
||||
startIndex = i
|
||||
if (totalTokens >= config.maxTokens) break
|
||||
if (totalTokens >= config.minTokens && textBlockMessageCount >= config.minTextBlockMessages) break
|
||||
}
|
||||
return adjustIndexToPreserveAPIInvariants(messages, startIndex)
|
||||
}
|
||||
```
|
||||
|
||||
这三个约束形成了"不要压缩太少(AI 会迷失),也不要保留太多(很快又需要压缩)"的平衡。
|
||||
这个算法确保压缩后保留的消息窗口满足:
|
||||
- 至少 10,000 token(有上下文深度)
|
||||
- 至少 5 条包含文本的消息(有对话连续性)
|
||||
- 最多 40,000 token(不会太大又触发下一次压缩)
|
||||
|
||||
### 工具对完整性
|
||||
### 工具对完整性保护
|
||||
|
||||
这是一个关键的**正确性保证**:API 要求每个工具调用都有对应的工具结果,反之亦然。如果压缩恰好切在一个工具调用和它的结果之间,API 会报错。
|
||||
`adjustIndexToPreserveAPIInvariants()` 是压缩中一个**关键的正确性保证**:
|
||||
|
||||
系统在计算压缩边界时,会自动向前或向后调整切分点,确保所有工具调用-结果对要么完整保留,要么一起被压缩。
|
||||
API 要求每个 `tool_result` 都有对应的 `tool_use`,反之亦然。如果压缩恰好切在一条 `tool_result` 消息处,会导致 API 报错。
|
||||
|
||||
## 第三层:AI 摘要压缩
|
||||
```typescript
|
||||
// sessionMemoryCompact.ts:232-314
|
||||
// Step 1: 向前扫描,找到所有被保留消息中 tool_result 引用的 tool_use
|
||||
// Step 2: 向前扫描,找到与被保留 assistant 消息共享 message.id 的 thinking block
|
||||
// 两种情况都需要将 startIndex 向前移动
|
||||
```
|
||||
|
||||
当上述方法都不可用时(或用户手动触发 `/compact` 时),系统调用 AI 生成对话摘要。
|
||||
流式传输会将一个 assistant 消息拆分为多条存储记录(thinking、tool_use 等各有独立 uuid 但共享 `message.id`),这增加了边界情况的复杂度。
|
||||
|
||||
## 第三层:传统 API 摘要压缩
|
||||
|
||||
源码路径:`src/services/compact/compact.ts`
|
||||
|
||||
当 SM 压缩不可用时,系统回退到传统方式:调用 AI 模型生成对话摘要。
|
||||
|
||||
### 压缩前处理
|
||||
|
||||
发送给摘要 AI 的消息会经过预处理:
|
||||
- 图片被替换为文本标记(防止摘要请求本身也超出 token 限制)
|
||||
- 会被重新注入的附件被剥离(避免重复)
|
||||
发送给摘要模型之前,消息会经过多层预处理:
|
||||
|
||||
```typescript
|
||||
// compact.ts:147-202
|
||||
const stripped = stripImagesFromMessages(messages) // 图片→[image] 文字标记
|
||||
const stripped2 = stripReinjectedAttachments(stripped) // 移除会被重新注入的附件
|
||||
```
|
||||
|
||||
图片被替换为 `[image]` 标记,防止摘要 API 调用本身也触发 prompt-too-long 错误。
|
||||
|
||||
### 压缩后的重新注入
|
||||
|
||||
压缩不是"砍掉旧对话就完了"。AI 在压缩后立即需要知道"现在正在做什么"。系统会在摘要后重新注入关键上下文:
|
||||
压缩后,系统会从摘要中**重新注入关键上下文**:
|
||||
|
||||
| 重新注入内容 | 预算 | 设计理由 |
|
||||
|-------------|------|----------|
|
||||
| 最近读取的文件(最多 5 个) | 每文件 5K token | AI 最可能需要的就是刚操作过的文件 |
|
||||
| 已激活的技能指令 | 每技能 5K,总计 25K | 保持 AI 的能力集不变 |
|
||||
| CLAUDE.md 内容 | 不限 | 用户的项目指令必须始终存在 |
|
||||
| MCP 工具发现结果 | 不限 | 保持工具可用性 |
|
||||
```typescript
|
||||
// compact.ts:126-134
|
||||
export const POST_COMPACT_TOKEN_BUDGET = 50_000 // 总预算
|
||||
export const POST_COMPACT_MAX_FILES_TO_RESTORE = 5 // 最多恢复 5 个文件
|
||||
export const POST_COMPACT_MAX_TOKENS_PER_FILE = 5_000 // 每文件 5K token
|
||||
export const POST_COMPACT_MAX_TOKENS_PER_SKILL = 5_000 // 每技能 5K token
|
||||
export const POST_COMPACT_SKILLS_TOKEN_BUDGET = 25_000 // 技能总预算 25K
|
||||
```
|
||||
|
||||
**设计洞察**:总共 50K token 的重新注入预算看起来是浪费——刚压缩释放的 token 立刻又被用掉了一部分。但这是必要的:没有这些重新注入,AI 在压缩后会"忘记"当前任务状态,导致糟糕的用户体验。
|
||||
这 50K token 的重新注入预算用于:
|
||||
1. 恢复最近读取的文件内容(最多 5 个文件,每个截断到 5K token)
|
||||
2. 恢复已激活的技能指令(每个技能截断到 5K token,总计 25K)
|
||||
3. 重新注入 CLAUDE.md 内容
|
||||
4. 恢复 MCP 工具发现结果
|
||||
|
||||
## 边界标记:压缩的"墓碑"
|
||||
## CompactBoundary:压缩的边界标记
|
||||
|
||||
每次压缩后,系统在消息流中插入一条边界标记消息。这条消息不展示给用户,但记录了:
|
||||
- 压缩类型(自动/手动/微压缩)
|
||||
- 压缩前的 token 数
|
||||
- 哪些消息被保留
|
||||
源码路径:`src/utils/messages.ts`(`createCompactBoundaryMessage`)
|
||||
|
||||
后续所有操作只处理最后一条边界标记之后的消息。这防止了"重复压缩已经压缩过的内容"——每次压缩都是相对于上一次边界的新压缩。
|
||||
每次压缩后,系统在消息流中插入一条 `SystemCompactBoundaryMessage`:
|
||||
|
||||
### 微压缩 vs 全量压缩的边界区别
|
||||
```typescript
|
||||
type SystemCompactBoundaryMessage = {
|
||||
type: 'system'
|
||||
message: {
|
||||
type: 'compact_boundary'
|
||||
compactMetadata: {
|
||||
compactType: 'auto' | 'manual' | 'micro'
|
||||
preCompactTokenCount: number
|
||||
lastUserMessageUuid: string
|
||||
preCompactDiscoveredTools?: string[]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
微压缩的边界标记更轻量——它只记录哪些工具结果被清除了,不生成摘要。原始消息仍然存在(只是内容被替换为占位符),而非被摘要替代。
|
||||
后续所有操作只处理**最后一条 boundary 之后**的消息:
|
||||
|
||||
## 紧急降级:Prompt Too Long
|
||||
```typescript
|
||||
// messages.ts
|
||||
export function getMessagesAfterCompactBoundary(messages: Message[]): Message[] {
|
||||
const lastBoundary = messages.findLastIndex(m => isCompactBoundaryMessage(m))
|
||||
return lastBoundary >= 0 ? messages.slice(lastBoundary + 1) : messages
|
||||
}
|
||||
```
|
||||
|
||||
当压缩后仍然超出 token 限制时,系统进入紧急路径:
|
||||
### Preserved Segment 注解
|
||||
|
||||
1. **更激进的压缩**:尝试比正常压缩更激进的策略
|
||||
2. **直接截断**:从最早的对话开始删除,直到能塞进上下文窗口
|
||||
3. **放弃并报错**:如果以上都失败,向用户报告错误
|
||||
boundary 消息上还附加了 `preservedSegment` 注解,记录哪些消息被保留而非压缩:
|
||||
|
||||
**设计哲学**:系统宁可丢失部分对话历史,也不让整个会话卡死。用户可以通过文件快照和 transcript 记录恢复丢失的信息。
|
||||
```typescript
|
||||
// compact.ts — annotateBoundaryWithPreservedSegment
|
||||
boundaryMarker.compactMetadata.preservedSegment = {
|
||||
summaryMessageUuid: string
|
||||
preservedMessageUuids: string[]
|
||||
}
|
||||
```
|
||||
|
||||
## Hook 机制
|
||||
这在会话恢复时帮助加载器正确重建消息链,避免重复压缩已保留的消息。
|
||||
|
||||
压缩前后支持自定义 Hook:
|
||||
### Microcompact Boundary
|
||||
|
||||
- **Pre-compact Hook**:压缩前执行,可以标记"必须保留"的内容
|
||||
- **Post-compact Hook**:压缩后执行,可以验证关键信息是否被保留
|
||||
- **Session Start Hook**:压缩后恢复 CLAUDE.md 等上下文
|
||||
Microcompact 操作使用单独的 boundary 类型,与全量压缩的 `compact_boundary` 不同:
|
||||
|
||||
这确保了用户的自定义逻辑在压缩过程中被尊重——例如,用户可以通过 Hook 确保某个关键文件的内容永远不会被压缩掉。
|
||||
```typescript
|
||||
// src/utils/messages.ts:4599-4614
|
||||
type SystemMicrocompactBoundaryMessage = {
|
||||
type: 'system'
|
||||
subtype: 'microcompact_boundary'
|
||||
content: 'Context microcompacted'
|
||||
compactMetadata: {
|
||||
trigger: 'auto' // Microcompact 只有自动触发
|
||||
preTokens: number // 压缩前 token 数
|
||||
tokensSaved: number // 节省的 token 数
|
||||
compactedToolIds: string[] // 被压缩的工具 ID 列表
|
||||
clearedAttachmentUUIDs: string[] // 被清除的附件 UUID
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 接下来
|
||||
与 `compact_boundary` 的区别:
|
||||
- **保留原始消息**:Microcompact 仅清除工具输出内容,不删除消息本身
|
||||
- **可追溯性**:`compactedToolIds` 记录了哪些工具结果被清除
|
||||
- **轻量级**:不生成摘要,不调用 API
|
||||
|
||||
- **项目记忆** — 理解跨会话的记忆持久化设计
|
||||
- **令牌预算** — 了解 token 窗口的动态计算和阈值管理
|
||||
- **系统提示词** — 理解上下文组装的缓存优化策略
|
||||
## PTL 紧急降级:Prompt Too Long
|
||||
|
||||
当压缩后仍然超出 token 限制(`PROMPT_TOO_LONG` 错误),系统会进入紧急降级路径:
|
||||
|
||||
1. **Reactive Compact**:`reactiveCompactOnPromptTooLong()` 尝试更激进的压缩
|
||||
2. **截断重试**:如果 reactive 也失败,`truncateHeadForPTLRetry()` 直接截断最早的消息
|
||||
3. 放弃并报错
|
||||
|
||||
Reactive Compact 目前在反编译版本中是 stub(`isReactiveOnlyMode() → false`),表明这是 Anthropic 内部的实验性功能。
|
||||
|
||||
## 压缩的 Hook 机制
|
||||
|
||||
压缩前后可以执行自定义 Hook:
|
||||
|
||||
- **Pre-compact Hook**(`executePreCompactHooks`):在压缩前执行,可以注入"必须保留"的标记
|
||||
- **Post-compact Hook**(`executePostCompactHooks`):在压缩后执行,可以验证关键信息是否保留
|
||||
- **Session Start Hook**(`processSessionStartHooks('compact')`):SM 压缩使用此 Hook 恢复 CLAUDE.md 等上下文
|
||||
|
||||
Hook 结果以 `HookResultMessage` 的形式附加到压缩结果中,确保用户的自定义逻辑在压缩过程中被尊重。
|
||||
|
||||
## Snip Compact(实验性)
|
||||
|
||||
源码路径:`src/services/compact/snipCompact.ts`(stub)
|
||||
|
||||
Snip Compact 是另一种实验性压缩策略,在反编译版本中为空壳实现。从 stub 的类型签名推断:
|
||||
|
||||
```typescript
|
||||
snipCompactIfNeeded(messages, options?: { force?: boolean }) → {
|
||||
messages: Message[]
|
||||
executed: boolean
|
||||
tokensFreed: number
|
||||
boundaryMessage?: Message
|
||||
}
|
||||
```
|
||||
|
||||
它似乎是一种**更细粒度的消息级裁剪**(snip = 剪切),可能是对单条消息的进一步压缩,而非整个对话。`shouldNudgeForSnips()` 和 `SNIP_NUDGE_TEXT` 暗示它可能会提示用户触发。
|
||||
|
||||
@@ -1,137 +1,226 @@
|
||||
---
|
||||
title: "项目记忆"
|
||||
description: "AI 没有真正的记忆。Claude Code 如何通过文件系统构建跨会话的记忆?理解存储架构、四类型分类法、智能召回和漂移防御设计。"
|
||||
keywords: ["项目记忆", "MEMORY.md", "AI 记忆", "跨对话", "自动记忆"]
|
||||
title: "项目记忆系统 - 文件级跨对话记忆架构"
|
||||
description: "深度解析 Claude Code 记忆系统:基于文件的持久化存储、MEMORY.md 索引结构、四类型分类法、Sonnet 智能召回、Session Memory 压缩集成。"
|
||||
keywords: ["项目记忆", "MEMORY.md", "AI 记忆", "跨对话", "自动记忆", "memdir"]
|
||||
---
|
||||
|
||||
## 核心问题
|
||||
{/* 本章目标:从源码层面剖析记忆系统的存储架构、召回机制和注入链路 */}
|
||||
|
||||
AI 的每次 API 调用都是无状态的——模型只看到当前请求中的内容。这意味着每次新会话,AI 都从零开始,不知道你之前讨论过什么、你喜欢什么风格、项目有什么特殊约束。
|
||||
## 记忆系统的存储架构
|
||||
|
||||
记忆系统的目标:**让 AI 在跨会话中保持连贯性**。
|
||||
|
||||
## 存储架构:纯文件
|
||||
源码路径:`src/memdir/paths.ts`、`src/memdir/memdir.ts`
|
||||
|
||||
Claude Code 的记忆系统是**纯文件**的——没有数据库、没有向量存储,只有 Markdown 文件和目录结构。
|
||||
|
||||
### 目录布局
|
||||
|
||||
```
|
||||
~/.claude/projects/<项目目录>/memory/
|
||||
~/.claude/projects/<sanitized-git-root>/memory/
|
||||
├── MEMORY.md ← 入口索引(每次对话加载)
|
||||
├── user_role.md ← 用户记忆
|
||||
├── feedback_testing.md ← 反馈记忆
|
||||
├── project_mobile_release.md ← 项目记忆
|
||||
└── reference_linear_ingest.md ← 参考记忆
|
||||
├── reference_linear_ingest.md ← 参考记忆
|
||||
└── logs/ ← KAIROS 模式:每日日志
|
||||
└── 2026/
|
||||
└── 04/
|
||||
└── 2026-04-01.md
|
||||
```
|
||||
|
||||
### 为什么选择文件而非数据库
|
||||
路径解析链路(`getAutoMemPath()`):
|
||||
1. `CLAUDE_COWORK_MEMORY_PATH_OVERRIDE` 环境变量(Cowork SDK 全路径覆盖)
|
||||
2. `autoMemoryDirectory` 设置(仅限 `policySettings`/`localSettings`/`userSettings`——**故意排除** `projectSettings`,防止恶意仓库将记忆路径指向 `~/.ssh`)
|
||||
3. 默认:`<memoryBase>/projects/<sanitized-git-root>/memory/`
|
||||
|
||||
| 方案 | 优势 | 劣势 |
|
||||
|------|------|------|
|
||||
| **Markdown 文件** | 用户可直接编辑、git 友好、零依赖 | 查询需要扫描文件 |
|
||||
| SQLite 数据库 | 查询高效 | 用户无法直接编辑、需要额外依赖 |
|
||||
| 向量数据库 | 语义搜索强大 | 过度工程、引入复杂依赖 |
|
||||
|
||||
选择文件的核心理由:**记忆应该是用户可以审查、编辑和删除的**。Markdown 文件让用户完全掌控 AI 记住了什么。如果 AI 记住了错误的信息,用户可以直接打开文件删除它。
|
||||
同一个 Git 仓库的所有 worktree 共享一个记忆目录(通过 `findCanonicalGitRoot()` 找到真正的 `.git` 根)。
|
||||
|
||||
### MEMORY.md 索引
|
||||
|
||||
`MEMORY.md` 是记忆系统的入口。每次对话开始时完整加载到上下文中。它不是记忆内容本身,而是一个链接索引:
|
||||
`MEMORY.md` 是记忆的入口索引,每次对话都完整加载到上下文中:
|
||||
|
||||
```markdown
|
||||
- [用户角色](user_role.md) — 深度 Go 开发者,React 新手
|
||||
- [测试反馈](feedback_testing.md) — 集成测试必须使用真实数据库
|
||||
```typescript
|
||||
// memdir.ts:34-38
|
||||
export const ENTRYPOINT_NAME = 'MEMORY.md'
|
||||
export const MAX_ENTRYPOINT_LINES = 200
|
||||
export const MAX_ENTRYPOINT_BYTES = 25_000
|
||||
```
|
||||
|
||||
索引有双重上限(行数和字节数),防止索引本身占用过多 token。超过上限时自动截断——这确保记忆系统不会成为新的 token 负担。
|
||||
索引有**双重上限**:200 行 AND 25KB。超过任何一条都会被 `truncateEntrypointContent()` 截断并追加警告。设计原因:p97 的索引文件用 200 行就能覆盖,但有些索引条目特别长(p100 观测到 197KB/200 行),字节上限捕捉这种长行异常。
|
||||
|
||||
索引条目格式:
|
||||
```markdown
|
||||
- [Title](file.md) — one-line hook
|
||||
```
|
||||
|
||||
每条一行,~150 字符以内。`MEMORY.md` 本身没有 frontmatter——它只是一个链接列表,不是记忆内容。
|
||||
|
||||
## 四类型分类法
|
||||
|
||||
记忆被约束为一个封闭的四类型系统,每种类型有明确的用途和保存时机:
|
||||
源码路径:`src/memdir/memoryTypes.ts`
|
||||
|
||||
| 类型 | 存储内容 | 设计目的 |
|
||||
|------|---------|----------|
|
||||
| **user** | 用户角色、偏好、技术背景 | 让 AI 理解"用户是谁" |
|
||||
| **feedback** | 用户对 AI 行为的纠正和确认 | 防止 AI 重复犯错或偏离已验证的工作方式 |
|
||||
| **project** | 无法从代码推导的项目上下文 | 记录"为什么这样决定"而非"代码长什么样" |
|
||||
| **reference** | 外部系统的指针 | 告诉 AI 去哪里找信息 |
|
||||
记忆被约束为一个**封闭的四类型系统**,每种类型有明确的 `<when_to_save>`、`<how_to_use>` 和 `<body_structure>` 规范:
|
||||
|
||||
### 关键设计约束
|
||||
| 类型 | 存储内容 | 典型触发 |
|
||||
|------|---------|---------|
|
||||
| **user** | 用户角色、偏好、技术背景 | "我是数据科学家"、"我写了十年 Go" |
|
||||
| **feedback** | 用户对 AI 行为的纠正和确认 | "别 mock 数据库"、"单 PR 更好" |
|
||||
| **project** | 非代码可推导的项目上下文 | "合并冻结从周四开始"、"auth 重写是合规要求" |
|
||||
| **reference** | 外部系统指针 | "pipeline bugs 在 Linear INGEST 项目" |
|
||||
|
||||
**只存储无法从当前项目状态推导的信息。** 代码架构、文件路径、git 历史都可以实时获取,不需要记忆。
|
||||
关键设计约束:**只存储无法从当前项目状态推导的信息**。代码架构、文件路径、git 历史都可以实时获取,不需要记忆。
|
||||
|
||||
这条约束防止记忆系统变成冗余缓存。如果某个信息可以通过读代码获得,那就不应该记下来——因为代码是最新的,而记忆可能已经过时。
|
||||
### 反馈类型的双通道捕获
|
||||
|
||||
### 反馈的双通道捕获
|
||||
`feedback` 类型的 `when_to_save` 指令特别强调:
|
||||
|
||||
反馈类型特别强调不仅要在用户纠正时保存("不要这样做"),也要在用户确认时保存("对,就是这样")。
|
||||
> Record from failure AND success: if you only save corrections, you will avoid past mistakes but drift away from approaches the user has already validated, and may grow overly cautious.
|
||||
|
||||
**设计考量**:如果只记录纠正,AI 会避免过去的错误,但也会偏离用户已经验证过的工作方式,变得越来越保守。记录"成功路径"和记录"失败路径"同等重要。
|
||||
这意味着 AI 不仅在用户说"不要这样做"时保存,也在用户说"对,就是这样"时保存。后一种更难捕捉,但同等重要——它防止 AI 的行为随时间漂移。
|
||||
|
||||
### 每条记忆的结构
|
||||
|
||||
每条记忆文件都有 frontmatter 元数据:
|
||||
### 每条记忆的 Frontmatter 格式
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: 测试反馈
|
||||
description: 集成测试的数据库使用偏好
|
||||
type: feedback
|
||||
name: {{memory name}}
|
||||
description: {{one-line description — 用于未来判断相关性}}
|
||||
type: {{user, feedback, project, reference}}
|
||||
---
|
||||
|
||||
集成测试必须使用真实数据库,不能 mock。
|
||||
**Why:** 上季度发生过 mock 通过但生产迁移失败的事故。
|
||||
**How to apply:** 所有涉及数据库的测试用真实实例。
|
||||
{{memory content — feedback/project 类型建议包含 **Why:** 和 **How to apply:** 行}}
|
||||
```
|
||||
|
||||
`description` 字段不是给人读的摘要——它是给 AI 召回系统做相关性判断的搜索关键词。`Why` 和 `How to apply` 行帮助 AI 理解"为什么有这条规则"和"什么时候该应用它"。
|
||||
`description` 字段是关键:它不是给人读的摘要,而是给 AI 召回系统做相关性判断的搜索关键词。
|
||||
|
||||
## 智能召回
|
||||
## 智能召回机制
|
||||
|
||||
不是所有记忆都适合每次对话。用户可能在 50 个记忆文件中积累了大量信息,但一次对话通常只需要其中 3-5 条。
|
||||
源码路径:`src/memdir/findRelevantMemories.ts`、`src/memdir/memoryScan.ts`
|
||||
|
||||
### 召回架构
|
||||
不是所有记忆都适合每次对话。系统使用一个**轻量级 Sonnet 侧查询**来筛选最相关的记忆。
|
||||
|
||||
系统使用一个轻量级的独立 AI 查询来筛选最相关的记忆:
|
||||
### 召回流程
|
||||
|
||||
```
|
||||
用户消息
|
||||
→ 扫描所有记忆文件的元数据
|
||||
→ 独立 AI 查询:从所有记忆中选出最相关的 ≤5 条
|
||||
→ 只加载选中的记忆文件内容
|
||||
用户消息 → findRelevantMemories(query, memoryDir)
|
||||
├── scanMemoryFiles() — 扫描所有记忆文件的 frontmatter
|
||||
├── selectRelevantMemories() — Sonnet 侧查询,从清单中选出 ≤5 条
|
||||
└── 返回 [{path, mtimeMs}, ...]
|
||||
```
|
||||
|
||||
**设计考量**:为什么不直接加载所有记忆?因为记忆文件会随时间增长,全部加载可能占用大量 token。使用独立查询筛选虽然多花一次 API 调用,但可以显著减少主对话的 token 消耗。
|
||||
核心是 `selectRelevantMemories()` 函数,它调用 `sideQuery()`(一个独立的轻量 API 调用):
|
||||
|
||||
### 去噪设计
|
||||
```typescript
|
||||
// findRelevantMemories.ts:98-121
|
||||
const result = await sideQuery({
|
||||
model: getDefaultSonnetModel(), // 用 Sonnet 做筛选(非主模型)
|
||||
system: SELECT_MEMORIES_SYSTEM_PROMPT,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: `Query: ${query}\n\nAvailable memories:\n${manifest}${toolsSection}`
|
||||
}],
|
||||
max_tokens: 256,
|
||||
output_format: { type: 'json_schema', schema: { ... } },
|
||||
})
|
||||
```
|
||||
|
||||
- **近期工具去噪**:当 AI 正在使用某个工具时,不召回该工具的使用文档(对话中已有工作上下文)。但仍召回关于这些工具的**警告和已知问题**——这正是使用时最关键的信息。
|
||||
- **已展示去重**:之前轮次已展示过的记忆不再重复召回,让有限的召回预算花在新的候选上。
|
||||
### 近期工具去噪
|
||||
|
||||
当 AI 正在使用某个工具时,召回该工具的使用文档是噪音(对话中已有工作上下文)。`recentTools` 参数让召回系统跳过这些记忆:
|
||||
|
||||
```typescript
|
||||
// findRelevantMemories.ts:92-95
|
||||
const toolsSection = recentTools.length > 0
|
||||
? `\n\nRecently used tools: ${recentTools.join(', ')}`
|
||||
: ''
|
||||
```
|
||||
|
||||
System Prompt 明确指示:"如果已提供最近使用的工具列表,不要选择该工具的使用参考或 API 文档。**仍然要选择**关于这些工具的警告、陷阱或已知问题——这正是使用时最关键的信息。"
|
||||
|
||||
### 已展示去重
|
||||
|
||||
`alreadySurfaced` 参数过滤之前轮次已展示过的文件路径,让 Sonnet 的 5 槽预算花在新的候选上,而不是重复召回同一文件。
|
||||
|
||||
## 记忆注入 System Prompt 的链路
|
||||
|
||||
源码路径:`src/memdir/memdir.ts` → `src/context.ts`
|
||||
|
||||
`loadMemoryPrompt()` 是记忆注入的入口,每会话调用一次(通过 `systemPromptSection('memory', ...)` 缓存):
|
||||
|
||||
```typescript
|
||||
// memdir.ts:419-507
|
||||
export async function loadMemoryPrompt(): Promise<string | null> {
|
||||
// 优先级:KAIROS 日志模式 → TEAMMEM 组合模式 → 纯自动记忆
|
||||
if (feature('KAIROS') && autoEnabled && getKairosActive()) {
|
||||
return buildAssistantDailyLogPrompt(skipIndex)
|
||||
}
|
||||
if (feature('TEAMMEM') && teamMemPaths!.isTeamMemoryEnabled()) {
|
||||
return teamMemPrompts!.buildCombinedMemoryPrompt(...)
|
||||
}
|
||||
if (autoEnabled) {
|
||||
return buildMemoryLines('auto memory', autoDir, ...).join('\n')
|
||||
}
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
注入时机:`context.ts` 中 `getSystemContext()` 调用时,记忆 Prompt 作为 system prompt 的一个 section 被组装。`MEMORY.md` 的内容作为 **user context message** 注入(而非 system prompt),这样可以利用 Prompt Cache 的 prefix 共享。
|
||||
|
||||
## KAIROS 模式:每日日志
|
||||
|
||||
源码路径:`src/memdir/memdir.ts`(`buildAssistantDailyLogPrompt`)
|
||||
|
||||
长期运行的 assistant 会话使用不同的记忆策略:
|
||||
|
||||
- **标准模式**:AI 维护 `MEMORY.md` 作为实时索引 + 独立记忆文件
|
||||
- **KAIROS 模式**:AI 只往日期文件追加日志(`logs/YYYY/MM/YYYY-MM-DD.md`),不做重组
|
||||
|
||||
```typescript
|
||||
// 日志路径模式(非字面路径——因为 Prompt 被缓存)
|
||||
const logPathPattern = join(memoryDir, 'logs', 'YYYY', 'MM', 'YYYY-MM-DD.md')
|
||||
```
|
||||
|
||||
一个独立的夜间 `/dream` 技能负责将日志蒸馏为主题文件 + `MEMORY.md` 索引。
|
||||
|
||||
## 记忆漂移防御
|
||||
|
||||
记忆可能过时。系统在 AI 的行为指令中设置了专门的防御:
|
||||
源码路径:`src/memdir/memoryTypes.ts`(`TRUSTING_RECALL_SECTION`)
|
||||
|
||||
> 一条记忆提到某个函数或文件,只是声明它**在记忆被写入时**存在。它可能已被重命名、删除或从未合并。在推荐之前,先验证它是否还存在。
|
||||
记忆可能过期。系统在 Prompt 中设置了一个专门的 section "Before recommending from memory":
|
||||
|
||||
这个指令从"行动导向"的角度设计——不是告诉 AI"记忆可能不准确"(太抽象),而是直接告诉它"在推荐之前先检查"(可操作)。
|
||||
```
|
||||
A memory that names a specific function, file, or flag is a claim
|
||||
that it existed *when the memory was written*. It may have been
|
||||
renamed, removed, or never merged. Before recommending it:
|
||||
|
||||
- If the memory names a file path: check the file exists.
|
||||
- If the memory names a function or flag: grep for it.
|
||||
```
|
||||
|
||||
这个 section 的标题经过 A/B 测试验证:"Before recommending from memory"(行动导向)比 "Trusting what you recall"(抽象描述)效果好(3/3 vs 0/3)。
|
||||
|
||||
### 忽略记忆的严格语义
|
||||
|
||||
当用户说"忽略记忆"时,AI 必须做到真正的忽略——不应用、不引用、不比较、甚至不提及记忆内容。
|
||||
```
|
||||
If the user says to *ignore* or *not use* memory:
|
||||
proceed as if MEMORY.md were empty.
|
||||
Do not apply remembered facts, cite, compare against,
|
||||
or mention memory content.
|
||||
```
|
||||
|
||||
这解决了一个常见的 AI 反模式:用户说"忽略关于 X 的记忆",AI 虽然正确识别了代码,但仍加上"不像记忆中说的 Y"——这不是"忽略",而是"承认然后覆盖"。
|
||||
这解决了 AI 的一个常见反模式:用户说"忽略关于 X 的记忆",AI 虽然正确识别了代码但仍然加上"不像记忆中说的 Y"——这不是"忽略",而是"承认然后覆盖"。
|
||||
|
||||
## 与上下文压缩的联动
|
||||
## Session Memory 与压缩的联动
|
||||
|
||||
记忆系统与上下文压缩深度集成。当 Session Memory 功能启用时,压缩优先使用已提取的记忆作为摘要——不需要额外的 AI 调用生成摘要,更快、更便宜、且不会丢失信息。
|
||||
源码路径:`src/services/compact/sessionMemoryCompact.ts`
|
||||
|
||||
这形成了一个正向循环:
|
||||
1. 对话中积累信息 → 提取为记忆
|
||||
2. 上下文需要压缩时 → 使用记忆作为摘要
|
||||
3. 下次对话开始 → 通过智能召回加载相关记忆
|
||||
记忆系统与上下文压缩有深度集成。当 `tengu_session_memory` 和 `tengu_sm_compact` 两个 feature flag 同时开启时,压缩优先使用 Session Memory 而非传统摘要:
|
||||
|
||||
## 接下来
|
||||
```typescript
|
||||
// sessionMemoryCompact.ts:57-61
|
||||
const DEFAULT_SM_COMPACT_CONFIG = {
|
||||
minTokens: 10_000, // 压缩后至少保留 10K token
|
||||
minTextBlockMessages: 5, // 至少保留 5 条文本消息
|
||||
maxTokens: 40_000, // 最多保留 40K token
|
||||
}
|
||||
```
|
||||
|
||||
- **自动记忆整理** — 了解 KAIROS 模式下的每日日志和蒸馏机制
|
||||
- **上下文压缩** — 理解记忆如何与压缩策略联动
|
||||
- **令牌预算** — 了解记忆加载的 token 开销管理
|
||||
SM-compact 不调用压缩 API(没有摘要模型),而是直接使用已有的 Session Memory 作为摘要——更快、更便宜、且不会丢失信息。
|
||||
|
||||
@@ -1,116 +1,290 @@
|
||||
---
|
||||
title: "系统提示词"
|
||||
description: "系统提示词不是一段写死的文本,而是一个动态组装、分块缓存的数组。理解三阶段管道、缓存分界标记和多级优先级选择的设计。"
|
||||
keywords: ["系统提示词", "System Prompt", "动态组装", "Prompt Cache", "缓存策略"]
|
||||
title: "System Prompt 动态组装 - AI 工作记忆构建"
|
||||
description: "深入解析 Claude Code 的 System Prompt 动态组装过程:缓存策略、分界标记、Section 注册表、CLAUDE.md 多级合并,以及如何将零散上下文拼装为 API 可消费的缓存友好结构。"
|
||||
keywords: ["System Prompt", "系统提示词", "动态组装", "CLAUDE.md", "Prompt Cache", "缓存策略"]
|
||||
---
|
||||
|
||||
## 核心问题
|
||||
## 从数组到 API 调用:System Prompt 的完整链路
|
||||
|
||||
AI 的行为由系统提示词(System Prompt)决定。但一个编程助手的系统提示词远不止一句"你是一个有用的助手"——它需要包含工具使用规则、安全策略、项目上下文、用户偏好等大量动态内容。
|
||||
System Prompt 在 Claude Code 中不是一段写死的文本,而是一个 **`string[]` 数组**(品牌类型 `SystemPrompt`,定义于 `src/utils/systemPromptType.ts:8`),经过组装、分块、缓存标记后发送给 API。
|
||||
|
||||
挑战在于:**这些内容每次请求都要发送,而且大部分是不变的。** 如何在保持动态性的同时最小化 token 成本?
|
||||
|
||||
## 从文本到数组:缓存驱动的架构选择
|
||||
|
||||
系统提示词不是单个字符串,而是一个 **字符串数组**。
|
||||
|
||||
### 为什么是数组?
|
||||
|
||||
Anthropic 的 Prompt Cache 以**内容块**为单位缓存。将提示词拆为多个块,不变的部分可以获得独立的缓存命中。如果是一个单字符串,任何一个字符变化(如日期更新)都会导致整个提示词的缓存失效。
|
||||
|
||||
一个典型的系统提示词约 20K+ tokens,通过缓存分块可以节省 30-50% 的输入 token 费用。
|
||||
|
||||
### 品牌类型保证
|
||||
|
||||
系统使用品牌类型(branded type)防止普通字符串数组被意外传入 API 调用——只有通过显式转换才能获得系统提示词类型。这是零开销的编译时安全保证。
|
||||
|
||||
## 三阶段组装管道
|
||||
### 三阶段管道
|
||||
|
||||
```
|
||||
收集内容 → 选择优先级 → 分块 + 缓存标记
|
||||
getSystemPrompt() → string[] (组装内容)
|
||||
↓
|
||||
buildEffectiveSystemPrompt() → SystemPrompt (选择优先级路径)
|
||||
↓
|
||||
buildSystemPromptBlocks() → TextBlockParam[] (分块 + cache_control 标记)
|
||||
```
|
||||
|
||||
### 第一阶段:内容收集
|
||||
1. **`getSystemPrompt()`**(`src/constants/prompts.ts:444`)—— 收集静态段 + 动态段,插入 `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 分界标记
|
||||
2. **`buildEffectiveSystemPrompt()`**(`src/utils/systemPrompt.ts:41`)—— 按 Override > Coordinator > Agent > Custom > Default 优先级选择
|
||||
3. **`buildSystemPromptBlocks()`**(`src/services/api/claude.ts:3279`)—— 调用 `splitSysPromptPrefix()` 分块,为每个块附加 `cache_control`
|
||||
|
||||
系统提示词的内容分为两个区域:
|
||||
## SystemPrompt 品牌类型
|
||||
|
||||
| 区域 | 内容 | 特点 |
|
||||
|------|------|------|
|
||||
| **静态区** | 工具使用规则、安全策略、输出格式规范 | 所有用户相同 |
|
||||
| **动态区** | 记忆、MCP 指令、模型覆盖、语言偏好 | 因用户/会话而异 |
|
||||
```typescript
|
||||
// packages/@ant/model-provider/src/types/systemPrompt.ts:4
|
||||
export type SystemPrompt = readonly string[] & {
|
||||
readonly __brand: 'SystemPrompt'
|
||||
}
|
||||
export function asSystemPrompt(value: readonly string[]): SystemPrompt {
|
||||
return value as SystemPrompt // 零开销类型断言
|
||||
}
|
||||
```
|
||||
|
||||
两个区域之间用一个特殊的**分界标记**分隔。这个标记永远不会发送给 AI——它只是告诉缓存系统:"到这里为止是静态的,从这里开始是动态的"。
|
||||
品牌类型(branded type)防止普通 `string[]` 被意外传入 API 调用——只有通过 `asSystemPrompt()` 显式转换才能获得 `SystemPrompt` 类型。
|
||||
|
||||
### 第二阶段:优先级选择
|
||||
## getSystemPrompt():内容组装的全景
|
||||
|
||||
最终使用哪个提示词取决于五级优先级:
|
||||
`src/constants/prompts.ts:444` 是 System Prompt 的核心工厂函数,返回一个有序数组:
|
||||
|
||||
| 优先级 | 来源 | 场景 |
|
||||
| 阶段 | 内容 | 缓存策略 |
|
||||
|------|------|----------|
|
||||
| **静态区** | Intro Section、System Rules、Doing Tasks、Actions、Using Tools、Tone & Style、Output Efficiency | 可跨组织缓存(`scope: 'global'`) |
|
||||
| **BOUNDARY** | `SYSTEM_PROMPT_DYNAMIC_BOUNDARY = '__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__'` | 分界标记(不发送给 API,仅用于分割静态区与动态区以实现全局缓存) |
|
||||
| **动态区** | Session Guidance、Memory、Model Override、Env Info、Language、Output Style、MCP Instructions、Scratchpad、FRC、Summarize Tool Results、Token Budget、Brief | 每次会话不同(`scope: 'org'` 或无缓存) |
|
||||
|
||||
> **Boundary 是什么**:它把 System Prompt 分成"不变的静态区"和"因用户/会话而异的动态区"。静态区对所有用户相同,可获得 `scope: 'global'` 跨组织缓存;动态区每次不同,只能 `scope: 'org'` 或不缓存。它本身是一个特殊字符串,在发送给 API 前被移除,AI 永远看不到。
|
||||
|
||||
### 动态区的 Section 注册表
|
||||
|
||||
动态区通过 `systemPromptSection()` / `DANGEROUS_uncachedSystemPromptSection()` 注册,这两个工厂函数定义于 `src/constants/systemPromptSections.ts`:
|
||||
|
||||
```typescript
|
||||
// 缓存式 Section:计算一次,/clear 或 /compact 后才重新计算
|
||||
systemPromptSection('memory', () => loadMemoryPrompt())
|
||||
|
||||
// 危险:每轮重新计算,会破坏 Prompt Cache
|
||||
DANGEROUS_uncachedSystemPromptSection(
|
||||
'mcp_instructions',
|
||||
() => isMcpInstructionsDeltaEnabled() ? null : getMcpInstructionsSection(mcpClients),
|
||||
'MCP servers connect/disconnect between turns' // 必须给出破坏缓存的理由
|
||||
)
|
||||
```
|
||||
|
||||
`resolveSystemPromptSections()` 在每轮查询时解析所有 Section,对于 `cacheBreak: false` 的 Section,优先使用 `getSystemPromptSectionCache()` 中的缓存值。只有 MCP 指令等真正动态的内容使用 `DANGEROUS_uncachedSystemPromptSection`。
|
||||
|
||||
### `CLAUDE_CODE_SIMPLE` 快速路径
|
||||
|
||||
当环境变量 `CLAUDE_CODE_SIMPLE` 为真时,整个 System Prompt 缩减为一行:
|
||||
|
||||
```typescript
|
||||
`You are Claude Code, Anthropic's official CLI for Claude.\n\nCWD: ${getCwd()}\nDate: ${getSessionStartDate()}`
|
||||
```
|
||||
|
||||
跳过所有 Section 注册、缓存分块、动态组装——用于最小化 token 消耗的测试场景。
|
||||
|
||||
## buildEffectiveSystemPrompt():五级优先级
|
||||
|
||||
`src/utils/systemPrompt.ts:41` 决定最终使用哪个 System Prompt:
|
||||
|
||||
| 优先级 | 条件 | 行为 |
|
||||
|--------|------|------|
|
||||
| **Override** | 外部覆盖 | SDK 集成、自动化测试 |
|
||||
| **Coordinator** | 协调者模式 | 多 Agent 编排 |
|
||||
| **Agent** | Agent 定义 | 自定义 Agent |
|
||||
| **Custom** | 命令行参数 | 用户的自定义提示词 |
|
||||
| **Default** | 完整组装 | 正常使用 |
|
||||
| **0. Override** | `overrideSystemPrompt` 非空 | 完全替换,返回 `[override]` |
|
||||
| **1. Coordinator** | `COORDINATOR_MODE` feature + 环境变量 | 使用协调者专用提示词 |
|
||||
| **2. Agent** | `mainThreadAgentDefinition` 存在 | Proactive 模式:追加到默认提示词尾部;否则:替换默认提示词 |
|
||||
| **3. Custom** | `--system-prompt` 参数指定 | 替换默认提示词 |
|
||||
| **4. Default** | 无特殊条件 | 使用 `getSystemPrompt()` 完整输出 |
|
||||
|
||||
**设计考量**:Override 级别完全替换默认提示词(包括安全规则),这是危险的但必要的——自动化测试和 SDK 集成需要完全控制。其他级别则在默认提示词基础上追加或替换。
|
||||
`appendSystemPrompt` 始终追加到末尾(Override 除外)。
|
||||
|
||||
### 第三阶段:分块与缓存标记
|
||||
## Provider 系统概述
|
||||
|
||||
分块策略根据条件选择三种模式:
|
||||
Claude Code 支持多种 API 提供商,分为两大类:
|
||||
|
||||
**模式 1:全局缓存(1P 用户默认)**
|
||||
```
|
||||
[归属头] → 不缓存
|
||||
[提示词前缀] → 不缓存
|
||||
[静态内容] → 全局缓存(跨组织共享)
|
||||
[动态内容] → 不缓存
|
||||
| 类别 | Provider | 环境变量 | 说明 |
|
||||
|------|----------|---------|------|
|
||||
| **1P (First Party)** | `firstParty` | 默认 | Anthropic 官方 API 直连 |
|
||||
| **3P (Third Party)** | `bedrock` | `CLAUDE_CODE_USE_BEDROCK=1` | AWS Bedrock 托管服务 |
|
||||
| **3P** | `vertex` | `CLAUDE_CODE_USE_VERTEX=1` | Google Vertex AI |
|
||||
| **3P** | `openai` | `CLAUDE_CODE_USE_OPENAI=1` | OpenAI 兼容层(Ollama/DeepSeek/vLLM) |
|
||||
| **3P** | `gemini` | `CLAUDE_CODE_USE_GEMINI=1` | Google Gemini API |
|
||||
| **3P** | `grok` | `CLAUDE_CODE_USE_GROK=1` | xAI Grok |
|
||||
|
||||
Provider 决定了:
|
||||
- **可用的 beta headers**:部分 beta 功能仅限 1P 用户
|
||||
- **缓存策略**:全局缓存 `scope: 'global'` 仅 1P 可用
|
||||
- **Token 计数方式**:Bedrock 有独立的 countTokens 端点,OpenAI/Gemini 依赖估算
|
||||
|
||||
```typescript
|
||||
// src/utils/model/providers.ts:5-13
|
||||
export type APIProvider =
|
||||
| 'firstParty' // 1P - Anthropic 直连
|
||||
| 'bedrock' // 3P - AWS Bedrock
|
||||
| 'vertex' // 3P - Google Vertex
|
||||
| 'foundry' // 3P - Anthropic Foundry
|
||||
| 'openai' // 3P - OpenAI 兼容层
|
||||
| 'gemini' // 3P - Google Gemini
|
||||
| 'grok' // 3P - xAI Grok
|
||||
```
|
||||
|
||||
**模式 2:组织缓存(MCP 工具存在时)**
|
||||
## 缓存策略:分块、标记、命中
|
||||
|
||||
这是 System Prompt 设计中最精密的部分。
|
||||
|
||||
### Anthropic Prompt Cache 基础
|
||||
|
||||
Anthropic API 的 Prompt Cache 允许跨请求复用相同的 System Prompt 前缀,按缓存命中量计费(远低于完整输入价格)。缓存键由内容的 Blake2b 哈希决定——任何字符变化都会导致缓存失效。
|
||||
|
||||
### `splitSysPromptPrefix()`:三种分块模式
|
||||
|
||||
`src/utils/api.ts:321` 是缓存策略的核心,根据条件选择三种分块模式:
|
||||
|
||||
#### 模式 1:MCP 工具存在时(`skipGlobalCacheForSystemPrompt=true`)
|
||||
|
||||
```
|
||||
[归属头] → 不缓存
|
||||
[提示词前缀] → 组织缓存
|
||||
[其余内容] → 组织缓存
|
||||
[attribution header] → cacheScope: null (不缓存)
|
||||
[system prompt prefix] → cacheScope: 'org' (组织级缓存)
|
||||
[everything else] → cacheScope: 'org' (组织级缓存)
|
||||
```
|
||||
|
||||
**模式 3:组织缓存(3P 用户)**
|
||||
MCP 工具列表在会话中可能变化(连接/断开),破坏了跨组织缓存的基础,因此降级为组织级。
|
||||
|
||||
#### 模式 2:Global Cache + Boundary 存在(1P 专用)
|
||||
|
||||
```
|
||||
[归属头] → 不缓存
|
||||
[提示词前缀] → 组织缓存
|
||||
[其余内容] → 组织缓存
|
||||
[attribution header] → cacheScope: null (不缓存)
|
||||
[system prompt prefix] → cacheScope: null (不缓存)
|
||||
[static content] → cacheScope: 'global' (全局缓存!跨组织共享)
|
||||
[dynamic content] → cacheScope: null (不缓存)
|
||||
```
|
||||
|
||||
**为什么 MCP 工具会降级缓存**?MCP 工具列表在会话中可能变化(连接/断开),这会改变提示词内容,破坏跨组织缓存的基础。因此当 MCP 工具存在时,只能使用组织级缓存。
|
||||
这是缓存效率最高的模式。`SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 之前的静态内容(Intro、Rules、Tone & Style 等)对所有用户相同,可跨组织缓存。
|
||||
|
||||
### 缓存破坏的防御
|
||||
> **Boundary 插入条件**:`SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 标记**仅在特定条件**下插入:
|
||||
|
||||
动态区的某些内容(如会话特定的工具集)必须严格放在分界标记之后。如果错误地放在静态区,每个运行时条件的组合会产生 2^N 种不同的哈希值(N = 条件数量),完全破坏缓存命中率。
|
||||
```typescript
|
||||
// src/utils/betas.ts:226-229
|
||||
export function shouldUseGlobalCacheScope(): boolean {
|
||||
return (
|
||||
getAPIProvider() === 'firstParty' &&
|
||||
!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS)
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
系统对"每轮都重新计算"的 Section 使用专门的危险标记——开发者必须给出破坏缓存的理由才能使用它。
|
||||
```typescript
|
||||
// src/constants/prompts.ts:574
|
||||
...(shouldUseGlobalCacheScope() ? [SYSTEM_PROMPT_DYNAMIC_BOUNDARY] : []),
|
||||
```
|
||||
|
||||
## 上下文注入:两条独立管道
|
||||
这意味着:
|
||||
- **3P 用户(Bedrock/Vertex/OpenAI/Gemini)**:Boundary 永远不存在,始终使用模式 3
|
||||
- **1P 用户禁用实验性功能**:设置 `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1`,Boundary 不插入
|
||||
- **1P 用户默认**:Boundary 存在,使用模式 2(最高缓存效率)
|
||||
|
||||
系统提示词本身不包含运行时上下文。上下文通过两条独立管道注入:
|
||||
#### 模式 3:默认(3P 提供商 或 Boundary 缺失)
|
||||
|
||||
### System Context(会话级不变)
|
||||
```
|
||||
[attribution header] → cacheScope: null (不缓存)
|
||||
[system prompt prefix] → cacheScope: 'org' (组织级缓存)
|
||||
[everything else] → cacheScope: 'org' (组织级缓存)
|
||||
```
|
||||
|
||||
- Git 分支和状态
|
||||
- 最近的提交记录
|
||||
- 计算一次,整个会话期间缓存
|
||||
### `getCacheControl()`:TTL 决策
|
||||
|
||||
### User Context(动态变化)
|
||||
`src/services/api/claude.ts:348` 生成的 `cache_control` 对象:
|
||||
|
||||
- 合并后的 CLAUDE.md 内容
|
||||
- 当前日期
|
||||
```typescript
|
||||
{
|
||||
type: 'ephemeral',
|
||||
ttl?: '1h', // 仅特定 querySource 符合条件时
|
||||
scope?: 'global', // 仅静态区
|
||||
}
|
||||
```
|
||||
|
||||
**为什么 User Context 不放在 System Prompt 中**?因为 User Context 作为用户消息发送,可以利用 Prompt Cache 的前缀共享——系统提示词是所有用户共享的前缀,User Context 是每个用户的变化部分。这种分层让缓存命中率最大化。
|
||||
1 小时 TTL 的判定逻辑(`should1hCacheTTL()`,第 383 行):
|
||||
- **Bedrock 用户**:通过环境变量 `ENABLE_PROMPT_CACHING_1H_BEDROCK` 启用
|
||||
- **1P 用户**:通过 GrowthBook 配置的 `allowlist` 数组匹配 `querySource`,支持前缀通配符(如 `"repl_main_thread*"`)
|
||||
- **会话级锁定**:资格判定结果在 bootstrap state 中缓存,防止 GrowthBook 配置中途变化导致同一会话内 TTL 不一致
|
||||
|
||||
### 缓存破坏:Session-Specific Guidance 的放置
|
||||
|
||||
`getSessionSpecificGuidanceSection()`(`src/constants/prompts.ts:354`)的内容必须放在 `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` **之后**。因为它包含:
|
||||
- 当前会话的 enabledTools 集合
|
||||
- `isForkSubagentEnabled()` 的运行时判定
|
||||
- `getIsNonInteractiveSession()` 的结果
|
||||
|
||||
这些运行时 bit 如果放在静态区,会产生 2^N 种 Blake2b 哈希变体(N = 运行时条件数),完全破坏缓存命中率。源码注释明确警告:
|
||||
|
||||
> Each conditional here is a runtime bit that would otherwise multiply the Blake2b prefix hash variants (2^N). See PR #24490, #24171 for the same bug class.
|
||||
|
||||
### `CLAUDE_CODE_SIMPLE` 模式
|
||||
|
||||
当设置了 `CLAUDE_CODE_SIMPLE` 环境变量时,整个系统提示词会大幅缩减:
|
||||
|
||||
```typescript
|
||||
return [`You are Claude Code, Anthropic's official CLI for Claude.\n\nCWD: ${getCwd()}\nDate: ${getSessionStartDate()}`]
|
||||
```
|
||||
|
||||
## 上下文注入:System Context 与 User Context
|
||||
|
||||
System Prompt 数组本身不包含运行时上下文(git 状态、CLAUDE.md 内容)。上下文通过两个独立的管道注入:
|
||||
|
||||
### System Context(`src/context.ts:116`)
|
||||
|
||||
```typescript
|
||||
export const getSystemContext = memoize(async () => {
|
||||
return {
|
||||
gitStatus, // git 分支、状态、最近提交(截断至 MAX_STATUS_CHARS=2000)
|
||||
cacheBreaker, // 仅 ant 用户的缓存破坏器
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
- 使用 `lodash.memoize` 缓存——**整个会话期间只计算一次**
|
||||
- Git 状态快照包含 5 个并行 `git` 命令(branch、defaultBranch、status、log、userName)
|
||||
- `status` 超过 2000 字符时截断并附加提示使用 BashTool 获取更多信息
|
||||
- `systemPromptInjection` 变更时,通过 `getUserContext.cache.clear?.()` 清除所有上下文缓存
|
||||
|
||||
### User Context(`src/context.ts:155`)
|
||||
|
||||
```typescript
|
||||
export const getUserContext = memoize(async () => {
|
||||
return {
|
||||
claudeMd, // 合并后的 CLAUDE.md 内容
|
||||
currentDate, // "Today's date is YYYY-MM-DD."
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
- **CLAUDE.md 禁用条件**:`CLAUDE_CODE_DISABLE_CLAUDE_MDS` 环境变量,或 `--bare` 模式(除非通过 `--add-dir` 显式指定目录)
|
||||
- `--bare` 模式的语义是"跳过我没要求的东西"而非"忽略所有"
|
||||
|
||||
### 注入位置
|
||||
|
||||
在 `src/query.ts:449`:
|
||||
|
||||
```typescript
|
||||
// System Context 追加到 System Prompt 尾部
|
||||
const fullSystemPrompt = asSystemPrompt(
|
||||
appendSystemContext(systemPrompt, systemContext) // 简单拼接
|
||||
)
|
||||
```
|
||||
|
||||
User Context 通过 `prependUserContext()`(`src/utils/api.ts:449`)注入为 `<system-reminder>` 标签包裹的首条用户消息,放在所有对话消息之前。
|
||||
|
||||
## Attribution Header:计费与安全
|
||||
|
||||
每个 API 请求的 System Prompt 首块是 Attribution Header(`src/constants/system.ts:30`),包含:
|
||||
- **`cc_version`**:Claude Code 版本 + 指纹
|
||||
- **`cc_entrypoint`**:入口点标识(REPL / SDK / pipe 等)
|
||||
- **`cch=00000`**(NATIVE_CLIENT_ATTESTATION 启用时):Bun 原生 HTTP 层在发送前将零替换为计算出的哈希值,服务器验证此 token 确认请求来自真实 Claude Code 客户端
|
||||
|
||||
Header 始终 `cacheScope: null`——它因版本和指纹不同而变化,不适合缓存。
|
||||
|
||||
## CLAUDE.md:项目级知识注入
|
||||
|
||||
这是 Claude Code 最巧妙的设计之一。在项目目录中放一个 `CLAUDE.md` 文件,就能让 AI "理解"项目。
|
||||
这是 Claude Code 最巧妙的设计之一。在项目根目录放一个 `CLAUDE.md` 文件,就能让 AI "理解" 你的项目:
|
||||
|
||||
### 多级合并
|
||||
- **项目概述**:这个项目做什么、用了什么技术栈
|
||||
- **开发约定**:代码风格、命名规范、分支策略
|
||||
- **常用命令**:怎么构建、怎么测试、怎么部署
|
||||
- **注意事项**:已知的坑、特殊的配置
|
||||
|
||||
系统会自动发现并合并多级 CLAUDE.md:
|
||||
|
||||
```
|
||||
~/.claude/CLAUDE.md ← 用户全局(个人偏好)
|
||||
@@ -118,34 +292,77 @@ Anthropic 的 Prompt Cache 以**内容块**为单位缓存。将提示词拆为
|
||||
└── /project/src/CLAUDE.md ← 子目录(模块特定)
|
||||
```
|
||||
|
||||
系统从当前工作目录向上遍历,合并所有匹配的 CLAUDE.md 文件。子目录的 CLAUDE.md 可以覆盖或补充父目录的规则。
|
||||
加载逻辑在 `src/utils/claudemd.ts` 中的 `getClaudeMds()` 和 `getMemoryFiles()` 实现——从 CWD 向上遍历目录树,合并所有匹配的 CLAUDE.md 文件内容。
|
||||
|
||||
**设计哲学**:项目知识应该由团队成员维护、随代码演进、可通过 git 追踪。CLAUDE.md 本质上是"给人读的项目文档,恰好 AI 也能读"。
|
||||
## 设计洞察:为什么是 `string[]` 而非单个 `string`
|
||||
|
||||
### 安全考量
|
||||
将 System Prompt 设计为数组而非单段文本,是为了 **缓存分块**:
|
||||
|
||||
项目级 CLAUDE.md 可以被仓库中的任何人修改(包括恶意贡献者)。系统因此限制了项目级 CLAUDE.md 的影响范围——它们不能覆盖安全关键设置,也不能修改权限模型。
|
||||
1. Anthropic Prompt Cache 以 **内容块**(TextBlock)为缓存单位
|
||||
2. 将 System Prompt 拆为多个块,可以让不变的部分(Intro、Rules)获得独立的缓存命中
|
||||
3. 如果是单个 `string`,任何一个字符变化(如日期更新)都会导致整个 System Prompt 的缓存失效
|
||||
4. `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 标记允许 `splitSysPromptPrefix()` 精确地将静态区标记为 `scope: 'global'`,动态区不标记或标记为 `scope: 'org'`
|
||||
|
||||
## Provider 差异与缓存
|
||||
这是 Claude Code 在 token 成本优化上的核心设计——一次典型的 System Prompt 约 20K+ tokens,通过缓存分块可以节省 30-50% 的输入 token 费用。
|
||||
|
||||
不同的 API Provider 有不同的缓存能力:
|
||||
## 兼容层:OpenAI 与 Gemini
|
||||
|
||||
| Provider | 全局缓存 | 精确 Token 计数 | 特殊 Beta 功能 |
|
||||
|----------|:--------:|:---------------:|:--------------:|
|
||||
| Anthropic 直连 | ✓ | ✓ | ✓ |
|
||||
| AWS Bedrock | ✗ | ✓(独立端点) | 部分 |
|
||||
| Google Vertex | ✗ | ✗ | 部分 |
|
||||
| OpenAI 兼容 | ✗ | ✗ | ✗ |
|
||||
| Gemini | ✗ | ✗ | ✗ |
|
||||
Claude Code 提供了 OpenAI 和 Gemini 协议的兼容层,允许使用非 Anthropic 端点。
|
||||
|
||||
3P 用户的系统提示词始终使用组织级缓存,因为没有全局缓存的 API 支持。这也意味着 token 计数依赖估算,影响自动压缩的触发时机。
|
||||
### OpenAI 兼容层
|
||||
|
||||
## 最小化模式
|
||||
通过 `CLAUDE_CODE_USE_OPENAI=1` 启用,支持任意 OpenAI Chat Completions 协议端点(Ollama、DeepSeek、vLLM 等)。
|
||||
|
||||
环境变量 `CLAUDE_CODE_SIMPLE` 可以将整个系统提示词缩减为一行——跳过所有 Section 注册、缓存分块和动态组装。用于最小化 token 消耗的测试场景。
|
||||
实现采用**流适配器模式**:
|
||||
1. 将 Anthropic 格式请求转换为 OpenAI 格式
|
||||
2. 调用 OpenAI 兼容端点
|
||||
3. 将 SSE 流转换回 `BetaRawMessageStreamEvent`
|
||||
4. 下游代码完全无感知
|
||||
|
||||
## 接下来
|
||||
```
|
||||
src/services/api/openai/
|
||||
├── client.ts # OpenAI 客户端配置
|
||||
├── convertMessages.ts # 消息格式转换(Anthropic → OpenAI)
|
||||
├── convertTools.ts # 工具定义转换
|
||||
├── streamAdapter.ts # SSE 流适配(OpenAI → Anthropic)
|
||||
├── modelMapping.ts # 模型名称映射
|
||||
└── index.ts # 入口函数 queryModelOpenAI()
|
||||
```
|
||||
|
||||
关键环境变量:
|
||||
- `CLAUDE_CODE_USE_OPENAI=1` — 启用 OpenAI provider
|
||||
- `OPENAI_API_KEY` — API 密钥
|
||||
- `OPENAI_BASE_URL` — API 端点(默认 `https://api.openai.com/v1`)
|
||||
- `OPENAI_MODEL` — 直接指定模型名
|
||||
|
||||
### Gemini 兼容层
|
||||
|
||||
通过 `CLAUDE_CODE_USE_GEMINI=1` 启用,支持 Google Gemini API。
|
||||
|
||||
```
|
||||
src/services/api/gemini/
|
||||
├── client.ts # Gemini 客户端配置
|
||||
├── convertMessages.ts # 消息格式转换
|
||||
├── convertTools.ts # 工具定义转换
|
||||
├── streamAdapter.ts # 流适配
|
||||
├── modelMapping.ts # 模型名称映射
|
||||
├── types.ts # 类型定义
|
||||
└── index.ts # 入口函数
|
||||
```
|
||||
|
||||
关键环境变量:
|
||||
- `CLAUDE_CODE_USE_GEMINI=1` — 启用 Gemini provider
|
||||
- `GEMINI_API_KEY` — API 密钥
|
||||
- `GEMINI_BASE_URL` — API 端点(默认 `https://generativelanguage.googleapis.com/v1beta`)
|
||||
- `GEMINI_MODEL` — 直接指定模型名
|
||||
- `GEMINI_DEFAULT_SONNET_MODEL` / `GEMINI_DEFAULT_OPUS_MODEL` — 按能力级别映射
|
||||
|
||||
### 兼容层的限制
|
||||
|
||||
使用 3P 兼容层时,部分功能受限:
|
||||
- **无精确 token 计数**:系统退回到近似估算,影响自动压缩触发时机
|
||||
- **无全局缓存**:只能使用组织级缓存 `scope: 'org'`
|
||||
- **部分 beta 功能不可用**:依赖 Anthropic 特有 beta headers 的功能受限
|
||||
|
||||
详见 `docs/plans/openai-compatibility.md` 和 `CLAUDE.md` 中的相关章节。
|
||||
|
||||
- **上下文压缩** — 理解当上下文增长超出限制时的压缩策略
|
||||
- **令牌预算** — 了解 token 窗口的动态计算
|
||||
- **Provider 系统** — 了解多 Provider 支持的架构设计
|
||||
|
||||
@@ -1,128 +1,195 @@
|
||||
---
|
||||
title: "令牌预算"
|
||||
description: "200K 上下文窗口不是全部。理解 Claude Code 如何管理 token 预算:动态计算、近似 vs 精确计数、分层压缩策略和缓存优化。"
|
||||
keywords: ["Token 预算", "上下文窗口", "token 计算", "压缩策略"]
|
||||
title: "Token 预算管理 - 上下文窗口动态计算"
|
||||
description: "从源码角度揭示 Claude Code token 预算管理:200K 上下文窗口的动态计算、截断机制、缓存优化和自动压缩的完整链路。"
|
||||
keywords: ["Token 预算", "上下文窗口", "token 计算", "截断机制", "缓存优化"]
|
||||
---
|
||||
|
||||
## 核心约束:200K 不是全部
|
||||
{/* 本章目标:从源码角度揭示 token 预算的动态计算、截断机制、缓存优化和自动压缩的完整链路 */}
|
||||
|
||||
Claude 的上下文窗口为 200K tokens(部分模型支持 1M),但实际可用于对话的空间远小于此:
|
||||
## 上下文窗口:200K 不是全部
|
||||
|
||||
Claude Code 的默认上下文窗口为 200K tokens(`MODEL_CONTEXT_WINDOW_DEFAULT = 200_000`),但实际可用于对话的空间远小于此:
|
||||
|
||||
```
|
||||
上下文窗口(200K)
|
||||
├── 系统提示词(~15-25K)
|
||||
├── 系统提示词(~15-25K,缓存后成本低)
|
||||
├── 工具定义(~10-20K,含 MCP 工具)
|
||||
├── 用户上下文(CLAUDE.md、git status 等)
|
||||
├── 输出预留(AI 响应的空间)
|
||||
└── 剩余:对话历史空间(随对话增长而缩小)
|
||||
├── 输出预留(maxOutputTokens)
|
||||
│ ├── 默认上限:64K
|
||||
│ ├── 实际默认:8K(slot-reservation 优化)
|
||||
│ └── 触顶自动升级:一次 64K 重试
|
||||
└── 剩余:对话历史空间(随对话增长)
|
||||
```
|
||||
|
||||
**设计挑战**:对话历史不断增长,可用空间持续缩小。系统必须在"保留足够的上下文让 AI 理解对话"和"不超出 token 限制"之间持续平衡。
|
||||
`getContextWindowForModel()`(`src/utils/context.ts:51`)按 5 级优先级解析窗口大小:
|
||||
|
||||
### 上下文窗口的动态解析
|
||||
1. `CLAUDE_CODE_MAX_CONTEXT_TOKENS` 环境变量覆盖
|
||||
2. 模型名含 `[1m]` 后缀 → 1M tokens
|
||||
3. `getModelCapability(model).max_input_tokens`
|
||||
4. 1M beta header + 支持的模型(claude-sonnet-4, opus-4-6)
|
||||
5. 兜底:200K
|
||||
|
||||
上下文窗口大小不是硬编码的。系统按优先级从多个来源解析:
|
||||
|
||||
1. 用户环境变量覆盖(强制指定)
|
||||
2. 模型名后缀标记(如 `[1m]` 表示 1M 窗口)
|
||||
3. 模型自身的能力声明
|
||||
4. 特定 beta 功能的支持情况
|
||||
5. 兜底值:200K
|
||||
|
||||
这种分层解析意味着同一个系统可以适配不同模型和不同配置,而不需要为每种情况写特殊逻辑。
|
||||
**有效上下文** = 窗口大小 - min(maxOutputTokens, 20K),因为压缩摘要需要预留输出空间。
|
||||
|
||||
## Token 计数:近似 vs 精确
|
||||
|
||||
Token 计数是所有预算决策的基础。系统采用两级策略:
|
||||
系统使用两级 token 计数策略:
|
||||
|
||||
### 近似估算(毫秒级)
|
||||
|
||||
基于一个简单的经验公式:大约每 4 个字节 ≈ 1 个 token。对不同内容类型有调整:
|
||||
- **JSON/JSONL**:更密集,每 2 字节 ≈ 1 token
|
||||
- **图片/文档**:固定估算值(基于尺寸上限的保守估计)
|
||||
- **普通文本**:每 4 字节 ≈ 1 token
|
||||
```typescript
|
||||
// src/services/tokenEstimation.ts
|
||||
function roughTokenCountEstimation(content: string, bytesPerToken = 4): number {
|
||||
return Math.round(content.length / bytesPerToken)
|
||||
}
|
||||
```
|
||||
|
||||
### 精确计数(需要 API 调用)
|
||||
对不同内容类型有特殊处理:
|
||||
- **JSON/JSONL**:`bytesPerToken = 2`(密集的 `{`, `:`, `,` 符号,每个仅 1-2 token)
|
||||
- **图片/文档**:固定 2000 tokens(基于 2000×2000px 上限的保守估计)
|
||||
- **thinking block**:按实际文本长度 / 4
|
||||
- **tool_use**:序列化 `name + JSON.stringify(input)` 后 / 4
|
||||
|
||||
使用 Anthropic 的 token 计数端点。不同 Provider 的支持程度不同:
|
||||
### 精确计数(API 调用)
|
||||
|
||||
| Provider 类别 | 精确计数支持 | 注意事项 |
|
||||
|---------------|-------------|----------|
|
||||
| Anthropic 直连 | 原生支持 | 最准确 |
|
||||
| 云平台(Bedrock/Vertex) | 各自的 SDK 接口 | 需要额外依赖 |
|
||||
| 第三方兼容(OpenAI/Gemini/Grok) | 不支持 | 退回近似估算 |
|
||||
使用 Anthropic 的 `beta.messages.countTokens` 端点。在不同 provider 上有不同路径:
|
||||
|
||||
### 为什么需要两级策略
|
||||
| Provider | 方法 |
|
||||
|----------|------|
|
||||
| Anthropic 直连 | `anthropic.beta.messages.countTokens()` |
|
||||
| AWS Bedrock | `@aws-sdk/client-bedrock-runtime` 的 `CountTokensCommand` |
|
||||
| Google Vertex | Anthropic SDK + beta 过滤 |
|
||||
| 兜底(Bedrock 不支持) | 用 Haiku 发送 `max_tokens=1` 的请求,读取 `usage.input_tokens` |
|
||||
|
||||
近似估算用于**热路径**——每轮 agentic loop 都需要判断"是否需要压缩",这个检查必须足够快。精确计数用于**关键决策点**——压缩前后对比、费用计算等需要准确数字的场景。
|
||||
精确计数在关键决策点使用(压缩前后对比、warning 判断),近似估算在热路径使用(每轮循环的 shouldAutoCompact 检查)。
|
||||
|
||||
**设计权衡**:近似估算可能偏差 10-20%,这意味着自动压缩的触发时机可能略有提前或延后。但这个偏差是可以接受的——提前压缩只多花一点 token,延后压缩最多触发一次 API 错误然后紧急压缩。
|
||||
### 3P Provider 的 Token 计数差异
|
||||
|
||||
## 分层压缩策略
|
||||
不同 Provider 的精确 token 计数实现方式不同,部分 provider 甚至不支持精确计数:
|
||||
|
||||
系统不是等到"满了才压缩",而是采用了由轻到重的分层策略:
|
||||
| Provider | 计数方式 | 注意事项 |
|
||||
|----------|---------|---------|
|
||||
| **Anthropic 直连** | `anthropic.beta.messages.countTokens()` | 标准 API,最准确 |
|
||||
| **AWS Bedrock** | `CountTokensCommand` | 需要动态加载 279KB AWS SDK |
|
||||
| **Google Vertex** | Anthropic SDK + beta 过滤 | 需要特定 beta headers |
|
||||
| **OpenAI 兼容层** | 无精确计数 | **退回到近似估算** |
|
||||
| **Gemini 兼容层** | 无精确计数 | **退回到近似估算** |
|
||||
| **Bedrock 不支持时** | 用 Haiku 发送 `max_tokens=1` 请求 | 读取 `usage.input_tokens` |
|
||||
|
||||
### 第一层:工具结果截断
|
||||
OpenAI 和 Gemini 兼容层**不支持精确 token 计数**,系统会退回到近似估算。这会影响:
|
||||
- **自动压缩触发时机**:可能略有偏差
|
||||
- **压缩前后 token 对比**:仅为估算值,非精确
|
||||
- **Warning/Error 阈值判断**:基于估算而非精确计数
|
||||
|
||||
单个工具的输出有硬性上限(通常 100K 字符)。超长的命令输出、文件内容在写入消息前就被截断。
|
||||
```typescript
|
||||
// src/services/tokenEstimation.ts - 近似估算函数
|
||||
function roughTokenCountEstimation(content: string, bytesPerToken = 4): number {
|
||||
return Math.round(content.length / bytesPerToken)
|
||||
}
|
||||
```
|
||||
|
||||
这是最轻量的"压缩"——只是防止单个工具结果占用过多空间。
|
||||
源码路径:`src/services/tokenEstimation.ts`
|
||||
|
||||
### 第二层:微压缩(Micro-Compact)
|
||||
## 自动压缩的触发阈值
|
||||
|
||||
在触发全量压缩之前,系统先尝试只压缩旧的工具调用结果:
|
||||
```
|
||||
src/services/compact/autoCompact.ts — 核心阈值
|
||||
```
|
||||
|
||||
- 超过一定时间的工具结果被替换为简短占位符
|
||||
| 常量 | 值 | 含义 |
|
||||
|------|----|------|
|
||||
| `AUTOCOMPACT_BUFFER_TOKENS` | 13,000 | 窗口减去此值 = 自动压缩触发点 |
|
||||
| `WARNING_THRESHOLD_BUFFER_TOKENS` | 20,000 | 在触发点 + 20K 处显示警告 |
|
||||
| `ERROR_THRESHOLD_BUFFER_TOKENS` | 20,000 | 在触发点 + 20K 处显示错误 |
|
||||
| `MANUAL_COMPACT_BUFFER_TOKENS` | 3,000 | 手动 /compact 的阻塞上限 |
|
||||
| `MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES` | 3 | 连续失败 3 次后停止尝试 |
|
||||
|
||||
以 200K 窗口为例:
|
||||
- **~167K**:warning 闪烁,用户看到建议压缩的提示
|
||||
- **~180K**:自动压缩触发(200K - 20K 输出预留 = 180K 有效,再 - 13K buffer)
|
||||
- **~197K**:达到 blocking limit,新消息被阻止
|
||||
|
||||
`shouldAutoCompact()` 有多个逃逸条件:
|
||||
- `compact` / `session_memory` 来源的查询永不触发(防递归死锁)
|
||||
- `DISABLE_COMPACT` / `DISABLE_AUTO_COMPACT` 环境变量
|
||||
- 用户配置 `autoCompactEnabled = false`
|
||||
- Context Collapse 模式激活时抑制(collapse 自己管理上下文)
|
||||
- Reactive Compact 实验模式下抑制主动压缩
|
||||
- 超过连续失败上限(circuit breaker)
|
||||
|
||||
## Micro-Compact:工具结果的渐进式压缩
|
||||
|
||||
在触发全量压缩之前,系统先尝试 **micro-compact**——只压缩旧的工具调用结果:
|
||||
|
||||
```
|
||||
可压缩工具列表(COMPACTABLE_TOOLS):
|
||||
FileRead, Bash, Grep, Glob, WebSearch, WebFetch, FileEdit, FileWrite
|
||||
```
|
||||
|
||||
策略基于时间:
|
||||
- 超过一定时间(由 `timeBasedMCConfig` 控制)的工具结果被替换为简短占位符
|
||||
- 图片/文档结果替换为 `[image]` / `[document]` 文本
|
||||
- 每次替换释放 token,可能推迟全量压缩的触发
|
||||
- 每次替换释放 tokens,可能推迟全量压缩
|
||||
|
||||
**设计考量**:为什么不直接全量压缩?因为全量压缩需要调用 AI 生成摘要,成本高且耗时。微压缩是确定性操作(简单替换),几乎零成本,可以频繁执行。
|
||||
工具本身也有 `maxResultSizeChars`(通常 100K)硬限制,超长结果在写入消息前就被截断。
|
||||
|
||||
### 第三层:自动压缩(Auto-Compact)
|
||||
## 全量压缩的完整流程
|
||||
|
||||
当对话接近 token 上限时,系统用 AI 自身来总结之前的对话:
|
||||
```
|
||||
autoCompactIfNeeded() / compactConversation()
|
||||
↓
|
||||
1. 执行 PreCompact hooks(外部可注入自定义指令)
|
||||
↓
|
||||
2. 尝试 Session Memory 压缩(更轻量,优先尝试)
|
||||
↓
|
||||
3. Session Memory 失败 → 全量压缩
|
||||
a. 图片/文档从消息中剥离(替换为 [image]/[document])
|
||||
b. skill_discovery/skill_listing 附件剥离(压缩后会重新注入)
|
||||
c. 通过 forked agent 发送摘要请求(复用主线程的 prompt cache)
|
||||
d. 如果摘要请求本身触发 prompt-too-long → truncateHeadForPTLRetry()
|
||||
从最老的 API 轮次开始删除,重试最多 3 次
|
||||
↓
|
||||
4. 压缩成功后重建上下文:
|
||||
- compactBoundaryMarker(记录压缩类型、前 token 数等)
|
||||
- 摘要消息(不可见的 user 消息)
|
||||
- 最近 5 个文件的重新读取(POST_COMPACT_TOKEN_BUDGET = 50K)
|
||||
- plan 文件附件(如果有)
|
||||
- plan mode 指令(如果在计划模式中)
|
||||
- 已调用的 skill 内容(每 skill ≤5K,总计 ≤25K)
|
||||
- deferred tools / agent listing / MCP 指令的增量重新注入
|
||||
- SessionStart hooks 重新执行
|
||||
- PostCompact hooks 执行
|
||||
↓
|
||||
5. 更新缓存基线,防止被误判为 cache break
|
||||
```
|
||||
|
||||
1. **剥离非必要内容**:图片、文档附件被替换为文本标记
|
||||
2. **生成摘要**:通过一个独立的 agent 调用生成对话摘要
|
||||
3. **重建上下文**:用摘要替代原始对话,同时重新注入关键信息(最近操作的文件、活跃的计划等)
|
||||
### Prompt Cache Sharing
|
||||
|
||||
**设计考量**:压缩后会重新读取最近操作的 5 个文件。这是因为在实际使用中,AI 最可能需要的就是刚刚操作过的文件——重新读取它们比让 AI 再次搜索更高效。
|
||||
|
||||
### 压缩的安全阀
|
||||
|
||||
- **连续失败上限**:连续 3 次压缩失败后停止尝试(断路器模式)
|
||||
- **压缩来源的查询不触发压缩**:防止压缩本身触发无限递归
|
||||
- **手动压缩**:用户可以随时通过 `/compact` 主动触发
|
||||
|
||||
## 缓存感知的压缩设计
|
||||
|
||||
压缩操作本身需要调用 API 生成摘要——这是整个会话中最昂贵的操作之一。系统通过**复用主线程的缓存前缀**来优化:
|
||||
|
||||
系统 prompt + 工具定义 + 上下文消息通常不变,这部分可以通过 API 的 prompt cache 机制缓存。压缩时的摘要请求复用了这些缓存,使得缓存命中率从接近 0% 提升到接近 100%。
|
||||
|
||||
**设计洞察**:这不是一个独立的优化——它是整个缓存策略的一部分。系统 prompt 的组装策略("不变内容在前")和压缩时的缓存复用,都是为了最大化 prompt cache 的命中率。
|
||||
压缩 API 调用是整个会话中最昂贵的操作之一。系统通过 `runForkedAgent` 复用主线程的缓存前缀(system prompt + tools + context messages),将缓存命中率从 2% 提升到接近 100%。这个优化单独节省了舰队级约 0.76% 的 `cache_creation` tokens。
|
||||
|
||||
## 输出 Token 的 Slot 优化
|
||||
|
||||
一个容易被忽视但影响深远的优化:AI 输出的 token 上限默认只设为 8K,而不是模型支持的最大值(32K 或 64K)。
|
||||
一个经常被忽视的优化:**maxOutputTokens 的动态调整**。
|
||||
|
||||
**为什么**?因为 API 服务端按 `max_tokens` 参数预留推理容量(slot)。99% 的请求实际输出不到 5K tokens,但如果所有请求都预留 32K 的 slot,会导致严重的容量浪费。
|
||||
```typescript
|
||||
// src/services/api/claude.ts — getMaxOutputTokensForModel()
|
||||
const defaultTokens = isMaxTokensCapEnabled()
|
||||
? Math.min(maxOutputTokens.default, 8_000) // 默认降到 8K
|
||||
: maxOutputTokens.default // 原始默认 32K/64K
|
||||
```
|
||||
|
||||
降到 8K 后,不到 1% 的请求会被截断——这些请求自动获得一次高上限的干净重试。
|
||||
为什么?因为 API 的 slot 机制按 `max_tokens` 预留推理容量。BQ p99 输出仅 4,911 tokens,32K 默认值浪费了 8-16 倍的 slot 容量。降到 8K 后,不到 1% 的请求被截断——这些请求会自动获得一次 64K 的 clean retry。
|
||||
|
||||
**设计哲学**:用 1% 的请求多一次重试的代价,换取 99% 请求的更快响应。这是一个典型的"优化常见路径,用重试处理边缘情况"的设计模式。
|
||||
这个优化对 token 预算的影响是间接的:更多的 slot 容量意味着更少的排队延迟,间接减少了超时和重试。
|
||||
|
||||
## 选择性压缩
|
||||
## Partial Compact:选择性地压缩
|
||||
|
||||
除了全量压缩,用户还可以选择只压缩对话的某一部分:
|
||||
除了全量压缩,用户还可以在消息历史中选择某个位置,只压缩该位置之前或之后的内容:
|
||||
|
||||
- **压缩早期内容**(保留最近对话):适用于"开头说了很多背景,但后面只需要关注最近操作"的场景
|
||||
- **压缩近期内容**(保留早期对话):适用于"早期的架构决策很重要,但最近的工具调用结果不需要"的场景
|
||||
- **`up_to` 方向**:压缩选中消息之前的内容,保留最近的对话
|
||||
- **`from` 方向**:压缩选中消息之后的内容,保留早期的对话
|
||||
|
||||
两种方向对缓存有不同的影响——保留前缀(早期内容)可以维持 prompt cache,修改前缀则破坏缓存。
|
||||
`from` 方向保留 prompt cache(前缀不变),`up_to` 方向则破坏 cache(摘要插在保留内容之前)。
|
||||
|
||||
## 接下来
|
||||
|
||||
- **上下文压缩** — 深入了解压缩的触发条件和摘要生成机制
|
||||
- **项目记忆** — 理解跨会话的记忆持久化设计
|
||||
- **穷鬼模式** — 了解如何减少 token 消耗
|
||||
两种方向的 PTL(prompt-too-long)重试策略相同:从最老的 API 轮次开始删除,确保至少保留一组消息供摘要。
|
||||
|
||||
@@ -1,130 +1,203 @@
|
||||
---
|
||||
title: "多轮对话"
|
||||
description: "理解 Claude Code 的会话编排设计:为什么需要 QueryEngine?会话如何持久化?成本如何追踪?模型如何热切换?"
|
||||
title: "多轮对话管理 - QueryEngine 会话编排与持久化"
|
||||
description: "从源码角度解析 Claude Code 多轮对话管理:QueryEngine 的会话状态机、JSONL transcript 持久化、成本追踪模型和模型热切换机制。"
|
||||
keywords: ["多轮对话", "会话管理", "QueryEngine", "transcript", "成本追踪"]
|
||||
sourceRef: "3ec5675 (2026-04-08)"
|
||||
---
|
||||
|
||||
## 单轮 vs 多轮
|
||||
{/* 本章目标:从源码角度揭示会话编排、持久化存储、成本追踪和模型切换的完整链路 */}
|
||||
|
||||
- **单轮**(一次 Agentic Loop):用户说一句话,AI 可能执行多步工具调用后返回结果
|
||||
- **多轮**(一个会话):用户和 AI 反复对话,持续数分钟到数小时
|
||||
## 单轮 vs 多轮:架构层面的差异
|
||||
|
||||
单轮关注"AI 如何自主完成任务",多轮关注"如何管理一个持续演进的会话"——这是完全不同的工程问题。
|
||||
- **单轮**(一次 Agentic Loop):`query()` 函数的一次完整执行——组装上下文 → 调 API → 处理工具调用 → 循环直到结束
|
||||
- **多轮**(一个 Session):`QueryEngine` 类管理的一次会话——跨越数十轮 `submitMessage()` 调用,持续数小时
|
||||
|
||||
## 为什么需要会话编排器
|
||||
|
||||
单轮的 Agentic Loop 已经很复杂了(错误恢复、上下文压缩、流式处理)。多轮在此基础上还需要管理:
|
||||
|
||||
- **对话历史的累积**:消息不断增长,需要压缩策略
|
||||
- **成本的持续追踪**:跨轮次的 token 用量累计
|
||||
- **模型的热切换**:用户可能中途换模型
|
||||
- **会话的持久化与恢复**:意外中断后能继续
|
||||
- **文件的快照与回滚**:AI 改了文件,用户想撤回
|
||||
|
||||
这些职责与"执行一次 agentic loop"无关,所以系统引入了 **QueryEngine** 作为会话编排器——它在 Agentic Loop 之上管理会话级别的状态。
|
||||
|
||||
### 设计原则:编排器不参与循环
|
||||
|
||||
QueryEngine 只负责"准备好上下文,然后调用 agentic loop"。它不干预单轮循环的内部逻辑——循环中的错误恢复、工具执行、上下文压缩都是自治的。
|
||||
|
||||
这种分层使得 agentic loop 可以独立测试和优化,而不受会话状态管理的影响。
|
||||
|
||||
## 会话持久化
|
||||
|
||||
### 为什么持久化很重要
|
||||
|
||||
终端会话是脆弱的——网络断开、进程崩溃、意外关闭都可能丢失对话。持久化确保用户的工作不丢失。
|
||||
|
||||
### 设计选择:JSONL 追加写入
|
||||
|
||||
对话事件以 JSONL(每行一条 JSON)格式追加写入磁盘。
|
||||
|
||||
为什么选择 JSONL 而不是数据库或 JSON 文件?
|
||||
|
||||
| 方案 | 优势 | 劣势 |
|
||||
|------|------|------|
|
||||
| **JSONL 追加写入** | 写入快速、不需要读-改-写、崩溃安全 | 查询需要扫描整个文件 |
|
||||
| JSON 文件 | 结构清晰 | 每次写入需要读取整个文件、修改、写回——大文件很慢 |
|
||||
| SQLite 数据库 | 查询高效 | 引入额外依赖、事务管理复杂 |
|
||||
|
||||
追加写入是关键设计:每次新消息只需要在文件末尾追加一行,不需要读取和修改已有内容。即使写入过程中崩溃,也只会丢失最后一条记录,不会损坏整个文件。
|
||||
|
||||
### 存储结构
|
||||
`QueryEngine`(`src/QueryEngine.ts`,类定义)是单轮 Agentic Loop 之上的**会话编排器**,它管理的状态远不止消息列表:
|
||||
|
||||
```
|
||||
~/.claude/projects/<项目目录>/
|
||||
├── <session-1>.jsonl
|
||||
├── <session-2>.jsonl
|
||||
└── ...
|
||||
QueryEngine 内部状态(src/QueryEngine.ts 构造函数)
|
||||
├── mutableMessages: Message[] ← 完整对话历史,跨 turn 累积
|
||||
├── readFileState: FileStateCache ← 已读文件内容缓存,避免重复读取
|
||||
├── totalUsage: NonNullableUsage ← 累计 token 消耗(input/output/cache)
|
||||
├── permissionDenials: SDKPermissionDenial[] ← 权限拒绝记录
|
||||
├── discoveredSkillNames: Set<string> ← 当前 turn 已发现的 skill
|
||||
├── loadedNestedMemoryPaths: Set<string> ← 已加载的嵌套 memory 路径(防重复)
|
||||
├── hasHandledOrphanedPermission: boolean ← 是否已处理孤立权限请求
|
||||
└── abortController: AbortController ← 会话级中断控制
|
||||
```
|
||||
|
||||
每个项目的会话归入同一目录。同一项目的对话可以跨会话积累上下文。
|
||||
## QueryEngine 的核心方法:submitMessage()
|
||||
|
||||
### 大小防护
|
||||
每次用户输入一条消息,REPL 或 SDK 调用 `submitMessage()`,它会执行完整的 turn 初始化链路:
|
||||
|
||||
读取上限为 50MB。超过这个大小的会话文件不会被完整加载——这是防止超大会话导致内存溢出的安全措施。
|
||||
```typescript
|
||||
// src/QueryEngine.ts — QueryEngine.submitMessage() 简化流程
|
||||
async *submitMessage(
|
||||
prompt: string | ContentBlockParam[],
|
||||
options?: { uuid?: string; isMeta?: boolean },
|
||||
): AsyncGenerator<SDKMessage> {
|
||||
// 1. 清除 turn 级追踪状态
|
||||
this.discoveredSkillNames.clear()
|
||||
|
||||
### 会话恢复
|
||||
// 2. 解析模型(用户可能中途通过 setModel() 切换了模型)
|
||||
const mainLoopModel = this.config.userSpecifiedModel
|
||||
? parseUserSpecifiedModel(this.config.userSpecifiedModel)
|
||||
: getMainLoopModel()
|
||||
|
||||
当用户使用 `--resume` 恢复会话时,系统:
|
||||
// 3. 动态组装 System Prompt(每次 turn 都重新构建)
|
||||
const { defaultSystemPrompt, userContext, systemContext } =
|
||||
await fetchSystemPromptParts({ tools, mainLoopModel, mcpClients })
|
||||
|
||||
1. 从 JSONL 文件重建消息数组
|
||||
2. 恢复累计费用状态
|
||||
3. 恢复用户选择的模型和配置
|
||||
4. 从中断点继续对话
|
||||
// 4. 包装权限检查(追踪每次拒绝)
|
||||
const wrappedCanUseTool = async (tool, input, ...) => {
|
||||
const result = await canUseTool(tool, input, ...)
|
||||
if (result.behavior !== 'allow') {
|
||||
this.permissionDenials.push({
|
||||
type: 'permission_denial',
|
||||
tool_name: sdkCompatToolName(tool.name),
|
||||
tool_use_id: toolUseID,
|
||||
tool_input: input,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
整个过程对用户是透明的——恢复后的对话就像从来没有中断过。
|
||||
// 5. 调用核心 query() 函数执行 agentic loop
|
||||
yield* query({
|
||||
systemPrompt, messages: this.mutableMessages,
|
||||
tools, model: mainLoopModel, ...
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## 成本追踪
|
||||
关键设计:`submitMessage()` 是 `async *Generator`——它逐步 yield `SDKMessage`,让调用方(REPL/SDK)能实时展示进度,而不是等整个 turn 结束。
|
||||
|
||||
### 为什么需要成本追踪
|
||||
## 会话持久化:JSONL Transcript
|
||||
|
||||
AI API 按 token 计费,一次复杂任务可能消耗大量 token。没有成本追踪,用户无法判断"这次对话花了多少钱",也无法在费用过高时及时终止。
|
||||
每次对话事件都被追加写入 transcript 文件(`src/utils/sessionStorage.ts`):
|
||||
|
||||
### 三层追踪架构
|
||||
### 存储路径
|
||||
|
||||
| 层 | 职责 | 持久性 |
|
||||
|----|------|--------|
|
||||
| **记录层** | 从每个 API 响应中提取 token 用量 | 实时 |
|
||||
| **累计层** | 按模型汇总累计费用(切换模型时分别统计) | 会话内 |
|
||||
| **持久化层** | 会话结束时保存到项目配置 | 跨重启 |
|
||||
```
|
||||
~/.claude/projects/<sanitized-cwd>/<session-uuid>.jsonl
|
||||
```
|
||||
|
||||
### 预算提醒
|
||||
- 路径由 `getProjectDir(originalCwd)` 生成,使用 `sanitizePath()` 将项目目录路径转换为安全的目录名(非 hash),同一项目目录的会话归入同一子目录
|
||||
- 每条记录是一行 JSON(JSONL 格式),支持追加写入而不需要读取-修改-写入整个文件
|
||||
- 读取上限为 50MB(`MAX_TRANSCRIPT_READ_BYTES` 常量,`src/utils/sessionStorage.ts`),防止超大会话导致 OOM
|
||||
|
||||
系统提供会话级的预算上限。当累计费用超过阈值时弹出提醒——这不是硬性阻断,而是"软提醒"。设计上选择了提醒而非强制终止,因为用户可能正在进行关键操作(如修复生产 bug),强制终止可能造成更大损失。
|
||||
### Transcript 写入器
|
||||
|
||||
`Project` 类(`src/utils/sessionStorage.ts`,私有类)管理 transcript 的写入。它通过 `writeQueues`(按文件分组的写队列)和 `drainWriteQueue()`(定时批量刷写)确保并发消息追加不会互相覆盖:
|
||||
|
||||
```
|
||||
写入流程(异步排队路径):
|
||||
recordTranscript(sessionId, entry)
|
||||
↓
|
||||
project.enqueueWrite(filePath, entry) ← 入列到 writeQueues
|
||||
↓
|
||||
scheduleDrain() ← 设置定时器(FLUSH_INTERVAL_MS)
|
||||
↓
|
||||
drainWriteQueue() ← 按 MAX_CHUNK_BYTES 分批
|
||||
↓ 写入每批
|
||||
appendToFile(path, batchContent) ← 批量追加
|
||||
↓
|
||||
如果配置了远程持久化:
|
||||
persistToRemote(sessionId, entry)
|
||||
├── CCR v2: internalEventWriter('transcript', entry)
|
||||
└── v1 Ingress: sessionIngress.appendSessionLog(...)
|
||||
|
||||
同步直写路径(用于元数据重写等场景):
|
||||
appendEntryToFile(fullPath, entry) ← 同步 appendFileSync
|
||||
↓
|
||||
失败时 mkdir + 重试
|
||||
```
|
||||
|
||||
### 会话恢复链路
|
||||
|
||||
`--resume` 参数触发的恢复流程(`src/main.tsx` 中 `--resume` 分支):
|
||||
|
||||
```
|
||||
1. 解析 resume 参数:
|
||||
├── UUID 格式 → getTranscriptPathForSession(uuid)
|
||||
├── .jsonl 文件路径 → 直接使用
|
||||
└── boolean → 最近一次会话的 picker
|
||||
|
||||
2. loadTranscriptFromFile(path)
|
||||
├── 按 JSONL 行解析
|
||||
├── 过滤出消息类型记录
|
||||
└── 重建 Message[] 数组
|
||||
|
||||
3. 恢复上下文状态:
|
||||
├── restoreCostStateForSession(sessionId) ← 恢复累计费用
|
||||
├── 恢复 agentSetting(用户选择的 Agent 类型)
|
||||
└── 如果有 --rewind-files,恢复文件到指定消息时的快照
|
||||
|
||||
4. 创建 QueryEngine({ initialMessages: restoredMessages })
|
||||
└── 从恢复的消息继续对话
|
||||
```
|
||||
|
||||
## 成本追踪:从 API Usage 到美元
|
||||
|
||||
成本追踪贯穿三个模块,形成完整的记录→累计→展示链路:
|
||||
|
||||
### 记录层:API 响应中的 Usage
|
||||
|
||||
每个 `message_delta` 事件携带 `usage` 字段(`input_tokens`、`output_tokens`、`cache_creation_input_tokens`、`cache_read_input_tokens`)。`accumulateUsage()` 将增量 usage 累加到会话总量。
|
||||
|
||||
### 累计层:cost-tracker.ts
|
||||
|
||||
```typescript
|
||||
// src/cost-tracker.ts — StoredCostState 类型定义
|
||||
type StoredCostState = {
|
||||
totalCostUSD: number // 累计美元花费
|
||||
totalAPIDuration: number // API 调用总时长(含重试)
|
||||
totalAPIDurationWithoutRetries: number // 不含重试的纯推理时间
|
||||
totalToolDuration: number // 工具执行总时长
|
||||
totalLinesAdded: number // 代码增加行数
|
||||
totalLinesRemoved: number // 代码删除行数
|
||||
lastDuration: number | undefined // 最近一次会话时长
|
||||
modelUsage: { [modelName: string]: ModelUsage } | undefined // 按模型分拆的用量
|
||||
}
|
||||
```
|
||||
|
||||
`addToTotalSessionCost()` 根据模型定价计算每次 API 调用的费用,累计到 `totalCostUSD`。按模型的 `ModelUsage` 支持在同一会话中切换模型后分别统计。
|
||||
|
||||
### 持久化:跨重启保留
|
||||
|
||||
```typescript
|
||||
// 每次会话结束时保存到项目配置
|
||||
saveCurrentSessionCosts(sessionId)
|
||||
→ projectConfig.lastCost = totalCostUSD
|
||||
→ projectConfig.lastSessionId = sessionId
|
||||
→ projectConfig.lastModelUsage = modelUsage
|
||||
```
|
||||
|
||||
### 预算熔断
|
||||
|
||||
`QueryEngineConfig.maxBudgetUsd` 提供了会话级的硬性预算上限。在 REPL 中,当累计费用超过 $5 时(`src/screens/REPL.tsx` 中费用阈值 `useEffect`),弹出费用提醒对话框——这不是硬性阻断,而是"软提醒",且仅在 `hasConsoleBillingAccess()` 为 true 时显示。
|
||||
|
||||
## 模型热切换
|
||||
|
||||
### 设计挑战
|
||||
在一个会话中切换模型不会丢失对话历史——因为 `mutableMessages` 与模型选择是解耦的:
|
||||
|
||||
在一个持续对话中切换模型看似简单,实际上需要解决几个问题:
|
||||
```
|
||||
/model sonnet → QueryEngine.setModel('claude-sonnet-4-20250514')
|
||||
↓ 实际操作:this.config.userSpecifiedModel = model(QueryEngine.setModel() 方法)
|
||||
下一次 submitMessage() 开始时:
|
||||
↓
|
||||
parseUserSpecifiedModel(this.config.userSpecifiedModel)
|
||||
→ 返回新的模型配置
|
||||
↓
|
||||
fetchSystemPromptParts({ mainLoopModel: newModel })
|
||||
→ System Prompt 根据新模型能力重新组装
|
||||
↓
|
||||
query({ model: newModel, messages: this.mutableMessages })
|
||||
→ 使用完整历史 + 新模型继续对话
|
||||
```
|
||||
|
||||
1. **上下文窗口不同**:Sonnet 的 200K 和 Opus 的 1M 需要不同的压缩策略
|
||||
2. **对话历史兼容**:旧模型生成的内容新模型需要能理解
|
||||
3. **费用计算**:不同模型定价不同,需要分别统计
|
||||
|
||||
### 设计决策:消息与模型解耦
|
||||
|
||||
对话历史(消息数组)和模型选择是独立的。切换模型只改变"下一次 API 调用用什么模型",不修改已有消息。系统会在下次调用前根据新模型的规格重新计算上下文窗口和输出限制。
|
||||
|
||||
这意味着用户可以在对话中随时切换模型——例如用便宜的 Sonnet 做简单操作,遇到复杂问题时切换到 Opus——而不需要开始新的会话。
|
||||
切换模型时,`contextWindowTokens` 和 `maxOutputTokens` 也会根据新模型的规格重新计算——例如从 Sonnet 切换到 Opus 时,上下文窗口可能从 200K 变为 1M。
|
||||
|
||||
## 文件快照与回滚
|
||||
|
||||
### 比 git 更细粒度的追踪
|
||||
|
||||
AI 每次修改文件前,系统自动保存当前文件内容的快照。快照绑定到具体的消息 ID,使得用户可以精确恢复到对话中任意时间点的文件状态。
|
||||
|
||||
这比 `git checkout` 更细粒度——git 只追踪已提交的内容,而文件快照追踪的是 AI 每一次修改前的状态。
|
||||
|
||||
### 使用场景
|
||||
|
||||
- AI 改了代码但用户不满意 → 回滚到修改前的状态
|
||||
- AI 进行了多轮修改 → 选择性回滚到某个中间状态
|
||||
- 用户关闭终端后想撤销 → 通过会话恢复 + 文件回滚实现
|
||||
|
||||
## 接下来
|
||||
|
||||
- **系统提示词** — 理解每轮对话前的上下文组装策略
|
||||
- **上下文压缩** — 深入了解对话过长时的自动压缩机制
|
||||
- **令牌预算** — 理解 token 预算管理和成本控制
|
||||
`fileHistoryMakeSnapshot()`(`src/utils/fileHistory.ts`)在 AI 每次修改文件前自动保存当前内容。快照绑定到具体的 `message.id`,使得 `--rewind-files <user-message-id>` 可以精确恢复到对话中任意时间点的文件状态——这比 git 更细粒度(git 只追踪已提交的内容)。
|
||||
|
||||
@@ -1,111 +1,192 @@
|
||||
---
|
||||
title: "流式响应"
|
||||
description: "为什么流式是 Claude Code 的核心设计选择?理解流式传输的设计考量、错误处理策略和多 Provider 适配。"
|
||||
keywords: ["流式响应", "SSE", "streaming", "API streaming"]
|
||||
title: "流式响应机制 - Claude Code 打字机效果原理"
|
||||
description: "解析 Claude Code 流式响应实现:如何通过 SSE 逐 token 接收 AI 输出,实现实时打字机效果,提升用户等待体验。"
|
||||
keywords: ["流式响应", "SSE", "streaming", "实时输出", "API streaming"]
|
||||
sourceRef: "3ec5675 (2026-04-08)"
|
||||
---
|
||||
|
||||
## 为什么流式是核心设计
|
||||
## 为什么需要流式
|
||||
|
||||
想象 AI 需要 30 秒才能生成完整回答——如果等 30 秒后才一次性显示,用户体验是灾难性的。
|
||||
|
||||
但流式不仅仅是为了"打字机效果"。在 Claude Code 中,流式是整个系统架构的基础假设:
|
||||
流式响应让用户**实时看到 AI 的思考过程**:
|
||||
- 文字逐字出现,用户能提前判断方向是否正确
|
||||
- 工具调用的参数在生成过程中就能预览
|
||||
- 长时间任务不会让用户觉得"卡死了"
|
||||
|
||||
- **工具并行执行**:AI 在流式输出过程中就可能发出工具调用,系统可以立即开始执行,不必等整个响应结束。这使得"AI 边想边做"成为可能
|
||||
- **实时反馈**:用户看到 AI 的思考方向后可以提前判断是否正确,必要时提前中断
|
||||
- **可取消性**:流式架构天然支持用户中断——随时可以终止正在进行的流
|
||||
- **工具执行反馈**:不仅是 AI 输出是流式的,工具执行(如 shell 命令)的输出也是流式的——用户实时看到命令输出
|
||||
## `BetaRawMessageStreamEvent` 核心事件类型
|
||||
|
||||
**代价**:流式架构比一次性响应复杂得多——需要处理连接中断、部分数据、乱序事件等边界情况。
|
||||
|
||||
## 流式响应的概念模型
|
||||
|
||||
一次流式响应不是"一个完整的回答",而是一系列按顺序到达的事件流:
|
||||
流式 API 返回的是一系列 `BetaRawMessageStreamEvent`,每种事件类型对应流式响应的不同阶段(`src/services/api/claude.ts`):
|
||||
|
||||
```
|
||||
消息开始
|
||||
├── 内容块 1:文本 "我来帮你修复这个 bug。"
|
||||
├── 内容块 2:工具调用 { name: "Read", input: "..." }
|
||||
├── 内容块 3:文本 "我看到了问题..."
|
||||
└── ...
|
||||
消息结束(包含停止原因和 token 用量)
|
||||
message_start ← 消息开始,包含 model、usage 初始值
|
||||
├── content_block_start ← 内容块开始(text / tool_use / thinking)
|
||||
│ ├── content_block_delta ← 增量数据(text_delta / input_json_delta / thinking_delta)
|
||||
│ ├── content_block_delta ← ... 持续到达
|
||||
│ └── content_block_stop ← 内容块结束,yield AssistantMessage
|
||||
├── content_block_start ← 下一个内容块...
|
||||
│ └── ...
|
||||
└── message_delta ← stop_reason + 最终 usage
|
||||
message_stop ← 消息结束
|
||||
```
|
||||
|
||||
关键设计点:
|
||||
### 事件处理状态机
|
||||
|
||||
### 内容块的增量累加
|
||||
`src/services/api/claude.ts` 中 `queryModelWithStreaming()` 函数的事件处理循环实现了一个基于 `switch(part.type)` 的状态机:
|
||||
|
||||
每个内容块(文本、思考、工具调用)都是通过增量数据逐步构建的。文本逐字到达,工具调用的 JSON 参数逐段到达。系统需要在内存中持续累加这些片段,直到一个内容块完整后才传递给消费者。
|
||||
| 事件类型 | 处理逻辑 | 状态变更 |
|
||||
|----------|----------|----------|
|
||||
| `message_start` | 初始化 `partialMessage`,记录 TTFT(首字节延迟) | `usage` 初始化 |
|
||||
| `content_block_start` | 按 `part.index` 创建对应类型的内容块 | `contentBlocks[index]` 初始化 |
|
||||
| `content_block_delta` | 按子类型增量追加数据 | text / thinking / input 累加 |
|
||||
| `content_block_stop` | 构建完整 `AssistantMessage` 并 yield | 消息推入 `newMessages` |
|
||||
| `message_delta` | 更新 stop_reason 和最终 usage | 写回最后一条消息 |
|
||||
| `message_stop` | 无操作(流结束标记) | — |
|
||||
|
||||
### 多消息产出
|
||||
### 内容块类型及其增量数据
|
||||
|
||||
因为一次 AI 响应可能包含多个内容块(文本和工具调用交替出现),每个完整的内容块都会触发一次消息传递。这意味着一个 API 响应会产生**多条消息**——文本消息和工具调用消息交替产出。
|
||||
`content_block_start` 中的 `content_block.type` 决定了如何处理后续 delta:
|
||||
|
||||
### 停止原因的回写
|
||||
| 内容块类型 | Delta 类型 | 累加逻辑 |
|
||||
|-----------|-----------|----------|
|
||||
| `text` | `text_delta` | `text += delta.text` |
|
||||
| `thinking` | `thinking_delta` + `signature_delta` | `thinking += delta.thinking`,`signature = delta.signature` |
|
||||
| `tool_use` | `input_json_delta` | `input += delta.partial_json`(JSON 字符串增量拼接) |
|
||||
| `server_tool_use` | `input_json_delta` | 同 tool_use |
|
||||
| `connector_text` | `connector_text_delta` | 特殊连接器文本(feature flag 控制) |
|
||||
|
||||
AI 最终是"回答完毕"还是"需要调用工具",这个信息要到最后才知道。所以停止原因是在消息结束时**回写**到最后一条消息上的——消费者在收到中间消息时还不知道整轮对话是否结束。
|
||||
关键设计:`content_block_start` 时所有文本字段初始化为空字符串,只通过 `content_block_delta` 累加。这是因为 SDK 有时在 start 和 delta 中重复发送相同文本。
|
||||
|
||||
## 错误处理:三层防护
|
||||
## 文本 chunk 和 tool_use block 的交织
|
||||
|
||||
流式连接比一次性请求脆弱得多——网络波动、服务器过载、连接超时都可能导致中断。系统设计了三层防护:
|
||||
一次 AI 响应可能包含多个内容块,交替出现:
|
||||
|
||||
### 第一层:被动停滞检测
|
||||
```
|
||||
content_block_start (text, index=0) "我来帮你修复这个 bug。"
|
||||
content_block_delta (text_delta) "首先..."
|
||||
content_block_stop (index=0)
|
||||
content_block_start (tool_use, index=1) { name: "Read", input: "..." }
|
||||
content_block_delta (input_json_delta) '{"file_p' → 'ath":' → '"src/foo.ts"}'
|
||||
content_block_stop (index=1)
|
||||
content_block_start (text, index=2) "我已经看到了问题所在..."
|
||||
content_block_stop (index=2)
|
||||
```
|
||||
|
||||
系统记录每个事件到达的时间间隔。当间隔超过 30 秒时,记录为一次"停滞"并写入遥测。这是被动检测——只在下一个事件到达时才能发现之前的停滞,不会主动中断流。
|
||||
每个 `content_block_stop` 触发一次 `yield`,将完整的 AssistantMessage 推送给消费者。这意味着一个 AI 响应会产生**多条** `AssistantMessage`——文本消息和工具调用消息交替产出。
|
||||
|
||||
**设计考量**:为什么不立即中断?因为 API 可能在做长时间的计算(如复杂推理),短暂的无响应不一定意味着故障。被动检测提供了观测能力,而不影响正常流程。
|
||||
`stop_reason` 要等到 `message_delta` 才确定(可能是 `end_turn`、`tool_use`、`max_tokens` 等),所以最后一条消息的 `stop_reason` 是**回写**的:
|
||||
|
||||
### 第二层:主动空闲超时
|
||||
```typescript
|
||||
// claude.ts — stop_reason 回写逻辑(直接属性修改,不用对象替换)
|
||||
// 因为 transcript 写队列持有 message.message 的引用
|
||||
const lastMsg = newMessages.at(-1)
|
||||
if (lastMsg) {
|
||||
lastMsg.message.usage = usage
|
||||
lastMsg.message.stop_reason = stopReason
|
||||
}
|
||||
```
|
||||
|
||||
如果 90 秒内没有收到任何事件(可通过环境变量配置),系统主动终止流并进入重试流程。
|
||||
## 流式中的错误处理
|
||||
|
||||
**设计考量**:这是兜底机制。真正的故障不能靠被动检测发现——因为被动检测依赖于"下一个事件到达",而如果连接已经死了,下一个事件永远不会到达。
|
||||
### 网络断开
|
||||
|
||||
### 第三层:非流式降级
|
||||
流式连接依赖 SSE(Server-Sent Events)。当连接中断时,系统有两层检测机制:
|
||||
|
||||
作为最后手段,系统可以回退到非流式请求——一次性获取完整响应。失去了实时性,但保证了功能可用性。
|
||||
1. **被动停滞检测**(`src/services/api/claude.ts` 中 stall 检测逻辑):当下一个事件到达时,计算与上一个事件的时间间隔。超过阈值(30 秒,`STALL_THRESHOLD_MS = 30_000`)记录为一次 stall,累积计数并写入遥测日志。这是被动检测——仅在下一个 chunk 到达时才触发,不会主动中断流。
|
||||
2. **主动空闲超时看门狗**(`src/services/api/claude.ts` 中 `STREAM_IDLE_TIMEOUT_MS` 看门狗逻辑):使用 `setTimeout` 设置 90 秒(可通过 `CLAUDE_STREAM_IDLE_TIMEOUT_MS` 环境变量覆盖)的硬性超时。如果在此期间没有收到任何事件,主动终止流并抛出错误进入重试流程。
|
||||
3. **非流式降级**:作为最后手段,设置 `didFallBackToNonStreaming` 标志,通过 `executeNonStreamingRequest()` 回退到非流式请求(一次性获取完整响应)。
|
||||
|
||||
### 降级策略对比
|
||||
```typescript
|
||||
// claude.ts — 被动停滞检测
|
||||
const STALL_THRESHOLD_MS = 30_000 // 30 秒无事件视为停滞
|
||||
let totalStallTime = 0
|
||||
let stallCount = 0
|
||||
|
||||
| 策略 | 检测方式 | 响应时间 | 用户体验 |
|
||||
|------|----------|----------|----------|
|
||||
| 正常流式 | — | 最低延迟 | 逐字显示 |
|
||||
| 被动停滞检测 | 下一个事件到达时 | 不变 | 无感知 |
|
||||
| 主动超时中断 | 定时器触发 | 中断后重试延迟 | 短暂停顿后恢复 |
|
||||
| 非流式降级 | 重试失败后 | 等待完整响应 | 等待后一次性显示 |
|
||||
// claude.ts — 主动空闲超时
|
||||
const STREAM_IDLE_TIMEOUT_MS =
|
||||
parseInt(process.env.CLAUDE_STREAM_IDLE_TIMEOUT_MS || '', 10) || 90_000
|
||||
```
|
||||
|
||||
## Token 超限的两种场景
|
||||
### API 限流
|
||||
|
||||
两种不同的 token 超限需要不同的处理策略:
|
||||
当 API 返回限流错误时,系统使用 `withRetry` 包装器进行指数退避重试。重试逻辑考虑了:
|
||||
- 错误类型(429 限流 vs 500 服务器错误)
|
||||
- 重试次数上限
|
||||
- 退避间隔
|
||||
|
||||
| 场景 | 含义 | 处理方式 |
|
||||
|------|------|----------|
|
||||
| **输出超限** | AI 话说了一半被切断 | 提升输出上限重试,或提示 AI "接着说" |
|
||||
| **上下文窗口超限** | 整个对话历史太长,塞不进 API | 触发自动压缩,用 AI 摘要替代原始对话 |
|
||||
### Token 超限
|
||||
|
||||
关键区别:输出超限是"AI 话太多",可以通过调整上限解决;上下文超限是"给 AI 看的东西太多",必须通过压缩或删除来减少。
|
||||
两种 token 超限场景有不同的处理:
|
||||
|
||||
| 场景 | stop_reason | 处理方式 |
|
||||
|------|------------|----------|
|
||||
| **输出超限** | `max_tokens` | 生成错误消息,建议设置 `CLAUDE_CODE_MAX_OUTPUT_TOKENS` |
|
||||
| **上下文窗口超限** | `model_context_window_exceeded` | 触发 compaction 压缩对话历史后重试 |
|
||||
|
||||
```typescript
|
||||
// claude.ts — stop_reason 处理
|
||||
if (stopReason === 'max_tokens') {
|
||||
yield createAssistantAPIErrorMessage({ error: 'max_output_tokens', ... })
|
||||
}
|
||||
if (stopReason === 'model_context_window_exceeded') {
|
||||
// 复用 max_output_tokens 的恢复路径
|
||||
yield createAssistantAPIErrorMessage({ error: 'max_output_tokens', ... })
|
||||
}
|
||||
```
|
||||
|
||||
### 流式停滞检测
|
||||
|
||||
系统持续监控事件到达间隔,检测"停滞"(stall):
|
||||
|
||||
```typescript
|
||||
// claude.ts — stall 检测逻辑
|
||||
const STALL_THRESHOLD_MS = 30_000 // 30 秒无事件视为停滞
|
||||
if (timeSinceLastEvent > STALL_THRESHOLD_MS) {
|
||||
stallCount++
|
||||
totalStallTime += timeSinceLastEvent
|
||||
logEvent('tengu_streaming_stall', { stall_duration_ms, stall_count, ... })
|
||||
}
|
||||
```
|
||||
|
||||
这是**被动检测**——仅在下一个 chunk 到达时才触发比较。与之互补的是 90 秒主动空闲超时看门狗(`STREAM_IDLE_TIMEOUT_MS`),会直接中断长时间无响应的流。
|
||||
|
||||
## 工具执行的流式反馈
|
||||
|
||||
不仅是 API 响应是流式的,工具执行本身也是流式的。例如 BashTool 执行 shell 命令时,命令的标准输出会实时推送给 UI——用户不需要等命令完全结束才能看到结果。
|
||||
BashTool 的命令执行也是流式的——通过 `onProgress` 回调逐行推送输出:
|
||||
|
||||
长时间运行的命令还支持**自动后台化**:如果命令执行超过一定时间,系统自动将其移到后台,AI 可以继续处理其他任务,命令完成后再回调结果。
|
||||
```
|
||||
BashTool.call() → runShellCommand() → AsyncGenerator
|
||||
├── 每秒轮询输出文件 → onProgress(lastLines, allLines, ...)
|
||||
├── yield { type: 'progress', output, fullOutput, elapsedTimeSeconds }
|
||||
└── return { code, stdout, interrupted, ... }
|
||||
```
|
||||
|
||||
UI 层通过 `useToolCallProgress` hook 实时展示命令输出,而不是等命令完全结束。长时间运行的命令还支持自动后台化(`shouldAutoBackground`)。
|
||||
|
||||
## 多 Provider 适配
|
||||
|
||||
系统支持 7 种 API Provider,每种有不同的流式协议和认证方式:
|
||||
| Provider | 流式协议 | 特殊处理 |
|
||||
|----------|----------|----------|
|
||||
| **firstParty** (Anthropic Direct) | 原生 SSE | 延迟最低,TTFT 最快 |
|
||||
| **AWS Bedrock** | AWS SDK 流式接口 | 需要额外的 beta header 和认证 |
|
||||
| **Google Vertex** | gRPC → 事件流 | 通过 `getMergedBetas()` 适配 |
|
||||
| **foundry** | Anthropic 兼容 API | 内部部署 |
|
||||
| **openai** | OpenAI 流式适配器 | 转换为 Anthropic 内部格式 |
|
||||
| **gemini** | Gemini 流式适配器 | 转换为 Anthropic 内部格式 |
|
||||
| **grok** (xAI) | Grok 流式适配器 | 转换为 Anthropic 内部格式 |
|
||||
|
||||
| Provider 类别 | 流式方式 | 设计挑战 |
|
||||
|---------------|----------|----------|
|
||||
| Anthropic 直连 | 原生 SSE | 基准实现,其他 Provider 对齐它 |
|
||||
| 云平台(Bedrock/Vertex) | SDK 封装的流式接口 | 需要适配认证、beta header、参数格式 |
|
||||
| 第三方兼容(OpenAI/Gemini/Grok) | 各自的流式协议 | 需要转换为 Anthropic 内部格式 |
|
||||
所有 Provider 通过统一的 `Stream<BetaRawMessageStreamEvent>` 抽象层屏蔽差异。上层代码(QueryEngine、REPL)不需要关心底层用的是哪个 Provider。
|
||||
|
||||
**设计策略**:所有 Provider 通过统一的流式抽象层屏蔽差异。上层代码(编排层、交互层)不需要关心底层用的是哪个 Provider——它们只看到统一的事件流。
|
||||
### Provider 选择
|
||||
|
||||
这意味着切换 Provider 不需要修改任何业务逻辑,只需要在通信层适配新的协议。这也是为什么"通信层"是独立的一层。
|
||||
`src/utils/model/providers.ts` 中的 `getAPIProvider()` 根据配置决定使用哪个 Provider:
|
||||
|
||||
## 接下来
|
||||
```typescript
|
||||
// 根据 api_provider 配置选择:
|
||||
// "anthropic" → 直连
|
||||
// "bedrock" → AWS SDK
|
||||
// "vertex" → Google SDK
|
||||
// 第三方 base URL → 自动检测
|
||||
```
|
||||
|
||||
- **多轮对话** — 理解跨迭代的上下文管理
|
||||
- **上下文压缩** — 深入了解 token 超限时的自动压缩机制
|
||||
- **工具系统** — 了解工具执行的并行策略
|
||||
每个 Provider 需要适配的细节包括:认证方式、beta header、请求参数格式、错误码映射——但这些差异在 `claude.ts` 的 `queryStream()` 函数中被统一处理。
|
||||
|
||||
@@ -1,166 +1,197 @@
|
||||
---
|
||||
title: "Agent Loop"
|
||||
description: "理解 Claude Code 的核心循环机制——AI 如何自主决定工具调用、处理错误、管理上下文,直到任务完成。"
|
||||
keywords: ["Agentic Loop", "tool_use", "状态机", "auto-compact", "streaming"]
|
||||
title: "Agentic Loop:AI 自主循环的核心机制"
|
||||
description: "深入解析 Claude Code 的 query() 异步生成器循环——从流式 API 调用、工具并行执行、上下文压缩、错误恢复到终止条件的完整状态机,基于 src/query.ts 的源码级分析。"
|
||||
keywords: ["Agentic Loop", "query loop", "tool_use", "状态机", "auto-compact", "streaming", "recovery"]
|
||||
sourceRef: "3ec5675 (2026-04-08)"
|
||||
---
|
||||
|
||||
{/* 本章目标:基于 src/query.ts 揭示 Agentic Loop 的完整状态机 */}
|
||||
|
||||
## 什么是 Agentic Loop
|
||||
|
||||
传统聊天机器人:你问一句,它答一句。
|
||||
传统聊天机器人:你问一句,它答一句。
|
||||
Claude Code 不一样:你说一个需求,它可能连续执行十几步操作才给你最终结果。
|
||||
|
||||
这背后的机制叫做 **Agentic Loop**(智能体循环)。它是一个"思考→行动→观察"的不断循环,直到任务完成或遇到终止条件。
|
||||
这背后的机制叫做 **Agentic Loop**(智能体循环),核心实现在 `src/query.ts` 的 `queryLoop()` 异步生成器函数。它是一个 `while(true)` 无限循环,每次迭代代表一次"思考→行动→观察"周期。
|
||||
|
||||
<Frame caption="Agentic Loop 循环示意">
|
||||
<img src="/docs/images/agentic-loop.png" alt="Agentic Loop 循环图" />
|
||||
</Frame>
|
||||
|
||||
### 为什么需要循环而非一次回答
|
||||
## 循环的完整结构
|
||||
|
||||
因为软件工程任务本质上是**探索性**的。AI 不可能在第一步就知道所有信息:
|
||||
`queryLoop()` 的每次迭代(`src/query.ts` 中 `while(true)` 主循环)包含以下阶段:
|
||||
|
||||
- 它需要先读代码才能知道怎么改
|
||||
- 它需要先运行命令才能知道结果
|
||||
- 它需要先搜索才能找到相关文件
|
||||
- 它需要先修改才能验证是否正确
|
||||
### 阶段 1:上下文预处理(Pre-Processing Pipeline)
|
||||
|
||||
每一步工具执行都产生**真实信息**——命令输出、文件内容、错误信息——这些是 AI 在执行前不可能预知的。因此,AI 必须在每一步后根据新信息重新决策。
|
||||
|
||||
## 循环的四个阶段
|
||||
|
||||
每次循环迭代包含四个阶段,形成一个完整的"感知→决策→执行→反馈"周期。
|
||||
|
||||
### 阶段一:上下文预处理
|
||||
|
||||
在调用 API 之前,系统会依次检查和处理上下文。这是一个串行管道,每一步的输出是下一步的输入:
|
||||
在调用 API 之前,依次执行 5 个压缩/优化步骤:
|
||||
|
||||
```
|
||||
原始消息
|
||||
→ 工具结果截断(单条输出过长时截断)
|
||||
→ 历史压缩(Snip 压缩旧消息)
|
||||
→ 微压缩(工具结果摘要化)
|
||||
→ 自动压缩(对话接近 token 上限时触发 AI 摘要)
|
||||
处理后的消息 → 发往 API
|
||||
messagesForQuery(原始消息)
|
||||
↓ applyToolResultBudget() — 工具结果预算截断(按 maxResultSizeChars)
|
||||
↓ snipCompactIfNeeded() — 历史 Snip 压缩(HISTORY_SNIP feature)
|
||||
↓ microcompact() — 微压缩(工具结果摘要)
|
||||
↓ applyCollapsesIfNeeded() — 上下文折叠(CONTEXT_COLLAPSE feature)
|
||||
↓ autocompact() — 自动压缩(超出阈值时触发)
|
||||
messagesForQuery(处理后的消息)→ 发往 API
|
||||
```
|
||||
|
||||
**设计考量**:为什么是串行管道而非一次性处理?因为每个步骤释放的 token 数会影响下一步的决策。例如,如果 Snip 压缩已经释放了足够的 token,自动压缩就不需要触发了。
|
||||
每个步骤的输出是下一步的输入,形成串行管道。Snip 和 Microcompact 的释放 token 数会传递给 autocompact 的阈值计算(`snipTokensFreed`),避免重复压缩。
|
||||
|
||||
### 阶段二:流式 API 调用
|
||||
### 阶段 2:流式 API 调用(Streaming Loop)
|
||||
|
||||
系统以流式方式调用 Claude API。流式传输不是"锦上添花"——它是核心设计决策:
|
||||
`deps.callModel()` 发起流式请求(`src/query.ts` 中 `attemptWithFallback` 循环内),返回一个 AsyncGenerator。在流式过程中:
|
||||
|
||||
- **用户体验**:用户看到 AI 逐字输出,而非等待数秒后一次性显示
|
||||
- **工具并行执行**:AI 在流式输出过程中就可能发出工具调用,系统可以立即开始执行,不必等流结束
|
||||
- **可取消性**:用户随时可以中断正在进行的流式响应
|
||||
- **AssistantMessage** 被收集到 `assistantMessages[]` 数组
|
||||
- **tool_use 块** 被提取到 `toolUseBlocks[]`,设置 `needsFollowUp = true`
|
||||
- **StreamingToolExecutor** 在流式过程中就开始并行执行工具(不等流结束)
|
||||
- 可恢复的错误(prompt-too-long、max-output-tokens)被**暂扣**(withheld),先尝试恢复
|
||||
|
||||
### 阶段三:工具执行
|
||||
流式回调中的关键守卫:
|
||||
- `backfillObservableInput()` —— 为 tool_use 块回填可观察字段(如文件路径展开),但只在添加了新字段时才克隆消息,避免破坏 prompt cache 的字节一致性
|
||||
- 流式降级检测——如果 `streamingFallbackOccured`,已收集的消息被标记为 tombstone,清空后重试
|
||||
|
||||
如果 AI 请求了工具调用,系统执行工具并将结果回传。这里有两个关键设计:
|
||||
### 阶段 3:工具执行(Tool Execution)
|
||||
|
||||
**并行执行**:当 AI 在一次响应中请求多个独立工具调用时(如同时读两个文件),系统并行执行它们。这直接减少了用户等待时间。
|
||||
如果 `needsFollowUp` 为 true,循环不会终止,而是执行工具:
|
||||
|
||||
**权限检查**:每个工具执行前都经过权限验证。危险操作(如执行 shell 命令)需要用户确认,安全操作(如读文件)可以自动放行。
|
||||
```typescript
|
||||
// 两种工具执行器(互斥)
|
||||
const toolUpdates = streamingToolExecutor
|
||||
? streamingToolExecutor.getRemainingResults() // 流式:获取已完成的+等待中的
|
||||
: runTools(toolUseBlocks, assistantMessages, canUseTool, toolUseContext)
|
||||
```
|
||||
|
||||
### 阶段四:终止或继续
|
||||
工具结果通过 `normalizeMessagesForAPI()` 标准化后,与原始消息合并,进入**下一轮循环迭代**。
|
||||
|
||||
每次迭代结束时,系统判断是否需要继续:
|
||||
### 阶段 4:终止或继续
|
||||
|
||||
| 条件 | 结果 |
|
||||
|------|------|
|
||||
| AI 请求了工具调用 | 继续(下一轮迭代) |
|
||||
| AI 只返回文本,没有工具调用 | 终止(任务完成) |
|
||||
| 用户中断 | 终止(用户取消) |
|
||||
| 达到最大 turn 数 | 终止(安全限制) |
|
||||
| Token 预算耗尽 | 终止(成本控制) |
|
||||
每次迭代结束时,根据条件决定 `return`(终止)或 `continue`(继续):
|
||||
|
||||
## 错误恢复:自愈的状态机
|
||||
## 终止条件(源码级)
|
||||
|
||||
Agentic Loop 不是"正常路径走完就结束"的简单循环。它包含了多层错误恢复机制,使系统在各种异常情况下都能优雅处理。
|
||||
循环有多种终止路径,按触发时机排列:
|
||||
|
||||
### 输出截断恢复
|
||||
| 终止原因 | 触发位置 | 机制 |
|
||||
|----------|---------|------|
|
||||
| **blocking_limit** | 第 686 行 | Token 计数超过硬限制(非 autocompact 模式)→ 生成 PTL 错误消息 → 返回 |
|
||||
| **image_error** | 第 1021 行 | `ImageSizeError` / `ImageResizeError` 异常 → 直接返回 |
|
||||
| **model_error** | 第 1040 行 | `callModel()` 抛出不可恢复异常 → 生成错误消息 → 返回 |
|
||||
| **aborted_streaming** | 第 1095 行 | `abortController.signal.aborted`(流式阶段)→ 为未完成的 tool_use 生成合成 tool_result → 返回 |
|
||||
| **prompt_too_long** | 第 1219/1226 行 | 413 错误且 reactive compact 无法恢复 → 暂扣的错误消息被释放 → 返回 |
|
||||
| **completed** | 第 1308 行 | API 错误(限流、认证失败等)导致无法继续 → 返回 |
|
||||
| **stop_hook_prevented** | 第 1323 行 | Stop hook 返回 `preventContinuation: true` → 返回 |
|
||||
| **completed** | 第 1401 行 | 正常完成:AI 未发出 tool_use → `needsFollowUp = false` → 经过 stop hooks → 返回 |
|
||||
| **aborted_tools** | 第 1559 行 | `abortController.signal.aborted`(工具执行阶段)→ 返回 |
|
||||
| **hook_stopped** | 第 1564 行 | 工具执行期间 hook 返回 `shouldPreventContinuation` → 返回 |
|
||||
| **max_turns** | 第 1755 行 | 轮次计数超过 `maxTurns` 限制 → 返回 |
|
||||
|
||||
当 AI 的响应被 token 上限截断时(AI 话说了一半被切断):
|
||||
## 继续条件(恢复路径)
|
||||
|
||||
1. **首次截断**:静默提升输出 token 上限,重试
|
||||
2. **仍然截断**:注入提示消息让 AI "接着说",最多重试 3 次
|
||||
3. **恢复耗尽**:将截断的响应作为最终结果返回
|
||||
循环不仅是一个简单的"有 tool_use 就继续",它还包含多种恢复/重试路径:
|
||||
|
||||
### 上下文过长恢复
|
||||
### 1. 正常工具循环(`next_turn`)
|
||||
`needsFollowUp = true` → 执行工具 → 新消息追加到 `messagesForQuery` → state 重新赋值 → `continue`
|
||||
|
||||
当对话历史超过 API 的 token 限制时(413 错误):
|
||||
### 2. max_output_tokens 恢复(`max_output_tokens_escalate` / `max_output_tokens_recovery`)
|
||||
当 AI 输出被截断时(`apiError === 'max_output_tokens'`),分两阶段恢复:
|
||||
- **提升阶段**(`max_output_tokens_escalate`):首次截断时,将 `maxOutputTokens` 从默认值提升到 `ESCALATED_MAX_TOKENS`(64K)。静默重试,不注入 meta 消息。
|
||||
- **恢复阶段**(`max_output_tokens_recovery`):提升后仍然截断时,注入恢复消息"Output token limit hit. Resume directly...",最多重试 `MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3` 次。恢复耗尽后,暂扣的错误消息被释放。
|
||||
|
||||
1. **压缩重试**:即时压缩对话历史,生成摘要后重试
|
||||
2. **压缩后仍过长**:返回错误信息,让用户决定如何处理
|
||||
### 3. Prompt-Too-Long 恢复(`collapse_drain_retry` / `reactive_compact_retry`)
|
||||
当遇到 413 错误时,按优先级尝试两种压缩策略:
|
||||
- **Context Collapse Drain**(`collapse_drain_retry`):提交所有已暂存的折叠(collapse),释放空间后重试。如果上一轮已经是 `collapse_drain_retry` 则跳过,避免无限循环。
|
||||
- **Reactive Compact**(`reactive_compact_retry`):如果 collapse drain 无法恢复,触发即时压缩(reactive compact),生成摘要后重试。`hasAttemptedReactiveCompact` 标志防止无限循环。
|
||||
|
||||
关键设计:系统通过标志位防止无限循环——每种恢复路径只尝试一次,不会在"压缩→失败→压缩"之间死循环。
|
||||
### 4. Stop Hook 阻塞重试(`stop_hook_blocking`)
|
||||
Stop hook 可以注入阻塞错误消息,强制 AI 重新思考。新的消息(包含阻塞错误)被追加到对话中,`stopHookActive = true`,进入下一轮迭代。
|
||||
|
||||
### 模型降级
|
||||
### 5. Token Budget 继续提示(`token_budget_continuation`)
|
||||
当 `TOKEN_BUDGET` feature 启用时,如果 token 消耗达到阈值但未超出预算,注入 nudge 消息让 AI 加速收尾,然后继续。
|
||||
|
||||
当主模型不可用时(过载、维护等):
|
||||
## 模型降级(Fallback)
|
||||
|
||||
1. 已收集的响应被保留为历史记录
|
||||
2. 自动切换到备用模型
|
||||
3. 通知用户发生了降级
|
||||
4. 从中断点继续,而不是从头开始
|
||||
当主模型不可用时(`FallbackTriggeredError`,`src/query.ts` 中 `attemptWithFallback` 循环的 catch 分支):
|
||||
|
||||
## 状态管理
|
||||
1. 已收集的 `assistantMessages` 被清空,tool_use 块收到合成 tool_result:"Model fallback triggered"
|
||||
2. 思维签名块被移除(`stripSignatureBlocks`)—— 因为思维签名与模型绑定,跨模型回放会 400
|
||||
3. 切换到 `fallbackModel`,更新 `toolUseContext.options.mainLoopModel`
|
||||
4. 生成系统消息:"Switched to {fallback} due to high demand for {original}"
|
||||
5. 重新发起流式请求
|
||||
|
||||
每次迭代的状态是不可变更新的——系统创建新的状态对象而非就地修改。状态中包含:
|
||||
## 状态机:State 对象
|
||||
|
||||
- **对话消息**:当前所有消息的数组
|
||||
- **压缩跟踪**:压缩操作的累计状态
|
||||
- **恢复计数**:各种错误恢复已尝试的次数
|
||||
- **继续原因**:上一轮为什么继续(用于检测和避免循环)
|
||||
每次迭代的状态通过 `State` 类型(`src/query.ts`,类型定义)传递:
|
||||
|
||||
**设计考量**:状态中记录"继续原因"是一个关键的防循环机制。系统可以在后续迭代中检查"上一轮是因为压缩重试而继续的",从而避免在同一个恢复路径上反复尝试。
|
||||
```typescript
|
||||
// src/query.ts — State 类型定义
|
||||
type State = {
|
||||
messages: Message[] // 当前对话消息
|
||||
toolUseContext: ToolUseContext // 工具上下文(含权限)
|
||||
autoCompactTracking: AutoCompactTrackingState | undefined // 压缩跟踪
|
||||
maxOutputTokensRecoveryCount: number // 输出截断恢复计数
|
||||
hasAttemptedReactiveCompact: boolean // 是否已尝试即时压缩
|
||||
maxOutputTokensOverride: number | undefined // 输出 token 上限覆盖
|
||||
pendingToolUseSummary: Promise<...> | undefined // 异步工具摘要
|
||||
stopHookActive: boolean | undefined // Stop hook 是否激活
|
||||
turnCount: number // 轮次计数
|
||||
transition: Continue | undefined // 上一次继续的原因
|
||||
}
|
||||
```
|
||||
|
||||
每次 `continue` 都创建新的 State 对象(不可变更新),而非就地修改。`transition` 字段记录了为什么继续——让后续迭代能检测特定恢复路径(如 `collapse_drain_retry`)避免循环。
|
||||
|
||||
## Token Budget(实验性)
|
||||
|
||||
当 `TOKEN_BUDGET` feature 启用时(`src/query.ts` 中 `!needsFollowUp` 分支内的预算检查逻辑),循环在终止前会检查 token 消耗:
|
||||
|
||||
- **continuation**:未达到预算但超过阈值 → 注入 nudge 消息,让 AI 加速收尾
|
||||
- **diminishing_returns**:检测到收益递减 → 提前终止
|
||||
- 预算数据来自 `createBudgetTracker()`,跨迭代累计
|
||||
|
||||
## 为什么不是"一次规划,批量执行"
|
||||
|
||||
一个自然的疑问是:为什么不先让 AI 规划好所有步骤,然后一次性批量执行?
|
||||
<Note>
|
||||
源码揭示了为什么 Claude Code 选择逐步循环:
|
||||
</Note>
|
||||
|
||||
答案在于软件工程的**不确定性**:
|
||||
|
||||
- **每步结果影响下一步**:搜索结果决定了要改哪些文件,修改后的编译结果决定了是否需要进一步调整
|
||||
- **错误需要即时修正**:如果某步失败,AI 需要立即调整策略,而非继续执行无效计划
|
||||
- **用户可能中途干预**:循环架构允许用户随时打断和修正方向
|
||||
|
||||
这不是说 AI 不做规划——事实上系统内置了规划模式(Plan Mode)用于复杂任务。但规划的结果仍然是逐步执行的,每一步都有机会根据新信息调整。
|
||||
- **每一步都产生真实信息**:`runTools()` 返回的 `toolResults` 是 API 不可能预知的——命令输出、文件内容、错误信息
|
||||
- **动态上下文管理**:每轮迭代前都重新评估压缩需求(autocompact → microcompact → snip),基于最新的 token 计数
|
||||
- **错误即时恢复**:工具失败不需要推倒重来——stop hook 可以注入阻塞错误让 AI 修正策略
|
||||
- **用户可控**:`abortController.signal` 在循环的多个检查点被检测(第 1059、1095、1529 行),用户按 ESC 可以优雅中断
|
||||
- **成本控制**:Token Budget 在每轮终止前检查,防止 AI 无效循环
|
||||
|
||||
## 一个完整的迭代示例
|
||||
|
||||
> 用户:"帮我找到项目里所有未使用的导入语句,然后删掉它们"
|
||||
|
||||
```
|
||||
迭代 1: 探索
|
||||
AI: 先找到所有 TypeScript 文件
|
||||
工具: Glob("**/*.ts") → 返回 42 个文件
|
||||
决策: 需要进一步分析 → 继续
|
||||
迭代 1: 思考→行动
|
||||
预处理管道: applyToolResultBudget → snipCompact(HISTORY_SNIP feature) → microcompact → applyCollapses(CONTEXT_COLLAPSE feature) → autocompact
|
||||
→ 上下文很短,无需压缩
|
||||
API 调用: 返回 tool_use(Glob, "**/*.ts")
|
||||
工具执行: 返回 42 个文件路径
|
||||
→ needsFollowUp = true
|
||||
→ transition: { reason: 'next_turn' }, continue
|
||||
|
||||
迭代 2: 分析
|
||||
AI: 搜索这些文件中的 import 语句
|
||||
工具: Grep("import.*from") → 在 15 个文件中找到 120 条 import
|
||||
决策: 结果太多,需要进一步筛选 → 继续
|
||||
迭代 2: 思考→行动
|
||||
预处理管道: 42 个文件结果仍在预算内
|
||||
API 调用: 返回 tool_use(Grep, "import.*from")
|
||||
工具执行: 在 15 个文件中找到 120 条 import
|
||||
→ needsFollowUp = true
|
||||
→ transition: { reason: 'next_turn' }, continue
|
||||
|
||||
迭代 3: 精确修改
|
||||
AI: 分析哪些 import 未被使用,删除它们
|
||||
上下文预处理: 120 条结果被微压缩为摘要
|
||||
工具: FileEdit × 3 → 删除 5 条未使用导入
|
||||
决策: 需要验证 → 继续
|
||||
迭代 3: 思考→行动(多轮)
|
||||
预处理管道: 120 条 Grep 结果触发 microcompact → 摘要化
|
||||
API 调用: 返回 3 个 tool_use(FileEdit, ...)
|
||||
工具执行: 删除 5 条未使用导入
|
||||
→ needsFollowUp = true
|
||||
→ transition: { reason: 'next_turn' }, continue
|
||||
|
||||
迭代 4: 验证与总结
|
||||
AI: 验证修改后编译通过
|
||||
工具: Bash("tsc --noEmit") → 编译通过
|
||||
决策: 任务完成 → 终止
|
||||
迭代 4: 总结
|
||||
API 调用: 返回纯文本"已清理 3 个文件中的 5 条未使用导入"
|
||||
→ needsFollowUp = false
|
||||
→ Stop hooks 通过
|
||||
→ Token Budget 检查通过(如果启用)
|
||||
→ return { reason: 'completed' }
|
||||
```
|
||||
|
||||
注意这个过程中的关键特征:
|
||||
- AI 在每一步后根据结果自主决定下一步
|
||||
- 上下文在迭代过程中动态调整(微压缩被触发)
|
||||
- 用户全程无需介入
|
||||
|
||||
## 接下来
|
||||
|
||||
- **流式响应** — 理解流式传输的设计细节和用户体验考量
|
||||
- **多轮对话** — 跨迭代的上下文管理和会话持久化
|
||||
- **上下文压缩** — 深入理解自动压缩的触发条件和策略
|
||||
- **工具系统** — 了解 AI 可以调用哪些工具及其设计
|
||||
|
||||
323
docs/design/tool-search-design-guide.md
Normal file
323
docs/design/tool-search-design-guide.md
Normal file
@@ -0,0 +1,323 @@
|
||||
# ToolSearch 设计指南
|
||||
|
||||
> 基于 feature/tool_search 分支的 4 次 commit 迭代,系统性地记录 ToolSearch 的架构、核心机制、演进历史和维护指南。
|
||||
|
||||
## 1. 问题背景
|
||||
|
||||
Claude Code 内置了 60+ 工具,加上用户连接的 MCP 服务器可能引入数十甚至上百个额外工具。将所有工具的完整 schema 一次性发送给模型,会产生几个严重问题:
|
||||
|
||||
1. **Token 爆炸** — 每个工具定义(name + description + inputSchema)平均消耗数百 token,60 个工具就是数万 token 的常量开销。
|
||||
2. **Prompt Cache 失效** — 工具列表作为 prompt 的一部分参与缓存计算。任何工具的增减(如 MCP 服务器连接/断开)都会导致整段缓存失效。
|
||||
3. **模型注意力稀释** — 过多的工具定义干扰模型对核心工具的选择准确性。
|
||||
|
||||
## 2. 解决方案概览
|
||||
|
||||
ToolSearch 采用 **延迟加载(Deferred Loading)** 模式:
|
||||
|
||||
- 将工具分为 **Core Tools**(始终加载)和 **Deferred Tools**(按需发现)
|
||||
- 模型通过 `SearchExtraTools` 工具搜索并发现 deferred tools
|
||||
- 通过 `ExecuteExtraTool` 工具代理执行发现的 deferred tools
|
||||
- **工具数组在会话中保持稳定**,不再动态注入已发现的 deferred tools(v3 修复的关键决策)
|
||||
|
||||
## 3. 核心架构
|
||||
|
||||
### 3.1 工具分类体系
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ All Tools (60+ built-in + MCP) │
|
||||
├───────────────────────────┬─────────────────────────────────┤
|
||||
│ Core Tools (29 个) │ Deferred Tools (其余全部) │
|
||||
│ 始终加载,直接调用 │ 不加载 schema,按需发现 │
|
||||
│ CORE_TOOLS 白名单定义 │ isDeferredTool() 判定 │
|
||||
└───────────────────────────┴─────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Core Tools**(`src/constants/tools.ts` 中的 `CORE_TOOLS` Set):
|
||||
|
||||
| 类别 | 工具 |
|
||||
|------|------|
|
||||
| 文件操作 | Bash/Shell, Read, Edit, Write, Glob, Grep, NotebookEdit |
|
||||
| Agent 交互 | Agent, AskUserQuestion |
|
||||
| 任务管理 | TaskOutput, TaskStop, TaskCreate, TaskGet, TaskList, TaskUpdate, TodoWrite |
|
||||
| 规划 | EnterPlanMode, ExitPlanMode, VerifyPlanExecution |
|
||||
| Web | WebFetch, WebSearch |
|
||||
| 代码智能 | LSP |
|
||||
| 技能 | Skill |
|
||||
| 调度/监控 | Sleep |
|
||||
| 工具发现 | SearchExtraTools, ExecuteExtraTool, SyntheticOutput |
|
||||
|
||||
**isDeferredTool 判定逻辑**(`packages/builtin-tools/src/tools/SearchExtraToolsTool/prompt.ts`):
|
||||
|
||||
```
|
||||
isDeferredTool(tool) =
|
||||
tool.alwaysLoad === true? → false(显式跳过延迟)
|
||||
CORE_TOOLS.has(tool.name)? → false(核心工具不延迟)
|
||||
otherwise → true(其余全部延迟)
|
||||
```
|
||||
|
||||
### 3.2 三层组件架构
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ API Layer (src/services/api/claude.ts) │
|
||||
│ ├─ 判定是否启用 ToolSearch │
|
||||
│ ├─ 过滤 deferred tools 不进入 API tools 数组 │
|
||||
│ ├─ 注入 <available-deferred-tools> 或 delta 附件 │
|
||||
│ └─ 处理 tool_reference/text 格式的消息归一化 │
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ Query Loop (src/query.ts) │
|
||||
│ ├─ Turn-zero 预取:用户输入时触发 │
|
||||
│ └─ Inter-turn 预取:assistant turn 后异步触发 │
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ Search Engine │
|
||||
│ ├─ SearchExtraToolsTool — 搜索入口(4 种查询模式) │
|
||||
│ ├─ TF-IDF Index (toolIndex.ts) — 语义搜索 │
|
||||
│ ├─ Keyword Search — 精确匹配 │
|
||||
│ └─ ExecuteExtraTool — 代理执行 │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.3 搜索引擎设计
|
||||
|
||||
SearchExtraToolsTool 支持四种查询模式:
|
||||
|
||||
| 模式 | 语法 | 行为 | 返回 |
|
||||
|------|------|------|------|
|
||||
| **Select** | `select:CronCreate,Snip` | 按名称直接获取,逗号分隔多选 | 精确匹配列表 |
|
||||
| **Discover** | `discover:schedule cron job` | 纯发现模式,返回描述+schema | 工具信息文本 |
|
||||
| **Keyword** | `notebook jupyter` | 关键词搜索 | 按相关性排序 |
|
||||
| **Required** | `+slack send` | `+` 前缀强制包含 | 包含必选词的结果 |
|
||||
|
||||
**混合搜索算法**:
|
||||
|
||||
```
|
||||
最终分数 = 关键词分数 × 0.4 + TF-IDF 分数 × 0.6
|
||||
```
|
||||
|
||||
- **Keyword Search**:基于工具名解析(CamelCase 分词、MCP 前缀拆解)、searchHint 匹配、描述文本匹配,加权计分
|
||||
- **TF-IDF Search**:复用 `skillSearch/localSearch.ts` 的算法,对 name (3.0)、searchHint (2.5)、description (1.0) 三个字段加权计算 TF-IDF 向量
|
||||
|
||||
**MCP 工具名解析**:
|
||||
|
||||
```
|
||||
mcp__slack__send_message → parts: ["slack", "send", "message"]
|
||||
CamelCase → parts: ["cron", "create"]
|
||||
```
|
||||
|
||||
### 3.4 执行管道
|
||||
|
||||
```
|
||||
模型调用 ExecuteExtraTool({tool_name: "CronCreate", params: {...}})
|
||||
↓
|
||||
ExecuteTool.call() 在全局工具注册表中查找 CronCreate
|
||||
↓
|
||||
检查目标工具 isEnabled() — 桥接/条件工具可能不可用
|
||||
↓
|
||||
委托目标工具的 checkPermissions() — 权限传递给实际工具
|
||||
↓
|
||||
调用目标工具的 call() — 与直接调用完全等价
|
||||
↓
|
||||
返回结果(包装为 ExecuteExtraTool 的 output schema)
|
||||
```
|
||||
|
||||
关键设计:ExecuteExtraTool 的 `checkPermissions()` 返回 `passthrough`,将权限决策完全委托给目标工具。它本身不引入额外的权限层。
|
||||
|
||||
### 3.5 Prompt Cache 稳定性策略(v3 关键修复)
|
||||
|
||||
**问题**:早期版本在发现 deferred tool 后会将其注入 API tools 数组,导致每次发现新工具时 tools JSON 变化,prompt cache 全面失效。
|
||||
|
||||
**修复**(commit `c14b7ead`):deferred tools **始终不进入 API tools 数组**。tools 数组在整个会话中只包含 core tools + SearchExtraTools + ExecuteExtraTool,保持稳定。
|
||||
|
||||
```
|
||||
API Tools 数组(会话期间不变):
|
||||
[Core Tools (29)] + [SearchExtraTools, ExecuteExtraTool, SyntheticOutput]
|
||||
|
||||
不包含: 任何 deferred tool(即使已被发现)
|
||||
执行方式: 通过 ExecuteExtraTool 代理调用
|
||||
```
|
||||
|
||||
## 4. 预取机制(Prefetch)
|
||||
|
||||
### 4.1 两个触发时机
|
||||
|
||||
1. **Turn-zero**(`getTurnZeroSearchExtraToolsPrefetch`)— 用户输入第一轮时,基于输入文本搜索相关 deferred tools,以 attachment 形式注入
|
||||
2. **Inter-turn**(`startSearchExtraToolsPrefetch`)— assistant turn 结束后,基于对话上下文异步搜索
|
||||
|
||||
### 4.2 Attachment 管道
|
||||
|
||||
```
|
||||
prefetch → Attachment(type: 'tool_discovery')
|
||||
→ messages.ts 转换为 system-reminder
|
||||
→ "The following tools were discovered... Use ExecuteExtraTool to invoke..."
|
||||
```
|
||||
|
||||
### 4.3 会话去重
|
||||
|
||||
`discoveredToolsThisSession` Set 跟踪已发现的工具,避免重复推荐。该 Set 独立于 skill prefetch 的去重集合,互不影响。使用 `addBoundedSessionEntry()` 保持上限 500 条,超出时裁剪到 400 条。
|
||||
|
||||
## 5. 模式切换系统
|
||||
|
||||
通过环境变量 `ENABLE_SEARCH_EXTRA_TOOLS` 控制:
|
||||
|
||||
| 环境变量值 | 模式 | 行为 |
|
||||
|-----------|------|------|
|
||||
| 未设置 | `tst` | 默认启用,始终延迟非核心工具 |
|
||||
| `true` | `tst` | 强制启用 |
|
||||
| `false` | `standard` | 完全禁用,所有工具内联加载 |
|
||||
| `auto` | `tst-auto` | 仅当 deferred tools 超过上下文窗口 10% 时启用 |
|
||||
| `auto:N` | `tst-auto` | 自定义阈值百分比(N=0 启用,N=100 禁用) |
|
||||
| `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1` | `standard` | 全局 kill switch |
|
||||
|
||||
`isSearchExtraToolsEnabledOptimistic()` — 快速判断(不检查阈值),用于工具注册
|
||||
`isSearchExtraToolsEnabled()` — 完整判断(含阈值检查),用于 API 调用
|
||||
|
||||
## 6. Deferred Tools Delta 机制
|
||||
|
||||
对于 Anthropic 内部用户(`USER_TYPE=ant`)或启用了 `tengu_glacier_2xr` feature flag 的用户,使用 **delta attachment** 替代 `<available-deferred-tools>` 头部注入:
|
||||
|
||||
- 首次:注入完整的 deferred tools 列表
|
||||
- 后续:只注入增量变化(新增/移除)
|
||||
- 优势:不会因为工具池变化导致整个头部缓存失效
|
||||
|
||||
Delta attachment 扫描历史消息中的 `deferred_tools_delta` 类型 attachment,重建已宣告集合,然后差分计算当前 deferred pool 的变化。
|
||||
|
||||
## 7. 演进历史
|
||||
|
||||
### v1: 基础设施层(`7be08f53`)
|
||||
|
||||
**34 个文件,+4040/-90 行**
|
||||
|
||||
- 定义 `CORE_TOOLS` 白名单(31 个核心工具)
|
||||
- 实现 TF-IDF 工具索引模块 `toolIndex.ts`
|
||||
- 创建 `ExecuteTool` 作为统一执行入口
|
||||
- 增强 ToolSearchTool:TF-IDF 搜索路径、discover 模式、并行搜索合并
|
||||
- 新增 27 个单元测试
|
||||
- 实现预取管道和 UI 组件
|
||||
|
||||
**关键文件**:
|
||||
- `src/services/toolSearch/toolIndex.ts` → 后续重命名为 `searchExtraTools/toolIndex.ts`
|
||||
- `packages/builtin-tools/src/tools/ExecuteTool/` — 执行入口
|
||||
- `src/constants/tools.ts` — CORE_TOOLS 定义
|
||||
|
||||
### v2: 统一自建搜索(`8c157f07`)
|
||||
|
||||
**17 个文件,+274/-395 行**(净减少 121 行)
|
||||
|
||||
- **移除 `tool_reference` blocks** — 不再依赖 Anthropic API 的 `tool_reference` 功能
|
||||
- **移除 `defer_loading` 字段** — 不再发送 API 级别的工具延迟加载标记
|
||||
- **移除 `modelSupportsToolReference()`** — 不再区分模型是否支持 tool_reference
|
||||
- **重命名 ExecuteTool → ExecuteExtraTool** — 更清晰地表达其作为代理执行器的角色
|
||||
- **输出改为纯文本** — 所有 provider 通用,无需特殊 API 功能支持
|
||||
- **简化 system prompt** — 工具使用指南从 ~120 行压缩到 ~10 行
|
||||
|
||||
**设计决策**:这次重构的核心洞察是 — 依赖 Anthropic 私有 API 特性(tool_reference、defer_loading、beta header)使得系统只能用于 first-party provider。自建 TF-IDF + keyword 搜索完全能满足需求,且对所有 provider(OpenAI、Gemini、Grok)通用。
|
||||
|
||||
### v3: Cache 稳定性修复(`c14b7ead`)
|
||||
|
||||
**7 个文件,+46/-31 行**
|
||||
|
||||
- **移除 "discover then include" 逻辑** — 发现的 deferred tools 不再注入 tools 数组
|
||||
- **tools 数组保持稳定** — 只有 core tools + SearchExtraTools + ExecuteExtraTool
|
||||
- **强化优先级引导** — core tools 直接调用,ToolSearch 仅作为发现 deferred tools 的手段
|
||||
- **已加载工具拒绝提示** — 搜索 core tool 时返回明确拒绝
|
||||
|
||||
**设计决策**:prompt cache 是 Claude Code 性能优化的关键。每次 tools JSON 变化都会导致缓存失效,代价远大于通过 ExecuteExtraTool 代理调用 deferred tools 的额外 token。因此选择牺牲一点直接调用的便利性,换取 cache 稳定性。
|
||||
|
||||
### v4: Agents/Teams 延迟化(`af0d7dc8`)
|
||||
|
||||
**7 个文件,+36/-18 行**
|
||||
|
||||
- 将 `TeamCreate`、`TeamDelete`、`SendMessage` 从 CORE_TOOLS 移除
|
||||
- 这些工具仅在 swarm 模式下常用,平时占用 context token
|
||||
- swarm 模式下 SendMessage 保持 always loaded
|
||||
- TeamCreate/TeamDelete 在 swarm 未启用时返回启用提示
|
||||
|
||||
**设计决策**:不是所有用户都需要团队功能。将其延迟化后,大部分用户可以节省约 3 个工具定义的 token 开销。
|
||||
|
||||
## 8. 文件索引
|
||||
|
||||
### 核心文件
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `src/constants/tools.ts` | CORE_TOOLS 白名单、工具权限集合 |
|
||||
| `src/utils/searchExtraTools.ts` | 模式判定、阈值计算、delta 差分、discovered tools 提取 |
|
||||
| `src/services/searchExtraTools/toolIndex.ts` | TF-IDF 索引构建和搜索 |
|
||||
| `src/services/searchExtraTools/prefetch.ts` | 预取管道(turn-zero + inter-turn) |
|
||||
| `packages/builtin-tools/src/tools/SearchExtraToolsTool/` | 搜索工具实现(4 种查询模式) |
|
||||
| `packages/builtin-tools/src/tools/ExecuteTool/` | 代理执行器实现 |
|
||||
| `src/services/api/claude.ts` | API 层集成(工具过滤、消息归一化) |
|
||||
| `src/query.ts` | 查询循环集成(预取触发点) |
|
||||
| `src/utils/messages.ts` | Attachment → system-reminder 转换 |
|
||||
|
||||
### 共享基础设施
|
||||
|
||||
| 文件 | 被复用的导出 |
|
||||
|------|-------------|
|
||||
| `src/services/skillSearch/localSearch.ts` | `tokenizeAndStem`, `computeWeightedTf`, `computeIdf`, `cosineSimilarity` |
|
||||
| `src/services/skillSearch/prefetch.ts` | `extractQueryFromMessages` |
|
||||
|
||||
### 测试文件
|
||||
|
||||
| 文件 | 覆盖范围 |
|
||||
|------|---------|
|
||||
| `src/services/searchExtraTools/__tests__/toolIndex.test.ts` | 索引构建、TF-IDF 搜索、CJK 处理 |
|
||||
| `src/services/searchExtraTools/__tests__/prefetch.test.ts` | 预取管道、去重、attachment 生成 |
|
||||
| `packages/builtin-tools/src/tools/SearchExtraToolsTool/__tests__/` | 搜索工具 4 种模式 |
|
||||
| `packages/builtin-tools/src/tools/ExecuteTool/__tests__/` | 代理执行 |
|
||||
|
||||
## 9. 维护指南
|
||||
|
||||
### 9.1 新增工具的延迟化决策
|
||||
|
||||
将新工具加入 deferred 状态的标准:
|
||||
- 工具仅在特定场景使用(如 swarm 模式、特定 MCP 集成)
|
||||
- 工具的 schema 较大(占用较多 context token)
|
||||
- 工具不是模型默认会尝试的核心操作
|
||||
|
||||
将已延迟的工具提升为 core tool:
|
||||
- 在 `src/constants/tools.ts` 的 `CORE_TOOLS` Set 中添加工具名常量
|
||||
- 确保导入对应的 `*_TOOL_NAME` 常量
|
||||
|
||||
### 9.2 修改注意事项
|
||||
|
||||
1. **修改 `localSearch.ts` 的 TF-IDF 函数**:需同步检查 `toolIndex.test.ts` 和 `localSearch.test.ts`
|
||||
2. **修改 `skillSearch/prefetch.ts` 的 `extractQueryFromMessages`**:需同步检查工具预取行为(`searchExtraTools/prefetch.ts` 调用同一函数)
|
||||
3. **修改 CORE_TOOLS**:需更新 `src/constants/__tests__/tools.test.ts` 测试
|
||||
4. **修改 `isDeferredTool`**:需更新 `src/constants/__tests__/tools.test.ts` 和 `SearchExtraToolsTool.test.ts`
|
||||
|
||||
### 9.3 性能优化配置
|
||||
|
||||
```bash
|
||||
# 环境变量调优
|
||||
ENABLE_SEARCH_EXTRA_TOOLS=auto:15 # 当 deferred tools 超过上下文 15% 时启用
|
||||
SEARCH_EXTRA_TOOLS_WEIGHT_KEYWORD=0.5 # 关键词搜索权重
|
||||
SEARCH_EXTRA_TOOLS_WEIGHT_TFIDF=0.5 # TF-IDF 搜索权重
|
||||
SEARCH_EXTRA_TOOLS_DISPLAY_MIN_SCORE=0.10 # 最低显示分数阈值
|
||||
```
|
||||
|
||||
### 9.4 搜索质量调优
|
||||
|
||||
- `TOOL_FIELD_WEIGHT`(`toolIndex.ts`):控制 name/searchHint/description 对 TF-IDF 分数的贡献权重
|
||||
- `KEYWORD_WEIGHT` / `TFIDF_WEIGHT`(`SearchExtraToolsTool.ts`):控制混合搜索中两种算法的最终权重比例
|
||||
- `searchHint` 属性:为工具添加精心编写的搜索提示,提高关键词匹配质量
|
||||
|
||||
## 10. 与 Skill Search 的关系
|
||||
|
||||
ToolSearch 和 SkillSearch 是平行的搜索系统,共享底层算法但服务于不同领域:
|
||||
|
||||
| 维度 | ToolSearch | SkillSearch |
|
||||
|------|-----------|-------------|
|
||||
| 搜索对象 | Deferred 工具(内置 + MCP) | 用户技能(skill) |
|
||||
| 执行方式 | `ExecuteExtraTool` 代理调用 | 直接注入 attachment 内容 |
|
||||
| 字段权重 | name:3.0, searchHint:2.5, desc:1.0 | name:3.0, whenToUse:2.0, desc:1.0 |
|
||||
| 缓存策略 | 按工具名列表缓存 | 按 cwd 缓存 |
|
||||
| 去重集合 | `discoveredToolsThisSession` | 独立的 Set |
|
||||
|
||||
共享的底层函数:
|
||||
- `tokenizeAndStem` — 统一的 CJK/ASCII 分词和词干提取
|
||||
- `computeWeightedTf` — 加权词频计算
|
||||
- `computeIdf` — 逆文档频率计算
|
||||
- `cosineSimilarity` — 向量余弦相似度
|
||||
- `extractQueryFromMessages` — 从对话历史中提取搜索查询文本
|
||||
@@ -1,107 +1,211 @@
|
||||
---
|
||||
title: "自定义 Agent"
|
||||
description: "用 Markdown 文件定义自己的 Agent。理解 Agent 定义的数据模型、工具过滤策略和与 AgentTool 的联动。"
|
||||
keywords: ["自定义 Agent", "Agent 定义", "Markdown Agent", "角色定制"]
|
||||
title: "自定义 Agent - 从 Markdown 到运行时的完整链路"
|
||||
description: "揭秘 Claude Code 自定义 Agent 完整链路:Agent 定义的 Markdown 数据模型、三种加载来源、工具过滤策略和与 AgentTool 的联动机制。"
|
||||
keywords: ["自定义 Agent", "Agent 定义", "Markdown Agent", "Agent 配置", "角色定制"]
|
||||
---
|
||||
|
||||
## 核心概念
|
||||
{/* 本章目标:揭示 Agent 定义的完整数据模型、加载发现机制、工具过滤和与 AgentTool 的联动 */}
|
||||
|
||||
自定义 Agent 是一个 Markdown 文件——frontmatter 定义配置,正文是 system prompt。不需要写代码。
|
||||
## Agent 定义的三种来源
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: "reviewer"
|
||||
description: "Code review specialist"
|
||||
tools: "Read,Glob,Grep"
|
||||
model: "haiku"
|
||||
---
|
||||
|
||||
你是代码审查专家。你的职责是...
|
||||
```
|
||||
|
||||
这个 Markdown 文件就定义了一个完整的 Agent:它使用什么工具、什么模型、什么行为规则。
|
||||
|
||||
## Agent 的三种来源
|
||||
Claude Code 的 Agent 不仅仅来自用户自定义——系统有三类来源,按优先级合并:
|
||||
|
||||
| 来源 | 位置 | 优先级 |
|
||||
|------|------|--------|
|
||||
| **Built-in** | 硬编码 | 最低(可被覆盖) |
|
||||
| **Plugin** | 插件系统注册 | 中 |
|
||||
| **User/Project** | `.claude/agents/*.md` | 最高 |
|
||||
| **Built-in** | `packages/builtin-tools/src/tools/AgentTool/built-in/` 硬编码 | 最低(可被覆盖) |
|
||||
| **Plugin** | 通过插件系统注册 | 中 |
|
||||
| **User/Project/Policy** | `.claude/agents/*.md` 或 settings.json | 最高 |
|
||||
|
||||
合并时按 `agentType` 去重,后者覆盖前者。这意味着你可以在 `.claude/agents/` 中放一个 `Explore.md` 来完全替换内置的 Explore Agent。
|
||||
合并逻辑在 `getActiveAgentsFromList()` 中:按 `agentType` 去重,后者覆盖前者。这意味着你可以在 `.claude/agents/` 中放一个 `Explore.md` 来完全替换内置的 Explore Agent。
|
||||
|
||||
## 配置字段
|
||||
## Markdown Agent 文件的完整格式
|
||||
|
||||
### 工具控制
|
||||
```markdown
|
||||
---
|
||||
# === 必需字段 ===
|
||||
name: "reviewer" # Agent 标识(agentType)
|
||||
description: "Code review specialist, read-only analysis"
|
||||
|
||||
- `tools` — 允许的工具白名单(未指定 = 全部工具)
|
||||
- `disallowedTools` — 显式禁止的工具(即使 `tools` 未指定也生效)
|
||||
# === 工具控制 ===
|
||||
tools: "Read,Glob,Grep,Bash" # 允许的工具列表(逗号分隔)
|
||||
disallowedTools: "Write,Edit" # 显式禁止的工具
|
||||
|
||||
**设计考量**:`disallowedTools` 是比 `tools` 更安全的控制方式。如果只指定 `tools` 白名单,新增工具时需要更新白名单。`disallowedTools` 是黑名单思维——默认允许,只禁止危险的。
|
||||
# === 模型配置 ===
|
||||
model: "haiku" # 指定模型(或 "inherit" 继承主线程)
|
||||
effort: "high" # 推理努力程度:low/medium/high 或整数
|
||||
|
||||
### 模型配置
|
||||
# === 行为控制 ===
|
||||
maxTurns: 10 # 最大 agentic 轮次
|
||||
permissionMode: "plan" # 权限模式:plan/bypassPermissions 等
|
||||
background: true # 始终作为后台任务运行
|
||||
initialPrompt: "/search TODO" # 首轮用户消息前缀(支持斜杠命令)
|
||||
|
||||
- `model` — 指定模型(`haiku`/`sonnet`/`opus`/`inherit`)
|
||||
- `effort` — 推理努力程度(`low`/`medium`/`high`)
|
||||
# === 隔离与持久化 ===
|
||||
isolation: "worktree" # 在独立 git worktree 中运行
|
||||
memory: "project" # 持久记忆范围:user/project/local
|
||||
|
||||
`inherit` 使用主线程的模型——适合需要完整推理能力的任务。`haiku` 适合轻量任务(如搜索),更便宜更快。
|
||||
# === MCP 服务器 ===
|
||||
mcpServers:
|
||||
- "slack" # 引用已配置的 MCP 服务器
|
||||
- database: # 内联定义
|
||||
command: "npx"
|
||||
args: ["mcp-db"]
|
||||
|
||||
### 行为控制
|
||||
# === Hooks ===
|
||||
hooks:
|
||||
PreToolUse:
|
||||
- command: "audit-log.sh"
|
||||
timeout: 5000
|
||||
|
||||
- `maxTurns` — 最大 agentic 轮次(防止无限循环)
|
||||
- `permissionMode` — 权限模式(`plan`/`bypassPermissions` 等)
|
||||
- `background` — 始终作为后台任务运行
|
||||
- `isolation` — 在独立 worktree 中运行
|
||||
# === Skills ===
|
||||
skills: "code-review,security-review" # 预加载的 skills(逗号分隔)
|
||||
|
||||
### 持久化
|
||||
# === 显示 ===
|
||||
color: "blue" # 终端中的 Agent 颜色标识
|
||||
---
|
||||
|
||||
- `memory` — 启用跨会话的持久记忆(`user`/`project`/`local`)
|
||||
- `mcpServers` — 引用或内联定义 MCP 服务器
|
||||
- `hooks` — Agent 专属的 Hook 配置
|
||||
- `skills` — 预加载的技能
|
||||
你是代码审查专家。你的职责是...
|
||||
|
||||
(正文内容 = system prompt)
|
||||
```
|
||||
|
||||
### 字段解析细节
|
||||
|
||||
- **`tools`**:通过 `parseAgentToolsFromFrontmatter()` 解析,支持逗号分隔字符串或数组
|
||||
- **`model: "inherit"`**:使用主线程的模型(区分大小写,只有小写 "inherit" 有效)
|
||||
- **`memory`**:启用后自动注入 `Write`/`Edit`/`Read` 工具(即使 `tools` 未包含),并在 system prompt 末尾追加 memory 指令
|
||||
- **`isolation: "remote"`**:仅在 Anthropic 内部可用(`USER_TYPE === 'ant'`),外部构建只支持 `worktree`
|
||||
- **`background`**:`true` 使 Agent 始终在后台运行,主线程不等待结果
|
||||
|
||||
## 加载与发现机制
|
||||
|
||||
`getAgentDefinitionsWithOverrides()`(被 `memoize` 缓存)执行完整的发现流程:
|
||||
|
||||
```
|
||||
1. 加载 Markdown 文件
|
||||
├── loadMarkdownFilesForSubdir('agents', cwd)
|
||||
│ ├── ~/.claude/agents/*.md (用户级,source = 'userSettings')
|
||||
│ ├── .claude/agents/*.md (项目级,source = 'projectSettings')
|
||||
│ └── managed/policy sources (策略级,source = 'policySettings')
|
||||
│
|
||||
└── 每个 .md 文件:
|
||||
├── 解析 YAML frontmatter
|
||||
├── 正文作为 system prompt
|
||||
├── 校验必需字段(name, description)
|
||||
├── 静默跳过无 frontmatter 的 .md 文件(可能是参考文档)
|
||||
└── 解析失败 → 记录到 failedFiles,不阻塞其他 Agent
|
||||
|
||||
2. 并行加载 Plugin Agents
|
||||
└── loadPluginAgents() → memoized
|
||||
|
||||
3. 初始化 Memory Snapshots(如果 AGENT_MEMORY_SNAPSHOT 启用)
|
||||
└── initializeAgentMemorySnapshots()
|
||||
|
||||
4. 合并 Built-in + Plugin + Custom
|
||||
└── getActiveAgentsFromList() → 按 agentType 去重,后者覆盖前者
|
||||
|
||||
5. 分配颜色
|
||||
└── setAgentColor(agentType, color) → 终端 UI 中区分不同 Agent
|
||||
```
|
||||
|
||||
## 工具过滤的实现
|
||||
|
||||
当 Agent 被派生时,`AgentTool` 根据定义中的 `tools` / `disallowedTools` 过滤可用工具列表:
|
||||
|
||||
```
|
||||
全部工具
|
||||
↓ disallowedTools 移除
|
||||
↓ tools 白名单过滤(如果指定)
|
||||
可用工具
|
||||
```
|
||||
|
||||
- **`tools` 未指定**:Agent 可以使用所有工具(默认全能)
|
||||
- **`tools` 指定**:只能使用列出的工具
|
||||
- **`disallowedTools`**:即使 `tools` 未指定,这些工具也被禁止
|
||||
- **自动注入**:`memory` 启用时自动添加 `Write`/`Edit`/`Read`
|
||||
|
||||
以内置 Explore Agent 为例:
|
||||
|
||||
```typescript
|
||||
// packages/builtin-tools/src/tools/AgentTool/built-in/exploreAgent.ts
|
||||
disallowedTools: [
|
||||
'Agent', // 不能嵌套调用 Agent
|
||||
'ExitPlanMode', // 不需要 plan mode
|
||||
'FileEdit', // 只读
|
||||
'FileWrite', // 只读
|
||||
'NotebookEdit', // 只读
|
||||
]
|
||||
```
|
||||
|
||||
## System Prompt 的注入方式
|
||||
|
||||
Agent 的 Markdown 正文**完全替换**默认的 system prompt,而非追加。这意味着自定义 Agent 的行为完全由你定义——不受默认 prompt 的约束。
|
||||
Agent 的 system prompt 通过 `getSystemPrompt()` 闭包延迟生成:
|
||||
|
||||
如果启用了 `memory`,记忆指令自动追加到 system prompt 末尾。
|
||||
|
||||
## 工具过滤流程
|
||||
|
||||
```
|
||||
全部工具 → disallowedTools 移除 → tools 白名单过滤 → 可用工具
|
||||
```typescript
|
||||
// Markdown Agent
|
||||
getSystemPrompt: () => {
|
||||
if (isAutoMemoryEnabled() && memory) {
|
||||
return systemPrompt + '\n\n' + loadAgentMemoryPrompt(agentType, memory)
|
||||
}
|
||||
return systemPrompt
|
||||
}
|
||||
```
|
||||
|
||||
内置 Explore Agent 的工具限制是很好的例子:
|
||||
- 禁止 `Agent`(不能嵌套调用子 Agent)
|
||||
- 禁止 `FileEdit`/`FileWrite`(只读)
|
||||
- 禁止 `ExitPlanMode`(不需要 plan mode)
|
||||
这意味着:
|
||||
1. **Markdown 正文 = 完整的 system prompt**——不是追加,而是替换默认 prompt
|
||||
2. **Memory 指令**在 memory 启用时自动追加到末尾
|
||||
3. **闭包延迟计算**——memory 状态可能在文件加载后才变化
|
||||
|
||||
这些限制让 Explore Agent 成为一个纯粹的搜索工具——它只能看,不能改。
|
||||
对于 Built-in Agent,`getSystemPrompt` 接受 `toolUseContext` 参数,可以根据运行时状态(如是否使用嵌入式搜索工具)动态调整 prompt 内容。
|
||||
|
||||
## 与 AgentTool 的联动
|
||||
|
||||
当主 Agent 需要派生子 Agent 时:
|
||||
|
||||
```
|
||||
查找 Agent 定义 → 检查 MCP 依赖 → 过滤工具 → 解析模型 → 构建隔离环境 → 注入 system prompt → 启动子 Agent
|
||||
AgentTool.call({ subagent_type: "reviewer", ... })
|
||||
↓
|
||||
1. 从 agentDefinitions.activeAgents 查找 agentType === "reviewer"
|
||||
↓
|
||||
2. 检查 requiredMcpServers(如果 Agent 要求特定 MCP 服务器)
|
||||
↓
|
||||
3. 过滤工具列表(tools / disallowedTools)
|
||||
↓
|
||||
4. 解析模型:
|
||||
- "inherit" → 使用主线程模型
|
||||
- 具体模型名 → 直接使用
|
||||
- 未指定 → 主线程模型
|
||||
↓
|
||||
5. 解析权限模式(permissionMode)
|
||||
↓
|
||||
6. 构建隔离环境(如果 isolation === "worktree")
|
||||
↓
|
||||
7. 注入 system prompt(getSystemPrompt())
|
||||
↓
|
||||
8. 注入 initialPrompt(如果定义了)
|
||||
↓
|
||||
9. 启动子 Agent 循环(forkSubagent / runAgent)
|
||||
```
|
||||
|
||||
每一步都使用 Agent 定义中的配置——工具列表、模型选择、权限模式、隔离环境等。
|
||||
|
||||
## 内置 Agent 参考
|
||||
|
||||
| Agent | 角色 | 工具限制 | 模型 |
|
||||
|-------|------|---------|------|
|
||||
| General Purpose | 通用任务 | 全部工具 | 继承主线程 |
|
||||
| Explore | 代码搜索 | 只读 | haiku |
|
||||
| Plan | 规划研究 | 只读 + ExitPlanMode | 继承主线程 |
|
||||
| Verification | 结果验证 | 由 feature flag 控制 | — |
|
||||
| Code Guide | 使用指南 | 只读 | — |
|
||||
| Agent | agentType | 角色 | 工具限制 | 模型 |
|
||||
|-------|-----------|------|---------|------|
|
||||
| **General Purpose** | `general-purpose` | 默认子 Agent | 全部工具 | 主线程模型 |
|
||||
| **Explore** | `Explore` | 代码搜索专家 | 只读(无 Write/Edit) | haiku(外部) |
|
||||
| **Plan** | `Plan` | 规划专家 | 只读 + ExitPlanMode | inherit |
|
||||
| **Verification** | `verification` | 结果验证 | 由 feature flag 控制 | — |
|
||||
| **Code Guide** | `claude-code-guide` | Claude Code 使用指南 | 只读 | — |
|
||||
| **Statusline Setup** | `statusline-setup` | 终端状态栏配置 | 有限 | — |
|
||||
|
||||
## 接下来
|
||||
SDK 入口(`sdk-ts`/`sdk-py`/`sdk-cli`)不加载 Code Guide Agent。环境变量 `CLAUDE_AGENT_SDK_DISABLE_BUILTIN_AGENTS` 可以完全禁用内置 Agent,给 SDK 用户提供空白画布。
|
||||
|
||||
- **子 Agent** — 理解子 Agent 的完整执行链路
|
||||
- **Skills** — 理解 Skill 中指定 Agent 定义
|
||||
- **MCP 配置** — 理解 Agent 中引用 MCP 服务器
|
||||
## Agent Memory:持久化的 Agent 状态
|
||||
|
||||
当 `memory` 字段启用时,Agent 获得跨会话的持久记忆:
|
||||
|
||||
- **`local`**:当前项目、当前用户有效
|
||||
- **`project`**:当前项目所有用户共享
|
||||
- **`user`**:所有项目共享
|
||||
|
||||
Memory 通过 `loadAgentMemoryPrompt()` 注入到 system prompt 末尾,包含读写记忆的指令。Agent Memory Snapshot 机制在项目间同步 `user` 级记忆。
|
||||
|
||||
@@ -1,148 +1,253 @@
|
||||
---
|
||||
title: "Hooks"
|
||||
description: "Hooks 是 Claude Code 的扩展机制——在工具调用前后注入自定义逻辑。理解四种 Hook 能力、匹配机制和安全防护。"
|
||||
title: "Hooks 生命周期钩子 - 执行引擎与拦截协议"
|
||||
description: "从源码角度解析 Claude Code Hooks 系统:27 种 Hook 事件、6 种 Hook 类型、同步/异步执行协议、JSON 输出 schema、if 条件匹配、以及 Hook 如何注入上下文和拦截工具调用。"
|
||||
keywords: ["Hooks", "生命周期钩子", "拦截器", "PreToolUse", "Hook 协议"]
|
||||
---
|
||||
|
||||
## 核心问题
|
||||
{/* 本章目标:从源码角度揭示 Hook 的执行引擎、匹配机制、返回值协议和生命周期管理 */}
|
||||
|
||||
Claude Code 提供了强大的内置功能,但每个团队和工作流都不同。Hooks 让你可以在关键节点注入自定义逻辑——不需要修改 Claude Code 本身。
|
||||
## 27 种 Hook 事件
|
||||
|
||||
## Hook 事件
|
||||
Claude Code 定义了 27 种 Hook 事件(`HOOK_EVENTS` 数组,`src/entrypoints/sdk/coreTypes.ts`),覆盖完整的 Agent 生命周期:
|
||||
|
||||
Hooks 覆盖 Agent 生命周期的所有关键节点:
|
||||
| 阶段 | 事件 | 触发时机 | 匹配字段 |
|
||||
|------|------|---------|---------|
|
||||
| **会话** | `SessionStart` | 会话启动 | `source` |
|
||||
| | `SessionEnd` | 会话结束 | `reason` |
|
||||
| | `Setup` | 初始化完成 | `trigger` |
|
||||
| **用户交互** | `UserPromptSubmit` | 用户提交消息 | — |
|
||||
| | `Stop` | Agent 停止响应 | — |
|
||||
| | `StopFailure` | Agent 停止失败 | `error` |
|
||||
| **工具执行** | `PreToolUse` | 工具调用前 | `tool_name` |
|
||||
| | `PostToolUse` | 工具调用后(成功) | `tool_name` |
|
||||
| | `PostToolUseFailure` | 工具调用后(失败) | `tool_name` |
|
||||
| **权限** | `PermissionRequest` | 权限请求 | `tool_name` |
|
||||
| | `PermissionDenied` | 权限被拒 | `tool_name` |
|
||||
| **子 Agent** | `SubagentStart` | 子 Agent 启动 | `agent_type` |
|
||||
| | `SubagentStop` | 子 Agent 停止 | `agent_type` |
|
||||
| **压缩** | `PreCompact` | 上下文压缩前 | `trigger` |
|
||||
| | `PostCompact` | 上下文压缩后 | `trigger` |
|
||||
| **协作** | `TeammateIdle` | Teammate 空闲 | — |
|
||||
| | `TaskCreated` | 任务创建 | — |
|
||||
| | `TaskCompleted` | 任务完成 | — |
|
||||
| **MCP** | `Elicitation` | MCP 服务器请求用户输入 | `mcp_server_name` |
|
||||
| | `ElicitationResult` | Elicitation 结果返回 | `mcp_server_name` |
|
||||
| **通知** | `Notification` | 系统通知事件 | `notification_type` |
|
||||
| **环境** | `ConfigChange` | 配置变更 | `source` |
|
||||
| | `CwdChanged` | 工作目录变更 | — |
|
||||
| | `FileChanged` | 文件变更 | `file_path` |
|
||||
| | `InstructionsLoaded` | 指令加载 | `load_reason` |
|
||||
| | `WorktreeCreate` / `WorktreeRemove` | Worktree 操作 | — |
|
||||
|
||||
| 阶段 | 典型事件 |
|
||||
|------|---------|
|
||||
| **会话** | 启动、结束、初始化 |
|
||||
| **用户交互** | 提交消息、停止响应 |
|
||||
| **工具执行** | 工具调用前、工具调用后(成功/失败) |
|
||||
| **权限** | 权限请求、权限被拒 |
|
||||
| **子 Agent** | 启动、停止 |
|
||||
| **压缩** | 压缩前、压缩后 |
|
||||
| **协作** | Teammate 空闲、任务创建/完成 |
|
||||
## 6 种 Hook 类型
|
||||
|
||||
## 四种 Hook 能力
|
||||
Hooks 配置支持 6 种执行方式,类型定义分布在 3 个文件中:
|
||||
|
||||
Hook 不仅是"执行一个脚本"——它有四种不同的能力:
|
||||
- **可持久化类型**(`command`、`prompt`、`agent`、`http`)— Zod schema 定义在 `src/schemas/hooks.ts`,通过 `z.discriminatedUnion('type', [...])` 声明
|
||||
- **callback 类型** — TypeScript 接口定义在 `src/types/hooks.ts`,用于 SDK 注册的内部 JS 函数
|
||||
- **function 类型** — 定义在 `src/utils/hooks/sessionHooks.ts`,用于运行时动态注册的函数 Hook
|
||||
|
||||
### 1. 拦截操作(PreToolUse)
|
||||
| 类型 | 执行方式 | 适用场景 |
|
||||
|------|---------|---------|
|
||||
| `command` | Shell 命令(bash/PowerShell) | 通用脚本、CI 检查 |
|
||||
| `prompt` | 注入到 AI 上下文 | 代码规范提醒 |
|
||||
| `agent` | 启动子 Agent 执行 | 复杂分析任务 |
|
||||
| `http` | HTTP 请求 | 远程服务、Webhook |
|
||||
| `callback` | 内部 JS 函数 | 系统内置 Hook |
|
||||
| `function` | 运行时注册的函数 Hook | Agent/Skill 内部使用 |
|
||||
|
||||
在工具执行前拦截,可以阻止危险操作:
|
||||
## 执行引擎:execCommandHook
|
||||
|
||||
`execCommandHook()`(`src/utils/hooks.ts`,`execCommandHook` 函数)是命令型 Hook 的执行核心:
|
||||
|
||||
```
|
||||
execCommandHook(hook, hookEvent, hookName, jsonInput, signal)
|
||||
├── Shell 选择: hook.shell ?? DEFAULT_HOOK_SHELL
|
||||
│ ├── bash: spawn(cmd, [], { shell: gitBashPath | true })
|
||||
│ └── powershell: spawn(pwsh, ['-NoProfile', '-NonInteractive', '-Command', cmd])
|
||||
├── 变量替换
|
||||
│ ├── ${CLAUDE_PLUGIN_ROOT} → pluginRoot 路径
|
||||
│ ├── ${CLAUDE_PLUGIN_DATA} → plugin 数据目录
|
||||
│ └── ${user_config.X} → 用户配置值
|
||||
├── 环境变量注入
|
||||
│ ├── CLAUDE_PROJECT_DIR
|
||||
│ ├── CLAUDE_ENV_FILE(SessionStart/Setup/CwdChanged/FileChanged)
|
||||
│ └── CLAUDE_PLUGIN_OPTION_*(plugin options)
|
||||
├── stdin 写入: jsonInput + '\n'
|
||||
├── 超时: hook.timeout * 1000 ?? 600000ms(10分钟)
|
||||
└── 异步检测: 检查 stdout 首行是否为 {"async":true}
|
||||
```
|
||||
|
||||
### 异步 Hook 的检测协议
|
||||
|
||||
Hook 进程的 stdout 第一行如果是 `{"async":true}`,系统将其转为后台任务(`isAsyncHookJSONOutput` 检测 + `executeInBackground` 调用):
|
||||
|
||||
```typescript
|
||||
const firstLine = firstLineOf(stdout).trim()
|
||||
if (isAsyncHookJSONOutput(parsed)) {
|
||||
executeInBackground({
|
||||
processId: `async_hook_${child.pid}`,
|
||||
asyncResponse: parsed,
|
||||
...
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
后台 Hook 通过 `registerPendingAsyncHook()` 注册到 `AsyncHookRegistry`,完成后通过 `enqueuePendingNotification()` 通知主线程。
|
||||
|
||||
### asyncRewake:Hook 唤醒模型
|
||||
|
||||
`asyncRewake` 模式的 Hook 绕过 `AsyncHookRegistry`。当 Hook 退出码为 2 时,通过 `enqueuePendingNotification()` 以 `task-notification` 模式注入消息,唤醒空闲的模型(通过 `useQueueProcessor`)或在忙碌时注入 `queued_command` 附件。
|
||||
|
||||
## Hook 输出的 JSON Schema
|
||||
|
||||
同步 Hook 的输出遵循严格的 Zod schema(`syncHookResponseSchema`,定义在 `src/types/hooks.ts`,`hookJSONOutputSchema` 定义在 `src/schemas/hooks.ts`):
|
||||
|
||||
```json
|
||||
{
|
||||
"continue": false, // 是否继续执行
|
||||
"suppressOutput": true, // 隐藏 stdout
|
||||
"stopReason": "安全检查失败", // continue=false 时的原因
|
||||
"decision": "approve" | "block", // 全局决策
|
||||
"reason": "原因说明", // 决策原因
|
||||
"systemMessage": "警告内容", // 注入到上下文的系统消息
|
||||
"hookSpecificOutput": {
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": "不允许在生产分支上强制推送"
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "allow" | "deny" | "ask",
|
||||
"permissionDecisionReason": "匹配了安全规则",
|
||||
"updatedInput": { ... }, // 修改后的工具输入
|
||||
"additionalContext": "额外上下文" // 注入到对话
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 修改行为
|
||||
### 各事件的 hookSpecificOutput
|
||||
|
||||
修改工具的输入或输出:
|
||||
| 事件 | 专有字段 | 作用 |
|
||||
|------|---------|------|
|
||||
| `PreToolUse` | `permissionDecision`, `permissionDecisionReason`, `updatedInput`, `additionalContext` | 拦截/修改工具输入 |
|
||||
| `PostToolUse` | `additionalContext`, `updatedMCPToolOutput` | 修改 MCP 工具输出 |
|
||||
| `PostToolUseFailure` | `additionalContext` | 失败后注入上下文 |
|
||||
| `UserPromptSubmit` | `additionalContext` | 注入额外上下文 |
|
||||
| `SessionStart` | `additionalContext`, `initialUserMessage`, `watchPaths` | 设置初始消息和文件监控 |
|
||||
| `PermissionRequest` | `decision`(含 `allow`/`deny` 子字段) | 权限请求的 Hook 决策 |
|
||||
| `PermissionDenied` | `retry` | 指示是否重试 |
|
||||
| `SubagentStart` | `additionalContext` | 子 Agent 启动时注入上下文 |
|
||||
| `Elicitation` | `action`, `content` | 控制用户输入对话框 |
|
||||
| `ElicitationResult` | `action`, `content` | Elicitation 结果处理 |
|
||||
| `Notification` | `additionalContext` | 通知事件注入上下文 |
|
||||
| `Setup` | `additionalContext` | 初始化时注入上下文 |
|
||||
| `CwdChanged` | `watchPaths` | 目录变更后更新监控路径 |
|
||||
| `FileChanged` | `watchPaths` | 文件变更后更新监控路径 |
|
||||
| `WorktreeCreate` | `worktreePath` | Worktree 创建通知 |
|
||||
|
||||
## Hook 匹配机制:getMatchingHooks
|
||||
|
||||
`getMatchingHooks()`(`src/utils/hooks.ts`,`getMatchingHooks` 函数)负责从所有来源中查找匹配的 Hook:
|
||||
|
||||
### 多来源合并
|
||||
|
||||
```
|
||||
getHooksConfig()
|
||||
├── getHooksConfigFromSnapshot() ← settings.json 中的 Hook(user/project/local)
|
||||
├── getRegisteredHooks() ← SDK 注册的 callback Hook
|
||||
├── getSessionHooks() ← Agent/Skill 前置注册的 session Hook
|
||||
└── getSessionFunctionHooks() ← 运行时 function Hook
|
||||
```
|
||||
|
||||
### 匹配规则
|
||||
|
||||
`matcher` 字段支持三种模式(`matchesPattern()` 函数,`src/utils/hooks.ts`):
|
||||
|
||||
```
|
||||
"Write" → 精确匹配
|
||||
"Write|Edit" → 管道分隔的多值匹配
|
||||
"^Bash(git.*)" → 正则匹配
|
||||
"*" 或 "" → 通配(匹配所有)
|
||||
```
|
||||
|
||||
### if 条件过滤
|
||||
|
||||
Hook 可以指定 `if` 条件,只在特定输入时触发。`prepareIfConditionMatcher()`(`src/utils/hooks.ts`,`prepareIfConditionMatcher` 函数)预编译匹配器:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": [{
|
||||
"command": "check-git-branch.sh",
|
||||
"if": "Bash(git push*)"
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
`if` 条件使用 `permissionRuleValueFromString` 解析,支持与权限规则相同的语法(工具名 + 参数模式)。Bash 工具还会使用 tree-sitter 进行 AST 级别的命令解析。
|
||||
|
||||
### Hook 去重
|
||||
|
||||
同一个 Hook 命令在不同配置层级(user/project/local)可能重复。系统按四部分复合键做 Map 去重:`${pluginRoot}\0${shell}\0${command}\0${ifCondition}`(由 `hookDedupKey()` 函数构建),保留**最后合并的层级**。
|
||||
|
||||
## 工作区信任检查
|
||||
|
||||
**所有 Hook 都要求工作区信任**(`shouldSkipHookDueToTrust()` 函数,`src/utils/hooks.ts`)。这是纵深防御措施——防止恶意仓库的 `.claude/settings.json` 在未信任的情况下执行任意命令。
|
||||
|
||||
```typescript
|
||||
// 交互模式下,所有 Hook 要求信任
|
||||
const hasTrust = checkHasTrustDialogAccepted()
|
||||
return !hasTrust
|
||||
```
|
||||
|
||||
SDK 非交互模式下信任是隐式的(`getIsNonInteractiveSession()` 为 true 时跳过检查)。
|
||||
|
||||
## 四种 Hook 能力的源码映射
|
||||
|
||||
### 1. 拦截操作(PreToolUse)
|
||||
|
||||
```json
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "deny"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`processHookJSONOutput()` 将 `permissionDecision` 映射为 `result.permissionBehavior = 'deny'`,并设置 `blockingError`,阻止工具执行。
|
||||
|
||||
### 2. 修改行为(updatedInput / updatedMCPToolOutput)
|
||||
|
||||
```json
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"updatedInput": { "command": "npm test -- --bail" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
PostToolUse 的 `updatedMCPToolOutput` 可以替换 MCP 工具的返回值——用于过滤敏感数据。
|
||||
`updatedInput` 替换原始工具输入;`updatedMCPToolOutput`(PostToolUse 事件)替换 MCP 工具的返回值——可用于过滤敏感数据。
|
||||
|
||||
### 3. 注入上下文
|
||||
### 3. 注入上下文(additionalContext / systemMessage)
|
||||
|
||||
向 AI 的对话中注入额外信息:
|
||||
- `additionalContext` — 注入为用户消息,AI 可以参考
|
||||
- `systemMessage` — 显示为系统警告
|
||||
- `additionalContext` → 通过 `createAttachmentMessage({ type: 'hook_additional_context' })` 注入为用户消息
|
||||
- `systemMessage` → 注入为系统警告,直接显示给用户
|
||||
|
||||
### 4. 控制流程
|
||||
|
||||
阻止 Agent 继续执行:
|
||||
### 4. 控制流程(continue / stopReason)
|
||||
|
||||
```json
|
||||
{
|
||||
"continue": false,
|
||||
"stopReason": "构建失败,停止执行"
|
||||
}
|
||||
{ "continue": false, "stopReason": "构建失败,停止执行" }
|
||||
```
|
||||
|
||||
**设计洞察**:四种能力从"被动观察"到"主动干预"递进。最简单的 Hook 只是记录日志,最强大的 Hook 可以阻止操作、修改输入、控制流程。
|
||||
|
||||
## 六种 Hook 类型
|
||||
|
||||
| 类型 | 执行方式 | 适用场景 |
|
||||
|------|---------|---------|
|
||||
| `command` | Shell 命令 | 通用脚本、CI 检查 |
|
||||
| `prompt` | 注入到 AI 上下文 | 代码规范提醒 |
|
||||
| `agent` | 启动子 Agent | 复杂分析任务 |
|
||||
| `http` | HTTP 请求 | 远程服务、Webhook |
|
||||
| `callback` | 内部 JS 函数 | 系统内置 Hook |
|
||||
| `function` | 运行时函数 | Agent/Skill 内部使用 |
|
||||
|
||||
### 异步 Hook
|
||||
|
||||
Hook 进程的 stdout 第一行如果是 `{"async":true}`,系统将其转为后台任务。异步 Hook 完成后通过通知机制汇报结果。
|
||||
|
||||
**设计考量**:有些 Hook 需要长时间运行(如"等待 CI 结果"),不应该阻塞 Agent 的执行。异步 Hook 让这些操作在后台运行,完成后再通知 Agent。
|
||||
|
||||
## 匹配机制
|
||||
|
||||
### Matcher 模式
|
||||
|
||||
```
|
||||
"Write" → 精确匹配
|
||||
"Write|Edit" → 多值匹配
|
||||
"^Bash(git.*)" → 正则匹配
|
||||
"*" 或 "" → 通配所有
|
||||
```
|
||||
|
||||
### if 条件
|
||||
|
||||
Hook 可以指定 `if` 条件,只在特定输入时触发:
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "check-branch.sh",
|
||||
"if": "Bash(git push*)"
|
||||
}
|
||||
```
|
||||
|
||||
条件使用与权限规则相同的语法——工具名 + 参数模式。Bash 工具还会进行 AST 级别的命令解析。
|
||||
|
||||
### 多来源合并
|
||||
|
||||
Hook 从多个来源汇聚:
|
||||
- settings.json 中的配置(user/project/local)
|
||||
- SDK 注册的回调
|
||||
- Agent/Skill 的 frontmatter
|
||||
- 运行时动态注册
|
||||
|
||||
同一命令可能在不同层级重复出现。系统按复合键去重,保留最后合并的层级。
|
||||
|
||||
## 安全防护
|
||||
|
||||
### 工作区信任
|
||||
|
||||
**所有 Hook 都要求工作区信任**。这是纵深防御——防止恶意仓库的 `.claude/settings.json` 在未信任的情况下执行任意命令。
|
||||
|
||||
**设计哲学**:Hook 有执行任意命令的能力,这个能力不应该被不可信的来源获取。项目级配置是团队共享的,任何人都可以修改——信任检查确保只有用户明确信任的项目才能运行 Hook。
|
||||
|
||||
### 超时控制
|
||||
|
||||
Hook 有默认 10 分钟的超时限制,可以通过配置调整。超时后 Hook 进程被终止,Agent 继续执行。
|
||||
`continue: false` 设置 `preventContinuation = true`,阻止 Agent 继续执行后续操作。
|
||||
|
||||
## Session Hook 的生命周期
|
||||
|
||||
Agent 和 Skill 可以注册 session Hook,绑定到特定的 session ID。Agent 结束时自动清理——Agent A 的 Hook 不会泄漏到 Agent B 的执行中。
|
||||
Agent 和 Skill 的前置 Hook 通过 `registerFrontmatterHooks()` 注册(调用位置:`packages/builtin-tools/src/tools/AgentTool/runAgent.ts`;定义位置:`src/utils/hooks/registerFrontmatterHooks.ts`),绑定到 agent 的 session ID。Agent 结束时通过 `clearSessionHooks()`(定义位置:`src/utils/hooks/sessionHooks.ts`)清理。
|
||||
|
||||
**设计考量**:如果不自动清理,Agent A 的 PreToolUse Hook 可能意外拦截 Agent B 的工具调用,导致难以调试的问题。
|
||||
```typescript
|
||||
// runAgent.ts — 注册 agent 的前置 Hook
|
||||
registerFrontmatterHooks(rootSetAppState, agentId, agentDefinition.hooks, ...)
|
||||
|
||||
## 接下来
|
||||
// runAgent.ts — finally 块清理
|
||||
clearSessionHooks(rootSetAppState, agentId)
|
||||
```
|
||||
|
||||
- **Skills** — 理解基于 Hook 的技能系统
|
||||
- **MCP 配置** — 理解外部工具的注册
|
||||
- **权限模型** — 理解 PreToolUse Hook 与权限系统的协作
|
||||
这确保 Agent A 的 Hook 不会泄漏到 Agent B 的执行中。
|
||||
|
||||
@@ -1,84 +1,346 @@
|
||||
---
|
||||
title: "MCP 配置"
|
||||
description: "MCP 服务器从多个来源汇聚配置。理解多来源合并、企业排他模式、项目配置审批和保留名称机制。"
|
||||
keywords: ["MCP", "配置", "settings.json", ".mcp.json", "企业策略"]
|
||||
title: "MCP 配置 - 多来源合并、作用域与策略管控"
|
||||
description: "详细说明 Claude Code MCP 配置的来源层次、合并优先级、传输类型、企业策略管控、插件集成和保留名称机制。"
|
||||
keywords: ["MCP", "配置", "settings.json", ".mcp.json", "企业策略", "插件"]
|
||||
---
|
||||
|
||||
## 核心问题
|
||||
## 配置来源与作用域
|
||||
|
||||
MCP(Model Context Protocol)让 Claude Code 可以使用外部工具——数据库查询、浏览器控制、API 调用等。但 MCP 配置来自多个来源:用户全局、项目级、插件、企业策略。如何合并?谁优先?
|
||||
Claude Code 的 MCP 配置来自多个来源,每个来源对应一个 `scope`(作用域)。配置按优先级合并,高优先级来源的同名配置覆盖低优先级。
|
||||
|
||||
## 配置来源与合并优先级
|
||||
### 来源列表
|
||||
|
||||
配置按优先级从低到高合并,高优先级覆盖低优先级:
|
||||
| 来源 | Scope | 文件/接口 | 说明 |
|
||||
|------|-------|----------|------|
|
||||
| 企业管控 | `enterprise` | 系统管理路径 `managed-mcp.json` | **排他模式**:存在时忽略所有其他来源 |
|
||||
| 本地项目 | `local` | `<project>/.claude/settings.local.json` | 项目级私有配置(不提交到 VCS) |
|
||||
| 项目配置 | `project` | `<project>/.mcp.json` | 项目级共享配置(可提交到 VCS) |
|
||||
| 用户全局 | `user` | `~/.claude/settings.json` | 用户级配置,所有项目共享 |
|
||||
| 插件 | `dynamic` | 插件 manifest 中 `.mcp.json` / `.mcpb` | 插件提供的 MCP 服务器 |
|
||||
| claude.ai | `claudeai` | 通过 API 获取 | claude.ai 网页端配置的连接器 |
|
||||
| 内置动态 | `dynamic` | 代码中注册 | Computer Use / Chrome 等内置服务器 |
|
||||
| IDE SDK | `sdk` | IDE 传入 | VS Code / JetBrains 嵌入模式 |
|
||||
|
||||
### 合并优先级(从低到高)
|
||||
|
||||
```
|
||||
claude.ai 连接器(最低) → 插件 → 用户全局 → 项目配置 → 本地项目 → 内置动态(最高)
|
||||
claude.ai 连接器 ← 最低优先级
|
||||
↓ 去重
|
||||
插件服务器
|
||||
↓ 去重
|
||||
用户全局配置
|
||||
↓
|
||||
项目配置(.mcp.json) ← 需要用户审批
|
||||
↓
|
||||
本地项目配置
|
||||
↓
|
||||
动态配置(内置 MCP) ← 最高优先级
|
||||
```
|
||||
|
||||
| 来源 | Scope | 说明 |
|
||||
|------|-------|------|
|
||||
| claude.ai 连接器 | `claudeai` | 网页端配置的远程连接器 |
|
||||
| 插件 | `dynamic` | 插件 manifest 中声明 |
|
||||
| 用户全局 | `user` | `~/.claude/settings.json` |
|
||||
| 项目配置 | `project` | `.mcp.json`(需审批) |
|
||||
| 本地项目 | `local` | `settings.local.json`(不提交 VCS) |
|
||||
| 内置动态 | `dynamic` | Computer Use 等内置服务器 |
|
||||
`Object.assign({}, dedupedPluginServers, userServers, approvedProjectServers, localServers)` 实现合并——后出现的同名键覆盖前者。
|
||||
|
||||
## 企业排他模式
|
||||
## 企业管控模式
|
||||
|
||||
当企业管理员部署 `managed-mcp.json` 时,进入**排他模式**:只使用企业配置,忽略所有用户、项目、插件和 claude.ai 配置。
|
||||
当 `managed-mcp.json` 文件存在时,进入 **排他模式**:
|
||||
|
||||
**设计考量**:企业环境需要严格控制 AI 可以访问哪些外部工具。排他模式确保用户不能绕过企业策略添加自己的 MCP 服务器。
|
||||
```typescript
|
||||
// config.ts:1084
|
||||
if (doesEnterpriseMcpConfigExist()) {
|
||||
// 只返回企业配置,忽略所有用户/项目/插件/claude.ai 配置
|
||||
return { servers: filtered, errors: [] }
|
||||
}
|
||||
```
|
||||
|
||||
特性:
|
||||
- 路径由系统管理决定(`getManagedFilePath()` + `managed-mcp.json`)
|
||||
- 覆盖所有用户级、项目级、插件和 claude.ai 配置
|
||||
- 仍然应用策略过滤(allowlist/denylist)
|
||||
- 无法通过 CLI 添加新服务器(`addMcpConfig` 会拒绝)
|
||||
|
||||
## 传输类型与配置 Schema
|
||||
|
||||
### stdio(默认)
|
||||
|
||||
启动子进程,通过 stdin/stdout JSON-RPC 通信。
|
||||
|
||||
```json
|
||||
{
|
||||
"my-server": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@my-org/mcp-server"],
|
||||
"env": { "API_KEY": "..." }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`type` 字段可省略(默认为 `stdio`)。环境变量通过 `env` 传递给子进程,会与当前进程环境合并。
|
||||
|
||||
**Windows 注意**:使用 `npx` 需要包装为 `cmd /c npx`,否则会报错。
|
||||
|
||||
### SSE(Server-Sent Events)
|
||||
|
||||
通过 HTTP SSE 连接远程 MCP 服务器。
|
||||
|
||||
```json
|
||||
{
|
||||
"my-remote": {
|
||||
"type": "sse",
|
||||
"url": "https://mcp.example.com/sse",
|
||||
"headers": { "Authorization": "Bearer ..." },
|
||||
"oauth": {
|
||||
"clientId": "...",
|
||||
"authServerMetadataUrl": "https://auth.example.com/.well-known/oauth-authorization-server"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
支持 OAuth 认证流程。认证失败时进入 `needs-auth` 状态,15 分钟 TTL 缓存避免重复提示。
|
||||
|
||||
### HTTP(Streamable HTTP)
|
||||
|
||||
HTTP 流式传输。
|
||||
|
||||
```json
|
||||
{
|
||||
"my-http": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.example.com/mcp",
|
||||
"headers": { "X-API-Key": "..." }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
支持与 SSE 相同的 OAuth 配置。
|
||||
|
||||
### WebSocket
|
||||
|
||||
```json
|
||||
{
|
||||
"my-ws": {
|
||||
"type": "ws",
|
||||
"url": "wss://mcp.example.com/ws"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### IDE 专用类型(内部)
|
||||
|
||||
`sse-ide` 和 `ws-ide` 是 IDE 扩展专用类型,不由用户直接配置。
|
||||
|
||||
- `sse-ide`:使用 lockfile token 认证
|
||||
- `ws-ide`:使用 `X-Claude-Code-Ide-Authorization` header
|
||||
|
||||
### SDK 类型(内部)
|
||||
|
||||
`type: "sdk"` 由 IDE 嵌入模式传入,不经过保留名称检查和企业管控排他限制。
|
||||
|
||||
### claude.ai 代理类型(内部)
|
||||
|
||||
`type: "claudeai-proxy"` 由 claude.ai 网页端配置的连接器使用,通过 OAuth bearer token 认证并支持 401 重试。
|
||||
|
||||
## 配置操作
|
||||
|
||||
### 添加 MCP 服务器
|
||||
|
||||
通过 CLI 命令 `claude mcp add` 或 API 调用 `addMcpConfig()`:
|
||||
|
||||
```bash
|
||||
# 添加到用户配置
|
||||
claude mcp add my-server -s user -- npx @my-org/mcp-server
|
||||
|
||||
# 添加到项目配置
|
||||
claude mcp add my-server -s project -- npx @my-org/mcp-server
|
||||
|
||||
# 添加 HTTP 类型
|
||||
claude mcp add my-remote -s user -t http -u https://mcp.example.com/mcp
|
||||
```
|
||||
|
||||
添加时的验证流程:
|
||||
|
||||
1. **名称校验**:只允许字母、数字、连字符和下划线
|
||||
2. **保留名检查**:`claude-in-chrome` 和 `computer-use` 被保留
|
||||
3. **企业管控检查**:企业模式下拒绝添加
|
||||
4. **Schema 验证**:Zod 校验配置格式
|
||||
5. **策略检查**:denylist 拒绝、allowlist 验证
|
||||
|
||||
### 移除 MCP 服务器
|
||||
|
||||
```bash
|
||||
claude mcp remove my-server -s user
|
||||
```
|
||||
|
||||
### 列出 MCP 服务器
|
||||
|
||||
```bash
|
||||
claude mcp list
|
||||
```
|
||||
|
||||
## 项目配置审批
|
||||
|
||||
`.mcp.json` 是项目级共享配置(可提交到 git),但需要用户显式审批才能生效。
|
||||
`.mcp.json` 中的项目配置需要用户显式审批才能生效:
|
||||
|
||||
**为什么需要审批**?项目配置可能由任何人修改(包括恶意贡献者)。审批机制确保用户知情并同意项目提供的 MCP 服务器连接到他们的环境。
|
||||
```typescript
|
||||
// config.ts:1166
|
||||
const approvedProjectServers: Record<string, ScopedMcpServerConfig> = {}
|
||||
for (const [name, config] of Object.entries(projectServers)) {
|
||||
if (getProjectMcpServerStatus(name) === 'approved') {
|
||||
approvedProjectServers[name] = config
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
审批状态持久化在本地配置中,不需要每次重新审批。
|
||||
首次打开项目时,Claude Code 会提示用户审批 `.mcp.json` 中的每个服务器。审批状态持久化在本地配置中。
|
||||
|
||||
## 传输类型
|
||||
## 插件 MCP 集成
|
||||
|
||||
MCP 服务器通过不同的传输方式连接:
|
||||
插件通过 manifest 中的 `.mcp.json` 或 `.mcpb` 文件声明 MCP 服务器:
|
||||
|
||||
| 类型 | 适用场景 | 配置方式 |
|
||||
|------|---------|---------|
|
||||
| **stdio** | 本地工具(启动子进程) | `command` + `args` |
|
||||
| **SSE** | 远程 Server-Sent Events | `url` + 可选 `headers` |
|
||||
| **HTTP** | HTTP 流式传输 | `url` + 可选 `headers` |
|
||||
| **WebSocket** | 双向实时通信 | `wss://` URL |
|
||||
```typescript
|
||||
// 插件 MCP 加载流程
|
||||
const pluginResult = await loadAllPluginsCacheOnly()
|
||||
const pluginServerResults = await Promise.all(
|
||||
pluginResult.enabled.map(plugin => getPluginMcpServers(plugin, mcpErrors))
|
||||
)
|
||||
```
|
||||
|
||||
stdio 类型最常见——它启动一个本地子进程,通过 stdin/stdout 通信。远程类型(SSE/HTTP/WS)用于连接远程服务。
|
||||
### 插件命名空间
|
||||
|
||||
### 认证
|
||||
插件 MCP 服务器名格式为 `plugin:<pluginName>:<serverName>`,不会与手动配置的名称冲突。
|
||||
|
||||
远程 MCP 服务器支持 OAuth 认证。认证失败时进入 `needs-auth` 状态,15 分钟内不重复提示。
|
||||
### 去重机制
|
||||
|
||||
## 插件集成
|
||||
插件服务器通过内容签名去重(`dedupPluginMcpServers`):
|
||||
|
||||
插件通过 manifest 声明 MCP 服务器,命名空间为 `plugin:<pluginName>:<serverName>`,不会与手动配置冲突。
|
||||
- **stdio 类型**:签名 = `stdio:` + JSON.stringify([command, ...args])
|
||||
- **URL 类型**:签名 = `url:` + 原始 URL(unwrap CCR proxy URL)
|
||||
- **sdk 类型**:签名为 null,不去重
|
||||
|
||||
插件服务器通过内容签名去重:
|
||||
- stdio 类型:基于 command + args
|
||||
- URL 类型:基于 URL
|
||||
- 手动配置优先于插件配置
|
||||
去重规则:
|
||||
1. 手动配置优先于插件配置
|
||||
2. 先加载的插件优先于后加载的
|
||||
3. 被抑制的插件服务器在 `/plugin` UI 中显示提示
|
||||
|
||||
### claude.ai 连接器去重
|
||||
|
||||
claude.ai 连接器使用相同的内容签名机制去重(`dedupClaudeAiMcpServers`):
|
||||
- 仅启用的手动配置参与去重(禁用的手动配置不应抑制连接器)
|
||||
- 连接器名格式为 `claude.ai <DisplayName>`
|
||||
|
||||
## 策略管控
|
||||
|
||||
企业策略通过 allowlist 和 denylist 控制可用的 MCP 服务器。策略检查不仅匹配服务器名称,还匹配 command/args(stdio)和 URL 模式(远程)。
|
||||
### Allowlist / Denylist
|
||||
|
||||
企业策略通过 allowlist 和 denylist 控制可用的 MCP 服务器:
|
||||
|
||||
```typescript
|
||||
// config.ts:1243 - 最终策略过滤
|
||||
for (const [name, serverConfig] of Object.entries(configs)) {
|
||||
if (!isMcpServerAllowedByPolicy(name, serverConfig)) {
|
||||
continue // 跳过策略禁止的服务器
|
||||
}
|
||||
filtered[name] = serverConfig
|
||||
}
|
||||
```
|
||||
|
||||
策略检查考虑:
|
||||
- 服务器名称匹配
|
||||
- stdio 类型的 command + args 匹配
|
||||
- URL 类型的 URL 模式匹配(支持通配符)
|
||||
|
||||
### 插件专用模式
|
||||
|
||||
`isRestrictedToPluginOnly('mcp')` 启用时,只允许插件提供的 MCP 服务器——用户/项目级配置被忽略。
|
||||
|
||||
## 环境变量展开
|
||||
|
||||
MCP 配置中的环境变量支持 `$VAR` 和 `${VAR}` 语法展开:
|
||||
|
||||
```json
|
||||
{
|
||||
"my-server": {
|
||||
"command": "npx",
|
||||
"args": ["@my-org/mcp-server"],
|
||||
"env": {
|
||||
"API_KEY": "$MY_API_KEY",
|
||||
"DB_URL": "${DATABASE_URL}"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
展开时缺失的变量会生成警告信息,但不阻止配置加载。
|
||||
|
||||
## 内置 MCP 动态注册
|
||||
|
||||
内置 MCP 服务器在 `main.tsx` 启动流程中动态注入配置:
|
||||
|
||||
### Computer Use MCP
|
||||
|
||||
```typescript
|
||||
// src/utils/computerUse/setup.ts
|
||||
export function setupComputerUseMCP(): {
|
||||
mcpConfig: Record<string, ScopedMcpServerConfig>
|
||||
allowedTools: string[]
|
||||
} {
|
||||
return {
|
||||
mcpConfig: {
|
||||
"computer-use": {
|
||||
type: "stdio",
|
||||
command: process.execPath,
|
||||
args: ["--computer-use-mcp"],
|
||||
scope: "dynamic",
|
||||
}
|
||||
},
|
||||
allowedTools: ["mcp__computer-use__screenshot", ...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
启用条件:
|
||||
- Feature flag `CHICAGO_MCP` 开启
|
||||
- `getPlatform() !== "unknown"`(macOS/Windows/Linux)
|
||||
- 非非交互式会话
|
||||
- GrowthBook gate `getChicagoEnabled()` 返回 true
|
||||
|
||||
### Claude in Chrome MCP
|
||||
|
||||
```typescript
|
||||
// 类似 Computer Use,在 main.tsx 中注册
|
||||
const { mcpConfig, allowedTools, systemPrompt } = setupClaudeInChrome()
|
||||
dynamicMcpConfig = { ...dynamicMcpConfig, ...mcpConfig }
|
||||
```
|
||||
|
||||
启用条件:
|
||||
- `--chrome` 参数或 `claudeInChromeDefaultEnabled` 配置
|
||||
- Chrome 扩展已安装
|
||||
|
||||
### VSCode SDK MCP
|
||||
|
||||
IDE 嵌入模式通过初始化消息传入 `type:'sdk'` 的配置,由 `setupVscodeSdkMcp()` 设置双向通知。
|
||||
|
||||
## 保留名称
|
||||
|
||||
以下名称被保留,用户无法手动配置:
|
||||
- `claude-in-chrome` — Chrome 浏览器控制
|
||||
- `computer-use` — 桌面自动化
|
||||
以下 MCP 服务器名称被保留,用户无法手动配置同名服务器:
|
||||
|
||||
这防止用户意外覆盖内置服务器的配置。
|
||||
| 名称 | 用途 | 检查条件 |
|
||||
|------|------|---------|
|
||||
| `claude-in-chrome` | Chrome 浏览器控制 | 始终检查 |
|
||||
| `computer-use` | 桌面自动化 | `CHICAGO_MCP` feature flag 开启时检查 |
|
||||
| `claude-vscode` | VSCode IDE 集成 | 由 SDK 传入,不经过名称检查 |
|
||||
|
||||
## 接下来
|
||||
保留名检查在两个位置:
|
||||
1. `addMcpConfig()`(`config.ts:636-648`)— 运行时拒绝
|
||||
2. `main.tsx` 启动检查(`main.tsx:2351-2368`)— 启动时退出
|
||||
|
||||
- **MCP 协议** — 理解连接管理、工具发现和执行链路
|
||||
- **Hooks** — 理解 MCP 生命周期中的 Hook 集成
|
||||
- **自定义 Agent** — 理解 Agent 中引用 MCP 服务器
|
||||
## 关键源文件索引
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `src/services/mcp/config.ts` | 配置管理核心:合并、去重、策略、添加/删除 |
|
||||
| `src/services/mcp/types.ts` | Zod Schema 定义、类型声明 |
|
||||
| `src/services/mcp/client.ts` | 连接管理、传输层选择 |
|
||||
| `src/utils/plugins/mcpPluginIntegration.ts` | 插件 MCP 配置加载 |
|
||||
| `src/utils/computerUse/setup.ts` | Computer Use 动态注册 |
|
||||
| `src/utils/claudeInChrome/common.ts` | Chrome MCP 保留名与工具名 |
|
||||
| `src/services/mcp/vscodeSdkMcp.ts` | VSCode SDK 双向通知 |
|
||||
|
||||
@@ -1,109 +1,407 @@
|
||||
---
|
||||
title: "MCP 协议"
|
||||
description: "从配置到可用工具:MCP 连接管理、内置 vs 外部两种模式、工具发现和执行链路的设计。"
|
||||
keywords: ["MCP", "Model Context Protocol", "工具扩展", "MCP 客户端", "工具发现"]
|
||||
title: "MCP 协议 - 连接管理、工具发现与执行链路"
|
||||
description: "从源码角度解析 Claude Code 的 MCP 集成:内置 MCP 与外部 MCP 的区别、7 种传输层实现、connectToServer 的 memoize 缓存、工具发现的 LRU 策略、认证状态机、以及 MCP 工具如何进入权限检查链路。"
|
||||
keywords: ["MCP", "Model Context Protocol", "工具扩展", "MCP 客户端", "工具发现", "内置 MCP", "外部 MCP"]
|
||||
---
|
||||
|
||||
## 核心问题
|
||||
{/* 本章目标:从源码角度揭示 MCP 客户端的两种运行模式(内置/外部)、连接管理、工具发现协议和执行链路 */}
|
||||
|
||||
MCP 让 Claude Code 使用外部工具。但连接外部服务有延迟、可能失败、工具列表可能变化。如何管理这些不确定性?
|
||||
|
||||
## 架构总览
|
||||
## 架构总览:从配置到可用工具
|
||||
|
||||
```
|
||||
配置(多来源合并) → 连接管理(缓存) → 工具发现(MCP 协议) → 工具执行
|
||||
配置层(多来源合并)
|
||||
├── settings.json: { mcpServers: { "my-db": { command: "npx", args: [...] } } } ← 外部
|
||||
├── .mcp.json: 项目级 MCP 配置 ← 外部
|
||||
├── 插件 manifest (.mcp.json / .mcpb) ← 外部(插件)
|
||||
├── claude.ai connectors ← 外部(远程)
|
||||
├── enterprise managed-mcp.json ← 外部(企业管控)
|
||||
├── setupComputerUseMCP() / setupClaudeInChrome() ← 内置(动态注册)
|
||||
└── SDK 传入 (type:'sdk') ← 内置(IDE 嵌入)
|
||||
↓
|
||||
getAllMcpConfigs() ← enterprise 独占 或 合并 user/project/local + plugin + claude.ai
|
||||
↓
|
||||
useManageMCPConnections() ← React Hook 管理连接生命周期
|
||||
↓
|
||||
connectToServer(name, config) ← memoize 缓存(lodash memoize)
|
||||
├── 判断:内置 MCP → InProcessTransport(同进程)
|
||||
├── 判断:外部 stdio → StdioClientTransport(子进程)
|
||||
├── 判断:远程 SSE/HTTP/WS → 网络传输
|
||||
└── 返回 MCPServerConnection ← { connected | failed | needs-auth | pending | disabled }
|
||||
↓
|
||||
fetchToolsForClient(client) ← LRU(20) 缓存
|
||||
├── client.request({ method: 'tools/list' })
|
||||
└── 每个工具包装为 MCPTool ← 统一 Tool 接口
|
||||
↓
|
||||
assembleToolPool() ← 合并内置工具 + MCP 工具
|
||||
↓
|
||||
工具名格式: mcp__<serverName>__<toolName> ← buildMcpToolName()
|
||||
```
|
||||
|
||||
## 两种 MCP 模式
|
||||
## 两种 MCP 模式:内置 vs 外部
|
||||
|
||||
Claude Code 的 MCP 实现区分 **内置 MCP 服务器** 和 **外部 MCP 服务器**。两者使用相同的客户端协议和工具发现机制,但在连接方式、生命周期管理和配置来源上完全不同。
|
||||
|
||||
### 内置 MCP 服务器
|
||||
|
||||
Computer Use、Chrome 控制等内置功能通过 MCP 协议暴露,但运行在**同进程内**——不启动子进程,无网络开销,无 IPC 序列化。
|
||||
内置 MCP 服务器由 Claude Code 自身提供,无需用户手动配置。它们在启动时自动注册为 `dynamic` scope 的配置,并在同进程内运行。
|
||||
|
||||
**设计洞察**:内置服务器使用与外部服务器完全相同的 MCP 协议(`tools/list`、`tools/call`),但通过 `InProcessTransport` 在进程内通信。这意味着所有工具发现、权限检查、执行逻辑都是同一套代码——内置和外部工具的唯一区别是传输方式。
|
||||
| 服务器 | 名称 | 包路径 | Feature Flag | 启用方式 |
|
||||
|--------|------|--------|-------------|---------|
|
||||
| Computer Use | `computer-use` | `@ant/computer-use-mcp` | `CHICAGO_MCP` | GrowthBook gate + macOS + interactive |
|
||||
| Claude in Chrome | `claude-in-chrome` | `@ant/claude-for-chrome-mcp` | — | `--chrome` 参数或 `claudeInChromeDefaultEnabled` 配置 |
|
||||
| VSCode SDK | `claude-vscode` | — | — | IDE 嵌入模式 (type:`sdk`) |
|
||||
|
||||
#### InProcessTransport:零开销同进程通信
|
||||
|
||||
内置服务器通过 `InProcessTransport`(`src/services/mcp/InProcessTransport.ts`)运行,**不启动子进程**:
|
||||
|
||||
```typescript
|
||||
// 创建一对 linked transport —— 消息在两端之间直接传递
|
||||
const [clientTransport, serverTransport] = createLinkedTransportPair()
|
||||
|
||||
// server 端连接到 serverTransport
|
||||
inProcessServer = createComputerUseMcpServerForCli()
|
||||
await inProcessServer.connect(serverTransport)
|
||||
|
||||
// client 端使用 clientTransport(与外部 MCP 的 Client 相同接口)
|
||||
transport = clientTransport
|
||||
```
|
||||
|
||||
`InProcessTransport` 的核心设计:
|
||||
- `send()` 通过 `queueMicrotask()` 异步投递消息到对端,避免同步请求/响应的栈深度问题
|
||||
- `close()` 双向关闭,任一端关闭都会触发两端的 `onclose` 回调
|
||||
- 无网络开销、无 IPC 序列化、无进程启动时间
|
||||
|
||||
#### 动态注册流程
|
||||
|
||||
内置服务器在 `main.tsx` 的启动流程中注册,注入 `dynamicMcpConfig`:
|
||||
|
||||
```typescript
|
||||
// main.tsx: Computer Use MCP 动态注册
|
||||
if (feature("CHICAGO_MCP") && getPlatform() !== "unknown" && !getIsNonInteractiveSession()) {
|
||||
const { getChicagoEnabled } = await import("src/utils/computerUse/gates.js")
|
||||
if (getChicagoEnabled()) {
|
||||
const { setupComputerUseMCP } = await import("src/utils/computerUse/setup.js")
|
||||
const { mcpConfig, allowedTools } = setupComputerUseMCP()
|
||||
dynamicMcpConfig = { ...dynamicMcpConfig, ...mcpConfig }
|
||||
allowedTools.push(...cuTools)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`setupComputerUseMCP()` 返回的配置(`src/utils/computerUse/setup.ts`):
|
||||
|
||||
```typescript
|
||||
{
|
||||
"computer-use": {
|
||||
type: "stdio", // 类型标记为 stdio(但 client.ts 会拦截为 InProcessTransport)
|
||||
command: process.execPath,
|
||||
args: ["--computer-use-mcp"],
|
||||
scope: "dynamic", // 动态作用域,不持久化
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 连接时拦截
|
||||
|
||||
`connectToServer()` 在 `client.ts:906-944` 中根据服务器名拦截内置服务器:
|
||||
|
||||
```typescript
|
||||
// Chrome MCP — 在 process 内运行,避免 ~325MB 子进程
|
||||
if (isClaudeInChromeMCPServer(name)) {
|
||||
const { createChromeContext } = await import('../../utils/claudeInChrome/mcpServer.js')
|
||||
const { createClaudeForChromeMcpServer } = await import('@ant/claude-for-chrome-mcp')
|
||||
const { createLinkedTransportPair } = await import('./InProcessTransport.js')
|
||||
const context = createChromeContext(config.env)
|
||||
inProcessServer = createClaudeForChromeMcpServer(context)
|
||||
const [clientTransport, serverTransport] = createLinkedTransportPair()
|
||||
await inProcessServer.connect(serverTransport)
|
||||
transport = clientTransport
|
||||
}
|
||||
|
||||
// Computer Use MCP — 同理
|
||||
if (feature('CHICAGO_MCP') && isComputerUseMCPServer(name)) {
|
||||
const { createComputerUseMcpServerForCli } = await import('../../utils/computerUse/mcpServer.js')
|
||||
const { createLinkedTransportPair } = await import('./InProcessTransport.js')
|
||||
inProcessServer = await createComputerUseMcpServerForCli()
|
||||
const [clientTransport, serverTransport] = createLinkedTransportPair()
|
||||
await inProcessServer.connect(serverTransport)
|
||||
transport = clientTransport
|
||||
}
|
||||
```
|
||||
|
||||
#### 保留名称保护
|
||||
|
||||
内置服务器的名称被保留,用户无法手动添加同名配置(`config.ts:636-648`):
|
||||
|
||||
```typescript
|
||||
// 添加 MCP 配置时检查保留名
|
||||
if (isClaudeInChromeMCPServer(name)) {
|
||||
throw new Error(`Cannot add MCP server "${name}": this name is reserved.`)
|
||||
}
|
||||
if (feature('CHICAGO_MCP') && isComputerUseMCPServer(name)) {
|
||||
throw new Error(`Cannot add MCP server "${name}": this name is reserved.`)
|
||||
}
|
||||
```
|
||||
|
||||
启动时也有全局检查(`main.tsx:2351-2368`):如果用户配置中包含保留名(非 `type:'sdk'`),直接 `process.exit(1)`。
|
||||
|
||||
#### VSCode SDK MCP
|
||||
|
||||
VSCode SDK MCP 是特殊的内置模式。IDE(如 VS Code、JetBrains)通过嵌入方式启动 Claude Code,并传入 `type:'sdk'` 的 MCP 配置。这类配置:
|
||||
- 不经过保留名称检查(IDE 可以使用任意名称)
|
||||
- 不参与 enterprise MCP 的排他控制
|
||||
- 通过 VSCode SDK transport 连接
|
||||
- 支持双向通知(如 `file_updated`、`experiment_gates`)
|
||||
|
||||
```typescript
|
||||
// src/services/mcp/vscodeSdkMcp.ts
|
||||
export function setupVscodeSdkMcp(sdkClients: MCPServerConnection[]): void {
|
||||
const client = sdkClients.find(client => client.name === 'claude-vscode')
|
||||
if (client && client.type === 'connected') {
|
||||
// 注册 log_event 通知处理器
|
||||
client.client.setNotificationHandler(LogEventNotificationSchema(), ...)
|
||||
// 发送实验门控到 VSCode
|
||||
client.client.notification({ method: 'experiment_gates', params: { gates } })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 外部 MCP 服务器
|
||||
|
||||
用户配置的外部工具,通过子进程(stdio)或网络连接(SSE/HTTP/WS)运行。
|
||||
外部 MCP 服务器由用户在配置文件中声明,通过子进程或网络连接运行。
|
||||
|
||||
| 维度 | 内置 | 外部 |
|
||||
|------|------|------|
|
||||
| 进程模型 | 同进程 | 子进程或网络 |
|
||||
| 启动开销 | 零 | 子进程启动或网络握手 |
|
||||
| 权限 | 自动授权 | 需要用户确认 |
|
||||
| 配置来源 | 动态注册 | settings.json / .mcp.json |
|
||||
| 名称保护 | 保留名,不可覆盖 | 自由命名 |
|
||||
#### 配置来源
|
||||
|
||||
## 连接管理
|
||||
| 来源 | Scope | 文件位置 | 优先级 |
|
||||
|------|-------|---------|--------|
|
||||
| 项目配置 | `project` | `<project>/.mcp.json` | 最高(同名覆盖) |
|
||||
| 本地配置 | `local` | `<project>/.claude/settings.local.json` | 高 |
|
||||
| 用户配置 | `user` | `~/.claude/settings.json` | 中 |
|
||||
| 插件 | `dynamic` | 插件 manifest 中 `.mcp.json` | 中 |
|
||||
| claude.ai | `claudeai` | 通过 API 获取 | 低 |
|
||||
| 企业管控 | `enterprise` | 系统管理路径 `managed-mcp.json` | 排他(存在时覆盖全部) |
|
||||
|
||||
### 缓存机制
|
||||
#### 配置示例
|
||||
|
||||
连接使用 memoize 缓存——相同的配置不会重复建立连接。缓存 key 包含服务器名和配置内容,配置变化时自动失效。
|
||||
```json
|
||||
// settings.json / .mcp.json 中的 MCP 配置
|
||||
{
|
||||
"mcpServers": {
|
||||
// stdio 类型 — 启动子进程
|
||||
"my-database": {
|
||||
"command": "npx",
|
||||
"args": ["@my-org/db-mcp-server"],
|
||||
"env": { "DB_URL": "postgres://..." }
|
||||
},
|
||||
|
||||
### 重连机制
|
||||
// HTTP 流类型 — 远程服务器
|
||||
"remote-api": {
|
||||
"type": "http",
|
||||
"url": "https://api.example.com/mcp"
|
||||
},
|
||||
|
||||
远程连接有连续错误计数器。遇到网络错误(ECONNRESET、ETIMEDOUT 等)连续 3 次后,主动关闭连接触发重连。
|
||||
// SSE 类型 — Server-Sent Events
|
||||
"realtime-feed": {
|
||||
"type": "sse",
|
||||
"url": "https://feed.example.com/sse"
|
||||
},
|
||||
|
||||
**设计考量**:网络连接是脆弱的——临时故障不应该永久禁用一个 MCP 服务器。自动重连确保服务恢复后工具能继续使用。
|
||||
// WebSocket 类型
|
||||
"ws-service": {
|
||||
"type": "ws",
|
||||
"url": "wss://ws.example.com/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 清理策略
|
||||
#### 配置合并与去重
|
||||
|
||||
stdio 类型的子进程清理使用信号升级策略:
|
||||
`getAllMcpConfigs()`(`config.ts`)按优先级合并多个来源的配置:
|
||||
|
||||
1. 企业管控配置存在时,**独占返回**(忽略所有其他来源)
|
||||
2. 否则合并:user → project → local → plugin → claude.ai
|
||||
3. 插件与手动配置去重:通过 `getMcpServerSignature()` 生成内容签名(基于 command/args/url),插件配置被同名手动配置抑制
|
||||
4. `addScopeToServers()` 为每个配置项标注来源 scope
|
||||
|
||||
## 7 种传输层实现
|
||||
|
||||
`connectToServer()`(`client.ts:596-1643`)根据 `config.type` 分发到不同的 Transport 实现:
|
||||
|
||||
| 传输类型 | Transport 类 | 适用场景 | 认证方式 |
|
||||
|----------|-------------|---------|---------|
|
||||
| `stdio`(默认) | `StdioClientTransport` | 外部本地子进程 | 无 |
|
||||
| `sse` | `SSEClientTransport` | 远程 SSE 服务 | `ClaudeAuthProvider` + OAuth |
|
||||
| `http` | `StreamableHTTPClientTransport` | HTTP 流 | `ClaudeAuthProvider` + OAuth |
|
||||
| `sse-ide` | `SSEClientTransport` | IDE 集成 | lockfile token |
|
||||
| `ws-ide` | `WebSocketTransport` | IDE WebSocket | `X-Claude-Code-Ide-Authorization` |
|
||||
| `ws` | `WebSocketTransport` | WebSocket 服务 | session ingress token |
|
||||
| `claudeai-proxy` | `StreamableHTTPClientTransport` | claude.ai 代理 | OAuth bearer + 401 重试 |
|
||||
| InProcess(内置) | `InProcessTransport` | Computer Use / Chrome | 无(同进程) |
|
||||
|
||||
### stdio 传输的进程管理
|
||||
|
||||
stdio 类型的 MCP 服务器作为子进程运行,cleanup 时采用 **信号升级策略**(`client.ts:1431-1564`):
|
||||
|
||||
```
|
||||
SIGINT (100ms) → SIGTERM (400ms) → SIGKILL
|
||||
```
|
||||
|
||||
总清理时间上限 600ms。防止 MCP 服务器关闭阻塞 CLI 退出。
|
||||
总清理时间上限 600ms,防止 MCP 服务器关闭阻塞 CLI 退出。
|
||||
|
||||
### 并发控制
|
||||
### 远程传输的认证状态机
|
||||
|
||||
| 类型 | 并发上限 | 原因 |
|
||||
|------|---------|------|
|
||||
| 本地(stdio) | 3 | 每个子进程是重量级资源 |
|
||||
| 远程(HTTP) | 20 | 轻量级 HTTP 请求 |
|
||||
SSE/HTTP 类型使用 `ClaudeAuthProvider` 实现 OAuth 认证流程。认证失败时进入 `needs-auth` 状态,并写入 15 分钟 TTL 的缓存文件(`mcp-needs-auth-cache.json`),避免重复弹出认证提示。
|
||||
|
||||
## 工具发现
|
||||
```
|
||||
连接尝试 → 401 Unauthorized
|
||||
↓
|
||||
handleRemoteAuthFailure()
|
||||
├── logEvent('tengu_mcp_server_needs_auth')
|
||||
├── setMcpAuthCacheEntry(name) ← 写入 15min TTL 缓存
|
||||
└── return { type: 'needs-auth' } ← UI 显示认证提示
|
||||
```
|
||||
|
||||
### 从 MCP 到 Tool 接口
|
||||
## 连接缓存与重连机制
|
||||
|
||||
MCP 服务器通过 `tools/list` 方法暴露工具列表。每个工具被包装为 Claude Code 统一的 Tool 接口,工具名格式为 `mcp__<serverName>__<toolName>`。
|
||||
`connectToServer` 使用 lodash `memoize` 缓存连接对象,缓存 key 为 `${name}-${JSON.stringify(config)}`。
|
||||
|
||||
### 缓存失效触发
|
||||
|
||||
当连接关闭时(`client.onclose`),清除所有相关缓存(`client.ts:1376-1404`):
|
||||
|
||||
```typescript
|
||||
client.onclose = () => {
|
||||
const key = getServerCacheKey(name, serverRef)
|
||||
fetchToolsForClient.cache.delete(name) // 工具缓存
|
||||
fetchResourcesForClient.cache.delete(name) // 资源缓存
|
||||
fetchCommandsForClient.cache.delete(name) // 命令缓存
|
||||
connectToServer.cache.delete(key) // 连接缓存
|
||||
}
|
||||
```
|
||||
|
||||
### 连接降级检测
|
||||
|
||||
远程传输有 **连续错误计数器**(`client.ts:1229`):
|
||||
|
||||
```typescript
|
||||
let consecutiveConnectionErrors = 0
|
||||
const MAX_ERRORS_BEFORE_RECONNECT = 3
|
||||
```
|
||||
|
||||
遇到终端错误(ECONNRESET、ETIMEDOUT、EPIPE 等)连续 3 次后,主动关闭 transport 触发重连。对于 HTTP 传输,还检测 session 过期(404 + JSON-RPC code -32001)。
|
||||
|
||||
### 请求级超时保护
|
||||
|
||||
每个 HTTP 请求使用独立的 `setTimeout` 超时(`wrapFetchWithTimeout`,`client.ts:493`),而非共享 `AbortSignal.timeout()`。原因是 Bun 对 AbortSignal.timeout 的 GC 是惰性的——每个请求约 2.4KB 原生内存,即使请求毫秒级完成也要等 60s 才回收。
|
||||
|
||||
```typescript
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(c => c.abort(...), MCP_REQUEST_TIMEOUT_MS, controller)
|
||||
timer.unref?.() // 不阻止进程退出
|
||||
```
|
||||
|
||||
## 工具发现:从 MCP 到 Tool 接口
|
||||
|
||||
`fetchToolsForClient()`(`client.ts:1744-2000`)使用 `memoizeWithLRU` 缓存(上限 100),将 MCP 工具转换为 Claude Code 的统一 Tool 接口:
|
||||
|
||||
```typescript
|
||||
const fullyQualifiedName = buildMcpToolName(client.name, tool.name)
|
||||
// 结果: "mcp__my-database__query"
|
||||
```
|
||||
|
||||
### 内置 MCP 的工具发现
|
||||
|
||||
内置 MCP 服务器虽然使用 InProcessTransport,但工具发现流程与外部服务器完全一致:
|
||||
|
||||
- **Computer Use**:`createComputerUseMcpServerForCli()` 在 `src/utils/computerUse/mcpServer.ts` 中构建 MCP Server 对象,注册 `ListToolsRequestSchema` handler。工具描述包含平台特定的已安装应用列表(1s 超时枚举)。
|
||||
- **Claude in Chrome**:`createClaudeForChromeMcpServer()` 在 `@ant/claude-for-chrome-mcp` 包中构建 Server,提供 17+ 个浏览器控制工具。
|
||||
- **VSCode SDK**:由 IDE 端提供工具列表,通过 SDK transport 传递。
|
||||
|
||||
### 工具描述截断
|
||||
|
||||
MCP 工具描述上限 2048 字符。OpenAPI 生成的 MCP 服务器曾观察到 15-60KB 的描述文档——截断防止这些巨大描述占满 System Prompt。
|
||||
MCP 工具描述上限 2048 字符(`MAX_MCP_DESCRIPTION_LENGTH`)。OpenAPI 生成的 MCP 服务器曾观察到 15-60KB 的描述文档。
|
||||
|
||||
### 工具能力标注
|
||||
|
||||
MCP 工具通过 `annotations` 声明自身特性:
|
||||
每个 MCP 工具根据 `tool.annotations` 自动标注:
|
||||
|
||||
| 注解 | 含义 |
|
||||
| 注解 | 映射到 | 含义 |
|
||||
|------|--------|------|
|
||||
| `readOnlyHint` | `isReadOnly()` + `isConcurrencySafe()` | 只读,可并行 |
|
||||
| `destructiveHint` | `isDestructive()` | 破坏性操作 |
|
||||
| `openWorldHint` | `isOpenWorld()` | 开放世界(不可枚举) |
|
||||
| `title` | `userFacingName()` | 显示名称 |
|
||||
|
||||
### MCP 工具的权限检查
|
||||
|
||||
MCP 工具默认返回 `{ behavior: 'passthrough' }`(`client.ts:1816-1834`),意味着它们始终进入权限确认流程。工具名使用 `mcp__` 前缀精确匹配权限规则。
|
||||
|
||||
内置 MCP 服务器的工具通过 `allowedTools` 列表自动授权——在 `main.tsx` 启动时加入,绕过普通权限提示。例如 Computer Use 工具的 `request_access` 自行处理会话级审批。
|
||||
|
||||
## MCP 工具的执行链路
|
||||
|
||||
```
|
||||
AI 生成 tool_use: { name: "mcp__my-db__query", input: { sql: "..." } }
|
||||
↓
|
||||
MCPTool.call() ← client.ts:1835
|
||||
├── ensureConnectedClient() ← 确保连接有效(重连)
|
||||
├── callMCPToolWithUrlElicitationRetry() ← 带 Elicitation 重试
|
||||
│ ├── client.request({ method: 'tools/call' })
|
||||
│ ├── 处理图片结果(resize + persist)
|
||||
│ └── 内容截断(mcpContentNeedsTruncation)
|
||||
├── McpSessionExpiredError → 重试一次
|
||||
└── 返回 { data: content, mcpMeta }
|
||||
```
|
||||
|
||||
### Session 过期自动重试
|
||||
|
||||
HTTP 传输的 MCP session 可能过期。检测到 `McpSessionExpiredError` 后自动重试一次(`client.ts:1862`),因为 `ensureConnectedClient()` 已经清除了缓存并建立了新连接。
|
||||
|
||||
### 内容截断与持久化
|
||||
|
||||
大型 MCP 工具输出通过 `truncateMcpContentIfNeeded` 截断,二进制内容(图片)通过 `persistBinaryContent` 写入文件并返回文件路径。图片自动 resize(`maybeResizeAndDownsampleImageBuffer`)。
|
||||
|
||||
## MCP 连接的并发控制
|
||||
|
||||
```typescript
|
||||
// 本地服务器并发连接数
|
||||
getMcpServerConnectionBatchSize() // 默认 3
|
||||
|
||||
// 远程服务器并发连接数
|
||||
getRemoteMcpServerConnectionBatchSize() // 默认 20
|
||||
```
|
||||
|
||||
本地 MCP 服务器(stdio)是重量级的子进程,默认限制 3 个并发连接。远程服务器是轻量级 HTTP 请求,允许 20 个并发。
|
||||
|
||||
## 内置 vs 外部 MCP 对比总结
|
||||
|
||||
| 维度 | 内置 MCP | 外部 MCP |
|
||||
|------|---------|---------|
|
||||
| **Transport** | `InProcessTransport`(同进程) | stdio / SSE / HTTP / WebSocket |
|
||||
| **配置来源** | `setupComputerUseMCP()` / `setupClaudeInChrome()` 等动态注册 | settings.json / .mcp.json / 插件 / claude.ai |
|
||||
| **Scope** | `dynamic` | `user` / `project` / `local` / `enterprise` / `claudeai` |
|
||||
| **进程模型** | 同进程,零开销 | 子进程(stdio)或网络连接 |
|
||||
| **名称保护** | 保留名,用户不可添加同名 | 自由命名(字母数字 + `-_`) |
|
||||
| **生命周期** | 随 CLI 启停 | 连接缓存 + 按需重连 |
|
||||
| **权限** | `allowedTools` 自动授权 | `passthrough` 进入权限确认 |
|
||||
| **Feature Flag** | `CHICAGO_MCP`(Computer Use)等 | 无(始终可用) |
|
||||
| **工具发现** | 与外部相同(MCP 协议) | 标准 MCP `tools/list` |
|
||||
| **清理** | `inProcessServer.close()` | 信号升级策略 SIGINT→SIGTERM→SIGKILL |
|
||||
|
||||
## 关键源文件索引
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `readOnlyHint` | 只读操作,可并行 |
|
||||
| `destructiveHint` | 破坏性操作 |
|
||||
| `openWorldHint` | 开放世界(不可枚举所有行为) |
|
||||
|
||||
这些标注影响权限检查——只读工具在更多权限模式下被自动放行。
|
||||
|
||||
### 权限检查
|
||||
|
||||
MCP 工具默认进入权限确认流程(`passthrough`),通过 `mcp__` 前缀匹配权限规则。用户可以为特定 MCP 服务器配置 allow/deny 规则。
|
||||
|
||||
内置 MCP 工具通过白名单自动授权,不触发权限提示。
|
||||
|
||||
## 执行链路
|
||||
|
||||
```
|
||||
AI 调用 MCP 工具 → 确保连接有效 → 通过 MCP 协议执行 → 处理结果
|
||||
→ Session 过期?自动重试一次
|
||||
→ 结果过大?截断 + 持久化到磁盘
|
||||
→ 包含图片?自动 resize + 持久化
|
||||
```
|
||||
|
||||
**设计考量**:MCP 工具的执行结果可能很大(数据库查询结果、API 响应)。截断 + 持久化确保大型结果不会耗尽上下文窗口,同时 AI 仍然知道结果在哪里可以找到。
|
||||
|
||||
## 接下来
|
||||
|
||||
- **MCP 配置** — 理解多来源合并和企业管控
|
||||
- **工具系统** — 理解所有工具的统一接口
|
||||
- **权限模型** — 理解 MCP 工具的权限检查
|
||||
| `src/services/mcp/client.ts` | 核心客户端:connectToServer、fetchToolsForClient、MCPTool.call |
|
||||
| `src/services/mcp/config.ts` | 配置管理:getAllMcpConfigs、addMcpConfig、removeMcpConfig |
|
||||
| `src/services/mcp/types.ts` | 类型定义:配置 Schema、连接状态类型 |
|
||||
| `src/services/mcp/InProcessTransport.ts` | 内置 MCP 传输层:linked transport pair |
|
||||
| `src/services/mcp/vscodeSdkMcp.ts` | VSCode SDK MCP:双向通知、实验门控 |
|
||||
| `src/services/mcp/useManageMCPConnections.ts` | React Hook:连接生命周期、重连 |
|
||||
| `src/utils/computerUse/mcpServer.ts` | Computer Use MCP Server 构建 |
|
||||
| `src/utils/computerUse/setup.ts` | Computer Use 动态注册 |
|
||||
| `src/utils/claudeInChrome/mcpServer.ts` | Chrome MCP Server 构建 + Bridge 配置 |
|
||||
| `src/tools/MCPTool/MCPTool.ts` | MCP 工具包装:统一 Tool 接口 |
|
||||
| `src/entrypoints/mcp.ts` | MCP server 入口(Claude Code 作为 MCP server) |
|
||||
|
||||
@@ -1,123 +1,221 @@
|
||||
---
|
||||
title: "Skills 技能系统"
|
||||
description: "Prompt 即能力。Skill 不是代码,而是高质量的 Prompt + 权限配置的声明式封装。理解加载链路、两条执行路径和条件激活机制。"
|
||||
keywords: ["Skills", "技能加载", "Prompt 即能力", "条件激活"]
|
||||
title: "Skills 技能系统 - Prompt 即能力的架构哲学"
|
||||
description: "深入剖析 Claude Code Skills 系统的完整实现:从磁盘加载、Frontmatter 解析、预算感知描述截断、双模式执行(inline/fork)、权限白名单、条件激活、动态发现到远程技能加载,揭示一条完整的 Skill 生命周期链路。"
|
||||
keywords: ["Skills", "SkillTool", "技能加载", "Frontmatter", "whenToUse", "allowedTools", "fork执行", "动态发现"]
|
||||
---
|
||||
|
||||
## 核心洞见:Prompt 即能力
|
||||
{/* 本章目标:揭示 Skill 系统从文件到执行的全链路实现 */}
|
||||
|
||||
Skill 的核心设计哲学:**复杂任务的关键不在代码逻辑,而在 Prompt 质量**。
|
||||
|
||||
一个代码审查 Skill 不需要审查引擎,只需告诉 AI "审查什么、按什么顺序、输出什么格式"。Skill 把这种"经验"封装为可复用的 Markdown 文件。
|
||||
## Tool vs Skill:本质差异
|
||||
|
||||
| | Tool | Skill |
|
||||
|---|---|---|
|
||||
| 粒度 | 单个原子操作(读文件、执行命令) | 完整工作流(代码审查、创建 PR) |
|
||||
| 本质 | TypeScript 执行逻辑 | Prompt + 权限配置的声明式封装 |
|
||||
| 创建 | 需要写代码 | 写 Markdown 文件即可 |
|
||||
| 粒度 | 单个原子操作(读文件、执行命令) | 一套完整的工作流(代码审查、创建 PR) |
|
||||
| 触发方式 | AI 自主选择 | 用户 `/skill-name` 或 AI 通过 `SkillTool` 自动匹配 |
|
||||
| 本质 | TypeScript 执行逻辑 | **Prompt + 权限配置**的声明式封装 |
|
||||
| 注册位置 | `src/tools.ts` → `getTools()` | `src/commands.ts` → `getCommands()` |
|
||||
| 执行器 | 各 Tool 的 `call()` 方法 | `SkillTool.call()` → 两条分支(inline / fork) |
|
||||
|
||||
## Skill 的来源
|
||||
Skill 的核心洞见:**复杂任务的关键不在代码逻辑,而在 Prompt 质量**。一个代码审查 Skill 不需要审查引擎,只需告诉 AI "审查什么、按什么顺序、输出什么格式"——Skill 把这种"经验"封装为可复用的 Markdown。
|
||||
|
||||
| 来源 | 路径 | 特点 |
|
||||
|------|------|------|
|
||||
| **内置命令** | 硬编码 | `/commit`、`/compact` 等 70+ 命令 |
|
||||
| **Bundled Skills** | 编译时打包 | 延迟解压,享有不可截断特权 |
|
||||
| **磁盘 Skills** | `.claude/skills/` | 最重要的来源,支持多层级 |
|
||||
| **MCP Skills** | MCP Server 提供 | 远程内容,禁止内联 shell 命令 |
|
||||
| **Legacy Commands** | `.claude/commands/` | 向后兼容旧格式 |
|
||||
## Skill 的五个来源与加载链路
|
||||
|
||||
### 多层级磁盘加载
|
||||
### 1. 内置命令(Built-in Commands)
|
||||
|
||||
硬编码在 `src/commands.ts:299` 的 `COMMANDS` memoize 数组中,包含 70+ 条命令(`/commit`、`/review`、`/compact` 等)。这些是 TypeScript 模块而非 Markdown,但实现了相同的 `Command` 接口(`src/types/command.ts`)。
|
||||
|
||||
### 2. Bundled Skills(编译时打包)
|
||||
|
||||
通过 `registerBundledSkill()`(`src/skills/bundledSkills.ts:53`)在模块初始化时注册。关键特性:
|
||||
|
||||
- **延迟文件提取**:如果 Skill 声明了 `files`(参考文件),首次调用时才解压到临时目录(`getBundledSkillExtractDir()`),使用 `O_NOFOLLOW | O_EXCL` 防止符号链接攻击(`safeWriteFile`,第 186 行)
|
||||
- **闭包级 memoize**:并发调用共享同一个 extraction promise,避免竞态写入
|
||||
- 来源标记为 `source: 'bundled'`,在 Prompt 预算中享有**不可截断**的特权
|
||||
|
||||
### 3. 磁盘 Skills(`.claude/skills/`)
|
||||
|
||||
由 `loadSkillsFromSkillsDir()`(`src/skills/loadSkillsDir.ts:407`)加载,这是最重要的加载路径:
|
||||
|
||||
```
|
||||
管理策略: $MANAGED_DIR/.claude/skills/ (企业管理)
|
||||
用户全局: ~/.claude/skills/ (个人偏好)
|
||||
项目级: .claude/skills/ (团队共享)
|
||||
附加目录: --add-dir 指定的路径 (额外来源)
|
||||
管理策略: $MANAGED_DIR/.claude/skills/ (policySettings)
|
||||
用户全局: ~/.claude/skills/ (userSettings)
|
||||
项目级: .claude/skills/ (projectSettings, 向上遍历至 home)
|
||||
附加目录: --add-dir 指定的路径下 .claude/skills/
|
||||
```
|
||||
|
||||
每个 Skill 是一个 `skill-name/SKILL.md` 目录。加载时解析 YAML frontmatter 提取配置。
|
||||
**加载协议**:只识别 `skill-name/SKILL.md` 目录格式,不再支持单文件 `.md`。加载流程:
|
||||
|
||||
### 安全边界
|
||||
1. `readdir` 扫描目录 → 仅保留 `isDirectory()` 或 `isSymbolicLink()` 的条目
|
||||
2. 在每个子目录中查找 `SKILL.md`,未找到则跳过
|
||||
3. `parseFrontmatter()` 解析 YAML 头部,提取 `whenToUse`、`allowedTools`、`context` 等字段
|
||||
4. `parseSkillFrontmatterFields()`(第 185 行)统一解析 16 个 frontmatter 字段
|
||||
5. `createSkillCommand()`(第 270 行)构造 `Command` 对象
|
||||
|
||||
MCP Skills 的 Prompt 内容**禁止执行内联 shell 命令**。因为远程内容不可信——如果允许,恶意 MCP Server 就可以通过 Skill 注入执行任意命令。
|
||||
**去重机制**:使用 `realpath()` 解析符号链接获得规范路径(`getFileIdentity`,第 118 行),避免通过符号链接或重叠父目录导致的重复加载。
|
||||
|
||||
## Frontmatter 配置
|
||||
### 4. MCP Skills(动态发现)
|
||||
|
||||
一个 SKILL.md 的完整配置:
|
||||
通过 `registerMCPSkillBuilders()` 注册构建器,MCP Server 的 prompt 被 `mcpSkillBuilders.ts` 转换为 `Command` 对象。标记为 `loadedFrom: 'mcp'`。
|
||||
|
||||
**安全边界**:MCP Skills 的 Prompt 内容**禁止执行内联 shell 命令**(`loadSkillsDir.ts:374` 的 `loadedFrom !== 'mcp'` 守卫),因为远程内容不可信。
|
||||
|
||||
### 5. Legacy Commands(`/commands/` 目录)
|
||||
|
||||
向后兼容的旧格式,由 `loadSkillsFromCommandsDir()`(第 566 行)加载。同时支持 `SKILL.md` 目录格式和单 `.md` 文件格式。
|
||||
|
||||
## Frontmatter 字段全景
|
||||
|
||||
一个 `SKILL.md` 的完整 frontmatter(`parseSkillFrontmatterFields`,第 185 行):
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: code-review
|
||||
description: 系统性代码审查
|
||||
when_to_use: "用户说审查代码、找 bug"
|
||||
allowed-tools:
|
||||
name: code-review # 显示名称(覆盖目录名)
|
||||
description: 系统性代码审查 # 描述(或从 Markdown 首段提取)
|
||||
when_to_use: "用户说审查代码、找 bug" # AI 自动匹配依据
|
||||
allowed-tools: # 工具白名单
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
context: fork # 执行模式:inline | fork
|
||||
model: opus # 模型覆盖
|
||||
effort: high # 努力级别
|
||||
paths: # 条件激活
|
||||
argument-hint: "<file-or-directory>" # 参数提示
|
||||
arguments: [path] # 声明式参数名(用于 $ARGUMENTS 替换)
|
||||
model: opus # 模型覆盖
|
||||
effort: high # 努力级别
|
||||
context: fork # 执行模式:inline(默认)| fork
|
||||
agent: code-reviewer # 指定 Agent 定义文件
|
||||
user-invocable: true # 用户是否可 /调用
|
||||
disable-model-invocation: false # 禁止 AI 自主调用
|
||||
version: "1.0" # 版本号
|
||||
paths: # 条件激活的文件路径模式
|
||||
- "src/**/*.ts"
|
||||
hooks: # Hook 配置
|
||||
PreToolUse:
|
||||
- command: ["echo", "checking"]
|
||||
shell: ["bash"] # Shell 执行环境
|
||||
---
|
||||
```
|
||||
|
||||
- `when_to_use` — AI 根据此描述自动匹配用户意图
|
||||
- `allowed-tools` — 限制 Skill 可用的工具白名单
|
||||
- `context` — 控制执行模式(见下文)
|
||||
- `paths` — 条件激活,只在操作匹配文件时出现
|
||||
解析后有 16 个字段被提取,其中 `allowedTools`、`model`、`effort` 在执行时动态修改 `toolPermissionContext`。
|
||||
|
||||
## 两条执行路径
|
||||
## 两条执行路径:Inline vs Fork
|
||||
|
||||
SkillTool(`packages/builtin-tools/src/tools/SkillTool/SkillTool.ts:332`)在 `call()` 中根据 `command.context` 分流:
|
||||
|
||||
### Inline 模式(默认)
|
||||
|
||||
Skill 的 Prompt 内容被注入为用户消息,在主对话流中继续执行。AI "穿上"了 Skill 的经验,但仍在同一个对话中。
|
||||
Skill 的 Prompt 内容被注入为 **UserMessage**,在主对话流中继续执行:
|
||||
|
||||
**优点**:共享主对话的完整上下文,可以引用之前的讨论。
|
||||
**缺点**:Skill 的中间过程会污染主对话的上下文。
|
||||
1. `processPromptSlashCommand()` 处理参数替换(`$ARGUMENTS`)和 shell 命令展开(`` !`...` ``)
|
||||
2. `${CLAUDE_SKILL_DIR}` 被替换为 Skill 所在目录的绝对路径
|
||||
3. `${CLAUDE_SESSION_ID}` 被替换为当前会话 ID
|
||||
4. 返回 `newMessages`(注入到对话流)+ `contextModifier`(修改权限上下文)
|
||||
|
||||
`contextModifier`(第 776 行)做了三件事:
|
||||
- **工具白名单注入**:将 `allowedTools` 合并到 `alwaysAllowRules.command`
|
||||
- **模型切换**:`resolveSkillModelOverride()` 处理模型覆盖,保留 `[1m]` 后缀以避免 200K 窗口截断
|
||||
- **努力级别覆盖**:修改 `effortValue`
|
||||
|
||||
### Fork 模式(`context: fork`)
|
||||
|
||||
Skill 在独立子 Agent 中执行,拥有独立的 token 预算和工具权限。执行完成后只返回最终结果,中间过程不保留。
|
||||
Skill 在**独立子 Agent** 中执行(`executeForkedSkill`,第 122 行):
|
||||
|
||||
**优点**:不污染主对话,适合长时间运行的任务。
|
||||
**缺点**:子 Agent 看不到主对话的完整上下文。
|
||||
1. `prepareForkedCommandContext()` 构建隔离的 Agent 定义和 Prompt
|
||||
2. `runAgent()` 启动子 Agent 循环,拥有独立的 token 预算
|
||||
3. 通过 `onProgress` 回调报告工具使用进度
|
||||
4. 结果通过 `extractResultText()` 提取,子 Agent 的全部消息在提取后被释放(`agentMessages.length = 0`)
|
||||
5. 最终通过 `clearInvokedSkillsForAgent()` 清理状态
|
||||
|
||||
**设计考量**:大多数 Skill 使用 inline 模式就够了——它们需要主对话的上下文。Fork 模式适合"重型"任务(如完整的代码审查),这些任务的中间步骤很多,留在主对话中会浪费大量 token。
|
||||
Fork 模式适用于需要强隔离的场景(如长时间运行的审查任务),避免污染主对话的上下文。
|
||||
|
||||
## 权限模型
|
||||
## 权限模型:Safe Properties 白名单
|
||||
|
||||
Skill 有五层权限检查:
|
||||
`checkPermissions()`(第 433 行)实现了一个五层权限检查:
|
||||
|
||||
```
|
||||
Deny 规则 → 远程 Skill 自动放行 → Allow 规则 → Safe Properties 白名单 → Ask 用户确认
|
||||
1. Deny 规则匹配(支持精确匹配和 prefix:* 通配符)
|
||||
↓ 未命中
|
||||
2. 远程 canonical Skill 自动放行(EXPERIMENTAL_SKILL_SEARCH + USER_TYPE === 'ant')
|
||||
↓ 未命中
|
||||
3. Allow 规则匹配
|
||||
↓ 未命中
|
||||
4. Safe Properties 白名单检查(skillHasOnlySafeProperties,第 911 行)
|
||||
↓ 有非安全属性
|
||||
5. Ask 用户确认(附带精确匹配和前缀匹配两条建议规则)
|
||||
```
|
||||
|
||||
**Safe Properties 白名单**是一个包含 30 个安全属性名的列表。任何不在白名单中的属性都会触发权限请求。这是**正向安全**设计——未来新增的属性默认需要权限,而非默认允许。
|
||||
**Safe Properties**(`SAFE_SKILL_PROPERTIES`,第 876 行)是一个包含 30 个属性名的白名单(覆盖 `PromptCommand` 和 `CommandBase` 两个类型的所有安全属性)。任何不在白名单中的**有意义的属性值**(排除 `undefined`、`null`、空数组、空对象)都会触发权限请求。这是**正向安全**设计——未来新增的属性默认需要权限。
|
||||
|
||||
## Prompt 预算
|
||||
## Prompt 预算:1% 上下文窗口的截断策略
|
||||
|
||||
Skill 列表注入 System Prompt 时有严格预算(约上下文窗口的 1%):
|
||||
1. 优先保留 bundled Skills 的完整描述
|
||||
2. 非 bundled Skills 按剩余预算均分
|
||||
3. 预算不足时只保留名称
|
||||
Skill 列表注入 System Prompt 时有严格的字符预算(`prompt.ts`):
|
||||
|
||||
**设计考量**:Skill 列表只是让 AI "知道有什么可用"。完整的 Skill Prompt 在 AI 选择后才加载,不需要全部塞进 System Prompt。
|
||||
- **预算计算**:`contextWindowTokens × 4 chars/token × 1%`(约 8000 字符)
|
||||
- **单条上限**:`MAX_LISTING_DESC_CHARS = 250` 字符(超出截断为 `…`)
|
||||
- **Bundled Skills 不可截断**:它们始终保留完整描述,预算不足时只截断非 bundled 的
|
||||
- **降级策略**:
|
||||
1. 尝试完整描述 → 超预算?
|
||||
2. Bundled 保留完整,非 bundled 均分剩余预算 → 每条描述低于 20 字符?
|
||||
3. 非 bundled 仅保留名称
|
||||
|
||||
## 条件激活
|
||||
`formatCommandsWithinBudget()`(`prompt.ts:70`)实现了这个三级降级。
|
||||
|
||||
带有 `paths` 模式的 Skill 在加载时不会立即可用。只有当被操作的文件路径匹配模式时,该 Skill 才被激活。
|
||||
## 动态发现与条件激活
|
||||
|
||||
一个只在 `*.test.ts` 上激活的测试 Skill,平时完全不可见,只有当 AI 读取或编辑测试文件时才会出现。
|
||||
### 基于文件路径的动态发现
|
||||
|
||||
**设计洞察**:这解决了"Skill 泛滥"问题——项目可能定义了几十个 Skill,但一次对话通常只需要其中几个。条件激活让 Skill 按需出现,而不是全部堆在 AI 面前让它选择。
|
||||
`discoverSkillDirsForPaths()`(`loadSkillsDir.ts:861`)在文件操作时触发:
|
||||
|
||||
1. 从被操作的文件路径开始,**向上遍历**至 CWD(不包含 CWD 本身)
|
||||
2. 在每层查找 `.claude/skills/` 目录
|
||||
3. 使用 `realpath` 去重,`git check-ignore` 过滤 gitignored 目录
|
||||
4. 按路径深度排序(**深层优先**),更接近文件的 Skill 优先级更高
|
||||
|
||||
### 条件激活(paths frontmatter)
|
||||
|
||||
带有 `paths` 模式的 Skill 在加载时不会立即可用,而是存入 `conditionalSkills` Map。当被操作的文件路径匹配某个 Skill 的 paths 模式时(使用 `ignore` 库做 gitignore 风格匹配),该 Skill 才被**激活**——从 `conditionalSkills` 移入 `dynamicSkills`。
|
||||
|
||||
这意味着一个只在 `*.test.ts` 上激活的测试 Skill,平时完全不可见,只有当 AI 读取或编辑测试文件时才会出现。
|
||||
|
||||
## 使用频率排名
|
||||
|
||||
Skill 的排序使用指数衰减算法:一周前的使用权重减半。这确保常用的 Skill 排在前面,但偶尔用的老 Skill 也不会完全沉底。
|
||||
`recordSkillUsage()`(`skillUsageTracking.ts`)使用指数衰减算法计算 Skill 排名分数:
|
||||
|
||||
## 接下来
|
||||
```
|
||||
score = usageCount × max(0.5^(daysSinceUse / 7), 0.1)
|
||||
```
|
||||
|
||||
- **Hooks** — 理解 Skill 中可以使用的 Hook 机制
|
||||
- **MCP 配置** — 理解 MCP Skills 的来源
|
||||
- **自定义 Agent** — 理解 Skill 中指定的 Agent 定义
|
||||
- **7 天半衰期**:一周前的使用权重减半
|
||||
- **最低 0.1 保底**:避免老但高频使用的 Skill 完全沉底
|
||||
- **60 秒去抖**:同一 Skill 在 1 分钟内的多次调用只计一次,减少文件 I/O
|
||||
|
||||
排名数据持久化在全局配置的 `skillUsage` 字段中。
|
||||
|
||||
## 远程技能加载(Experimental)
|
||||
|
||||
通过 `EXPERIMENTAL_SKILL_SEARCH` feature flag 控制,支持从远程(AKI/GCS/S3)加载 `_canonical_<slug>` 格式的 Skill:
|
||||
|
||||
1. `validateInput()` 中 `stripCanonicalPrefix()` 拦截 canonical 名称
|
||||
2. `executeRemoteSkill()`(第 970 行)从远程 URL 加载 SKILL.md
|
||||
3. 支持 `gs://`、`https://`、`s3://` 等 URL 协议
|
||||
4. 内容经过 frontmatter 剥离、`${CLAUDE_SKILL_DIR}` 替换后直接注入
|
||||
5. 通过 `addInvokedSkill()` 注册到 compaction 保留状态,确保压缩后仍可恢复
|
||||
6. 远程 Skill 不经过 `processPromptSlashCommand`——无 `!command` 替换、无 `$ARGUMENTS` 展开
|
||||
|
||||
## 完整生命周期总结
|
||||
|
||||
```
|
||||
磁盘 SKILL.md
|
||||
↓ parseFrontmatter()
|
||||
↓ parseSkillFrontmatterFields() → 16 个字段
|
||||
↓ createSkillCommand() → Command 对象
|
||||
↓ 去重(realpath + seenFileIds)
|
||||
↓ 条件 Skill → conditionalSkills Map(等待路径匹配激活)
|
||||
↓ getSkillDirCommands() memoize 缓存
|
||||
↓ getAllCommands() 合并 local + MCP
|
||||
↓ formatCommandsWithinBudget() → 截断后的 Skill 列表注入 System Prompt
|
||||
↓ AI 选择匹配的 Skill
|
||||
↓ SkillTool.validateInput() → 名称校验 + 存在性检查
|
||||
↓ SkillTool.checkPermissions() → 五层权限检查
|
||||
↓ SkillTool.call() → inline 或 fork 执行
|
||||
↓ contextModifier() → 注入 allowedTools + model + effort
|
||||
↓ recordSkillUsage() → 更新使用频率排名
|
||||
```
|
||||
|
||||
@@ -99,12 +99,15 @@ ARGUMENTS
|
||||
|
||||
## 四、认证
|
||||
|
||||
默认启动时自动生成随机 token。客户端连接时需通过 query 参数传递:
|
||||
默认启动时自动生成随机 token。客户端连接时不要把 token 放在 URL 中:
|
||||
|
||||
```
|
||||
ws://localhost:9315/ws?token=<your-token>
|
||||
ws://localhost:9315/ws
|
||||
```
|
||||
|
||||
无法发送 `Authorization` header 的 WebSocket 客户端需要使用
|
||||
`rcs.auth.<base64url-token>` 子协议传递 token。
|
||||
|
||||
配置固定 token:
|
||||
|
||||
```bash
|
||||
@@ -135,6 +138,9 @@ acp-link ccb-bun -- --acp
|
||||
1. **REST 注册**:通过 `POST /v1/environments/bridge` 向 RCS 注册环境
|
||||
2. **WS identify**:建立 WebSocket 连接后发送 `identify` 消息(携带 agentId),替代完整 `register`
|
||||
|
||||
RCS 的 ACP WebSocket 连接不接受 URL query token。acp-link 会通过
|
||||
`rcs.auth.<base64url-token>` WebSocket 子协议发送 `ACP_RCS_TOKEN`。
|
||||
|
||||
```
|
||||
acp-link RCS
|
||||
│ │
|
||||
|
||||
225
docs/features/background-agent-selector.md
Normal file
225
docs/features/background-agent-selector.md
Normal file
@@ -0,0 +1,225 @@
|
||||
# Background Agent Selector — 底部统一后台 Agent 切换器
|
||||
|
||||
> Feature Flag: 无(直接启用)
|
||||
> 实现状态:完整可用
|
||||
> 依赖:`viewingAgentTaskId` / `enterTeammateView` / `exitTeammateView` 已有机制
|
||||
|
||||
## 一、功能概述
|
||||
|
||||
Background Agent Selector 是渲染在 PromptInput 下方的常驻状态条,列出当前所有 **backgrounded 的 local_agent 任务**(包括 `/fork` 派生的 fork agent 和 Task/AgentTool 调用 `run_in_background: true` 派生的子 agent)。用户可以用 ↑/↓ 方向键在 `main` 和各 agent 之间切换焦点,按 Enter 把 REPL 主视图替换为所选 agent 的实时 transcript,再按 Enter 选中 `main` 即可回到主对话。
|
||||
|
||||
整个机制完全复用官方已有的 teammate transcript 查看基础设施,不引入新的视图层 / 数据流,仅新增一条 footer pill 类型。
|
||||
|
||||
### 核心特性
|
||||
|
||||
- **统一入口**:`/fork`、Task 派生的 subagent、所有 `run_in_background: true` 的 agent 都在同一栏显示
|
||||
- **就地切换**:prompt 为空时按 ↓ 溢出进入底部 selector,↑↓ 选中某行,Enter 即切主视图
|
||||
- **实时状态**:每行显示 agent 类型 + 描述 + 运行时长 + 已消耗 token;running 时圆点为绿色
|
||||
- **Keep-alive 视图**:agent 完成后在 `evictAfter` grace 窗口内保留一段时间,用户可回看
|
||||
- **零界面侵入**:tasks 数为 0 时 selector 完全不渲染,不占屏幕高度
|
||||
- **与旧 Dialog 共存**:Shift+↓ 打开的 `BackgroundTasksDialog` 原有行为保留,selector 只作为展示 + 快捷切换
|
||||
|
||||
## 二、用户交互
|
||||
|
||||
### 触发方式
|
||||
|
||||
有任何 background agent 时,selector 自动出现在 `bypass permissions on` 行下方:
|
||||
|
||||
```
|
||||
claude-code | Opus 4.7 (1M context) | ctx:4%
|
||||
▶▶ bypass permissions on (shift+tab to cycle)
|
||||
|
||||
○ main ↑/↓ to select · Enter to view
|
||||
● Explore Research src/hooks 23s · ↓ 10.9k tokens
|
||||
○ Explore Research src/components 22s · ↓ 9.5k tokens
|
||||
○ Explore Research src/utils 21s · ↓ 13.6k tokens
|
||||
```
|
||||
|
||||
### 键盘路由
|
||||
|
||||
| 位置 / 状态 | 按键 | 行为 |
|
||||
|---|---|---|
|
||||
| PromptInput 非空 | ↑↓ | 光标移动 / 翻历史(不变) |
|
||||
| PromptInput 空 + 历史底部 | ↓ | 焦点下放到 selector,高亮到 `● main` |
|
||||
| Selector 聚焦(`footerSelection === 'bg_agent'`) | ↓ | 高亮下移,-1 → 0 → ... → N-1 |
|
||||
| Selector 聚焦 | ↑ | 高亮上移;在 `main` 再 ↑ → 焦点回 PromptInput |
|
||||
| Selector 聚焦 | Enter | `-1` → `exitTeammateView`;`>=0` → `enterTeammateView(agentId)`。焦点保留在 pill |
|
||||
| Selector 聚焦 | Esc | `footer:clearSelection`,焦点回 PromptInput |
|
||||
|
||||
### 视觉规则
|
||||
|
||||
- `● main` / `● <agent>`:当前被**查看**(viewingAgentTaskId 指向)或被**光标聚焦**(pill focused 时以光标为准)的一行
|
||||
- running 状态的 agent:圆点渲染为 `success` 色(绿色),与 `BackgroundTasksDialog` 状态语义对齐
|
||||
- 右上角 hint 随状态变化:
|
||||
- pill 聚焦:`↑/↓ to select · Enter to view`
|
||||
- 已选中 running agent:`shift+↓ to manage · x to stop`
|
||||
- 已选中 terminal agent:`shift+↓ to manage · x to clear`
|
||||
- 未选中任何 agent:`shift+↓ to manage background agents`
|
||||
|
||||
## 三、实现架构
|
||||
|
||||
### 3.1 数据层:`useBackgroundAgentTasks`
|
||||
|
||||
文件:`src/hooks/useBackgroundAgentTasks.ts`
|
||||
|
||||
封装对 `useAppState(s => s.tasks)` 的过滤:
|
||||
|
||||
```ts
|
||||
export function useBackgroundAgentTasks(): LocalAgentTaskState[] {
|
||||
const tasks = useAppState(s => s.tasks)
|
||||
return useMemo(() => {
|
||||
const now = Date.now()
|
||||
return Object.values(tasks)
|
||||
.filter(isLocalAgentTask)
|
||||
.filter(t => t.agentType !== 'main-session')
|
||||
.filter(t => t.isBackgrounded !== false)
|
||||
.filter(t => t.evictAfter === undefined || t.evictAfter > now)
|
||||
.sort((a, b) => a.startTime - b.startTime)
|
||||
}, [tasks])
|
||||
}
|
||||
```
|
||||
|
||||
`/fork` 和 `AgentTool` 的 `run_in_background: true` 底层都走 `registerAsyncAgent → runAsyncAgentLifecycle`,最终写入同一个 `appState.tasks` Map;此 hook 是唯一数据源,Selector 和 PromptInput 的 `bgAgentList` 都消费它。
|
||||
|
||||
### 3.2 状态层:新增两个字段
|
||||
|
||||
文件:`src/state/AppStateStore.ts`
|
||||
|
||||
```ts
|
||||
export type FooterItem =
|
||||
| 'tasks' | 'tmux' | 'bagel' | 'teams' | 'bridge' | 'companion'
|
||||
| 'bg_agent' // ← 新增
|
||||
|
||||
export type AppState = DeepImmutable<{
|
||||
// ...
|
||||
selectedBgAgentIndex: number // -1 = main, 0..N-1 = 选中的 agent
|
||||
}>
|
||||
```
|
||||
|
||||
- `'bg_agent'` 作为 `FooterItem` 加入 footer pill 体系,享受既有的 `footer:up` / `footer:down` / `footer:openSelected` keybinding 路由
|
||||
- `selectedBgAgentIndex` 记录 selector 的光标位置,与 `viewingAgentTaskId`("正在看什么")独立;它不可从 `viewingAgentTaskId` 派生——Enter 后光标留在 pill 继续导航,查看目标才变
|
||||
|
||||
### 3.3 键盘路由:PromptInput footer pill 分支
|
||||
|
||||
文件:`src/components/PromptInput/PromptInput.tsx`
|
||||
|
||||
1. **`bg_agent` 进入 footerItems[0]**:保证 prompt ↓ 溢出时(`handleHistoryDown` → `selectFooterItem(footerItems[0])`)直接进入 selector,而不是 `tasks` 等其他 pill
|
||||
2. **`footer:up` 分支**:`bgAgentSelected` 时 `selectedBgAgentIndex > -1` 则递减;在 -1 → `selectFooterItem(null)` 退出 pill
|
||||
3. **`footer:down` 分支**:`selectedBgAgentIndex < bgAgentList.length - 1` 则递增,到底 clamp
|
||||
4. **`footer:openSelected` 分支**:index === -1 → `exitTeammateView`;否则 `enterTeammateView(bgAgentList[i].agentId)`。**不清理 pill 焦点**,光标留在 selector 上继续导航
|
||||
5. **`selectFooterItem('bg_agent')`**:入 pill 时重置 `selectedBgAgentIndex = -1`(光标落到 `main`)
|
||||
|
||||
### 3.4 渲染层:`BackgroundAgentSelector`
|
||||
|
||||
文件:`src/components/tasks/BackgroundAgentSelector.tsx`
|
||||
|
||||
纯展示组件,不订阅键盘:
|
||||
|
||||
```tsx
|
||||
const tasks = useBackgroundAgentTasks()
|
||||
const viewingId = useAppState(s => s.viewingAgentTaskId)
|
||||
const footerSelection = useAppState(s => s.footerSelection)
|
||||
const selectedBgIndex = useAppState(s => s.selectedBgAgentIndex)
|
||||
|
||||
if (tasks.length === 0) return null
|
||||
|
||||
const pillFocused = footerSelection === 'bg_agent'
|
||||
const highlightedId = pillFocused
|
||||
? (selectedBgIndex === -1 ? null : tasks[selectedBgIndex]?.agentId ?? null)
|
||||
: (viewingId ?? null)
|
||||
```
|
||||
|
||||
**高亮派生规则**:pill 聚焦 → 跟 `selectedBgAgentIndex`;未聚焦 → 镜像 `viewingAgentTaskId`。这样当用户通过 Shift+↓ Dialog 或 `enterTeammateView` 其它途径切换视图时,selector 也会正确反映。
|
||||
|
||||
### 3.5 主视图切换:复用 `viewingAgentTaskId`
|
||||
|
||||
REPL.tsx 主体仍复用原有查看逻辑:
|
||||
|
||||
```ts
|
||||
const viewedTask = viewingAgentTaskId ? tasks[viewingAgentTaskId] : undefined
|
||||
const viewedAgentTask = ... (isLocalAgentTask(viewedTask) ? viewedTask : undefined)
|
||||
const displayedMessages = viewedAgentTask ? displayedAgentMessages : messages
|
||||
```
|
||||
|
||||
当 `enterTeammateView(agentId)` 把 `viewingAgentTaskId` 设成某个 local_agent 的 id:
|
||||
|
||||
- `viewedAgentTask` 解析成该 agent
|
||||
- `displayedMessages` 切换到 agent 的 messages
|
||||
- 消息列表、spinner、unseen divider 等一整套组件自动用 agent transcript 重渲染
|
||||
- 主对话流被"暂停"(并非销毁,回到 `main` 时仍在原处)
|
||||
|
||||
`enterTeammateView` 同步负责:设 `retain: true` 阻止 eviction、清 `evictAfter`、触发 disk bootstrap 从 `agent-<id>.jsonl` 加载完整 transcript 到 `task.messages`。
|
||||
|
||||
#### Fork agent prompt 归一化
|
||||
|
||||
`/fork` agent 的 transcript 和普通 subagent 不同:它继承 main agent 的上下文,真实初始消息形态是:
|
||||
|
||||
```text
|
||||
...parent messages
|
||||
assistant([...tool_use])
|
||||
user([tool_result..., text("<fork-boilerplate>...Your directive: <prompt>")])
|
||||
...fork live messages
|
||||
```
|
||||
|
||||
这里的 prompt 文本混在 `[tool_result..., text]` 多 block user message 里。消息渲染管线会优先把这条 user message 当作 tool-result plumbing 来处理,导致 `<fork-boilerplate>` 里的用户 prompt 不稳定可见。为保证切换到 fork agent 时总能看到用户发起的 fork prompt,REPL.tsx 对 fork 视图做一次展示层归一化:
|
||||
|
||||
1. 仅当 `viewedAgentTask.agentType === 'fork'` 时启用,不影响普通 Explore / Task subagent。
|
||||
2. 从原始 messages 中识别包含 `<fork-boilerplate>` 的 carrier message。
|
||||
3. 剥离 carrier message 里的 boilerplate text block,但保留 `tool_result` blocks,避免破坏父 assistant `tool_use` 的承接关系。
|
||||
4. 强制插入一条独立 `createUserMessage({ content: viewedAgentTask.prompt })` 作为可见用户 prompt。
|
||||
5. 插入位置优先为 boilerplate carrier 后;如果 sidechain bootstrap 还没读到 carrier,则插到最后一条 inherited `assistant tool_use` 后面,确保 prompt 接在 main 上下文之后,而不是跑到视图顶部。
|
||||
|
||||
这个归一化只影响 UI 展示用的 `displayedAgentMessages`,不回写 `task.messages`,也不改变发送给模型的 fork transcript。
|
||||
|
||||
### 3.6 生命周期
|
||||
|
||||
完全复用官方既有机制:
|
||||
|
||||
- **运行中**:`isBackgroundTask()` 谓词为真,selector 列出
|
||||
- **完成 / 失败 / 中止**:`completeAgentTask` / `failAgentTask` / `killAsyncAgent` 设 `status` 为 terminal
|
||||
- **回访后退出**:`exitTeammateView` 调 `release(task)`——清 `retain`、清 `messages`、terminal 状态下设 `evictAfter = now + PANEL_GRACE_MS (30s)`
|
||||
- **evictAfter 过期**:`useBackgroundAgentTasks` 过滤时自然剔除,selector 行消失
|
||||
- **手动清除**:`stopOrDismissAgent(taskId)` 设 `evictAfter = 0`,立即消失
|
||||
|
||||
## 四、设计决策
|
||||
|
||||
1. **数据源单一**:`useBackgroundAgentTasks` 是唯一过滤点,PromptInput 也复用,避免过滤条件散落
|
||||
2. **pill 聚焦保留**:Enter 切视图后不松焦,让 ↑↓ 连续导航,贴近官方体验
|
||||
3. **`bg_agent` 放 footerItems[0]**:确保 ↓ 溢出直接进入 selector 而非其它 pill
|
||||
4. **selector 不订阅键盘**:所有按键路由集中在 PromptInput 的 `footer:*` 分支,避免 selector 组件和 PromptInput 双重 `useInput` 的冲突
|
||||
5. **`selectedBgAgentIndex` 存 AppState 而非局部 state**:selector 和 PromptInput 分别在两棵不同子树,需要全局字段协调;该值不能从 `viewingAgentTaskId` 派生
|
||||
6. **与 `BackgroundTasksDialog` 共存**:Shift+↓ 行为完全不变,selector 是补充快捷入口;Dialog 仍管 shell / workflow / monitor_mcp 等 selector 不显示的 task 类型
|
||||
7. **fork prompt 展示层兜底**:fork prompt 不依赖 boilerplate 自身渲染,统一在 `displayedAgentMessages` 中合成独立用户消息;普通 subagent 不走该分支,避免 prompt 重复
|
||||
|
||||
## 五、关键 API 复用
|
||||
|
||||
| 官方已有能力 | selector 如何使用 |
|
||||
|---|---|
|
||||
| `AppState.tasks` | 单一数据源,无需 file watcher / output JSONL 订阅 |
|
||||
| `registerAsyncAgent` | `/fork` 和 AgentTool 共用,selector 不区分来源 |
|
||||
| `enterTeammateView(id)` | Enter 时调用,负责 retain + disk bootstrap |
|
||||
| `exitTeammateView` | Enter 选中 `main` 时调用 |
|
||||
| `release(task)` + `PANEL_GRACE_MS` | 30s keep-alive,selector 自动生效 |
|
||||
| `useElapsedTime` | 每行时长显示,非 running 自动停 interval |
|
||||
| `formatTokens` (`utils/format.ts`) | token 数 1k 缩写 |
|
||||
| `footer:up` / `footer:down` / `footer:openSelected` keybinding | 键盘路由复用 Footer context |
|
||||
|
||||
## 六、文件索引
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `src/hooks/useBackgroundAgentTasks.ts` | 数据过滤 hook(backgrounded local_agent + evictAfter 过滤 + startTime 排序) |
|
||||
| `src/components/tasks/BackgroundAgentSelector.tsx` | 底部 selector UI,纯展示 |
|
||||
| `src/components/PromptInput/PromptInput.tsx` | 新增 `'bg_agent'` footer pill + 对应的 `footer:up/down/openSelected` 分支 |
|
||||
| `src/state/AppStateStore.ts` | `FooterItem` 加 `'bg_agent'`;新增 `selectedBgAgentIndex` 字段 |
|
||||
| `src/main.tsx` | `getDefaultAppState` 同步初始化 `selectedBgAgentIndex: -1` |
|
||||
| `src/screens/REPL.tsx` | 在 PromptInput + SessionBackgroundHint 之后挂载 `<BackgroundAgentSelector />`;切换 agent 主视图;对 fork transcript 做 prompt 归一化 |
|
||||
| `src/components/messages/AssistantToolUseMessage.tsx` | 新增 `defaultCollapsed?: boolean` prop,为后续详情视图默认折叠工具块预留 |
|
||||
| `src/components/messages/UserTextMessage.tsx` | 识别 `<fork-boilerplate>`,交给 fork 专用 renderer 处理 |
|
||||
| `src/components/messages/UserForkBoilerplateMessage.tsx` | 将 fork boilerplate text 折叠为纯用户 prompt;作为 transcript 中原位渲染的兼容路径 |
|
||||
|
||||
## 七、已知限制
|
||||
|
||||
- `Date.now()` 在 `useBackgroundAgentTasks` 的 useMemo 里冻结于 `[tasks]` 触发时:若长时间没有新 task 变更事件,某个 terminal agent 的 grace 期过期后不会立即从 selector 消失,要等下一次 tasks 变化才刷新。在典型使用(主对话一直在产生消息)下感知不到,暂不额外加 interval。
|
||||
- Selector 当前不处理 Shell Task / Workflow / Monitor MCP 等类型——这些仍走 `BackgroundTasksDialog`(Shift+↓)管理。
|
||||
- `AssistantToolUseMessage` 的 `defaultCollapsed` prop 目前无调用方传值,保留作为后续"agent 详情视图内工具块默认折叠"扩展点。
|
||||
140
docs/features/context-collapse.md
Normal file
140
docs/features/context-collapse.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# CONTEXT_COLLAPSE — 上下文折叠
|
||||
|
||||
> Feature Flag: `FEATURE_CONTEXT_COLLAPSE=1`
|
||||
> 子 Feature: `FEATURE_HISTORY_SNIP=1`
|
||||
> 实现状态:核心逻辑全部 Stub,布线完整
|
||||
> 引用数:CONTEXT_COLLAPSE 20 + HISTORY_SNIP 16 = 36
|
||||
|
||||
## 一、功能概述
|
||||
|
||||
CONTEXT_COLLAPSE 让模型内省上下文窗口使用情况,并智能压缩旧消息。当对话接近上下文限制时,自动将旧消息折叠为压缩摘要,保留关键信息的同时释放 token 空间。
|
||||
|
||||
### 子 Feature
|
||||
|
||||
| Feature | 功能 |
|
||||
|---------|------|
|
||||
| `CONTEXT_COLLAPSE` | 上下文折叠引擎(后台 LLM 调用压缩旧消息) |
|
||||
| `HISTORY_SNIP` | SnipTool — 标记消息进行折叠/修剪 |
|
||||
|
||||
## 二、实现架构
|
||||
|
||||
### 2.1 模块状态
|
||||
|
||||
| 模块 | 文件 | 状态 |
|
||||
|------|------|------|
|
||||
| 折叠核心 | `src/services/contextCollapse/index.ts` | **Stub** — 接口完整(`ContextCollapseStats`、`CollapseResult`、`DrainResult`),函数全部空操作 |
|
||||
| 折叠操作 | `src/services/contextCollapse/operations.ts` | **Stub** — `projectView` 为恒等函数 |
|
||||
| 折叠持久化 | `src/services/contextCollapse/persist.ts` | **Stub** — `restoreFromEntries` 为空操作 |
|
||||
| CtxInspectTool | `packages/builtin-tools/src/tools/CtxInspectTool/CtxInspectTool.ts` | **实现** — 上下文内省工具 |
|
||||
| SnipTool 提示 | `src/tools/SnipTool/prompt.ts` | **Stub** — 空工具名 |
|
||||
| SnipTool 实现 | `src/tools/SnipTool/SnipTool.ts` | **缺失** |
|
||||
| force-snip 命令 | `src/commands/force-snip.js` | **缺失** |
|
||||
| 折叠读取搜索 | `src/utils/collapseReadSearch.ts` | **完整** — Snip 作为静默吸收操作 |
|
||||
| QueryEngine 集成 | `src/QueryEngine.ts` | **布线** — 导入并使用 snip 投影 |
|
||||
| Token 警告 UI | `src/components/TokenWarning.tsx` | **布线** — 折叠进度标签 |
|
||||
|
||||
### 2.2 核心接口(已定义,待实现)
|
||||
|
||||
```ts
|
||||
// contextCollapse/index.ts
|
||||
interface ContextCollapseStats {
|
||||
// 上下文使用统计
|
||||
}
|
||||
interface CollapseResult {
|
||||
// 折叠操作结果
|
||||
}
|
||||
interface DrainResult {
|
||||
// 紧急释放结果
|
||||
}
|
||||
|
||||
// 关键函数(全部 stub):
|
||||
isContextCollapseEnabled() // → false
|
||||
applyCollapsesIfNeeded(messages) // 透传
|
||||
recoverFromOverflow(messages) // 透传(413 恢复)
|
||||
initContextCollapse() // 空操作
|
||||
```
|
||||
|
||||
### 2.3 预期数据流
|
||||
|
||||
```
|
||||
对话持续增长
|
||||
│
|
||||
▼
|
||||
上下文接近限制(由 query.ts 检测)
|
||||
│
|
||||
├── 溢出检测 (query.ts:440,616,802)
|
||||
│
|
||||
▼
|
||||
applyCollapsesIfNeeded(messages) [需要实现]
|
||||
│
|
||||
├── 后台 LLM 调用压缩旧消息
|
||||
├── 保留关键信息(决策、文件路径、错误)
|
||||
└── 替换旧消息为压缩摘要
|
||||
│
|
||||
├── 413 恢复 (query.ts:1093,1179)
|
||||
│ └── recoverFromOverflow() 紧急折叠
|
||||
│
|
||||
▼
|
||||
projectView() 过滤折叠后的消息视图
|
||||
│
|
||||
▼
|
||||
模型继续工作(在压缩后的上下文中)
|
||||
```
|
||||
|
||||
### 2.4 HISTORY_SNIP 子功能
|
||||
|
||||
SnipTool 提供手动折叠能力:
|
||||
|
||||
- `/force-snip` 命令 — 强制执行折叠
|
||||
- SnipTool — 标记特定消息进行折叠/修剪
|
||||
- `collapseReadSearch.ts` 已完整实现,将 Snip 作为静默吸收操作处理
|
||||
|
||||
### 2.5 集成点
|
||||
|
||||
| 文件 | 位置 | 说明 |
|
||||
|------|------|------|
|
||||
| `src/query.ts` | 18,440,616,802,1093,1179 | 溢出检测、413 恢复、折叠应用 |
|
||||
| `src/QueryEngine.ts` | 124,127,1301 | Snip 投影使用 |
|
||||
| `src/utils/analyzeContext.ts` | 1122 | 跳过保留缓冲区显示 |
|
||||
| `src/utils/sessionRestore.ts` | 127,494 | 恢复折叠状态 |
|
||||
| `src/services/compact/autoCompact.ts` | 179,215 | 自动压缩时考虑折叠 |
|
||||
|
||||
## 三、需要补全的内容
|
||||
|
||||
| 优先级 | 模块 | 工作量 | 说明 |
|
||||
|--------|------|--------|------|
|
||||
| 1 | `services/contextCollapse/index.ts` | 大 | 折叠状态机、LLM 调用、消息压缩 |
|
||||
| 2 | `services/contextCollapse/operations.ts` | 中 | `projectView()` 消息过滤 |
|
||||
| 3 | `services/contextCollapse/persist.ts` | 小 | `restoreFromEntries()` 磁盘持久化 |
|
||||
| 4 | `tools/CtxInspectTool/` | 已完成 | 上下文内省工具已实现(`packages/builtin-tools/src/tools/CtxInspectTool/`) |
|
||||
| 5 | `tools/SnipTool/SnipTool.ts` | 中 | Snip 工具实现 |
|
||||
| 6 | `commands/force-snip.js` | 小 | `/force-snip` 命令 |
|
||||
|
||||
## 四、关键设计决策
|
||||
|
||||
1. **后台 LLM 压缩**:折叠不是简单截断,而是用 LLM 生成压缩摘要保留关键信息
|
||||
2. **413 恢复**:当 API 返回 413(请求过大)时,紧急折叠是最重要的恢复手段
|
||||
3. **与 autoCompact 协作**:折叠和自动压缩(compact)是不同的机制,折叠在消息级别,压缩在对话级别
|
||||
4. **持久化**:折叠状态持久化到磁盘,会话恢复时重载
|
||||
|
||||
## 五、使用方式
|
||||
|
||||
```bash
|
||||
# 启用 context collapse
|
||||
FEATURE_CONTEXT_COLLAPSE=1 bun run dev
|
||||
|
||||
# 启用 snip 子功能
|
||||
FEATURE_CONTEXT_COLLAPSE=1 FEATURE_HISTORY_SNIP=1 bun run dev
|
||||
```
|
||||
|
||||
## 六、文件索引
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `src/services/contextCollapse/index.ts` | 折叠核心(stub,接口已定义) |
|
||||
| `src/services/contextCollapse/operations.ts` | 投影操作(stub) |
|
||||
| `src/services/contextCollapse/persist.ts` | 持久化(stub) |
|
||||
| `src/utils/collapseReadSearch.ts` | Snip 吸收操作(完整) |
|
||||
| `src/query.ts` | 溢出检测和 413 恢复集成 |
|
||||
| `src/QueryEngine.ts` | Snip 投影使用 |
|
||||
| `src/components/TokenWarning.tsx` | 折叠进度 UI |
|
||||
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 发送消息(不依赖选择状态,直接指定目标)。
|
||||
|
||||
|
||||
@@ -225,6 +225,11 @@ acp-link ◄──ACP relay──► RCS ◄──Web UI WS──► 浏览器
|
||||
| `src/transport/acp-relay-handler.ts` | 前端 WS → acp-link 透传 + EventBus inbound 转发 |
|
||||
| `src/transport/acp-sse-writer.ts` | SSE event stream 供外部消费者订阅 |
|
||||
|
||||
ACP 的 agents、channel groups、relay 和 channel-group SSE 端点都要求有效
|
||||
API key。浏览器 `EventSource` 不能发送 `Authorization` header,外部订阅
|
||||
`/acp/channel-groups/:id/events` 时需要使用 `fetch` + `ReadableStream` 并带
|
||||
`Authorization: Bearer <api-key>`。
|
||||
|
||||
### acp-link 连接
|
||||
|
||||
详见 [acp-link 文档](./acp-link.md)。
|
||||
|
||||
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 输出获取详细错误信息
|
||||
275
docs/features/status-line.mdx
Normal file
275
docs/features/status-line.mdx
Normal file
@@ -0,0 +1,275 @@
|
||||
---
|
||||
title: "StatusLine 底部状态栏 - 自定义 shell 渲染管线"
|
||||
description: "从源码角度解析 Claude Code 底部状态栏:自定义 shell 脚本 + JSON stdin 协议、三种触发源(event / settings / time)、debounce + abort、信任与 hook 开关、以及本仓库 refreshInterval 缺失修复。"
|
||||
keywords: ["statusLine", "状态栏", "自定义提示符", "refreshInterval", "Hooks"]
|
||||
---
|
||||
|
||||
{/* 本章目标:完整讲清 StatusLine 的渲染管线、触发模型、协议契约与安全网关,并记录本仓库相对官方版本的已知缺口与修复 */}
|
||||
|
||||
## 概述
|
||||
|
||||
StatusLine 是 Claude Code REPL 底部显示的一行自定义文本,由**用户提供的 shell 命令**渲染。主进程把运行时状态(模型、工作目录、token、限流、会话元数据等)打包成 JSON 通过 stdin 喂给脚本,脚本在 stdout 输出一行字符串,Ink 侧以 ANSI 转义渲染到 footer。
|
||||
|
||||
核心设计哲学:**语言无关 + 进程隔离 + Unix 管道**。用户可用 bash / python / node / 任意语言写脚本;脚本崩溃不影响主进程;输入输出都是纯文本,可以离线测试(`echo '{...}' | ./script.sh`)。
|
||||
|
||||
## 配置
|
||||
|
||||
`~/.claude/settings.json` 里添加 `statusLine` 字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"statusLine": {
|
||||
"type": "command",
|
||||
"command": "bash ~/.claude/statusline-command.sh",
|
||||
"refreshInterval": 1,
|
||||
"padding": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 作用 |
|
||||
|------|------|------|
|
||||
| `type` | `"command"` | 目前仅支持 command 型 |
|
||||
| `command` | `string` | shell 命令字符串;主进程用系统 shell 解释执行 |
|
||||
| `refreshInterval` | `number` (秒) | 定时刷新周期;缺省/0 表示不定时刷新 |
|
||||
| `padding` | `number` | 左右 padding,单位为 Ink cell |
|
||||
|
||||
Schema 定义在 `src/utils/settings/types.ts:550`(`statusLine` Zod object)。
|
||||
|
||||
## 渲染管线(整体图)
|
||||
|
||||
```
|
||||
┌─────────────────────── Ink 侧 ───────────────────────┐ ┌──────── 用户侧 ────────┐
|
||||
│ │ │ │
|
||||
│ buildStatusLineCommandInput() ──┐ │ │ ~/.claude/ │
|
||||
│ 收集运行时状态 │ │ │ statusline-*.sh │
|
||||
│ ▼ │ │ │
|
||||
│ executeStatusLineCommand() ─── JSON via stdin ────────────► jq '.model...' │
|
||||
│ execCommandHook() 拉起 shell │ │ 计算、格式化 │
|
||||
│ ▲ │ │ │
|
||||
│ stdout ◄──────────────────── 一行文本 ──────────────── printf '...' │
|
||||
│ │ │ │ │
|
||||
│ setAppState({ statusLineText }) ─┘ │ └────────────────────────┘
|
||||
│ zustand 存字段,组件 memo 订阅 │
|
||||
│ │
|
||||
│ <StatusLine /> → <Text><Ansi>{text}</Ansi></Text> │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Input 协议:主进程 → 脚本
|
||||
|
||||
`buildStatusLineCommandInput`(`src/components/StatusLine.tsx:53`)构造的 JSON 对象字段如下,**这是脚本可以 `jq` 读取的全部内容**:
|
||||
|
||||
| 字段 | 来源 | 备注 |
|
||||
|------|------|------|
|
||||
| `session_id` | `getSessionId()` | UUID,用于脚本侧 per-session 状态隔离 |
|
||||
| `session_name` | `getCurrentSessionTitle(sessionId)` | 用户命名的会话标题(可选) |
|
||||
| `model.id` / `model.display_name` | `getRuntimeMainLoopModel()` | 运行时真实模型(经 permission mode 降级/200k 升级) |
|
||||
| `workspace.current_dir` / `project_dir` / `added_dirs` | `getCwd()` / `getOriginalCwd()` / permission context | current_dir 随 `cd` 变化 |
|
||||
| `version` | `MACRO.VERSION` | 构建注入,如 `2.1.888` |
|
||||
| `output_style.name` | `settings.outputStyle` | 缺省 `DEFAULT_OUTPUT_STYLE_NAME` |
|
||||
| `cost.total_cost_usd` / `total_duration_ms` / `total_api_duration_ms` / `total_lines_added` / `total_lines_removed` | `cost-tracker.js` 聚合 | 会话累计 |
|
||||
| `context_window.total_input_tokens` / `total_output_tokens` | 同上 | 累计 token |
|
||||
| `context_window.context_window_size` | `getContextWindowForModel()` | 模型上下文上限 |
|
||||
| `context_window.current_usage` | `getCurrentUsage(messages)` | **最新一次 assistant message 的 usage**;含 `input_tokens` / `cache_creation_input_tokens` / `cache_read_input_tokens` / `output_tokens` |
|
||||
| `context_window.used_percentage` / `remaining_percentage` | `calculateContextPercentages()` | 0-100 浮点 |
|
||||
| `exceeds_200k_tokens` | 检查最近 assistant message | 用于 1M 上下文模型的展示 |
|
||||
| `rate_limits.five_hour` / `seven_day` | `getRawUtilization()` | `{ used_percentage, resets_at }`,来自 Claude.ai 限流 API |
|
||||
| `vim.mode` | 启用 vim 模式时 | `INSERT` / `NORMAL` / ... |
|
||||
| `agent.name` | 主线程 agent 类型 | 子 agent fork 时非空 |
|
||||
| `remote.session_id` | Bridge / Remote Control 模式 | 远程会话 |
|
||||
| `worktree` | 当前 worktree 元信息 | `name` / `path` / `branch` / `original_cwd` / `original_branch` |
|
||||
|
||||
类型签名目前在 `src/types/statusLine.ts` 是 `any` 的 stub(反编译残留),实际字段以上表为准。
|
||||
|
||||
## Output 协议:脚本 → 主进程
|
||||
|
||||
`executeStatusLineCommand`(`src/utils/hooks.ts:4752`)对脚本 stdout 做如下处理:
|
||||
|
||||
1. `trim()` 首尾空白
|
||||
2. 按 `\n` 拆行,每行再 `trim()`
|
||||
3. 空行丢弃,剩余用 `\n` 重新拼接
|
||||
|
||||
多行输出会被**保留为多行**(Ink 渲染时 `<Text>` 允许换行),但设计推荐**单行**——多行会挤占 REPL 高度,fullscreen 模式下可能挤掉 ScrollBox 行。
|
||||
|
||||
状态码约定:
|
||||
- `exit 0` + 有 stdout → 显示
|
||||
- `exit 0` + 空 stdout → 清空 statusLine(显示为空)
|
||||
- 非 0 → 忽略,保留上次内容;`logResult=true` 时 warn 级日志
|
||||
- 超时(默认 5000ms) → 忽略
|
||||
- 被 AbortController 取消 → 忽略
|
||||
|
||||
ANSI 颜色可用,Ink 通过 `<Ansi>{text}</Ansi>` 组件解析 SGR 序列。
|
||||
|
||||
## 三种触发源
|
||||
|
||||
StatusLine 的重算由**三类事件**驱动,全部经同一个 debounce 队列:
|
||||
|
||||
### 1. Event-driven(`src/components/StatusLine.tsx:275`)
|
||||
|
||||
监听这些状态变化,触发 `scheduleUpdate()`:
|
||||
|
||||
- `lastAssistantMessageId` — 新助手回复出现
|
||||
- `permissionMode` — `/mode` 切换权限模式
|
||||
- `vimMode` — vim insert/normal 切换
|
||||
- `mainLoopModel` — `/model` 切换
|
||||
|
||||
### 2. Settings-driven(`src/components/StatusLine.tsx:294`)
|
||||
|
||||
`settings.statusLine.command` 字符串变化时(热重载 settings.json),标记下一次结果 log 并立即 `doUpdate()`。
|
||||
|
||||
### 3. Time-driven(`src/components/StatusLine.tsx:292`,本仓库补丁)
|
||||
|
||||
读取 `settings.statusLine.refreshInterval`(秒),`setInterval` 每到点走一次 `scheduleUpdate()`。配置为 0 或缺省时不启定时器(零开销)。
|
||||
|
||||
> **本仓库历史缺口**:反编译出的 `StatusLine.tsx` 最初没有 Time-driven 触发路径,`refreshInterval` 字段也不在 Zod schema 里。导致脚本里 TTL 倒计时、时钟类动态内容不会秒刷,只有助手回复出现时才重算。已在 2026-05-06 补齐,细节见下方"已知缺口与修复"。
|
||||
|
||||
## Debounce + Abort
|
||||
|
||||
三种触发源都走 `scheduleUpdate`(`src/components/StatusLine.tsx:259`):
|
||||
|
||||
```
|
||||
scheduleUpdate() → setTimeout(300ms) → doUpdate()
|
||||
│
|
||||
└─ 再次 schedule 会 clearTimeout 前次
|
||||
```
|
||||
|
||||
300ms debounce 合并抖动事件(例如短时间连续切 vim/permission)。
|
||||
|
||||
`doUpdate()` 里:
|
||||
|
||||
```
|
||||
abortControllerRef.current?.abort() // 取消上一次 in-flight shell
|
||||
controller = new AbortController()
|
||||
executeStatusLineCommand(..., controller.signal, ...)
|
||||
```
|
||||
|
||||
**单飞(single-flight)语义**:任何新触发都会 abort 上一次未完成的 shell 调用,保证同一时刻最多一个子进程。这对 `refreshInterval: 1` 尤其关键——若脚本执行 > 1 秒,新 tick 到来时老进程被 kill,不会堆积。
|
||||
|
||||
## 安全网关
|
||||
|
||||
`executeStatusLineCommand`(`src/utils/hooks.ts:4752`)在执行前有**三层拦截**:
|
||||
|
||||
1. `shouldDisableAllHooksIncludingManaged()` → managed settings 全局禁用 hooks 时直接返回
|
||||
2. `shouldSkipHookDueToTrust()` → **工作区未接受信任对话框时跳过**,避免打开未知仓库时执行任意 shell 命令(RCE 防护)
|
||||
3. `shouldAllowManagedHooksOnly()` → 非 managed settings 禁用 hooks 但 managed 未禁用时,只读取 policySettings 源的 statusLine
|
||||
|
||||
组件侧配合(`src/components/StatusLine.tsx:318`):未接受 trust 时在通知中心提示 `"statusline skipped · restart to fix"`。
|
||||
|
||||
另外,`statusLineShouldDisplay`(`src/components/StatusLine.tsx:46`)在 **Kairos assistant mode** 下直接返回 false——因为那时 statusline 字段反映的是 REPL/daemon 进程状态,不是 agent 子进程在跑的东西,显示出来会误导用户。
|
||||
|
||||
## 渲染细节
|
||||
|
||||
### memo 隔离
|
||||
|
||||
```tsx
|
||||
export const StatusLine = memo(StatusLineInner)
|
||||
```
|
||||
|
||||
父组件 `PromptInputFooter` 每次 `setMessages` 都 rerender,但 `StatusLine` 的 props 只有 `lastAssistantMessageId` 会变,`memo` 阻断了无意义的重渲染。此前(未 memo 版本)一个 session 内大约 18 次冗余渲染。
|
||||
|
||||
### 订阅粒度
|
||||
|
||||
```tsx
|
||||
const statusLineText = useAppState(s => s.statusLineText)
|
||||
```
|
||||
|
||||
`useAppState` 是选择器订阅,仅在 `statusLineText` 字段变化时触发 rerender;`doUpdate()` 里还做了幂等检查(`prev.statusLineText === text` 则直接返回原 state),**文本不变就不更新 zustand**,连一次 notify 都省掉。
|
||||
|
||||
### Fullscreen 占位
|
||||
|
||||
```tsx
|
||||
{statusLineText ? (
|
||||
<Text dimColor wrap="truncate"><Ansi>{statusLineText}</Ansi></Text>
|
||||
) : isFullscreenEnvEnabled() ? (
|
||||
<Text> </Text> // 占位一行
|
||||
) : null}
|
||||
```
|
||||
|
||||
Fullscreen 模式下 footer `flexShrink:0`,statusline 从 0 行变 1 行会挤掉 ScrollBox 一行内容导致抖动。首次脚本还没返回时,用空格文本占住一行高度,脚本返回后原位替换。
|
||||
|
||||
## 内置 `/statusline` slash command
|
||||
|
||||
`src/commands/statusline.tsx` 定义了一个 **prompt 型 command**,展开成自然语言指令喂给主 Agent:
|
||||
|
||||
```
|
||||
Create an AgentTool with subagent_type "statusline-setup" and the prompt "<user-args>"
|
||||
```
|
||||
|
||||
默认 prompt 是 `"Configure my statusLine from my shell PS1 configuration"`。主 Agent 收到后会调用内置子 agent `statusline-setup`。该子 agent 权限极小:
|
||||
|
||||
- **Tools**: 仅 `Read`、`Edit`
|
||||
- **Allowed paths**: `Read(~/**)`、`Edit(~/.claude/settings.json)`
|
||||
|
||||
也就是说它**不能 Write 新文件、不能跑 Bash**。典型工作是读用户的 shell 配置、读/改 `settings.json`、增量编辑已有的 statusline 脚本。
|
||||
|
||||
## 编写自定义脚本的要点
|
||||
|
||||
1. **脚本必须无状态** — 每次 tick 主进程 fork 一次新 shell,进程内变量不跨调用保留。需要跨 tick 的状态(上次时间戳、上次 token 数)用 `~/.claude/statusline-state/<hash>.state` 文件持久化。
|
||||
2. **按 `session_id` 哈希隔离状态文件** — 多会话同时开着时共享一个 state 文件会串。典型做法:`md5(session_id) | head -c 16` 作为文件名。
|
||||
3. **防御性读取** — state 文件可能损坏/被截断,按行 read + 字段校验(数字字段用 `case "$var" in ''|*[!0-9]*) invalid ;;`)。
|
||||
4. **`refreshInterval` 不等于"脚本秒级调用"** — tick 和事件触发(新消息、模式切换)都走同一 debounce 队列,脚本实际被调用的频率介于"每 N 秒"和"每 N+0.3 秒"之间;且 abort 机制下,上一次没跑完会被 kill。
|
||||
5. **执行时间预算** — 默认 5000ms 超时;为避免 `refreshInterval=1` 时频繁超时,脚本热路径应在 100ms 内完成。重计算(curl、git log 拉取)需缓存。
|
||||
6. **颜色用 ANSI 转义** — 不要依赖 TERM 环境变量;Ink 的 `<Ansi>` 组件独立解析 SGR。
|
||||
7. **不要输出多行** — 单行文本,否则挤占 REPL 布局。
|
||||
8. **处理 `current_usage` 为 null 的情况** — 首次响应之前 `context_window.current_usage` 可能为 null,脚本应有 fallback(如读 state 里上次命中率)。
|
||||
|
||||
### 示例:Cache 命中率 + TTL 倒计时
|
||||
|
||||
本仓库默认安装了一个示例脚本 `~/.claude/statusline-command.sh`(用户侧),输出格式 `<dir> | <model> | ctx:N% | Cache 97% 59:43`:
|
||||
|
||||
- **命中率** = `cache_read / (input + cache_creation + cache_read)`(取自 `current_usage`)
|
||||
- **TTL** 从上次响应倒数 60 分钟,**只在 token signature 变化时重置时间戳**,避免秒级 tick 把 TTL 一直锁在 60:00
|
||||
- **颜色分段** — 命中率 ≥50% 绿 / <50% 灰;TTL 0-20m 绿 / 20-40m 黄 / 40-55m 红 / 最后 5m 闪红 / 过期 `exp` 灰
|
||||
- **Per-session state** — `~/.claude/statusline-state/<md5(session_id)[:16]>.state` 三行(signature、timestamp、hit),读前做 numeric 校验
|
||||
- **Fallback** — `current_usage` 为 null 时读 state 显示上次命中率
|
||||
|
||||
> 该脚本配合 `refreshInterval: 1` 即可秒刷 TTL,前提是 `refreshInterval` 触发路径已实现(见下节)。
|
||||
|
||||
## 已知缺口与修复(本仓库)
|
||||
|
||||
反编译版的 `StatusLine.tsx` 存在一处功能缺口:
|
||||
|
||||
| 项 | 官方 Claude Code | 本仓库原始 | 本仓库现状 |
|
||||
|----|-----------------|-----------|-----------|
|
||||
| `refreshInterval` Zod 字段 | ✅ 有 | ❌ 无 | ✅ 已补 |
|
||||
| Time-driven `setInterval` 触发 | ✅ 有 | ❌ 无 | ✅ 已补 |
|
||||
| Event-driven 触发 | ✅ 有 | ✅ 有 | — |
|
||||
| Settings-driven 触发 | ✅ 有 | ✅ 有 | — |
|
||||
| Debounce + Abort | ✅ 有 | ✅ 有 | — |
|
||||
| Trust 网关 | ✅ 有 | ✅ 有 | — |
|
||||
|
||||
修复(2026-05-06):
|
||||
|
||||
**1. `src/utils/settings/types.ts:554`** — statusLine schema 新增 `refreshInterval: z.number().optional()`,让字段进入类型系统而非被当未知键忽略。
|
||||
|
||||
**2. `src/components/StatusLine.tsx:292`** — 新增 Time-driven useEffect:
|
||||
|
||||
```tsx
|
||||
const refreshIntervalMs = (settings?.statusLine?.refreshInterval ?? 0) * 1000;
|
||||
useEffect(() => {
|
||||
if (refreshIntervalMs <= 0) return;
|
||||
const id = setInterval(() => scheduleUpdate(), refreshIntervalMs);
|
||||
return () => clearInterval(id);
|
||||
}, [refreshIntervalMs, scheduleUpdate]);
|
||||
```
|
||||
|
||||
关键点:
|
||||
- 走 `scheduleUpdate`(非 `doUpdate`)复用 300ms debounce,interval + event 双触发不会双跑
|
||||
- `refreshIntervalMs <= 0` 时不启定时器,对未启用该字段的用户零开销
|
||||
- 依赖数组含 `refreshIntervalMs`,settings 热重载会自动清理旧 interval 重建新的
|
||||
|
||||
**静默失效特征**:修复前 settings.json 写 `refreshInterval: 1` 无任何报错——JSON 解析通过,Zod schema 默认 strip 多余字段,官方文档又说支持这个字段,用户很容易以为生效了而没意识到 TTL/时钟类输出根本没秒刷。这是反编译版本的典型"文档与实现不一致"。
|
||||
|
||||
## 相关源码
|
||||
|
||||
| 文件 | 作用 |
|
||||
|------|------|
|
||||
| `src/components/StatusLine.tsx` | UI 组件、触发逻辑、buildStatusLineCommandInput |
|
||||
| `src/utils/hooks.ts:4752` | `executeStatusLineCommand`:shell 执行、输出处理、安全网关 |
|
||||
| `src/utils/settings/types.ts:550` | `statusLine` Zod schema |
|
||||
| `src/types/statusLine.ts` | `StatusLineCommandInput` 类型(当前为 stub) |
|
||||
| `src/commands/statusline.tsx` | `/statusline` slash command 定义 |
|
||||
| `src/state/AppStateStore.ts:95` | `statusLineText` 字段声明 |
|
||||
| `src/components/PromptInput/PromptInputFooter.tsx:159` | StatusLine 组件挂载点 |
|
||||
@@ -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` 类型定义 |
|
||||
|
||||
564
docs/internals/agent-comm-fix-jira-tasks.md
Normal file
564
docs/internals/agent-comm-fix-jira-tasks.md
Normal file
@@ -0,0 +1,564 @@
|
||||
# Agent 通讯修复 Jira Task
|
||||
|
||||
- 版本:v1.0
|
||||
- 生成日期:2026-04-25
|
||||
- 来源:由按文件执行清单、Claude 交叉验证意见整理合并
|
||||
- 范围:ACP Agent / Bridge / Remote Control Server / REPL Hook 生命周期
|
||||
- 使用方式:这是唯一执行任务文档;每个 `JIRA-*` 小节可直接拆成一个 Jira issue,字段保持统一,便于复制或二次导入。
|
||||
|
||||
---
|
||||
|
||||
## 方案性质
|
||||
|
||||
本文档是目标状态式执行方案,不是临时补丁清单。每张 ticket 必须交付明确的代码终态、测试覆盖和回归边界;不得只用局部 workaround 掩盖问题。
|
||||
|
||||
---
|
||||
|
||||
## 执行总则
|
||||
|
||||
1. 先边界安全,后内部优化:先修 WS 入站大小与输入校验,避免线上风险扩大。
|
||||
2. 单文件可回滚:每个文件内修改保持内聚,便于回滚与 bisect。
|
||||
3. 不改协议语义,只修实现缺陷:除 `resource_link` 表达形式统一外,不改变主流程契约。
|
||||
4. 每个文件必须有验收输出:要么测试用例,要么日志/指标验证。
|
||||
5. 发布前必须确认协议层行为无回归:`stopReason` 决策与 `sessionUpdate` 发送顺序保持稳定。
|
||||
|
||||
---
|
||||
|
||||
## Epic
|
||||
|
||||
### JIRA-EPIC-001:提升 Agent 通讯链路稳定性与边界安全
|
||||
|
||||
- Issue Type:Epic
|
||||
- Priority:P0
|
||||
- Owner:核心通讯 / 后端网关 / QA
|
||||
- Scope:ACP Agent、ACP Bridge、Remote Control Server、REPL 初始化生命周期
|
||||
- Goal:修复长会话资源泄漏、补齐 WebSocket 入站边界、统一 prompt 转换、收敛类型风险,并补充关键回归测试。
|
||||
|
||||
#### Epic 验收标准
|
||||
|
||||
- `bun run typecheck` 0 error。
|
||||
- P0 WebSocket 超大消息拒绝逻辑已实现并覆盖测试。
|
||||
- ACP bridge abort listener 生命周期无累积。
|
||||
- prompt 转换实现单源化。
|
||||
- settings/defaultMode 能真实影响 ACP permission mode,且 `_meta.permissionMode` 保持最高优先级。
|
||||
- REPL 目标 hook suppress 清理完成,timer cleanup 完整。
|
||||
|
||||
---
|
||||
|
||||
## P0 Tickets
|
||||
|
||||
### JIRA-001:为 session ingress WebSocket 补齐消息大小限制
|
||||
|
||||
- Issue Type:Bug
|
||||
- Priority:P0
|
||||
- Story Points:3
|
||||
- Owner:后端/网关
|
||||
- Files:
|
||||
- `packages/remote-control-server/src/routes/v1/session-ingress.ts`
|
||||
- 后续票:JIRA-008(同文件 P1 类型与 decode path 收尾)
|
||||
|
||||
#### 参考代码位置
|
||||
|
||||
- `packages/remote-control-server/src/routes/v1/session-ingress.ts:100-106`
|
||||
|
||||
#### 背景
|
||||
|
||||
`session-ingress` 当前缺少 WebSocket message size limit。ACP 路由已有类似限制,两个入口边界不一致,可能导致大包占用内存或绕过入口保护。
|
||||
|
||||
#### 实施要求
|
||||
|
||||
- 新增 `MAX_WS_MESSAGE_SIZE = 10 * 1024 * 1024`,与 ACP 路由的 10MB 上限保持一致。
|
||||
- 在 `onMessage` decode 后优先检查 payload size。
|
||||
- 超限时执行 `ws.close(1009, "message too large")`。
|
||||
- 日志记录 `sessionId`、payload size、limit。
|
||||
- 对 `string`、`ArrayBuffer`、`Uint8Array` 进行统一 decode 分流。
|
||||
- 非支持类型直接拒绝并记录,不进入业务 handler。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
- 11MB payload 被 1009 close。
|
||||
- 1KB 合法 payload 仍正常进入 handler。
|
||||
- 非支持类型 payload 不进入 handler。
|
||||
- 不改变 URL、auth、session 解析逻辑。
|
||||
|
||||
#### 回归范围
|
||||
|
||||
- Remote Control Server session ingress WebSocket。
|
||||
- 正常会话消息转发。
|
||||
- WebSocket close code 行为。
|
||||
|
||||
#### 风险等级
|
||||
|
||||
- 中。入口逻辑变更可能影响特殊客户端 payload 类型。
|
||||
|
||||
#### 必须验证
|
||||
|
||||
- 在 `packages/remote-control-server/src/__tests__/routes.test.ts` 增加 session-ingress WebSocket 大包、小包、坏类型 payload 用例。
|
||||
- 运行 `bun run typecheck`。
|
||||
|
||||
---
|
||||
|
||||
### JIRA-002:修复 ACP bridge abort listener 生命周期泄漏
|
||||
|
||||
- Issue Type:Bug
|
||||
- Priority:P0
|
||||
- Story Points:3
|
||||
- Owner:核心通讯
|
||||
- Files:
|
||||
- `src/services/acp/bridge.ts`
|
||||
|
||||
#### 参考代码位置
|
||||
|
||||
- `src/services/acp/bridge.ts:576-585`
|
||||
|
||||
#### 背景
|
||||
|
||||
ACP bridge 的 `Promise.race` abort 分支注册 listener 后缺少完整 cleanup。长会话或高频 next 场景可能出现 listener 累积。
|
||||
|
||||
#### 实施要求
|
||||
|
||||
- 将 abort race 改为可清理监听器写法。
|
||||
- 注册 listener 后保留 handler 引用。
|
||||
- `sdkMessages.next()` 先返回时必须 `removeEventListener`。
|
||||
- abort、throw、return 等路径都在 `finally` 中清理。
|
||||
- 不改变 `stopReason` 决策逻辑。
|
||||
- 不改变 `sessionUpdate` 发送顺序。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
- 模拟 10k 次 next 且不 abort,listener 不增长。
|
||||
- abort 场景仍返回 `cancelled`。
|
||||
- 原有 streaming/session update 行为无回归。
|
||||
|
||||
#### 回归范围
|
||||
|
||||
- ACP bridge streaming loop。
|
||||
- 用户取消请求。
|
||||
- SDK generator 异常路径。
|
||||
|
||||
#### 风险等级
|
||||
|
||||
- 中。异步控制流变更需要覆盖取消与异常路径。
|
||||
|
||||
#### 必须验证
|
||||
|
||||
- 新增 listener cleanup 单元测试。
|
||||
- 运行 `bun run typecheck`。
|
||||
|
||||
---
|
||||
|
||||
## P1 Tickets
|
||||
|
||||
### JIRA-003:优化 ACP agent pending prompt 队列为 O(1) 出队
|
||||
|
||||
- Issue Type:Task
|
||||
- Priority:P1
|
||||
- Story Points:5
|
||||
- Owner:核心通讯
|
||||
- Files:
|
||||
- `src/services/acp/agent.ts`
|
||||
|
||||
#### 参考代码位置
|
||||
|
||||
- `src/services/acp/agent.ts:332-339`
|
||||
|
||||
#### 背景
|
||||
|
||||
当前 pending prompt 队列使用 `Map + sort` 获取下一项,排队量上升时会带来不必要的排序成本。
|
||||
|
||||
#### 实施要求
|
||||
|
||||
- 改为 `queue: string[]` + `pendingMap: Map<string, PendingPrompt>` 组合。
|
||||
- 入队执行 `queue.push(id)` 与 `pendingMap.set(id, prompt)`。
|
||||
- 出队从队首惰性跳过已取消项。
|
||||
- 取消只从 `pendingMap` 删除,不做数组中间删除。
|
||||
- 保持现有取消语义和出队顺序。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
- 1000 pending prompt 场景下出队顺序正确。
|
||||
- 已取消 prompt 不会被 resolve。
|
||||
- 出队不再依赖全量 sort。
|
||||
- 1000 排队场景下出队耗时低于旧实现;测试记录旧实现复杂度风险和新实现 O(1) 出队路径。
|
||||
- 行为与旧实现兼容。
|
||||
|
||||
#### 回归范围
|
||||
|
||||
- ACP prompt queue。
|
||||
- 并发 prompt 请求。
|
||||
- prompt cancel / resolve 边界。
|
||||
|
||||
#### 风险等级
|
||||
|
||||
- 中。队列结构变更可能引入取消边界问题。
|
||||
|
||||
#### 必须验证
|
||||
|
||||
- 新增 queue 顺序与取消测试。
|
||||
- 对 1000 prompt 场景做性能断言或日志记录。
|
||||
|
||||
---
|
||||
|
||||
### JIRA-004:接入真实 settings 读取并校验 ACP permission mode
|
||||
|
||||
- Issue Type:Bug
|
||||
- Priority:P1
|
||||
- Story Points:3
|
||||
- Owner:核心通讯
|
||||
- Files:
|
||||
- `src/services/acp/agent.ts`
|
||||
|
||||
#### 参考代码位置
|
||||
|
||||
- `src/services/acp/agent.ts:465-467`
|
||||
|
||||
#### 背景
|
||||
|
||||
`getSetting()` 当前未真正接入项目配置,导致默认 permission mode 配置无法按预期生效。
|
||||
|
||||
#### 实施要求
|
||||
|
||||
- 接入项目现有 settings/config 读取逻辑。
|
||||
- 仅接受合法 permission mode 枚举值。
|
||||
- 非法值 fallback 到 `default`。
|
||||
- `_meta.permissionMode` 继续保持最高优先级。
|
||||
- 不改变外部协议字段。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
- settings/defaultMode 能影响默认 permission mode。
|
||||
- `_meta.permissionMode` 能覆盖 settings。
|
||||
- 非法 settings 值不会传播到运行时。
|
||||
- 类型检查通过。
|
||||
|
||||
#### 回归范围
|
||||
|
||||
- ACP agent session 初始化。
|
||||
- 权限模式同步。
|
||||
- 客户端 `_meta` 覆盖逻辑。
|
||||
|
||||
#### 风险等级
|
||||
|
||||
- 中。配置优先级错误会影响权限行为。
|
||||
|
||||
#### 必须验证
|
||||
|
||||
- 新增 defaultMode / `_meta.permissionMode` 优先级测试。
|
||||
- 运行 `bun run typecheck`。
|
||||
|
||||
---
|
||||
|
||||
### JIRA-005:单源化 ACP prompt 转换逻辑
|
||||
|
||||
- Issue Type:Refactor
|
||||
- Priority:P1
|
||||
- Story Points:5
|
||||
- Owner:核心通讯
|
||||
- Files:
|
||||
- `src/services/acp/agent.ts`
|
||||
- `src/services/acp/bridge.ts`
|
||||
- `src/services/acp/promptConversion.ts`(新增)
|
||||
|
||||
#### 参考代码位置
|
||||
|
||||
- `src/services/acp/agent.ts:754-758`
|
||||
- `src/services/acp/agent.ts:764-785`
|
||||
- `src/services/acp/bridge.ts:522-537`
|
||||
|
||||
#### 背景
|
||||
|
||||
ACP agent 与 bridge 存在重复 prompt 转换逻辑,`resource_link` 等 block 的输出策略容易分叉。
|
||||
|
||||
#### 实施要求
|
||||
|
||||
- 新增共享转换模块 `src/services/acp/promptConversion.ts`。
|
||||
- `agent.ts` 与 `bridge.ts` 改为调用共享转换函数。
|
||||
- 删除 `bridge.ts` 中 `promptToQueryContent` 的真实实现;如导出仍需保留,则只允许保留调用共享函数的 wrapper。
|
||||
- `resource_link` 输出改为稳定纯文本元信息,禁止 markdown link。
|
||||
- 保持其他 block 转换语义不变。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
- 全仓库仅保留一个真实 prompt 转换实现。
|
||||
- 相同 input block 在 agent/bridge 输出一致。
|
||||
- `resource_link` 不再输出 `[name](uri)` 形式。
|
||||
- 相关测试覆盖转换一致性。
|
||||
|
||||
#### 回归范围
|
||||
|
||||
- ACP prompt input。
|
||||
- bridge query content。
|
||||
- resource link prompt 表达。
|
||||
|
||||
#### 风险等级
|
||||
|
||||
- 中。文本格式变化可能影响下游 prompt 快照或断言。
|
||||
|
||||
#### 必须验证
|
||||
|
||||
- 新增 shared conversion 单元测试。
|
||||
- 全仓库搜索重复转换函数。
|
||||
- 运行 `bun run typecheck`。
|
||||
|
||||
---
|
||||
|
||||
### JIRA-006:治理 REPL onInit effect 依赖并补齐 timer cleanup
|
||||
|
||||
- Issue Type:Task
|
||||
- Priority:P1
|
||||
- Story Points:3
|
||||
- Owner:终端 UI
|
||||
- Files:
|
||||
- `src/screens/REPL.tsx`
|
||||
|
||||
#### 参考代码位置
|
||||
|
||||
- `src/screens/REPL.tsx:654-662`
|
||||
- `src/screens/REPL.tsx:4996-5005`
|
||||
|
||||
#### 背景
|
||||
|
||||
REPL 中目标初始化 effect 存在 hook dependency suppress,warm-up timer 也需要显式 cleanup,避免频繁挂载/卸载时留下悬挂任务。
|
||||
|
||||
#### 实施要求
|
||||
|
||||
- 整理 `onInit` 生命周期,使用稳定引用或 effect 内联。
|
||||
- 移除目标段 `exhaustive-deps` suppress。
|
||||
- 保持 unmount cleanup 行为不变。
|
||||
- warm-up effect 中记录 timeout id。
|
||||
- cleanup 中执行 `clearTimeout(timeoutId)`。
|
||||
- 保留 `alive` 判定作为并发保护。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
- 目标段不再需要 hooks lint suppress。
|
||||
- 高频打开/关闭搜索栏无悬挂 timer 增长。
|
||||
- REPL 初始化行为无回归。
|
||||
|
||||
#### 回归范围
|
||||
|
||||
- REPL 初始化。
|
||||
- 搜索栏 warm-up。
|
||||
- 组件卸载 cleanup。
|
||||
|
||||
#### 风险等级
|
||||
|
||||
- 中。React effect 依赖治理可能改变初始化时机。
|
||||
|
||||
#### 必须验证
|
||||
|
||||
- 运行 lint/typecheck。
|
||||
- 手动或测试覆盖 REPL mount/unmount。
|
||||
|
||||
---
|
||||
|
||||
### JIRA-007:收敛 ACP route WebSocket 事件 any 类型
|
||||
|
||||
- Issue Type:Task
|
||||
- Priority:P1
|
||||
- Story Points:2
|
||||
- Owner:后端/网关
|
||||
- Files:
|
||||
- `packages/remote-control-server/src/routes/acp/index.ts`
|
||||
|
||||
#### 参考代码位置
|
||||
|
||||
- `packages/remote-control-server/src/routes/acp/index.ts:108-146`
|
||||
|
||||
#### 背景
|
||||
|
||||
ACP route 中 WebSocket 事件和 socket 参数存在 `any`,降低编译期保护。
|
||||
|
||||
#### 实施要求
|
||||
|
||||
- 定义最小 WebSocket 事件类型:open/message/close/error。
|
||||
- 将 `_evt: any`、`evt: any`、`ws: any` 替换为窄类型。
|
||||
- 不改变 payload decode 与大小检查策略。
|
||||
- 不改变现有 handler 行为。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
- 编译期能捕获错误事件字段访问。
|
||||
- 现有 WebSocket 行为不变。
|
||||
- `bun run typecheck` 通过。
|
||||
|
||||
#### 回归范围
|
||||
|
||||
- ACP WebSocket route。
|
||||
- message decode。
|
||||
- close/error handler。
|
||||
|
||||
#### 风险等级
|
||||
|
||||
- 低。类型收敛为主。
|
||||
|
||||
#### 必须验证
|
||||
|
||||
- 运行 `bun run typecheck`。
|
||||
- 保留现有测试通过。
|
||||
|
||||
---
|
||||
|
||||
### JIRA-008:收敛 session ingress WebSocket 事件类型与 decode path
|
||||
|
||||
- Issue Type:Task
|
||||
- Priority:P1
|
||||
- Story Points:3
|
||||
- Owner:后端/网关
|
||||
- Files:
|
||||
- `packages/remote-control-server/src/routes/v1/session-ingress.ts`
|
||||
- 前置依赖:JIRA-001 已合并
|
||||
|
||||
#### 参考代码位置
|
||||
|
||||
- `packages/remote-control-server/src/routes/v1/session-ingress.ts:100-106`
|
||||
|
||||
#### 背景
|
||||
|
||||
在完成 P0 size guard 后,session ingress 仍需要进一步收敛事件类型与 decode path,减少隐式类型风险。
|
||||
|
||||
#### 实施要求
|
||||
|
||||
- 定义或复用最小 WebSocket message event 类型。
|
||||
- 将 message decode 分支集中到一个小函数。
|
||||
- 保持 P0 size guard 与 close code 语义。
|
||||
- 不改变 auth/session 解析。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
- decode path 单一清晰。
|
||||
- 不支持 payload 类型有明确拒绝路径。
|
||||
- `bun run typecheck` 通过。
|
||||
|
||||
#### 回归范围
|
||||
|
||||
- Session ingress WebSocket message handling。
|
||||
- P0 大包拒绝逻辑。
|
||||
|
||||
#### 风险等级
|
||||
|
||||
- 低到中。与 P0 同文件,注意避免重复改动冲突。
|
||||
|
||||
#### 必须验证
|
||||
|
||||
- 与 JIRA-001 同批测试。
|
||||
- 运行 `bun run typecheck`。
|
||||
|
||||
---
|
||||
|
||||
## QA Tickets
|
||||
|
||||
### JIRA-009:补充 ACP 通讯回归测试
|
||||
|
||||
- Issue Type:Test
|
||||
- Priority:P1
|
||||
- Story Points:5
|
||||
- Owner:QA/核心通讯
|
||||
- Files:
|
||||
- `src/services/acp/agent.ts`
|
||||
- `src/services/acp/bridge.ts`
|
||||
- `src/services/acp/promptConversion.ts`
|
||||
- `src/services/acp/__tests__/agent.test.ts`
|
||||
- `src/services/acp/__tests__/bridge.test.ts`
|
||||
- `src/services/acp/__tests__/promptConversion.test.ts`
|
||||
|
||||
#### 覆盖场景
|
||||
|
||||
- 长会话 10k turn,无 abort listener 累积。
|
||||
- prompt queue 1000 并发排队,取消/出队顺序正确。
|
||||
- settings/defaultMode 与 `_meta.permissionMode` 优先级正确。
|
||||
- `resource_link` 转换在 agent 与 bridge 输出一致。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
- 新增测试在本地稳定通过。
|
||||
- 不依赖真实网络或外部服务。
|
||||
- 测试 mock 遵守仓库规范,只 mock 有副作用链路。
|
||||
|
||||
#### 回归范围
|
||||
|
||||
- ACP bridge。
|
||||
- ACP agent。
|
||||
- prompt conversion。
|
||||
- permission mode resolution。
|
||||
|
||||
#### 风险等级
|
||||
|
||||
- 中。异步测试可能有稳定性问题,需要避免时间敏感断言。
|
||||
|
||||
#### 必须验证
|
||||
|
||||
- 运行相关 `bun test`。
|
||||
- 运行 `bun run typecheck`。
|
||||
|
||||
---
|
||||
|
||||
### JIRA-010:补充 Remote Control Server WebSocket 入站回归测试
|
||||
|
||||
- Issue Type:Test
|
||||
- Priority:P1
|
||||
- Story Points:3
|
||||
- Owner:QA/后端
|
||||
- Files:
|
||||
- `packages/remote-control-server/src/__tests__/routes.test.ts`
|
||||
- `packages/remote-control-server/src/routes/v1/session-ingress.ts`
|
||||
|
||||
#### 覆盖场景
|
||||
|
||||
- 11MB session ingress payload 被 1009 close(与 10MB 上限对齐)。
|
||||
- 合法小 payload 正常进入 handler。
|
||||
- 非支持 payload 类型被拒绝。
|
||||
- 日志或可观测输出包含 sessionId、payload size、limit。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
- 11MB payload 被 1009 close(与 10MB 上限对齐)。
|
||||
- 新增测试稳定通过。
|
||||
- 不启动真实外部服务。
|
||||
- 不改变现有 route public contract。
|
||||
|
||||
#### 回归范围
|
||||
|
||||
- RCS session ingress route。
|
||||
- WebSocket message handling。
|
||||
- close code 行为。
|
||||
|
||||
#### 风险等级
|
||||
|
||||
- 中。测试需要适配现有 WebSocket/mock 基础设施。
|
||||
|
||||
#### 必须验证
|
||||
|
||||
- 运行 RCS package 相关测试。
|
||||
- 运行 `bun run typecheck`。
|
||||
|
||||
---
|
||||
|
||||
## 推荐执行顺序
|
||||
|
||||
执行节奏与原计划保持一致:先完成 P0 全部改动和冒烟验证,再启动 P1 改造;测试票可穿插执行,但不得绕过 P0 gate。
|
||||
|
||||
1. JIRA-001:先封入口大包风险。
|
||||
2. JIRA-002:修长会话 listener 生命周期。
|
||||
3. JIRA-010:补 RCS 入站测试,锁住 P0 行为。
|
||||
4. JIRA-003:优化 pending prompt queue。
|
||||
5. JIRA-004:接入 settings/defaultMode。
|
||||
6. JIRA-005:单源化 prompt 转换。
|
||||
7. JIRA-009:补 ACP 回归测试。
|
||||
8. JIRA-006:治理 REPL effect/timer。
|
||||
9. JIRA-007:收敛 ACP route 类型。
|
||||
10. JIRA-008:收敛 session ingress 类型与 decode path。
|
||||
|
||||
---
|
||||
|
||||
## Release Checklist
|
||||
|
||||
- [ ] `bun run typecheck` 0 error
|
||||
- [ ] P0 tickets 已合并并测试通过
|
||||
- [ ] ACP 回归测试通过
|
||||
- [ ] RCS WebSocket 入站测试通过
|
||||
- [ ] prompt conversion 单源化已通过代码搜索确认
|
||||
- [ ] permission mode 优先级测试通过
|
||||
- [ ] 协议层行为无回归(stopReason 决策、sessionUpdate 发送顺序)
|
||||
- [ ] REPL hook/timer 改动通过 lint/typecheck
|
||||
- [ ] 最终变更说明包含风险与未覆盖项
|
||||
74
docs/internals/agent-comm-fix-questions.md
Normal file
74
docs/internals/agent-comm-fix-questions.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Agent 通讯修复问题文档
|
||||
|
||||
- 版本:v1.0
|
||||
- 生成日期:2026-04-25
|
||||
- 范围:ACP Agent / Bridge / Remote Control Server / REPL Hook 生命周期
|
||||
- 配套执行文档:`docs/internals/agent-comm-fix-jira-tasks.md`
|
||||
- 目的:保留决策前要问的问题、交叉验证提示词和已确认结论;不要在这里写 Jira 执行步骤。
|
||||
|
||||
---
|
||||
|
||||
## 1. 当前已确认结论
|
||||
|
||||
- 只保留两份交付文档:本问题文档 + Jira Task 文档。
|
||||
- Jira Task 文档是唯一执行入口,包含 Owner、优先级、文件范围、验收标准、风险和验证建议。
|
||||
- Claude 交叉验证结论:整体通过,无 blocking findings;建议补充协议回归 gate、JIRA-001/008 依赖、代码参考位置和阈值一致性,这些建议已合并到 Jira Task 文档。
|
||||
- 本次已进入业务代码修复阶段,必须运行 `bun run typecheck` 和相关回归测试。
|
||||
|
||||
---
|
||||
|
||||
## 2. 执行前必须问清的问题
|
||||
|
||||
1. `session-ingress` 的 WebSocket 上限是否固定为 10MB,并与 ACP route 保持一致?
|
||||
2. 超限 close code 是否统一使用 `1009`,close reason 是否固定为 `message too large`?
|
||||
3. `resource_link` 的纯文本格式是否已有下游依赖,能否替代当前 markdown link 表达?
|
||||
4. ACP permission mode 的真实 settings key 是哪个,非法值 fallback 是否统一为 `default`?
|
||||
5. `_meta.permissionMode` 是否必须始终覆盖 settings/defaultMode?
|
||||
6. abort listener 测试中,是否能通过 mock signal 或计数器稳定证明 10k next 后无 listener 累积?
|
||||
7. pending prompt queue 的取消语义是否允许惰性清理,而不是立刻从数组中删除?
|
||||
8. REPL hook suppress 的清理范围是否只限目标段,不顺手改其他 decompiled React Compiler 结构?
|
||||
9. RCS WebSocket 测试应放在现有哪个 `__tests__` 布局下,是否已有 route/mock 基础设施可复用?
|
||||
10. 发布 gate 是否必须包含 `stopReason` 决策与 `sessionUpdate` 发送顺序不回归?
|
||||
|
||||
---
|
||||
|
||||
## 3. 给 Claude 或 Reviewer 的复核问题
|
||||
|
||||
```text
|
||||
请作为外部审查者,复核 docs/internals/agent-comm-fix-jira-tasks.md。
|
||||
|
||||
请检查:
|
||||
1. 是否仍满足“按文件分工的执行清单”和“Jira task 文档”要求。
|
||||
2. 是否存在遗漏的文件、验收标准、风险或前置依赖。
|
||||
3. 是否有重复、误导执行者、优先级不合理或测试不可落地的问题。
|
||||
4. 是否还有必须阻断实施的 finding。
|
||||
|
||||
请用中文输出:
|
||||
- Verdict
|
||||
- Blocking Findings
|
||||
- Non-blocking Findings
|
||||
- Suggested Edits
|
||||
- Final Recommendation
|
||||
|
||||
不要修改文件,只输出审查意见。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 已处理的复核建议
|
||||
|
||||
- Release Checklist 已补充协议层行为无回归 gate。
|
||||
- JIRA-001 与 JIRA-008 已明确同文件前后置关系。
|
||||
- JIRA-001 到 JIRA-008 已补充参考代码位置。
|
||||
- JIRA-003 已补回 1000 排队场景下的出队耗时验收。
|
||||
- JIRA-008 story points 已从 2 调整为 3。
|
||||
- JIRA-010 已明确 11MB payload 对齐 10MB 上限并触发 1009 close。
|
||||
- 推荐执行顺序已明确 P0 gate:P0 全部改动和冒烟验证完成后,再启动 P1 改造。
|
||||
|
||||
---
|
||||
|
||||
## 5. 不在本文档维护的内容
|
||||
|
||||
- 不维护 Jira ticket 正文;统一在 `docs/internals/agent-comm-fix-jira-tasks.md` 修改。
|
||||
- 不维护业务代码实现方案;实现时按具体 ticket 读取对应文件。
|
||||
- 不维护历史中间稿;旧执行清单已合并进 Jira Task 文档。
|
||||
@@ -1,31 +1,53 @@
|
||||
---
|
||||
title: "Ant 特权世界"
|
||||
description: "Anthropic 内部构建与公开发布版本的差异。理解身份门控机制、Ant-Only 工具/命令和 Beta Header 的分层设计。"
|
||||
keywords: ["Ant 特权", "USER_TYPE", "身份门控", "内部功能"]
|
||||
title: "Ant 特权世界 - Anthropic 员工专属功能"
|
||||
description: "完整记录 Claude Code 身份门控层:USER_TYPE === 'ant' 时解锁的专属工具、命令、API 和代号体系,揭示内外部构建的差异。"
|
||||
keywords: ["Ant 特权", "USER_TYPE", "身份门控", "内部功能", "Anthropic 员工"]
|
||||
---
|
||||
|
||||
{/* 本章目标:完整记录身份门控层——ant 构建独享的一切 */}
|
||||
|
||||
## 什么是 Ant
|
||||
|
||||
Claude Code 有两种构建:Anthropic 员工使用的内部构建(`USER_TYPE === 'ant'`)和公开发布的外部构建(`USER_TYPE === 'external'`)。
|
||||
`USER_TYPE` 是一个构建时常量,通过 Bun 打包器的 `--define` 注入。在 Anthropic 的内部构建中它被设为 `'ant'`,在公开发布的版本中是 `'external'`:
|
||||
|
||||
`USER_TYPE` 是构建时常量,通过 Bun 的 `--define` 注入。外部构建中,所有 `process.env.USER_TYPE === 'ant'` 判断被编译器折叠为 `false`,后续代码被死代码消除(DCE)移除。
|
||||
```typescript
|
||||
// 反编译版本(src/types/global.d.ts 第 63 行)
|
||||
// Build-time constants BUILD_TARGET/BUILD_ENV/INTERFACE_TYPE — removed (zero runtime usage)
|
||||
```
|
||||
|
||||
这个门控在代码库中出现 410+ 处,控制着工具、命令、API、UI 等方方面面。
|
||||
`BUILD_TARGET` 等构建时常量在反编译版本中已被移除。`USER_TYPE` 通过 Bun 的 `--define` 或环境变量注入,Bun 会进行**常量折叠**——所有 `process.env.USER_TYPE === 'ant'` 在外部构建中直接变为 `false`,后续代码被 DCE 移除。但在反编译版本中,这些代码保留完整。
|
||||
|
||||
`USER_TYPE === 'ant'` 在代码库中出现 **351+ 次**(跨 163 个文件),另有 `!== 'ant'` 59 次(跨 38 个文件),总计 **410+ 处引用**,控制着工具、命令、API、UI 等方方面面。
|
||||
|
||||
## Ant-Only 工具
|
||||
|
||||
| 工具 | 用途 |
|
||||
|------|------|
|
||||
| **REPLTool** | 高级 REPL 模式——在 VM 中包装其他工具 |
|
||||
| **ConfigTool** | 交互式配置编辑器,包含 Gates 标签页覆盖 feature flags |
|
||||
| **SuggestBackgroundPRTool** | 建议在后台创建 PR |
|
||||
| **TungstenTool** | 基于 tmux 的终端面板工具 |
|
||||
以下工具仅在内部构建中被加载到工具注册表:
|
||||
|
||||
**设计考量**:这些工具要么涉及内部基础设施(如 GrowthBook flag 覆盖),要么需要 Anthropic 特有的 API 支持。对外部用户暴露它们没有意义——甚至可能引起混淆。
|
||||
| 工具 | 代码位置 | 用途 |
|
||||
|------|---------|------|
|
||||
| **REPLTool** | `packages/builtin-tools/src/tools/REPLTool/` | 高级 REPL 模式——在 VM 中包装 Bash/Read/Edit/Glob/Grep/Agent 等工具 |
|
||||
| **SuggestBackgroundPRTool** | `packages/builtin-tools/src/tools/SuggestBackgroundPRTool/` | 建议在后台创建 PR |
|
||||
| **ConfigTool** | `packages/builtin-tools/src/tools/ConfigTool/` | 交互式配置编辑器,包含 Gates 标签页用于覆盖 GrowthBook flags |
|
||||
| **TungstenTool** | `packages/builtin-tools/src/tools/TungstenTool/` | 基于 tmux 的终端面板工具(反编译版中已 stub) |
|
||||
|
||||
```typescript
|
||||
// src/tools.ts 第 14-24 行——条件导入 + Dead Code Elimination 标记
|
||||
// Dead code elimination: conditional import for ant-only tools
|
||||
/* eslint-disable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */
|
||||
const REPLTool =
|
||||
process.env.USER_TYPE === 'ant'
|
||||
? require('@claude-code-best/builtin-tools/tools/REPLTool/REPLTool.js').REPLTool
|
||||
: null
|
||||
const SuggestBackgroundPRTool =
|
||||
process.env.USER_TYPE === 'ant'
|
||||
? require('@claude-code-best/builtin-tools/tools/SuggestBackgroundPRTool/SuggestBackgroundPRTool.js')
|
||||
.SuggestBackgroundPRTool
|
||||
: null
|
||||
```
|
||||
|
||||
## Ant-Only 命令
|
||||
|
||||
内部构建注册了 24+ 个额外的斜杠命令,覆盖调试、实验、工作流和基础设施:
|
||||
`src/commands.ts` 注册了 **24+** 个仅在内部构建中可用的斜杠命令(`INTERNAL_ONLY_COMMANDS`,lines 267-295),在 `USER_TYPE === 'ant' && !IS_DEMO` 时才加载(line 400-401):
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="调试类">
|
||||
@@ -34,6 +56,14 @@ Claude Code 有两种构建:Anthropic 员工使用的内部构建(`USER_TYPE
|
||||
- `debugToolCall` — 调试工具调用
|
||||
- `env` — 显示环境变量
|
||||
- `mockLimits` — 模拟速率限制
|
||||
- `resetLimits` — 重置速率限制
|
||||
- `resetLimitsNonInteractive` — 重置速率限制(非交互式)
|
||||
</Accordion>
|
||||
<Accordion title="实验类">
|
||||
- `bughunter` — Bug 猎人模式
|
||||
- `goodClaude` — 质量评估工具
|
||||
- `antTrace` — 追踪分析
|
||||
- `perfIssue` — 性能问题诊断
|
||||
</Accordion>
|
||||
<Accordion title="工作流类">
|
||||
- `commit` — 快速提交
|
||||
@@ -42,75 +72,139 @@ Claude Code 有两种构建:Anthropic 员工使用的内部构建(`USER_TYPE
|
||||
- `autofixPr` — 自动修复 PR 中的问题
|
||||
- `share` — 分享会话
|
||||
- `summary` — 生成摘要
|
||||
- `subscribePr` — 订阅 PR(需要 `KAIROS_GITHUB_WEBHOOKS` feature flag)
|
||||
- `forceSnip` — 强制截断历史(需要 `HISTORY_SNIP` feature flag)
|
||||
- `ultraplan` — 超级规划(需要 `ULTRAPLAN` feature flag,单独注册于 `commands.ts:396`)
|
||||
</Accordion>
|
||||
<Accordion title="基础设施类">
|
||||
- `backfillSessions` — 回填会话数据
|
||||
- `bridgeKick` — 重启 Bridge 连接
|
||||
- `oauthRefresh` — 刷新 OAuth Token
|
||||
- `teleport` — 传送到指定上下文
|
||||
- `onboarding` — 新手引导
|
||||
- `agentsPlatform` — Agents 平台管理
|
||||
- `version` — 内部版本详情
|
||||
- `initVerifiers` — 初始化验证器
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
这些命令在演示模式(`IS_DEMO`)下也被隐藏,防止在公开演示中暴露内部功能。
|
||||
<Note>
|
||||
这些命令在 `IS_DEMO` 模式下也会被隐藏,防止在演示环境中暴露内部功能。
|
||||
</Note>
|
||||
|
||||
## Beta API Headers 的分层
|
||||
## Beta API Headers
|
||||
|
||||
Claude Code 向 API 发送的 beta headers 按可见性分为多层:
|
||||
Claude Code 向 API 发送的 beta headers 分布在 `src/constants/betas.ts`(主注册表)和其他文件中,按可见性分为以下几类:
|
||||
|
||||
### 公开 Headers(所有构建)
|
||||
### 公开 Headers(所有构建均发送)
|
||||
|
||||
| Header | 功能 |
|
||||
|--------|------|
|
||||
| `claude-code-20250219` | Claude Code 标识 |
|
||||
| `effort-2025-11-24` | 推理强度控制 |
|
||||
| `interleaved-thinking-2025-05-14` | 交错思考模式 |
|
||||
| `context-1m-2025-08-07` | 1M 上下文窗口 |
|
||||
| Header | 功能 | 额外条件 |
|
||||
|--------|------|----------|
|
||||
| `claude-code-20250219` | Claude Code 标识 | 非 Haiku 时始终发送;Haiku 在 agentic 模式下也发送 |
|
||||
| `effort-2025-11-24` | 推理强度控制 | 动态注入 |
|
||||
| `task-budgets-2026-03-13` | 任务预算 | 始终通过 `addAgenticBetas()` 注入 |
|
||||
| `fast-mode-2026-02-01` | 快速模式 | 通过 sticky-on latch 动态注入 |
|
||||
| `advisor-tool-2026-03-01` | 顾问工具 | 启用 advisor 时动态注入 |
|
||||
| `advanced-tool-use-2025-11-20` | 工具搜索(1P) | Claude API / Foundry |
|
||||
| `tool-search-tool-2025-10-19` | 工具搜索(3P) | Vertex / Bedrock |
|
||||
|
||||
### 模型能力相关(有条件发送)
|
||||
|
||||
| Header | 功能 | 条件 |
|
||||
|--------|------|------|
|
||||
| `interleaved-thinking-2025-05-14` | 交错思考模式 | 模型支持 ISP 且未禁用 |
|
||||
| `context-1m-2025-08-07` | 1M 上下文窗口 | 模型支持 1M context |
|
||||
| `context-management-2025-06-27` | 上下文管理 | Claude 4+ 或 ant 手动启用 |
|
||||
| `structured-outputs-2025-12-15` | 结构化输出 | Claude 4.5/4.6 + GrowthBook `tengu_tool_pear` |
|
||||
| `web-search-2025-03-05` | 网页搜索 | Vertex (Claude 4+) / Foundry |
|
||||
| `redact-thinking-2026-02-12` | 思维摘要/脱敏 | ISP 模型 + 非交互 + 未强制显示思维 |
|
||||
| `prompt-caching-scope-2026-01-05` | 提示缓存作用域 | firstParty/foundry + 全局缓存 |
|
||||
|
||||
### Ant-Only Headers
|
||||
|
||||
| Header | 功能 |
|
||||
|--------|------|
|
||||
| `cli-internal-2026-02-09` | 内部 CLI 功能 |
|
||||
| `token-efficient-tools-2026-03-28` | Token 高效工具 |
|
||||
| Header | 功能 | 条件 |
|
||||
|--------|------|------|
|
||||
| **`cli-internal-2026-02-09`** | 内部 CLI 功能 | `USER_TYPE === 'ant'` + CLI 入口 |
|
||||
| **`token-efficient-tools-2026-03-28`** | Token 高效工具 | `USER_TYPE === 'ant'` + GrowthBook `tengu_amber_json_tools` |
|
||||
|
||||
**设计洞察**:`cli-internal` header 说明 Anthropic 的 API 服务端也维护着 ant-only 的行为——这不只是客户端门控,而是端到端的功能隔离。
|
||||
### Feature Flag Gated
|
||||
|
||||
| Header | 功能 | 条件 |
|
||||
|--------|------|------|
|
||||
| **`afk-mode-2026-01-31`** | AFK 模式(离开键盘自动审批) | `feature('TRANSCRIPT_CLASSIFIER')` |
|
||||
|
||||
### 其他特殊 Headers
|
||||
|
||||
| Header | 功能 | 来源 |
|
||||
|--------|------|------|
|
||||
| `oauth-2025-04-20` | OAuth 订阅者标识 | `src/constants/oauth.ts`,Pro/Max/Team/Enterprise |
|
||||
| `environments-2025-11-01` | Bridge 环境 API | `src/bridge/bridgeApi.ts`,仅 Bridge 模式 |
|
||||
|
||||
```typescript
|
||||
// src/constants/betas.ts — 常量定义
|
||||
export const TOKEN_EFFICIENT_TOOLS_BETA_HEADER =
|
||||
'token-efficient-tools-2026-03-28'
|
||||
export const CLI_INTERNAL_BETA_HEADER =
|
||||
process.env.USER_TYPE === 'ant' ? 'cli-internal-2026-02-09' : ''
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/utils/betas.ts 第 315-321 行——TOKEN_EFFICIENT_TOOLS 的实际门控逻辑
|
||||
if (
|
||||
process.env.USER_TYPE === 'ant' &&
|
||||
includeFirstPartyOnlyBetas &&
|
||||
tokenEfficientToolsEnabled // GrowthBook 'tengu_amber_json_tools' flag
|
||||
) {
|
||||
betaHeaders.push(TOKEN_EFFICIENT_TOOLS_BETA_HEADER)
|
||||
}
|
||||
```
|
||||
|
||||
`cli-internal` header 意味着 Anthropic 的 API 服务端也维护着一套 ant-only 的服务端行为——这不仅仅是客户端的门控。`token-efficient-tools` 进一步需要 GrowthBook flag 开启,说明 Ant 员工内部也有分层灰度。
|
||||
|
||||
## 内部代号体系
|
||||
|
||||
Anthropic 有"动物命名"文化:
|
||||
Anthropic 有浓厚的"动物命名"文化:
|
||||
|
||||
| 代号 | 身份 |
|
||||
|------|------|
|
||||
| **Tengu**(天狗) | Claude Code 项目代号(所有内部 flag 的 `tengu_` 前缀) |
|
||||
| **Capybara**(水豚) | 模型代号 |
|
||||
| **Fennec**(耳廓狐) | 已退役模型别名(已迁移到 Opus) |
|
||||
| 代号 | 身份 | 出处 |
|
||||
|------|------|------|
|
||||
| **Tengu**(天狗) | Claude Code 项目代号 | 所有 GrowthBook flags 的 `tengu_` 前缀、分析事件名称 |
|
||||
| **Capybara**(水豚) | 模型代号 | `src/constants/prompts.ts` 中被 Undercover Mode 屏蔽的名称 |
|
||||
| **Fennec**(耳廓狐) | 已退役模型别名 | `src/migrations/migrateFennecToOpus.ts`——曾用名已迁移到 Opus |
|
||||
|
||||
这些代号通过 Undercover Mode 在公开仓库的 commit 中被过滤。
|
||||
这些代号通过 Undercover Mode 在公开仓库的 commit 中被严格过滤。
|
||||
|
||||
## 环境变量开关
|
||||
|
||||
除了 `USER_TYPE`,还有一系列精细的环境变量控制各项功能:
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="功能禁用开关">
|
||||
- `CLAUDE_CODE_SIMPLE` — 简化模式(禁用高级功能)
|
||||
- `CLAUDE_CODE_DISABLE_THINKING` — 禁用 thinking
|
||||
- `DISABLE_INTERLEAVED_THINKING` — 禁用交错思考
|
||||
- `DISABLE_COMPACT` — 禁用消息压缩
|
||||
- `DISABLE_AUTO_COMPACT` — 禁用自动压缩
|
||||
- `CLAUDE_CODE_DISABLE_AUTO_MEMORY` — 禁用自动记忆
|
||||
- `CLAUDE_CODE_DISABLE_BACKGROUND_TASKS` — 禁用后台任务
|
||||
- `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS` — 禁用实验性 beta headers
|
||||
- `USE_API_CONTEXT_MANAGEMENT` — 上下文管理工具清除(需 ant)
|
||||
</Accordion>
|
||||
<Accordion title="功能启用开关">
|
||||
- `CLAUDE_CODE_VERIFY_PLAN` — 启用 VerifyPlanExecutionTool
|
||||
- `ENABLE_LSP_TOOL` — 启用 LSP 工具
|
||||
- `ENABLE_LSP_TOOL` — 启用 LSP 语言服务器工具
|
||||
- `CLAUDE_CODE_UNDERCOVER` — 强制启用 Undercover Mode
|
||||
- `CLAUDE_CODE_TERMINAL_RECORDING` — 启用终端录制(asciicast)
|
||||
- `CLAUDE_CODE_ABLATION_BASELINE` — 启用基线对照模式
|
||||
</Accordion>
|
||||
<Accordion title="环境配置">
|
||||
- `CLAUDE_CODE_REMOTE` — 远程执行模式
|
||||
- `CLAUDE_CODE_REMOTE` — 远程执行模式(自动增加堆内存限制)
|
||||
- `CLAUDE_CODE_COORDINATOR_MODE` — 启用 Coordinator 模式
|
||||
- `CLAUDE_INTERNAL_FC_OVERRIDES` — GrowthBook flag 覆盖(ant-only)
|
||||
- `IS_DEMO` — 演示模式(隐藏内部命令和敏感信息)
|
||||
- `CLAUDE_CODE_ENTRYPOINT` — 入口类型标识(`cli` | 其他)
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
`CLAUDE_CODE_ABLATION_BASELINE` 特别有趣——它同时关闭 thinking、compaction、auto-memory 和 background tasks,用于测量这些高级功能对 AI 表现的**因果影响**。这是一个严肃的"科学对照实验"工具。
|
||||
|
||||
## 接下来
|
||||
|
||||
- **Feature Flags** — 理解功能开关的设计
|
||||
- **权限模型** — 理解身份门控与权限的协作
|
||||
<Note>
|
||||
`ABLATION_BASELINE` 特别有趣——它同时关闭 thinking、compaction、auto-memory 和 background tasks,用于测量这些高级功能对 AI 表现的**因果影响**。这是一个严肃的"科学对照实验"工具。
|
||||
</Note>
|
||||
|
||||
314
docs/internals/autonomy-jira.md
Normal file
314
docs/internals/autonomy-jira.md
Normal file
@@ -0,0 +1,314 @@
|
||||
# Autonomy Reliability Jira Drafts
|
||||
|
||||
These tickets are based on the call-chain audit of `/autonomy`, proactive
|
||||
ticks, HEARTBEAT managed flows, cron scheduling, command queue consumption,
|
||||
and daemon process supervision.
|
||||
|
||||
## AUT-001: Preserve autonomy lifecycle when queued commands are consumed mid-turn
|
||||
|
||||
Type: Bug
|
||||
Priority: P0
|
||||
Status: Draft
|
||||
Patch status: Implemented in `fix/autonomy-lifecycle`.
|
||||
|
||||
Problem:
|
||||
`query.ts` can drain queued prompt/task-notification commands as attachments
|
||||
during an active turn. Autonomy prompts consumed this way were removed from the
|
||||
in-memory queue without marking the persisted run as running/completed/failed,
|
||||
so managed flows could stay stuck in `queued` and never advance.
|
||||
|
||||
Evidence:
|
||||
- `src/query.ts` drains queued commands via `getCommandsByMaxPriority()`.
|
||||
- `src/query.ts` removes consumed commands from the queue.
|
||||
- Lifecycle updates existed only in the normal queued-submit path
|
||||
`src/utils/handlePromptSubmit.ts` and headless `src/cli/print.ts`.
|
||||
|
||||
Acceptance criteria:
|
||||
- Mid-turn consumed autonomy commands mark runs `running`.
|
||||
- Normal query completion finalizes consumed runs and queues next managed-flow
|
||||
steps.
|
||||
- Query errors or abort terminal reasons mark consumed runs failed.
|
||||
- Stale/cancelled autonomy commands are removed from the in-memory queue
|
||||
without being sent to the model.
|
||||
- Regression tests cover stale command filtering and managed-flow advancement.
|
||||
|
||||
## AUT-002: Make autonomy run lifecycle transitions terminal-safe
|
||||
|
||||
Type: Bug
|
||||
Priority: P0
|
||||
Status: Draft
|
||||
Patch status: Implemented in `fix/autonomy-lifecycle`.
|
||||
|
||||
Problem:
|
||||
Run lifecycle helpers rewrote status unconditionally. A stale in-memory command
|
||||
could mark a cancelled/completed/failed run back to `running`, causing a
|
||||
cancelled flow to execute or a terminal flow to be rewritten.
|
||||
|
||||
Evidence:
|
||||
- `markAutonomyRunRunning`, `markAutonomyRunCompleted`,
|
||||
`markAutonomyRunFailed`, and `markAutonomyRunCancelled` updated records
|
||||
without checking current status.
|
||||
- External CLI cancel cannot remove queued commands living inside another
|
||||
process, so stale commands are a realistic input.
|
||||
|
||||
Acceptance criteria:
|
||||
- `queued -> running/completed/failed/cancelled` remains allowed.
|
||||
- `running -> completed/failed/cancelled` remains allowed.
|
||||
- Any terminal status rejects later lifecycle updates.
|
||||
- Rejected transitions do not update managed-flow step state.
|
||||
- Regression tests cover stale lifecycle calls after cancellation.
|
||||
|
||||
## AUT-003: Prevent proactive and scheduled-task async fire failures from becoming invisible
|
||||
|
||||
Type: Bug
|
||||
Priority: P1
|
||||
Status: Draft
|
||||
Patch status: Implemented in `fix/autonomy-lifecycle`.
|
||||
|
||||
Problem:
|
||||
Proactive tick and cron fire callbacks launch detached async work. Failures in
|
||||
prompt preparation or queue insertion could surface as unhandled rejections or
|
||||
be lost from diagnostics. In one-shot cron paths, the scheduler has already
|
||||
decided the task fired.
|
||||
|
||||
Evidence:
|
||||
- `src/proactive/useProactive.ts` used a detached async IIFE without catch.
|
||||
- `src/cli/print.ts` proactive and cron paths also detached async work.
|
||||
- `src/hooks/useScheduledTasks.ts` cron callbacks detached async work.
|
||||
|
||||
Acceptance criteria:
|
||||
- Detached proactive/cron fire work has explicit error logging.
|
||||
- REPL proactive tick generation is non-reentrant.
|
||||
- Tick generation stops queueing after hook unmount.
|
||||
|
||||
## AUT-004: Bound long-running daemon restart timers during shutdown
|
||||
|
||||
Type: Bug
|
||||
Priority: P1
|
||||
Status: Draft
|
||||
Patch status: Implemented in `fix/autonomy-lifecycle`.
|
||||
|
||||
Problem:
|
||||
The daemon supervisor scheduled worker restarts with `setTimeout()` but did
|
||||
not store, clear, or `unref()` the timer. Shutdown during backoff could keep
|
||||
the supervisor alive until the timer fired, forcing the stop path toward
|
||||
SIGKILL.
|
||||
|
||||
Evidence:
|
||||
- `src/daemon/main.ts` scheduled restart timers directly in the worker exit
|
||||
handler.
|
||||
- Shutdown only signaled child processes and did not clear restart timers.
|
||||
|
||||
Acceptance criteria:
|
||||
- Worker restart timers are tracked per worker.
|
||||
- Shutdown clears any pending restart timers.
|
||||
- Restart and force-kill grace timers do not keep the supervisor alive alone.
|
||||
|
||||
## AUT-005: Release autonomy persistence lock bookkeeping after each chain
|
||||
|
||||
Type: Bug
|
||||
Priority: P1
|
||||
Status: Draft
|
||||
Patch status: Implemented in `fix/autonomy-lifecycle`.
|
||||
|
||||
Problem:
|
||||
`withAutonomyPersistenceLock` stored a chained promise in its map but compared
|
||||
the map value against the raw current promise during cleanup. That condition
|
||||
never matched, so root-level lock bookkeeping could accumulate in long-lived
|
||||
processes that touch many workspaces.
|
||||
|
||||
Evidence:
|
||||
- `src/utils/autonomyPersistence.ts` stored `previous.then(() => current)`.
|
||||
- Cleanup compared `persistenceLocks.get(key) === current`.
|
||||
|
||||
Acceptance criteria:
|
||||
- The stored chained promise is the value used for cleanup comparison.
|
||||
- Existing serialization behavior for same-root calls remains unchanged.
|
||||
- Tests directly assert same-root lock bookkeeping returns to zero after both
|
||||
success and failure.
|
||||
|
||||
## AUT-006: Add active-record protection before persistence truncation
|
||||
|
||||
Type: Reliability
|
||||
Priority: P2
|
||||
Status: Draft
|
||||
Patch status: Implemented in `fix/autonomy-lifecycle`.
|
||||
|
||||
Problem:
|
||||
Autonomy runs and flows are capped by latest-created/updated order only.
|
||||
Under high churn, active `queued` or `running` records can be truncated before
|
||||
completion, which removes recovery evidence and can break managed-flow
|
||||
advancement.
|
||||
|
||||
Evidence:
|
||||
- `src/utils/autonomyRuns.ts` keeps the latest 200 runs by `createdAt`.
|
||||
- `src/utils/autonomyFlows.ts` keeps the latest 100 flows by `updatedAt`.
|
||||
|
||||
Acceptance criteria:
|
||||
- Active records are retained before completed historical records are trimmed.
|
||||
- Tests cover trimming with more than the configured cap and active records
|
||||
near the tail.
|
||||
|
||||
## AUT-007: Treat provider API-error responses as failed autonomy turns
|
||||
|
||||
Type: Bug
|
||||
Priority: P0
|
||||
Status: Draft
|
||||
Patch status: Implemented in `fix/autonomy-lifecycle`.
|
||||
|
||||
Problem:
|
||||
Third-party provider adapters can convert provider failures into synthetic
|
||||
assistant API-error messages instead of throwing. `query.ts` treated
|
||||
`isApiErrorMessage` terminal responses as `completed`, so an autonomy command
|
||||
that had already been consumed as a queued attachment could be marked
|
||||
completed and advance its managed flow even though the provider call failed.
|
||||
|
||||
Evidence:
|
||||
- `src/services/api/openai/index.ts`, `src/services/api/gemini/index.ts`, and
|
||||
`src/services/api/grok/index.ts` yield `createAssistantAPIErrorMessage()` on
|
||||
adapter errors.
|
||||
- `src/query.ts` skipped stop hooks for API-error assistant messages but
|
||||
returned `reason: 'completed'`.
|
||||
- Top-level autonomy finalization used terminal completion to decide whether
|
||||
to mark consumed runs completed or failed.
|
||||
|
||||
Acceptance criteria:
|
||||
- Provider API-error assistant messages terminate the query with
|
||||
`reason: 'model_error'`.
|
||||
- Any consumed autonomy run is marked failed rather than completed.
|
||||
- Managed flows do not advance to the next step after provider API errors.
|
||||
- A regression test simulates provider error after a queued autonomy attachment
|
||||
has been consumed.
|
||||
|
||||
## AUT-008: Finalize consumed autonomy runs on async-generator close
|
||||
|
||||
Type: Bug
|
||||
Priority: P0
|
||||
Status: Draft
|
||||
Patch status: Implemented in `fix/autonomy-lifecycle`.
|
||||
|
||||
Problem:
|
||||
`query()` is an async generator. When its consumer calls `.return()` or breaks
|
||||
out of iteration, JavaScript executes `finally` blocks and skips code after the
|
||||
`try/finally`. The previous autonomy finalization ran after the `finally`, so
|
||||
queued autonomy commands that had already been claimed as `running` could stay
|
||||
persisted as `running` forever if the REPL/SDK consumer closed the generator.
|
||||
|
||||
Evidence:
|
||||
- Claimed run IDs were collected during queued attachment injection.
|
||||
- Completion/failure finalization happened only after `yield* queryLoop(...)`
|
||||
returned normally or threw.
|
||||
- Claude cross-validation flagged this as a durable run/flow leak.
|
||||
|
||||
Acceptance criteria:
|
||||
- Consumed autonomy runs are finalized from a `finally` path.
|
||||
- Normal completion marks consumed runs completed and enqueues next managed
|
||||
flow steps.
|
||||
- Provider/model errors mark consumed runs failed.
|
||||
- Generator close and user abort terminals mark consumed runs cancelled.
|
||||
- A regression test closes the generator after a queued autonomy attachment and
|
||||
verifies the run/flow are cancelled, not left running.
|
||||
|
||||
## AUT-009: Claim queued autonomy runs before attachment injection
|
||||
|
||||
Type: Bug
|
||||
Priority: P0
|
||||
Status: Draft
|
||||
Patch status: Implemented in `fix/autonomy-lifecycle`.
|
||||
|
||||
Problem:
|
||||
The query loop filtered stale queued autonomy commands before attachment
|
||||
generation, but it did not claim runs as `running` until after attachments were
|
||||
already yielded. A concurrent cancellation between those steps could still send
|
||||
a cancelled prompt into the model context.
|
||||
|
||||
Evidence:
|
||||
- `partitionConsumableQueuedAutonomyCommands()` only checked persisted status.
|
||||
- `markAutonomyRunRunning()` previously ran after `getAttachmentMessages()`.
|
||||
- Reviewer cross-validation identified the check-then-act race.
|
||||
|
||||
Acceptance criteria:
|
||||
- Query claims queued autonomy runs before passing commands to attachment
|
||||
generation.
|
||||
- Only successfully claimed commands are injected as queued-command
|
||||
attachments.
|
||||
- Failed claims are treated as stale and removed from the in-memory queue.
|
||||
- Claiming reads persisted run state once per turn rather than once per
|
||||
command.
|
||||
|
||||
## AUT-010: Cancel proactive and cron runs dropped before enqueue
|
||||
|
||||
Type: Bug
|
||||
Priority: P1
|
||||
Status: Draft
|
||||
Patch status: Implemented in `fix/autonomy-lifecycle`.
|
||||
|
||||
Problem:
|
||||
`/proactive` and scheduled-task producers persist autonomy runs before
|
||||
returning queue commands. If the component is disposed or headless input closes
|
||||
after persistence but before enqueue, the queued run is left on disk with no
|
||||
in-memory command to consume it.
|
||||
|
||||
Evidence:
|
||||
- `createProactiveAutonomyCommands()` commits runs before returning commands.
|
||||
- `commitAutonomyQueuedPrompt()` persists scheduled-task runs before callers
|
||||
enqueue them.
|
||||
- Callers checked `disposed` / `inputClosed` after command creation and could
|
||||
return without terminalizing the run.
|
||||
|
||||
Acceptance criteria:
|
||||
- Proactive hook cancellation checks run both before commit and after command
|
||||
creation.
|
||||
- Headless proactive and cron paths cancel any already-created command that is
|
||||
dropped due to input close.
|
||||
- REPL scheduled-task cleanup cancels already-created commands when unmounted.
|
||||
- A regression test verifies a proactive command created but dropped before
|
||||
enqueue is marked cancelled.
|
||||
|
||||
## AUT-011: Replace query transition `any` stubs with typed contracts
|
||||
|
||||
Type: Test/Type Safety
|
||||
Priority: P2
|
||||
Status: Draft
|
||||
Patch status: Implemented in `fix/autonomy-lifecycle`.
|
||||
|
||||
Problem:
|
||||
`src/query/transitions.ts` defined both `Terminal` and `Continue` as `any`.
|
||||
That allowed new terminal reasons such as `model_error` and continuation
|
||||
reasons such as `collapse_drain_retry` to drift without compiler checks.
|
||||
|
||||
Evidence:
|
||||
- Claude cross-validation flagged the `Terminal = any` contract as a remaining
|
||||
issue.
|
||||
- Tightening the type immediately caught that
|
||||
`collapse_drain_retry.committed` is a `number`, not a `boolean`.
|
||||
|
||||
Acceptance criteria:
|
||||
- `Terminal` is a concrete union of query terminal reasons.
|
||||
- `Continue` is a concrete union of continuation reasons and payloads.
|
||||
- `bun run typecheck` validates all query return sites against that contract.
|
||||
|
||||
## AUT-012: Avoid provider test settings-module mock pollution
|
||||
|
||||
Type: Test Reliability
|
||||
Priority: P2
|
||||
Status: Draft
|
||||
Patch status: Implemented in `fix/autonomy-lifecycle`.
|
||||
|
||||
Problem:
|
||||
The provider tests previously mocked `settings.js`. A minimal mock broke other
|
||||
tests that imported additional settings exports in the same Bun process; the
|
||||
expanded mock avoided the failure but over-coupled the provider test to
|
||||
unrelated settings internals.
|
||||
|
||||
Evidence:
|
||||
- Full test runs observed cross-file settings mock pollution.
|
||||
- `src/utils/model/providers.ts` only needs the real `getInitialSettings()`
|
||||
behavior.
|
||||
|
||||
Acceptance criteria:
|
||||
- Provider tests do not mock `settings.js`.
|
||||
- `modelType` precedence is exercised through an injected settings snapshot,
|
||||
leaving global bootstrap state untouched.
|
||||
- Provider tests pass when run alongside permissions tests and the provider
|
||||
matrix.
|
||||
@@ -1,28 +1,36 @@
|
||||
---
|
||||
title: "Feature Flags"
|
||||
description: "88+ 个构建时特性门控:理解 feature() 的编译时求值机制、死代码消除和 flags 的分类全景。"
|
||||
keywords: ["feature flags", "特性标志", "构建时门控", "条件编译"]
|
||||
title: "88 个 Feature Flags - 构建时特性门控全解"
|
||||
description: "深入剖析 Claude Code 的 88+ 个构建时 feature flags:bun:bundle 编译时门控机制,揭示被编译器删除的隐藏功能模块。"
|
||||
keywords: ["feature flags", "特性标志", "构建时门控", "bun:bundle", "条件编译"]
|
||||
---
|
||||
|
||||
## 核心机制
|
||||
{/* 本章目标:完整梳理构建时 feature flag 系统的机制和所有 flag 的分类 */}
|
||||
|
||||
Claude Code 使用 Bun 打包器的编译时特性门控。代码中通过 `import { feature } from 'bun:bundle'` 导入 `feature()` 函数,在构建时被求值——返回 `true` 的代码保留,返回 `false` 的代码被**死代码消除(DCE)**彻底移除。
|
||||
## feature() 是什么
|
||||
|
||||
**设计洞察**:这不是运行时的 if-else——feature flag 在构建时就已经决定了哪些代码存在。外部构建中不存在的功能不是"被禁用",而是"从未编译进去"。这是一种零运行时开销的特性门控。
|
||||
Claude Code 使用 Bun 打包器的 `bun:bundle` 模块提供编译时特性门控:
|
||||
|
||||
## 三种使用模式
|
||||
```typescript
|
||||
// 源码中的用法(src/tools.ts 等)
|
||||
import { feature } from 'bun:bundle'
|
||||
|
||||
### 1. 条件加载工具
|
||||
const SleepTool = feature('PROACTIVE') || feature('KAIROS')
|
||||
? require('@claude-code-best/builtin-tools/tools/SleepTool/SleepTool.js').SleepTool
|
||||
: null
|
||||
```
|
||||
|
||||
当 flag 为 `false` 时,`require()` 调用被 DCE 移除,工具不会出现在可用工具列表中。不增加打包体积。
|
||||
在 Anthropic 的内部构建中,`feature()` 在打包时被求值——返回 `true` 的代码会被保留,返回 `false` 的代码会被 **Dead Code Elimination (DCE)** 彻底移除。
|
||||
|
||||
### 2. 条件注册命令
|
||||
在我们的反编译版本中,`feature` 从 `bun:bundle` 导入(声明在 `src/types/internal-modules.d.ts`),在运行时始终返回 `false`:
|
||||
|
||||
斜杠命令只在对应 flag 启用时注册。用户不会看到不可用的命令。
|
||||
```typescript
|
||||
// src/types/internal-modules.d.ts
|
||||
declare module 'bun:bundle' {
|
||||
export function feature(name: string): boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 条件启用 API 特性
|
||||
|
||||
控制发送给 API 的 beta header。未启用的功能不会向服务端声明能力。
|
||||
这意味着所有 88+ 个 feature flag 后的代码**在运行时永远不会执行**,但代码本身完整保留,可以阅读和分析。
|
||||
|
||||
## Flags 分类全景
|
||||
|
||||
@@ -64,14 +72,46 @@ Claude Code 使用 Bun 打包器的编译时特性门控。代码中通过 `impo
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## 代码中的典型模式
|
||||
|
||||
Feature flags 在代码中主要有三种使用模式:
|
||||
|
||||
### 模式一:条件加载工具
|
||||
|
||||
```typescript
|
||||
// src/tools.ts — 最常见的模式
|
||||
const MonitorTool = feature('MONITOR_TOOL')
|
||||
? require('@claude-code-best/builtin-tools/tools/MonitorTool/MonitorTool.js').MonitorTool
|
||||
: null
|
||||
```
|
||||
|
||||
当 flag 为 `false` 时,`require()` 调用被 DCE 移除,工具不会出现在可用工具列表中。
|
||||
|
||||
### 模式二:条件注册命令
|
||||
|
||||
```typescript
|
||||
// src/commands.ts — 注册斜杠命令
|
||||
if (feature('VOICE_MODE')) {
|
||||
commands.push({ name: 'voice', description: '...' })
|
||||
}
|
||||
```
|
||||
|
||||
### 模式三:条件启用 API 特性
|
||||
|
||||
```typescript
|
||||
// src/constants/betas.ts — 控制发送给 API 的 beta header
|
||||
export const AFK_MODE_BETA_HEADER = feature('TRANSCRIPT_CLASSIFIER')
|
||||
? 'afk-mode-2026-01-31'
|
||||
: ''
|
||||
```
|
||||
|
||||
<Note>
|
||||
由于 `feature()` 在构建时求值,被 DCE 移除的代码不会增加最终打包体积。但在反编译版本中,这些代码全部保留——这正是我们能够进行完整分析的原因。
|
||||
</Note>
|
||||
|
||||
## 有趣的发现
|
||||
|
||||
- **KAIROS 家族**最庞大——6 个相关 flag 控制从核心功能到推送通知的方方面面
|
||||
- **ABLATION_BASELINE** 是用于"科学对照实验"的——关闭 thinking、compaction、auto-memory 等高级功能,测量裸 API 调用的基线性能
|
||||
- **BUDDY** 是一个 AI 吉祥物/精灵系统
|
||||
- **ABLATION_BASELINE** 是用于"科学对照实验"的——它会关闭 thinking、compaction、auto-memory 等高级功能,测量裸 API 调用的基线性能
|
||||
- **BUDDY** 是一个 AI 吉祥物/精灵系统——在 `src/buddy/` 目录下有完整实现
|
||||
- **ULTRAPLAN** 和 **ULTRATHINK** 暗示着比当前 extended thinking 更高级的推理模式
|
||||
|
||||
## 接下来
|
||||
|
||||
- **Ant 特权世界** — 理解 USER_TYPE 门控与 feature flags 的关系
|
||||
- **Auto Mode** — 理解 TRANSCRIPT_CLASSIFIER flag 控制的自动权限分类
|
||||
|
||||
@@ -1,131 +1,115 @@
|
||||
---
|
||||
title: "架构总览"
|
||||
description: "从交互层到通信层,理解 Claude Code 的五层架构设计。每层的职责、边界和设计考量。"
|
||||
keywords: ["Claude Code 架构", "五层架构", "Agentic Loop", "数据流"]
|
||||
og:image: "https://ccb.agent-aura.top/docs/images/og-cover.png"
|
||||
title: "架构全景 - Claude Code 五层架构详解"
|
||||
description: "从交互层到基础设施层,详解 Claude Code 的五层架构设计。基于 src/main.tsx、src/QueryEngine.ts、src/query.ts、src/tools.ts、src/services/api/claude.ts 的源码级数据流分析。"
|
||||
keywords: ["Claude Code 架构", "五层架构", "QueryEngine", "Agentic Loop", "数据流"]
|
||||
---
|
||||
|
||||
{/* 本章目标:一张图讲清楚整体架构,为后续章节建立坐标系 */}
|
||||
|
||||
## 五层架构
|
||||
|
||||
Claude Code 从上到下分为五个层次。每一层职责清晰、边界分明,层与层之间通过明确的接口通信。
|
||||
Claude Code 从上到下分为五个层次,每一层职责清晰、边界分明:
|
||||
|
||||
<Frame caption="Claude Code 五层架构">
|
||||
<img src="/docs/images/architecture-layers.png" alt="Claude Code 五层架构图" />
|
||||
</Frame>
|
||||
|
||||
| 层次 | 职责 | 设计考量 |
|
||||
|------|------|----------|
|
||||
| **交互层** | 终端 UI、用户输入、消息展示 | 为什么用 React/Ink 而不是 readline?因为需要组件化渲染工具权限确认、进度条等复杂 UI |
|
||||
| **编排层** | 多轮对话管理、会话持久化、成本追踪 | 为什么需要独立的编排层?因为 agentic loop 本身不应该关心"会话保存"和"token 计费" |
|
||||
| **核心循环层** | 单轮:发请求 → 拿响应 → 执行工具 → 循环 | 这是整个系统的心脏。为什么用 AsyncGenerator?因为流式输出需要逐步 yield,工具执行需要可取消 |
|
||||
| **工具层** | AI 的"双手"——读写文件、执行命令等 50+ 工具 | 为什么工具是独立层?因为工具可以动态增减(MCP 扩展),不应该硬编码在核心循环中 |
|
||||
| **通信层** | 与 Claude API 的流式通信 | 为什么支持 7 种 Provider?因为不同用户使用不同的 API 端点(直连、AWS Bedrock、Google Vertex 等) |
|
||||
| 层次 | 职责 | 入口源码 | 关键词 |
|
||||
|------|------|---------|--------|
|
||||
| **交互层** | 终端 UI、用户输入、消息展示 | `src/screens/REPL.tsx` | React/Ink、PromptInput |
|
||||
| **编排层** | 多轮对话、会话持久化、成本追踪 | `src/QueryEngine.ts` | QueryEngine、transcript |
|
||||
| **核心循环层** | 单轮:发请求 → 拿响应 → 执行工具 → 循环 | `src/query.ts` | Agentic Loop、State |
|
||||
| **工具层** | AI 的"双手"——读写文件、执行命令 | `src/tools.ts` → `src/Tool.ts` | Tool 接口、MCP |
|
||||
| **通信层** | 与 Claude API 的流式通信 | `src/services/api/claude.ts` | Streaming、Provider |
|
||||
|
||||
### 层间通信原则
|
||||
|
||||
- **交互层 → 编排层**:用户消息和指令(如斜杠命令)
|
||||
- **编排层 → 核心循环层**:上下文参数(消息历史、工具列表、权限上下文)
|
||||
- **核心循环层 → 工具层**:工具调用请求(工具名 + 参数)
|
||||
- **工具层 → 核心循环层**:工具执行结果
|
||||
- **核心循环层 → 通信层**:API 请求(消息数组 + 系统提示 + 工具定义)
|
||||
- **通信层 → 核心循环层**:流式响应事件
|
||||
|
||||
每层只依赖下一层的接口,不跨层访问。这种约束使得:
|
||||
- 通信层可以替换 Provider 而不影响核心循环
|
||||
- 工具层可以新增工具而不影响编排逻辑
|
||||
- 交互层可以替换为 Web UI 而不影响底层
|
||||
|
||||
## 核心数据流
|
||||
## 一条主数据流的源码追踪
|
||||
|
||||
<Frame caption="核心数据流">
|
||||
<img src="/docs/images/data-flow.png" alt="Claude Code 核心数据流" />
|
||||
</Frame>
|
||||
|
||||
整个系统的运转可以浓缩为一条循环数据流。以下是每一步的设计意图:
|
||||
整个系统的运转可以浓缩为一条核心数据流,以下是每一步对应的源码路径:
|
||||
|
||||
### 1. 用户输入 → 交互层
|
||||
### 1. 用户输入 → REPL
|
||||
|
||||
用户输入经过预处理管道:斜杠命令解析、文件附件处理、图片编码等。设计上,输入处理是可扩展的——新的输入类型(如语音)只需要在预处理管道中添加一个处理器。
|
||||
`src/screens/REPL.tsx` 是基于 React/Ink 的终端 UI 组件。用户输入经 `processUserInput()`(`src/utils/processUserInput/processUserInput.ts`)处理,支持斜杠命令、文件附件、图片等。
|
||||
|
||||
### 2. 交互层 → 编排层
|
||||
### 2. QueryEngine 编排
|
||||
|
||||
编排层是会话的"大脑"。它管理三类关键状态:
|
||||
- **对话状态**:消息数组、工具权限上下文——决定 AI 看到什么
|
||||
- **成本状态**:token 用量累计——决定何时触发压缩或警告用户
|
||||
- **持久化状态**:对话序列化到磁盘——支持会话恢复和 undo
|
||||
`src/QueryEngine.ts` 是 REPL 与 `query()` 之间的中间层,管理:
|
||||
- **会话状态**:消息数组、工具权限上下文(`ToolPermissionContext`)、文件历史快照
|
||||
- **成本追踪**:`accumulateUsage()` / `getTotalCost()` 累计 token 用量
|
||||
- **Transcript 持久化**:`recordTranscript()` 将对话序列化到磁盘,支持 `--resume`
|
||||
- **文件历史**:`fileHistoryMakeSnapshot()` 在修改前创建快照,支持 undo
|
||||
|
||||
### 3. 编排层 → 核心循环(Agentic Loop)
|
||||
关键方法:`queryEngine.query()` 构造 `QueryParams`,调用 `query()` 异步生成器。
|
||||
|
||||
核心循环是一个 `while(true)` 的迭代:
|
||||
### 3. Agentic Loop(`src/query.ts`)
|
||||
|
||||
`query()` 是一个 `AsyncGenerator`,`while(true)` 循环的每次迭代包含:
|
||||
|
||||
```
|
||||
上下文预处理 → API 调用 → 解析响应 → 工具执行 → 结果回传 → 再次调用 → 循环
|
||||
① 上下文预处理管道:
|
||||
applyToolResultBudget → snipCompact → microcompact → contextCollapse → autocompact
|
||||
|
||||
② 流式 API 调用:
|
||||
deps.callModel() → AsyncGenerator<StreamEvent | Message>
|
||||
收集 assistantMessages[]、toolUseBlocks[]
|
||||
|
||||
③ 工具执行:
|
||||
StreamingToolExecutor(并行) 或 runTools(串行)
|
||||
→ toolResults[]
|
||||
|
||||
④ 终止/继续判定:
|
||||
needsFollowUp ? continue : return { reason }
|
||||
```
|
||||
|
||||
**上下文预处理管道**是循环中最精巧的部分。在每次 API 调用前,系统会依次检查:
|
||||
- 单条工具输出是否过长 → 截断
|
||||
- 对话历史是否接近 token 上限 → 自动压缩
|
||||
- 是否需要紧急压缩 → API 返回 token 超限错误时触发
|
||||
完整的状态机通过 `State` 类型(`src/query.ts:207`)在迭代间传递,包含 10 个字段(messages、autoCompactTracking、maxOutputTokensRecoveryCount 等)。
|
||||
|
||||
这套管道确保 AI 始终在有效的上下文窗口内工作。
|
||||
### 4. 工具层(`src/tools.ts` → `src/Tool.ts`)
|
||||
|
||||
### 4. 核心循环 → 工具层
|
||||
`getAllBaseTools()`(`src/tools.ts:195`)组装 50+ 工具列表,经过 `filterToolsByDenyRules()` 权限过滤后传给 API。
|
||||
|
||||
工具执行支持**并行和串行两种模式**。多个独立的工具调用可以并行执行(如同时读两个文件),有依赖关系的调用则串行执行。这个设计直接影响用户体验——并行意味着更快的响应速度。
|
||||
每个工具实现 `Tool<Input, Output, Progress>` 接口(`src/Tool.ts:368`),核心方法链:
|
||||
```
|
||||
validateInput() → canUseTool()(UI 层)→ checkPermissions() → call() → ToolResult
|
||||
```
|
||||
|
||||
### 5. 工具层 → 核心循环 → 通信层
|
||||
### 5. 通信层(`src/services/api/claude.ts`)
|
||||
|
||||
工具执行结果回传到核心循环,拼入消息数组,再次调用 API。AI 根据工具结果决定:
|
||||
- 继续调用工具(任务未完成)
|
||||
- 返回最终回答(任务完成)
|
||||
- 请求用户输入(需要决策)
|
||||
API 客户端支持 7 种 Provider:
|
||||
- **Anthropic Direct (firstParty)**:默认
|
||||
- **AWS Bedrock**:`ANTHROPIC_BEDROCK_BASE_URL`
|
||||
- **Google Vertex**:`ANTHROPIC_VERTEX_PROJECT_ID`
|
||||
- **Foundry**:`ANTHROPIC_CODE_USE_FOUNDRY`
|
||||
- **OpenAI**:兼容层
|
||||
- **Gemini**:兼容层
|
||||
- **Grok (xAI)**:兼容层
|
||||
|
||||
### 6. 终止条件
|
||||
|
||||
循环不是无限运行的。终止条件包括:
|
||||
- AI 不再请求工具调用(任务完成)
|
||||
- 用户主动中断
|
||||
- 达到最大 turn 数限制
|
||||
- Token 预算耗尽
|
||||
`deps.callModel()` 发起流式请求,返回 `BetaRawMessageStreamEvent` 事件流。支持 Prompt Cache(`cache_control`)、thinking blocks、multi-turn tool use。
|
||||
|
||||
## 四个核心设计原则
|
||||
|
||||
### 流式优先(Streaming-first)
|
||||
<AccordionGroup>
|
||||
<Accordion title="流式优先 (Streaming-first)">
|
||||
所有 API 通信都是流式的——`deps.callModel()` 返回 AsyncGenerator,用户看到 AI "逐字打出"回答。StreamingToolExecutor 在流式过程中就开始并行执行工具,不等流结束。模型降级(Fallback)时,已收集的 assistantMessages 被标记为 tombstone 并清空,重试整个流式请求。
|
||||
</Accordion>
|
||||
<Accordion title="工具即能力 (Tool as Capability)">
|
||||
每个工具是 `Tool<Input, Output, Progress>` 结构化类型,通过 `buildTool()` 工厂创建。`getTools()` 在每次 API 调用时组装(非全局缓存),因为 `isEnabled()` 可能随运行时状态变化。MCP 工具通过 `mcpInfo` 字段标记来源,支持 server 级别的 blanket deny。
|
||||
</Accordion>
|
||||
<Accordion title="权限即边界 (Permission as Boundary)">
|
||||
每次工具调用经过 `validateInput() → checkPermissions()` 双重检查。权限规则从 5 个来源汇聚(session → project → user → managed → default),支持工具名、命令模式、路径前缀等匹配方式。Plan Mode 通过 `prepareContextForPlanMode()` 切换为只读模式,退出时自动恢复。
|
||||
</Accordion>
|
||||
<Accordion title="上下文即记忆 (Context as Memory)">
|
||||
System Prompt 由 `fetchSystemPromptParts()` 动态组装,包含 CLAUDE.md、git 状态、日期、MCP 服务器列表。Auto-compact 在每轮迭代前评估 token 阈值,超出时触发压缩。压缩后的摘要通过 `buildPostCompactMessages()` 替换原始消息,`taskBudgetRemaining` 跨压缩边界累计。
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
所有 API 通信都是流式的。用户看到 AI "逐字打出"回答,工具执行在流式过程中就开始并行执行,不需要等流结束。
|
||||
## 入口与引导
|
||||
|
||||
当模型需要降级(如从 Opus 降到 Sonnet)时,已收集的响应被标记为历史记录,整个流式请求重新发起。
|
||||
|
||||
### 工具即能力(Tool as Capability)
|
||||
|
||||
每个工具是一个结构化的类型定义,包含输入验证、权限检查和执行逻辑三个阶段。工具列表在每次 API 调用时动态组装(不是全局缓存),因为工具的可用性可能随运行时状态变化(如 MCP 服务器连接状态)。
|
||||
|
||||
### 权限即边界(Permission as Boundary)
|
||||
|
||||
每次工具调用经过"输入验证 → 权限检查"双重检查。权限规则从五个来源汇聚(会话级 → 项目级 → 用户级 → 管理级 → 默认级),优先级从高到低。
|
||||
|
||||
这个分层设计意味着:团队可以为整个项目设置统一的权限策略(项目级),同时允许个人覆盖(用户级),管理员还可以强制执行安全策略(管理级)。
|
||||
|
||||
### 上下文即记忆(Context as Memory)
|
||||
|
||||
System Prompt 在每轮调用时动态组装,包含项目结构、git 状态、用户指令(CLAUDE.md)等信息。组装策略是"不变内容在前、变化内容在后",利用 API 的前缀缓存机制减少重复计算。
|
||||
|
||||
自动压缩在每轮迭代前评估 token 阈值,超出时用 AI 自身来总结之前的对话,保留语义信息的同时减少 token 占用。
|
||||
|
||||
## 入口概览
|
||||
|
||||
| 入口 | 说明 | 使用场景 |
|
||||
|------|------|----------|
|
||||
| CLI 启动 | 加载配置、认证、启动 REPL | 日常交互式使用 |
|
||||
| 管道模式 | 从 stdin 读取输入,输出到 stdout | 嵌入 CI/CD 和脚本 |
|
||||
| 远程控制 | 通过 Bridge API 远程控制 CLI | 从 claude.ai 或手机发送指令 |
|
||||
| Daemon 模式 | 长驻后台的 supervisor 进程 | 持续运行的自动化任务 |
|
||||
|
||||
## 接下来
|
||||
|
||||
现在你已经理解了整体架构。以下是推荐的深入路径:
|
||||
|
||||
- **Agent Loop** — 深入核心循环的每一轮迭代细节
|
||||
- **工具系统** — 了解 50+ 工具的设计和分类
|
||||
- **上下文工程** — 理解 token 预算和自动压缩机制
|
||||
- **安全机制** — 了解权限模型和沙箱设计
|
||||
| 入口 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| CLI 启动 | `src/entrypoints/cli.tsx` | 注入 `feature()` polyfill(始终返回 false)、MACRO 全局变量 |
|
||||
| 命令定义 | `src/main.tsx` | Commander.js 解析参数,初始化 auth/analytics/policy |
|
||||
| 一次性初始化 | `src/entrypoints/init.ts` | 遥测配置、信任对话框 |
|
||||
| 管道模式 | `src/main.tsx` `-p` flag | `echo "say hello" \| bun run dev -p` |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: "项目介绍"
|
||||
description: "Claude Code 是运行在终端中的 agentic coding system。理解它的设计定位、架构选择和核心能力。"
|
||||
keywords: ["Claude Code", "AI 编程助手", "Agentic Coding", "终端 AI"]
|
||||
title: "什么是 Claude Code - Terminal Native Agentic Coding System"
|
||||
description: "Claude Code 是运行在终端中的 agentic coding system,直接在你的项目目录中读代码、改文件、跑命令、调试程序。了解它的技术定位、架构差异和核心能力。"
|
||||
keywords: ["Claude Code", "AI 编程助手", "Agentic Coding", "终端 AI", "CLI AI"]
|
||||
og:image: "https://ccb.agent-aura.top/docs/images/og-cover.png"
|
||||
---
|
||||
|
||||
@@ -9,104 +9,103 @@ og:image: "https://ccb.agent-aura.top/docs/images/og-cover.png"
|
||||
|
||||
Claude Code 是一个**运行在本地终端中的 agentic coding system**。它不是给建议的聊天机器人——它直接在你的项目目录中读代码、改文件、跑命令、调试程序,拥有完整的 shell 能力。
|
||||
|
||||
## 设计定位
|
||||
## 技术定位:terminal-native agentic system
|
||||
|
||||
理解 Claude Code 的关键在于三个词:
|
||||
|
||||
| 定位 | 含义 | 设计影响 |
|
||||
|------|------|----------|
|
||||
| **Terminal-native** | 原生 CLI 应用,不是 IDE 插件也不是 Web 界面 | 拥有完整 shell 访问权,但需要自建权限模型和安全沙箱 |
|
||||
| **Agentic** | AI 自主决定调用什么工具、传什么参数、何时停止 | 不是"一问一答",而是多轮自主决策循环 |
|
||||
| **Coding system** | 面向软件工程全流程设计 | 工具集覆盖文件操作、命令执行、代码搜索、任务管理 |
|
||||
| 定位关键词 | 含义 |
|
||||
|-----------|------|
|
||||
| **Terminal-native** | 原生 CLI 应用,不是 IDE 插件、不是 Web 界面、不是 API wrapper |
|
||||
| **Agentic** | AI 自主决策工具调用链,不是"一问一答"的聊天模式 |
|
||||
| **Coding system** | 面向软件工程全流程,不是通用问答工具 |
|
||||
|
||||
### 为什么选择终端
|
||||
与同类工具的**架构层面**差异(不是功能清单):
|
||||
|
||||
终端不是限制,而是一个有意为之的架构选择。它带来了独特的能力,也带来了对应的代价:
|
||||
| 工具 | 架构模式 | 运行位置 | 工具执行 |
|
||||
|------|----------|----------|----------|
|
||||
| **Claude Code** | Terminal-native agentic loop | 本地进程 | 直接 shell 执行 |
|
||||
| Cursor / Copilot | IDE-integrated autocomplete + chat | IDE 进程内 | LSP / IDE API |
|
||||
| Aider | CLI chat → git patch | 本地进程 | 文件操作为主 |
|
||||
| ChatGPT / Claude.ai | Cloud chat + artifacts | 浏览器/云端 | 沙箱容器 |
|
||||
|
||||
**优势:**
|
||||
- **完整的 shell 访问** — 可以运行任何命令行工具,无需为每个能力写插件
|
||||
- **项目原生** — 直接在项目目录工作,天然理解文件系统结构和 git 状态
|
||||
- **可组合性** — 管道模式(`echo "..." | claude -p`)允许嵌入 CI/CD 和自动化流程
|
||||
- **低延迟** — 没有 Electron 开销,React/Ink 渲染的 TUI 响应极快
|
||||
核心差异:Claude Code 拥有**完整的 shell 访问权**——这意味着它可以做任何你在终端里能做的事,但也需要对应的安全机制来约束这个能力。
|
||||
|
||||
**代价:**
|
||||
- 用户需要适应命令行界面
|
||||
- 没有 GUI 意味着无法直接展示图片、图表等富内容
|
||||
- 权限管理的复杂度远高于 IDE 插件(因为能力边界更大)
|
||||
## 端到端示例:从输入到输出
|
||||
|
||||
### 与同类工具的架构差异
|
||||
|
||||
| 工具 | 架构模式 | 工具执行方式 | 安全边界 |
|
||||
|------|----------|-------------|----------|
|
||||
| **Claude Code** | Terminal-native agentic loop | 直接 shell 执行 | 自建权限模型 + 沙箱 |
|
||||
| Cursor / Copilot | IDE-integrated autocomplete + chat | LSP / IDE API | IDE 沙箱天然隔离 |
|
||||
| Aider | CLI chat → git patch | 文件操作为主 | 通过 git diff 约束变更范围 |
|
||||
| ChatGPT / Claude.ai | Cloud chat + artifacts | 沙箱容器 | 云端容器隔离 |
|
||||
|
||||
核心区别:Claude Code 把 AI 放在了**与用户相同的权限层级**上。这既是它最强大的地方,也是安全设计最复杂的挑战。
|
||||
|
||||
## 端到端:一次任务的生命周期
|
||||
|
||||
当你在终端中输入 `bun run dev 有个 TypeScript 报错,帮我修一下` 时,系统经历了什么?
|
||||
当你在终端中输入 `bun run dev 有个 TypeScript 报错,帮我修一下` 时,系统发生了什么?
|
||||
|
||||
```
|
||||
用户输入 → REPL 捕获 → 上下文组装 → API 调用 → 流式响应
|
||||
↓
|
||||
解析工具调用
|
||||
↓
|
||||
权限检查 → 执行工具
|
||||
↓
|
||||
结果回传 → 再次调 API
|
||||
↓
|
||||
循环直到完成
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 1. 入口层 (cli.tsx → main.tsx) │
|
||||
│ feature() = false, MACRO 注入, 启动 Commander.js CLI │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 2. 交互层 (REPL.tsx — React/Ink) │
|
||||
│ PromptInput 捕获用户输入 → UserMessage 加入会话 │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 3. 编排层 (QueryEngine.ts) │
|
||||
│ 管理 turn 生命周期、token 预算、compaction 触发 │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 4. 核心循环 (query.ts — Agentic Loop) │
|
||||
│ 组装上下文 → 调 API → 收流式响应 → 解析工具调用 │
|
||||
│ → 权限检查 → 执行工具 → 结果回传 → 再次调 API → 循环 │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 5. 工具执行 (BashTool.call / FileEditTool.call / ...) │
|
||||
│ 实际执行: 读文件、运行命令、搜索代码... │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 6. 通信层 (claude.ts → Anthropic API) │
|
||||
│ 流式 HTTP, 支持 Bedrock/Vertex/Foundry 等 7 种 provider │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
具体到这个报错修复场景,一次典型的 agentic loop:
|
||||
具体到这个报错修复场景,一次典型的 agentic loop 可能包含多轮工具调用:
|
||||
|
||||
| 步骤 | AI 自主决策 | 工具调用 | 设计考量 |
|
||||
|------|------------|----------|----------|
|
||||
| 1 | 先复现报错 | `Bash("bun run dev 2>&1 | head -30")` | AI 自行决定先诊断再修复,而非直接改代码 |
|
||||
| 2 | 定位到问题文件 | `Read("src/utils/foo.ts")` | 读取而非猜测,保证修改基于最新代码 |
|
||||
| 3 | 搜索相关类型定义 | `Grep("interface Foo", "src/")` | 跨文件理解依赖关系 |
|
||||
| 4 | 修改代码 | `FileEdit(old, new)` | 精确替换而非全文重写,保留 git 历史可追溯 |
|
||||
| 5 | 验证修复 | `Bash("bun run dev 2>&1 | head -10")` | 自主验证是 agentic 系统的关键闭环 |
|
||||
| Turn | AI 决策 | 工具调用 | 结果 |
|
||||
|------|---------|----------|------|
|
||||
| 1 | 先看报错信息 | `Bash("bun run dev 2>&1 | head -30")` | TypeScript 错误输出 |
|
||||
| 2 | 定位到文件 | `Read("src/utils/foo.ts")` | 源代码内容 |
|
||||
| 3 | 搜索相关类型定义 | `Grep("interface Foo", "src/")` | 类型定义位置 |
|
||||
| 4 | 修复代码 | `FileEdit(old, new)` | 代码已修改 |
|
||||
| 5 | 验证修复 | `Bash("bun run dev 2>&1 | head -10")` | 编译通过 |
|
||||
|
||||
每一步都是 AI 自主决策的。它决定用哪个工具、传什么参数、何时停止——这就是 "agentic" 的含义。
|
||||
|
||||
## 核心设计原则
|
||||
|
||||
这些原则贯穿整个系统的设计:
|
||||
|
||||
**1. 能力优先,安全兜底**
|
||||
|
||||
系统设计的第一步是"让 AI 能做什么",然后才是"如何限制它"。这导致工具系统非常强大(完整 shell),但需要配套的权限模型(每次敏感操作可配置为需用户确认)。
|
||||
|
||||
**2. 本地优先**
|
||||
|
||||
所有代码执行都在本地机器上,不经过云端沙箱。这意味着:
|
||||
- 用户对数据有完全控制权
|
||||
- 可以访问本地文件系统、网络、硬件
|
||||
- 但也需要自己承担安全风险
|
||||
|
||||
**3. 流式一切**
|
||||
|
||||
从 API 响应到工具执行结果,所有数据都以流的方式处理。这让用户可以在 AI 思考的同时看到进展,也使得长时间运行的任务可以中途取消。
|
||||
|
||||
**4. 上下文工程**
|
||||
|
||||
系统花大量精力在"给 AI 看什么"上——自动收集 git 状态、项目结构、CLAUDE.md 指令、历史记忆等。上下文的质量直接决定 AI 的表现。
|
||||
每一步都是 AI 自主决策的——它决定用哪个工具、传什么参数、何时停止。这就是 "agentic" 的含义。
|
||||
|
||||
## 它不是什么
|
||||
|
||||
理解边界和了解能力一样重要:
|
||||
- **不是 IDE 插件**:没有图形界面,不依赖 VS Code 或任何 IDE
|
||||
- **不是 API wrapper**:它有自己的工具系统、权限模型、上下文工程、会话管理
|
||||
- **不是聊天机器人**:输出不是纯文本,而是实际的文件修改、命令执行
|
||||
- **不是无脑执行器**:每个敏感操作都有权限检查和用户确认环节
|
||||
|
||||
- **不是 IDE 插件** — 没有图形界面,不依赖任何 IDE
|
||||
- **不是 API wrapper** — 它有自己的工具系统、权限模型、上下文工程、会话管理
|
||||
- **不是聊天机器人** — 输出不是纯文本建议,而是实际的文件修改和命令执行
|
||||
- **不是无脑执行器** — 每个敏感操作都有权限检查,系统内置规划模式用于复杂任务
|
||||
## 启动入口解剖
|
||||
|
||||
## 接下来
|
||||
真正的代码入口是 `src/entrypoints/cli.tsx`,它做了三件关键的事:
|
||||
|
||||
- **架构总览** — 了解系统的模块划分和数据流
|
||||
- **Agent Loop** — 深入理解核心循环的工作机制
|
||||
- **工具系统** — 了解 AI 拥有哪些工具及其设计考量
|
||||
```typescript
|
||||
// 1. 注入运行时 polyfill —— feature() 永远返回 false
|
||||
const feature = (_name: string) => false;
|
||||
|
||||
// 2. 注入构建时宏
|
||||
globalThis.MACRO = { VERSION: "2.1.888", BUILD_TIME: ..., };
|
||||
|
||||
// 3. 声明构建目标
|
||||
globalThis.BUILD_TARGET = "external"; // 外部构建(非 Anthropic 内部)
|
||||
globalThis.BUILD_ENV = "production";
|
||||
globalThis.INTERFACE_TYPE = "stdio"; // 标准 I/O 交互
|
||||
```
|
||||
|
||||
然后控制流传递到 `src/main.tsx`:
|
||||
1. Commander.js 解析命令行参数
|
||||
2. 初始化认证、遥测、策略限制
|
||||
3. 加载工具列表(`getTools()`)
|
||||
4. 启动 REPL(`launchRepl()`)或管道模式(`-p`)
|
||||
|
||||
## 为什么选择终端
|
||||
|
||||
终端不是限制,而是选择。它带来了独特的能力:
|
||||
|
||||
- **完整的 shell 访问**:可以运行任何命令行工具,无需为每个能力写插件
|
||||
- **项目原生**:直接在项目目录工作,理解文件系统结构、git 状态
|
||||
- **可组合性**:管道模式(`echo "..." | claude -p`)允许嵌入 CI/CD 和自动化流程
|
||||
- **低延迟**:没有 Electron 开销,React/Ink 渲染的 TUI 响应极快
|
||||
|
||||
代价是用户需要适应命令行界面——但也正因如此,它吸引的是需要**真正掌控开发环境**的开发者。
|
||||
|
||||
@@ -1,155 +1,121 @@
|
||||
---
|
||||
title: "项目动机"
|
||||
description: "Claude Code 解决了什么工程问题?为什么 agentic coding 需要一整套独立的设计体系?理解这些设计决策背后的动机。"
|
||||
keywords: ["Claude Code", "设计动机", "Agentic Coding", "工程决策"]
|
||||
og:image: "https://ccb.agent-aura.top/docs/images/og-cover.png"
|
||||
title: "为什么写这份白皮书 - Claude Code 逆向工程分析"
|
||||
description: "对 Anthropic 官方 Claude Code CLI 的逆向工程分析白皮书。通过反编译 TypeScript 单文件 bundle,深入解析运行时行为与源码结构。"
|
||||
keywords: ["Claude Code", "逆向工程", "白皮书", "反编译", "TypeScript"]
|
||||
---
|
||||
|
||||
## 为什么需要这篇文章
|
||||
## 这份白皮书是什么
|
||||
|
||||
理解一个系统的"是什么"只是第一步。真正有用的是理解**为什么这样设计**——每个架构选择的动机、权衡和代价。
|
||||
这是对 Anthropic 官方发布的 **Claude Code CLI** 的**逆向工程分析**。
|
||||
|
||||
本文梳理 Claude Code 最核心的五个设计决策,解释它们解决了什么问题、放弃了什么、以及这些决策如何相互影响。
|
||||
源码经过反编译处理(TypeScript 单文件 bundle 逆向),保留了核心功能模块,但包含大量 `unknown`/`never`/`{}` 类型错误——这些不影响 Bun 运行时执行,但意味着我们的分析基于运行时行为 + 残留源码结构,而非原始源码。
|
||||
|
||||
## 决策一:Agentic Loop 而非 Chat
|
||||
**这不是:**
|
||||
- 官方文档或使用教程
|
||||
- API 参考手册
|
||||
- Claude Code 的功能推销
|
||||
|
||||
### 问题
|
||||
**这是:**
|
||||
- 一个生产级 agentic system 的架构解构
|
||||
- 每个设计决策背后的"为什么"
|
||||
- 可复用的工程模式:agentic loop、工具抽象、上下文工程、安全纵深防御
|
||||
|
||||
大多数 AI 编程工具采用"一问一答"模式:用户描述问题,AI 给出建议,用户手动执行。这个模式的瓶颈在于——人类成为了 AI 和代码之间的中转站。
|
||||
## 逆向过程中最精妙的设计决策
|
||||
|
||||
### 决策
|
||||
### 1. Agentic Loop 的自愈能力
|
||||
|
||||
Claude Code 采用 **agentic loop**:AI 自主决定调用什么工具、传什么参数、何时停止。用户只需要描述目标,AI 自己规划执行路径。
|
||||
`src/query.ts` 实现的核心循环不是简单的"发请求→收响应"。它是一个**自愈的状态机**:
|
||||
|
||||
### 关键设计
|
||||
- API 返回错误(限流、token 超限)→ 自动重试/降级
|
||||
- 工具执行超时 → 后台化 + 通知机制
|
||||
- 对话过长触发 compaction → 压缩历史后无缝继续
|
||||
- 用户中断 → 生成 `UserInterruptionMessage` 让 AI 理解发生了什么
|
||||
|
||||
这个循环不是简单的"发请求→收响应",而是一个**自愈的状态机**:
|
||||
这不是"if-else 堆叠",而是让 AI 自己根据上下文决定下一步——即使发生了意外。
|
||||
|
||||
- API 返回错误(限流、token 超限)→ 自动重试或降级处理
|
||||
- 工具执行超时 → 后台化运行,通过通知机制回馈结果
|
||||
- 对话过长触发 compaction → 自动压缩历史后无缝继续
|
||||
- 用户中途打断 → 系统生成中断消息让 AI 理解发生了什么
|
||||
### 2. 上下文工程的分层策略
|
||||
|
||||
核心思想:**让 AI 根据上下文自己决定下一步**,即使是意外情况也不需要人类介入。
|
||||
AI 没有真正的"记忆",Claude Code 通过精心分层营造了这个幻觉:
|
||||
|
||||
### 代价
|
||||
| 层 | 机制 | 持久性 |
|
||||
|----|------|--------|
|
||||
| **System Prompt** | 项目结构、git 状态、CLAUDE.md | 每轮重建 |
|
||||
| **对话历史** | 完整的 User/Assistant/Tool 消息 | 会话内 |
|
||||
| **Compaction** | 自动压缩过长对话为摘要 | 压缩后替代原始消息 |
|
||||
| **Memory 文件** | 跨会话持久化的笔记 | 永久(用户可控) |
|
||||
| **File History** | 文件修改时间戳快照 | 会话内 |
|
||||
|
||||
- 更高的 token 消耗(AI 需要多轮工具调用才能完成一个任务)
|
||||
- 需要精心设计的权限模型(自主性越高,失控风险越大)
|
||||
- 调试困难(AI 的决策链不总是可预测的)
|
||||
`src/context.ts` 组装 System Prompt 时的策略是:**不变内容在前、变化内容在后**——这利用了 API 的缓存机制,前缀不变时可以复用缓存 token。
|
||||
|
||||
## 决策二:终端原生而非 IDE 插件
|
||||
### 3. 工具系统的权限双轨制
|
||||
|
||||
### 问题
|
||||
`packages/builtin-tools/src/tools/BashTool/shouldUseSandbox.ts` 展示了一个精巧的双重安全模型:
|
||||
|
||||
IDE 插件可以复用 IDE 的 UI 框架、权限沙箱和用户习惯。为什么还要做一个独立的终端应用?
|
||||
- **应用层**:权限规则决定"能不能执行"(白名单/黑名单/用户确认)
|
||||
- **OS 层**:沙箱决定"执行时能做什么"(文件系统/网络/进程隔离)
|
||||
|
||||
### 决策
|
||||
两层的信任假设不同:应用层信任用户配置,OS 层不信任任何东西。即使 AI 绕过了应用层权限(理论上不可能,但纵深防御),OS 层沙箱仍然限制实际危害。
|
||||
|
||||
选择终端原生(Terminal-native)架构。CLI 拥有与用户相同的 shell 权限,可以运行任何命令行工具。
|
||||
### 4. Feature Flag 的全局开关
|
||||
|
||||
### 动机
|
||||
`src/entrypoints/cli.tsx` 中一行代码决定了整个系统的行为:
|
||||
|
||||
1. **能力边界最大化** — IDE 插件受限于 IDE 提供的 API;终端应用可以做任何用户能在终端里做的事
|
||||
2. **可组合性** — 管道模式让 AI 能力可以嵌入 CI/CD、脚本和自动化流程
|
||||
3. **零依赖** — 不需要安装特定 IDE,任何有终端的环境都能运行
|
||||
|
||||
### 代价
|
||||
|
||||
- 必须自建整个权限模型和安全沙箱(IDE 天然提供这些)
|
||||
- 用户门槛更高(需要适应命令行)
|
||||
- 没有 GUI 意味着无法直接展示富内容
|
||||
|
||||
## 决策三:上下文工程的分层策略
|
||||
|
||||
### 问题
|
||||
|
||||
AI 没有真正的"记忆"。每次 API 调用都是无状态的——模型只看到当前请求中的内容。如何在一个 200K token 的窗口内让 AI "记住"足够多的上下文?
|
||||
|
||||
### 决策
|
||||
|
||||
通过精心分层来营造"记忆"的幻觉:
|
||||
|
||||
| 层 | 机制 | 生命周期 | 设计目的 |
|
||||
|----|------|----------|----------|
|
||||
| **System Prompt** | 项目结构、git 状态、用户指令 | 每轮重建 | 让 AI 理解当前环境 |
|
||||
| **对话历史** | 完整的用户/AI/工具消息流 | 会话内 | 保持任务连贯性 |
|
||||
| **Compaction** | AI 自主压缩过长对话为摘要 | 自动触发 | 在有限窗口内保留语义 |
|
||||
| **Memory 文件** | 跨会话持久化的笔记文件 | 永久 | 跨会话保留关键信息 |
|
||||
|
||||
### 关键洞察
|
||||
|
||||
System Prompt 的组装策略是**不变内容在前、变化内容在后**。这不是随意排列——它利用了 API 的前缀缓存机制:不变的系统指令部分可以复用缓存的 token,只有变化的部分需要重新计算。
|
||||
|
||||
### 代价
|
||||
|
||||
- Compaction 会丢失细节(压缩毕竟是信息损失)
|
||||
- Memory 文件需要用户主动维护才有用
|
||||
- 整个策略的复杂度很高,任何一环出问题都会影响 AI 表现
|
||||
|
||||
## 决策四:权限的双轨制
|
||||
|
||||
### 问题
|
||||
|
||||
AI 拥有完整 shell 权限意味着它可以做任何事——包括删除文件、发送网络请求、安装软件包。如何在保持能力的同时控制风险?
|
||||
|
||||
### 决策
|
||||
|
||||
采用**纵深防御**的双轨权限模型:
|
||||
|
||||
- **应用层**(权限规则)— 决定"能不能执行"。通过白名单、黑名单和用户确认三级控制
|
||||
- **OS 层**(沙箱)— 决定"执行时能做什么"。通过文件系统、网络和进程隔离限制实际行为
|
||||
|
||||
### 设计哲学
|
||||
|
||||
两层的**信任假设不同**:应用层信任用户配置和 AI 的判断力;OS 层不信任任何东西。即使应用层被绕过(纵深防御的思路),OS 层仍然限制实际危害。
|
||||
|
||||
### 代价
|
||||
|
||||
- 双轨制增加了系统复杂度
|
||||
- 频繁的权限确认会打断工作流(因此需要"自动模式"等快捷方案)
|
||||
- 沙箱不是所有平台都支持(目前主要是 macOS)
|
||||
|
||||
## 决策五:Feature Flag 驱动的渐进发布
|
||||
|
||||
### 问题
|
||||
|
||||
一个大型系统需要同时服务不同用户群体:内部测试者、早期用户、正式用户。如何在不维护多套代码的情况下控制功能可见性?
|
||||
|
||||
### 决策
|
||||
|
||||
所有实验性功能通过 feature flag 控制。默认情况下所有 flag 关闭,不同的运行模式(开发/构建/发布)启用不同的 flag 子集。
|
||||
|
||||
### 设计效果
|
||||
|
||||
- **同一个代码库** — 不需要维护功能分支
|
||||
- **运行时切换** — 通过环境变量即时启用或关闭功能
|
||||
- **渐进式发布** — 可以先对内部用户开放,再逐步扩大范围
|
||||
- **安全回退** — 发现问题时关闭 flag 即可,不需要回滚代码
|
||||
|
||||
### 代价
|
||||
|
||||
- 代码中到处都是 `if (feature('X'))` 分支,增加阅读复杂度
|
||||
- flag 之间的依赖关系需要手动管理
|
||||
- 测试组合爆炸(每个 flag 的开/关都需要考虑)
|
||||
|
||||
## 这些决策如何相互影响
|
||||
|
||||
这五个决策不是孤立的——它们形成了一个互相支撑的系统:
|
||||
|
||||
```
|
||||
Agentic Loop(自主决策)
|
||||
└── 需要强大的工具系统 → 终端原生架构
|
||||
└── 需要安全约束 → 双轨权限模型
|
||||
└── 需要在有限窗口内工作 → 上下文分层策略
|
||||
└── 需要渐进式发布 → Feature Flag 体系
|
||||
```typescript
|
||||
const feature = (_name: string) => false;
|
||||
```
|
||||
|
||||
每个决策都为其他决策创造了前提条件。这也是为什么理解"动机"比理解"实现"更重要——知道了为什么,才能判断在什么情况下应该偏离这些选择。
|
||||
所有 `feature('FLAG_NAME')` 调用返回 `false`——这意味着 Anthropic 内部的实验功能(COORDINATOR_MODE、KAIROS、PROACTIVE 等)全部禁用。在官方构建中,这些 flag 通过 Bun 的 `bun:bundle` 在编译时注入,不同用户群体看到不同功能。
|
||||
|
||||
## 适合谁读这套文档
|
||||
这是一个**渐进式发布架构**:同一个代码库,通过 feature flag 控制功能可见性,而不需要维护多个分支。
|
||||
|
||||
- **AI Agent 开发者** — 想理解生产级 agentic system 的架构模式
|
||||
- **安全工程师** — 对 AI 操作真实环境时的信任模型感兴趣
|
||||
- **工具构建者** — 正在构建类似的 coding assistant 或 CLI 工具
|
||||
- **好奇心驱动的开发者** — 想知道"AI 编程助手到底怎么工作的"
|
||||
### 5. Compaction 的分档策略
|
||||
|
||||
`src/services/compact/` 实现了三种压缩策略:
|
||||
|
||||
- **Micro-compact**:单次工具输出过长时,截断结果
|
||||
- **Auto-compact**:对话 token 接近上限时,自动压缩历史
|
||||
- **Reactive-compact**:API 返回 token 超限错误时,紧急压缩后重试
|
||||
|
||||
这不是简单的"砍掉旧消息"——而是用 AI 自身来总结之前的对话,保留语义信息。压缩后插入一条 `TombstoneMessage` 标记边界。
|
||||
|
||||
## 阅读路线图
|
||||
|
||||
推荐的阅读顺序,每章解决一个核心问题:
|
||||
|
||||
```
|
||||
什么是 Claude Code (你在读的) ← 建立直觉
|
||||
│
|
||||
├── 架构全景 ← 五层架构 + 数据流
|
||||
│
|
||||
├── 安全体系 ← 信任与控制
|
||||
│ ├── 权限模型 ← 应用层安全
|
||||
│ ├── 沙箱机制 ← OS 层安全
|
||||
│ └── Plan Mode ← 用户主导模式
|
||||
│
|
||||
├── 对话引擎 ← AI 如何思考
|
||||
│ ├── Agentic Loop ← 核心循环
|
||||
│ ├── 流式响应 ← 实时通信
|
||||
│ └── 多轮对话 ← 上下文管理
|
||||
│
|
||||
├── 上下文工程 ← 记忆与预算
|
||||
│ ├── System Prompt ← 上下文组装
|
||||
│ ├── Token 预算 ← 预算管理
|
||||
│ └── 项目记忆 ← 跨会话持久化
|
||||
│
|
||||
├── 工具系统 ← AI 的双手
|
||||
│ ├── 工具概览 ← 统一接口
|
||||
│ ├── Shell 执行 ← Bash 工具
|
||||
│ └── 搜索与导航 ← Glob/Grep
|
||||
│
|
||||
└── Agent 与扩展 ← 能力扩展
|
||||
├── 子 Agent ← 并行任务
|
||||
├── 自定义 Agent ← 用户定义
|
||||
└── MCP 协议 ← 外部工具接入
|
||||
```
|
||||
|
||||
## 适合谁读
|
||||
|
||||
- **AI Agent 开发者**:想理解生产级 agentic system 的架构模式
|
||||
- **安全工程师**:对 AI 操作真实环境时的信任模型感兴趣
|
||||
- **工具构建者**:正在构建类似的 coding assistant 或 CLI 工具
|
||||
- **好奇心驱动的开发者**:想知道"AI 编程助手到底怎么工作的"
|
||||
|
||||
@@ -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 | 否 | 工作区目录路径 |
|
||||
|
||||
659
docs/memory-leak-audit.md
Normal file
659
docs/memory-leak-audit.md
Normal file
@@ -0,0 +1,659 @@
|
||||
# 内存泄漏排查报告
|
||||
|
||||
> 基于官方 CHANGELOG 记录的 11 个已修复内存泄漏 + 1 个代码注释中的已知问题,对反编译代码库进行逐文件验证。
|
||||
> 审计日期:2026-04-28
|
||||
|
||||
## TODO
|
||||
|
||||
- [x] #1 图片处理无限内存增长 — 确认已实现 ✅
|
||||
- [x] #2 /usage 命令泄漏约 2GB — 确认已实现 ✅
|
||||
- [x] #3 长时间运行工具进度事件泄漏 — 确认已实现 ✅
|
||||
- [x] #4 空闲重新渲染循环 — **已确认完整**:所有 10 个 useAnimationFrame 调用者均正确传递 null 暂停时钟,keepAlive 机制工作正常
|
||||
- [x] #5 虚拟滚动器保留历史消息拷贝 — 确认已实现 ✅
|
||||
- [x] #6 管道模式超宽行过度分配 — 确认已实现 ✅
|
||||
- [x] #7 语言语法按需加载 — **已修复**:改用 highlight.js/lib/core + 静态注册 26 个常用语言,从 190+ 语言降至 ~25,内存减少 ~80%
|
||||
- [x] #8 NO_FLICKER 模式流状态泄漏 — **已修复**:StreamingToolExecutor.discard() 现在完整释放 tools 数组、中止 siblingAbortController、清理 turnSpan,7 tests
|
||||
- [x] #9 Remote Control 权限条目保留 — **已修复**:pendingPermissionHandlers 提升至 useEffect 作用域,cleanup 时显式 clear(),8 tests
|
||||
- [x] #10 MCP HTTP/SSE 缓冲区累积 — 确认已实现 ✅
|
||||
- [x] #11 LRU 缓存键保留大 JSON — **已确认完整实现**:FileStateCache 使用 LRU 双重限制(max 100 条目 + maxSize 25MB)+ sizeCalculation,22 tests
|
||||
- [x] #12 QueryEngine.mutableMessages 不收缩 — **已修复**:实现 snipCompactIfNeeded(按 removedUuids 过滤)+ snipProjection(边界检测 + 视图投影),28 tests
|
||||
- [x] #18 Permission Polling Interval 泄漏 — **已修复**:inProcessRunner 权限响应后未调用 cleanup(),导致 setInterval 永远运行 + abort listener 挂载,6 tests
|
||||
- [x] #17 LSP Opened Files Map 不收缩 — **已修复**:LSPServerManager 添加 closeAllFiles() 方法,postCompactCleanup 集成调用,compaction 后释放 openedFiles Map,5 tests
|
||||
|
||||
## 总览
|
||||
---
|
||||
|
||||
## 1. 图片处理无限内存增长 (v2.1.121)
|
||||
|
||||
**CHANGELOG 描述**:Fixed unbounded memory growth (multi-GB RSS) when processing many images in a session
|
||||
|
||||
### 实现位置
|
||||
|
||||
- `src/utils/imageStore.ts` — 核心修复
|
||||
- `src/commands/clear/caches.ts` — 缓存清理
|
||||
- `src/screens/REPL.tsx` — UI 层释放
|
||||
|
||||
### 修复方式
|
||||
|
||||
三层防护机制:
|
||||
|
||||
1. **LRU 内存缓存**:`storedImagePaths` Map 上限 200 条目(`MAX_STORED_IMAGE_PATHS`),超出自动驱逐最早条目
|
||||
2. **磁盘持久化**:图片 base64 数据写入 `~/.claude/image-cache/<sessionId>/`,内存中仅保留路径字符串
|
||||
3. **立即释放**:`setPastedContents({})` 在消息提交/命令执行后清空 React state 中的 base64 数据
|
||||
|
||||
### 关键代码
|
||||
|
||||
```typescript
|
||||
// imageStore.ts:10
|
||||
const MAX_STORED_IMAGE_PATHS = 200
|
||||
|
||||
// imageStore.ts:115-124
|
||||
function evictOldestIfAtCap(): void {
|
||||
while (storedImagePaths.size >= MAX_STORED_IMAGE_PATHS) {
|
||||
const oldest = storedImagePaths.keys().next().value
|
||||
if (oldest !== undefined) {
|
||||
storedImagePaths.delete(oldest)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// imageStore.ts:129-167 — 清理旧会话目录
|
||||
export async function cleanupOldImageCaches(): Promise<void> { ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. /usage 命令泄漏约 2GB (v2.1.121)
|
||||
|
||||
|
||||
**CHANGELOG 描述**:Fixed /usage leaking up to ~2GB of memory on machines with large transcript histories
|
||||
|
||||
### 实现位置
|
||||
|
||||
- `src/utils/sessionStoragePortable.ts:716-792` — 核心流式读取
|
||||
- `src/utils/attribution.ts` — 调用方
|
||||
|
||||
### 修复方式
|
||||
|
||||
1. **分块流式读取**:使用 `TRANSCRIPT_READ_CHUNK_SIZE = 1MB` 固定块大小,通过 `fd.read()` 逐块处理,避免一次性加载整个 transcript
|
||||
2. **字节级过滤**:在 fd 层面直接跳过 `attribution-snapshot` 类型的行(占长会话 84% 的字节空间)
|
||||
3. **边界截断**:搜索 `compact_boundary` 标记,只保留边界之后的数据
|
||||
4. **缓冲区控制**:初始缓冲区限制 `Math.min(fileSize, 8MB)`
|
||||
|
||||
### 关键代码
|
||||
|
||||
```typescript
|
||||
// sessionStoragePortable.ts:716-792
|
||||
export async function readTranscriptForLoad(
|
||||
filePath: string,
|
||||
fileSize: number,
|
||||
): Promise<{
|
||||
boundaryStartOffset: number
|
||||
postBoundaryBuf: Buffer
|
||||
hasPreservedSegment: boolean
|
||||
}> {
|
||||
const s: LoadState = {
|
||||
out: {
|
||||
buf: Buffer.allocUnsafe(Math.min(fileSize, 8 * 1024 * 1024)),
|
||||
len: 0,
|
||||
cap: fileSize + 1,
|
||||
},
|
||||
// ...
|
||||
}
|
||||
const chunk = Buffer.allocUnsafe(CHUNK_SIZE)
|
||||
const fd = await fsOpen(filePath, 'r')
|
||||
try {
|
||||
let filePos = 0
|
||||
while (filePos < fileSize) {
|
||||
const { bytesRead } = await fd.read(chunk, 0, Math.min(CHUNK_SIZE, fileSize - filePos), filePos)
|
||||
if (bytesRead === 0) break
|
||||
filePos += bytesRead
|
||||
// ... 分块处理逻辑
|
||||
}
|
||||
finalizeOutput(s)
|
||||
} finally {
|
||||
await fd.close()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 长时间运行工具进度事件泄漏 (v2.1.121)
|
||||
|
||||
|
||||
**CHANGELOG 描述**:Fixed memory leak when long-running tools fail to emit a clear progress event
|
||||
|
||||
### 实现位置
|
||||
|
||||
- `src/screens/REPL.tsx:3054-3114` — progress 消息替换逻辑
|
||||
- `src/utils/sessionStorage.ts:186-196` — 临时消息类型定义
|
||||
|
||||
### 修复方式
|
||||
|
||||
1. **向后扫描替换**:从只检查最后一条消息改为向后遍历所有 progress 消息,找到匹配的 `parentToolUseID` + `type` 后替换(修复交错消息导致 13k+ 条目堆积)
|
||||
2. **全屏模式硬上限**:`MAX_FULLSCREEN_SCROLLBACK = 500`,超出截断
|
||||
3. **临时消息识别**:`isEphemeralToolProgress()` 区分 `bash_progress`、`sleep_progress` 等一次性消息与需要保留的 `agent_progress` 等
|
||||
|
||||
### 关键代码
|
||||
|
||||
```typescript
|
||||
// REPL.tsx:3094-3114
|
||||
setMessages(oldMessages => {
|
||||
const newData = newMessage.data as Record<string, unknown>;
|
||||
// Scan backwards to find the last ephemeral progress with matching
|
||||
// parentToolUseID and type.
|
||||
for (let i = oldMessages.length - 1; i >= 0; i--) {
|
||||
const m = oldMessages[i]!
|
||||
if (m.type !== 'progress') break
|
||||
const mData = m.data as Record<string, unknown> | undefined
|
||||
if (
|
||||
m.parentToolUseID === newMessage.parentToolUseID &&
|
||||
mData?.type === newData.type
|
||||
) {
|
||||
const copy = oldMessages.slice();
|
||||
copy[i] = newMessage;
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
return [...oldMessages, newMessage];
|
||||
});
|
||||
|
||||
// REPL.tsx:3058-3064 — 全屏模式硬上限
|
||||
const MAX_FULLSCREEN_SCROLLBACK = 500
|
||||
const kept = postBoundary.length > MAX_FULLSCREEN_SCROLLBACK
|
||||
? postBoundary.slice(-MAX_FULLSCREEN_SCROLLBACK)
|
||||
: postBoundary
|
||||
return [...kept, newMessage]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 空闲重新渲染循环 (v2.1.117)
|
||||
|
||||
**状态:已确认完整**
|
||||
|
||||
**CHANGELOG 描述**:Fixed idle re-render loop when background tasks are present, reducing memory growth on Linux
|
||||
|
||||
### 实现位置
|
||||
|
||||
- `packages/@ant/ink/src/components/ClockContext.tsx` — 核心时钟管理
|
||||
|
||||
### 已实现部分
|
||||
|
||||
`ClockContext` 的 `keepAlive` 订阅者分类机制完整存在:
|
||||
|
||||
```typescript
|
||||
// ClockContext.tsx:11-43
|
||||
function createClock(tickIntervalMs: number): Clock {
|
||||
const subscribers = new Map<() => void, boolean>()
|
||||
let interval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function updateInterval(): void {
|
||||
const anyKeepAlive = [...subscribers.values()].some(Boolean)
|
||||
if (anyKeepAlive) {
|
||||
// 有 keepAlive 订阅者时启动 interval
|
||||
interval = setInterval(tick, currentTickIntervalMs)
|
||||
} else if (interval) {
|
||||
// 无 keepAlive 订阅者时停止 interval
|
||||
clearInterval(interval)
|
||||
interval = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe(onChange, keepAlive) {
|
||||
subscribers.set(onChange, keepAlive)
|
||||
updateInterval()
|
||||
return () => {
|
||||
subscribers.delete(onChange)
|
||||
updateInterval()
|
||||
}
|
||||
},
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 不确定部分
|
||||
|
||||
无法确认 `useAnimationFrame` hook 是否在所有使用时钟的组件中正确传递了 `keepAlive` 参数。反编译代码中调用链可能不完整。
|
||||
|
||||
---
|
||||
|
||||
## 5. 虚拟滚动器保留历史消息拷贝 (v2.1.101)
|
||||
|
||||
|
||||
**CHANGELOG 描述**:Fixed a memory leak where long sessions retained dozens of historical copies of the message list in the virtual scroller
|
||||
|
||||
### 实现位置
|
||||
|
||||
- `src/components/VirtualMessageList.tsx:276-296`
|
||||
|
||||
### 修复方式
|
||||
|
||||
增量式键值数组:使用 `useRef` 保存 keys 数组引用,流式追加而非每次 O(n) 全量重建。
|
||||
|
||||
```typescript
|
||||
// VirtualMessageList.tsx:276-296
|
||||
const keysRef = useRef<string[]>([])
|
||||
const prevMessagesRef = useRef<typeof messages>(messages)
|
||||
const prevItemKeyRef = useRef(itemKey)
|
||||
if (
|
||||
prevItemKeyRef.current !== itemKey ||
|
||||
messages.length < keysRef.current.length ||
|
||||
messages[0] !== prevMessagesRef.current[0]
|
||||
) {
|
||||
// 全量重建(仅在 itemKey 变化、数组缩短等场景)
|
||||
keysRef.current = messages.map(m => itemKey(m))
|
||||
} else {
|
||||
// 增量追加(正常流式场景)
|
||||
for (let i = keysRef.current.length; i < messages.length; i++) {
|
||||
keysRef.current.push(itemKey(messages[i]!))
|
||||
}
|
||||
}
|
||||
prevMessagesRef.current = messages
|
||||
prevItemKeyRef.current = itemKey
|
||||
const keys = keysRef.current
|
||||
```
|
||||
|
||||
修复前 27k 消息时每次新消息添加产生 ~1MB 内存分配,修复后降为 O(1) 追加。
|
||||
|
||||
---
|
||||
|
||||
## 6. 管道模式超宽行过度分配 (v2.1.110)
|
||||
|
||||
|
||||
**CHANGELOG 描述**:Fixed potential excessive memory allocation when piped (non-TTY) Ink output contains a single very wide line
|
||||
|
||||
### 实现位置
|
||||
|
||||
- `packages/@ant/ink/src/core/output.ts:200-207`
|
||||
|
||||
### 修复方式
|
||||
|
||||
在 `Output.reset()` 中当字符缓存超过 16384 条目时清空:
|
||||
|
||||
```typescript
|
||||
// output.ts:200-207
|
||||
reset(width: number, height: number, screen: Screen): void {
|
||||
this.width = width
|
||||
this.height = height
|
||||
this.screen = screen
|
||||
this.operations.length = 0
|
||||
resetScreen(screen, width, height)
|
||||
if (this.charCache.size > 16384) this.charCache.clear() // 关键修复
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 语言语法按需加载 (v2.1.108)
|
||||
|
||||
**状态:已修复**
|
||||
|
||||
**CHANGELOG 描述**:Reduced memory footprint for file reads, edits, and syntax highlighting by loading language grammars on demand
|
||||
|
||||
### 实现位置
|
||||
|
||||
- `packages/color-diff-napi/src/index.ts:21-37`
|
||||
|
||||
### 当前状态
|
||||
|
||||
延迟加载逻辑**已被移除**,改为顶层静态导入。代码注释说明原因:
|
||||
|
||||
```typescript
|
||||
// color-diff-napi/src/index.ts:21-37
|
||||
// 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.
|
||||
import hljs from 'highlight.js' // 顶层静态导入
|
||||
|
||||
type HLJSApi = typeof hljs
|
||||
let cachedHljs: HLJSApi | null = null
|
||||
function hljsApi(): HLJSApi {
|
||||
if (cachedHljs) return cachedHljs
|
||||
const mod = hljs as HLJSApi & { default?: HLJSApi }
|
||||
cachedHljs = 'default' in mod && mod.default ? mod.default : mod
|
||||
return cachedHljs!
|
||||
}
|
||||
```
|
||||
|
||||
**影响**:highlight.js 包含 190+ 语言语法(约 50MB),现在在模块加载时即全部载入内存,无法按需释放。这是为了兼容 Bun `--compile` 模式做的妥协。
|
||||
|
||||
---
|
||||
|
||||
## 8. NO_FLICKER 模式流状态泄漏 (v2.1.105)
|
||||
|
||||
**状态:已修复**
|
||||
|
||||
**CHANGELOG 描述**:Fixed a NO_FLICKER mode memory leak where API retries left stale streaming state
|
||||
|
||||
### 实现位置
|
||||
|
||||
- `src/screens/REPL.tsx:1841-1861` — `resetLoadingState()`
|
||||
- `src/screens/REPL.tsx:3568-3578` — finally 块调用
|
||||
|
||||
### 已实现部分
|
||||
|
||||
`resetLoadingState()` 在 `onQuery` 的 finally 块中无条件调用,清理 `streamingText`、`streamingToolUses` 等:
|
||||
|
||||
```typescript
|
||||
// REPL.tsx:1841-1861
|
||||
const resetLoadingState = useCallback(() => {
|
||||
setStreamingText(null);
|
||||
setStreamingToolUses([]);
|
||||
setSpinnerMessage(null);
|
||||
// ...
|
||||
}, [pickNewSpinnerTip]);
|
||||
|
||||
// REPL.tsx:3568-3578 — finally 块
|
||||
} finally {
|
||||
if (queryGuard.end(thisGeneration)) {
|
||||
resetLoadingState(); // 无条件清理
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 不确定部分
|
||||
|
||||
无法确认 `query.ts` 中 `StreamingToolExecutor.discard()` 的逻辑是否完整实现了旧工具结果的释放。
|
||||
|
||||
---
|
||||
|
||||
## 9. Remote Control 权限条目保留 (v2.1.98)
|
||||
|
||||
**状态:已修复**
|
||||
|
||||
**CHANGELOG 描述**:Fixed a memory leak where Remote Control permission handler entries were retained for the lifetime of the session
|
||||
|
||||
### 实现位置
|
||||
|
||||
- `src/hooks/useReplBridge.tsx:466-491` — 处理 + 删除
|
||||
- `src/hooks/useReplBridge.tsx:712-717` — 注册 + 清理函数
|
||||
|
||||
### 已实现部分
|
||||
|
||||
```typescript
|
||||
// useReplBridge.tsx:466-491
|
||||
const pendingPermissionHandlers = new Map<string, (response: ...) => void>()
|
||||
|
||||
function handlePermissionResponse(msg: SDKControlResponse): void {
|
||||
const requestId = msg.response?.request_id
|
||||
if (!requestId) return
|
||||
const handler = pendingPermissionHandlers.get(requestId)
|
||||
if (!handler) return
|
||||
const parsed = parseBridgePermissionResponse(msg)
|
||||
if (!parsed) return
|
||||
pendingPermissionHandlers.delete(requestId) // 处理后删除
|
||||
handler(parsed)
|
||||
}
|
||||
|
||||
// useReplBridge.tsx:712-717
|
||||
onResponse(requestId, handler) {
|
||||
pendingPermissionHandlers.set(requestId, handler)
|
||||
return () => {
|
||||
pendingPermissionHandlers.delete(requestId) // 取消时删除
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 不确定部分
|
||||
|
||||
hook 的 cleanup 函数(组件卸载时的 `replBridgePermissionCallbacks = undefined`)是否完整调用。
|
||||
|
||||
---
|
||||
|
||||
## 10. MCP HTTP/SSE 缓冲区累积 (v2.1.97)
|
||||
|
||||
|
||||
**CHANGELOG 描述**:Fixed MCP HTTP/SSE connections accumulating ~50 MB/hr of unreleased buffers when servers reconnect
|
||||
|
||||
### 实现位置
|
||||
|
||||
- `src/services/api/claude.ts:1557-1564` — `releaseStreamResources()`
|
||||
- `src/cli/transports/SSETransport.ts:419` — `reader.releaseLock()`
|
||||
- `@modelcontextprotocol/sdk` (sse.js, streamableHttp.js) — `response.body?.cancel()`
|
||||
|
||||
### 修复方式
|
||||
|
||||
1. **主动释放响应体**:`releaseStreamResources()` 清理 stream 和 response
|
||||
|
||||
```typescript
|
||||
// claude.ts:1553-1564
|
||||
// Release all stream resources to prevent native memory leaks.
|
||||
// The Response object holds native TLS/socket buffers that live outside the
|
||||
// V8 heap (observed on the Node.js/npm path; see GH #32920), so we must
|
||||
// explicitly cancel and release it regardless of how the generator exits.
|
||||
function releaseStreamResources(): void {
|
||||
cleanupStream(stream)
|
||||
stream = undefined
|
||||
if (streamResponse) {
|
||||
streamResponse.body?.cancel().catch(() => {})
|
||||
streamResponse = undefined
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **SSE 读取器释放**:
|
||||
|
||||
```typescript
|
||||
// SSETransport.ts:418-419
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
```
|
||||
|
||||
3. **MCP SDK 层面**:在所有 HTTP 路径(成功/失败/重连)调用 `response.body?.cancel()`
|
||||
|
||||
---
|
||||
|
||||
## 11. LRU 缓存键保留大 JSON (v2.1.89)
|
||||
|
||||
**状态:已确认完整实现**
|
||||
|
||||
|
||||
**CHANGELOG 描述**:Fixed memory leak where large JSON inputs were retained as LRU cache keys in long-running sessions
|
||||
|
||||
### 实现位置
|
||||
|
||||
- `src/utils/fileStateCache.ts:37-48` — 大小计算修复
|
||||
- `src/utils/queryHelpers.ts:48-54` — 类型强制转换
|
||||
|
||||
### 修复方式
|
||||
|
||||
1. **正确计算缓存大小**:处理 `content` 为嵌套对象的情况
|
||||
|
||||
```typescript
|
||||
// fileStateCache.ts:37-48
|
||||
sizeCalculation: value => {
|
||||
const c = value.content
|
||||
const s =
|
||||
typeof c === 'string'
|
||||
? c
|
||||
: c === null || c === undefined
|
||||
? ''
|
||||
: typeof c === 'object'
|
||||
? JSON.stringify(c)
|
||||
: String(c)
|
||||
return Math.max(1, Buffer.byteLength(s, 'utf8'))
|
||||
}
|
||||
```
|
||||
|
||||
2. **强制类型转换**:确保 Write 工具 content 始终为字符串
|
||||
|
||||
```typescript
|
||||
// queryHelpers.ts:48-54
|
||||
function coerceToolContentToString(value: unknown): string {
|
||||
if (typeof value === 'string') return value
|
||||
if (value === null || value === undefined) return ''
|
||||
if (typeof value === 'object') return JSON.stringify(value)
|
||||
return String(value)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. QueryEngine.mutableMessages 不收缩
|
||||
|
||||
**状态:已修复**
|
||||
|
||||
**代码注释描述**:`markers persist and re-trigger on every turn, and mutableMessages never shrinks (memory leak in long SDK sessions)`(`src/QueryEngine.ts:929-930`)
|
||||
|
||||
### 实现位置
|
||||
|
||||
- `src/services/compact/snipCompact.ts` — **存根文件**
|
||||
- `src/QueryEngine.ts:925-962` — 消息处理逻辑
|
||||
|
||||
### 问题详情
|
||||
|
||||
`mutableMessages` 数组只增不减,每轮对话 push 多条消息(assistant、progress、user、attachment 等)。清理依赖两条路径:
|
||||
|
||||
**路径 1:API 返回 compact_boundary**(已实现)
|
||||
|
||||
```typescript
|
||||
// QueryEngine.ts:946-962
|
||||
if (msg.subtype === 'compact_boundary' && msg.compactMetadata) {
|
||||
const mutableBoundaryIdx = this.mutableMessages.length - 1
|
||||
if (mutableBoundaryIdx > 0) {
|
||||
this.mutableMessages.splice(0, mutableBoundaryIdx) // 清理旧消息
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**路径 2:本地 snip 压缩**(存根 — 永不执行)
|
||||
|
||||
```typescript
|
||||
// snipCompact.ts — 完整文件
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
import type { Message } from 'src/types/message';
|
||||
|
||||
export const isSnipMarkerMessage: (message: Message) => boolean = () => false;
|
||||
export const snipCompactIfNeeded: (
|
||||
messages: Message[],
|
||||
options?: { force?: boolean },
|
||||
) => { messages: Message[]; executed: boolean; tokensFreed: number; boundaryMessage?: Message } = (messages) => ({
|
||||
messages,
|
||||
executed: false, // 永远 false — 清理从不执行
|
||||
tokensFreed: 0,
|
||||
});
|
||||
export const isSnipRuntimeEnabled: () => boolean = () => false;
|
||||
export const shouldNudgeForSnips: (messages: Message[]) => boolean = () => false;
|
||||
export const SNIP_NUDGE_TEXT: string = '';
|
||||
```
|
||||
|
||||
`snipReplay` 回调依赖 `HISTORY_SNIP` feature flag,且调用的 `snipCompactIfNeeded` 永远返回 `executed: false`。
|
||||
|
||||
```typescript
|
||||
// QueryEngine.ts:933-942
|
||||
const snipResult = this.config.snipReplay?.(msg, this.mutableMessages)
|
||||
if (snipResult !== undefined) {
|
||||
if (snipResult.executed) { // 永远是 false
|
||||
this.mutableMessages.length = 0
|
||||
this.mutableMessages.push(...snipResult.messages)
|
||||
}
|
||||
break
|
||||
}
|
||||
```
|
||||
|
||||
### 风险评估
|
||||
|
||||
- 在长时间 SDK 会话中,如果 API 不频繁返回 `compact_boundary`,`mutableMessages` 会持续增长
|
||||
- 每条消息可能包含大量内容(工具输出、文件内容等),长时间运行可能导致 GB 级内存占用
|
||||
- 这是当前代码库中**最明确的未实现内存泄漏点**
|
||||
|
||||
---
|
||||
|
||||
## 17. LSP Opened Files Map 不收缩
|
||||
|
||||
**状态:已修复**
|
||||
|
||||
**代码注释描述**:`closeFile()` 存在但未与 compact 流程集成(`LSPServerManager.ts:373-375` 显式标注为 TODO)
|
||||
|
||||
### 实现位置
|
||||
|
||||
- `src/services/lsp/LSPServerManager.ts:414-428` — `closeAllFiles()` 方法
|
||||
- `src/services/compact/postCompactCleanup.ts:81-88` — 集成调用
|
||||
|
||||
### 问题详情
|
||||
|
||||
`LSPServerManager` 中的 `openedFiles: Map<string, string>` 追踪所有通过 `didOpen` 打开的文件。`closeFile()` 方法存在可以发送 `didClose` 通知并清理 Map 条目,但代码注释明确标注:
|
||||
|
||||
```
|
||||
NOTE: Currently available but not yet integrated with compact flow.
|
||||
TODO: Integrate with compact - call closeFile() when compact removes files from context
|
||||
```
|
||||
|
||||
长时间会话中,每次读取/编辑文件都会通过 `openFile()` 添加条目,但 compaction 不会清理这些条目,导致 Map 无限增长。
|
||||
|
||||
### 修复方式
|
||||
|
||||
1. **添加 `closeAllFiles()` 方法**:遍历 `openedFiles` Map,对每个文件发送 `didClose` 通知,然后清空 Map。Best-effort 错误处理。
|
||||
|
||||
```typescript
|
||||
async function closeAllFiles(): Promise<void> {
|
||||
const entries = [...openedFiles.entries()]
|
||||
openedFiles.clear()
|
||||
for (const [fileUri, serverName] of entries) {
|
||||
const server = servers.get(serverName)
|
||||
if (!server || server.state !== 'running') continue
|
||||
try {
|
||||
await server.sendNotification('textDocument/didClose', {
|
||||
textDocument: { uri: fileUri },
|
||||
})
|
||||
} catch {
|
||||
// Best-effort — server may have stopped
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **集成到 `postCompactCleanup`**:在 compaction 后自动调用 `closeAllFiles()`,释放所有 LSP 服务器端的文件状态。
|
||||
|
||||
```typescript
|
||||
// postCompactCleanup.ts
|
||||
try {
|
||||
const lspManager = getLspServerManager()
|
||||
if (lspManager) {
|
||||
await lspManager.closeAllFiles()
|
||||
}
|
||||
} catch {
|
||||
// LSP module may not be available in all environments
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
```
|
||||
确认已实现 (12): #1 图片 #2 /usage #3 进度消息 #4 空闲渲染 #5 虚拟滚动器 #6 管道输出 #10 MCP缓冲区
|
||||
已修复 (7): #7 语法加载 #8 NO_FLICKER #9 RC权限 #11 LRU缓存键 #12 snipCompact #17 LSP文件追踪 #18 Permission Polling
|
||||
|
||||
### 测试覆盖
|
||||
|
||||
| 修复项 | 测试文件 | 测试数 |
|
||||
|--------|----------|--------|
|
||||
| #12 snipCompact | `src/services/compact/__tests__/snipCompact.test.ts` | 17 |
|
||||
| #12 snipProjection | `src/services/compact/__tests__/snipProjection.test.ts` | 11 |
|
||||
| #8 StreamingToolExecutor | `src/services/tools/__tests__/StreamingToolExecutor.test.ts` | 7 |
|
||||
| #9 RC 权限 | `src/hooks/__tests__/replBridgePermissionHandlers.test.ts` | 8 |
|
||||
| #11 FileStateCache | `src/utils/__tests__/fileStateCache.test.ts` | 22 |
|
||||
| #7 语言注册 | `packages/color-diff-napi/src/__tests__/language-registration.test.ts` | 7 |
|
||||
| #18 Permission Polling | `src/hooks/__tests__/swarmPermissionPoller.test.ts` | 6 |
|
||||
| #17 LSP Opened Files | `src/services/lsp/__tests__/closeAllFiles.test.ts` | 5 |
|
||||
| **总计** | **8 个测试文件** | **83** |
|
||||
```
|
||||
|
||||
### 需要关注的优先级
|
||||
|
||||
1. ~~**P0 — `snipCompact.ts` 存根**~~ **已修复**
|
||||
2. ~~**P1 — 语法按需加载回退**~~ **已修复**
|
||||
3. ~~**P2 — NO_FLICKER 流状态**~~ **已修复**
|
||||
4. ~~**P2 — 空闲渲染循环**~~ **已确认完整**
|
||||
5. ~~**P2 — Permission Polling Interval**~~ **已修复**
|
||||
6. ~~**P2 — LSP Opened Files Map**~~ **已修复**:closeAllFiles() 集成到 postCompactCleanup
|
||||
103
docs/memory-peak-analysis.md
Normal file
103
docs/memory-peak-analysis.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# 内存与性能峰值分析报告
|
||||
|
||||
> 进程 bun,RSS 基线 **682 MB**,最差 **1.8 GB** | 2026-05-02 | **调研完成**(12 轮迭代)
|
||||
> 修复 commit:`ef10ad28` + `ab0bbbc4`(降 100-300 MB)| 架构限制:Bun mimalloc/JSC 不归还内存页(~150-250 MB 永久占用)
|
||||
|
||||
## 已修复(10 项)
|
||||
|
||||
| 问题 | 原峰值 | 修复 | 位置 |
|
||||
|------|--------|------|------|
|
||||
| 流式字符串拼接 O(n²) | 2-20 MB | `+=` → 数组累积 | `claude.ts:1834,2271` |
|
||||
| Messages.tsx 多次遍历 | 100-270 MB | 合并单次 pass | `Messages.tsx:417-418` |
|
||||
| ColorFile 无缓存 | 50-100 MB | LRU-50 | `HighlightedCode.tsx:14-61` |
|
||||
| Ink StylePool 无界 | 10-50+ MB | 1000 上限 | `@ant/ink/screen.ts:122` |
|
||||
| CompanionSprite 高频 | CPU | TICK_MS→1000ms | `CompanionSprite.tsx:15` |
|
||||
| MCP stderr 缓冲 | 1-640 MB | 64→8MB/server | `mcp-client/connection.ts:117` |
|
||||
| BashTool 输出缓冲 | 30-330 MB | 32→2MB | `stringUtils.ts:88` |
|
||||
| Transcript 写入队列 | 5-50 MB | 1000 上限 | `sessionStorage.ts:613-619` |
|
||||
| contentReplacementState | 持续增长 | compact 清理 | `compact/compact.ts` |
|
||||
| SSE 缓冲 | 无上限 | 1MB cap | SSE 处理代码 |
|
||||
|
||||
## P0 — 核心瓶颈(6 项)
|
||||
|
||||
| # | 问题 | 峰值 | 位置 | 建议 |
|
||||
|---|------|------|------|------|
|
||||
| 1 | 消息数组 7-8x spread 拷贝(turn 尾部 3-4 份同时驻留) | 120-320 MB | `query.ts` 7 处(:477,:491,:897,:1135,:1745,:1857,:1878) | 去掉 spread / 传引用 / 改 push |
|
||||
| 2 | AutoCompact 时序缺陷(检查在 API 前,增长在 API 后) | API 超限 | `query.ts:575` | 加入预测式阈值检查 |
|
||||
| 3 | reactiveCompact 空存根(API 413 时无紧急压缩) | 无降级 | `reactiveCompact.ts` 全文 | 实现真实逻辑 |
|
||||
| 4 | buildMessageLookups 8 Map/Set 重建(流式每个 delta 触发) | GC STW 100-173ms | `Messages.tsx:519` | 增量更新 / 拆分 useMemo 链 |
|
||||
| 5 | useDeferredValue 双缓冲 | 100-200 MB | `REPL.tsx:1569` | React 调度机制固有,优化空间有限 |
|
||||
| 6 | Compact 峰值窗口(preCompactReadFileState + summary + attachments) | 20-80 MB | `compact.ts:524-644` | 提前释放 preCompactReadFileState/summaryResponse |
|
||||
|
||||
## P1 — 重要瓶颈(14 项)
|
||||
|
||||
| # | 问题 | 峰值 | 位置 | 建议 |
|
||||
|---|------|------|------|------|
|
||||
| 7 | OpenAI/Gemini/Grok 兼容层 O(n²) 拼接 | 25-75 MB | 3 文件 9 处(`openai/index.ts:386`, `gemini/index.ts:148`, `grok/index.ts:163`) | 改数组累积(同 claude.ts 模式) |
|
||||
| 8 | messages.ts O(n²) 拼接 | 10-25 MB | `messages.ts:3252,3268` | 改数组累积 |
|
||||
| 9 | highlight.js 全量 192 语言(仅需 26 种) | 8-12 MB | `color-diff-napi/index.ts:21` | 自定义构建 |
|
||||
| 10 | hlLineCache 模块级单例 2048 条目 | ~4 MB | `color-diff-napi/index.ts:508` | 改 LRU + size 上限 |
|
||||
| 11 | colorFileCache 3x 代码存储 | 2-5 MB | `HighlightedCode.tsx:14` | 移除 value 中 code 字段 |
|
||||
| 12 | 虚拟滚动 200 组件常驻 | 50 MB | `useVirtualScroll.ts` | 降低 OVERSCAN_ROWS / MAX_MOUNTED_ITEMS |
|
||||
| 13 | FileReadTool 大文件(输出上限 100K 字符,但读取期间完整加载) | 临时数 MB | `FileReadTool.ts:342` | 读取前检测大小,流式截断 |
|
||||
| 14 | Session 恢复全量加载(磁盘→JSON→REPL 三阶段) | 200-300 MB | `sessionStorage.ts:3482` | 流式 JSONL / 增量恢复 |
|
||||
| 15 | Session 写入 100MB 累积 | ~100 MB | `sessionStorage.ts:652` | 流式写入 |
|
||||
| 16 | Forked Agent FileStateCache 完整克隆 | 50N MB | `forkedAgent.ts:382` | 共享/分层缓存(agent 用 10MB) |
|
||||
| 17 | GC 阈值 350MB < 基线(每秒无意义强制 GC) | CPU 浪费 | `cli/print.ts:554` | 提高到 800MB+ |
|
||||
| 18 | PDF 100 页处理 | ~100 MB | `apiLimits.ts:54` | 分页流式处理 |
|
||||
| 19 | 图片单张处理(base64→解码→resize) | ~16 MB/张 | `apiLimits.ts:22` | 流式 resize |
|
||||
| 20 | token 估算 ±25-50% 误差放大时序问题 | 阈值不准 | `tokenEstimation.ts:215` | 内容类型感知估算 |
|
||||
|
||||
## P2 — 次要问题(10 项)
|
||||
|
||||
| # | 问题 | 峰值 | 位置 |
|
||||
|---|------|------|------|
|
||||
| 21 | lastAPIRequestMessages 常驻 | 30-50 MB | `bootstrap/state.ts:118` |
|
||||
| 22 | MCP Tool Schema 双重存储 | ~40 MB | `manager.ts:73` + `AppStateStore.ts:175` |
|
||||
| 23 | ContentReplacementState 单调增长 | 0.5-2 MB | `toolResultStorage.ts:390` |
|
||||
| 24 | Perfetto 100K 事件 | ~30 MB | `perfettoTracing.ts:106` |
|
||||
| 25 | StreamingMarkdown 双渲染 | 临时 | `Markdown.tsx:185` |
|
||||
| 26 | MarkdownTable 3 次遍历 | CPU 峰值 | `MarkdownTable.tsx:99` |
|
||||
| 27 | 搜索索引 WeakMap | 5-10 MB | `transcriptSearch.ts:17` |
|
||||
| 28 | ACP FileStateCache/会话 | 50 MB | `acp/agent.ts:554` |
|
||||
| 29 | Agent initialMessages 浅拷贝 | 1-5 MB/agent | `runAgent.ts:382` |
|
||||
| 30 | Hook 结果累积 | ~1 MB+ | `toolExecution.ts:1474` |
|
||||
|
||||
## CPU / 渲染热点
|
||||
|
||||
| # | 问题 | 影响 | 位置 |
|
||||
|---|------|------|------|
|
||||
| C2 | Ink 每次 React commit 触发 Yoga 布局 | ~1-3ms/commit | `reconciler.ts:279` → `ink.tsx:323` |
|
||||
| C3 | MessageRow 挂载 ~1.5ms(React/Yoga/Ink 管线开销) | 批量挂载 ~290ms 卡顿 | `useVirtualScroll.ts` |
|
||||
| C4 | 布局偏移触发全屏 damage | O(rows×cols) | `ink.tsx:655-661` |
|
||||
| C9 | 同步 fs 操作阻塞主线程 | 间歇卡顿 | `projectOnboardingState.ts:20` 等 |
|
||||
|
||||
已有缓解:React ConcurrentRoot 批处理、帧率限制 16ms、虚拟滚动 overscan 80 + SLIDE_STEP=25 + useDeferredValue、Markdown tokenCache LRU-500 + hasMarkdownSyntax 快速路径、Yoga 增量缓存。
|
||||
|
||||
## 已否认(12 轮汇总)
|
||||
|
||||
VSZ 516 GB 是虚拟映射 | Zod ~650KB | Markdown LRU-500 已优化 | useSkillsChange/useSettingsChange 正确 cleanup | useInboxPoller 收敛设计(非循环)| React Compiler `_c(N)` 未使用 | File watchers ~5KB | React reconciler WeakMap + freeRecursive | Ink 屏幕缓冲 ~86KB | CharPool/HyperlinkPool ~1-5MB 5min 重置 | AWS/Google/Azure SDK 均懒加载 | Sentry 空实现 | useCallback 闭包通过 messagesRef 规避(无泄漏)| MCP stderrHandler 有 64MB cap + cleanup | useRef 有 clearConversation/compact 清理 | apiMetricsRef turn 结束重置 | useEffect 有 cleanup 函数 | lodash-es tree-shakable | AppState useSyncExternalStore 仅相关切片更新 | SDK 无全局重试队列 | Ink unmount 有清理
|
||||
|
||||
## 结论
|
||||
|
||||
**内存根因排序**:
|
||||
1. 消息数组 7-8x spread 拷贝(120-320 MB)— 核心瓶颈
|
||||
2. useDeferredValue 双缓冲 + React useMemo 链全量重算(100-200 MB + GC STW)
|
||||
3. Session 恢复/写入峰值(200-300 MB)
|
||||
4. AutoCompact 时序缺陷 + reactiveCompact 空存根(API 超限风险)
|
||||
5. Forked Agent FileStateCache 克隆(50N MB)
|
||||
6. 虚拟滚动 200 组件 ~50MB 常驻
|
||||
7. Bun/JSC 不归还内存页(架构级)
|
||||
|
||||
**CPU 根因**:useInboxPoller 每秒轮询 → React commit → Yoga 布局 → 全屏 Ink diff 完整管线。Markdown 渲染批量挂载时 ~290ms 卡顿。
|
||||
|
||||
**预估优化空间**:
|
||||
|
||||
| 优先级 | 措施数 | 预估降低 |
|
||||
|--------|--------|----------|
|
||||
| P0 | 6 | 240-600 MB |
|
||||
| P1 | 14 | 300-600 MB |
|
||||
| P2 | 10 | 80-200 MB |
|
||||
| **合计** | **30 项** | **620-1400 MB** |
|
||||
|
||||
理论可从 400-700 MB 降至 **200-350 MB**(受 mimalloc/JSC 架构限制约束)。
|
||||
@@ -1,102 +1,263 @@
|
||||
---
|
||||
title: "Auto Mode"
|
||||
description: "AI 分类器驱动的自主执行模式。理解两阶段分类流水线、危险权限剥离和分类器不可用时的降级策略。"
|
||||
keywords: ["auto mode", "自动执行", "AI 分类器", "权限分类"]
|
||||
title: "Auto Mode - AI 分类器驱动的自主执行模式"
|
||||
description: "详解 Claude Code 的 auto mode:基于 transcript classifier 的自动权限决策、两阶段分类流水线、危险权限剥离机制、模式切换状态管理、以及与 plan mode 的协作方式。"
|
||||
keywords: ["auto mode", "yoloClassifier", "transcript classifier", "权限分类", "自动执行", "两阶段分类"]
|
||||
---
|
||||
|
||||
## 核心问题
|
||||
## 概述
|
||||
|
||||
默认模式下,AI 执行每个敏感操作都需要用户确认。这在处理复杂任务时产生大量打断——一次重构可能需要确认 20 次文件编辑和 10 次命令执行。
|
||||
|
||||
Auto mode 的目标:**让 AI 连续自主执行,只在真正危险时才停下来。**
|
||||
|
||||
## 权限模式的层级
|
||||
Auto mode 是 Claude Code 的一种权限模式,让 AI 进入**连续自主执行**状态。与传统模式(每个敏感操作都弹出权限对话框等待用户审批)不同,auto mode 使用 AI 分类器(transcript classifier)自动判断每个工具调用是否安全,从而实现无中断的执行体验。
|
||||
|
||||
```
|
||||
default → auto → bypass
|
||||
权限模式层级:
|
||||
|
||||
default → auto → bypassPermissions
|
||||
(逐项确认) (AI 分类器审批) (全部放行)
|
||||
```
|
||||
|
||||
Auto mode 不是 bypass——它不是"什么都允许",而是"让 AI 判断什么安全、什么危险"。
|
||||
## 核心架构
|
||||
|
||||
## 核心架构:AI 分类器
|
||||
### 1. AI 分类器(yoloClassifier)
|
||||
|
||||
Auto mode 的核心是一个 AI 分类器。每个工具调用经过分类器评估,返回三种裁决:
|
||||
分类器是 auto mode 的核心,位于 `src/utils/permissions/yoloClassifier.ts`。
|
||||
|
||||
| 裁决 | 含义 | 处理 |
|
||||
|------|------|------|
|
||||
| **allow** | 操作安全 | 直接执行 |
|
||||
| **deny** | 操作危险 | 拒绝并告知 AI |
|
||||
| **ask** | 无法确定 | 回退到用户确认 |
|
||||
每个工具调用经过分类器评估,返回三种裁决之一:
|
||||
|
||||
**设计洞察**:分类器基于完整的对话上下文判断,而非只看单条命令。它能理解操作的意图——同样是 `rm` 命令,删除构建产物是安全的,删除源代码是危险的。
|
||||
| 裁决 | 含义 | 处理方式 |
|
||||
|------|------|---------|
|
||||
| **allow** | 操作安全 | 直接执行,用户无感知 |
|
||||
| **deny** | 操作危险 | 拒绝执行,向 AI 返回拒绝消息 |
|
||||
| **ask** | 无法确定 | 回退到传统权限对话框 |
|
||||
|
||||
## 两阶段分类流水线
|
||||
分类器的判断基于完整的对话上下文(transcript),而不仅仅看单条命令——它能理解操作的意图和上下文。
|
||||
|
||||
分类器采用两阶段设计:
|
||||
### 2. 两阶段分类流水线
|
||||
|
||||
### Stage 1:快速判断
|
||||
分类器支持两阶段 XML 分类(`classifyYoloActionXml`):
|
||||
|
||||
使用短输出(最多 64 token)快速给出 allow/block 判断。大多数安全操作在这一阶段就被放行,延迟极低。
|
||||
```
|
||||
Stage 1 — "fast"(快速判断)
|
||||
├── max_tokens=64,stop_sequences 触发即时 yes/no
|
||||
├── 如果 allow → 直接放行(最快路径)
|
||||
└── 如果 block → 进入 Stage 2
|
||||
|
||||
### Stage 2:深度思考
|
||||
Stage 2 — "thinking"(深度思考)
|
||||
├── chain-of-thought 推理
|
||||
├── 减少误报(false positives)
|
||||
└── 最终决定 allow / deny / ask
|
||||
```
|
||||
|
||||
Stage 1 判断为 block 的操作进入深度推理阶段,通过 chain-of-thought 减少误报。
|
||||
两个阶段共享相同的 system prompt 和 user content,利用 API 的 prompt caching(1 小时 TTL)优化性能。
|
||||
|
||||
**设计考量**:两阶段设计在速度和准确性之间取得平衡。99% 的操作在 Stage 1 就能正确判断,只有少数模糊操作需要 Stage 2 的深度分析。这避免了每个操作都跑完整推理的性能开销。
|
||||
可通过配置选择模式:
|
||||
- `'both'`(默认)— 两阶段都跑
|
||||
- `'fast'` — 只跑 Stage 1
|
||||
- `'thinking'` — 只跑 Stage 2
|
||||
|
||||
### 3. 分类器结果类型
|
||||
|
||||
```typescript
|
||||
// src/types/permissions.ts
|
||||
type YoloClassifierResult = {
|
||||
thinking?: string // 分类器的推理过程
|
||||
shouldBlock: boolean // 是否阻止
|
||||
reason: string // 决策原因
|
||||
unavailable?: boolean // 分类器是否不可用
|
||||
transcriptTooLong?: boolean // 对话是否超出上下文窗口
|
||||
model: string // 使用的分类器模型
|
||||
stage?: 'fast' | 'thinking' // 哪个阶段做出的决定
|
||||
// ... token 使用量、耗时等监控字段
|
||||
}
|
||||
```
|
||||
|
||||
## 安全机制
|
||||
|
||||
### 危险权限剥离
|
||||
|
||||
进入 auto mode 时,系统自动剥离所有可能绕过分类器的 allow 规则:
|
||||
进入 auto mode 时,系统调用 `stripDangerousPermissionsForAutoMode()`(`permissionSetup.ts:510`),移除所有可能绕过分类器的 allow 规则。
|
||||
|
||||
| 被剥离的规则类型 | 原因 |
|
||||
|----------------|------|
|
||||
| Bash 解释器规则(python/node/bash) | 可执行任意代码 |
|
||||
| Agent allow 规则 | 会绕过分类器审批子 Agent |
|
||||
| 权限提升规则(sudo/eval) | 可执行任意命令 |
|
||||
被剥离的规则类型(`dangerousPatterns.ts`):
|
||||
|
||||
剥离的规则在退出 auto mode 时恢复。
|
||||
| 规则类型 | 示例 | 剥离原因 |
|
||||
|---------|------|---------|
|
||||
| **Bash 代码执行** | `Bash(python:*)`, `Bash(node:*)` | 解释器可执行任意代码,绕过分类器审查 |
|
||||
| **Shell 入口** | `Bash(bash:*)`, `Bash(sh:*)` | 直接 shell 访问等同无限制 |
|
||||
| **Agent 规则** | `Agent(*)` | 任何 Agent allow 规则会绕过分类器审批子代理 |
|
||||
| **PowerShell 代码执行** | `PowerShell(node:*)` | 同 Bash 逻辑 |
|
||||
| **权限提升** | `Bash(sudo:*)`, `Bash(eval:*)` | 可执行任意命令 |
|
||||
|
||||
**设计哲学**:auto mode 的安全性依赖于分类器的判断。如果用户之前设置了"Bash: always allow",分类器就被绕过了。剥离这些规则确保分类器是唯一的安全决策者。
|
||||
|
||||
### Circuit Breaker
|
||||
|
||||
远程配置可以在紧急情况下全局禁用 auto mode。这为 Anthropic 提供了远程紧急关停能力——如果发现分类器存在系统性漏洞,可以在不发布新版本的情况下立即禁用。
|
||||
剥离的规则被暂存在 `strippedDangerousRules` 中,退出 auto mode 时通过 `restoreDangerousPermissions()` 恢复。
|
||||
|
||||
### 模型支持检测
|
||||
|
||||
不是所有模型都支持 auto mode。分类器需要特定的能力(如理解安全语义),不支持该能力的模型无法进入 auto mode。
|
||||
不是所有模型都支持 auto mode。`modelSupportsAutoMode()`(`src/utils/betas.ts`)检查当前模型是否具备安全分类能力。不支持的模型无法进入 auto mode。
|
||||
|
||||
### Circuit Breaker 机制
|
||||
|
||||
`autoModeState.ts` 维护一个 circuit breaker 标志:
|
||||
|
||||
```typescript
|
||||
let autoModeCircuitBroken = false // 由远程配置控制
|
||||
```
|
||||
|
||||
当远程配置(GrowthBook `tengu_auto_mode_config.enabled`)设为 `'disabled'` 时,circuit breaker 触发,阻止 auto mode 的进入和继续使用。这为 Anthropic 提供了远程紧急关停能力。
|
||||
|
||||
## 模式切换状态管理
|
||||
|
||||
### 进入 Auto Mode
|
||||
|
||||
`transitionPermissionMode()`(`permissionSetup.ts:597`)处理所有模式切换:
|
||||
|
||||
```
|
||||
1. 检查 auto mode gate 是否开启(isAutoModeGateEnabled)
|
||||
2. 设置 autoModeActive = true
|
||||
3. 调用 stripDangerousPermissionsForAutoMode() 剥离危险规则
|
||||
4. 向对话注入 Auto Mode 系统提示
|
||||
```
|
||||
|
||||
### 退出 Auto Mode
|
||||
|
||||
```
|
||||
1. 设置 autoModeActive = false
|
||||
2. 设置 needsAutoModeExitAttachment = true(触发退出通知)
|
||||
3. 调用 restoreDangerousPermissions() 恢复被剥离的规则
|
||||
4. 向对话注入 "Exited Auto Mode" 提示
|
||||
```
|
||||
|
||||
### 触发路径
|
||||
|
||||
Auto mode 可通过以下方式激活:
|
||||
- CLI 参数 `--enable-auto-mode`
|
||||
- settings.json 中的 `autoMode` 配置
|
||||
- Plan mode 默认使用 auto mode 语义(`useAutoModeDuringPlan`,默认 true)
|
||||
- SDK 控制消息
|
||||
- REPL 中 Shift+Tab 切换
|
||||
|
||||
## 系统提示词
|
||||
|
||||
### 进入时
|
||||
### 进入时(Full Instructions)
|
||||
|
||||
注入到对话中的指令要求 AI:
|
||||
1. **直接执行** — 做合理假设,减少提问
|
||||
2. **偏好行动** — 默认直接编码,不进 plan mode
|
||||
3. **避免破坏性操作** — 删除数据、修改生产系统仍需确认
|
||||
注入到对话中的指令(`messages.ts:3481`):
|
||||
|
||||
### 退出时
|
||||
> Auto mode is active. The user chose continuous, autonomous execution. You should:
|
||||
>
|
||||
> 1. **Execute immediately** — 直接实现,做合理假设
|
||||
> 2. **Minimize interruptions** — 常规决策自行判断,减少提问
|
||||
> 3. **Prefer action over planning** — 默认直接编码,不进 plan mode
|
||||
> 4. **Expect course corrections** — 用户可随时纠正
|
||||
> 5. **Do not take overly destructive actions** — 删除数据/修改生产系统仍需确认
|
||||
> 6. **Avoid data exfiltration** — 不主动分享密钥/内部文档
|
||||
|
||||
注入"退出 auto mode"提示,要求 AI 回到谨慎模式——方案不明确时提问而非假设。
|
||||
### 持续运行时(Sparse Instructions)
|
||||
|
||||
## 降级策略
|
||||
后续轮次注入简短提醒:
|
||||
|
||||
当分类器 API 不可用时:
|
||||
- **不直接 allow** — 回退到传统权限对话框
|
||||
- 告知 AI 分类器暂时不可用
|
||||
- 确定性错误(如对话过长)不重试
|
||||
> Auto mode still active. Execute autonomously, minimize interruptions, prefer action over planning.
|
||||
|
||||
**设计哲学**:降级到更安全的行为。宁可多确认一次,也不要在没有分类器保护的情况下自动放行。
|
||||
### 退出时(Exit Instructions)
|
||||
|
||||
> You have exited auto mode. Ask clarifying questions when the approach is ambiguous rather than making assumptions.
|
||||
|
||||
## 与 Plan Mode 的协作
|
||||
|
||||
Plan mode 默认使用 auto mode 语义——在只读探索阶段,分类器自动判断哪些只读操作是安全的,进一步减少打断。
|
||||
Plan mode 默认使用 auto mode 语义(`getUseAutoModeDuringPlan()`,默认 true)。这意味着:
|
||||
|
||||
## 接下来
|
||||
- Plan mode 进入时,如果 auto mode 可用,也会激活分类器
|
||||
- `isAutoModeActive()` 是权威信号(`prePlanMode`/`strippedDangerousRules` 不可靠)
|
||||
- 退出 plan mode 时会同时退出 auto mode
|
||||
|
||||
- **权限模型** — 理解 auto mode 在权限体系中的位置
|
||||
- **Plan Mode** — 理解"先规划再执行"的安全工作流
|
||||
- **为什么安全很重要** — 理解安全体系的设计动机
|
||||
## 分类器不可用的降级策略
|
||||
|
||||
当分类器 API 不可用时(`unavailable: true` 或 `transcriptTooLong: true`):
|
||||
|
||||
- 不会直接 allow — 回退到传统的权限对话框(ask)
|
||||
- 向 AI 发送消息:"{model} is temporarily unavailable, so auto mode cannot determine the safety of {toolName} right now."
|
||||
- 确定性错误(如对话过长)不重试,直接降级
|
||||
|
||||
## 分类器 Prompt 模板
|
||||
|
||||
分类器的行为由三个 prompt 文件控制,位于 `src/utils/permissions/yolo-classifier-prompts/`。这些文件在构建时通过 `require()` 内联为字符串常量,运行时不可修改。
|
||||
|
||||
### auto_mode_system_prompt.txt
|
||||
|
||||
主系统提示词,定义分类器的角色、分类流程和决策类别。包含:
|
||||
|
||||
- **分类流程**:理解操作 → 检查用户意图 → 评估风险
|
||||
- **BLOCK 始终阻止**:外部代码执行、不可逆删除、未授权持久化、安全削弱、提权、网络服务
|
||||
- **BLOCK 除非明确意图**:CWD 外写入、系统包管理、git push、大规模变更
|
||||
- **ALLOW 安全操作**:读文件、搜索、git 只读命令、测试/lint/构建、CWD 内编辑
|
||||
- `<permissions_template>` 占位符 — 运行时替换为具体权限模板(external 或 anthropic)
|
||||
- 末尾 `Use the classify_result tool to report your classification.` — XML 模式下被替换为 `<block>` 标签格式说明
|
||||
|
||||
### permissions_external.txt
|
||||
|
||||
外部用户版本的权限模板。三个 `<user_*_to_replace>` 标签内包裹默认规则(bullet 格式),用户自定义规则**整体替换**默认值:
|
||||
|
||||
```
|
||||
<user_allow_rules_to_replace>
|
||||
- 默认 allow 规则 1
|
||||
- 默认 allow 规则 2
|
||||
</user_allow_rules_to_replace>
|
||||
```
|
||||
|
||||
- **allow**:9 条默认规则(只读命令、版本检查、git 只读、测试/lint/构建、包安装、CWD 内文件操作等)
|
||||
- **soft_deny**:10 条默认规则(外部代码执行、递归删除、shell 配置修改、提权、网络服务等)
|
||||
- **environment**:4 条环境描述(终端环境、auto mode 上下文、开发工具可用、语言/框架不限)
|
||||
|
||||
`getDefaultExternalAutoModeRules()` 从此文件提取 bullet 项,供 `claude auto-mode defaults` 命令输出。
|
||||
|
||||
### permissions_anthropic.txt
|
||||
|
||||
Anthropic 内部版本的权限模板。默认规则在标签**外部**,标签内为空,用户自定义规则以**追加**方式叠加:
|
||||
|
||||
```
|
||||
- 默认规则(在标签外,始终生效)
|
||||
<user_allow_rules_to_replace>
|
||||
</user_allow_rules_to_replace>
|
||||
```
|
||||
|
||||
相比 external 版本,额外包含:
|
||||
- 云 CLI 只读命令(aws describe, gcloud describe, kubectl get 等)
|
||||
- 基础设施即代码 plan 命令(terraform plan, pulumi preview 等)
|
||||
- 对应的 deny 规则(云资源创建/修改/删除、IaC apply、生产环境访问等)
|
||||
|
||||
### 模板替换流程
|
||||
|
||||
```
|
||||
buildYoloSystemPrompt()
|
||||
├── BASE_PROMPT.replace('<permissions_template>', EXTERNAL/ANTHROPIC_TEMPLATE)
|
||||
├── .replace(<user_allow_rules_to_replace>, userAllow ?? defaults)
|
||||
├── .replace(<user_deny_rules_to_replace>, userDeny ?? defaults)
|
||||
└── .replace(<user_environment_to_replace>, userEnvironment ?? defaults)
|
||||
```
|
||||
|
||||
- 外部模板:用户设置非空时**替换**对应标签内容,否则保留默认值
|
||||
- 内部模板:用户设置**追加**到默认值之后(标签在末尾为空)
|
||||
|
||||
## 当前状态说明
|
||||
|
||||
> **注意**:auto mode 的完整代码逻辑已存在于代码库中,但依赖 `feature('TRANSCRIPT_CLASSIFIER')` feature flag。
|
||||
> 在当前反编译版本中,`feature()` 始终返回 `false`,因此 auto mode 不可用。
|
||||
> 要启用需将 `feature('TRANSCRIPT_CLASSIFIER')` 改为 `true`,并确保 GrowthBook 配置源有合理的 fallback 默认值。
|
||||
|
||||
Prompt 模板文件为**重建产物**——原始文件在反编译过程中丢失,已根据代码逻辑和 `yoloClassifier.ts` 中的替换模式重新编写。
|
||||
|
||||
## 相关源码索引
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `src/utils/permissions/yoloClassifier.ts` | 分类器核心实现 |
|
||||
| `src/utils/permissions/autoModeState.ts` | Auto mode 状态管理 |
|
||||
| `src/utils/permissions/permissionSetup.ts` | 模式切换、危险权限剥离 |
|
||||
| `src/utils/permissions/dangerousPatterns.ts` | 危险命令模式列表 |
|
||||
| `src/utils/permissions/classifierDecision.ts` | 分类器决策处理 |
|
||||
| `src/utils/permissions/classifierShared.ts` | 分类器共享逻辑 |
|
||||
| `src/utils/permissions/bashClassifier.ts` | Bash 命令分类规则 |
|
||||
| `src/utils/permissions/bypassPermissionsKillswitch.ts` | bypass 权限熔断器 |
|
||||
| `src/utils/permissions/yolo-classifier-prompts/auto_mode_system_prompt.txt` | 分类器主系统提示词 |
|
||||
| `src/utils/permissions/yolo-classifier-prompts/permissions_external.txt` | 外部权限模板 |
|
||||
| `src/utils/permissions/yolo-classifier-prompts/permissions_anthropic.txt` | 内部权限模板 |
|
||||
| `src/cli/handlers/autoMode.ts` | CLI `auto-mode` 子命令处理 |
|
||||
| `src/utils/messages.ts` | Auto mode 系统提示词注入 |
|
||||
| `src/types/permissions.ts` | 权限类型定义 |
|
||||
| `src/utils/betas.ts` | 模型 auto mode 支持检测 |
|
||||
|
||||
@@ -1,106 +1,177 @@
|
||||
---
|
||||
title: "权限模型"
|
||||
description: "AI 执行命令是最危险的能力。Allow/Ask/Deny 三级权限体系如何在安全与效率间取得平衡?理解规则来源、匹配引擎和死循环防护。"
|
||||
keywords: ["权限模型", "Allow Ask Deny", "权限规则", "权限模式"]
|
||||
title: "权限模型 - Allow/Ask/Deny 三级权限体系"
|
||||
description: "详解 Claude Code 的三级权限模型实现:基于 src/utils/permissions/permissions.ts 的规则匹配引擎、五层规则来源优先级、工具名/命令/路径三维度匹配、Denial Tracking 死循环防护、权限模式切换机制。"
|
||||
keywords: ["权限模型", "Allow Ask Deny", "PermissionRule", "checkPermissions", "Denial Tracking", "权限规则"]
|
||||
---
|
||||
|
||||
## 核心问题
|
||||
|
||||
AI 可以读取文件、执行命令、修改代码——这些操作在带来效率的同时也带来风险。权限系统的问题是:**什么时候需要人类确认,什么时候可以自动放行?**
|
||||
{/* 本章目标:基于源码揭示权限系统的完整实现 */}
|
||||
|
||||
## 三种权限行为
|
||||
|
||||
| 行为 | 含义 | 用户感知 |
|
||||
|------|------|---------|
|
||||
| **Allow** | 自动放行 | 无感知 |
|
||||
| **Ask** | 弹出确认 | 需要批准或拒绝 |
|
||||
| **Deny** | 直接拒绝 | 被告知原因 |
|
||||
每一次工具调用,系统都会做出三种裁决之一:
|
||||
|
||||
## 规则来源的八层优先级
|
||||
| 行为 | 含义 | 返回类型 | 典型场景 |
|
||||
|------|------|---------|---------|
|
||||
| **Allow** | 自动放行,用户无感知 | `{ behavior: 'allow', updatedInput, decisionReason }` | Read 读取项目内文件 |
|
||||
| **Ask** | 弹出确认对话框 | `{ behavior: 'ask', message, suggestions, metadata }` | Bash 执行未知命令 |
|
||||
| **Deny** | 直接拒绝 | `{ behavior: 'deny', message, decisionReason }` | 尝试执行被禁止的命令 |
|
||||
|
||||
权限规则从 8 个来源汇聚,后者覆盖前者:
|
||||
这些行为由 `PermissionResult` 类型定义(`src/utils/permissions/PermissionResult.ts`)。
|
||||
|
||||
## 权限规则的来源
|
||||
|
||||
规则从 8 个来源汇聚(`PERMISSION_RULE_SOURCES`,`permissions.ts:109`),优先级从低到高(后者覆盖前者):
|
||||
|
||||
```
|
||||
用户全局设置 < 项目设置 < 本地覆盖 < 命令行参数 < 企业策略 < CLI 参数 < 技能白名单 < 本次会话授权
|
||||
1. userSettings — ~/.claude/settings.json(跨项目)
|
||||
2. projectSettings — .claude/settings.json(团队共享)
|
||||
3. localSettings — .claude/settings.local.json(gitignored,个人覆盖)
|
||||
4. flagSettings — --settings 命令行参数
|
||||
5. policySettings — 企业管理员下发的策略(用户不可覆盖)
|
||||
6. cliArg — 命令行 --allow/--deny 参数
|
||||
7. command — Skill 工具的 allowedTools 白名单
|
||||
8. session — 用户在当前对话中手动授权("Always allow")
|
||||
```
|
||||
|
||||
**设计考量**:
|
||||
- **企业策略不可被用户覆盖**——企业管理员可以通过策略强制禁止某些操作
|
||||
- **本次会话授权优先级最高**——用户在对话中选择"Always allow"立即生效
|
||||
- **项目设置可被 git 追踪**——团队可以通过 `.claude/settings.json` 共享权限规则
|
||||
每个来源维护三个数组:`alwaysAllowRules[source]`、`alwaysAskRules[source]`、`alwaysDenyRules[source]`。
|
||||
|
||||
### 规则结构
|
||||
|
||||
每条规则指定三个要素:
|
||||
- **工具名**:如 `Bash`、`Edit`、`mcp__server1`
|
||||
- **匹配内容**:如 `git *`(命令模式)、`src/**`(路径模式)
|
||||
- **行为**:allow / ask / deny
|
||||
|
||||
## 三维度匹配引擎
|
||||
|
||||
### 1. 工具名匹配
|
||||
|
||||
最简单的匹配——规则只指定工具名,没有额外内容:
|
||||
- `"Bash"` → 匹配所有 Bash 调用
|
||||
- `"mcp__server1"` → 匹配该 MCP Server 的所有工具
|
||||
|
||||
### 2. 命令模式匹配(Bash 专用)
|
||||
|
||||
Bash 工具的规则可以指定命令模式:
|
||||
- `{"tool": "Bash", "content": "git *"}` → 匹配 `git commit -m 'fix'`
|
||||
|
||||
命令通过 AST 解析提取第一个子命令进行匹配,不受管道或条件表达式干扰。
|
||||
|
||||
### 3. 路径匹配(文件工具专用)
|
||||
|
||||
Read/Edit/Write 工具的规则可以指定文件路径 glob:
|
||||
- `{"tool": "Edit", "content": "src/**"}` → 匹配 `src/utils/foo.ts`
|
||||
|
||||
**设计洞察**:三维度匹配对应三种不同的安全关注点:
|
||||
- 工具名 → "这个工具允不允许用"
|
||||
- 命令模式 → "这条命令安不安全"
|
||||
- 路径 → "这个文件能不能碰"
|
||||
|
||||
## 权限检查流程
|
||||
|
||||
```
|
||||
Blanket deny → 工具名完全匹配 deny 规则? → 直接拒绝
|
||||
Blanket allow → 工具名完全匹配 allow 规则? → 直接放行
|
||||
工具自身检查 → 各工具有自定义逻辑
|
||||
Hook 系统 → PreToolUse hook 可以覆盖结果
|
||||
Ask 规则 → 匹配则弹出确认
|
||||
默认行为 → 由当前权限模式决定
|
||||
规则数据结构为 `PermissionRule`:
|
||||
```typescript
|
||||
{
|
||||
source: PermissionRuleSource // 来自哪个层级
|
||||
ruleBehavior: 'allow' | 'ask' | 'deny'
|
||||
ruleValue: {
|
||||
toolName: string // 如 "Bash"、"mcp__server1"
|
||||
ruleContent?: string // 如 "git *"、"src/**"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**设计哲学**:deny 优先于 allow。如果某条规则说"禁止 Bash",即使另一条规则说"允许 Bash",最终结果也是禁止。
|
||||
## 规则匹配引擎
|
||||
|
||||
### 三维度匹配
|
||||
|
||||
`permissions.ts` 实现了三种匹配维度:
|
||||
|
||||
**1. 工具名匹配**(`toolMatchesRule()`,第 238 行)
|
||||
|
||||
匹配整个工具,仅当规则没有 `ruleContent`:
|
||||
```typescript
|
||||
// 精确匹配
|
||||
rule "Bash" → 匹配 BashTool
|
||||
rule "mcp__server1" → 匹配该 MCP Server 的所有工具(server 级别)
|
||||
rule "mcp__server1__*" → 通配符匹配(同上)
|
||||
```
|
||||
|
||||
MCP 工具使用 `getToolNameForPermissionCheck()` 获取匹配名称,支持有前缀(`mcp__server__tool`)和无前缀模式。
|
||||
|
||||
**2. 命令模式匹配**(BashTool 的 `checkPermissions()`)
|
||||
|
||||
BashTool 通过 `preparePermissionMatcher()`(`Tool.ts:520`)解析命令模式:
|
||||
```json
|
||||
{"tool": "Bash", "ruleContent": "git *"} → 匹配 "git commit -m 'fix'"
|
||||
```
|
||||
|
||||
命令通过 AST 解析(`readOnlyValidation.ts` 使用 tree-sitter bash),提取第一个子命令进行匹配。
|
||||
|
||||
**3. 路径匹配**(文件工具的 `checkPermissions()`)
|
||||
|
||||
Read/Edit/Write 工具通过 `getPath()` 提取文件路径,与 `ruleContent` 中的 glob 模式匹配:
|
||||
```json
|
||||
{"tool": "Edit", "ruleContent": "src/**"} → 匹配 "src/utils/foo.ts"
|
||||
```
|
||||
|
||||
### 权限检查的完整流程
|
||||
|
||||
每次工具调用的权限检查(`canUseTool()` → `checkPermissions()`)经过以下步骤:
|
||||
|
||||
```
|
||||
1a. Blanket deny 检查
|
||||
getDenyRuleForTool() → 工具名完全匹配 deny 规则?
|
||||
↓ 命中 → deny(工具在 getTools() 阶段就被过滤掉)
|
||||
|
||||
1b. Blanket allow 检查
|
||||
toolAlwaysAllowedRule() → 工具名完全匹配 allow 规则?
|
||||
↓ 命中 → allow
|
||||
|
||||
2. 工具自身 checkPermissions()
|
||||
各工具有自定义逻辑:
|
||||
- BashTool: readOnlyValidation → sandbox 判定 → AST 解析 → 模式匹配
|
||||
- FileEditTool: 路径白名单检查
|
||||
- SkillTool: safe properties 白名单 + 精确/前缀匹配
|
||||
↓ 返回 PermissionResult
|
||||
|
||||
3. Hook 系统
|
||||
executePermissionRequestHooks() → PreToolUse hook 可以 override
|
||||
↓ hook 返回 deny → deny
|
||||
↓ hook 返回 ask → 升级为 ask
|
||||
|
||||
4. Ask 规则检查
|
||||
getAskRules() → 命中 → ask
|
||||
|
||||
5. 默认行为
|
||||
根据当前 permissionMode 决定默认行为
|
||||
- 'default': 大部分工具 ask
|
||||
- 'plan': 写操作 deny,读操作 allow
|
||||
- 'bypass': 全部 allow
|
||||
```
|
||||
|
||||
## 权限模式
|
||||
|
||||
| 模式 | 适用场景 | 行为 |
|
||||
|------|---------|------|
|
||||
| **Default** | 日常使用 | 敏感操作逐一确认 |
|
||||
| **Plan Mode** | 探索阶段 | 只能读不能写 |
|
||||
| **Accept Edits** | 快速迭代 | 文件编辑自动放行 |
|
||||
| **Don't Ask** | 减少打断 | 尽量自动决策 |
|
||||
| **Auto** | 信任 AI | 自动分类决策 |
|
||||
| **Bypass** | 完全信任 | 所有操作自动放行 |
|
||||
| 模式 | `PermissionMode` 值 | 适用场景 | 行为 |
|
||||
|------|---------------------|---------|------|
|
||||
| **Default** | `'default'` | 日常使用 | 敏感操作逐一确认 |
|
||||
| **Plan Mode** | `'plan'` | 探索阶段 | 只能读不能写(`isReadOnly()` 检查) |
|
||||
| **Accept Edits** | `'acceptEdits'` | 快速迭代 | 工作区内文件编辑自动放行,其他操作仍需确认 |
|
||||
| **Don't Ask** | `'dontAsk'` | 减少打断 | 尽量自动决策,减少确认弹窗 |
|
||||
| **Auto** | `'auto'` | 信任 AI | 通过 transcript classifier 自动决策(需 `TRANSCRIPT_CLASSIFIER` feature flag) |
|
||||
| **Bypass** | `'bypassPermissions'` | 完全信任 | 所有操作自动放行(需显式 `--dangerously-skip-permissions`) |
|
||||
|
||||
**设计考量**:权限模式不是"越宽松越好"。Default 模式下每次确认看似烦人,但它是防止 AI 误操作的最后防线。Bypass 模式需要显式的危险标志才能启用——这不是给日常使用的。
|
||||
Plan Mode 切换由 `EnterPlanModeTool.call()` 触发:
|
||||
```typescript
|
||||
// EnterPlanModeTool.ts:88
|
||||
context.setAppState(prev => ({
|
||||
...prev,
|
||||
toolPermissionContext: applyPermissionUpdate(
|
||||
prepareContextForPlanMode(prev.toolPermissionContext),
|
||||
{ type: 'setMode', mode: 'plan', destination: 'session' },
|
||||
),
|
||||
}))
|
||||
```
|
||||
|
||||
退出时由 `ExitPlanModeV2Tool` 恢复为之前的模式。
|
||||
|
||||
## Denial Tracking:死循环防护
|
||||
|
||||
当 AI 被连续拒绝同一类操作达到 3 次时,系统注入消息迫使其改变策略。
|
||||
`src/utils/permissions/denialTracking.ts` 实现了拒绝追踪机制:
|
||||
|
||||
**为什么需要这个**?AI 有时会陷入"请求 → 被拒 → 用略有不同的方式请求同一个操作 → 再被拒"的死循环。Denial tracking 检测到这种模式后强制 AI 换思路。
|
||||
```typescript
|
||||
const DENIAL_LIMITS = {
|
||||
maxConsecutive: 3, // 同一工具连续拒绝上限
|
||||
maxTotal: 20, // 总拒绝上限
|
||||
}
|
||||
```
|
||||
|
||||
这是一个经典的"AI 行为修正"设计——不是通过硬约束阻止特定行为,而是通过反馈引导 AI 自行调整。
|
||||
当 AI 被连续拒绝同一类操作达到上限时:
|
||||
1. `recordDenial()` 记录拒绝,增加计数
|
||||
2. `shouldFallbackToPrompting()` 检测到连续拒绝,返回 true
|
||||
3. 系统向 AI 注入消息:"Your previous tool call was rejected..."
|
||||
4. AI 被迫改变策略,避免"反复请求同一个被拒操作"的死循环
|
||||
|
||||
## 运行时更新
|
||||
操作成功时调用 `recordSuccess()` 重置计数。
|
||||
|
||||
权限规则可以在运行时动态更新。当用户在确认对话框中选择"Always allow",规则被同时写入 settings 文件和内存中的权限上下文,立即生效。
|
||||
## 规则的运行时更新
|
||||
|
||||
## 接下来
|
||||
权限规则可以在运行时动态更新(`applyPermissionUpdate()`,`PermissionUpdate.ts`):
|
||||
|
||||
- **为什么安全很重要** — 理解权限系统的设计动机
|
||||
- **Plan Mode** — 理解"先规划再执行"的安全工作流
|
||||
- **沙箱** — 理解 Bash 命令的隔离执行环境
|
||||
```typescript
|
||||
type PermissionUpdate =
|
||||
| { type: 'addRules', destination, rules, behavior }
|
||||
| { type: 'replaceRules', destination, rules, behavior }
|
||||
| { type: 'removeRules', destination, rules, behavior }
|
||||
| { type: 'setMode', destination, mode }
|
||||
| { type: 'addDirectories', destination, directories }
|
||||
| { type: 'removeDirectories', destination, directories }
|
||||
```
|
||||
|
||||
当用户在 Ask 对话框中选择 "Always allow",系统调用 `persistPermissionUpdates()` 将规则写入对应层级的 settings 文件(project/user/managed),同时更新内存中的 `toolPermissionContext`。
|
||||
|
||||
@@ -1,82 +1,151 @@
|
||||
---
|
||||
title: "Plan Mode"
|
||||
description: "先看后做的安全机制。Plan Mode 让 AI 在探索阶段只能读不能写,形成方案后再提交用户审批。理解权限收窄、Prompt-based 权限和计划持久化的设计。"
|
||||
keywords: ["Plan Mode", "计划模式", "先规划再执行", "安全工作流"]
|
||||
title: "计划模式 - Plan Mode 先看后做的安全机制"
|
||||
description: "基于源码解析 Claude Code Plan Mode 的完整实现:EnterPlanModeTool/ExitPlanModeV2Tool 的工具设计、权限上下文切换机制、Prompt-based 权限请求、计划文件持久化、Teammate 审批流程。"
|
||||
keywords: ["Plan Mode", "计划模式", "EnterPlanMode", "ExitPlanMode", "prepareContextForPlanMode", "allowedPrompts"]
|
||||
---
|
||||
|
||||
## 核心问题
|
||||
{/* 本章目标:基于源码揭示 Plan Mode 的完整实现 */}
|
||||
|
||||
## 问题场景
|
||||
|
||||
你说"重构这个模块",AI 立刻开始改代码——但你还没搞清楚它打算怎么改。等改了一半发现方向不对,已经来不及了。
|
||||
|
||||
## 解决方案:先看后做
|
||||
## Plan Mode 的解决方案
|
||||
|
||||
Plan Mode 给对话加了一个"只读阶段":
|
||||
计划模式给对话加了一个"只读阶段",通过两个工具实现闭环:
|
||||
|
||||
<Steps>
|
||||
<Step title="进入计划模式">
|
||||
AI 判断任务需要规划,请求进入 Plan Mode。需要**用户审批**。
|
||||
<Step title="EnterPlanMode — 进入计划模式">
|
||||
AI 自主判断(或用户触发)任务需要规划,调用 `EnterPlanModeTool`(`packages/builtin-tools/src/tools/EnterPlanModeTool/EnterPlanModeTool.ts:36`)。该工具需要**用户审批**(`checkPermissions` 返回 `ask`)。
|
||||
</Step>
|
||||
<Step title="探索阶段">
|
||||
AI 只能使用只读工具(Read、Grep、Glob)。写操作被自动拒绝。
|
||||
<Step title="探索阶段 — 只读工具集">
|
||||
权限模式切换为 `'plan'`,AI 只能使用 `isReadOnly()` 为 true 的工具(Read、Grep、Glob、Agent 等)。写操作被自动拒绝。
|
||||
</Step>
|
||||
<Step title="提交方案审批">
|
||||
AI 完成探索后,将计划文件提交给用户审阅。这是第二个**需要审批**的节点。
|
||||
<Step title="ExitPlanMode — 提交方案审批">
|
||||
AI 完成探索后,调用 `ExitPlanModeV2Tool`(`packages/builtin-tools/src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.ts:147`),将计划文件提交给用户审阅。这是第二个**需要用户审批**的节点。
|
||||
</Step>
|
||||
<Step title="恢复执行">
|
||||
用户批准后,权限恢复,AI 按计划执行。
|
||||
<Step title="恢复执行 — 全部工具权限">
|
||||
用户批准后,权限模式恢复为进入前的状态,AI 按计划执行。
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
**两个审批节点的设计**:进入时审批("我可以探索吗?")和退出时审批("这个方案可以吗?")。两次审批确保用户对过程和结果都有控制权。
|
||||
|
||||
## 权限的自动收窄与恢复
|
||||
|
||||
### 进入:只读锁定
|
||||
### 进入:`prepareContextForPlanMode()`
|
||||
|
||||
权限模式切换为 `plan`,所有工具的 `isReadOnly()` 检查成为唯一准入条件。不是"某些工具被禁用",而是"只有标记为只读的工具才能执行"。
|
||||
`EnterPlanModeTool.call()`(第 77 行)的核心逻辑:
|
||||
|
||||
**设计考量**:基于属性而非名单的权限控制更安全。如果新增了一个工具但忘记把它加入"plan 模式禁用名单",基于名单的系统会漏过它;但基于 `isReadOnly()` 的系统不会——新工具必须显式声明自己只读才能在 Plan Mode 中使用。
|
||||
```typescript
|
||||
// 1. 记录转换状态(保存之前的模式)
|
||||
handlePlanModeTransition(currentMode, 'plan')
|
||||
|
||||
### 退出:Prompt-based 权限
|
||||
// 2. 切换权限上下文为 plan 模式
|
||||
context.setAppState(prev => ({
|
||||
...prev,
|
||||
toolPermissionContext: applyPermissionUpdate(
|
||||
prepareContextForPlanMode(prev.toolPermissionContext),
|
||||
{ type: 'setMode', mode: 'plan', destination: 'session' },
|
||||
),
|
||||
}))
|
||||
```
|
||||
|
||||
AI 可以在计划中声明它需要执行的命令类别。用户批准计划后,这些命令自动放行。
|
||||
`prepareContextForPlanMode()`(`src/utils/permissions/permissionSetup.ts`)做了什么:
|
||||
- 创建新的 `ToolPermissionContext`,`mode` 设为 `'plan'`
|
||||
- 在 plan 模式下,工具的 `isReadOnly()` 检查成为唯一准入条件
|
||||
- 如果用户的默认模式是 `'auto'`,还会激活 classifier 的副作用
|
||||
|
||||
**设计洞察**:这是 Plan Mode 最精妙的设计。传统做法是:计划完成后,AI 每执行一步都要用户确认。Prompt-based 权限让用户在审批计划时"一揽子"授权了后续操作——既减少了打断,又保持了控制。
|
||||
### 退出:权限恢复 + Prompt-based 权限
|
||||
|
||||
当然,AI 只能获得计划中声明的权限。如果计划说"运行测试",AI 不能突然执行 `rm -rf /`。
|
||||
`ExitPlanModeV2Tool` 的退出逻辑做了两件关键的事:
|
||||
|
||||
## 计划文件:可编辑的方案
|
||||
**1. 恢复权限模式**
|
||||
|
||||
计划内容被写入磁盘文件,用户可以在审批前修改 AI 的方案。
|
||||
通过 `handlePlanModeTransition()` 和 `applyPermissionUpdate()` 恢复到进入前的模式。
|
||||
|
||||
**为什么不直接在对话中展示计划**?
|
||||
1. 对话中的计划是"只读"的,用户无法直接修改
|
||||
2. 磁盘文件可以被用户用任何编辑器修改
|
||||
3. 修改后的计划成为 AI 执行的"合约"——AI 必须执行用户修改后的版本
|
||||
**2. 注入 Prompt-based 权限**
|
||||
|
||||
## 什么时候该用 Plan Mode
|
||||
这是 Plan Mode 最精妙的设计——AI 可以在计划中声明它需要执行的命令类别:
|
||||
|
||||
| 场景 | 应该用吗 | 原因 |
|
||||
|------|:--------:|------|
|
||||
| 修复 typo | 跳过 | 改动明确,无需规划 |
|
||||
| 添加删除按钮 | 通常跳过 | 路径明确 |
|
||||
| 重构认证系统 | **使用** | 高影响,需要理解全局 |
|
||||
| 架构决策(Redis vs 内存缓存) | **使用** | 需要权衡多种方案 |
|
||||
| 用户说"开始做 X" | 通常跳过 | 用户已明确意图 |
|
||||
```typescript
|
||||
// ExitPlanModeV2Tool 的 inputSchema
|
||||
allowedPrompts: z.array(z.object({
|
||||
tool: z.enum(['Bash']),
|
||||
prompt: z.string().describe('Semantic description, e.g. "run tests"'),
|
||||
})).optional()
|
||||
```
|
||||
|
||||
**设计哲学**:Plan Mode 是工具而非默认行为。AI 应该在"不确定性高"时使用它,而不是在每次修改时都进入。过度使用 Plan Mode 会降低效率,如同人类在每次改代码前都写设计文档一样低效。
|
||||
当 AI 提交计划时,如果声明了 `allowedPrompts: [{ tool: 'Bash', prompt: 'run tests' }]`,用户批准后,"run tests" 这类 Bash 命令会被自动放行——不再需要逐个确认。
|
||||
|
||||
## 与任务系统的配合
|
||||
## 计划文件的持久化
|
||||
|
||||
Plan Mode 通常与任务系统配合:
|
||||
1. AI 在探索阶段创建任务列表
|
||||
计划内容被写入磁盘文件(由 `getPlanFilePath()` 确定路径),这与简单的"AI 说一段话然后开始执行"有本质区别:
|
||||
|
||||
1. `ExitPlanModeV2Tool` 的 `normalizeToolInput` 从磁盘读取计划内容,注入到 `input.plan` 和 `input.planFilePath`
|
||||
2. 计划文件是用户**可编辑**的——用户可以在审批前修改 AI 的方案
|
||||
3. `planWasEdited` 字段标记用户是否修改了计划,影响后续的 tool_result 回显
|
||||
4. `persistFileSnapshotIfRemote()` 在远程场景下保存文件快照
|
||||
|
||||
## Teammate 场景下的计划审批
|
||||
|
||||
在 Agent Swarms(`isAgentSwarmsEnabled()`)模式下,计划审批有额外的协作流程:
|
||||
|
||||
```typescript
|
||||
// 如果是 Teammate 角色
|
||||
if (isTeammate()) {
|
||||
// 发送计划到 Team Leader 的 mailbox 等待审批
|
||||
const requestId = generateRequestId()
|
||||
writeToMailbox(getTeamName(), {
|
||||
type: 'plan_approval_request',
|
||||
plan, requestId, ...
|
||||
})
|
||||
// 返回 awaitingLeaderApproval: true
|
||||
// Team Leader 审批后通过 mailbox 通知 Teammate
|
||||
}
|
||||
```
|
||||
|
||||
这意味着在蜂群模式下,计划可能不是由直接用户审批,而是由 Team Leader 审批。
|
||||
|
||||
## 什么时候该用计划模式
|
||||
|
||||
`EnterPlanModeTool` 的 Prompt(`packages/builtin-tools/src/tools/EnterPlanModeTool/prompt.ts`)定义了两套触发标准——外部版本更积极(鼓励规划),内部版本更克制(仅在真正模糊时使用):
|
||||
|
||||
| 场景 | 外部版本 | 内部版本 |
|
||||
|------|---------|---------|
|
||||
| 修复 typo | 跳过 | 跳过 |
|
||||
| 添加删除按钮 | **进入**(涉及多个文件) | **跳过**(路径明确) |
|
||||
| 重构认证系统 | **进入** | **进入**(高影响重构) |
|
||||
| "开始做 X" | — | **跳过**(直接开始) |
|
||||
| 架构决策(Redis vs 内存缓存) | **进入** | **进入**(真正模糊) |
|
||||
|
||||
## 计划模式 + 任务系统
|
||||
|
||||
计划模式通常与任务系统配合使用:
|
||||
|
||||
1. 在计划模式中,AI 把实施步骤创建为任务列表(`TodoWrite`)
|
||||
2. 用户审批计划(包含任务列表)
|
||||
3. 退出 Plan Mode 后,AI 按任务列表逐项执行
|
||||
3. 退出计划模式后,AI 按任务列表逐项执行
|
||||
4. 用户可以通过任务列表追踪进度
|
||||
|
||||
这把"理解"和"执行"在时间上分开了——先花时间理解问题,再高效执行方案。
|
||||
## 完整生命周期
|
||||
|
||||
## 接下来
|
||||
|
||||
- **权限模型** — 理解支撑 Plan Mode 的完整权限体系
|
||||
- **任务管理** — 理解 Plan Mode 中创建的任务追踪
|
||||
- **子 Agent** — 理解 Plan Mode 中使用的 Explore Agent
|
||||
```
|
||||
用户: "重构这个模块"
|
||||
↓
|
||||
AI 判断需要规划 → 调用 EnterPlanModeTool
|
||||
↓ 用户审批(Ask 对话框)
|
||||
handlePlanModeTransition(default, 'plan') // 保存 default
|
||||
prepareContextForPlanMode() // 创建只读上下文
|
||||
↓
|
||||
AI 使用 Read/Grep/Glob/Agent 探索代码库
|
||||
↓ (可能 10+ 轮只读工具调用)
|
||||
AI 形成方案 → 调用 ExitPlanModeV2Tool({
|
||||
allowedPrompts: [
|
||||
{ tool: 'Bash', prompt: 'run tests' },
|
||||
{ tool: 'Bash', prompt: 'install dependencies' }
|
||||
]
|
||||
})
|
||||
↓ 用户审批计划(可编辑计划文件)
|
||||
恢复权限模式 → 注入 prompt-based 权限
|
||||
↓
|
||||
AI 使用全部工具执行计划,"run tests" 等命令自动放行
|
||||
```
|
||||
|
||||
@@ -20,7 +20,7 @@ keywords: ["沙箱", "sandbox", "权限", "Bash", "PowerShell", "bubblewrap", "s
|
||||
- 这一层仓库自己负责:策略、配置转换、启停判断、命令包裹、清理和权限联动
|
||||
- 真正做 OS 级隔离的是外部运行时 `@anthropic-ai/sandbox-runtime`
|
||||
|
||||
适配层负责导入底层运行时对象(`SandboxManager`、`SandboxViolationStore` 等),然后在它们外面再包一层符合 Claude Code 自身权限模型的接口。
|
||||
在 `src/utils/sandbox/sandbox-adapter.ts` 里,可以很清楚地看到这条边界:项目导入 `SandboxManager as BaseSandboxManager`、`SandboxViolationStore` 等运行时对象,然后在外面再包一层符合 Claude Code 自身权限模型的适配器。
|
||||
|
||||
底层隔离在不同平台上的落地也不是同一套实现:
|
||||
|
||||
@@ -64,7 +64,7 @@ keywords: ["沙箱", "sandbox", "权限", "Bash", "PowerShell", "bubblewrap", "s
|
||||
|
||||
### 1. 给 shell 一个 OS 级兜底
|
||||
|
||||
Bash AST 分析明确声明自己不是沙箱——它只判断"能不能可靠地理解命令结构",不能阻止危险命令真正运行。
|
||||
`src/utils/bash/ast.ts` 开头就写得很明确:Bash AST 分析不是沙箱,它只是在判断我们能不能可靠地理解命令结构,不能阻止危险命令真的运行。
|
||||
|
||||
这就是为什么应用层再聪明,也很难仅靠“执行前推断”覆盖完整风险面。像下面这些命令,真实副作用都要到运行时才完全展开:
|
||||
|
||||
@@ -106,7 +106,7 @@ Bash AST 分析明确声明自己不是沙箱——它只判断"能不能可靠
|
||||
|
||||
### 4. 拦截运行时绕过和逃逸路径
|
||||
|
||||
适配层专门把一些高风险路径额外加入 `denyWrite`,例如:
|
||||
这个仓库在 `src/utils/sandbox/sandbox-adapter.ts` 里专门把一些高风险路径额外加入 `denyWrite`,例如:
|
||||
|
||||
- `settings.json`
|
||||
- `.claude/skills`
|
||||
@@ -138,7 +138,7 @@ Bash AST 分析明确声明自己不是沙箱——它只判断"能不能可靠
|
||||
- 纯应用层的权限弹窗和规则匹配
|
||||
- Bash AST 解析本身
|
||||
|
||||
尤其要注意一点:Bash AST 分析不是沙箱。它只回答”我们能不能可信地提取 argv 结构”,并不负责阻止危险命令真正运行。
|
||||
尤其要注意一点:Bash AST 分析不是沙箱。源码自己写得很明确,它只回答“我们能不能可信地提取 argv 结构”,并不负责阻止危险命令真正运行。
|
||||
|
||||
## 哪些场景会走沙箱
|
||||
|
||||
@@ -166,7 +166,7 @@ Bash AST 分析明确声明自己不是沙箱——它只判断"能不能可靠
|
||||
5. 这条命令没有被显式排除
|
||||
6. 这次调用没有被允许以 `dangerouslyDisableSandbox` 绕过
|
||||
|
||||
对应入口在 `shouldUseSandbox()` 和沙箱适配层中。
|
||||
对应入口在 `packages/builtin-tools/src/tools/BashTool/shouldUseSandbox.ts` 和 `src/utils/sandbox/sandbox-adapter.ts`。
|
||||
|
||||
### 3. PowerShell 只在支持平台上走
|
||||
|
||||
@@ -514,6 +514,20 @@ REPL / CLI 启动
|
||||
|
||||
而沙箱负责的是最终兜底。
|
||||
|
||||
## 推荐的阅读路径
|
||||
|
||||
如果你想继续顺着源码深入,推荐按下面顺序看:
|
||||
|
||||
1. `packages/builtin-tools/src/tools/BashTool/shouldUseSandbox.ts`
|
||||
2. `src/utils/Shell.ts`
|
||||
3. `src/utils/sandbox/sandbox-adapter.ts`
|
||||
4. `src/utils/permissions/permissions.ts`
|
||||
5. `packages/builtin-tools/src/tools/BashTool/bashPermissions.ts`
|
||||
6. `src/utils/permissions/pathValidation.ts`
|
||||
7. `src/utils/permissions/filesystem.ts`
|
||||
|
||||
按这条线读,会更容易把“权限系统”和“沙箱系统”在脑中拆开。
|
||||
|
||||
## FAQ
|
||||
|
||||
### Q1:Linux 下 `echo hi > /etc/hosts` 会怎样?
|
||||
|
||||
@@ -1,121 +1,220 @@
|
||||
---
|
||||
title: "文件操作"
|
||||
description: "Read/Edit/Write 三个工具不是功能划分,而是风险分级。理解读取去重、原子性编辑、文件历史快照和安全防线的设计。"
|
||||
title: "文件操作工具 - 三大工具的源码级解剖"
|
||||
description: "逆向分析 FileRead、FileEdit、FileWrite 三大工具的完整执行链路:去重缓存、AST 安全编辑、原子性读写、文件历史快照的实现细节。"
|
||||
keywords: ["文件操作", "FileRead", "FileEdit", "FileWrite", "代码编辑", "原子写入"]
|
||||
---
|
||||
|
||||
## 核心设计:风险分级
|
||||
{/* 本章目标:从源码层面解剖三大文件工具的完整执行链路 */}
|
||||
|
||||
## 三大工具的职责分化
|
||||
|
||||
Claude Code 将文件操作拆分为三个独立工具——这不是功能划分,而是**风险分级**:
|
||||
|
||||
| 工具 | 风险级别 | 典型场景 |
|
||||
|------|---------|----------|
|
||||
| **Read** | 只读(免审批) | 查看代码、搜索内容 |
|
||||
| **Edit** | 写入(需确认) | 修改已有代码 |
|
||||
| **Write** | 写入/创建(需确认) | 创建新文件、全量重写 |
|
||||
| 工具 | 权限级别 | 核心方法 | 关键属性 |
|
||||
|------|---------|---------|---------|
|
||||
| **Read** | 只读(免审批) | `isReadOnly() → true` | `maxResultSizeChars: 100,000` |
|
||||
| **Edit** | 写入(需确认) | `checkWritePermissionForTool()` | `maxResultSizeChars: 100,000` |
|
||||
| **Write** | 写入(需确认) | `checkWritePermissionForTool()` | `maxResultSizeChars: 100,000` |
|
||||
|
||||
拆成三个工具让权限系统可以精确控制:只读模式只禁用 Edit/Write,允许 AI 自由探索代码;而全禁用模式则连 Read 都受限。
|
||||
<Tip>
|
||||
Read 的 `maxResultSizeChars` 为 100,000(100KB)。超出此阈值的结果会被持久化到磁盘,减少长会话的内存压力。实际的 token 级别截断由 `validateContentTokens()` 动态控制。
|
||||
</Tip>
|
||||
|
||||
## Read:多模态读取引擎
|
||||
## FileRead:多模态文件读取引擎
|
||||
|
||||
Read 工具不只是一个 `cat` 命令。它是一个多格式分发器:
|
||||
源码路径:`packages/builtin-tools/src/tools/FileReadTool/FileReadTool.ts`
|
||||
|
||||
| 文件类型 | 处理路径 | 特殊处理 |
|
||||
|---------|---------|---------|
|
||||
| 文本文件 | 分页读取 | 支持行号范围 |
|
||||
| 图片 | 压缩 + 降采样 | 自动调整到 token 预算内 |
|
||||
| PDF | 页面级提取 | 超大 PDF 强制分页读取 |
|
||||
| Notebook | JSON cell 解析 | 保留 cell 结构 |
|
||||
### 读取去重机制
|
||||
|
||||
### 读取去重
|
||||
Read 工具有一个常被忽视但至关重要的**去重层**。当 AI 重复读取同一个文件的同一范围时,系统不会浪费 token 发送两份完整内容:
|
||||
|
||||
当 AI 重复读取同一个文件时,系统通过文件修改时间(mtime)比对避免重复发送相同内容。约 18% 的 Read 调用是重复读取——去重机制直接节省了这部分 token 开销。
|
||||
```typescript
|
||||
// FileReadTool.ts — 去重逻辑
|
||||
const existingState = readFileState.get(fullFilePath)
|
||||
if (existingState && !existingState.isPartialView && existingState.offset !== undefined) {
|
||||
const rangeMatch = existingState.offset === offset && existingState.limit === limit
|
||||
if (rangeMatch) {
|
||||
const mtimeMs = await getFileModificationTimeAsync(fullFilePath)
|
||||
if (mtimeMs === existingState.timestamp) {
|
||||
return { data: { type: 'file_unchanged', file: { filePath: file_path } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**设计细节**:去重只对 Read 工具自身的读取生效。Edit/Write 也会更新内部状态,但不会误触发去重——通过 offset 字段区分读取来源。
|
||||
关键设计点:
|
||||
- 去重仅对 **Read 工具自身的读取**生效(通过 `offset !== undefined` 判定)
|
||||
- Edit/Write 也会写入 `readFileState`,但它们的 `offset` 为 `undefined`,所以不会误命中去重
|
||||
- 通过 mtime 比对确保文件未被外部修改
|
||||
- 有 GrowthBook killswitch(`tengu_read_dedup_killswitch`)可紧急关闭
|
||||
|
||||
实测数据:BQ proxy 显示约 18% 的 Read 调用是同文件碰撞,占 fleet `cache_creation` 的 2.64%。
|
||||
|
||||
### 多格式分发:文本、图片、PDF、Notebook 四条路径
|
||||
|
||||
Read 工具的 `callInner()` 按 `ext` 分发到四条完全不同的处理路径:
|
||||
|
||||
```
|
||||
.ipynb → readNotebook() → JSON cell 解析 → token 校验
|
||||
.png/.jpg/.gif/.webp → readImageWithTokenBudget() → 压缩+降采样
|
||||
.pdf → extractPDFPages() / readPDF() → 页面级提取
|
||||
其他 → readFileInRange() → 分页读取
|
||||
```
|
||||
|
||||
**图片路径的压缩策略**特别精细:
|
||||
1. 先用 `maybeResizeAndDownsampleImageBuffer()` 标准缩放
|
||||
2. 用 `base64.length * 0.125` 估算 token 数
|
||||
3. 超出预算时调用 `compressImageBufferWithTokenLimit()` 激进压缩
|
||||
4. 仍然超限时用 sharp 做最后兜底:`resize(400,400).jpeg({quality:20})`
|
||||
|
||||
**PDF 路径**有页数阈值:超过 `PDF_AT_MENTION_INLINE_THRESHOLD`(默认值在 `apiLimits.ts`)时强制分页读取,每请求最多 `PDF_MAX_PAGES_PER_READ` 页。
|
||||
|
||||
### 安全防线
|
||||
|
||||
Read 工具有多层安全门:
|
||||
Read 工具在 `validateInput()` 中设置了多层安全门:
|
||||
|
||||
- **设备文件屏蔽**:`/dev/zero`、`/dev/random` 等被直接拒绝——它们会产生无限输出或阻塞
|
||||
- **二进制文件拒绝**:排除图片/PDF 后,`.exe`、`.so` 等二进制文件被阻止
|
||||
- **UNC 路径跳过**:Windows 下 `\\server\share` 路径跳过操作,防止 SMB 凭据泄露
|
||||
1. **设备文件屏蔽**(`BLOCKED_DEVICE_PATHS`):`/dev/zero`、`/dev/random`、`/dev/tty` 等——防止无限输出或阻塞挂起
|
||||
2. **二进制文件拒绝**(`hasBinaryExtension`):排除 PDF 和图片扩展名后,阻止读取 `.exe`、`.so` 等二进制文件
|
||||
3. **UNC 路径跳过**:Windows 下 `\\server\share` 路径跳过文件系统操作,防止 SMB NTLM 凭据泄露
|
||||
4. **权限拒绝规则**(`matchingRuleForInput`):匹配 `deny` 规则后直接拒绝
|
||||
|
||||
### 智能错误提示
|
||||
### 文件未找到时的智能建议
|
||||
|
||||
文件不存在时,Read 不只是报错——它会尝试提供修复建议:
|
||||
- 相似文件名的推荐
|
||||
- 基于 CWD 的相对路径建议
|
||||
- macOS 截图文件名中特殊空格字符的纠正
|
||||
当文件不存在时,Read 不会只报一个 "file not found":
|
||||
|
||||
## Edit:精确字符串替换
|
||||
```typescript
|
||||
// FileReadTool.ts
|
||||
const similarFilename = findSimilarFile(fullFilePath) // 相似扩展名
|
||||
const cwdSuggestion = await suggestPathUnderCwd(fullFilePath) // cwd 相对路径建议
|
||||
// macOS 截图特殊处理:薄空格(U+202F) vs 普通空格
|
||||
const altPath = getAlternateScreenshotPath(fullFilePath)
|
||||
```
|
||||
|
||||
Edit 工具的核心操作是"找到旧字符串,替换为新字符串"。听起来简单,实际充满了边缘情况。
|
||||
对 macOS 截图文件名中 AM/PM 前的薄空格(U+202F)做了特殊处理——这是实测中发现的跨 macOS 版本兼容性问题。
|
||||
|
||||
### 引号标准化
|
||||
## FileEdit:精确字符串替换引擎
|
||||
|
||||
AI 模型只能输出直引号(`'` `"`),但源码中可能使用弯引号(`'` `'` `"` `"`)。Edit 工具在匹配时自动标准化引号,但写入时保持文件原有的引号风格——如果文件用弯引号,替换后的新内容也用弯引号。
|
||||
源码路径:`packages/builtin-tools/src/tools/FileEditTool/FileEditTool.ts` + `utils.ts`
|
||||
|
||||
**设计洞察**:这是一个典型的"AI 能力边界补偿"设计。AI 的输出限制(只能用直引号)不应该成为文件修改的问题。系统在 AI 和文件系统之间做了透明翻译。
|
||||
### 引号标准化:AI 无法输出的字符怎么办
|
||||
|
||||
AI 模型只能输出直引号(`'` `"`),但源码中可能使用弯引号(`'` `'` `"` `"`)。`findActualString()` 函数处理了这个不对齐:
|
||||
|
||||
```typescript
|
||||
// utils.ts:73-93
|
||||
export function findActualString(fileContent: string, searchString: string): string | null {
|
||||
if (fileContent.includes(searchString)) return searchString // 精确匹配
|
||||
const normalizedSearch = normalizeQuotes(searchString) // 弯引号→直引号
|
||||
const normalizedFile = normalizeQuotes(fileContent)
|
||||
const idx = normalizedFile.indexOf(normalizedSearch)
|
||||
if (idx !== -1) return fileContent.substring(idx, idx + searchString.length)
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
匹配后还有**反向引号保持**(`preserveQuoteStyle`):如果文件用弯引号,替换后的新字符串也自动转换为弯引号,包括缩写中的撇号(如 "don't")。
|
||||
|
||||
### 原子性读-改-写
|
||||
|
||||
Edit 的执行过程是一个无锁原子更新协议:
|
||||
Edit 工具的 `call()` 方法实现了一个**无锁原子更新**协议:
|
||||
|
||||
```
|
||||
备份旧内容 → 同步读取 → mtime 校验 → 查找匹配 → 计算 diff → 写入磁盘 → 更新缓存
|
||||
1. await fs.mkdir(dir) ← 确保目录存在(异步,在临界区外)
|
||||
2. await fileHistoryTrackEdit() ← 备份旧内容(异步,在临界区外)
|
||||
3. readFileSyncWithMetadata() ← 同步读取当前文件内容(临界区开始)
|
||||
4. getFileModificationTime() ← mtime 校验
|
||||
5. findActualString() ← 引号标准化匹配
|
||||
6. getPatchForEdit() ← 计算 diff
|
||||
7. writeTextContent() ← 写入磁盘
|
||||
8. readFileState.set() ← 更新缓存(临界区结束)
|
||||
```
|
||||
|
||||
**关键约束**:从"同步读取"到"写入磁盘"之间不允许任何异步操作。这确保在 mtime 校验和实际写入之间不会有其他进程修改文件——否则就会出现"读到的内容和写入时的内容不一致"的竞态条件。
|
||||
步骤 3-8 之间**不允许任何异步操作**(源码注释明确写道:"Please avoid async operations between here and writing to disk to preserve atomicity")。这确保了在 mtime 校验和实际写入之间不会有其他进程修改文件。
|
||||
|
||||
### 防覆写校验
|
||||
|
||||
Edit 前置条件:
|
||||
1. **必须先读取**文件(AI 不能编辑没看过的文件)
|
||||
2. **文件未被外部修改**(mtime 未变)
|
||||
Edit 工具在 `validateInput()` 中检查两个条件:
|
||||
1. **必须先读取**(`readFileState` 中有记录且不是局部视图)
|
||||
2. **文件未被外部修改**(`mtime` 未变,或全量读取时内容完全一致)
|
||||
|
||||
Windows 上的 mtime 可能因云同步或杀毒软件被修改而不改变内容,因此对全量读取做了内容级比对作为兜底。
|
||||
```typescript
|
||||
// FileEditTool.ts — Windows 特殊处理
|
||||
const isFullRead = readTimestamp.offset === undefined && readTimestamp.limit === undefined
|
||||
if (isFullRead && fileContent === readTimestamp.content) {
|
||||
// 内容不变,安全继续(Windows 云同步/杀毒可能改 mtime)
|
||||
}
|
||||
```
|
||||
|
||||
## Write:全量写入与创建
|
||||
Windows 上的 mtime 可能因云同步、杀毒软件等被修改而不改变内容,因此对全量读取做了内容级比对作为兜底。
|
||||
|
||||
Write 与 Edit 共享大部分基础设施(权限检查、mtime 校验、历史备份),但有两个关键差异。
|
||||
### 编辑大小限制
|
||||
|
||||
```typescript
|
||||
const MAX_EDIT_FILE_SIZE = 1024 * 1024 * 1024 // 1 GiB
|
||||
```
|
||||
|
||||
超过 1 GiB 的文件直接拒绝编辑——这是 V8 字符串长度限制(~2^30 字符)的安全边界。
|
||||
|
||||
## FileWrite:全量写入与创建
|
||||
|
||||
源码路径:`packages/builtin-tools/src/tools/FileWriteTool/FileWriteTool.ts`
|
||||
|
||||
Write 工具与 Edit 共享大部分基础设施(权限检查、mtime 校验、fileHistory 备份),但有两个关键差异:
|
||||
|
||||
### 行尾处理
|
||||
|
||||
Write 始终使用 LF 行尾。早期版本会保留旧文件的行尾风格,但这导致 Linux 上 bash 脚本被注入 `\r`——现在 AI 发什么行尾就用什么行尾,不再尝试"智能"转换。
|
||||
```typescript
|
||||
// FileWriteTool.ts:300-305 — 关键注释
|
||||
// Write is a full content replacement — the model sent explicit line endings
|
||||
// in `content` and meant them. Do not rewrite them.
|
||||
writeTextContent(fullFilePath, content, enc, 'LF')
|
||||
```
|
||||
|
||||
**设计教训**:有时"不做智能处理"比"做智能处理"更安全。
|
||||
Write 工具始终使用 `LF` 行尾。早期版本会保留旧文件的行尾或采样仓库行尾风格,但这导致 Linux 上 bash 脚本被注入 `\r`——现在 AI 发什么行尾就用什么行尾。
|
||||
|
||||
### 创建 vs 更新
|
||||
### 输出区分
|
||||
|
||||
Write 返回操作类型:
|
||||
- **create**:文件不存在,全新创建
|
||||
- **update**:文件存在且被覆盖,包含完整 diff
|
||||
Write 工具返回 `type: 'create' | 'update'`:
|
||||
- `create`:文件不存在,`originalFile: null`
|
||||
- `update`:文件存在且被覆盖,`structuredPatch` 包含完整 diff
|
||||
|
||||
这让用户和 AI 都能清楚知道操作的实际影响。
|
||||
## 文件历史快照系统
|
||||
|
||||
## 文件历史快照
|
||||
源码路径:`src/utils/fileHistory.ts`
|
||||
|
||||
每次 Edit/Write 前都会备份旧内容。快照系统最多保留 100 个版本,使用内容哈希去重(同一文件多次未变只存一份)。
|
||||
每次 Edit/Write 前都会调用 `fileHistoryTrackEdit()`,快照存储在 `FileHistoryState` 中:
|
||||
|
||||
**设计目的**:不是版本控制(那是 git 的工作),而是"撤销"功能——用户可以在会话中回退 AI 的任何文件修改。
|
||||
```typescript
|
||||
type FileHistorySnapshot = {
|
||||
messageId: UUID // 关联的助手消息 ID
|
||||
trackedFileBackups: Record<string, FileHistoryBackup> // 文件路径 → 备份版本
|
||||
timestamp: Date
|
||||
}
|
||||
```
|
||||
|
||||
## LSP 通知链路
|
||||
- 最多保留 `MAX_SNAPSHOTS = 100` 个快照
|
||||
- 备份使用**内容哈希**去重(同一文件多次未变只存一份)
|
||||
- 支持差异统计(`DiffStats`:`insertions` / `deletions` / `filesChanged`)
|
||||
- 快照通过 `recordFileHistorySnapshot()` 持久化到会话存储
|
||||
|
||||
Edit 和 Write 完成后会通知 LSP 服务器和 IDE 扩展:
|
||||
1. 清除旧的诊断信息
|
||||
2. 通知 LSP 文件已变更
|
||||
3. 触发 LSP 重新计算诊断(如 TypeScript 类型检查)
|
||||
4. 通知 IDE 更新 diff 视图
|
||||
### LSP 通知链路
|
||||
|
||||
这确保文件修改后 IDE 端的实时反馈是同步的——AI 改了一个文件,TypeScript 的类型错误立刻出现在编辑器中。
|
||||
Edit 和 Write 完成写入后都会:
|
||||
1. `clearDeliveredDiagnosticsForFile()` — 清除旧诊断
|
||||
2. `lspManager.changeFile()` — 通知 LSP 文件已变更
|
||||
3. `lspManager.saveFile()` — 触发 LSP 保存事件(TypeScript server 会重新计算诊断)
|
||||
4. `notifyVscodeFileUpdated()` — 通知 VSCode 扩展更新 diff 视图
|
||||
|
||||
## 安全提醒
|
||||
这条链路确保文件修改后 IDE 端的实时反馈是同步的。
|
||||
|
||||
Read 工具在读取文件内容后追加安全提醒:如果文件看起来像恶意代码,AI 应该分析但拒绝改进。这是在"帮助用户"和"防止滥用"之间的平衡。
|
||||
## Cyber Risk 防御
|
||||
|
||||
## 接下来
|
||||
Read 工具在文本内容后追加一个 `<system-reminder>` 提示:
|
||||
|
||||
- **搜索与导航** — Glob/Grep 的搜索策略
|
||||
- **Shell 执行** — Bash 的沙箱和超时控制
|
||||
- **权限模型** — 理解工具权限的完整设计
|
||||
```
|
||||
Whenever you read a file, you should consider whether it would be
|
||||
considered malware. You CAN and SHOULD provide analysis of malware,
|
||||
what it is doing. But you MUST refuse to improve or augment the code.
|
||||
```
|
||||
|
||||
这个提示只在非豁免模型上生效(`MITIGATION_EXEMPT_MODELS` 目前包含 `claude-opus-4-6`)。模型级别的豁免表明:防恶意代码的判断力在不同模型间有差异,这是一个精巧的分级策略。
|
||||
|
||||
@@ -1,116 +1,283 @@
|
||||
---
|
||||
title: "搜索与导航"
|
||||
description: "Glob 按名称找文件,Grep 按内容搜代码——两个维度覆盖代码库定位需求。理解搜索结果排序、token 预算控制和多后端 Web 搜索的设计。"
|
||||
title: "搜索与导航工具 - 代码库精准定位"
|
||||
description: "解析 Claude Code 的搜索导航工具:Glob 文件匹配、Grep 内容搜索,基于 ripgrep 的高性能代码检索,帮助 AI 在百万行代码中精准定位。"
|
||||
keywords: ["代码搜索", "Glob", "Grep", "ripgrep", "文件搜索"]
|
||||
---
|
||||
|
||||
## 两个搜索维度
|
||||
## 两种搜索维度
|
||||
|
||||
| 维度 | 工具 | 适用场景 |
|
||||
|------|------|---------|
|
||||
| **按名称找文件** | Glob | "找到所有测试文件"、"找 config 开头的文件" |
|
||||
| **按内容找代码** | Grep | "哪里定义了这个函数"、"谁在调用这个 API" |
|
||||
| 维度 | 工具 | 底层实现 | 适用场景 |
|
||||
|------|------|----------|---------|
|
||||
| **按名称找文件** | Glob | ripgrep `--files` + glob 过滤 | "找到所有测试文件"、"找 config 开头的文件" |
|
||||
| **按内容找代码** | Grep | ripgrep 正则搜索 | "哪里定义了这个函数"、"谁在调用这个 API" |
|
||||
|
||||
两者共享同一个 ripgrep 引擎,通过不同的参数组合实现不同搜索模式。
|
||||
|
||||
## ripgrep 的内嵌策略
|
||||
## ripgrep 的内嵌方式
|
||||
|
||||
Claude Code 不依赖系统安装的 ripgrep。它使用三级降级策略确保在任何环境下都能工作:
|
||||
Claude Code 不依赖系统安装的 ripgrep——它在 `src/utils/ripgrep.ts` 中实现了三级降级策略:
|
||||
|
||||
| 优先级 | 方式 | 场景 |
|
||||
|--------|------|------|
|
||||
| 1 | 系统 PATH 中的 rg | 用户自定义安装 |
|
||||
| 2 | 编译进二进制的 rg | Bun 静态编译模式 |
|
||||
| 3 | vendor 目录中的预编译二进制 | npm 安装模式 |
|
||||
```
|
||||
优先级 1: 系统 ripgrep (USE_BUILTIN_RIPGREP=false)
|
||||
→ 使用 PATH 中的 rg 二进制
|
||||
→ 安全考虑:只用命令名 'rg',不用完整路径,防止 PATH 劫持
|
||||
|
||||
**设计考量**:搜索是 AI 最频繁使用的操作之一,不能因为用户没装 ripgrep 就降级到低效方案。但也不能假设系统 ripgrep 总是可用——不同环境的安装差异太大了。
|
||||
优先级 2: 内嵌模式 (bundled/native build)
|
||||
→ process.execPath 自身,argv0='rg'
|
||||
→ Bun 将 rg 静态编译进二进制,通过 argv0 分发
|
||||
|
||||
优先级 3: vendor 目录 (npm build)
|
||||
→ vendor/ripgrep/{arch}-{platform}/rg
|
||||
→ macOS 需要 codesign 签名 + 移除 quarantine xattr
|
||||
```
|
||||
|
||||
平台适配示例:
|
||||
```
|
||||
vendor/ripgrep/
|
||||
├── x86_64-darwin/rg # macOS Intel
|
||||
├── arm64-darwin/rg # macOS Apple Silicon
|
||||
├── x86_64-linux/rg # Linux Intel
|
||||
├── arm64-linux/rg # Linux ARM
|
||||
└── x86_64-win32/rg.exe # Windows
|
||||
```
|
||||
|
||||
### macOS 代码签名
|
||||
|
||||
vendor 模式下的 rg 二进制需要 ad-hoc 签名才能通过 Gatekeeper(`codesignRipgrepIfNecessary()`):
|
||||
|
||||
```typescript
|
||||
// 首次使用时执行:
|
||||
// 1. 检查是否已是有效签名
|
||||
codesign -vv -d <rg-path>
|
||||
// 2. 如果只是 linker-signed,重新签名
|
||||
codesign --sign - --force --preserve-metadata=entitlements,requirements,flags,runtime <rg-path>
|
||||
// 3. 移除隔离属性
|
||||
xattr -d com.apple.quarantine <rg-path>
|
||||
```
|
||||
|
||||
## 搜索结果的设计考量
|
||||
|
||||
### 结果数量与 Token 预算
|
||||
### head_limit 与 Token 预算
|
||||
|
||||
默认最多返回 250 条匹配。这不是随意选择的数字:
|
||||
大型项目的搜索结果可能有数十万条。默认最多返回 250 条匹配——这不是随意选择,而是**token 预算**的约束:
|
||||
|
||||
- 每条匹配行约 50-100 token
|
||||
- 250 条 ≈ 12,500-25,000 token
|
||||
- 这大约占 200K 上下文窗口的 6-12%
|
||||
- 这大约占 200k 上下文窗口的 6-12%
|
||||
- 超过这个比例,AI 的推理质量会下降
|
||||
|
||||
AI 可以按需调整 `head_limit` 参数——搜索小项目时可以用更大的值。
|
||||
Grep 工具的 `head_limit` 参数让 AI 可以按需调整——搜索小项目时可以用更大的值。
|
||||
|
||||
### 按修改时间排序
|
||||
|
||||
Glob 默认把**最近修改的文件排在前面**。这不是文件系统的默认排序,而是刻意的设计决策:
|
||||
Glob 默认把**最近修改的文件排在前面**。这不是默认的文件系统排序,而是刻意的设计决策:
|
||||
|
||||
**设计假设**:最近修改的文件最可能与当前任务相关。
|
||||
```
|
||||
设计假设:最近修改的文件最可能与当前任务相关
|
||||
实际效果:AI 优先看到"活"的代码,而不是沉寂的历史文件
|
||||
```
|
||||
|
||||
**实际效果**:AI 优先看到"活"的代码,而不是沉寂的历史文件。当用户说"帮我修这个 bug"时,最近改过的文件通常就是 bug 所在。
|
||||
在 `packages/builtin-tools/src/tools/GlobTool/` 中,ripgrep 的输出在返回给 AI 前按 mtime 排序。
|
||||
|
||||
### 错误恢复
|
||||
### ripgrep 的错误处理
|
||||
|
||||
ripgrep 执行有专门的错误恢复策略:
|
||||
ripgrep 执行有专门的错误恢复链(`src/utils/ripgrep.ts`):
|
||||
|
||||
| 错误 | 处理 |
|
||||
|------|------|
|
||||
| 资源不足 | 自动切换到单线程模式重试 |
|
||||
| 超时 | 返回已有部分结果,丢弃可能不完整的最后一行 |
|
||||
| 进程无响应 | 5 秒后强制终止 |
|
||||
|
||||
**设计哲学**:部分结果比没有结果好。即使搜索没完成,AI 也能基于已有信息做出判断。
|
||||
| **EAGAIN**(资源不足) | 自动以单线程模式 `-j 1` 重试 |
|
||||
| **超时**(默认 20s,WSL 60s) | 返回已有部分结果,丢弃可能不完整的最后一行 |
|
||||
| **缓冲区溢出** | 截断到 20MB,返回已收集的结果 |
|
||||
| **SIGTERM 失效** | 5 秒后升级为 SIGKILL |
|
||||
|
||||
## ToolSearch:在 50+ 工具中发现目标
|
||||
|
||||
当可用工具超过 50 个时(含 MCP 提供的外部工具),AI 可能不知道该用哪个。ToolSearch 提供了工具发现机制。
|
||||
当可用工具超过 50 个时(含 MCP 提供的外部工具),AI 可能不知道该用哪个。**ToolSearch**(`packages/builtin-tools/src/tools/ToolSearchTool/`)提供了工具发现机制。
|
||||
|
||||
### 搜索算法
|
||||
|
||||
ToolSearch 实现了基于关键词的加权搜索(`searchToolsWithKeywords()`):
|
||||
|
||||
```
|
||||
输入: "database connection"
|
||||
→ 精确匹配工具名(快速路径)
|
||||
→ MCP 前缀匹配("mcp__postgres" 匹配所有 postgres 工具)
|
||||
→ 关键词拆分 + 加权评分
|
||||
→ 按分数排序,返回 top-N
|
||||
输入: query = "database connection"
|
||||
↓
|
||||
1. 精确匹配: 检查是否有工具名完全匹配(快速路径)
|
||||
2. MCP 前缀匹配: "mcp__postgres" → 匹配所有 postgres 相关工具
|
||||
3. 关键词拆分: ["database", "connection"]
|
||||
4. 工具名解析:
|
||||
- MCP 工具: "mcp__server__action" → ["server", "action"]
|
||||
- 普通工具: "FileEditTool" → ["file", "edit", "tool"]
|
||||
5. 加权评分:
|
||||
- 工具名精确匹配: 10 分(MCP: 12 分)
|
||||
- 工具名部分匹配: 5 分(MCP: 6 分)
|
||||
- searchHint 匹配: 4 分
|
||||
- 描述匹配: 2 分
|
||||
6. 必选词过滤: "+database" 前缀表示必须包含
|
||||
7. 按分数排序,返回 top-N
|
||||
```
|
||||
|
||||
评分权重:工具名精确匹配(10分)> 工具名部分匹配(5分)> 搜索提示匹配(4分)> 描述匹配(2分)。MCP 工具额外加分,因为它们通常按功能组织。
|
||||
### `select:` 直接选择
|
||||
|
||||
### 延迟加载
|
||||
AI 也可以用 `select:ToolName` 精确选择已知工具。这比搜索更快,且支持逗号分隔的批量选择(`select:A,B,C`)。
|
||||
|
||||
不是所有工具都常驻内存。MCP 工具和低频工具被标记为延迟加载——只有在 ToolSearch 选中后才真正加载。这减少了每次 API 调用的 token 开销(工具描述占用大量 token)。
|
||||
### 延迟加载(Deferred Tools)
|
||||
|
||||
不是所有工具都常驻内存。MCP 工具和低频工具被标记为 `isDeferredTool`,只有在 ToolSearch 选中后才真正加载。这减少了每次 API 调用的 token 开销(工具描述占用大量 token)。
|
||||
|
||||
### 缓存策略
|
||||
|
||||
工具描述的获取是 memoized 的——只在延迟工具集合变化时清除缓存:
|
||||
|
||||
```typescript
|
||||
// 工具名排序后拼接作为缓存 key
|
||||
function getDeferredToolsCacheKey(deferredTools: Tools): string {
|
||||
return deferredTools.map(t => t.name).sort().join(',')
|
||||
}
|
||||
```
|
||||
|
||||
## Web 搜索与抓取
|
||||
|
||||
AI 的信息获取不局限于本地代码。WebSearch 搜索互联网,WebFetch 抓取特定网页内容——和人类开发者的工作方式一致。
|
||||
AI 的信息获取不局限于本地代码:
|
||||
|
||||
### WebSearch 的多后端设计
|
||||
- **WebSearch**(`packages/builtin-tools/src/tools/WebSearchTool/`):调用 Anthropic API 的 `web_search_20250305` server tool 搜索互联网
|
||||
- **WebFetch**(`packages/builtin-tools/src/tools/WebFetchTool/`):抓取特定 URL 内容,转换为 Markdown 供 AI 阅读
|
||||
|
||||
WebSearch 通过适配器模式支持三种搜索后端:
|
||||
这让 AI 可以查阅文档、搜索 Stack Overflow、阅读 GitHub issue——和人类开发者的工作方式一致。
|
||||
|
||||
| 后端 | 适用场景 | 需要 API 密钥 |
|
||||
|------|---------|:------------:|
|
||||
| **Anthropic API** | 使用官方 API 的用户 | 是 |
|
||||
| **Bing** | 第三方代理/非官方端点 | 否 |
|
||||
| **Brave** | 需要 Brave 搜索 | 是 |
|
||||
### WebSearch 实现机制
|
||||
|
||||
**设计考量**:不是所有用户都使用 Anthropic 官方 API。第三方代理(OpenAI 兼容、Bedrock 等)无法使用 Anthropic 的服务端搜索工具,因此需要独立的搜索后端。
|
||||
WebSearch 通过适配器模式支持三种搜索后端,由 `packages/builtin-tools/src/tools/WebSearchTool/adapters/` 中的工厂函数 `createAdapter()` 选择:
|
||||
|
||||
Bing 适配器直接抓取搜索页面并解析结果。它使用完整的浏览器请求头绕过反爬机制,并解码 Bing 的重定向 URL 获取真实链接。
|
||||
```
|
||||
适配器架构:
|
||||
WebSearchTool.call()
|
||||
→ createAdapter() 选择后端
|
||||
├─ ApiSearchAdapter — Anthropic API 服务端搜索(需官方 API 密钥)
|
||||
├─ BingSearchAdapter — 直接抓取 Bing 搜索页面解析(无需 API 密钥)
|
||||
└─ BraveSearchAdapter — 调用 Brave LLM Context API 解析(需 Brave API 密钥)
|
||||
→ adapter.search(query, options)
|
||||
→ 转换为统一 SearchResult[] 格式返回
|
||||
```
|
||||
|
||||
### WebFetch 的安全防护
|
||||
#### 适配器选择逻辑
|
||||
|
||||
WebFetch 不只是"抓取 URL"——它有完整的安全防护层:
|
||||
`adapters/index.ts` 中的工厂函数按以下优先级选择后端:
|
||||
|
||||
| 防护 | 说明 |
|
||||
| 优先级 | 条件 | 适配器 |
|
||||
|--------|------|--------|
|
||||
| 1 | 环境变量 `WEB_SEARCH_ADAPTER=api` | `ApiSearchAdapter` |
|
||||
| 2 | 环境变量 `WEB_SEARCH_ADAPTER=bing` | `BingSearchAdapter` |
|
||||
| 3 | 环境变量 `WEB_SEARCH_ADAPTER=brave` | `BraveSearchAdapter` |
|
||||
| 4 | API Base URL 指向 Anthropic 官方 | `ApiSearchAdapter` |
|
||||
| 5 | 第三方代理 / 非官方端点 | `BingSearchAdapter` |
|
||||
|
||||
适配器是无状态的,同一会话内缓存复用。
|
||||
|
||||
#### ApiSearchAdapter — API 服务端搜索
|
||||
|
||||
将搜索请求委托给 Anthropic API 的 `web_search_20250305` server tool:
|
||||
|
||||
```
|
||||
调用链:
|
||||
ApiSearchAdapter.search(query, options)
|
||||
→ queryModelWithStreaming() 发起独立的 API 调用
|
||||
→ 携带 extraToolSchemas: [BetaWebSearchTool20250305]
|
||||
→ API 服务端执行搜索,返回流式事件
|
||||
→ server_tool_use / web_search_tool_result / text 交替返回
|
||||
→ extractSearchResults() 从 content blocks 提取 SearchResult[]
|
||||
```
|
||||
|
||||
| 特性 | 实现 |
|
||||
|------|------|
|
||||
| 域名预检 | 调用 Anthropic API 检查域名是否在黑名单中 |
|
||||
| 重定向控制 | 仅允许同域重定向,跨域重定向需要 AI 重新调用 |
|
||||
| 内容大小限制 | 单次响应上限 10MB |
|
||||
| URL 验证 | 长度、协议、公网域名检查 |
|
||||
| **模型选择** | Feature flag `tengu_plum_vx3` 控制用 Haiku(强制 tool_choice)还是主模型 |
|
||||
| **搜索上限** | 每次调用最多 8 次搜索(`max_uses: 8`) |
|
||||
| **域过滤** | 支持 `allowedDomains` / `blockedDomains` |
|
||||
| **进度追踪** | 流式解析 `input_json_delta` 提取 query,实时回调 `onProgress` |
|
||||
|
||||
**预批准域名**:约 90 个主流技术文档站点(MDN、Python docs、React docs 等)无需手动授权即可抓取。对预批准域名,WebFetch 跳过摘要步骤直接返回原文——因为技术文档本身的结构化程度已经足够好。
|
||||
#### BingSearchAdapter — Bing 搜索页面解析
|
||||
|
||||
## 接下来
|
||||
直接抓取 Bing 搜索 HTML 并用正则提取结果,无需 API 密钥:
|
||||
|
||||
- **Shell 执行** — Bash 的沙箱和超时控制
|
||||
- **任务管理** — TaskCreate/TaskUpdate 的追踪系统
|
||||
- **工具系统** — 理解所有工具的统一接口设计
|
||||
```
|
||||
调用链:
|
||||
BingSearchAdapter.search(query, options)
|
||||
→ axios.get(bing.com/search?q=...) — 使用浏览器级别 headers 绕过反爬
|
||||
→ extractBingResults(html)
|
||||
→ 正则匹配 <li class="b_algo"> 块
|
||||
→ 提取 <h2><a> 标题和 URL
|
||||
→ resolveBingUrl() 解码 Bing 重定向链接
|
||||
→ extractSnippet() 三级降级提取摘要
|
||||
→ 客户端域过滤 (allowedDomains / blockedDomains)
|
||||
→ 返回 SearchResult[]
|
||||
```
|
||||
|
||||
**反爬策略**:Bing 对非浏览器 UA 返回需要 JS 渲染的空页面。适配器使用完整的 Edge 浏览器请求头(包含 `Sec-Ch-Ua`、`Sec-Fetch-*` 等现代浏览器标头)确保获得完整 HTML。同时使用 `setmkt=en-US` 参数统一市场定位,避免 Bing 基于用户 IP 做区域化定向(如跳转到德语/新加坡市场导致结果不相关)。
|
||||
|
||||
**URL 解码**:Bing 搜索结果中的 URL 为重定向格式(`bing.com/ck/a?...&u=a1aHR0cHM6Ly9...`),`resolveBingUrl()` 从 `u` 参数中 base64 解码出真实目标 URL(`a1` 前缀 = https,`a0` = http)。
|
||||
|
||||
**摘要提取**(`extractSnippet()`)按优先级尝试三个来源:
|
||||
1. `<p class="b_lineclamp...">` — 带行截断的摘要段落
|
||||
2. `<div class="b_caption">` 内的 `<p>` — 普通摘要段落
|
||||
3. `<div class="b_caption">` 的直接文本内容 — 兜底方案
|
||||
|
||||
| 特性 | 实现 |
|
||||
|------|------|
|
||||
| **超时** | 30 秒(`FETCH_TIMEOUT_MS`) |
|
||||
| **域过滤** | 支持 `allowedDomains` / `blockedDomains`,含子域名匹配 |
|
||||
| **进度追踪** | 发送 query_update 和 search_results_received 回调 |
|
||||
| **中止支持** | 外部 AbortSignal 传播到 axios 请求 |
|
||||
|
||||
### WebSearchTool 统一接口
|
||||
|
||||
`WebSearchTool`(`packages/builtin-tools/src/tools/WebSearchTool/WebSearchTool.ts`)是面向主循环的工具定义,所有 provider 均可使用(`isEnabled()` 始终返回 true)。它将适配器返回的 `SearchResult[]` 转换为内部 `Output` 格式,`mapToolResultToToolResultBlockParam` 将搜索结果格式化为带 markdown 超链接的文本,并附加 "REMINDER" 要求主模型在回复中包含 Sources。
|
||||
|
||||
### WebFetch 实现机制
|
||||
|
||||
WebFetch 是一个完整的 HTTP 客户端 + 内容处理管线:
|
||||
|
||||
```
|
||||
调用链:
|
||||
WebFetchTool.call({ url, prompt })
|
||||
→ getURLMarkdownContent(url)
|
||||
→ validateURL() — 长度≤2000、无用户名密码、公网域名
|
||||
→ URL_CACHE 命中检查(15 分钟 TTL LRU,50MB 上限)
|
||||
→ checkDomainBlocklist() — 调用 api.anthropic.com/api/web/domain_info 预检
|
||||
→ getWithPermittedRedirects() — axios 请求,自定义重定向处理
|
||||
→ HTML → Turndown 转 Markdown(懒加载单例,~1.4MB)
|
||||
→ 非 HTML → 原始文本
|
||||
→ 二进制(PDF 等)→ persistBinaryContent() 保存到磁盘
|
||||
→ applyPromptToMarkdown()
|
||||
→ 截断到 100K 字符
|
||||
→ queryHaiku() 用小模型按 prompt 提取信息
|
||||
→ 返回处理后的结果
|
||||
```
|
||||
|
||||
安全防护多层设计:
|
||||
|
||||
| 层级 | 机制 | 说明 |
|
||||
|------|------|------|
|
||||
| **域名预检** | `checkDomainBlocklist()` | 调用 `api.anthropic.com/api/web/domain_info?domain=…`,5 分钟缓存 |
|
||||
| **重定向控制** | `isPermittedRedirect()` | 仅允许同 host(±www)重定向,跨域重定向返回提示让 AI 重新调用 |
|
||||
| **重定向深度** | `MAX_REDIRECTS = 10` | 防止重定向循环无限挂起 |
|
||||
| **内容大小** | `MAX_HTTP_CONTENT_LENGTH = 10MB` | 单次响应上限 |
|
||||
| **请求超时** | `FETCH_TIMEOUT_MS = 60s` | 主请求超时;域名预检 10s |
|
||||
| **URL 验证** | `validateURL()` | 长度、协议、用户名密码、公网域名检查 |
|
||||
| **egress 检测** | `X-Proxy-Error: blocked-by-allowlist` | 检测企业代理拦截 |
|
||||
|
||||
预批准域名(`packages/builtin-tools/src/tools/WebFetchTool/preapproved.ts`):
|
||||
|
||||
用户无需手动授权即可抓取的域名列表,包含 ~90 个主流技术文档站点(MDN、Python docs、React docs、AWS docs 等)。列表分为 hostname-only 和 path-prefix 两类,查找复杂度 O(1)。
|
||||
|
||||
对预批准域名,WebFetch 跳过 Haiku 摘要步骤(如果内容是 Markdown 且 < 100K 字符),直接返回原文——因为技术文档本身的结构化程度已经足够好。
|
||||
|
||||
权限模型方面,WebFetch 按 hostname 生成 `domain:xxx` 规则匹配用户的 allow/deny/ask 规则,支持用户对特定域名配置永久允许或拒绝。
|
||||
|
||||
### ripgrep 的流式输出
|
||||
|
||||
对于交互式场景(如 QuickOpen),ripgrep 支持**流式输出**(`ripGrepStream()`):
|
||||
|
||||
```
|
||||
rg --files → 逐 chunk 到达 → 按行分割 → onLines(lines) 回调
|
||||
```
|
||||
|
||||
不需要等 ripgrep 完成整个搜索——第一批结果在 rg 仍在遍历目录树时就已展示。调用者可以通过 AbortSignal 提前终止搜索(例如找到足够多的结果后)。
|
||||
|
||||
@@ -1,84 +1,168 @@
|
||||
---
|
||||
title: "Shell 执行"
|
||||
description: "AI 执行命令是最危险的能力。BashTool 如何通过只读判定、AST 解析、自动后台化和输出截断在安全与效率间取得平衡。"
|
||||
title: "命令执行工具 - BashTool 安全设计与实现"
|
||||
description: "从源码角度解析 Claude Code BashTool:只读命令判定、AST 安全解析、自动后台化、输出截断和专用工具 vs shell 命令的设计权衡。"
|
||||
keywords: ["Bash 工具", "命令执行", "Shell 执行", "安全命令", "AI 执行命令"]
|
||||
---
|
||||
|
||||
## 核心挑战
|
||||
{/* 本章目标:从源码角度揭示 BashTool 的安全设计、执行链路和关键工程决策 */}
|
||||
|
||||
AI 能执行任意 shell 命令是最强大也最危险的能力。一个 `rm -rf /` 就能造成不可逆的破坏。
|
||||
## 执行链路总览
|
||||
|
||||
BashTool 的设计核心是在**安全**和**效率**之间取得平衡——让 AI 能自由执行只读操作,但对有副作用的命令严格把关。
|
||||
一条 Bash 命令从 AI 决策到实际执行的完整路径:
|
||||
|
||||
## 只读命令的判定
|
||||
```
|
||||
AI 生成 tool_use: { command: "npm test" }
|
||||
↓
|
||||
BashTool.validateInput() ← 基础输入校验
|
||||
↓
|
||||
BashTool.checkPermissions() ← 权限检查(详见安全体系章节)
|
||||
├── isReadOnly()? → 自动 allow(只读命令免审批)
|
||||
├── bashToolHasPermission() ← AST 解析 + 语义检查 + 规则匹配
|
||||
└── 未匹配 → 弹窗确认
|
||||
↓
|
||||
BashTool.call() → runShellCommand()
|
||||
↓
|
||||
shouldUseSandbox(input) ← 是否需要沙箱包裹
|
||||
↓
|
||||
Shell.exec(command, { shouldUseSandbox, shouldAutoBackground })
|
||||
↓
|
||||
spawn(wrapped_command) ← 实际进程创建
|
||||
```
|
||||
|
||||
BashTool 的 `isReadOnly()` 决定一条命令是否需要用户确认:
|
||||
## 只读命令的判定:为什么 Read 免审批而 Bash 不一定
|
||||
|
||||
| 命令类别 | 示例 | 是否只读 |
|
||||
|---------|------|:--------:|
|
||||
| 搜索类 | `find`、`grep`、`rg`、`which` | ✓ |
|
||||
| 读取类 | `cat`、`head`、`wc`、`jq`、`sort` | ✓ |
|
||||
| 列表类 | `ls`、`tree`、`du` | ✓ |
|
||||
| 中性命令 | `echo`、`true`、`false` | 不影响判定 |
|
||||
BashTool 的 `isReadOnly()` 方法(`packages/builtin-tools/src/tools/BashTool/BashTool.tsx:655`)决定一条命令是否被视为"只读":
|
||||
|
||||
### 复合命令的处理
|
||||
```typescript
|
||||
isReadOnly(input) {
|
||||
const compoundCommandHasCd = commandHasAnyCd(input.command)
|
||||
const result = checkReadOnlyConstraints(input, compoundCommandHasCd)
|
||||
return result.behavior === 'allow'
|
||||
}
|
||||
```
|
||||
|
||||
对于复合命令(`ls dir && echo "---" && ls dir2`),系统拆分后逐段检查——**所有非中性段都必须属于只读集合**,整条命令才被视为只读。
|
||||
判定逻辑基于 4 个命令集合(`BashTool.tsx:120-166`):
|
||||
|
||||
**设计考量**:`echo` 等中性命令不影响判定,因为 `ls && echo "done"` 和单纯的 `ls` 在副作用上没有区别。但如果 `ls && git push`,`git push` 有副作用,整条命令就不能免审批。
|
||||
| 集合 | 命令 | 性质 |
|
||||
|------|------|------|
|
||||
| `BASH_SEARCH_COMMANDS` | find, grep, rg, ag, ack, locate, which, whereis | 搜索类 |
|
||||
| `BASH_READ_COMMANDS` | cat, head, tail, wc, stat, file, jq, awk, sort, uniq... | 读取/分析类 |
|
||||
| `BASH_LIST_COMMANDS` | ls, tree, du | 列表类 |
|
||||
| `BASH_SEMANTIC_NEUTRAL_COMMANDS` | echo, printf, true, false, : | 语义中性(不影响判定) |
|
||||
|
||||
## AST 安全解析
|
||||
对于复合命令(`ls dir && echo "---" && ls dir2`),系统拆分后逐段检查——**所有非中性段都必须属于上述集合**,整条命令才被视为只读。
|
||||
|
||||
权限检查不是基于简单的字符串匹配——系统使用 tree-sitter bash 解析器分析命令的抽象语法树。
|
||||
```typescript
|
||||
// BashTool.tsx — 简化的判定逻辑
|
||||
for (const part of partsWithOperators) {
|
||||
if (BASH_SEMANTIC_NEUTRAL_COMMANDS.has(baseCommand)) continue // 跳过中性段
|
||||
if (!isPartSearch && !isPartRead && !isPartList) {
|
||||
return { isSearch: false, isRead: false, isList: false } // 有任何一段不通过 → 非只读
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**为什么需要 AST 解析**?字符串匹配无法处理 `git push` 被嵌在管道、子 shell 或条件表达式中的情况。AST 解析可以准确提取每个子命令,确保 `git push` 不会因为被嵌在看似无害的上下文中而绕过检查。
|
||||
## AST 安全解析:tree-sitter bash 解析
|
||||
|
||||
**Fail-safe 策略**:解析失败时,系统假设命令不安全,触发所有安全检查。宁可多确认一次,也不要漏过一个危险命令。
|
||||
`preparePermissionMatcher()`(`BashTool.tsx:663`)在权限检查前用 `parseForSecurity()` 解析命令结构:
|
||||
|
||||
## 超时与自动后台化
|
||||
```typescript
|
||||
async preparePermissionMatcher({ command }) {
|
||||
const parsed = await parseForSecurity(command)
|
||||
if (parsed.kind !== 'simple') {
|
||||
return () => true // 解析失败 → fail-safe,触发所有 hook
|
||||
}
|
||||
// 提取子命令列表,剥离 VAR=val 前缀
|
||||
const subcommands = parsed.commands.map(c => c.argv.join(' '))
|
||||
return pattern => {
|
||||
return subcommands.some(cmd => matchWildcardPattern(pattern, cmd))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
长时间运行的命令不应该阻塞 AI 的整个工作循环。
|
||||
关键安全点:对于复合命令 `ls && git push`,解析后拆分为 `["ls", "git push"]`,确保 `git push` 不会因为前半段是只读命令而绕过权限检查。解析失败时采用 fail-safe 策略——假设不安全,触发所有安全 hook。
|
||||
|
||||
### 分级超时
|
||||
## 超时控制:分级策略
|
||||
|
||||
| 场景 | 超时 |
|
||||
|------|------|
|
||||
| 默认 | 2 分钟 |
|
||||
| 用户显式设置 | 最长 10 分钟 |
|
||||
```
|
||||
用户指定 timeout → 直接使用
|
||||
↓ 未指定
|
||||
getDefaultTimeoutMs()
|
||||
├── 默认上限:120,000ms(2 分钟)
|
||||
└── 最大上限:600,000ms(10 分钟,用户显式设置时)
|
||||
```
|
||||
|
||||
### 自动后台化
|
||||
超时后系统不会直接杀进程——`ShellCommand`(`src/utils/ShellCommand.ts:144`)通过 `onTimeout` 回调通知调用方,由调用方决定是终止还是后台化。
|
||||
|
||||
主线程 Agent 有 15 秒的"阻塞预算"——超过这个时间,系统自动将命令转为后台任务,AI 可以继续做其他事。后台任务完成后通过通知机制汇报结果。
|
||||
## 自动后台化
|
||||
|
||||
**设计考量**:一个 `npm install` 可能需要几分钟,不应该让 AI 在此期间完全停滞。自动后台化让 AI 可以在等待安装的同时继续做其他工作——和人类开发者一样。
|
||||
长时间运行的命令可以自动转为后台任务,不阻塞 AI 的 agentic loop:
|
||||
|
||||
## 输出截断
|
||||
```typescript
|
||||
// BashTool.tsx:1158
|
||||
const shouldAutoBackground = !isBackgroundTasksDisabled
|
||||
&& isAutobackgroundingAllowed(command)
|
||||
```
|
||||
|
||||
命令输出过长时会触发截断,防止海量日志塞进 AI 的上下文窗口。
|
||||
自动后台化的完整链路:
|
||||
|
||||
截断不是简单砍尾——系统通过 `isIncomplete` 标记告知 AI 输出不完整。AI 可以决定是否需要用更精确的命令(如 `grep` 管道、`head` 限制)重新获取。
|
||||
```
|
||||
命令开始执行
|
||||
↓ 进度轮询
|
||||
15 秒内未完成(ASSISTANT_BLOCKING_BUDGET_MS)
|
||||
↓
|
||||
检查 isAutobackgroundingAllowed(command)
|
||||
↓ 允许
|
||||
将前台任务转为后台任务(backgroundExistingForegroundTask)
|
||||
↓
|
||||
shellCommand.onTimeout → spawnBackgroundTask()
|
||||
↓
|
||||
返回 taskId 给 AI,AI 可以继续做其他事
|
||||
↓
|
||||
后台任务完成后通过通知机制汇报结果
|
||||
```
|
||||
|
||||
**设计哲学**:让 AI 知道"信息不完整"比给它一堆截断的垃圾更有用。AI 会根据不完整的信息自行调整策略。
|
||||
主线程 Agent 有 15 秒的阻塞预算——超过这个时间,系统自动将命令后台化。这防止了一个 `npm install` 阻塞整个 agentic loop 数分钟。
|
||||
|
||||
## 专用工具 vs Shell 命令
|
||||
## 输出截断策略
|
||||
|
||||
Claude Code 为文件读写、搜索等操作提供了专用工具(Read、Grep、Glob),而不是让 AI 用 `cat`、`grep` 等 shell 命令:
|
||||
命令输出过长时会触发截断,防止把海量日志塞进 AI 的上下文窗口:
|
||||
|
||||
| 截断点 | 位置 | 行为 |
|
||||
|--------|------|------|
|
||||
| `maxResultSizeChars` | 工具级(通常 100K 字符) | 超长输出在写入消息前截断 |
|
||||
| 进度轮询截断 | `onProgress` 回调 | 只传递最后几行作为进度显示 |
|
||||
| `totalBytes` 标记 | `isIncomplete` 参数 | 告知 AI 输出被截断 |
|
||||
|
||||
截断不是简单砍尾——`isIncomplete` 标记确保 AI 知道输出不完整,可以决定是否需要用更精确的命令重新获取。
|
||||
|
||||
## 为什么用专用工具而不是直接调 shell
|
||||
|
||||
Claude Code 为文件读写、代码搜索等操作提供了专用工具(Read、Grep、Glob),而不是让 AI 用 `cat`、`grep` 等 shell 命令。这不仅是用户体验的选择,更是架构层面的设计决策:
|
||||
|
||||
| 维度 | 专用工具 | Bash 命令 |
|
||||
|------|---------|----------|
|
||||
| **权限** | 只读操作自动放行 | 需要整条命令的权限检查 |
|
||||
| **输出** | 结构化数据,支持 diff 高亮 | 纯文本,无渲染优化 |
|
||||
| **性能** | 文件缓存、分页、token 预算 | 每次新进程,无缓存 |
|
||||
| **并发** | 只读操作可并行执行 | 有副作用的命令必须串行 |
|
||||
| **权限粒度** | `Read` 是只读操作 → 自动放行 | `Bash: cat file` 需要审批整条命令(cat 在只读集合中但走不同路径) |
|
||||
| **输出结构化** | 返回结构化数据,UI 可渲染 diff、高亮 | 纯文本输出,无渲染优化 |
|
||||
| **性能优化** | 文件缓存、分页、token 预算控制 | 每次都是新进程,无缓存 |
|
||||
| **并发安全** | `isConcurrencySafe()` 返回 `true` → 可并行执行 | Bash 命令可能有副作用,串行执行 |
|
||||
| **安全审计** | 工具名精确匹配权限规则 | 需 AST 解析命令结构后匹配 |
|
||||
|
||||
**设计洞察**:专用工具在安全性和效率上都优于等效的 shell 命令。`Read` 工具知道自己是只读的,所以可以自动放行;而 `cat` 走 BashTool 的权限路径,需要更多检查。
|
||||
`isConcurrencySafe()`(`BashTool.tsx:652`)是一个常被忽视但重要的设计——只有只读命令可以在 agentic loop 中并行执行,有副作用的命令必须串行,防止竞态条件。
|
||||
|
||||
## 进度反馈
|
||||
## 进度反馈的流式设计
|
||||
|
||||
BashTool 的命令执行是流式的——输出逐行推送,用户可以实时看到 AI 正在执行什么。这比"命令执行中...请等待"的黑盒体验好得多。
|
||||
BashTool 的命令执行是流式的,通过 `onProgress` 回调逐行推送输出:
|
||||
|
||||
## 接下来
|
||||
```
|
||||
runShellCommand()
|
||||
├── Shell.exec() 启动子进程
|
||||
├── 每秒轮询输出文件
|
||||
├── onProgress(lastLines, allLines, totalLines, totalBytes, isIncomplete)
|
||||
│ ├── 更新 lastProgressOutput / fullOutput
|
||||
│ └── resolveProgress() → 唤醒 generator yield
|
||||
├── yield { type: 'progress', output, fullOutput, elapsedTimeSeconds }
|
||||
└── return { code, stdout, interrupted, ... }
|
||||
```
|
||||
|
||||
- **任务管理** — TaskCreate/TaskUpdate 的追踪系统
|
||||
- **权限模型** — 理解只读判定的完整安全体系
|
||||
- **沙箱** — Bash 命令的隔离执行环境
|
||||
UI 层通过 `useToolCallProgress` hook 实时展示命令输出。`resolveProgress()` 信号机制让 generator 在有新数据时才 yield,避免了忙等待。
|
||||
|
||||
@@ -1,113 +1,212 @@
|
||||
---
|
||||
title: "任务管理"
|
||||
description: "任务追踪是 AI 自我管理的关键。理解双轨架构(V1 内存 / V2 文件系统)、依赖管理、认领竞争和验证推动的设计。"
|
||||
title: "任务管理系统 - TodoWrite 与 Tasks 双轨架构"
|
||||
description: "揭秘 Claude Code 任务管理系统的双轨架构:V1 内存 TodoWrite 与 V2 文件系统 Tasks,包含依赖管理、认领竞争和验证推动机制。"
|
||||
keywords: ["任务管理", "TodoWrite", "任务队列", "依赖管理", "多任务"]
|
||||
---
|
||||
|
||||
## 核心问题
|
||||
{/* 本章目标:揭示任务系统 V1(内存 TodoWrite)和 V2(文件系统 Task*)的双轨架构,以及依赖管理、认领竞争、验证推动的工程细节 */}
|
||||
|
||||
复杂任务需要分解为多个步骤。没有任务追踪,AI 容易"迷失"——做了第三步忘了第二步,或者跳过关键验证直接宣布完成。
|
||||
## 双轨架构:TodoWrite V1 与 Tasks V2
|
||||
|
||||
任务系统让 AI 能规划、追踪和汇报自己的工作进度。
|
||||
Claude Code 的任务管理并非单一系统,而是两个并存、按运行模式切换的实现:
|
||||
|
||||
## 双轨架构
|
||||
| 维度 | V1: TodoWrite | V2: TaskCreate / TaskUpdate / TaskList / TaskGet |
|
||||
|------|--------------|--------------------------------------------------|
|
||||
| **启用条件** | 非交互式(pipe/SDK)或 `isTodoV2Enabled()` 返回 `false` | 交互式 REPL(默认)或 `CLAUDE_CODE_ENABLE_TASKS=1` |
|
||||
| **存储** | 内存中 `AppState.todos[sessionId]`(Zustand store) | 文件系统 `~/.claude/tasks/<taskListId>/<id>.json` |
|
||||
| **数据模型** | `{content, status, activeForm}` — 扁平三元组 | `{id, subject, description, activeForm, owner, status, blocks[], blockedBy[], metadata}` — 完整实体 |
|
||||
| **持久化** | 进程退出即丢失 | 跨进程存活,支持多 Agent 并发访问 |
|
||||
| **并发安全** | 无(单会话单写者) | 文件锁 + 高水位标记 + TOCTOU 防护 |
|
||||
|
||||
Claude Code 有两个并存的任务系统,按运行模式自动切换:
|
||||
|
||||
| 维度 | V1: TodoWrite | V2: TaskCreate/TaskUpdate |
|
||||
|------|:------------:|:------------------------:|
|
||||
| **存储** | 内存(进程退出即丢失) | 文件系统(跨进程持久化) |
|
||||
| **适用** | 非交互式(pipe/SDK) | 交互式 REPL(默认) |
|
||||
| **数据** | 扁平三元组 | 完整实体(含依赖、认领) |
|
||||
| **并发** | 无(单会话) | 文件锁 + 高水位标记 |
|
||||
|
||||
**设计考量**:V1 追求极简——pipe 模式是一次性执行,不需要持久化。V2 追求可靠——交互式会话和多 Agent 团队需要任务在进程崩溃后仍能恢复。
|
||||
切换逻辑位于 `isTodoV2Enabled()`(`src/utils/tasks.ts:133`):交互式会话默认启用 V2,SDK/pipe 模式回落 V1。两者互斥——`TodoWriteTool.isEnabled` 返回 `!isTodoV2Enabled()`,而 `TaskCreateTool.isEnabled` 返回 `isTodoV2Enabled()`。
|
||||
|
||||
## V1:TodoWrite 的极简设计
|
||||
|
||||
V1 本质是一个全量替换操作——每次调用传入完整的任务列表,完全覆盖之前的状态。
|
||||
TodoWrite 本质是一个**全量替换**操作——每次调用传入完整的 `todos[]` 数组,完全覆盖之前的状态:
|
||||
|
||||
### 智能清空
|
||||
```typescript
|
||||
// packages/builtin-tools/src/tools/TodoWriteTool/TodoWriteTool.ts — call() 核心逻辑
|
||||
async call({ todos }, context) {
|
||||
const todoKey = context.agentId ?? getSessionId()
|
||||
const oldTodos = appState.todos[todoKey] ?? []
|
||||
const allDone = todos.every(_ => _.status === 'completed')
|
||||
const newTodos = allDone ? [] : todos // 全部完成则清空列表
|
||||
// ... 写入 AppState
|
||||
}
|
||||
```
|
||||
|
||||
当所有任务都完成时,列表被自动清空。这确保 UI 上不会有"已完成"的视觉噪音。
|
||||
### 智能清空与验证推动
|
||||
|
||||
### 验证推动(Verification Nudge)
|
||||
一个微妙的设计:当所有任务都 `completed` 时,`newTodos` 被设为空数组(而非保留 `completed` 列表)。这确保 UI 上不会有"已完成"的视觉噪音。
|
||||
|
||||
当 AI 完成了 3+ 个任务且没有任何一个是验证步骤时,系统追加提示催促 AI 派生验证子 Agent。
|
||||
此外,V1 包含一个**验证推动**(verification nudge)机制:当主线程 Agent 完成 3+ 个任务且没有任何一个是验证步骤时,系统在 tool_result 中追加提示,催促 Agent 派生验证子 Agent:
|
||||
|
||||
**设计洞察**:这是防止 AI "自说自话地宣布完成"的防御性设计。它不是硬约束(AI 可以忽略),而是结构性推动——通过在合适的时机插入提醒,让 AI 自己决定是否验证。
|
||||
```typescript
|
||||
// 条件:主线程 + 全部完成 + ≥3 项 + 无验证任务
|
||||
if (allDone && todos.length >= 3 && !todos.some(t => /verif/i.test(t.content))) {
|
||||
verificationNudgeNeeded = true
|
||||
}
|
||||
// tool_result 中追加:
|
||||
// "NOTE: You just closed out 3+ tasks and none was a verification step..."
|
||||
```
|
||||
|
||||
为什么是 3 个任务?太少的阈值会产生过多噪音,太多则会让 AI 在完成大量工作后才被提醒验证,错过了早期发现问题的机会。
|
||||
这是防止 Agent "自说自话地宣布完成"的防御性设计——通过结构性推动而非硬约束。
|
||||
|
||||
## V2:文件系统持久化
|
||||
## V2:文件系统持久化的任务系统
|
||||
|
||||
### 数据模型
|
||||
|
||||
每个任务是一个独立的 JSON 文件,包含:
|
||||
- **subject**:祈使句标题("Fix auth bug")
|
||||
- **activeForm**:进行时形式("Fixing auth bug"),用于 spinner
|
||||
- **status**:pending → in_progress → completed
|
||||
- **owner**:认领该任务的 Agent
|
||||
- **blocks / blockedBy**:任务间依赖
|
||||
每个任务是一个独立 JSON 文件,路径为 `~/.claude/tasks/<taskListId>/<id>.json`:
|
||||
|
||||
### ID 分配的安全保证
|
||||
```typescript
|
||||
// src/utils/tasks.ts — TaskSchema
|
||||
{
|
||||
id: string, // 自增整数(1, 2, 3...)
|
||||
subject: string, // 祈使句标题(如 "Fix auth bug")
|
||||
description: string, // 详细描述
|
||||
activeForm?: string, // 进行时形式(如 "Fixing auth bug"),用于 spinner
|
||||
owner?: string, // 认领该任务的 Agent ID/名称
|
||||
status: "pending" | "in_progress" | "completed",
|
||||
blocks: string[], // 此任务阻塞哪些任务 ID
|
||||
blockedBy: string[], // 哪些任务 ID 阻塞此任务
|
||||
metadata?: Record<string, unknown> // 任意附加数据
|
||||
}
|
||||
```
|
||||
|
||||
任务 ID 是递增整数,但在并发场景下需要防止竞争:
|
||||
- 使用排他锁防止两个 Agent 同时创建相同 ID
|
||||
- 高水位标记确保删除任务后 ID 不会被重用
|
||||
### 任务列表 ID 的解析优先级
|
||||
|
||||
**设计考量**:为什么不使用 UUID?整数 ID 更直观("任务 3 阻塞任务 5"比"任务 a3f2 阻塞任务 b7c1"更易读)。但在并发环境下需要额外的工作来保证唯一性。
|
||||
`getTaskListId()` 按 5 级优先级解析任务归属:
|
||||
|
||||
1. `CLAUDE_CODE_TASK_LIST_ID` 环境变量(显式覆盖)
|
||||
2. 进程内 teammate 上下文的 teamName(共享 leader 的任务列表)
|
||||
3. `CLAUDE_CODE_TEAM_NAME` 环境变量(进程级 teammate)
|
||||
4. Leader 通过 `setLeaderTeamName()` 设置的 teamName
|
||||
5. `getSessionId()`(独立会话的兜底)
|
||||
|
||||
这意味着多 Agent 团队模式下,所有 teammate 自动共享同一个任务列表,无需额外协调。
|
||||
|
||||
### ID 分配与高水位标记
|
||||
|
||||
任务 ID 是简单的递增整数,但在并发场景下需要防止竞争:
|
||||
|
||||
```typescript
|
||||
// src/utils/tasks.ts — createTask() 简化
|
||||
async function createTask(taskListId, taskData) {
|
||||
release = await lockfile.lock(lockPath, LOCK_OPTIONS) // 获取排他锁
|
||||
const highestId = await findHighestTaskId(taskListId) // 读取当前最大 ID
|
||||
const id = String(highestId + 1) // 递增
|
||||
await writeFile(path, JSON.stringify({ id, ...taskData }))
|
||||
return id
|
||||
}
|
||||
```
|
||||
|
||||
锁配置使用指数退避重试 30 次(总计约 2.6 秒),适配 10+ 并发 Agent 的 swarm 场景。
|
||||
|
||||
高水位标记文件 `.highwatermark` 确保删除任务后 ID 不会被重用——即使任务 #5 被删除,下一个新建任务仍然是 #6。
|
||||
|
||||
## 依赖管理:blocks / blockedBy
|
||||
|
||||
任务间的依赖通过双向字段实现:
|
||||
- `taskA.blocks = ["3"]` — 任务 A 完成前,任务 3 不能开始
|
||||
- `task3.blockedBy = ["A"]` — 任务 3 必须等任务 A 完成
|
||||
任务间的依赖通过双向链表式的 `blocks` / `blockedBy` 字段实现:
|
||||
|
||||
两端同时维护,删除任务时自动清理所有引用。
|
||||
- `taskA.blocks = ["3"]` 表示 "任务 A 完成前,任务 3 不能开始"
|
||||
- `task3.blockedBy = ["A"]` 表示 "任务 3 必须等任务 A 完成"
|
||||
|
||||
**为什么是双向而非单向**?因为两个方向的查询都很常见:
|
||||
- "任务 A 阻塞了谁?" → 读 `blocks`
|
||||
- "任务 3 在等谁?" → 读 `blockedBy`
|
||||
`blockTask()` 函数同时维护两端:
|
||||
|
||||
单向存储需要遍历所有任务来回答其中一个问题。
|
||||
```typescript
|
||||
// src/utils/tasks.ts — blockTask()
|
||||
// A blocks B → 更新 A.blocks 加入 B,同时更新 B.blockedBy 加入 A
|
||||
if (!fromTask.blocks.includes(toTaskId)) {
|
||||
await updateTask(taskListId, fromTaskId, { blocks: [...fromTask.blocks, toTaskId] })
|
||||
}
|
||||
if (!toTask.blockedBy.includes(fromTaskId)) {
|
||||
await updateTask(taskListId, toTaskId, { blockedBy: [...toTask.blockedBy, fromTaskId] })
|
||||
}
|
||||
```
|
||||
|
||||
删除任务时,系统自动清理所有指向它的依赖引用(`deleteTask()` 遍历全部任务移除 `blocks` 和 `blockedBy` 中的引用)。
|
||||
|
||||
## 任务认领与并发控制
|
||||
|
||||
多个 Agent 可能同时想认领同一个任务。系统提供两种锁定粒度:
|
||||
`claimTask()` 是 V2 的核心并发原语,支持两种锁定粒度:
|
||||
|
||||
| 模式 | 锁定范围 | 适用场景 |
|
||||
|------|---------|---------|
|
||||
| 任务级锁 | 只锁定目标任务 | 单 Agent |
|
||||
| 列表级锁 + Agent 忙碌检查 | 锁定整个任务目录 | 多 Agent 团队 |
|
||||
### 1. 任务级锁(默认)
|
||||
|
||||
认领失败有多种原因:任务已被认领、任务已完成、依赖未满足、Agent 已有其他未完成任务。
|
||||
|
||||
**设计考量**:列表级锁的 `checkAgentBusy` 防止一个 Agent 一次认领太多任务。在 swarm 模式下,每个 Agent 应该专注于一件事——认领新任务前必须完成或放弃当前任务。
|
||||
|
||||
## 多 Agent 团队的生命周期
|
||||
仅锁定目标任务文件,适合单 Agent 场景:
|
||||
|
||||
```
|
||||
Leader 创建任务 → 设置依赖 → Teammate 认领 → 执行 → 完成 → 解锁下游任务
|
||||
↓
|
||||
Teammate 异常退出 → 未完成任务被重置
|
||||
getTask → 检查 owner → 检查 status → 检查 blockedBy → 写入 owner
|
||||
```
|
||||
|
||||
Teammate 异常退出时,其未完成任务被自动重置为无主状态,Leader 可以重新分配。这确保了单个 Agent 的崩溃不会永远阻塞整个团队。
|
||||
### 2. 列表级锁 + Agent 忙碌检查
|
||||
|
||||
## 与 Plan Mode 的配合
|
||||
当 `checkAgentBusy: true` 时,锁定整个任务列表目录(`.lock` 文件),原子化地完成:
|
||||
|
||||
Plan Mode 和任务系统是互补但独立的机制:
|
||||
```
|
||||
listTasks → 检查任务状态 → 检查依赖 → 检查 Agent 是否已拥有其他未完成任务 → 写入 owner
|
||||
```
|
||||
|
||||
1. Plan Mode 限制工具集为只读,迫使 AI 先理解再行动
|
||||
2. AI 在 Plan Mode 中创建任务列表
|
||||
认领失败有 4 种原因:
|
||||
|
||||
| `reason` | 含义 |
|
||||
|----------|------|
|
||||
| `task_not_found` | 任务 ID 不存在 |
|
||||
| `already_claimed` | 已被其他 Agent 认领 |
|
||||
| `already_resolved` | 任务已标记 completed |
|
||||
| `blocked` | blockedBy 列表中有未完成的任务 |
|
||||
| `agent_busy` | 该 Agent 已拥有其他未完成任务(仅 `checkAgentBusy` 模式) |
|
||||
|
||||
## Agent 团队的任务生命周期
|
||||
|
||||
在 swarms 模式下,任务系统的生命周期是这样的:
|
||||
|
||||
```
|
||||
Leader 创建团队
|
||||
↓
|
||||
Leader 用 TaskCreate 创建任务(status=pending, owner=undefined)
|
||||
↓
|
||||
Leader 用 TaskUpdate 设置依赖关系(addBlocks/addBlockedBy)
|
||||
↓
|
||||
Teammate 调用 TaskList → 发现可认领的任务
|
||||
↓
|
||||
Teammate 调用 TaskUpdate(taskId, {status: "in_progress"})
|
||||
→ 自动设置 owner 为 teammate 名称
|
||||
→ Leader 通过 mailbox 收到 task_assignment 通知
|
||||
↓
|
||||
Teammate 完成工作 → TaskUpdate(taskId, {status: "completed"})
|
||||
→ tool_result 提示 "Call TaskList to find your next available task"
|
||||
→ 依赖此任务的其他任务自动解锁
|
||||
↓
|
||||
Teammate 异常退出 → unassignTeammateTasks()
|
||||
→ 未完成任务被重置为 pending + owner=undefined
|
||||
→ Leader 收到通知并重新分配
|
||||
```
|
||||
|
||||
### Hooks 集成
|
||||
|
||||
TaskCreate 和 TaskUpdate 都集成了 hooks 系统:
|
||||
|
||||
- **创建时**:`executeTaskCreatedHooks` — 外部钩子可以阻断任务创建(blockingError 导致任务被立即删除)
|
||||
- **完成时**:`executeTaskCompletedHooks` — 外部钩子可以阻断任务标记为完成
|
||||
|
||||
这允许外部系统(CI、审批流)参与任务状态机。
|
||||
|
||||
## activeForm:终端 UX 的细节
|
||||
|
||||
每个任务有两个文案字段:
|
||||
|
||||
- `subject`:祈使句,用于任务列表展示("Fix auth bug")
|
||||
- `activeForm`:进行时形式,用于 spinner 动画("Fixing auth bug...")
|
||||
|
||||
当 `activeForm` 缺省时,spinner 回退显示 `subject`。这个看似微小的设计确保了用户在等待时看到的是"正在做什么"而非"要做什么"。
|
||||
|
||||
## Plan Mode 与任务系统的配合
|
||||
|
||||
Plan Mode(计划模式)和任务系统是互补但独立的机制:
|
||||
|
||||
1. Plan Mode 限制工具集为只读(搜索、阅读),迫使 AI 先理解再行动
|
||||
2. AI 在 Plan Mode 中用 TaskCreate 建立任务列表
|
||||
3. 用户审批后退出 Plan Mode
|
||||
4. AI 按依赖拓扑序逐项执行
|
||||
4. AI 按 `blockedBy` 拓扑序逐项执行,每项用 TaskUpdate 标记进度
|
||||
|
||||
任务管理操作始终自动批准——它们不产生副作用(不修改代码、不执行命令),只是追踪"要做什么"。
|
||||
|
||||
## 接下来
|
||||
|
||||
- **Agent 系统** — 理解子 Agent 的创建和协调
|
||||
- **Plan Mode** — 理解"先规划再执行"的工作流
|
||||
- **Swarm 模式** — 理解多 Agent 团队的任务分配
|
||||
`shouldDefer: true` 属性确保这些工具调用不会触发权限确认弹窗——任务管理操作始终自动批准,因为它们不产生副作用。
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
---
|
||||
title: "工具系统"
|
||||
description: "AI 本质上只能生成文本。工具是 AI 的双手——让它能读文件、跑命令、搜代码。理解统一接口、分层注册和调用链路的设计。"
|
||||
keywords: ["工具系统", "Tool 抽象", "AI 工具", "function calling"]
|
||||
title: "工具系统设计 - AI 如何从说到做"
|
||||
description: "深入理解 Claude Code 的 Tool 抽象设计:从类型定义、注册机制、调用链路到渲染系统,揭示 50+ 内置工具如何通过统一的 Tool 接口协同工作。"
|
||||
keywords: ["工具系统", "Tool 抽象", "AI 工具", "function calling", "buildTool", "getTools"]
|
||||
---
|
||||
|
||||
## 核心问题
|
||||
{/* 本章目标:基于 src/Tool.ts 和 src/tools.ts 揭示工具系统的完整架构 */}
|
||||
|
||||
## AI 为什么需要工具
|
||||
|
||||
大语言模型本质上只能做一件事:**根据输入文本,生成输出文本**。
|
||||
|
||||
@@ -12,93 +14,159 @@ keywords: ["工具系统", "Tool 抽象", "AI 工具", "function calling"]
|
||||
|
||||
工具是 AI 的双手。AI 说"我想读这个文件",工具系统替它真正去读;AI 说"我想执行这条命令",工具系统替它真正去跑。
|
||||
|
||||
## 统一接口:一个工具长什么样
|
||||
## Tool 类型:35 个字段的统一接口
|
||||
|
||||
所有工具——无论是读文件、执行命令还是启动子 Agent——都实现同一个接口。这不是一个类,而是一个结构化类型:任何满足该接口的对象就是一个工具。
|
||||
所有工具都实现 `src/Tool.ts:368` 的 `Tool<Input, Output, Progress>` 类型。这不是一个 class,而是一个包含 35+ 字段的**结构化类型**(structural typing),任何满足该接口的对象就是一个工具:
|
||||
|
||||
### 核心四要素
|
||||
|
||||
| 要素 | 作用 |
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `name` | `string` | 唯一标识(如 `Read`、`Bash`、`Agent`) |
|
||||
| `description()` | `(input) => Promise<string>` | **动态描述**——根据输入参数返回不同描述(如 `Execute skill: ${skill}`) |
|
||||
| `inputSchema` | `z.ZodType` | Zod schema,定义参数类型和校验规则 |
|
||||
| `call()` | `(args, context, canUseTool, parentMessage, onProgress?) => Promise<ToolResult<Output>>` | 执行函数 |
|
||||
|
||||
### 注册与发现
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| **name** | 唯一标识(如 `Read`、`Bash`、`Agent`) |
|
||||
| **description()** | 动态描述——根据输入参数返回不同描述 |
|
||||
| **inputSchema** | 参数类型和校验规则(Zod schema) |
|
||||
| **call()** | 执行函数——真正做事的地方 |
|
||||
| `aliases` | 别名数组(向后兼容重命名) |
|
||||
| `searchHint` | 3-10 词的短语,供 ToolSearch 关键词匹配(如 `"jupyter"` for NotebookEdit) |
|
||||
| `shouldDefer` | 是否延迟加载(配合 ToolSearch 按需加载) |
|
||||
| `alwaysLoad` | 永不延迟加载(如 SkillTool 必须在 turn 1 可见) |
|
||||
| `isEnabled()` | 运行时开关(如 PowerShellTool 检查平台) |
|
||||
|
||||
**设计洞察**:`description()` 是动态的而非静态字符串。这意味着同一个工具在不同参数下可以展示不同的描述——例如 Agent 工具会显示它正在执行的具体子任务。
|
||||
### 安全与权限
|
||||
|
||||
### 安全层字段
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `validateInput()` | 输入校验(在权限检查之前),返回 `ValidationResult` |
|
||||
| `checkPermissions()` | 权限检查(在校验之后),返回 `PermissionResult` |
|
||||
| `isReadOnly()` | 是否只读操作(影响权限模式) |
|
||||
| `isDestructive()` | 是否不可逆操作(删除、覆盖、发送) |
|
||||
| `isConcurrencySafe()` | 相同输入是否可以并行执行 |
|
||||
| `preparePermissionMatcher()` | 为 Hook 的 `if` 条件准备模式匹配器 |
|
||||
| `interruptBehavior()` | 用户中断时的行为:`'cancel'` 或 `'block'` |
|
||||
|
||||
工具接口包含了完整的安全控制:
|
||||
### 输出与渲染
|
||||
|
||||
- **validateInput()** — 输入校验(在权限检查之前)
|
||||
- **checkPermissions()** — 权限检查(在校验之后)
|
||||
- **isReadOnly()** — 是否只读(影响权限模式的自动审批)
|
||||
- **isDestructive()** — 是否不可逆(触发更严格的确认)
|
||||
- **interruptBehavior()** — 用户中断时的行为(取消 vs 阻塞)
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `maxResultSizeChars` | 结果字符上限(超出则持久化到磁盘,如 `100_000`) |
|
||||
| `mapToolResultToToolResultBlockParam()` | 将 Output 映射为 API 格式的 `ToolResultBlockParam` |
|
||||
| `renderToolResultMessage()` | React 组件渲染工具结果到终端 |
|
||||
| `renderToolUseMessage()` | React 组件渲染工具调用过程 |
|
||||
| `backfillObservableInput()` | 在不破坏 prompt cache 的前提下回填可观察字段 |
|
||||
|
||||
**设计考量**:校验和权限是两个独立步骤,有明确的执行顺序。校验先于权限——如果输入本身不合法,就不需要检查权限。这避免了在无效输入上触发权限 UI 的尴尬体验。
|
||||
### 上下文与 Prompt
|
||||
|
||||
### 渲染字段
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `prompt()` | 返回该工具的详细使用说明,注入到 System Prompt |
|
||||
| `outputSchema` | 输出 Zod schema(用于类型安全的结果处理) |
|
||||
| `getPath()` | 提取操作的文件路径(用于权限匹配和 UI 显示) |
|
||||
|
||||
每个工具不仅有执行逻辑,还有 UI 渲染:
|
||||
- 工具调用时的进度展示
|
||||
- 工具结果的内容渲染
|
||||
- 活动描述(为 spinner 提供文字,如 "Reading src/foo.ts")
|
||||
## 工具注册:`getTools()` 的分层组装
|
||||
|
||||
**设计哲学**:工具不是黑盒。用户应该实时看到 AI 正在做什么、结果是什么。渲染与执行是同一接口的一等公民。
|
||||
|
||||
## 工具注册:分层组装
|
||||
|
||||
工具不是一次性全部加载的。系统使用分层注册策略:
|
||||
|
||||
### 固定工具(始终可用)
|
||||
|
||||
文件操作(Read/Write/Edit)、命令执行(Bash)、搜索(Glob/Grep)、Web(Fetch/Search)等基础工具始终在工具列表中。
|
||||
|
||||
### 条件工具(运行时检查)
|
||||
|
||||
| 条件 | 加载的工具 |
|
||||
|------|-----------|
|
||||
| 需要搜索能力 | GlobTool、GrepTool |
|
||||
| 平台是 Windows | PowerShellTool |
|
||||
| Worktree 模式 | EnterWorktree/ExitWorktree |
|
||||
| Agent Swarms 启用 | Teams 相关工具 |
|
||||
|
||||
### Feature-flag 工具
|
||||
|
||||
通过 feature flag 控制的实验性工具——协调者模式工具、KAIROS 工具等。
|
||||
|
||||
**关键设计**:工具列表在每次 API 调用时重新组装,而非全局缓存。因为 `isEnabled()` 的结果可能随运行时状态变化——例如 MCP 服务器在会话中途连接或断开。
|
||||
|
||||
## 工具调用链路
|
||||
|
||||
从 AI 发出调用到结果回传,经过一条严格的管道:
|
||||
`src/tools.ts` 的 `getAllBaseTools()`(第 195 行)是工具注册的核心:
|
||||
|
||||
```
|
||||
API 返回 tool_use → 输入校验 → 权限检查 → 执行 → 结果格式化 → 回传 API
|
||||
固定工具(始终可用):
|
||||
AgentTool, BashTool, FileReadTool, FileEditTool, FileWriteTool,
|
||||
NotebookEditTool, WebFetchTool, WebSearchTool, TodoWriteTool,
|
||||
AskUserQuestionTool, SkillTool, EnterPlanModeTool, ExitPlanModeV2Tool,
|
||||
TaskOutputTool, BriefTool, ListMcpResourcesTool, ReadMcpResourceTool
|
||||
|
||||
条件工具(运行时检查):
|
||||
← hasEmbeddedSearchTools() ? [] : [GlobTool, GrepTool]
|
||||
← isTodoV2Enabled() ? V2 Tasks : []
|
||||
← isWorktreeModeEnabled() ? Worktree : []
|
||||
← isAgentSwarmsEnabled() ? Teams : []
|
||||
← isToolSearchEnabled() ? ToolSearch: []
|
||||
← isPowerShellToolEnabled() ? PowerShell: []
|
||||
|
||||
Feature-flag 工具:
|
||||
← feature('COORDINATOR_MODE') ? [coordinatorMode tools]
|
||||
← feature('KAIROS') ? [SleepTool, SendUserFileTool, ...]
|
||||
← feature('WEB_BROWSER_TOOL') ? [WebBrowserTool]
|
||||
← feature('HISTORY_SNIP') ? [SnipTool]
|
||||
|
||||
Ant-only 工具:
|
||||
← process.env.USER_TYPE === 'ant' ? [REPLTool, ConfigTool, TungstenTool]
|
||||
```
|
||||
|
||||
每一步都有明确的失败路径:
|
||||
- 校验失败 → 返回错误,AI 可以修正参数重试
|
||||
- 权限拒绝 → 返回拒绝原因,AI 可以调整方案
|
||||
- 执行出错 → 返回错误信息,AI 可以诊断重试
|
||||
`getTools()`(第 274 行)在 `getAllBaseTools()` 基础上应用权限过滤:
|
||||
|
||||
**设计考量**:错误不是异常——它们是正常的对话流程。AI 看到错误信息后会自行调整,不需要人类介入。这把"AI 犯错 → 人类纠正"的循环转变为"AI 试错 → 自我纠正"的循环。
|
||||
```typescript
|
||||
export const getTools = (permissionContext): Tools => {
|
||||
const base = getAllBaseTools()
|
||||
// 过滤 blanket deny 规则命中的工具
|
||||
return filterToolsByDenyRules(base, permissionContext)
|
||||
}
|
||||
```
|
||||
|
||||
**关键设计**:工具列表在每次 API 调用时组装(而非全局缓存),因为 `isEnabled()` 的结果可能随运行时状态变化。
|
||||
|
||||
## `buildTool()` 工厂函数
|
||||
|
||||
大多数工具通过 `buildTool()` 创建(`src/Tool.ts:789`),它是一个类型安全的构造器:
|
||||
|
||||
```typescript
|
||||
export const BashTool: Tool<...> = buildTool({
|
||||
name: 'Bash',
|
||||
inputSchema: lazySchema(() => z.object({command: z.string(), ...})),
|
||||
// ...其他字段
|
||||
}) satisfies ToolDef<Input, Output, Progress>
|
||||
```
|
||||
|
||||
`satisfies ToolDef` 确保编译时类型检查,`lazySchema` 延迟 Zod schema 解析(避免循环依赖)。
|
||||
|
||||
## 工具调用的完整链路
|
||||
|
||||
从 AI 发出 `tool_use` 到结果回传,经过以下步骤:
|
||||
|
||||
```
|
||||
1. API 返回 tool_use block(包含 name + input)
|
||||
↓
|
||||
2. StreamingToolExecutor.addTool() / runTools()
|
||||
↓
|
||||
3. findToolByName() 查找工具
|
||||
↓
|
||||
4. validateInput() — 输入校验
|
||||
↓ 失败 → 返回错误 tool_result
|
||||
5. canUseTool() — 权限 UI(Ask 模式下弹确认)
|
||||
↓ 拒绝 → 返回拒绝 tool_result
|
||||
6. checkPermissions() — 规则匹配
|
||||
↓
|
||||
7. call() — 执行实际操作
|
||||
↓ onProgress() 回调实时更新 UI
|
||||
8. 返回 ToolResult<Output>
|
||||
↓
|
||||
9. mapToolResultToToolResultBlockParam() — 转为 API 格式
|
||||
↓
|
||||
10. 新消息追加到对话 → 进入下一轮迭代
|
||||
```
|
||||
|
||||
## 工具结果的预算控制
|
||||
|
||||
每个工具声明自己的输出上限(BashTool: 30K 字符,SkillTool: 100K 等)。超出上限的结果被持久化到磁盘,AI 只收到预览 + 文件路径。
|
||||
每个工具通过 `maxResultSizeChars` 声明输出上限:
|
||||
|
||||
**为什么不无限返回**?因为工具输出的 token 会累积到上下文中。一个 `find /` 命令可能产生几十万字符的输出——如果不限制,几次工具调用就能耗尽整个上下文窗口。
|
||||
- **BashTool**:`30_000`(命令输出)
|
||||
- **SkillTool**:`100_000`(技能执行结果)
|
||||
- **FileReadTool**:`Infinity`(文件内容不走持久化,避免 Read→file→Read 循环)
|
||||
|
||||
FileReadTool 故意设为无上限——文件内容需要完整呈现给 AI,截断可能导致错误的代码修改。
|
||||
超出上限的结果被 `applyToolResultBudget()`(`src/utils/toolResultStorage.ts`)持久化到磁盘,AI 只收到预览 + 文件路径。
|
||||
|
||||
## MCP 工具的扩展
|
||||
|
||||
MCP(Model Context Protocol)允许外部工具注册到系统中。MCP 工具的 schema 使用 JSON Schema 而非 Zod,因为 schema 来自远程协议而非本地定义。
|
||||
MCP Server 提供的工具通过 `mcpInfo` 字段标记来源:
|
||||
|
||||
MCP 工具支持 `mcp__server` 前缀的 deny 规则——用户可以精确控制哪些 MCP 服务器的哪些工具被禁止使用。
|
||||
```typescript
|
||||
mcpInfo?: { serverName: string; toolName: string }
|
||||
```
|
||||
|
||||
MCP 工具的 `inputJSONSchema` 直接使用 JSON Schema(而非 Zod),因为 schema 来自远程协议。它们通过 `filterToolsByDenyRules()` 支持 `mcp__server` 前缀的 blanket deny 规则。
|
||||
|
||||
## 50+ 内置工具全景
|
||||
|
||||
@@ -123,8 +191,16 @@ MCP 工具支持 `mcp__server` 前缀的 deny 规则——用户可以精确控
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## 接下来
|
||||
## 工具的可视化渲染
|
||||
|
||||
- **文件操作** — Read/Write/Edit 的设计和安全机制
|
||||
- **搜索与导航** — Glob/Grep 的搜索策略
|
||||
- **Shell 执行** — Bash 的沙箱和超时控制
|
||||
工具不仅能"做事",还能"展示"。每个工具通过 React 组件定义 UI 渲染:
|
||||
|
||||
- **FileEdit** → `renderToolResultMessage` 展示语法高亮的 diff 视图
|
||||
- **Bash** → 实时显示命令输出(通过 `onProgress` 回调),带进度指示
|
||||
- **Grep** → 高亮匹配结果,显示文件路径和行号链接
|
||||
- **Agent** → 显示子 Agent 的进度条和状态
|
||||
- **SkillTool** → 渲染技能执行进度
|
||||
|
||||
`isSearchOrReadCommand()` 允许工具声明自己是搜索/读取操作,触发 UI 的折叠显示模式(避免大量搜索结果占满屏幕)。
|
||||
|
||||
`getActivityDescription()` 为 spinner 提供活动描述(如 "Reading src/foo.ts"、"Running bun test"),替代默认的工具名显示。
|
||||
|
||||
40
knip.json
40
knip.json
@@ -1,22 +1,22 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/knip@6/schema.json",
|
||||
"entry": ["src/entrypoints/cli.tsx"],
|
||||
"project": ["src/**/*.{ts,tsx}"],
|
||||
"ignore": ["src/types/**", "src/**/*.d.ts"],
|
||||
"ignoreDependencies": [
|
||||
"@ant/*",
|
||||
"react-compiler-runtime",
|
||||
"@anthropic-ai/mcpb",
|
||||
"@anthropic-ai/sandbox-runtime"
|
||||
],
|
||||
"ignoreBinaries": ["bun"],
|
||||
"workspaces": {
|
||||
"packages/*": {
|
||||
"entry": ["src/index.ts"],
|
||||
"project": ["src/**/*.ts"]
|
||||
},
|
||||
"packages/@ant/*": {
|
||||
"ignore": ["**"]
|
||||
}
|
||||
}
|
||||
"$schema": "https://unpkg.com/knip@6/schema.json",
|
||||
"entry": ["src/entrypoints/cli.tsx"],
|
||||
"project": ["src/**/*.{ts,tsx}"],
|
||||
"ignore": ["src/types/**", "src/**/*.d.ts"],
|
||||
"ignoreDependencies": [
|
||||
"@ant/*",
|
||||
"react-compiler-runtime",
|
||||
"@anthropic-ai/mcpb",
|
||||
"@anthropic-ai/sandbox-runtime"
|
||||
],
|
||||
"ignoreBinaries": ["bun"],
|
||||
"workspaces": {
|
||||
"packages/*": {
|
||||
"entry": ["src/index.ts"],
|
||||
"project": ["src/**/*.ts"]
|
||||
},
|
||||
"packages/@ant/*": {
|
||||
"ignore": ["**"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
152
learn/LEARN.md
152
learn/LEARN.md
@@ -1,152 +0,0 @@
|
||||
# Claude Code 源码学习路线
|
||||
|
||||
> 基于反编译版 Claude Code CLI (v2.1.888) 的源码学习跟踪
|
||||
>
|
||||
> 各阶段详细笔记见同目录下的 `phase-*.md` 文件
|
||||
|
||||
## 第一阶段:启动流程(入口链路) ✅
|
||||
|
||||
详细笔记:[phase-1-startup-flow.md](phase-1-startup-flow.md)
|
||||
|
||||
理解程序从命令行启动到用户看到交互界面的完整路径。
|
||||
|
||||
- [x] `src/entrypoints/cli.tsx` — 真正入口,polyfill 注入 + 快速路径分发
|
||||
- [x] 全局 polyfill:`feature()` 永远返回 false、`MACRO` 全局对象、`BUILD_*` 常量
|
||||
- [x] 快速路径设计:按开销从低到高检查,能早返回就早返回
|
||||
- [x] 动态 import 模式:`await import()` 延迟加载,减少启动时间
|
||||
- [x] 最终出口:`import("../main.jsx")` → `cliMain()`
|
||||
- [x] `src/main.tsx` — Commander.js CLI 定义,重型初始化(4683 行)
|
||||
- [x] 三段式结构:辅助函数(1-584) → main()(585-856) → run()(884-4683)
|
||||
- [x] side-effect import:profileCheckpoint、startMdmRawRead、startKeychainPrefetch 并行预加载
|
||||
- [x] preAction 钩子:MDM 等待、init()、迁移、远程设置
|
||||
- [x] Commander 参数定义:40+ CLI 选项
|
||||
- [x] action handler(2800 行):参数解析 → 服务初始化 → showSetupScreens → launchRepl()
|
||||
- [x] --print 分支走 print.ts;交互分支走 launchRepl()(7 个场景分支)
|
||||
- [x] 子命令注册:mcp/auth/plugin/doctor/update/install 等
|
||||
- [x] `src/replLauncher.tsx` — 桥梁(22 行),组合 `<App>` + `<REPL>` 渲染到终端
|
||||
- [x] `src/screens/REPL.tsx` — 交互式 REPL 界面(5009 行)
|
||||
- [x] Props:commands、tools、messages、systemPrompt、thinkingConfig 等
|
||||
- [x] 50+ 状态:messages、inputValue、screen、streamingText、queryGuard 等
|
||||
- [x] 核心数据流:onSubmit → handlePromptSubmit → onQuery → onQueryImpl → query() → onQueryEvent
|
||||
- [x] QueryGuard 并发控制:idle → running → idle,防止重复查询
|
||||
- [x] 渲染:Transcript 模式(只读历史)/ Prompt 模式(Messages + PermissionRequest + PromptInput)
|
||||
|
||||
**数据流**:`bun run dev` → `package.json scripts.dev` → `bun run src/entrypoints/cli.tsx` → 快速路径检查 → `main.tsx:main()` → `launchRepl()` → `<App><REPL /></App>`
|
||||
|
||||
---
|
||||
|
||||
## 第二阶段:核心对话循环 ✅
|
||||
|
||||
详细笔记:[phase-2-conversation-loop.md](phase-2-conversation-loop.md)
|
||||
|
||||
理解用户发一句话后,如何变成 API 请求、如何处理流式响应和工具调用。
|
||||
|
||||
- [x] `src/query.ts` — 核心查询循环(1732 行)
|
||||
- [x] `query()` AsyncGenerator 入口,委托给 `queryLoop()`
|
||||
- [x] `queryLoop()` — while(true) 主循环,State 对象管理迭代状态
|
||||
- [x] 消息预处理(autocompact、compact boundary)
|
||||
- [x] `deps.callModel()` → 流式 API 调用
|
||||
- [x] StreamingToolExecutor — API 流式返回时并行执行工具
|
||||
- [x] 工具调用循环(tool use → 执行 → result → continue)
|
||||
- [x] 错误恢复(prompt-too-long、max_output_tokens 升级+多轮恢复)
|
||||
- [x] 模型降级(FallbackTriggeredError → 切换 fallbackModel)
|
||||
- [x] Withheld 消息模式(暂扣可恢复错误)
|
||||
- [x] `src/QueryEngine.ts` — 高层编排器(1320 行)
|
||||
- [x] QueryEngine 类 — 一个 conversation 一个实例
|
||||
- [x] `submitMessage()` — 处理用户输入 → 调用 `query()` → 消费事件流
|
||||
- [x] SDK/print 模式专用(REPL 直接调用 query())
|
||||
- [x] 会话持久化(recordTranscript)
|
||||
- [x] Usage 跟踪、权限拒绝记录
|
||||
- [x] `ask()` 便捷包装函数
|
||||
- [x] `src/services/api/claude.ts` — API 客户端(3420 行)
|
||||
- [x] `queryModelWithStreaming` / `queryModelWithoutStreaming` — 两个公开入口
|
||||
- [x] `queryModel()` — 核心私有函数(2400 行)
|
||||
- [x] 请求参数组装(system prompt、betas、tools、cache control)
|
||||
- [x] Anthropic SDK 流式调用(`anthropic.beta.messages.stream()`)
|
||||
- [x] `BetaRawMessageStreamEvent` 事件处理(message_start/content_block_*/message_delta/stop)
|
||||
- [x] withRetry 重试策略(429/500/529 + 模型降级)
|
||||
- [x] Prompt Caching 策略(ephemeral/1h TTL/global scope)
|
||||
- [x] 多 provider 支持(Anthropic / Bedrock / Vertex / Azure)
|
||||
|
||||
**数据流**:REPL.onSubmit → handlePromptSubmit → onQuery → onQueryImpl → `query()` AsyncGenerator → `queryLoop()` while(true) → `deps.callModel()` → `claude.ts queryModel()` → `anthropic.beta.messages.stream()` → 流式事件 → 收集 tool_use → 执行工具 → 结果追加到 messages → continue → 无工具调用时 return
|
||||
|
||||
---
|
||||
|
||||
## 第三阶段:工具系统
|
||||
|
||||
理解 Claude 如何定义、注册、调用工具。先读框架,再挑具体工具。
|
||||
|
||||
- [ ] `src/Tool.ts` — Tool 接口定义
|
||||
- [ ] `Tool` 类型结构(name、description、inputSchema、call)
|
||||
- [ ] `findToolByName`、`toolMatchesName` 工具函数
|
||||
- [ ] `src/tools.ts` — 工具注册表
|
||||
- [ ] 工具列表组装逻辑
|
||||
- [ ] 条件加载(feature flag、USER_TYPE)
|
||||
- [ ] 具体工具实现(挑选 2-3 个深入阅读):
|
||||
- [ ] `src/tools/BashTool/` — 执行 shell 命令,最常用的工具
|
||||
- [ ] `src/tools/FileReadTool/` — 读取文件,简单直观,适合理解工具模式
|
||||
- [ ] `src/tools/FileEditTool/` — 编辑文件,理解 diff/patch 机制
|
||||
- [ ] `src/tools/AgentTool/` — 子 Agent 机制,较复杂但核心
|
||||
|
||||
---
|
||||
|
||||
## 第四阶段:上下文与系统提示
|
||||
|
||||
理解 Claude 如何"知道"项目信息、用户偏好等上下文。
|
||||
|
||||
- [ ] `src/context.ts` — 系统/用户上下文构建
|
||||
- [ ] git 状态注入
|
||||
- [ ] CLAUDE.md 内容加载
|
||||
- [ ] 内存文件(memory)注入
|
||||
- [ ] 日期、平台等环境信息
|
||||
- [ ] `src/utils/claudemd.ts` — CLAUDE.md 发现与加载
|
||||
- [ ] 项目层级搜索逻辑
|
||||
- [ ] 多级 CLAUDE.md 合并
|
||||
|
||||
---
|
||||
|
||||
## 第五阶段:UI 层(按兴趣选读)
|
||||
|
||||
理解终端 UI 的渲染机制(React/Ink)。
|
||||
|
||||
- [ ] `src/components/App.tsx` — 根组件,Provider 注入
|
||||
- [ ] `src/state/AppState.tsx` — 全局状态类型与 Context
|
||||
- [ ] `src/components/permissions/` — 工具权限审批 UI
|
||||
- [ ] `src/components/messages/` — 消息渲染组件
|
||||
|
||||
---
|
||||
|
||||
## 第六阶段:外围系统(按需探索)
|
||||
|
||||
- [ ] `src/services/mcp/` — MCP 协议(Model Context Protocol)
|
||||
- [ ] `src/skills/` — 技能系统(/commit 等斜杠命令)
|
||||
- [ ] `src/commands/` — CLI 子命令
|
||||
- [ ] `src/tasks/` — 后台任务系统
|
||||
- [ ] `src/utils/model/providers.ts` — 多 provider 选择逻辑
|
||||
|
||||
---
|
||||
|
||||
## 学习笔记
|
||||
|
||||
### 关键设计模式
|
||||
|
||||
| 模式 | 位置 | 说明 |
|
||||
|------|------|------|
|
||||
| 快速路径 | cli.tsx | 按开销从低到高逐级检查,减少不必要的模块加载 |
|
||||
| 动态 import | cli.tsx / main.tsx | `await import()` 延迟加载,优化启动时间 |
|
||||
| feature flag | 全局 | `feature()` 永远返回 false,所有内部功能禁用 |
|
||||
| React/Ink | UI 层 | 用 React 组件模型渲染终端 UI |
|
||||
| 工具循环 | query.ts | AI 返回工具调用 → 执行 → 结果回传 → 继续,直到无工具调用 |
|
||||
| AsyncGenerator 链 | query.ts → claude.ts | `yield*` 透传事件流,形成管道 |
|
||||
| State 对象 | query.ts queryLoop | 循环间通过不可变 State + transition 字段传递状态 |
|
||||
| StreamingToolExecutor | query.ts | API 流式返回时并行执行工具 |
|
||||
| Withheld 消息 | query.ts | 暂扣可恢复错误,恢复成功则吞掉 |
|
||||
| withRetry | claude.ts | 429/500/529 自动重试 + 模型降级 |
|
||||
| Prompt Caching | claude.ts | 缓存系统提示和历史消息,减少 token 消耗 |
|
||||
|
||||
### 需要忽略的内容
|
||||
|
||||
- `_c()` 调用 — React Compiler 反编译产物
|
||||
- `feature('...')` 后面的代码块 — 全部是死代码
|
||||
- tsc 类型错误 — 反编译导致,不影响 Bun 运行
|
||||
- `packages/@ant/` — stub 包,无实际实现
|
||||
@@ -1,273 +0,0 @@
|
||||
# 第一阶段 Q&A
|
||||
|
||||
## Q1:cli.tsx 的快速路径分发具体在做什么?
|
||||
|
||||
**核心思想**:根据用户输入的命令参数,尽早决定走哪条路,避免加载不需要的代码。cli.tsx 充当一个轻量级路由器,把简单请求就地处理,只有真正需要完整 CLI 时才加载 main.tsx。
|
||||
|
||||
### 场景对比
|
||||
|
||||
#### 场景 1:`claude --version`(命中快速路径)
|
||||
|
||||
```
|
||||
cli.tsx main() 开始执行
|
||||
├── args = ["--version"]
|
||||
├── 命中第 64 行: args[0] === "--version" ✅
|
||||
├── console.log("2.1.888 (Claude Code)")
|
||||
└── return ← 立即退出,零 import,~10ms
|
||||
```
|
||||
|
||||
#### 场景 2:`claude --claude-in-chrome-mcp`(命中中间路径)
|
||||
|
||||
```
|
||||
cli.tsx main() 开始执行
|
||||
├── 第 64 行: --version? ❌
|
||||
├── 第 75 行: 加载 profileCheckpoint(仅此一个 import)
|
||||
├── 第 81 行: feature("DUMP_SYSTEM_PROMPT") → false ❌
|
||||
├── 第 95 行: --claude-in-chrome-mcp? ✅ 命中
|
||||
├── await import("../utils/claudeInChrome/mcpServer.js") ← 只加载这一个模块
|
||||
└── return ← 没有加载 main.tsx 的 200+ import
|
||||
```
|
||||
|
||||
#### 场景 3:`claude`(无参数,最常见,全部未命中)
|
||||
|
||||
```
|
||||
cli.tsx main() 开始执行
|
||||
├── --version? ❌
|
||||
├── profileCheckpoint 加载
|
||||
├── feature(DUMP)? ❌ (feature=false)
|
||||
├── --chrome-mcp? ❌
|
||||
├── --chrome-native? ❌
|
||||
├── feature(CHICAGO)? ❌ (feature=false)
|
||||
├── feature(DAEMON)? ❌ (feature=false)
|
||||
├── feature(BRIDGE)? ❌ (feature=false)
|
||||
├── ... 所有快速路径逐一检查,全部未命中
|
||||
│
|
||||
├── 走到第 310 行 ← 最终出口
|
||||
├── await import("../main.jsx") ← 加载完整 CLI(200+ import,~135ms)
|
||||
└── await cliMain() ← 进入 main.tsx 重型初始化
|
||||
```
|
||||
|
||||
### 性能对比
|
||||
|
||||
| 方式 | `claude --version` 耗时 |
|
||||
|------|------------------------|
|
||||
| 无快速路径(全部走 main.tsx) | ~200ms(加载 200+ import → 初始化 Commander → 解析参数 → 打印) |
|
||||
| 有快速路径(cli.tsx 拦截) | ~10ms(读 args → 打印 → 退出) |
|
||||
|
||||
### feature() 的加速作用
|
||||
|
||||
大量快速路径被 `feature()` 守护:
|
||||
|
||||
```ts
|
||||
if (feature("DAEMON") && args[0] === "daemon") { ... }
|
||||
```
|
||||
|
||||
`feature()` 返回 false → `&&` 短路求值 → 连 `args[0]` 都不检查,直接跳过。在反编译版本中这些路径等于不存在,进一步加速了"全部没命中 → 走默认路径"的过程。
|
||||
|
||||
---
|
||||
|
||||
## Q2:main.tsx 中不同命令的具体执行流程是怎样的?
|
||||
|
||||
所有命令都会经过 main() → run(),但在 run() 内部根据 Commander 路由到不同分支。
|
||||
|
||||
### 场景 1:`claude`(无参数 — 启动交互 REPL)
|
||||
|
||||
最常见的场景,走完整条主命令路径:
|
||||
|
||||
```
|
||||
main() (第 585 行)
|
||||
├── 信号处理注册(SIGINT、exit)
|
||||
├── feature flag 路径全部跳过
|
||||
├── isNonInteractive = false(有 TTY,没有 -p)
|
||||
├── clientType = 'cli'
|
||||
└── await run()
|
||||
│
|
||||
▼
|
||||
run() (第 884 行)
|
||||
├── Commander 初始化 + preAction 钩子 + 主命令选项注册
|
||||
├── isPrintMode = false → 注册所有子命令
|
||||
└── program.parseAsync(process.argv)
|
||||
│ Commander 匹配到主命令,先执行 preAction
|
||||
▼
|
||||
preAction (第 907 行)
|
||||
├── await ensureMdmSettingsLoaded() ← 等 side-effect import 的子进程完成
|
||||
├── await ensureKeychainPrefetchCompleted() ← 等 keychain 预读完成
|
||||
├── await init() ← 遥测、配置、信任
|
||||
├── initSinks() ← 分析日志
|
||||
├── runMigrations() ← 数据迁移
|
||||
└── loadRemoteManagedSettings() / loadPolicyLimits() ← 非阻塞
|
||||
│ 然后执行 action handler
|
||||
▼
|
||||
action(undefined, options) (第 1007 行) ← prompt = undefined
|
||||
├── [参数解析] permissionMode, model, thinkingConfig...
|
||||
├── [工具加载] tools = getTools(toolPermissionContext)
|
||||
├── [并行初始化]
|
||||
│ ├── setup() ← worktree、CWD
|
||||
│ ├── getCommands() ← 加载斜杠命令
|
||||
│ └── getAgentDefinitionsWithOverrides() ← 加载 agent 定义
|
||||
├── [MCP 连接] 连接配置的 MCP 服务器
|
||||
├── [构建初始状态] initialState = { tools, mcp, permissions, ... }
|
||||
│
|
||||
├── [UI 初始化](交互模式专属)
|
||||
│ ├── createRoot() ← 创建 Ink 渲染根节点
|
||||
│ └── showSetupScreens() ← 信任对话框 / OAuth / 引导
|
||||
│
|
||||
├── [后续初始化] LSP、插件版本、session 注册
|
||||
│
|
||||
└── 默认分支 (第 3760 行) ← 没有 --continue/--resume/--print
|
||||
└── await launchRepl(root, {
|
||||
initialState
|
||||
}, {
|
||||
...sessionConfig,
|
||||
initialMessages: undefined ← 全新对话,无历史消息
|
||||
}, renderAndRun)
|
||||
│
|
||||
▼
|
||||
REPL.tsx 渲染,用户看到空白对话界面
|
||||
```
|
||||
|
||||
### 场景 2:`echo "explain this" | claude -p`(管道/非交互模式)
|
||||
|
||||
```
|
||||
main() →
|
||||
├── isNonInteractive = true(-p 标志 + stdin 不是 TTY)
|
||||
├── clientType = 'sdk-cli'
|
||||
└── run()
|
||||
│
|
||||
▼
|
||||
run()
|
||||
├── Commander 初始化 + preAction + 主命令选项
|
||||
├── isPrintMode = true
|
||||
│ → ★ 跳过所有子命令注册(节省 ~65ms)
|
||||
└── program.parseAsync() ← 直接解析,Commander 路由到主命令 action
|
||||
│
|
||||
▼
|
||||
preAction → init、迁移等(同场景 1)
|
||||
│
|
||||
▼
|
||||
action("", { print: true, ... })
|
||||
├── inputPrompt = await getInputPrompt("")
|
||||
│ ├── stdin.isTTY = false → 从 stdin 读数据
|
||||
│ ├── 等待最多 3s 读入: "explain this"
|
||||
│ └── 返回 "explain this"
|
||||
├── tools = getTools()
|
||||
├── setup() + getCommands()(并行)
|
||||
│
|
||||
├── isNonInteractiveSession = true → 走 --print 分支(第 2584 行)
|
||||
│ ├── applyConfigEnvironmentVariables() ← -p 模式信任隐含
|
||||
│ ├── 构建 headlessInitialState(无 UI)
|
||||
│ ├── headlessStore = createStore(headlessInitialState)
|
||||
│ │
|
||||
│ ├── await import('src/cli/print.js')
|
||||
│ └── runHeadless(inputPrompt, ...) ★ 不走 REPL
|
||||
│ ├── 发送 API 请求
|
||||
│ ├── 流式输出到 stdout
|
||||
│ └── 完成后 process.exit()
|
||||
│
|
||||
└── ← 不走 createRoot()、showSetupScreens()、launchRepl()
|
||||
```
|
||||
|
||||
**关键差异**:
|
||||
- 检测到 `-p` 后跳过子命令注册(节省 ~65ms)
|
||||
- 不创建 Ink UI,不调用 `showSetupScreens()`
|
||||
- 从 stdin 读取输入(`getInputPrompt` 第 857 行)
|
||||
- 走 `print.js` 路径直接执行查询输出到 stdout
|
||||
|
||||
### 场景 3:`claude -c`(继续最近对话)
|
||||
|
||||
```
|
||||
... main() → run() → preAction → action(前半部分同场景 1)
|
||||
│
|
||||
▼
|
||||
action(undefined, { continue: true, ... })
|
||||
├── [参数解析 + 工具加载 + 并行初始化 + UI 初始化](同场景 1)
|
||||
│
|
||||
├── options.continue = true → 命中第 3101 行
|
||||
│ ├── clearSessionCaches() ← 清除过期缓存
|
||||
│ ├── result = await loadConversationForResume()
|
||||
│ │ └── 从 ~/.claude/projects/<cwd>/ 读最近的会话 JSONL
|
||||
│ │
|
||||
│ ├── result 为 null? → exitWithError("No conversation found")
|
||||
│ │
|
||||
│ ├── loaded = await processResumedConversation(result)
|
||||
│ │ ├── 解析 JSONL → messages[]
|
||||
│ │ ├── 恢复文件历史快照
|
||||
│ │ └── 重建 initialState
|
||||
│ │
|
||||
│ └── await launchRepl(root, {
|
||||
│ initialState: loaded.initialState
|
||||
│ }, {
|
||||
│ ...sessionConfig,
|
||||
│ initialMessages: loaded.messages, ★ 带上历史消息
|
||||
│ initialFileHistorySnapshots: loaded.fileHistorySnapshots,
|
||||
│ initialAgentName: loaded.agentName
|
||||
│ }, renderAndRun)
|
||||
│ │
|
||||
│ ▼
|
||||
│ REPL.tsx 渲染,显示历史对话,用户继续聊天
|
||||
│
|
||||
└── ← 其他分支不执行
|
||||
```
|
||||
|
||||
**关键差异**:`initialMessages` 有值(历史消息),REPL 启动时会渲染之前的对话内容。
|
||||
|
||||
### 场景 4:`claude mcp list`(子命令)
|
||||
|
||||
```
|
||||
main() → run()
|
||||
│
|
||||
▼
|
||||
run()
|
||||
├── Commander 初始化 + preAction 钩子
|
||||
├── 注册主命令 .action(...)
|
||||
├── isPrintMode = false → 注册所有子命令
|
||||
│ ├── program.command('mcp') (第 3894 行)
|
||||
│ │ ├── mcp.command('serve').action(...)
|
||||
│ │ ├── mcp.command('add').action(...)
|
||||
│ │ ├── mcp.command('list').action(async () => { ★
|
||||
│ │ │ const { mcpListHandler } = await import('./cli/handlers/mcp.js');
|
||||
│ │ │ await mcpListHandler();
|
||||
│ │ │ })
|
||||
│ │ └── ...
|
||||
│ ├── program.command('auth')
|
||||
│ ├── program.command('doctor')
|
||||
│ └── ...
|
||||
│
|
||||
└── program.parseAsync(["node", "claude", "mcp", "list"])
|
||||
│ Commander 匹配到 mcp → list
|
||||
▼
|
||||
preAction (第 907 行) ← 子命令也触发 preAction
|
||||
├── await init()
|
||||
├── initSinks()
|
||||
├── runMigrations()
|
||||
└── ...
|
||||
│
|
||||
▼ 执行子命令自己的 action(不走主命令 action)
|
||||
mcp list action
|
||||
├── await import('./cli/handlers/mcp.js')
|
||||
└── await mcpListHandler()
|
||||
├── 读取 MCP 配置(user/project/local 三级)
|
||||
├── 连接每个服务器做健康检查
|
||||
├── 格式化输出到终端
|
||||
└── 退出
|
||||
|
||||
← 主命令的 action handler 完全不执行
|
||||
← 没有 REPL、没有 Ink UI、没有 showSetupScreens
|
||||
```
|
||||
|
||||
**关键差异**:
|
||||
- Commander 路由到子命令,**主命令 action 完全跳过**
|
||||
- `preAction` 仍然执行(基础初始化所有命令都需要)
|
||||
- 子命令有自己独立的轻量 action
|
||||
|
||||
### 四种场景对比
|
||||
|
||||
| | `claude` | `claude -p` | `claude -c` | `claude mcp list` |
|
||||
|---|---------|------------|------------|-------------------|
|
||||
| preAction | 执行 | 执行 | 执行 | 执行 |
|
||||
| 主命令 action | 执行 | 执行 | 执行 | **跳过** |
|
||||
| 子命令注册 | 注册 | **跳过** | 注册 | 注册 |
|
||||
| showSetupScreens | 执行 | **跳过** | 执行 | **跳过** |
|
||||
| createRoot (Ink) | 执行 | **跳过** | 执行 | **跳过** |
|
||||
| 加载历史消息 | 否 | 否 | **是** | 否 |
|
||||
| 最终出口 | launchRepl | print.js | launchRepl | 子命令 action |
|
||||
@@ -1,597 +0,0 @@
|
||||
# 第一阶段:启动流程详解
|
||||
|
||||
> 从 `bun run dev` 到用户看到交互界面的完整路径
|
||||
|
||||
## 启动链路总览
|
||||
|
||||
```
|
||||
bun run dev
|
||||
→ package.json scripts.dev: "bun run src/entrypoints/cli.tsx"
|
||||
→ cli.tsx: polyfill 注入 + 快速路径检查
|
||||
→ import("../main.jsx") → cliMain()
|
||||
→ main.tsx: main() → run()
|
||||
→ Commander 参数解析 → preAction 钩子
|
||||
→ action handler: 服务初始化 → showSetupScreens
|
||||
→ launchRepl()
|
||||
→ replLauncher.tsx: <App><REPL /></App>
|
||||
→ REPL.tsx: 渲染交互界面,等待用户输入
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. cli.tsx(321 行)— 入口与快速路径分发
|
||||
|
||||
**文件路径**: `src/entrypoints/cli.tsx`
|
||||
|
||||
### 1.1 全局 Polyfill(第 1-53 行)
|
||||
|
||||
模块加载时立即执行的 side-effect,在 `main()` 之前运行。
|
||||
|
||||
#### feature() 桩函数(第 3 行)
|
||||
|
||||
```ts
|
||||
const feature = (_name: string) => false;
|
||||
```
|
||||
|
||||
原版 Claude Code 构建时,Bun bundler 通过 `bun:bundle` 提供 `feature()` 函数,用于**编译时 feature flag**(类似 C 的 `#ifdef`)。反编译版没有构建流程,所以直接定义为永远返回 `false`。
|
||||
|
||||
**效果**:所有 Anthropic 内部功能分支全部禁用,包括:
|
||||
- `COORDINATOR_MODE` — 协调器模式
|
||||
- `KAIROS` — 助手模式
|
||||
- `DAEMON` — 后台守护进程
|
||||
- `BRIDGE_MODE` — 远程控制
|
||||
- `SSH_REMOTE` — SSH 远程
|
||||
- `BG_SESSIONS` — 后台会话
|
||||
- ... 等 20+ 个 flag
|
||||
|
||||
#### MACRO 全局对象(第 4-14 行)
|
||||
|
||||
```ts
|
||||
globalThis.MACRO = {
|
||||
VERSION: "2.1.888",
|
||||
BUILD_TIME: new Date().toISOString(),
|
||||
FEEDBACK_CHANNEL: "",
|
||||
ISSUES_EXPLAINER: "",
|
||||
NATIVE_PACKAGE_URL: "",
|
||||
PACKAGE_URL: "",
|
||||
VERSION_CHANGELOG: "",
|
||||
};
|
||||
```
|
||||
|
||||
原版构建时 Bun 会把这些值内联到代码里。这里模拟注入,让后续代码读 `MACRO.VERSION` 时能拿到值。
|
||||
|
||||
#### 构建常量(第 16-18 行)
|
||||
|
||||
```ts
|
||||
BUILD_TARGET = "external"; // 标记为"外部"构建(非 Anthropic 内部)
|
||||
BUILD_ENV = "production"; // 生产环境
|
||||
INTERFACE_TYPE = "stdio"; // 标准输入输出模式
|
||||
```
|
||||
|
||||
这三个全局变量在代码各处被读取,用来区分运行环境。`"external"` 意味着很多 `("external" as string) === 'ant'` 的检查会返回 false。
|
||||
|
||||
#### 环境修补(第 22-33 行)
|
||||
|
||||
- 禁用 corepack 自动 pin(防止污染 package.json)
|
||||
- 远程模式下设置 Node.js 堆内存上限 8GB
|
||||
|
||||
#### ABLATION_BASELINE(第 40-53 行)
|
||||
|
||||
```ts
|
||||
if (feature("ABLATION_BASELINE") && ...) { ... }
|
||||
```
|
||||
|
||||
`feature()` 返回 false,**永远不执行**。Anthropic 内部 A/B 测试代码。
|
||||
|
||||
### 1.2 main() 函数(第 60-317 行)
|
||||
|
||||
设计模式:**分层快速路径(fast path cascading)**——按开销从低到高逐级检查,命中即返回。
|
||||
|
||||
#### 快速路径列表
|
||||
|
||||
| 优先级 | 行号 | 检查条件 | 功能 | 开销 | 可执行 |
|
||||
|--------|------|---------|------|------|--------|
|
||||
| 1 | 64-72 | `--version` / `-v` | 打印版本号退出 | **零 import** | 是 |
|
||||
| 2 | 81-94 | `feature("DUMP_SYSTEM_PROMPT")` | 导出系统提示 | - | 否(flag) |
|
||||
| 3 | 95-99 | `--claude-in-chrome-mcp` | Chrome MCP 服务 | 动态 import | 是 |
|
||||
| 4 | 101-105 | `--chrome-native-host` | Chrome Native Host | 动态 import | 是 |
|
||||
| 5 | 108-116 | `feature("CHICAGO_MCP")` | Computer Use MCP | - | 否(flag) |
|
||||
| 6 | 123-127 | `feature("DAEMON")` | Daemon Worker | - | 否(flag) |
|
||||
| 7 | 133-178 | `feature("BRIDGE_MODE")` | 远程控制 | - | 否(flag) |
|
||||
| 8 | 181-190 | `feature("DAEMON")` | Daemon 主进程 | - | 否(flag) |
|
||||
| 9 | 195-225 | `feature("BG_SESSIONS")` | ps/logs/attach/kill | - | 否(flag) |
|
||||
| 10 | 228-240 | `feature("TEMPLATES")` | 模板任务 | - | 否(flag) |
|
||||
| 11 | 244-253 | `feature("BYOC_ENVIRONMENT_RUNNER")` | BYOC 运行器 | - | 否(flag) |
|
||||
| 12 | 258-264 | `feature("SELF_HOSTED_RUNNER")` | 自托管运行器 | - | 否(flag) |
|
||||
| 13 | 267-293 | `--tmux` + `--worktree` | tmux worktree | 动态 import | 是 |
|
||||
|
||||
#### 参数修正(第 296-307 行)
|
||||
|
||||
```ts
|
||||
// --update/--upgrade → 重写为 update 子命令
|
||||
if (args[0] === "--update") process.argv = [..., "update"];
|
||||
// --bare → 设置简单模式环境变量
|
||||
if (args.includes("--bare")) process.env.CLAUDE_CODE_SIMPLE = "1";
|
||||
```
|
||||
|
||||
#### 最终出口(第 310-316 行)
|
||||
|
||||
```ts
|
||||
const { startCapturingEarlyInput } = await import("../utils/earlyInput.js");
|
||||
startCapturingEarlyInput(); // 捕获用户提前输入的内容
|
||||
const { main: cliMain } = await import("../main.jsx");
|
||||
await cliMain(); // 进入 main.tsx 重型初始化
|
||||
```
|
||||
|
||||
所有快速路径都没命中时(99% 的情况),才走到这里。
|
||||
|
||||
### 1.3 启动(第 320 行)
|
||||
|
||||
```ts
|
||||
void main();
|
||||
```
|
||||
|
||||
`void` 表示不关心 Promise 返回值。
|
||||
|
||||
### 1.4 关键设计思想
|
||||
|
||||
- **快速路径**:`--version` 零开销返回,不加载任何模块
|
||||
- **动态 import**:`await import()` 替代静态 import,每条路径只加载自己需要的模块
|
||||
- **feature flag 过滤**:`feature()` 返回 false 使大量内部功能成为死代码
|
||||
|
||||
---
|
||||
|
||||
## 2. main.tsx(4683 行)— 重型初始化与 Commander CLI
|
||||
|
||||
**文件路径**: `src/main.tsx`
|
||||
|
||||
整个项目最大的单文件,但结构清晰:**辅助函数 → main() → run()**。
|
||||
|
||||
### 2.1 Import 区(第 1-215 行)
|
||||
|
||||
200+ 行 import,加载几乎所有子系统。关键的是前三个 **side-effect import**(import 即执行):
|
||||
|
||||
```ts
|
||||
// 第 9 行:记录时间戳
|
||||
profileCheckpoint('main_tsx_entry');
|
||||
|
||||
// 第 16 行:启动 MDM 子进程读取(macOS plutil)
|
||||
startMdmRawRead();
|
||||
|
||||
// 第 20 行:启动 keychain 预读取(OAuth token、API key)
|
||||
startKeychainPrefetch();
|
||||
```
|
||||
|
||||
这三个在 import 阶段就**并行启动子进程**,和后续 ~135ms 的模块加载同时进行——**用并行隐藏延迟**。
|
||||
|
||||
### 2.2 辅助函数(第 216-584 行)
|
||||
|
||||
| 函数 | 行号 | 作用 |
|
||||
|------|------|------|
|
||||
| `logManagedSettings()` | 216 | 记录企业托管设置到分析日志 |
|
||||
| `isBeingDebugged()` | 232 | 检测调试模式,**外部构建下直接 exit(1)**(第 266 行) |
|
||||
| `logSessionTelemetry()` | 279 | Session 遥测(技能、插件) |
|
||||
| `getCertEnvVarTelemetry()` | 291 | SSL 证书环境变量收集 |
|
||||
| `runMigrations()` | 326 | 数据迁移(模型重命名、设置格式升级等) |
|
||||
| `prefetchSystemContextIfSafe()` | 360 | 信任关系建立后安全预取系统上下文 |
|
||||
| `startDeferredPrefetches()` | 388 | REPL 首次渲染后的延迟预取 |
|
||||
| `eagerLoadSettings()` | 502 | 在 init() 之前提前加载 `--settings` 参数 |
|
||||
| `initializeEntrypoint()` | 517 | 根据运行模式设置 `CLAUDE_CODE_ENTRYPOINT` |
|
||||
|
||||
还有 `_pendingConnect`、`_pendingSSH`、`_pendingAssistantChat` 三个状态变量(第 542-583 行),用于暂存子命令参数。
|
||||
|
||||
### 2.3 main() 函数(第 585-856 行)
|
||||
|
||||
`main()` 本身不长,做完环境检测后调用 `run()`:
|
||||
|
||||
```
|
||||
main()
|
||||
├── 安全设置(NoDefaultCurrentDirectoryInExePath)
|
||||
├── 信号处理(SIGINT → exit, exit → 恢复光标)
|
||||
├── feature flag 保护的特殊路径(全部跳过)
|
||||
├── 检测 -p/--print / --init-only → 判断是否交互模式
|
||||
├── clientType 判断(cli / sdk-typescript / remote / github-action 等)
|
||||
├── eagerLoadSettings()
|
||||
└── await run() ← 进入真正的逻辑
|
||||
```
|
||||
|
||||
### 2.4 run() 函数(第 884-4683 行)
|
||||
|
||||
占 3800 行,是整个文件的核心。
|
||||
|
||||
#### Commander 初始化 + preAction 钩子(第 884-967 行)
|
||||
|
||||
```ts
|
||||
const program = new CommanderCommand()
|
||||
.configureHelp(createSortedHelpConfig())
|
||||
.enablePositionalOptions();
|
||||
```
|
||||
|
||||
**preAction 钩子**(所有命令执行前都会运行):
|
||||
|
||||
```
|
||||
preAction
|
||||
├── await ensureMdmSettingsLoaded() ← 等 MDM 子进程完成
|
||||
├── await ensureKeychainPrefetchCompleted() ← 等 keychain 预读完成
|
||||
├── await init() ← 一次性初始化
|
||||
├── initSinks() ← 分析日志接收器
|
||||
├── runMigrations() ← 数据迁移
|
||||
├── loadRemoteManagedSettings() ← 企业远程设置(非阻塞)
|
||||
└── loadPolicyLimits() ← 策略限制(非阻塞)
|
||||
```
|
||||
|
||||
#### 主命令 Option 定义(第 968-1006 行)
|
||||
|
||||
定义了 40+ CLI 参数,关键的包括:
|
||||
|
||||
| 参数 | 作用 |
|
||||
|------|------|
|
||||
| `-p, --print` | 非交互模式,输出后退出 |
|
||||
| `--model <model>` | 指定模型(如 sonnet、opus) |
|
||||
| `--permission-mode <mode>` | 权限模式 |
|
||||
| `-c, --continue` | 继续最近对话 |
|
||||
| `-r, --resume` | 恢复指定对话 |
|
||||
| `--mcp-config` | MCP 服务器配置文件 |
|
||||
| `--allowedTools` | 允许的工具列表 |
|
||||
| `--system-prompt` | 自定义系统提示 |
|
||||
| `--dangerously-skip-permissions` | 跳过所有权限检查 |
|
||||
| `--output-format` | 输出格式(text/json/stream-json) |
|
||||
| `--effort <level>` | 推理努力级别(low/medium/high/max) |
|
||||
| `--bare` | 最小模式 |
|
||||
|
||||
#### action 处理器(第 1006-3808 行)
|
||||
|
||||
主命令的执行逻辑,内部按阶段和场景分支:
|
||||
|
||||
```
|
||||
action(async (prompt, options) => {
|
||||
│
|
||||
├── [1007-1600] 参数解析与预处理
|
||||
│ ├── --bare 模式
|
||||
│ ├── 解析 model / permission-mode / thinking / effort
|
||||
│ ├── 解析 MCP 配置、工具列表、系统提示
|
||||
│ └── 初始化工具权限上下文
|
||||
│
|
||||
├── [1600-2220] 服务初始化
|
||||
│ ├── MCP 客户端连接
|
||||
│ ├── 插件加载 + 技能初始化
|
||||
│ ├── 工具列表组装
|
||||
│ └── 初始 AppState 构建
|
||||
│
|
||||
├── [2220-2315] UI 初始化(交互模式)
|
||||
│ ├── createRoot() — 创建 Ink 渲染根节点
|
||||
│ ├── showSetupScreens() — 信任对话框、OAuth 登录、引导
|
||||
│ └── 登录后刷新各种服务
|
||||
│
|
||||
├── [2315-2582] 后续初始化
|
||||
│ ├── LSP 管理器、插件版本管理
|
||||
│ ├── session 注册、遥测日志
|
||||
│ └── 遥测上报
|
||||
│
|
||||
├── [2584-3050] --print 非交互模式分支
|
||||
│ ├── 构建 headless AppState + store
|
||||
│ └── 交给 print.ts 执行
|
||||
│
|
||||
└── [3050-3808] 交互模式:启动 REPL(7 个分支)
|
||||
├── --continue → 加载最近对话 → launchRepl()
|
||||
├── DIRECT_CONNECT → ❌ flag 关闭
|
||||
├── SSH_REMOTE → ❌ flag 关闭
|
||||
├── KAIROS assistant → ❌ flag 关闭
|
||||
├── --resume <id> → 恢复指定对话 → launchRepl()
|
||||
├── --resume 无 ID → 显示对话选择器
|
||||
└── 默认(无参数) → launchRepl() ★最常走的路径
|
||||
})
|
||||
```
|
||||
|
||||
#### 子命令注册(第 3808-4683 行)
|
||||
|
||||
| 子命令 | 行号 | 作用 |
|
||||
|--------|------|------|
|
||||
| `claude mcp` | 3892 | MCP 服务器管理(serve/add/remove/list/get) |
|
||||
| `claude server` | 3960 | Session 服务器(❌ flag 关闭) |
|
||||
| `claude auth` | 4098 | 认证管理(login/logout/status/token) |
|
||||
| `claude plugin` | 4148 | 插件管理(install/uninstall/list/update) |
|
||||
| `claude setup-token` | 4267 | 设置长期认证 token |
|
||||
| `claude agents` | 4278 | 列出已配置的 agents |
|
||||
| `claude doctor` | 4346 | 健康检查 |
|
||||
| `claude update` | 4362 | 检查更新 |
|
||||
| `claude install` | 4394 | 安装原生构建 |
|
||||
| `claude log` | 4411 | 查看对话日志(内部) |
|
||||
| `claude completion` | 4491 | Shell 自动补全 |
|
||||
|
||||
最后执行解析:
|
||||
|
||||
```ts
|
||||
await program.parseAsync(process.argv);
|
||||
```
|
||||
|
||||
### 2.5 main.tsx 学习建议
|
||||
|
||||
- **不要通读**。记住三段结构:辅助函数 → main() → run()
|
||||
- `feature()` 返回 false 的分支全部跳过,可忽略 50%+ 代码
|
||||
- `("external" as string) === 'ant'` 的分支也跳过(内部构建专用)
|
||||
- 需要深入某功能时,通过搜索定位对应代码段
|
||||
|
||||
---
|
||||
|
||||
## 3. replLauncher.tsx(22 行)— 胶水层
|
||||
|
||||
**文件路径**: `src/replLauncher.tsx`
|
||||
|
||||
极其简单,就做一件事:
|
||||
|
||||
```tsx
|
||||
export async function launchRepl(root, appProps, replProps, renderAndRun) {
|
||||
const { App } = await import('./components/App.js');
|
||||
const { REPL } = await import('./screens/REPL.js');
|
||||
await renderAndRun(root, <App {...appProps}><REPL {...replProps} /></App>);
|
||||
}
|
||||
```
|
||||
|
||||
- `App` — 全局 Provider(AppState、Stats、FpsMetrics)
|
||||
- `REPL` — 交互界面组件
|
||||
- `renderAndRun` — 把 React 元素渲染到 Ink 终端
|
||||
|
||||
动态 import 保持了按需加载的策略。
|
||||
|
||||
---
|
||||
|
||||
## 4. REPL.tsx(5009 行)— 交互界面
|
||||
|
||||
**文件路径**: `src/screens/REPL.tsx`
|
||||
|
||||
项目第二大文件,是用户直接交互的界面。一个巨型 React 函数组件。
|
||||
|
||||
### 4.1 文件结构
|
||||
|
||||
```
|
||||
REPL.tsx (5009 行)
|
||||
├── [1-310] Import 区(150+ import)
|
||||
├── [312-525] 辅助组件
|
||||
│ ├── median() — 数学工具函数
|
||||
│ ├── TranscriptModeFooter — 转录模式底栏
|
||||
│ ├── TranscriptSearchBar — 转录搜索栏
|
||||
│ └── AnimatedTerminalTitle — 终端标题动画
|
||||
├── [527-571] Props 类型定义
|
||||
└── [573-5009] REPL() 组件主体
|
||||
├── [600-900] 状态声明(50+ 个 useState/useRef/useAppState)
|
||||
├── [900-2750] 副作用与回调(useEffect/useCallback)
|
||||
├── [2750-2860] onQueryImpl — 核心:执行 API 查询
|
||||
├── [2860-3030] onQuery — 查询守卫与并发控制
|
||||
├── [3030-3145] 查询相关辅助回调
|
||||
├── [3146-3550] onSubmit — 用户提交处理
|
||||
├── [3550-4395] 更多副作用与状态管理
|
||||
└── [4396-5009] JSX 渲染
|
||||
```
|
||||
|
||||
### 4.2 Props
|
||||
|
||||
从 main.tsx 通过 launchRepl() 传入:
|
||||
|
||||
| Prop | 类型 | 含义 |
|
||||
|------|------|------|
|
||||
| `commands` | `Command[]` | 可用的斜杠命令 |
|
||||
| `debug` | `boolean` | 调试模式 |
|
||||
| `initialTools` | `Tool[]` | 初始工具集 |
|
||||
| `initialMessages` | `MessageType[]` | 初始消息(恢复对话时有值) |
|
||||
| `pendingHookMessages` | `Promise<...>` | 延迟加载的 hook 消息 |
|
||||
| `mcpClients` | `MCPServerConnection[]` | MCP 服务器连接 |
|
||||
| `systemPrompt` | `string` | 自定义系统提示 |
|
||||
| `appendSystemPrompt` | `string` | 追加系统提示 |
|
||||
| `onBeforeQuery` | `fn` | 查询前回调,返回 false 可阻止查询 |
|
||||
| `onTurnComplete` | `fn` | 轮次完成回调 |
|
||||
| `mainThreadAgentDefinition` | `AgentDefinition` | 主线程 Agent 定义 |
|
||||
| `thinkingConfig` | `ThinkingConfig` | 思考模式配置 |
|
||||
| `disabled` | `boolean` | 禁用输入 |
|
||||
|
||||
### 4.3 状态管理
|
||||
|
||||
分三层:
|
||||
|
||||
**全局 AppState(通过 useAppState 选择器读取):**
|
||||
|
||||
```ts
|
||||
const toolPermissionContext = useAppState(s => s.toolPermissionContext);
|
||||
const verbose = useAppState(s => s.verbose);
|
||||
const mcp = useAppState(s => s.mcp);
|
||||
const plugins = useAppState(s => s.plugins);
|
||||
const agentDefinitions = useAppState(s => s.agentDefinitions);
|
||||
```
|
||||
|
||||
**本地状态(useState):**
|
||||
|
||||
```ts
|
||||
const [messages, setMessages] = useState(initialMessages ?? []);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [screen, setScreen] = useState<Screen>('prompt');
|
||||
const [streamingText, setStreamingText] = useState(null);
|
||||
const [streamingToolUses, setStreamingToolUses] = useState([]);
|
||||
// ... 50+ 个状态
|
||||
```
|
||||
|
||||
**关键 Ref:**
|
||||
|
||||
```ts
|
||||
const queryGuard = useRef(new QueryGuard()).current; // 查询并发控制
|
||||
const messagesRef = useRef(messages); // 消息的同步引用(避免闭包问题)
|
||||
const abortController = ...; // 取消请求控制器
|
||||
const responseLengthRef = useRef(0); // 响应长度追踪
|
||||
```
|
||||
|
||||
### 4.4 核心数据流:用户输入 → API 调用
|
||||
|
||||
```
|
||||
用户按回车
|
||||
│
|
||||
▼
|
||||
onSubmit (第 3146 行)
|
||||
├── 斜杠命令?→ immediate command 直接执行 或 handlePromptSubmit 路由
|
||||
├── 空输入?→ 忽略
|
||||
├── 空闲检测 → 可能弹出"是否开始新对话"对话框
|
||||
├── 加入历史记录
|
||||
│
|
||||
▼
|
||||
handlePromptSubmit (外部函数,src/utils/handlePromptSubmit.ts)
|
||||
├── 斜杠命令 → 路由到对应 Command handler
|
||||
├── 普通文本 → 构建 UserMessage,调用 onQuery()
|
||||
│
|
||||
▼
|
||||
onQuery (第 2860 行) — 并发守卫层
|
||||
├── queryGuard.tryStart() → 已有查询?排队等待
|
||||
├── setMessages([...old, ...newMessages]) — 追加用户消息
|
||||
├── onQueryImpl()
|
||||
│
|
||||
▼
|
||||
onQueryImpl (第 2750 行) — 真正执行 API 调用
|
||||
│
|
||||
├── 1. 并行加载上下文:
|
||||
│ await Promise.all([
|
||||
│ getSystemPrompt(), // 构建系统提示
|
||||
│ getUserContext(), // 用户上下文
|
||||
│ getSystemContext(), // 系统上下文(git、平台等)
|
||||
│ ])
|
||||
│
|
||||
├── 2. buildEffectiveSystemPrompt() — 合成最终系统提示
|
||||
│
|
||||
├── 3. for await (const event of query({...})) ★核心★
|
||||
│ │ 调用 src/query.ts 的 query() AsyncGenerator
|
||||
│ │ 流式产出事件
|
||||
│ │
|
||||
│ └── onQueryEvent(event) — 处理每个流式事件
|
||||
│ ├── 更新 streamingText(打字机效果)
|
||||
│ ├── 更新 messages(工具调用结果)
|
||||
│ └── 更新 inProgressToolUseIDs
|
||||
│
|
||||
└── 4. 收尾:resetLoadingState()、onTurnComplete()
|
||||
```
|
||||
|
||||
**核心代码(第 2797-2807 行)**:
|
||||
|
||||
```ts
|
||||
for await (const event of query({
|
||||
messages: messagesIncludingNewMessages,
|
||||
systemPrompt,
|
||||
userContext,
|
||||
systemContext,
|
||||
canUseTool,
|
||||
toolUseContext,
|
||||
querySource: getQuerySourceForREPL()
|
||||
})) {
|
||||
onQueryEvent(event);
|
||||
}
|
||||
```
|
||||
|
||||
`query()` 来自 `src/query.ts`,是第二阶段要学的核心函数。
|
||||
|
||||
### 4.5 QueryGuard 并发控制
|
||||
|
||||
防止同时发起多个 API 请求的状态机:
|
||||
|
||||
```
|
||||
idle ──tryStart()──▶ running ──end()──▶ idle
|
||||
│
|
||||
└── tryStart() 返回 null(已在运行)
|
||||
→ 新消息排入队列
|
||||
```
|
||||
|
||||
- `tryStart()` — 原子操作,检查并转换 idle→running,返回 generation 号
|
||||
- `end(generation)` — 检查 generation 匹配后转换 running→idle
|
||||
- 防止 cancel+resubmit 竞态条件
|
||||
|
||||
### 4.6 JSX 渲染
|
||||
|
||||
两个互斥的渲染分支:
|
||||
|
||||
#### Transcript 模式(第 4396-4493 行)
|
||||
|
||||
按 `v` 键切换,只读浏览对话历史,支持搜索:
|
||||
|
||||
```tsx
|
||||
<KeybindingSetup>
|
||||
<AnimatedTerminalTitle />
|
||||
<GlobalKeybindingHandlers />
|
||||
<ScrollKeybindingHandler />
|
||||
<CancelRequestHandler />
|
||||
<FullscreenLayout
|
||||
scrollable={<Messages />}
|
||||
bottom={<TranscriptSearchBar /> 或 <TranscriptModeFooter />}
|
||||
/>
|
||||
</KeybindingSetup>
|
||||
```
|
||||
|
||||
#### Prompt 模式(第 4552-5009 行)
|
||||
|
||||
主交互界面,从上到下:
|
||||
|
||||
```tsx
|
||||
<KeybindingSetup>
|
||||
<AnimatedTerminalTitle /> // 终端 tab 标题
|
||||
<GlobalKeybindingHandlers /> // 全局快捷键
|
||||
<CommandKeybindingHandlers /> // 命令快捷键
|
||||
<ScrollKeybindingHandler /> // 滚动快捷键
|
||||
<CancelRequestHandler /> // Ctrl+C 取消
|
||||
<MCPConnectionManager> // MCP 连接管理
|
||||
<FullscreenLayout
|
||||
overlay={<PermissionRequest />} // 权限审批覆盖层
|
||||
scrollable={ // 可滚动区域
|
||||
<>
|
||||
<Messages /> // ★ 对话消息渲染
|
||||
<UserTextMessage /> // 用户输入占位
|
||||
{toolJSX} // 工具 UI
|
||||
<SpinnerWithVerb /> // 加载动画
|
||||
</>
|
||||
}
|
||||
bottom={ // 固定底部
|
||||
<>
|
||||
{/* 各种对话框 */}
|
||||
<SandboxPermissionRequest />
|
||||
<PromptDialog />
|
||||
<ElicitationDialog />
|
||||
<CostThresholdDialog />
|
||||
<FeedbackSurvey />
|
||||
|
||||
{/* ★ 用户输入框 */}
|
||||
<PromptInput
|
||||
onSubmit={onSubmit}
|
||||
commands={commands}
|
||||
isLoading={isLoading}
|
||||
messages={messages}
|
||||
// ... 20+ props
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</MCPConnectionManager>
|
||||
</KeybindingSetup>
|
||||
```
|
||||
|
||||
### 4.7 REPL.tsx 学习建议
|
||||
|
||||
- 核心只有一条线:`onSubmit → onQuery → query() → onQueryEvent → 更新消息`
|
||||
- 其余 4000+ 行是 UI 细节:快捷键、对话框、动画、边界情况处理
|
||||
- `feature('...')` 保护的 JSX 全部跳过
|
||||
- `("external" as string) === 'ant'` 的分支也跳过
|
||||
|
||||
---
|
||||
|
||||
## 关键设计模式总结
|
||||
|
||||
| 模式 | 位置 | 说明 |
|
||||
|------|------|------|
|
||||
| 快速路径 | cli.tsx | 按开销从低到高逐级检查,零开销处理简单请求 |
|
||||
| 动态 import | cli.tsx / main.tsx | `await import()` 延迟加载,每条路径只加载需要的模块 |
|
||||
| Side-effect import | main.tsx 顶部 | import 阶段就并行启动子进程,用并行隐藏延迟 |
|
||||
| feature flag | 全局 | `feature()` 永远返回 false,编译时消除死代码 |
|
||||
| preAction 钩子 | main.tsx run() | Commander.js 命令执行前统一初始化 |
|
||||
| QueryGuard | REPL.tsx | 状态机防止并发 API 请求,带 generation 计数防竞态 |
|
||||
| React/Ink | UI 层 | 用 React 组件模型渲染终端 UI,支持全屏和虚拟滚动 |
|
||||
|
||||
## 需要忽略的代码模式
|
||||
|
||||
| 模式 | 来源 | 说明 |
|
||||
|------|------|------|
|
||||
| `_c(N)` 调用 | React Compiler | 反编译产生的 memoization 样板代码 |
|
||||
| `feature('FLAG')` 后面的代码 | Bun bundler | 全部是死代码,在当前版本不会执行 |
|
||||
| `("external" as string) === 'ant'` | 构建目标检查 | 永远为 false(external !== ant) |
|
||||
| tsc 类型错误 | 反编译 | `unknown`/`never`/`{}` 类型,不影响 Bun 运行 |
|
||||
| `packages/@ant/` | stub 包 | 空实现,仅满足 import 依赖 |
|
||||
@@ -1,774 +0,0 @@
|
||||
# 第二阶段:核心对话循环详解
|
||||
|
||||
> 用户发一句话后,如何变成 API 请求、如何处理流式响应和工具调用
|
||||
|
||||
## 对话循环总览
|
||||
|
||||
```
|
||||
用户输入 "帮我读取 README.md"
|
||||
│
|
||||
▼
|
||||
REPL.tsx: onSubmit → onQuery → onQueryImpl
|
||||
│
|
||||
├── 1. 并行加载上下文:
|
||||
│ getSystemPrompt() + getUserContext() + getSystemContext()
|
||||
│
|
||||
├── 2. buildEffectiveSystemPrompt() — 合成最终系统提示
|
||||
│
|
||||
├── 3. for await (const event of query({...})) ★ 核心循环
|
||||
│ │
|
||||
│ │ query.ts: queryLoop()
|
||||
│ │ ├── while (true) {
|
||||
│ │ │ ├── autocompact / microcompact 处理
|
||||
│ │ │ ├── deps.callModel() → claude.ts 流式 API 调用
|
||||
│ │ │ │ └── for await (message of stream) { yield message }
|
||||
│ │ │ │
|
||||
│ │ │ ├── 收集 assistant 消息中的 tool_use 块
|
||||
│ │ │ │
|
||||
│ │ │ ├── needsFollowUp?
|
||||
│ │ │ │ ├── true → 执行工具 → 收集结果 → state = next → continue
|
||||
│ │ │ │ └── false → 检查错误恢复 → return { reason: 'completed' }
|
||||
│ │ │ }
|
||||
│ │
|
||||
│ └── onQueryEvent(event) — 更新 UI 状态
|
||||
│
|
||||
└── 4. 收尾: resetLoadingState(), onTurnComplete()
|
||||
```
|
||||
|
||||
### 两条数据路径
|
||||
|
||||
| 路径 | 调用方 | 说明 |
|
||||
|------|--------|------|
|
||||
| **交互式(REPL)** | REPL.tsx → `query()` | 直接调用 `query()` AsyncGenerator |
|
||||
| **非交互式(SDK/print)** | print.ts → `QueryEngine.submitMessage()` → `query()` | 通过 QueryEngine 包装,增加了会话持久化、usage 跟踪等 |
|
||||
|
||||
---
|
||||
|
||||
## 1. query.ts(1732 行)— 核心查询循环
|
||||
|
||||
**文件路径**: `src/query.ts`
|
||||
|
||||
### 1.1 文件结构
|
||||
|
||||
```
|
||||
query.ts (1732 行)
|
||||
├── [0-120] Import 区 + feature flag 条件模块加载
|
||||
├── [122-148] yieldMissingToolResultBlocks() — 为未配对的 tool_use 生成错误 tool_result
|
||||
├── [150-178] 常量与辅助函数 (MAX_OUTPUT_TOKENS_RECOVERY_LIMIT, isWithheldMaxOutputTokens)
|
||||
├── [180-198] QueryParams 类型定义
|
||||
├── [200-216] State 类型 — 循环迭代间的可变状态
|
||||
├── [218-238] query() — 导出的 AsyncGenerator,委托给 queryLoop()
|
||||
├── [240-1732] queryLoop() — 核心 while(true) 循环
|
||||
│ ├── [241-306] 初始化 State + 内存预取
|
||||
│ ├── [307-448] 循环开头:解构 state、消息预处理(snip/microcompact/context collapse)
|
||||
│ ├── [449-578] 系统提示构建(第449行) + autocompact(第453行) + StreamingToolExecutor 初始化(第562行)
|
||||
│ ├── [650-866] ★ deps.callModel()(第659行) + 流式响应处理 + tool_use 收集
|
||||
│ ├── [896-956] 错误处理(FallbackTriggeredError、通用错误)
|
||||
│ ├── [1002-1054] 中断处理(abortController.signal.aborted)
|
||||
│ ├── [1065-1360] 无 followUp 时的终止/恢复逻辑
|
||||
│ │ ├── prompt-too-long 恢复
|
||||
│ │ ├── max_output_tokens 恢复(升级 + 多轮)
|
||||
│ │ ├── stop hooks 执行
|
||||
│ │ └── return { reason: 'completed' }
|
||||
│ └── [1360-1732] 有 followUp 时的工具执行 + 下一轮准备
|
||||
│ ├── 工具执行(streaming 或 sequential)
|
||||
│ ├── attachment 注入(排队命令、内存预取、技能发现)
|
||||
│ ├── maxTurns 检查
|
||||
│ └── state = next → continue
|
||||
```
|
||||
|
||||
### 1.2 入口:query() 函数(第 219 行)
|
||||
|
||||
```ts
|
||||
export async function* query(params: QueryParams):
|
||||
AsyncGenerator<StreamEvent | Message | ..., Terminal> {
|
||||
const consumedCommandUuids: string[] = []
|
||||
const terminal = yield* queryLoop(params, consumedCommandUuids)
|
||||
// 通知所有消费的排队命令已完成
|
||||
for (const uuid of consumedCommandUuids) {
|
||||
notifyCommandLifecycle(uuid, 'completed')
|
||||
}
|
||||
return terminal
|
||||
}
|
||||
```
|
||||
|
||||
`query()` 本身很薄,只做两件事:
|
||||
1. 委托给 `queryLoop()` 执行实际逻辑
|
||||
2. 在正常返回后通知排队命令的生命周期
|
||||
|
||||
### 1.3 QueryParams(第 181 行)
|
||||
|
||||
```ts
|
||||
type QueryParams = {
|
||||
messages: Message[] // 当前对话消息
|
||||
systemPrompt: SystemPrompt // 系统提示
|
||||
userContext: { [k: string]: string } // 用户上下文(CLAUDE.md 等)
|
||||
systemContext: { [k: string]: string } // 系统上下文(git 状态等)
|
||||
canUseTool: CanUseToolFn // 工具权限检查函数
|
||||
toolUseContext: ToolUseContext // 工具执行上下文
|
||||
fallbackModel?: string // 备用模型
|
||||
querySource: QuerySource // 查询来源标识
|
||||
maxTurns?: number // 最大轮次限制
|
||||
taskBudget?: { total: number } // 令牌预算
|
||||
}
|
||||
```
|
||||
|
||||
### 1.4 State — 循环迭代间的可变状态(第 204 行)
|
||||
|
||||
```ts
|
||||
type State = {
|
||||
messages: Message[] // 累积的消息列表
|
||||
toolUseContext: ToolUseContext // 工具执行上下文
|
||||
autoCompactTracking: ... // 自动压缩跟踪
|
||||
maxOutputTokensRecoveryCount: number // 输出令牌恢复尝试次数
|
||||
hasAttemptedReactiveCompact: boolean // 是否已尝试响应式压缩
|
||||
maxOutputTokensOverride: number | undefined // 输出令牌覆盖
|
||||
pendingToolUseSummary: Promise<...> // 待处理的工具使用摘要
|
||||
stopHookActive: boolean | undefined // stop hook 是否活跃
|
||||
turnCount: number // 当前轮次
|
||||
transition: Continue | undefined // 上一次迭代为何 continue
|
||||
}
|
||||
```
|
||||
|
||||
**设计关键**:每次 `continue` 时通过 `state = { ... }` 一次性更新所有状态,而不是分散的 9 个赋值。`transition` 字段记录了为什么要继续循环(便于调试和测试)。
|
||||
|
||||
### 1.5 queryLoop() 核心流程(第 241 行)
|
||||
|
||||
`while (true)` 循环(第 307 行)的每次迭代代表一次 API 调用。循环直到:
|
||||
- 模型不需要工具调用 → `return { reason: 'completed' }`
|
||||
- 被用户中断 → `return { reason: 'aborted_*' }`
|
||||
- 达到最大轮次 → `return { reason: 'max_turns' }`
|
||||
- 遇到不可恢复的错误 → `return { reason: 'model_error' }`
|
||||
|
||||
#### 步骤 1:消息预处理
|
||||
|
||||
```
|
||||
每次迭代开头:
|
||||
├── 解构 state → messages, toolUseContext, tracking, ...
|
||||
├── getMessagesAfterCompactBoundary() — 只保留压缩边界后的消息
|
||||
├── snip 处理(feature flag,跳过)
|
||||
├── microcompact 处理(feature flag,跳过)
|
||||
└── autocompact 检查 — 消息过长时自动压缩
|
||||
```
|
||||
|
||||
#### 步骤 2:系统提示构建(第 449 行)
|
||||
|
||||
```ts
|
||||
const fullSystemPrompt = asSystemPrompt(
|
||||
appendSystemContext(systemPrompt, systemContext),
|
||||
)
|
||||
```
|
||||
|
||||
将系统上下文(git 状态、日期等)追加到系统提示。注意:用户上下文(CLAUDE.md 等)不在这里注入,而是在 `deps.callModel()` 调用时通过 `prependUserContext(messagesForQuery, userContext)` 注入到消息数组的最前面(第 660 行)。
|
||||
|
||||
#### 步骤 3:Autocompact(第 454-543 行)
|
||||
|
||||
当消息历史过长时自动压缩:
|
||||
|
||||
```
|
||||
autocompact 流程:
|
||||
├── 检查 token 数量是否超过阈值
|
||||
├── 超过 → 调用 compact API(用 Haiku 总结历史)
|
||||
│ ├── yield compactBoundaryMessage ← 标记压缩边界
|
||||
│ └── 更新 messages 为压缩后的版本
|
||||
└── 未超过 → 继续
|
||||
```
|
||||
|
||||
#### 步骤 4:调用 API(第 559-708 行)— 核心
|
||||
|
||||
StreamingToolExecutor 在第 562 行初始化,API 调用在第 659 行开始:
|
||||
|
||||
```ts
|
||||
// 第 562 行:初始化流式工具执行器
|
||||
let streamingToolExecutor = useStreamingToolExecution
|
||||
? new StreamingToolExecutor(
|
||||
toolUseContext.options.tools, canUseTool, toolUseContext,
|
||||
)
|
||||
: null
|
||||
|
||||
// 第 659 行:调用 API
|
||||
for await (const message of deps.callModel({
|
||||
messages: prependUserContext(messagesForQuery, userContext), // ← 用户上下文注入到消息最前面
|
||||
systemPrompt: fullSystemPrompt,
|
||||
thinkingConfig: toolUseContext.options.thinkingConfig,
|
||||
tools: toolUseContext.options.tools,
|
||||
signal: toolUseContext.abortController.signal,
|
||||
options: { model: currentModel, querySource, fallbackModel, ... }
|
||||
})) {
|
||||
// 处理每条流式消息(第 708-866 行)
|
||||
}
|
||||
```
|
||||
|
||||
`deps.callModel()` 最终调用 `claude.ts` 的 `queryModelWithStreaming()`。
|
||||
|
||||
#### 步骤 5:流式响应处理(第 708-866 行)
|
||||
|
||||
处理逻辑在 `for await` 循环体内(第 708 行的 `})` 之后到第 866 行):
|
||||
|
||||
```
|
||||
for await (const message of stream):
|
||||
├── message.type === 'assistant'?
|
||||
│ ├── 记录到 assistantMessages[]
|
||||
│ ├── 提取 tool_use 块 → toolUseBlocks[]
|
||||
│ ├── needsFollowUp = true(如果有 tool_use)
|
||||
│ └── streamingToolExecutor.addTool() ← 流式工具并行执行
|
||||
│
|
||||
├── withheld? (prompt-too-long / max_output_tokens)
|
||||
│ └── 暂扣不 yield,等后面恢复逻辑处理
|
||||
│
|
||||
└── yield message ← 正常 yield 给上层(REPL/QueryEngine)
|
||||
```
|
||||
|
||||
**StreamingToolExecutor**:在 API 流式返回的同时就开始执行工具(如读文件),不等流结束。通过 `addTool()` 添加待执行工具,`getCompletedResults()` 获取已完成的结果。
|
||||
|
||||
#### 步骤 6A:无 followUp — 终止/恢复(第 1065-1360 行)
|
||||
|
||||
当模型没有请求工具调用时(`needsFollowUp === false`):
|
||||
|
||||
```
|
||||
无 followUp:
|
||||
├── prompt-too-long 恢复?
|
||||
│ ├── context collapse drain(feature flag,跳过)
|
||||
│ ├── reactive compact → 压缩消息重试
|
||||
│ └── 都失败 → yield 错误 + return
|
||||
│
|
||||
├── max_output_tokens 恢复?
|
||||
│ ├── 第一次 → 升级到 64k token 限制,continue
|
||||
│ ├── 后续 → 注入恢复消息("继续,别道歉"),continue
|
||||
│ └── 超过 3 次 → yield 错误 + return
|
||||
│
|
||||
├── stop hooks 执行
|
||||
│ ├── preventContinuation? → return
|
||||
│ └── blockingErrors? → 将错误加入消息,continue
|
||||
│
|
||||
└── return { reason: 'completed' } ★ 正常结束
|
||||
```
|
||||
|
||||
**恢复消息内容(第 1229 行)**:
|
||||
```
|
||||
"Output token limit hit. Resume directly — no apology, no recap of what
|
||||
you were doing. Pick up mid-thought if that is where the cut happened.
|
||||
Break remaining work into smaller pieces."
|
||||
```
|
||||
|
||||
#### 步骤 6B:有 followUp — 工具执行 + 下一轮(第 1363-1731 行)
|
||||
|
||||
当模型请求了工具调用时(`needsFollowUp === true`):
|
||||
|
||||
```
|
||||
有 followUp:
|
||||
├── 工具执行(两种模式)
|
||||
│ ├── streamingToolExecutor? → getRemainingResults()(流式已启动)
|
||||
│ └── 否 → runTools()(传统顺序执行)
|
||||
│
|
||||
├── for await (const update of toolUpdates):
|
||||
│ ├── yield update.message ← 工具结果消息
|
||||
│ └── toolResults.push(...) ← 收集工具结果
|
||||
│
|
||||
├── 中断检查(abortController.signal.aborted)
|
||||
│ └── return { reason: 'aborted_tools' }
|
||||
│
|
||||
├── attachment 注入
|
||||
│ ├── 排队命令(其他线程提交的消息)
|
||||
│ ├── 内存预取(相关记忆文件)
|
||||
│ └── 技能发现预取
|
||||
│
|
||||
├── maxTurns 检查
|
||||
│ └── 超过 → yield max_turns_reached + return
|
||||
│
|
||||
└── state = { messages: [...old, ...assistant, ...toolResults], turnCount: +1 }
|
||||
→ continue ★ 回到循环顶部,发起下一次 API 调用
|
||||
```
|
||||
|
||||
### 1.6 错误处理与模型降级(第 897-956 行)
|
||||
|
||||
```
|
||||
API 调用出错:
|
||||
├── FallbackTriggeredError(529 过载)?
|
||||
│ ├── 切换到 fallbackModel
|
||||
│ ├── 清空本轮 assistant/tool 消息
|
||||
│ ├── yield 系统消息 "Switched to X due to high demand for Y"
|
||||
│ └── continue(重试整个请求)
|
||||
│
|
||||
└── 其他错误
|
||||
├── ImageSizeError/ImageResizeError → yield 友好错误 + return
|
||||
├── yieldMissingToolResultBlocks() — 补全未配对的 tool_result
|
||||
└── yield API 错误消息 + return
|
||||
```
|
||||
|
||||
### 1.7 关键设计思想
|
||||
|
||||
| 设计 | 说明 |
|
||||
|------|------|
|
||||
| **AsyncGenerator 模式** | `query()` 是 `async function*`,通过 `yield` 逐条产出事件,调用者用 `for await` 消费 |
|
||||
| **while(true) + state 对象** | 每次 `continue` 构建新 State 对象,避免分散的状态修改 |
|
||||
| **transition 字段** | 记录为什么要 continue(`next_turn`、`max_output_tokens_recovery`、`reactive_compact_retry`...),便于调试 |
|
||||
| **StreamingToolExecutor** | API 流式返回时就并行执行工具,不等流结束 |
|
||||
| **Withheld 消息** | 可恢复错误先暂扣,恢复成功则不 yield 错误,失败才 yield |
|
||||
|
||||
---
|
||||
|
||||
## 2. QueryEngine.ts(1320 行)— 高层编排器
|
||||
|
||||
**文件路径**: `src/QueryEngine.ts`
|
||||
|
||||
### 2.1 定位
|
||||
|
||||
QueryEngine 是 `query()` 的**上层包装**,主要用于:
|
||||
- **print 模式**(`claude -p`):通过 `ask()` → `QueryEngine.submitMessage()`
|
||||
- **SDK 模式**:外部程序通过 SDK 调用
|
||||
- **REPL 不用它**:REPL 直接调用 `query()`
|
||||
|
||||
### 2.2 文件结构
|
||||
|
||||
```
|
||||
QueryEngine.ts (1320 行)
|
||||
├── [0-130] Import 区 + feature flag 条件模块
|
||||
├── [131-174] QueryEngineConfig 类型定义
|
||||
├── [185-1202] QueryEngine 类
|
||||
│ ├── [185-208] 成员变量 + constructor
|
||||
│ ├── [210-1181] submitMessage() — 核心方法(~970 行)
|
||||
│ │ ├── [210-400] 参数解析 + processUserInputContext 构建
|
||||
│ │ ├── [400-465] 用户输入处理 + 会话持久化
|
||||
│ │ ├── [465-660] 斜杠命令处理 + 无需查询的快速返回
|
||||
│ │ ├── [660-690] 文件历史快照
|
||||
│ │ ├── [679-1074] ★ for await (const message of query({...})) — 消费 query()
|
||||
│ │ └── [1074-1181] 结果提取 + yield result
|
||||
│ ├── [1183-1202] interrupt() / getMessages() / setModel() 辅助方法
|
||||
├── [1210-1320] ask() — 便捷包装函数
|
||||
```
|
||||
|
||||
### 2.3 QueryEngineConfig
|
||||
|
||||
```ts
|
||||
type QueryEngineConfig = {
|
||||
cwd: string // 工作目录
|
||||
tools: Tools // 工具列表
|
||||
commands: Command[] // 斜杠命令
|
||||
mcpClients: MCPServerConnection[] // MCP 服务器连接
|
||||
agents: AgentDefinition[] // Agent 定义
|
||||
canUseTool: CanUseToolFn // 权限检查
|
||||
getAppState / setAppState // 全局状态存取
|
||||
initialMessages?: Message[] // 初始消息(恢复对话)
|
||||
readFileCache: FileStateCache // 文件读取缓存
|
||||
customSystemPrompt?: string // 自定义系统提示
|
||||
thinkingConfig?: ThinkingConfig // 思考模式配置
|
||||
maxTurns?: number // 最大轮次
|
||||
maxBudgetUsd?: number // USD 预算上限
|
||||
jsonSchema?: Record<...> // 结构化输出 schema
|
||||
// ... 更多配置
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 submitMessage() 核心流程
|
||||
|
||||
```
|
||||
submitMessage(prompt)
|
||||
│
|
||||
├── 1. 参数准备
|
||||
│ ├── 解构 config 获取 tools, commands, model, ...
|
||||
│ ├── 构建 wrappedCanUseTool(包装权限检查,跟踪拒绝)
|
||||
│ ├── fetchSystemPromptParts() — 获取系统提示各部分
|
||||
│ └── 构建 processUserInputContext
|
||||
│
|
||||
├── 2. 用户输入处理
|
||||
│ ├── processUserInput(prompt) — 解析斜杠命令 / 普通文本
|
||||
│ ├── mutableMessages.push(...messagesFromUserInput)
|
||||
│ └── recordTranscript(messages) — 持久化到 JSONL
|
||||
│
|
||||
├── 3. yield buildSystemInitMessage() — SDK 初始化消息
|
||||
│
|
||||
├── 4. shouldQuery === false?(斜杠命令的本地执行结果)
|
||||
│ ├── yield 命令输出
|
||||
│ ├── yield { type: 'result', subtype: 'success' }
|
||||
│ └── return
|
||||
│
|
||||
├── 5. ★ for await (const message of query({...}))
|
||||
│ │ 消费 query() 产出的每条消息
|
||||
│ │
|
||||
│ ├── message.type === 'assistant'
|
||||
│ │ ├── mutableMessages.push(msg)
|
||||
│ │ ├── recordTranscript() ← fire-and-forget
|
||||
│ │ ├── yield* normalizeMessage(msg) — 转换为 SDK 格式
|
||||
│ │ └── 捕获 stop_reason
|
||||
│ │
|
||||
│ ├── message.type === 'user'(工具结果)
|
||||
│ │ ├── mutableMessages.push(msg)
|
||||
│ │ ├── turnCount++
|
||||
│ │ └── yield* normalizeMessage(msg)
|
||||
│ │
|
||||
│ ├── message.type === 'stream_event'
|
||||
│ │ ├── 跟踪 usage(message_start/delta/stop)
|
||||
│ │ └── includePartialMessages? → yield 流事件
|
||||
│ │
|
||||
│ ├── message.type === 'system'
|
||||
│ │ ├── compact_boundary → GC 旧消息 + yield 给 SDK
|
||||
│ │ └── api_error → yield 重试信息
|
||||
│ │
|
||||
│ └── maxBudgetUsd 检查 → 超预算则 yield error + return
|
||||
│
|
||||
└── 6. yield { type: 'result', subtype: 'success', result: textResult }
|
||||
```
|
||||
|
||||
### 2.5 ask() 便捷函数(第 1211 行)
|
||||
|
||||
```ts
|
||||
export async function* ask({ prompt, tools, ... }) {
|
||||
const engine = new QueryEngine({ ... })
|
||||
try {
|
||||
yield* engine.submitMessage(prompt)
|
||||
} finally {
|
||||
setReadFileCache(engine.getReadFileState())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`ask()` 是 `QueryEngine` 的一次性包装,创建 engine → 提交消息 → 清理。用于 `print.ts` 的 `--print` 模式。
|
||||
|
||||
### 2.6 QueryEngine vs REPL 直接调用 query()
|
||||
|
||||
| 特性 | QueryEngine (SDK/print) | REPL 直接调用 query() |
|
||||
|------|------------------------|---------------------|
|
||||
| 会话持久化 | 自动 recordTranscript | 由 useLogMessages 处理 |
|
||||
| Usage 跟踪 | 内部 totalUsage 累积 | 由外层 cost-tracker 处理 |
|
||||
| 权限拒绝跟踪 | 记录 permissionDenials[] | 直接 UI 交互 |
|
||||
| 结果格式 | yield SDKMessage 格式 | 原始 Message 格式 |
|
||||
| 消息 GC | compact_boundary 后释放旧消息 | UI 需要保留完整历史 |
|
||||
|
||||
---
|
||||
|
||||
## 3. claude.ts(3420 行)— API 客户端
|
||||
|
||||
**文件路径**: `src/services/api/claude.ts`
|
||||
|
||||
### 3.1 文件结构
|
||||
|
||||
```
|
||||
claude.ts (3420 行)
|
||||
├── [0-260] Import 区(大量 SDK 类型、工具函数)
|
||||
├── [272-331] getExtraBodyParams() — 构建额外请求体参数
|
||||
├── [333-502] 缓存相关(getPromptCachingEnabled, getCacheControl, should1hCacheTTL, configureEffortParams, configureTaskBudgetParams)
|
||||
├── [504-587] verifyApiKey() — API 密钥验证
|
||||
├── [589-675] 消息转换(userMessageToMessageParam, assistantMessageToMessageParam)
|
||||
├── [677-708] Options 类型定义
|
||||
├── [710-781] queryModelWithoutStreaming / queryModelWithStreaming — 公开的两个入口
|
||||
├── [783-813] 辅助函数(shouldDeferLspTool, getNonstreamingFallbackTimeoutMs)
|
||||
├── [819-918] executeNonStreamingRequest() — 非流式请求辅助
|
||||
├── [920-999] 更多辅助函数(getPreviousRequestIdFromMessages, stripExcessMediaItems)
|
||||
├── [1018-3420] ★ queryModel() — 核心私有函数(2400 行)
|
||||
│ ├── [1018-1370] 前置检查 + 工具 schema 构建 + 消息归一化 + 系统提示组装
|
||||
│ ├── [1539-1730] paramsFromContext() — 构建 API 请求参数
|
||||
│ ├── [1777-2100] withRetry + 流式 API 调用(anthropic.beta.messages.create + stream)
|
||||
│ ├── [1941-2300] 流式事件处理(for await of stream)
|
||||
│ └── [2300-3420] 非流式降级 + 日志、分析、清理
|
||||
```
|
||||
|
||||
### 3.2 两个公开入口
|
||||
|
||||
```ts
|
||||
// 入口 1:流式(主要路径)
|
||||
export async function* queryModelWithStreaming({
|
||||
messages, systemPrompt, thinkingConfig, tools, signal, options
|
||||
}) {
|
||||
yield* withStreamingVCR(messages, async function* () {
|
||||
yield* queryModel(messages, systemPrompt, thinkingConfig, tools, signal, options)
|
||||
})
|
||||
}
|
||||
|
||||
// 入口 2:非流式(compact 等内部用途)
|
||||
export async function queryModelWithoutStreaming({
|
||||
messages, systemPrompt, thinkingConfig, tools, signal, options
|
||||
}) {
|
||||
let assistantMessage
|
||||
for await (const message of ...) {
|
||||
if (message.type === 'assistant') assistantMessage = message
|
||||
}
|
||||
return assistantMessage
|
||||
}
|
||||
```
|
||||
|
||||
两者都委托给内部的 `queryModel()`。`withStreamingVCR` 是一个 VCR(录像/回放)包装器,用于调试。
|
||||
|
||||
### 3.3 Options 类型(第 677 行)
|
||||
|
||||
```ts
|
||||
type Options = {
|
||||
getToolPermissionContext: () => Promise<ToolPermissionContext>
|
||||
model: string // 模型名称
|
||||
toolChoice?: BetaToolChoiceTool // 强制使用特定工具
|
||||
isNonInteractiveSession: boolean // 是否非交互模式
|
||||
fallbackModel?: string // 备用模型
|
||||
querySource: QuerySource // 查询来源
|
||||
agents: AgentDefinition[] // Agent 定义
|
||||
enablePromptCaching?: boolean // 启用提示缓存
|
||||
effortValue?: EffortValue // 推理努力级别
|
||||
mcpTools: Tools // MCP 工具
|
||||
fastMode?: boolean // 快速模式
|
||||
taskBudget?: { total: number; remaining?: number } // 令牌预算
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 queryModel() 核心流程(第 1018 行)
|
||||
|
||||
这是整个 API 调用的核心,2400 行。关键步骤:
|
||||
|
||||
#### 阶段 1:前置准备(1018-1400 行)
|
||||
|
||||
```
|
||||
queryModel()
|
||||
├── off-switch 检查(Opus 过载时的全局关闭开关)
|
||||
├── beta headers 组装(getMergedBetas)
|
||||
│ ├── 基础 betas
|
||||
│ ├── advisor beta(如果启用)
|
||||
│ ├── tool search beta(如果启用)
|
||||
│ ├── cache scope beta
|
||||
│ └── effort / task budget betas
|
||||
│
|
||||
├── 工具过滤
|
||||
│ ├── tool search 启用 → 只包含已发现的 deferred tools
|
||||
│ └── tool search 未启用 → 过滤掉 ToolSearchTool
|
||||
│
|
||||
├── toolToAPISchema() — 每个工具转为 API 格式
|
||||
│
|
||||
├── normalizeMessagesForAPI() — 消息转换为 API 格式
|
||||
│ ├── UserMessage → { role: 'user', content: ... }
|
||||
│ ├── AssistantMessage → { role: 'assistant', content: ... }
|
||||
│ └── 跳过 system/attachment/progress 等内部消息类型
|
||||
│
|
||||
└── 系统提示最终组装
|
||||
├── getAttributionHeader(fingerprint)
|
||||
├── getCLISyspromptPrefix()
|
||||
├── ...systemPrompt
|
||||
└── advisor 指令(如果启用)
|
||||
```
|
||||
|
||||
#### 阶段 2:构建请求参数 — paramsFromContext()(第 1539-1730 行)
|
||||
|
||||
```ts
|
||||
const paramsFromContext = (retryContext: RetryContext) => {
|
||||
// ... 动态 beta headers、effort、task budget 配置 ...
|
||||
|
||||
// 思考模式配置(adaptive 或 enabled + budget)
|
||||
let thinking = undefined
|
||||
if (hasThinking && modelSupportsThinking(options.model)) {
|
||||
if (modelSupportsAdaptiveThinking(options.model)) {
|
||||
thinking = { type: 'adaptive' }
|
||||
} else {
|
||||
thinking = { type: 'enabled', budget_tokens: thinkingBudget }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
model: normalizeModelStringForAPI(options.model),
|
||||
messages: addCacheBreakpoints(messagesForAPI, ...), // 带缓存标记的消息
|
||||
system, // 系统提示块(已构建好)
|
||||
tools: allTools, // 工具 schema
|
||||
tool_choice: options.toolChoice,
|
||||
max_tokens: maxOutputTokens,
|
||||
thinking,
|
||||
...(temperature !== undefined && { temperature }),
|
||||
...(useBetas && { betas: betasParams }),
|
||||
metadata: getAPIMetadata(),
|
||||
...extraBodyParams,
|
||||
...(speed !== undefined && { speed }), // 快速模式
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 阶段 3:流式 API 调用(第 1779-1858 行)
|
||||
|
||||
```ts
|
||||
// 使用 withRetry 包装,自动处理重试
|
||||
const generator = withRetry(
|
||||
() => getAnthropicClient({ maxRetries: 0, model, source: querySource }),
|
||||
async (anthropic, attempt, context) => {
|
||||
const params = paramsFromContext(context)
|
||||
|
||||
// ★ 核心 API 调用(第 1823 行)
|
||||
// 使用 .create() + stream: true(而非 .stream())
|
||||
// 避免 BetaMessageStream 的 O(n²) partial JSON 解析开销
|
||||
const result = await anthropic.beta.messages
|
||||
.create(
|
||||
{ ...params, stream: true },
|
||||
{ signal, ...(clientRequestId && { headers: { ... } }) },
|
||||
)
|
||||
.withResponse()
|
||||
|
||||
return result.data // Stream<BetaRawMessageStreamEvent>
|
||||
},
|
||||
{ model, fallbackModel, thinkingConfig, signal, querySource }
|
||||
)
|
||||
|
||||
// 消费 withRetry 的系统错误消息(重试通知等)
|
||||
let e
|
||||
do {
|
||||
e = await generator.next()
|
||||
if (!('controller' in e.value)) yield e.value // yield API 错误消息
|
||||
} while (!e.done)
|
||||
stream = e.value // 获取最终的 Stream 对象
|
||||
|
||||
// 处理流式事件(第 1941 行)
|
||||
for await (const part of stream) {
|
||||
switch (part.type) {
|
||||
case 'message_start': // 记录 request_id、usage
|
||||
case 'content_block_start': // 新的内容块开始(text/thinking/tool_use)
|
||||
case 'content_block_delta': // 增量内容 → yield stream_event 给 UI
|
||||
case 'content_block_stop': // 内容块完成 → yield AssistantMessage
|
||||
case 'message_delta': // stop_reason、usage 更新
|
||||
case 'message_stop': // 整条消息完成
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 阶段 4:withRetry 重试策略
|
||||
|
||||
```
|
||||
withRetry 逻辑:
|
||||
├── 429 (Rate Limit) → 等待 Retry-After 后重试
|
||||
├── 529 (Overloaded) → 切换到 fallbackModel,throw FallbackTriggeredError
|
||||
├── 500 (Server Error) → 指数退避重试
|
||||
├── 408 (Timeout) → 重试
|
||||
├── 其他错误 → 不重试,直接抛出
|
||||
└── 最大重试次数: 根据模型和错误类型动态计算
|
||||
```
|
||||
|
||||
#### 阶段 5:非流式降级
|
||||
|
||||
当流式请求中途失败时,可能降级为非流式请求:
|
||||
|
||||
```
|
||||
流式失败(部分响应已收到):
|
||||
├── 已接收的内容 → yield 给上层
|
||||
├── 剩余部分 → 降级为非流式请求(anthropic.beta.messages.create)
|
||||
└── 非流式结果 → 转换格式 yield
|
||||
```
|
||||
|
||||
### 3.5 消息转换函数
|
||||
|
||||
```ts
|
||||
// UserMessage → API 格式
|
||||
userMessageToMessageParam(message, addCache, enablePromptCaching, querySource)
|
||||
→ { role: 'user', content: [...] }
|
||||
// addCache=true 时最后一个 content block 添加 cache_control
|
||||
|
||||
// AssistantMessage → API 格式
|
||||
assistantMessageToMessageParam(message, addCache, enablePromptCaching, querySource)
|
||||
→ { role: 'assistant', content: [...] }
|
||||
// thinking/redacted_thinking 块不加 cache_control
|
||||
```
|
||||
|
||||
### 3.6 Prompt Caching 策略
|
||||
|
||||
```
|
||||
缓存策略:
|
||||
├── cache_control: { type: 'ephemeral' } — 默认,5 分钟 TTL
|
||||
├── cache_control: { type: 'ephemeral', ttl: '1h' } — 订阅用户/Ant,1 小时
|
||||
├── cache_control: { ..., scope: 'global' } — 跨会话共享(无 MCP 工具时)
|
||||
└── 禁用条件:
|
||||
├── DISABLE_PROMPT_CACHING 环境变量
|
||||
├── DISABLE_PROMPT_CACHING_HAIKU(仅 Haiku)
|
||||
└── DISABLE_PROMPT_CACHING_SONNET(仅 Sonnet)
|
||||
```
|
||||
|
||||
### 3.7 多 Provider 支持
|
||||
|
||||
`getAnthropicClient()` 根据配置返回不同的 SDK 客户端:
|
||||
|
||||
| Provider | 入口 | 说明 |
|
||||
|----------|------|------|
|
||||
| Anthropic | 直接 API | 默认,`api.anthropic.com` |
|
||||
| AWS Bedrock | 通过 Bedrock | 使用 `@anthropic-ai/bedrock-sdk` |
|
||||
| Google Vertex | 通过 Vertex | 使用 `@anthropic-ai/vertex-sdk` |
|
||||
| Azure | 通过 Azure | 类似 Bedrock 的包装 |
|
||||
|
||||
Provider 选择逻辑在 `src/utils/model/providers.ts` 的 `getAPIProvider()` 中。
|
||||
|
||||
---
|
||||
|
||||
## 完整数据流:一次工具调用的生命周期
|
||||
|
||||
以用户输入 "读取 README.md" 为例:
|
||||
|
||||
```
|
||||
1. REPL.tsx: 用户按回车
|
||||
onSubmit("读取 README.md")
|
||||
└── handlePromptSubmit()
|
||||
└── onQuery([userMessage])
|
||||
|
||||
2. REPL.tsx: onQueryImpl()
|
||||
├── getSystemPrompt() + getUserContext() + getSystemContext()
|
||||
└── for await (event of query({messages, systemPrompt, ...}))
|
||||
|
||||
3. query.ts: queryLoop() — 第 1 次迭代
|
||||
├── messagesForQuery = [...messages] // 包含用户消息
|
||||
├── deps.callModel({...})
|
||||
│ └── claude.ts: queryModel()
|
||||
│ ├── 构建 API 参数
|
||||
│ └── anthropic.beta.messages.create({ ...params, stream: true })
|
||||
│
|
||||
├── API 流式返回:
|
||||
│ content_block_start: { type: 'tool_use', name: 'Read', id: 'toolu_123' }
|
||||
│ content_block_delta: { input: '{"file_path": "/path/to/README.md"}' }
|
||||
│ content_block_stop
|
||||
│ message_delta: { stop_reason: 'tool_use' }
|
||||
│
|
||||
├── 收集: toolUseBlocks = [{ name: 'Read', id: 'toolu_123', input: {...} }]
|
||||
├── needsFollowUp = true
|
||||
│
|
||||
├── 工具执行:
|
||||
│ streamingToolExecutor.getRemainingResults()
|
||||
│ └── Read 工具执行 → 返回文件内容
|
||||
│ yield toolResultMessage ← 包含文件内容
|
||||
│
|
||||
└── state = { messages: [...old, assistantMsg, toolResultMsg], turnCount: 2 }
|
||||
→ continue
|
||||
|
||||
4. query.ts: queryLoop() — 第 2 次迭代
|
||||
├── messagesForQuery 现在包含:
|
||||
│ [userMsg, assistantMsg(tool_use), userMsg(tool_result)]
|
||||
│
|
||||
├── deps.callModel({...}) ← 再次调用 API
|
||||
│
|
||||
├── API 返回:
|
||||
│ content_block_start: { type: 'text' }
|
||||
│ content_block_delta: { text: "README.md 的内容是..." }
|
||||
│ content_block_stop
|
||||
│ message_delta: { stop_reason: 'end_turn' }
|
||||
│
|
||||
├── toolUseBlocks = [] ← 没有工具调用
|
||||
├── needsFollowUp = false
|
||||
│
|
||||
└── return { reason: 'completed' } ★ 循环结束
|
||||
|
||||
5. REPL.tsx: onQueryEvent(event)
|
||||
├── 更新 streamingText(打字机效果)
|
||||
├── 更新 messages 数组
|
||||
└── 重新渲染 UI
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键设计模式总结
|
||||
|
||||
| 模式 | 位置 | 说明 |
|
||||
|------|------|------|
|
||||
| AsyncGenerator 链式传递 | query.ts → claude.ts | `yield*` 将底层事件透传给上层,形成事件流管道 |
|
||||
| while(true) + State 对象 | query.ts queryLoop | 循环迭代间通过不可变 State 传递,transition 字段记录原因 |
|
||||
| StreamingToolExecutor | query.ts | API 流式返回时并行执行工具,不等流结束 |
|
||||
| Withheld 消息 | query.ts | 可恢复错误先暂扣不 yield,恢复成功则吞掉错误 |
|
||||
| withRetry 重试 | claude.ts | 429/500/529 自动重试,529 触发模型降级 |
|
||||
| Prompt Caching | claude.ts | 缓存系统提示和历史消息,减少 API token 消耗 |
|
||||
| 非流式降级 | claude.ts | 流式请求中途失败时降级为非流式完成剩余部分 |
|
||||
| QueryEngine 包装 | QueryEngine.ts | 为 SDK/print 提供会话管理、持久化、usage 跟踪 |
|
||||
|
||||
## 需要忽略的代码
|
||||
|
||||
| 模式 | 说明 |
|
||||
|------|------|
|
||||
| `feature('REACTIVE_COMPACT')` / `feature('CONTEXT_COLLAPSE')` 等 | 所有 feature flag 保护的代码 — 全部是死代码 |
|
||||
| `feature('CACHED_MICROCOMPACT')` | 缓存微压缩 — 死代码 |
|
||||
| `feature('HISTORY_SNIP')` / `snipModule` | 历史截断 — 死代码 |
|
||||
| `feature('TOKEN_BUDGET')` / `budgetTracker` | 令牌预算 — 死代码 |
|
||||
| `feature('BG_SESSIONS')` / `taskSummaryModule` | 后台会话 — 死代码 |
|
||||
| `process.env.USER_TYPE === 'ant'` | Anthropic 内部专用代码 |
|
||||
| VCR (withStreamingVCR/withVCR) | 调试录像/回放包装器,不影响正常流程 |
|
||||
@@ -1,372 +0,0 @@
|
||||
# 第二阶段 Q&A
|
||||
|
||||
## Q1:query.ts 的流式消息处理具体是怎样的?
|
||||
|
||||
**核心问题**:`deps.callModel()` yield 出的每一条消息,在 `queryLoop()` 的 `for await` 循环体(L659-866)中具体经历了什么处理?
|
||||
|
||||
### 场景
|
||||
|
||||
用户说:**"帮我看看 package.json 的内容"**
|
||||
|
||||
模型回复:一段文字 "我来读取文件。" + 一个 Read 工具调用。
|
||||
|
||||
### callModel yield 的完整消息序列
|
||||
|
||||
claude.ts 的 `queryModel()` 会 yield 两种类型的消息:
|
||||
|
||||
| 类型标记 | 含义 | 产出时机 |
|
||||
|---------|------|---------|
|
||||
| `stream_event` | 原始 SSE 事件包装 | 每个 SSE 事件都产出一条 |
|
||||
| `assistant` | 完整的 AssistantMessage | 仅在 `content_block_stop` 时产出 |
|
||||
|
||||
本例中 callModel 依次 yield **共 13 条消息**:
|
||||
|
||||
```
|
||||
#1 { type: 'stream_event', event: { type: 'message_start', ... }, ttftMs: 342 }
|
||||
#2 { type: 'stream_event', event: { type: 'content_block_start', index: 0, content_block: { type: 'text' } } }
|
||||
#3 { type: 'stream_event', event: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: '我来' } } }
|
||||
#4 { type: 'stream_event', event: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: '读取文件。' } } }
|
||||
#5 { type: 'stream_event', event: { type: 'content_block_stop', index: 0 } }
|
||||
#6 { type: 'assistant', uuid: 'uuid-1', message: { content: [{ type: 'text', text: '我来读取文件。' }], stop_reason: null } }
|
||||
#7 { type: 'stream_event', event: { type: 'content_block_start', index: 1, content_block: { type: 'tool_use', id: 'toolu_001', name: 'Read' } } }
|
||||
#8 { type: 'stream_event', event: { type: 'content_block_delta', index: 1, delta: { type: 'input_json_delta', partial_json: '{"file_path":' } } }
|
||||
#9 { type: 'stream_event', event: { type: 'content_block_delta', index: 1, delta: { type: 'input_json_delta', partial_json: '"/path/package.json"}' } } }
|
||||
#10 { type: 'stream_event', event: { type: 'content_block_stop', index: 1 } }
|
||||
#11 { type: 'assistant', uuid: 'uuid-2', message: { content: [{ type: 'tool_use', id: 'toolu_001', name: 'Read', input: { file_path: '/path/package.json' } }], stop_reason: null } }
|
||||
#12 { type: 'stream_event', event: { type: 'message_delta', delta: { stop_reason: 'tool_use' }, usage: { output_tokens: 87 } } }
|
||||
#13 { type: 'stream_event', event: { type: 'message_stop' } }
|
||||
```
|
||||
|
||||
注意 `#6` 和 `#11` 是 **assistant 类型**(content_block_stop 时由 claude.ts 组装),其余全是 **stream_event 类型**。
|
||||
|
||||
### 循环体结构
|
||||
|
||||
循环体在 L708-866,结构如下:
|
||||
|
||||
```
|
||||
for await (const message of deps.callModel({...})) { // L659
|
||||
// A. 降级检查 (L712)
|
||||
// B. backfill (L747-789)
|
||||
// C. withheld 检查 (L801-824)
|
||||
// D. yield (L825-827)
|
||||
// E. assistant 收集 + addTool (L828-848)
|
||||
// F. getCompletedResults (L850-865)
|
||||
}
|
||||
```
|
||||
|
||||
### 逐条走循环体
|
||||
|
||||
#### #1 stream_event (message_start)
|
||||
|
||||
```
|
||||
A. L712: streamingFallbackOccured = false → 跳过
|
||||
|
||||
B. L748: message.type === 'assistant'?
|
||||
→ 'stream_event' !== 'assistant' → 跳过整个 backfill 块
|
||||
|
||||
C. L801-824: withheld 检查
|
||||
→ 不是 assistant 类型,各项检查均为 false → withheld = false
|
||||
|
||||
D. L825: yield message ✅ → 透传给 REPL(REPL 记录 ttftMs)
|
||||
|
||||
E. L828: message.type === 'assistant'? → 否 → 跳过
|
||||
|
||||
F. L850-854: streamingToolExecutor.getCompletedResults()
|
||||
→ tools 数组为空 → 无结果
|
||||
```
|
||||
|
||||
**净效果**:`yield` 透传。
|
||||
|
||||
---
|
||||
|
||||
#### #2 stream_event (content_block_start, type: text)
|
||||
|
||||
```
|
||||
A-C. 同 #1
|
||||
D. yield message ✅ → REPL 设置 spinner 为 "Responding..."
|
||||
E-F. 同 #1
|
||||
```
|
||||
|
||||
**净效果**:`yield` 透传。
|
||||
|
||||
---
|
||||
|
||||
#### #3 stream_event (text_delta: "我来")
|
||||
|
||||
```
|
||||
A-C. 同 #1
|
||||
D. yield message ✅ → REPL 追加 streamingText += "我来"(打字机效果)
|
||||
E-F. 同 #1
|
||||
```
|
||||
|
||||
**净效果**:`yield` 透传。
|
||||
|
||||
---
|
||||
|
||||
#### #4 stream_event (text_delta: "读取文件。")
|
||||
|
||||
```
|
||||
同 #3
|
||||
D. yield message ✅ → REPL streamingText += "读取文件。"
|
||||
```
|
||||
|
||||
**净效果**:`yield` 透传。
|
||||
|
||||
---
|
||||
|
||||
#### #5 stream_event (content_block_stop, index:0)
|
||||
|
||||
```
|
||||
同 #2
|
||||
D. yield message ✅ → REPL 无特殊操作(真正的 AssistantMessage 在下一条 #6)
|
||||
```
|
||||
|
||||
**净效果**:`yield` 透传。
|
||||
|
||||
---
|
||||
|
||||
#### #6 assistant (text block 完整消息) ★
|
||||
|
||||
第一条 `type: 'assistant'` 的消息,走**完全不同的路径**:
|
||||
|
||||
```
|
||||
A. L712: streamingFallbackOccured = false → 跳过
|
||||
|
||||
B. L748: message.type === 'assistant'? → ✅ 进入 backfill
|
||||
L750: contentArr = [{ type: 'text', text: '我来读取文件。' }]
|
||||
L752: for i=0: block.type === 'text'
|
||||
L754: block.type === 'tool_use'? → 否 → 跳过
|
||||
L783: clonedContent 为 undefined → yieldMessage = message(原样不变)
|
||||
|
||||
C. L801: let withheld = false
|
||||
L802: feature('CONTEXT_COLLAPSE') → false → 跳过
|
||||
L813: reactiveCompact?.isWithheldPromptTooLong(message) → 否 → false
|
||||
L822: isWithheldMaxOutputTokens(message)
|
||||
→ message.message.stop_reason === null → false
|
||||
→ withheld = false
|
||||
|
||||
D. L825: yield message ✅ → REPL 清除 streamingText,添加完整 text 消息到列表
|
||||
|
||||
E. L828: message.type === 'assistant'? → ✅
|
||||
L830: assistantMessages.push(message)
|
||||
→ assistantMessages = [uuid-1(text)]
|
||||
|
||||
L832-834: msgToolUseBlocks = content.filter(type === 'tool_use')
|
||||
→ [](这是 text block,没有 tool_use)
|
||||
|
||||
L835: length > 0? → 否 → 不设 needsFollowUp
|
||||
L844: msgToolUseBlocks 为空 → 不调用 addTool
|
||||
|
||||
F. L854: getCompletedResults() → 空
|
||||
```
|
||||
|
||||
**净效果**:`yield` 消息 + `assistantMessages` 增加一条。`needsFollowUp` 仍为 `false`。
|
||||
|
||||
---
|
||||
|
||||
#### #7 stream_event (content_block_start, tool_use: Read)
|
||||
|
||||
```
|
||||
A-C. 同 stream_event 通用路径
|
||||
D. yield message ✅ → REPL 设置 spinner 为 "tool-input",添加 streamingToolUse
|
||||
E. 不是 assistant → 跳过
|
||||
F. getCompletedResults() → 空
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### #8 stream_event (input_json_delta: '{"file_path":')
|
||||
|
||||
```
|
||||
D. yield message ✅ → REPL 追加工具输入 JSON 碎片
|
||||
F. getCompletedResults() → 空
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### #9 stream_event (input_json_delta: '"/path/package.json"}')
|
||||
|
||||
```
|
||||
D. yield message ✅
|
||||
F. getCompletedResults() → 空
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### #10 stream_event (content_block_stop, index:1)
|
||||
|
||||
```
|
||||
D. yield message ✅
|
||||
F. getCompletedResults() → 空
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### #11 assistant (tool_use block 完整消息) ★★
|
||||
|
||||
这条是**最关键的**——触发工具执行:
|
||||
|
||||
```
|
||||
A. L712: streamingFallbackOccured = false → 跳过
|
||||
|
||||
B. L748: message.type === 'assistant'? → ✅ 进入 backfill
|
||||
L750: contentArr = [{ type: 'tool_use', id: 'toolu_001', name: 'Read',
|
||||
input: { file_path: '/path/package.json' } }]
|
||||
L752: for i=0:
|
||||
L754: block.type === 'tool_use'? → ✅
|
||||
L756: typeof block.input === 'object' && !== null? → ✅
|
||||
L759: tool = findToolByName(tools, 'Read') → Read 工具定义
|
||||
L763: tool.backfillObservableInput 存在? → 假设存在
|
||||
L764-766: inputCopy = { file_path: '/path/package.json' }
|
||||
tool.backfillObservableInput(inputCopy)
|
||||
→ 可能添加 absolutePath 字段
|
||||
L773-776: addedFields? → 假设有新增字段
|
||||
clonedContent = [...contentArr]
|
||||
clonedContent[0] = { ...block, input: inputCopy }
|
||||
L783-788: yieldMessage = {
|
||||
...message, // uuid, type, timestamp 不变
|
||||
message: {
|
||||
...message.message, // stop_reason, usage 不变
|
||||
content: clonedContent // ★ 替换为带 absolutePath 的副本
|
||||
}
|
||||
}
|
||||
// ★ 原始 message 保持不变(回传 API 保证缓存一致)
|
||||
|
||||
C. L801-824: withheld 检查 → 全部 false → withheld = false
|
||||
|
||||
D. L825: yield yieldMessage ✅
|
||||
→ yield 的是克隆版(带 backfill 字段),给 REPL 和 SDK 用
|
||||
→ 原始 message 下面存进 assistantMessages,回传 API 保证缓存一致
|
||||
|
||||
E. L828: message.type === 'assistant'? → ✅
|
||||
L830: assistantMessages.push(message) // ★ push 原始 message,不是 yieldMessage
|
||||
→ assistantMessages = [uuid-1(text), uuid-2(tool_use)]
|
||||
|
||||
L832-834: msgToolUseBlocks = content.filter(type === 'tool_use')
|
||||
→ [{ type: 'tool_use', id: 'toolu_001', name: 'Read', input: {...} }]
|
||||
|
||||
L835: length > 0? → ✅
|
||||
L836: toolUseBlocks.push(...msgToolUseBlocks)
|
||||
→ toolUseBlocks = [Read_block]
|
||||
L837: needsFollowUp = true // ★★★ 决定 while(true) 不会终止
|
||||
|
||||
L840-842: streamingToolExecutor 存在 ✓ && !aborted ✓
|
||||
L844-846: for (const toolBlock of msgToolUseBlocks):
|
||||
streamingToolExecutor.addTool(Read_block, uuid-2消息)
|
||||
// ★★★ 工具开始执行!
|
||||
// → StreamingToolExecutor 内部:
|
||||
// isConcurrencySafe = true(Read 是安全的)
|
||||
// queued → processQueue() → canExecuteTool() → true
|
||||
// → executeTool() → runToolUse() → 后台异步读文件
|
||||
|
||||
F. L850-854: getCompletedResults()
|
||||
→ Read 刚开始执行,status = 'executing' → 无完成结果
|
||||
```
|
||||
|
||||
**净效果**:
|
||||
- `yield` 克隆消息(带 backfill 字段)
|
||||
- `assistantMessages` push 原始消息
|
||||
- `needsFollowUp = true`
|
||||
- **Read 工具在后台异步开始执行**
|
||||
|
||||
---
|
||||
|
||||
#### #12 stream_event (message_delta, stop_reason: 'tool_use')
|
||||
|
||||
```
|
||||
A-C. 同 stream_event 通用路径
|
||||
D. yield message ✅
|
||||
|
||||
E. 不是 assistant → 跳过
|
||||
|
||||
F. L854: getCompletedResults()
|
||||
→ ★ 此时 Read 可能已经完成了!(读文件通常 <1ms)
|
||||
→ 如果完成: status = 'completed', results 有值
|
||||
L428(StreamingToolExecutor): tool.status = 'yielded'
|
||||
L431-432: yield { message: UserMsg(tool_result) }
|
||||
→ 回到 query.ts:
|
||||
L855: result.message 存在
|
||||
L856: yield result.message ✅ → REPL 显示工具结果
|
||||
L857-862: toolResults.push(normalizeMessagesForAPI([result.message])...)
|
||||
→ toolResults = [Read 的 tool_result]
|
||||
```
|
||||
|
||||
**净效果**:`yield` stream_event + **可能 yield 工具结果**(如果工具已完成)。
|
||||
|
||||
---
|
||||
|
||||
#### #13 stream_event (message_stop)
|
||||
|
||||
```
|
||||
D. yield message ✅
|
||||
F. getCompletedResults()
|
||||
→ 如果 Read 在 #12 已被收割 → 空
|
||||
→ 如果 Read 此时才完成 → yield 工具结果(同 #12 的 F 逻辑)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### for await 循环退出后
|
||||
|
||||
```
|
||||
L1018: aborted? → false → 跳过
|
||||
|
||||
L1065: if (!needsFollowUp)
|
||||
→ needsFollowUp = true → 不进入 → 跳过终止逻辑
|
||||
|
||||
L1383: toolUpdates = streamingToolExecutor.getRemainingResults()
|
||||
→ 如果 Read 已在 #12/#13 被收割 → 立即返回空
|
||||
→ 如果 Read 还没完成 → 阻塞等待 → 完成后 yield 结果
|
||||
|
||||
L1387-1404: for await (const update of toolUpdates) {
|
||||
yield update.message → REPL 显示
|
||||
toolResults.push(...) → 收集
|
||||
}
|
||||
|
||||
L1718-1730: 构建 next State:
|
||||
state = {
|
||||
messages: [
|
||||
...messagesForQuery, // [UserMessage("帮我看看...")]
|
||||
...assistantMessages, // [AssistantMsg(text), AssistantMsg(tool_use)]
|
||||
...toolResults, // [UserMsg(tool_result)]
|
||||
],
|
||||
turnCount: 1,
|
||||
transition: { reason: 'next_turn' },
|
||||
}
|
||||
→ continue → while(true) 第 2 次迭代 → 带着工具结果再次调 API
|
||||
```
|
||||
|
||||
### 循环体判定树总结
|
||||
|
||||
```
|
||||
for await (const message of deps.callModel(...)) {
|
||||
│
|
||||
├─ message.type === 'stream_event'?
|
||||
│ │
|
||||
│ └─ YES → 几乎零操作
|
||||
│ ├─ yield message(透传给 REPL 做实时 UI)
|
||||
│ └─ getCompletedResults()(顺便检查有没有完成的工具)
|
||||
│
|
||||
└─ message.type === 'assistant'?
|
||||
│
|
||||
├─ B. backfill: 有 tool_use + backfillObservableInput?
|
||||
│ ├─ YES → 克隆消息,yield 克隆版(原始消息保留给 API)
|
||||
│ └─ NO → yield 原始消息
|
||||
│
|
||||
├─ C. withheld: prompt_too_long / max_output_tokens?
|
||||
│ ├─ YES → 不 yield(暂扣,等后面恢复逻辑处理)
|
||||
│ └─ NO → yield
|
||||
│
|
||||
├─ E. assistantMessages.push(原始 message)
|
||||
│
|
||||
├─ E. 有 tool_use block?
|
||||
│ ├─ YES → toolUseBlocks.push()
|
||||
│ │ + needsFollowUp = true
|
||||
│ │ + streamingToolExecutor.addTool() → ★ 立即开始执行工具
|
||||
│ └─ NO → 什么都不做
|
||||
│
|
||||
└─ F. getCompletedResults() → 收割已完成的工具结果
|
||||
}
|
||||
```
|
||||
|
||||
**一句话总结**:stream_event 透传不处理;assistant 消息才是"真正的货"——收集起来、判断要不要暂扣、有工具就立即开始执行、顺便收割已完成的工具结果。
|
||||
91
package.json
91
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-code-best",
|
||||
"version": "1.6.0",
|
||||
"version": "2.2.1",
|
||||
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
|
||||
"type": "module",
|
||||
"author": "claude-code-best <claude-code-best@proton.me>",
|
||||
@@ -22,7 +22,7 @@
|
||||
"repl"
|
||||
],
|
||||
"engines": {
|
||||
"bun": ">=1.2.0"
|
||||
"bun": ">=1.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"ccb": "dist/cli-node.js",
|
||||
@@ -47,44 +47,53 @@
|
||||
"build:bun": "bun run build.ts",
|
||||
"dev": "bun run scripts/dev.ts",
|
||||
"dev:inspect": "bun run scripts/dev-debug.ts",
|
||||
"prepublishOnly": "bun run build",
|
||||
"lint": "biome lint src/",
|
||||
"lint:fix": "biome lint --fix src/",
|
||||
"format": "biome format --write src/",
|
||||
"prepare": "git config core.hooksPath .githooks",
|
||||
"prepublishOnly": "bun run build:vite",
|
||||
"lint": "biome lint .",
|
||||
"lint:fix": "biome lint --fix .",
|
||||
"format": "biome format --write .",
|
||||
"check": "biome check .",
|
||||
"check:fix": "biome check --fix .",
|
||||
"prepare": "husky",
|
||||
"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",
|
||||
"postinstall": "node scripts/run-parallel.mjs scripts/postinstall.cjs scripts/setup-chrome-mcp.mjs",
|
||||
"docs:dev": "npx mintlify dev",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"precheck": "bun run typecheck && bun run check:fix && bun test",
|
||||
"rcs": "bun run scripts/rcs.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.19.0",
|
||||
"@claude-code-best/mcp-chrome-bridge": "^2.0.8",
|
||||
"@claude-code-best/mcp-chrome-bridge": "^3.0.1",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@alcalzone/ansi-tokenize": "^0.3.0",
|
||||
"@ant/model-provider": "workspace:*",
|
||||
"@ant/claude-for-chrome-mcp": "workspace:*",
|
||||
"@ant/computer-use-input": "workspace:*",
|
||||
"@ant/computer-use-mcp": "workspace:*",
|
||||
"@ant/computer-use-swift": "workspace:*",
|
||||
"@anthropic-ai/bedrock-sdk": "^0.26.4",
|
||||
"@ant/model-provider": "workspace:*",
|
||||
"@anthropic-ai/bedrock-sdk": "^0.29.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.114",
|
||||
"@anthropic-ai/foundry-sdk": "^0.2.3",
|
||||
"@anthropic-ai/mcpb": "^2.1.2",
|
||||
"@anthropic-ai/sandbox-runtime": "^0.0.44",
|
||||
"@anthropic-ai/sdk": "^0.80.0",
|
||||
"@anthropic-ai/vertex-sdk": "^0.14.4",
|
||||
"@anthropic-ai/sdk": "^0.81.0",
|
||||
"@anthropic-ai/vertex-sdk": "^0.16.0",
|
||||
"@anthropic/ink": "workspace:*",
|
||||
"@aws-sdk/client-bedrock": "^3.1032.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.1032.0",
|
||||
"@aws-sdk/client-sts": "^3.1032.0",
|
||||
"@aws-sdk/credential-provider-node": "^3.972.32",
|
||||
"@aws-sdk/credential-providers": "^3.1032.0",
|
||||
"@aws-sdk/client-bedrock": "^3.1037.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.1037.0",
|
||||
"@aws-sdk/client-sts": "^3.1037.0",
|
||||
"@aws-sdk/credential-provider-node": "^3.972.36",
|
||||
"@aws-sdk/credential-providers": "^3.1037.0",
|
||||
"@azure/identity": "^4.13.1",
|
||||
"@biomejs/biome": "^2.4.12",
|
||||
"@claude-code-best/agent-tools": "workspace:*",
|
||||
@@ -97,20 +106,20 @@
|
||||
"@langfuse/tracing": "^5.1.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/api-logs": "^0.214.0",
|
||||
"@opentelemetry/api-logs": "^0.215.0",
|
||||
"@opentelemetry/core": "^2.7.0",
|
||||
"@opentelemetry/exporter-logs-otlp-grpc": "^0.214.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
|
||||
"@opentelemetry/exporter-logs-otlp-proto": "^0.214.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-grpc": "^0.214.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "^0.214.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-proto": "^0.214.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.214.0",
|
||||
"@opentelemetry/exporter-trace-otlp-grpc": "^0.214.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
|
||||
"@opentelemetry/exporter-trace-otlp-proto": "^0.214.0",
|
||||
"@opentelemetry/exporter-logs-otlp-grpc": "^0.215.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.215.0",
|
||||
"@opentelemetry/exporter-logs-otlp-proto": "^0.215.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-grpc": "^0.215.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "^0.215.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-proto": "^0.215.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.215.0",
|
||||
"@opentelemetry/exporter-trace-otlp-grpc": "^0.215.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.215.0",
|
||||
"@opentelemetry/exporter-trace-otlp-proto": "^0.215.0",
|
||||
"@opentelemetry/resources": "^2.7.0",
|
||||
"@opentelemetry/sdk-logs": "^0.214.0",
|
||||
"@opentelemetry/sdk-logs": "^0.215.0",
|
||||
"@opentelemetry/sdk-metrics": "^2.7.0",
|
||||
"@opentelemetry/sdk-trace-base": "^2.7.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.40.0",
|
||||
@@ -138,7 +147,7 @@
|
||||
"asciichart": "^1.5.25",
|
||||
"audio-capture-napi": "workspace:*",
|
||||
"auto-bind": "^5.0.1",
|
||||
"axios": "^1.15.0",
|
||||
"axios": "^1.15.2",
|
||||
"bidi-js": "^1.0.3",
|
||||
"cacache": "^20.0.4",
|
||||
"chalk": "^5.6.2",
|
||||
@@ -157,13 +166,14 @@
|
||||
"get-east-asian-width": "^1.5.0",
|
||||
"google-auth-library": "^10.6.2",
|
||||
"he": "^1.2.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"https-proxy-agent": "^8.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"ignore": "^7.0.5",
|
||||
"image-processor-napi": "workspace:*",
|
||||
"indent-string": "^5.0.0",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"knip": "^6.4.1",
|
||||
"lint-staged": "^16.4.0",
|
||||
"lodash-es": "^4.18.1",
|
||||
"lru-cache": "^11.3.5",
|
||||
"marked": "^17.0.6",
|
||||
@@ -200,5 +210,24 @@
|
||||
"xss": "^1.0.15",
|
||||
"yaml": "^2.8.3",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"doubaoime-asr": "^0.1.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@inquirer/prompts": "8.4.2",
|
||||
"@xmldom/xmldom": "0.8.13",
|
||||
"follow-redirects": "1.16.0",
|
||||
"hono": "4.12.15",
|
||||
"postcss": "8.5.10",
|
||||
"uuid": "14.0.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,js,mjs,jsx}": [
|
||||
"biome check --fix --no-errors-on-unmatched"
|
||||
],
|
||||
"*.{json,jsonc}": [
|
||||
"biome format --write --no-errors-on-unmatched"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "@ant/claude-for-chrome-mcp",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts"
|
||||
"name": "@ant/claude-for-chrome-mcp",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,546 +1,546 @@
|
||||
export const BROWSER_TOOLS = [
|
||||
{
|
||||
name: "javascript_tool",
|
||||
name: 'javascript_tool',
|
||||
description:
|
||||
"Execute JavaScript code in the context of the current page. The code runs in the page's context and can interact with the DOM, window object, and page variables. Returns the result of the last expression or any thrown errors. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: {
|
||||
type: "string",
|
||||
type: 'string',
|
||||
description: "Must be set to 'javascript_exec'",
|
||||
},
|
||||
text: {
|
||||
type: "string",
|
||||
type: 'string',
|
||||
description:
|
||||
"The JavaScript code to execute. The code will be evaluated in the page context. The result of the last expression will be returned automatically. Do NOT use 'return' statements - just write the expression you want to evaluate (e.g., 'window.myData.value' not 'return window.myData.value'). You can access and modify the DOM, call page functions, and interact with page variables.",
|
||||
},
|
||||
tabId: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
description:
|
||||
"Tab ID to execute the code in. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
|
||||
},
|
||||
},
|
||||
required: ["action", "text", "tabId"],
|
||||
required: ['action', 'text', 'tabId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "read_page",
|
||||
name: 'read_page',
|
||||
description:
|
||||
"Get an accessibility tree representation of elements on the page. By default returns all elements including non-visible ones. Output is limited to 50000 characters by default. If the output exceeds this limit, you will receive an error asking you to specify a smaller depth or focus on a specific element using ref_id. Optionally filter for only interactive elements. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
filter: {
|
||||
type: "string",
|
||||
enum: ["interactive", "all"],
|
||||
type: 'string',
|
||||
enum: ['interactive', 'all'],
|
||||
description:
|
||||
'Filter elements: "interactive" for buttons/links/inputs only, "all" for all elements including non-visible ones (default: all elements)',
|
||||
},
|
||||
tabId: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
description:
|
||||
"Tab ID to read from. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
|
||||
},
|
||||
depth: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
description:
|
||||
"Maximum depth of the tree to traverse (default: 15). Use a smaller depth if output is too large.",
|
||||
'Maximum depth of the tree to traverse (default: 15). Use a smaller depth if output is too large.',
|
||||
},
|
||||
ref_id: {
|
||||
type: "string",
|
||||
type: 'string',
|
||||
description:
|
||||
"Reference ID of a parent element to read. Will return the specified element and all its children. Use this to focus on a specific part of the page when output is too large.",
|
||||
'Reference ID of a parent element to read. Will return the specified element and all its children. Use this to focus on a specific part of the page when output is too large.',
|
||||
},
|
||||
max_chars: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
description:
|
||||
"Maximum characters for output (default: 50000). Set to a higher value if your client can handle large outputs.",
|
||||
'Maximum characters for output (default: 50000). Set to a higher value if your client can handle large outputs.',
|
||||
},
|
||||
},
|
||||
required: ["tabId"],
|
||||
required: ['tabId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "find",
|
||||
name: 'find',
|
||||
description:
|
||||
'Find elements on the page using natural language. Can search for elements by their purpose (e.g., "search bar", "login button") or by text content (e.g., "organic mango product"). Returns up to 20 matching elements with references that can be used with other tools. If more than 20 matches exist, you\'ll be notified to use a more specific query. If you don\'t have a valid tab ID, use tabs_context_mcp first to get available tabs.',
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: "string",
|
||||
type: 'string',
|
||||
description:
|
||||
'Natural language description of what to find (e.g., "search bar", "add to cart button", "product title containing organic")',
|
||||
},
|
||||
tabId: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
description:
|
||||
"Tab ID to search in. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
|
||||
},
|
||||
},
|
||||
required: ["query", "tabId"],
|
||||
required: ['query', 'tabId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "form_input",
|
||||
name: 'form_input',
|
||||
description:
|
||||
"Set values in form elements using element reference ID from the read_page tool. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
ref: {
|
||||
type: "string",
|
||||
type: 'string',
|
||||
description:
|
||||
'Element reference ID from the read_page tool (e.g., "ref_1", "ref_2")',
|
||||
},
|
||||
value: {
|
||||
type: ["string", "boolean", "number"],
|
||||
type: ['string', 'boolean', 'number'],
|
||||
description:
|
||||
"The value to set. For checkboxes use boolean, for selects use option value or text, for other inputs use appropriate string/number",
|
||||
'The value to set. For checkboxes use boolean, for selects use option value or text, for other inputs use appropriate string/number',
|
||||
},
|
||||
tabId: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
description:
|
||||
"Tab ID to set form value in. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
|
||||
},
|
||||
},
|
||||
required: ["ref", "value", "tabId"],
|
||||
required: ['ref', 'value', 'tabId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "computer",
|
||||
name: 'computer',
|
||||
description: `Use a mouse and keyboard to interact with a web browser, and take screenshots. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.\n* Whenever you intend to click on an element like an icon, you should consult a screenshot to determine the coordinates of the element before moving the cursor.\n* If you tried clicking on a program or link but it failed to load, even after waiting, try adjusting your click location so that the tip of the cursor visually falls on the element that you want to click.\n* Make sure to click any buttons, links, icons, etc with the cursor tip in the center of the element. Don't click boxes on their edges unless asked.`,
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: {
|
||||
type: "string",
|
||||
type: 'string',
|
||||
enum: [
|
||||
"left_click",
|
||||
"right_click",
|
||||
"type",
|
||||
"screenshot",
|
||||
"wait",
|
||||
"scroll",
|
||||
"key",
|
||||
"left_click_drag",
|
||||
"double_click",
|
||||
"triple_click",
|
||||
"zoom",
|
||||
"scroll_to",
|
||||
"hover",
|
||||
'left_click',
|
||||
'right_click',
|
||||
'type',
|
||||
'screenshot',
|
||||
'wait',
|
||||
'scroll',
|
||||
'key',
|
||||
'left_click_drag',
|
||||
'double_click',
|
||||
'triple_click',
|
||||
'zoom',
|
||||
'scroll_to',
|
||||
'hover',
|
||||
],
|
||||
description:
|
||||
"The action to perform:\n* `left_click`: Click the left mouse button at the specified coordinates.\n* `right_click`: Click the right mouse button at the specified coordinates to open context menus.\n* `double_click`: Double-click the left mouse button at the specified coordinates.\n* `triple_click`: Triple-click the left mouse button at the specified coordinates.\n* `type`: Type a string of text.\n* `screenshot`: Take a screenshot of the screen.\n* `wait`: Wait for a specified number of seconds.\n* `scroll`: Scroll up, down, left, or right at the specified coordinates.\n* `key`: Press a specific keyboard key.\n* `left_click_drag`: Drag from start_coordinate to coordinate.\n* `zoom`: Take a screenshot of a specific region for closer inspection.\n* `scroll_to`: Scroll an element into view using its element reference ID from read_page or find tools.\n* `hover`: Move the mouse cursor to the specified coordinates or element without clicking. Useful for revealing tooltips, dropdown menus, or triggering hover states.",
|
||||
'The action to perform:\n* `left_click`: Click the left mouse button at the specified coordinates.\n* `right_click`: Click the right mouse button at the specified coordinates to open context menus.\n* `double_click`: Double-click the left mouse button at the specified coordinates.\n* `triple_click`: Triple-click the left mouse button at the specified coordinates.\n* `type`: Type a string of text.\n* `screenshot`: Take a screenshot of the screen.\n* `wait`: Wait for a specified number of seconds.\n* `scroll`: Scroll up, down, left, or right at the specified coordinates.\n* `key`: Press a specific keyboard key.\n* `left_click_drag`: Drag from start_coordinate to coordinate.\n* `zoom`: Take a screenshot of a specific region for closer inspection.\n* `scroll_to`: Scroll an element into view using its element reference ID from read_page or find tools.\n* `hover`: Move the mouse cursor to the specified coordinates or element without clicking. Useful for revealing tooltips, dropdown menus, or triggering hover states.',
|
||||
},
|
||||
coordinate: {
|
||||
type: "array",
|
||||
items: { type: "number" },
|
||||
type: 'array',
|
||||
items: { type: 'number' },
|
||||
minItems: 2,
|
||||
maxItems: 2,
|
||||
description:
|
||||
"(x, y): The x (pixels from the left edge) and y (pixels from the top edge) coordinates. Required for `left_click`, `right_click`, `double_click`, `triple_click`, and `scroll`. For `left_click_drag`, this is the end position.",
|
||||
'(x, y): The x (pixels from the left edge) and y (pixels from the top edge) coordinates. Required for `left_click`, `right_click`, `double_click`, `triple_click`, and `scroll`. For `left_click_drag`, this is the end position.',
|
||||
},
|
||||
text: {
|
||||
type: "string",
|
||||
type: 'string',
|
||||
description:
|
||||
'The text to type (for `type` action) or the key(s) to press (for `key` action). For `key` action: Provide space-separated keys (e.g., "Backspace Backspace Delete"). Supports keyboard shortcuts using the platform\'s modifier key (use "cmd" on Mac, "ctrl" on Windows/Linux, e.g., "cmd+a" or "ctrl+a" for select all).',
|
||||
},
|
||||
duration: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
minimum: 0,
|
||||
maximum: 30,
|
||||
description:
|
||||
"The number of seconds to wait. Required for `wait`. Maximum 30 seconds.",
|
||||
'The number of seconds to wait. Required for `wait`. Maximum 30 seconds.',
|
||||
},
|
||||
scroll_direction: {
|
||||
type: "string",
|
||||
enum: ["up", "down", "left", "right"],
|
||||
description: "The direction to scroll. Required for `scroll`.",
|
||||
type: 'string',
|
||||
enum: ['up', 'down', 'left', 'right'],
|
||||
description: 'The direction to scroll. Required for `scroll`.',
|
||||
},
|
||||
scroll_amount: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
minimum: 1,
|
||||
maximum: 10,
|
||||
description:
|
||||
"The number of scroll wheel ticks. Optional for `scroll`, defaults to 3.",
|
||||
'The number of scroll wheel ticks. Optional for `scroll`, defaults to 3.',
|
||||
},
|
||||
start_coordinate: {
|
||||
type: "array",
|
||||
items: { type: "number" },
|
||||
type: 'array',
|
||||
items: { type: 'number' },
|
||||
minItems: 2,
|
||||
maxItems: 2,
|
||||
description:
|
||||
"(x, y): The starting coordinates for `left_click_drag`.",
|
||||
'(x, y): The starting coordinates for `left_click_drag`.',
|
||||
},
|
||||
region: {
|
||||
type: "array",
|
||||
items: { type: "number" },
|
||||
type: 'array',
|
||||
items: { type: 'number' },
|
||||
minItems: 4,
|
||||
maxItems: 4,
|
||||
description:
|
||||
"(x0, y0, x1, y1): The rectangular region to capture for `zoom`. Coordinates define a rectangle from top-left (x0, y0) to bottom-right (x1, y1) in pixels from the viewport origin. Required for `zoom` action. Useful for inspecting small UI elements like icons, buttons, or text.",
|
||||
'(x0, y0, x1, y1): The rectangular region to capture for `zoom`. Coordinates define a rectangle from top-left (x0, y0) to bottom-right (x1, y1) in pixels from the viewport origin. Required for `zoom` action. Useful for inspecting small UI elements like icons, buttons, or text.',
|
||||
},
|
||||
repeat: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
minimum: 1,
|
||||
maximum: 100,
|
||||
description:
|
||||
"Number of times to repeat the key sequence. Only applicable for `key` action. Must be a positive integer between 1 and 100. Default is 1. Useful for navigation tasks like pressing arrow keys multiple times.",
|
||||
'Number of times to repeat the key sequence. Only applicable for `key` action. Must be a positive integer between 1 and 100. Default is 1. Useful for navigation tasks like pressing arrow keys multiple times.',
|
||||
},
|
||||
ref: {
|
||||
type: "string",
|
||||
type: 'string',
|
||||
description:
|
||||
'Element reference ID from read_page or find tools (e.g., "ref_1", "ref_2"). Required for `scroll_to` action. Can be used as alternative to `coordinate` for click actions.',
|
||||
},
|
||||
modifiers: {
|
||||
type: "string",
|
||||
type: 'string',
|
||||
description:
|
||||
'Modifier keys for click actions. Supports: "ctrl", "shift", "alt", "cmd" (or "meta"), "win" (or "windows"). Can be combined with "+" (e.g., "ctrl+shift", "cmd+alt"). Optional.',
|
||||
},
|
||||
tabId: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
description:
|
||||
"Tab ID to execute the action on. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
|
||||
},
|
||||
},
|
||||
required: ["action", "tabId"],
|
||||
required: ['action', 'tabId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "navigate",
|
||||
name: 'navigate',
|
||||
description:
|
||||
"Navigate to a URL, or go forward/back in browser history. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: {
|
||||
type: "string",
|
||||
type: 'string',
|
||||
description:
|
||||
'The URL to navigate to. Can be provided with or without protocol (defaults to https://). Use "forward" to go forward in history or "back" to go back in history.',
|
||||
},
|
||||
tabId: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
description:
|
||||
"Tab ID to navigate. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
|
||||
},
|
||||
},
|
||||
required: ["url", "tabId"],
|
||||
required: ['url', 'tabId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "resize_window",
|
||||
name: 'resize_window',
|
||||
description:
|
||||
"Resize the current browser window to specified dimensions. Useful for testing responsive designs or setting up specific screen sizes. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
width: {
|
||||
type: "number",
|
||||
description: "Target window width in pixels",
|
||||
type: 'number',
|
||||
description: 'Target window width in pixels',
|
||||
},
|
||||
height: {
|
||||
type: "number",
|
||||
description: "Target window height in pixels",
|
||||
type: 'number',
|
||||
description: 'Target window height in pixels',
|
||||
},
|
||||
tabId: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
description:
|
||||
"Tab ID to get the window for. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
|
||||
},
|
||||
},
|
||||
required: ["width", "height", "tabId"],
|
||||
required: ['width', 'height', 'tabId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gif_creator",
|
||||
name: 'gif_creator',
|
||||
description:
|
||||
"Manage GIF recording and export for browser automation sessions. Control when to start/stop recording browser actions (clicks, scrolls, navigation), then export as an animated GIF with visual overlays (click indicators, action labels, progress bar, watermark). All operations are scoped to the tab's group. When starting recording, take a screenshot immediately after to capture the initial state as the first frame. When stopping recording, take a screenshot immediately before to capture the final state as the last frame. For export, either provide 'coordinate' to drag/drop upload to a page element, or set 'download: true' to download the GIF.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: {
|
||||
type: "string",
|
||||
enum: ["start_recording", "stop_recording", "export", "clear"],
|
||||
type: 'string',
|
||||
enum: ['start_recording', 'stop_recording', 'export', 'clear'],
|
||||
description:
|
||||
"Action to perform: 'start_recording' (begin capturing), 'stop_recording' (stop capturing but keep frames), 'export' (generate and export GIF), 'clear' (discard frames)",
|
||||
},
|
||||
tabId: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
description:
|
||||
"Tab ID to identify which tab group this operation applies to",
|
||||
'Tab ID to identify which tab group this operation applies to',
|
||||
},
|
||||
download: {
|
||||
type: "boolean",
|
||||
type: 'boolean',
|
||||
description:
|
||||
"Always set this to true for the 'export' action only. This causes the gif to be downloaded in the browser.",
|
||||
},
|
||||
filename: {
|
||||
type: "string",
|
||||
type: 'string',
|
||||
description:
|
||||
"Optional filename for exported GIF (default: 'recording-[timestamp].gif'). For 'export' action only.",
|
||||
},
|
||||
options: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
description:
|
||||
"Optional GIF enhancement options for 'export' action. Properties: showClickIndicators (bool), showDragPaths (bool), showActionLabels (bool), showProgressBar (bool), showWatermark (bool), quality (number 1-30). All default to true except quality (default: 10).",
|
||||
properties: {
|
||||
showClickIndicators: {
|
||||
type: "boolean",
|
||||
type: 'boolean',
|
||||
description:
|
||||
"Show orange circles at click locations (default: true)",
|
||||
'Show orange circles at click locations (default: true)',
|
||||
},
|
||||
showDragPaths: {
|
||||
type: "boolean",
|
||||
description: "Show red arrows for drag actions (default: true)",
|
||||
type: 'boolean',
|
||||
description: 'Show red arrows for drag actions (default: true)',
|
||||
},
|
||||
showActionLabels: {
|
||||
type: "boolean",
|
||||
type: 'boolean',
|
||||
description:
|
||||
"Show black labels describing actions (default: true)",
|
||||
'Show black labels describing actions (default: true)',
|
||||
},
|
||||
showProgressBar: {
|
||||
type: "boolean",
|
||||
description: "Show orange progress bar at bottom (default: true)",
|
||||
type: 'boolean',
|
||||
description: 'Show orange progress bar at bottom (default: true)',
|
||||
},
|
||||
showWatermark: {
|
||||
type: "boolean",
|
||||
description: "Show Claude logo watermark (default: true)",
|
||||
type: 'boolean',
|
||||
description: 'Show Claude logo watermark (default: true)',
|
||||
},
|
||||
quality: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
description:
|
||||
"GIF compression quality, 1-30 (lower = better quality, slower encoding). Default: 10",
|
||||
'GIF compression quality, 1-30 (lower = better quality, slower encoding). Default: 10',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["action", "tabId"],
|
||||
required: ['action', 'tabId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "upload_image",
|
||||
name: 'upload_image',
|
||||
description:
|
||||
"Upload a previously captured screenshot or user-uploaded image to a file input or drag & drop target. Supports two approaches: (1) ref - for targeting specific elements, especially hidden file inputs, (2) coordinate - for drag & drop to visible locations like Google Docs. Provide either ref or coordinate, not both.",
|
||||
'Upload a previously captured screenshot or user-uploaded image to a file input or drag & drop target. Supports two approaches: (1) ref - for targeting specific elements, especially hidden file inputs, (2) coordinate - for drag & drop to visible locations like Google Docs. Provide either ref or coordinate, not both.',
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
imageId: {
|
||||
type: "string",
|
||||
type: 'string',
|
||||
description:
|
||||
"ID of a previously captured screenshot (from the computer tool's screenshot action) or a user-uploaded image",
|
||||
},
|
||||
ref: {
|
||||
type: "string",
|
||||
type: 'string',
|
||||
description:
|
||||
'Element reference ID from read_page or find tools (e.g., "ref_1", "ref_2"). Use this for file inputs (especially hidden ones) or specific elements. Provide either ref or coordinate, not both.',
|
||||
},
|
||||
coordinate: {
|
||||
type: "array",
|
||||
type: 'array',
|
||||
items: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
},
|
||||
description:
|
||||
"Viewport coordinates [x, y] for drag & drop to a visible location. Use this for drag & drop targets like Google Docs. Provide either ref or coordinate, not both.",
|
||||
'Viewport coordinates [x, y] for drag & drop to a visible location. Use this for drag & drop targets like Google Docs. Provide either ref or coordinate, not both.',
|
||||
},
|
||||
tabId: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
description:
|
||||
"Tab ID where the target element is located. This is where the image will be uploaded to.",
|
||||
'Tab ID where the target element is located. This is where the image will be uploaded to.',
|
||||
},
|
||||
filename: {
|
||||
type: "string",
|
||||
type: 'string',
|
||||
description:
|
||||
'Optional filename for the uploaded file (default: "image.png")',
|
||||
},
|
||||
},
|
||||
required: ["imageId", "tabId"],
|
||||
required: ['imageId', 'tabId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_page_text",
|
||||
name: 'get_page_text',
|
||||
description:
|
||||
"Extract raw text content from the page, prioritizing article content. Ideal for reading articles, blog posts, or other text-heavy pages. Returns plain text without HTML formatting. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
tabId: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
description:
|
||||
"Tab ID to extract text from. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
|
||||
},
|
||||
},
|
||||
required: ["tabId"],
|
||||
required: ['tabId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tabs_context_mcp",
|
||||
title: "Tabs Context",
|
||||
name: 'tabs_context_mcp',
|
||||
title: 'Tabs Context',
|
||||
description:
|
||||
"Get context information about the current MCP tab group. Returns all tab IDs inside the group if it exists. CRITICAL: You must get the context at least once before using other browser automation tools so you know what tabs exist. Each new conversation should create its own new tab (using tabs_create_mcp) rather than reusing existing tabs, unless the user explicitly asks to use an existing tab.",
|
||||
'Get context information about the current MCP tab group. Returns all tab IDs inside the group if it exists. CRITICAL: You must get the context at least once before using other browser automation tools so you know what tabs exist. Each new conversation should create its own new tab (using tabs_create_mcp) rather than reusing existing tabs, unless the user explicitly asks to use an existing tab.',
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
createIfEmpty: {
|
||||
type: "boolean",
|
||||
type: 'boolean',
|
||||
description:
|
||||
"Creates a new MCP tab group if none exists, creates a new Window with a new tab group containing an empty tab (which can be used for this conversation). If a MCP tab group already exists, this parameter has no effect.",
|
||||
'Creates a new MCP tab group if none exists, creates a new Window with a new tab group containing an empty tab (which can be used for this conversation). If a MCP tab group already exists, this parameter has no effect.',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tabs_create_mcp",
|
||||
title: "Tabs Create",
|
||||
name: 'tabs_create_mcp',
|
||||
title: 'Tabs Create',
|
||||
description:
|
||||
"Creates a new empty tab in the MCP tab group. CRITICAL: You must get the context using tabs_context_mcp at least once before using other browser automation tools so you know what tabs exist.",
|
||||
'Creates a new empty tab in the MCP tab group. CRITICAL: You must get the context using tabs_context_mcp at least once before using other browser automation tools so you know what tabs exist.',
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update_plan",
|
||||
name: 'update_plan',
|
||||
description:
|
||||
"Present a plan to the user for approval before taking actions. The user will see the domains you intend to visit and your approach. Once approved, you can proceed with actions on the approved domains without additional permission prompts.",
|
||||
'Present a plan to the user for approval before taking actions. The user will see the domains you intend to visit and your approach. Once approved, you can proceed with actions on the approved domains without additional permission prompts.',
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
domains: {
|
||||
type: "array" as const,
|
||||
items: { type: "string" as const },
|
||||
type: 'array' as const,
|
||||
items: { type: 'string' as const },
|
||||
description:
|
||||
"List of domains you will visit (e.g., ['github.com', 'stackoverflow.com']). These domains will be approved for the session when the user accepts the plan.",
|
||||
},
|
||||
approach: {
|
||||
type: "array" as const,
|
||||
items: { type: "string" as const },
|
||||
type: 'array' as const,
|
||||
items: { type: 'string' as const },
|
||||
description:
|
||||
"High-level description of what you will do. Focus on outcomes and key actions, not implementation details. Be concise - aim for 3-7 items.",
|
||||
'High-level description of what you will do. Focus on outcomes and key actions, not implementation details. Be concise - aim for 3-7 items.',
|
||||
},
|
||||
},
|
||||
required: ["domains", "approach"],
|
||||
required: ['domains', 'approach'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "read_console_messages",
|
||||
name: 'read_console_messages',
|
||||
description:
|
||||
"Read browser console messages (console.log, console.error, console.warn, etc.) from a specific tab. Useful for debugging JavaScript errors, viewing application logs, or understanding what's happening in the browser console. Returns console messages from the current domain only. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs. IMPORTANT: Always provide a pattern to filter messages - without a pattern, you may get too many irrelevant messages.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
tabId: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
description:
|
||||
"Tab ID to read console messages from. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
|
||||
},
|
||||
onlyErrors: {
|
||||
type: "boolean",
|
||||
type: 'boolean',
|
||||
description:
|
||||
"If true, only return error and exception messages. Default is false (return all message types).",
|
||||
'If true, only return error and exception messages. Default is false (return all message types).',
|
||||
},
|
||||
clear: {
|
||||
type: "boolean",
|
||||
type: 'boolean',
|
||||
description:
|
||||
"If true, clear the console messages after reading to avoid duplicates on subsequent calls. Default is false.",
|
||||
'If true, clear the console messages after reading to avoid duplicates on subsequent calls. Default is false.',
|
||||
},
|
||||
pattern: {
|
||||
type: "string",
|
||||
type: 'string',
|
||||
description:
|
||||
"Regex pattern to filter console messages. Only messages matching this pattern will be returned (e.g., 'error|warning' to find errors and warnings, 'MyApp' to filter app-specific logs). You should always provide a pattern to avoid getting too many irrelevant messages.",
|
||||
},
|
||||
limit: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
description:
|
||||
"Maximum number of messages to return. Defaults to 100. Increase only if you need more results.",
|
||||
'Maximum number of messages to return. Defaults to 100. Increase only if you need more results.',
|
||||
},
|
||||
},
|
||||
required: ["tabId"],
|
||||
required: ['tabId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "read_network_requests",
|
||||
name: 'read_network_requests',
|
||||
description:
|
||||
"Read HTTP network requests (XHR, Fetch, documents, images, etc.) from a specific tab. Useful for debugging API calls, monitoring network activity, or understanding what requests a page is making. Returns all network requests made by the current page, including cross-origin requests. Requests are automatically cleared when the page navigates to a different domain. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
tabId: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
description:
|
||||
"Tab ID to read network requests from. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
|
||||
},
|
||||
urlPattern: {
|
||||
type: "string",
|
||||
type: 'string',
|
||||
description:
|
||||
"Optional URL pattern to filter requests. Only requests whose URL contains this string will be returned (e.g., '/api/' to filter API calls, 'example.com' to filter by domain).",
|
||||
},
|
||||
clear: {
|
||||
type: "boolean",
|
||||
type: 'boolean',
|
||||
description:
|
||||
"If true, clear the network requests after reading to avoid duplicates on subsequent calls. Default is false.",
|
||||
'If true, clear the network requests after reading to avoid duplicates on subsequent calls. Default is false.',
|
||||
},
|
||||
limit: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
description:
|
||||
"Maximum number of requests to return. Defaults to 100. Increase only if you need more results.",
|
||||
'Maximum number of requests to return. Defaults to 100. Increase only if you need more results.',
|
||||
},
|
||||
},
|
||||
required: ["tabId"],
|
||||
required: ['tabId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "shortcuts_list",
|
||||
name: 'shortcuts_list',
|
||||
description:
|
||||
"List all available shortcuts and workflows (shortcuts and workflows are interchangeable). Returns shortcuts with their commands, descriptions, and whether they are workflows. Use shortcuts_execute to run a shortcut or workflow.",
|
||||
'List all available shortcuts and workflows (shortcuts and workflows are interchangeable). Returns shortcuts with their commands, descriptions, and whether they are workflows. Use shortcuts_execute to run a shortcut or workflow.',
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
tabId: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
description:
|
||||
"Tab ID to list shortcuts from. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
|
||||
},
|
||||
},
|
||||
required: ["tabId"],
|
||||
required: ['tabId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "shortcuts_execute",
|
||||
name: 'shortcuts_execute',
|
||||
description:
|
||||
"Execute a shortcut or workflow by running it in a new sidepanel window using the current tab (shortcuts and workflows are interchangeable). Use shortcuts_list first to see available shortcuts. This starts the execution and returns immediately - it does not wait for completion.",
|
||||
'Execute a shortcut or workflow by running it in a new sidepanel window using the current tab (shortcuts and workflows are interchangeable). Use shortcuts_list first to see available shortcuts. This starts the execution and returns immediately - it does not wait for completion.',
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
tabId: {
|
||||
type: "number",
|
||||
type: 'number',
|
||||
description:
|
||||
"Tab ID to execute the shortcut on. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
|
||||
},
|
||||
shortcutId: {
|
||||
type: "string",
|
||||
description: "The ID of the shortcut to execute",
|
||||
type: 'string',
|
||||
description: 'The ID of the shortcut to execute',
|
||||
},
|
||||
command: {
|
||||
type: "string",
|
||||
type: 'string',
|
||||
description:
|
||||
"The command name of the shortcut to execute (e.g., 'debug', 'summarize'). Do not include the leading slash.",
|
||||
},
|
||||
},
|
||||
required: ["tabId"],
|
||||
required: ['tabId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "switch_browser",
|
||||
name: 'switch_browser',
|
||||
description:
|
||||
"Switch which Chrome browser is used for browser automation. Call this when the user wants to connect to a different Chrome browser. Broadcasts a connection request to all Chrome browsers with the extension installed — the user clicks 'Connect' in the desired browser.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
export { BridgeClient, createBridgeClient } from "./bridgeClient.js";
|
||||
export { BROWSER_TOOLS } from "./browserTools.js";
|
||||
export { BridgeClient, createBridgeClient } from './bridgeClient.js'
|
||||
export { BROWSER_TOOLS } from './browserTools.js'
|
||||
export {
|
||||
createChromeSocketClient,
|
||||
createClaudeForChromeMcpServer,
|
||||
} from "./mcpServer.js";
|
||||
export { localPlatformLabel } from "./types.js";
|
||||
} from './mcpServer.js'
|
||||
export { localPlatformLabel } from './types.js'
|
||||
export type {
|
||||
BridgeConfig,
|
||||
ChromeExtensionInfo,
|
||||
@@ -12,4 +12,4 @@ export type {
|
||||
Logger,
|
||||
PermissionMode,
|
||||
SocketClient,
|
||||
} from "./types.js";
|
||||
} from './types.js'
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
} from '@modelcontextprotocol/sdk/types.js'
|
||||
|
||||
import { createBridgeClient } from "./bridgeClient.js";
|
||||
import { BROWSER_TOOLS } from "./browserTools.js";
|
||||
import { createMcpSocketClient } from "./mcpSocketClient.js";
|
||||
import { createMcpSocketPool } from "./mcpSocketPool.js";
|
||||
import { handleToolCall } from "./toolCalls.js";
|
||||
import type { ClaudeForChromeContext, SocketClient } from "./types.js";
|
||||
import { createBridgeClient } from './bridgeClient.js'
|
||||
import { BROWSER_TOOLS } from './browserTools.js'
|
||||
import { createMcpSocketClient } from './mcpSocketClient.js'
|
||||
import { createMcpSocketPool } from './mcpSocketPool.js'
|
||||
import { handleToolCall } from './toolCalls.js'
|
||||
import type { ClaudeForChromeContext, SocketClient } from './types.js'
|
||||
|
||||
/**
|
||||
* Create the socket/bridge client for the Chrome extension MCP server.
|
||||
@@ -24,23 +24,22 @@ export function createChromeSocketClient(
|
||||
? createBridgeClient(context)
|
||||
: context.getSocketPaths
|
||||
? createMcpSocketPool(context)
|
||||
: createMcpSocketClient(context);
|
||||
: createMcpSocketClient(context)
|
||||
}
|
||||
|
||||
export function createClaudeForChromeMcpServer(
|
||||
context: ClaudeForChromeContext,
|
||||
existingSocketClient?: SocketClient,
|
||||
): Server {
|
||||
const { serverName, logger } = context;
|
||||
const { serverName, logger } = context
|
||||
|
||||
// Choose transport: bridge (WebSocket) > socket pool (multi-profile) > single socket.
|
||||
const socketClient =
|
||||
existingSocketClient ?? createChromeSocketClient(context);
|
||||
const socketClient = existingSocketClient ?? createChromeSocketClient(context)
|
||||
|
||||
const server = new Server(
|
||||
{
|
||||
name: serverName,
|
||||
version: "1.0.0",
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
@@ -48,49 +47,49 @@ export function createClaudeForChromeMcpServer(
|
||||
logging: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
if (context.isDisabled?.()) {
|
||||
return { tools: [] };
|
||||
return { tools: [] }
|
||||
}
|
||||
return {
|
||||
tools: context.bridgeConfig
|
||||
? BROWSER_TOOLS
|
||||
: BROWSER_TOOLS.filter((t) => t.name !== "switch_browser"),
|
||||
};
|
||||
});
|
||||
: BROWSER_TOOLS.filter(t => t.name !== 'switch_browser'),
|
||||
}
|
||||
})
|
||||
|
||||
server.setRequestHandler(
|
||||
CallToolRequestSchema,
|
||||
async (request): Promise<CallToolResult> => {
|
||||
logger.info(`[${serverName}] Executing tool: ${request.params.name}`);
|
||||
logger.info(`[${serverName}] Executing tool: ${request.params.name}`)
|
||||
|
||||
return handleToolCall(
|
||||
context,
|
||||
socketClient,
|
||||
request.params.name,
|
||||
request.params.arguments || {},
|
||||
);
|
||||
)
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
socketClient.setNotificationHandler((notification) => {
|
||||
socketClient.setNotificationHandler(notification => {
|
||||
logger.info(
|
||||
`[${serverName}] Forwarding MCP notification: ${notification.method}`,
|
||||
);
|
||||
)
|
||||
server
|
||||
.notification({
|
||||
method: notification.method,
|
||||
params: notification.params,
|
||||
})
|
||||
.catch((error) => {
|
||||
.catch(error => {
|
||||
// Server may not be connected yet (e.g., during startup or after disconnect)
|
||||
logger.info(
|
||||
`[${serverName}] Failed to forward MCP notification: ${error.message}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
return server;
|
||||
return server
|
||||
}
|
||||
|
||||
@@ -1,327 +1,324 @@
|
||||
import { promises as fsPromises } from "fs";
|
||||
import { createConnection } from "net";
|
||||
import type { Socket } from "net";
|
||||
import { platform } from "os";
|
||||
import { dirname } from "path";
|
||||
import { promises as fsPromises } from 'fs'
|
||||
import { createConnection } from 'net'
|
||||
import type { Socket } from 'net'
|
||||
import { platform } from 'os'
|
||||
import { dirname } from 'path'
|
||||
|
||||
import type {
|
||||
ClaudeForChromeContext,
|
||||
PermissionMode,
|
||||
PermissionOverrides,
|
||||
} from "./types.js";
|
||||
} from './types.js'
|
||||
|
||||
export class SocketConnectionError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "SocketConnectionError";
|
||||
super(message)
|
||||
this.name = 'SocketConnectionError'
|
||||
}
|
||||
}
|
||||
|
||||
interface ToolRequest {
|
||||
method: string; // "execute_tool"
|
||||
method: string // "execute_tool"
|
||||
params?: {
|
||||
client_id?: string; // "desktop" | "claude-code"
|
||||
tool?: string;
|
||||
args?: Record<string, unknown>;
|
||||
};
|
||||
client_id?: string // "desktop" | "claude-code"
|
||||
tool?: string
|
||||
args?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
interface ToolResponse {
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
result?: unknown
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface Notification {
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
method: string
|
||||
params?: Record<string, unknown>
|
||||
}
|
||||
|
||||
type SocketMessage = ToolResponse | Notification;
|
||||
type SocketMessage = ToolResponse | Notification
|
||||
|
||||
function isToolResponse(message: SocketMessage): message is ToolResponse {
|
||||
return "result" in message || "error" in message;
|
||||
return 'result' in message || 'error' in message
|
||||
}
|
||||
|
||||
function isNotification(message: SocketMessage): message is Notification {
|
||||
return "method" in message && typeof message.method === "string";
|
||||
return 'method' in message && typeof message.method === 'string'
|
||||
}
|
||||
|
||||
class McpSocketClient {
|
||||
private socket: Socket | null = null;
|
||||
private connected = false;
|
||||
private connecting = false;
|
||||
private responseCallback: ((response: ToolResponse) => void) | null = null;
|
||||
private socket: Socket | null = null
|
||||
private connected = false
|
||||
private connecting = false
|
||||
private responseCallback: ((response: ToolResponse) => void) | null = null
|
||||
private notificationHandler: ((notification: Notification) => void) | null =
|
||||
null;
|
||||
private responseBuffer = Buffer.alloc(0);
|
||||
private reconnectAttempts = 0;
|
||||
private maxReconnectAttempts = 10;
|
||||
private reconnectDelay = 1000;
|
||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||
private context: ClaudeForChromeContext;
|
||||
null
|
||||
private responseBuffer = Buffer.alloc(0)
|
||||
private reconnectAttempts = 0
|
||||
private maxReconnectAttempts = 10
|
||||
private reconnectDelay = 1000
|
||||
private reconnectTimer: NodeJS.Timeout | null = null
|
||||
private context: ClaudeForChromeContext
|
||||
// When true, disables automatic reconnection. Used by McpSocketPool which
|
||||
// manages reconnection externally by rescanning available sockets.
|
||||
public disableAutoReconnect = false;
|
||||
public disableAutoReconnect = false
|
||||
|
||||
constructor(context: ClaudeForChromeContext) {
|
||||
this.context = context;
|
||||
this.context = context
|
||||
}
|
||||
|
||||
private async connect(): Promise<void> {
|
||||
const { serverName, logger } = this.context;
|
||||
const { serverName, logger } = this.context
|
||||
|
||||
if (this.connecting) {
|
||||
logger.info(
|
||||
`[${serverName}] Already connecting, skipping duplicate attempt`,
|
||||
);
|
||||
return;
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
this.closeSocket();
|
||||
this.connecting = true;
|
||||
this.closeSocket()
|
||||
this.connecting = true
|
||||
|
||||
const socketPath =
|
||||
this.context.getSocketPath?.() ?? this.context.socketPath;
|
||||
logger.info(`[${serverName}] Attempting to connect to: ${socketPath}`);
|
||||
const socketPath = this.context.getSocketPath?.() ?? this.context.socketPath
|
||||
logger.info(`[${serverName}] Attempting to connect to: ${socketPath}`)
|
||||
|
||||
try {
|
||||
await this.validateSocketSecurity(socketPath);
|
||||
await this.validateSocketSecurity(socketPath)
|
||||
} catch (error) {
|
||||
this.connecting = false;
|
||||
logger.info(`[${serverName}] Security validation failed:`, error);
|
||||
this.connecting = false
|
||||
logger.info(`[${serverName}] Security validation failed:`, error)
|
||||
// Don't retry on security failures (wrong perms/owner) - those won't
|
||||
// self-resolve. Only the error handler retries on transient errors.
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
this.socket = createConnection(socketPath);
|
||||
this.socket = createConnection(socketPath)
|
||||
|
||||
// Timeout the initial connection attempt - if socket file exists but native
|
||||
// host is dead, the connect can hang indefinitely
|
||||
const connectTimeout = setTimeout(() => {
|
||||
if (!this.connected) {
|
||||
logger.info(
|
||||
`[${serverName}] Connection attempt timed out after 5000ms`,
|
||||
);
|
||||
this.closeSocket();
|
||||
this.scheduleReconnect();
|
||||
logger.info(`[${serverName}] Connection attempt timed out after 5000ms`)
|
||||
this.closeSocket()
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}, 5000);
|
||||
}, 5000)
|
||||
|
||||
this.socket.on("connect", () => {
|
||||
clearTimeout(connectTimeout);
|
||||
this.connected = true;
|
||||
this.connecting = false;
|
||||
this.reconnectAttempts = 0;
|
||||
logger.info(`[${serverName}] Successfully connected to bridge server`);
|
||||
});
|
||||
this.socket.on('connect', () => {
|
||||
clearTimeout(connectTimeout)
|
||||
this.connected = true
|
||||
this.connecting = false
|
||||
this.reconnectAttempts = 0
|
||||
logger.info(`[${serverName}] Successfully connected to bridge server`)
|
||||
})
|
||||
|
||||
this.socket.on("data", (data: Buffer) => {
|
||||
this.responseBuffer = Buffer.concat([this.responseBuffer, data]);
|
||||
this.socket.on('data', (data: Buffer) => {
|
||||
this.responseBuffer = Buffer.concat([this.responseBuffer, data])
|
||||
|
||||
while (this.responseBuffer.length >= 4) {
|
||||
const length = this.responseBuffer.readUInt32LE(0);
|
||||
const length = this.responseBuffer.readUInt32LE(0)
|
||||
|
||||
if (this.responseBuffer.length < 4 + length) {
|
||||
break;
|
||||
break
|
||||
}
|
||||
|
||||
const messageBytes = this.responseBuffer.slice(4, 4 + length);
|
||||
this.responseBuffer = this.responseBuffer.slice(4 + length);
|
||||
const messageBytes = this.responseBuffer.slice(4, 4 + length)
|
||||
this.responseBuffer = this.responseBuffer.slice(4 + length)
|
||||
|
||||
try {
|
||||
const message = JSON.parse(
|
||||
messageBytes.toString("utf-8"),
|
||||
) as SocketMessage;
|
||||
messageBytes.toString('utf-8'),
|
||||
) as SocketMessage
|
||||
|
||||
if (isNotification(message)) {
|
||||
logger.info(
|
||||
`[${serverName}] Received notification: ${message.method}`,
|
||||
);
|
||||
)
|
||||
if (this.notificationHandler) {
|
||||
this.notificationHandler(message);
|
||||
this.notificationHandler(message)
|
||||
}
|
||||
} else if (isToolResponse(message)) {
|
||||
logger.info(`[${serverName}] Received tool response: ${message}`);
|
||||
this.handleResponse(message);
|
||||
logger.info(`[${serverName}] Received tool response: ${message}`)
|
||||
this.handleResponse(message)
|
||||
} else {
|
||||
logger.info(`[${serverName}] Received unknown message: ${message}`);
|
||||
logger.info(`[${serverName}] Received unknown message: ${message}`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.info(`[${serverName}] Failed to parse message:`, error);
|
||||
logger.info(`[${serverName}] Failed to parse message:`, error)
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
this.socket.on("error", (error: Error & { code?: string }) => {
|
||||
clearTimeout(connectTimeout);
|
||||
logger.info(`[${serverName}] Socket error (code: ${error.code}):`, error);
|
||||
this.connected = false;
|
||||
this.connecting = false;
|
||||
this.socket.on('error', (error: Error & { code?: string }) => {
|
||||
clearTimeout(connectTimeout)
|
||||
logger.info(`[${serverName}] Socket error (code: ${error.code}):`, error)
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
|
||||
if (
|
||||
error.code &&
|
||||
[
|
||||
"ECONNREFUSED", // Native host not listening (stale socket)
|
||||
"ECONNRESET", // Connection reset by peer
|
||||
"EPIPE", // Broken pipe (native host died mid-write)
|
||||
"ENOENT", // Socket file was deleted
|
||||
"EOPNOTSUPP", // Socket file exists but is not a valid socket
|
||||
"ECONNABORTED", // Connection aborted
|
||||
'ECONNREFUSED', // Native host not listening (stale socket)
|
||||
'ECONNRESET', // Connection reset by peer
|
||||
'EPIPE', // Broken pipe (native host died mid-write)
|
||||
'ENOENT', // Socket file was deleted
|
||||
'EOPNOTSUPP', // Socket file exists but is not a valid socket
|
||||
'ECONNABORTED', // Connection aborted
|
||||
].includes(error.code)
|
||||
) {
|
||||
this.scheduleReconnect();
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
this.socket.on("close", () => {
|
||||
clearTimeout(connectTimeout);
|
||||
this.connected = false;
|
||||
this.connecting = false;
|
||||
this.scheduleReconnect();
|
||||
});
|
||||
this.socket.on('close', () => {
|
||||
clearTimeout(connectTimeout)
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
this.scheduleReconnect()
|
||||
})
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
const { serverName, logger } = this.context;
|
||||
const { serverName, logger } = this.context
|
||||
|
||||
if (this.disableAutoReconnect) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
if (this.reconnectTimer) {
|
||||
logger.info(`[${serverName}] Reconnect already scheduled, skipping`);
|
||||
return;
|
||||
logger.info(`[${serverName}] Reconnect already scheduled, skipping`)
|
||||
return
|
||||
}
|
||||
|
||||
this.reconnectAttempts++;
|
||||
this.reconnectAttempts++
|
||||
|
||||
// Give up after extended polling (~50 min). A new ensureConnected() call
|
||||
// from a tool request will restart the cycle if needed.
|
||||
const maxTotalAttempts = 100;
|
||||
const maxTotalAttempts = 100
|
||||
if (this.reconnectAttempts > maxTotalAttempts) {
|
||||
logger.info(
|
||||
`[${serverName}] Giving up after ${maxTotalAttempts} attempts. Will retry on next tool call.`,
|
||||
);
|
||||
this.reconnectAttempts = 0;
|
||||
return;
|
||||
)
|
||||
this.reconnectAttempts = 0
|
||||
return
|
||||
}
|
||||
|
||||
// Use aggressive backoff for first 10 attempts, then slow poll every 30s.
|
||||
const delay = Math.min(
|
||||
this.reconnectDelay * Math.pow(1.5, this.reconnectAttempts - 1),
|
||||
this.reconnectDelay * 1.5 ** (this.reconnectAttempts - 1),
|
||||
30000,
|
||||
);
|
||||
)
|
||||
|
||||
if (this.reconnectAttempts <= this.maxReconnectAttempts) {
|
||||
logger.info(
|
||||
`[${serverName}] Reconnecting in ${Math.round(delay)}ms (attempt ${
|
||||
this.reconnectAttempts
|
||||
})`,
|
||||
);
|
||||
)
|
||||
} else if (this.reconnectAttempts % 10 === 0) {
|
||||
// Log every 10th slow-poll attempt to avoid log spam
|
||||
logger.info(
|
||||
`[${serverName}] Still polling for native host (attempt ${this.reconnectAttempts})`,
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
void this.connect();
|
||||
}, delay);
|
||||
this.reconnectTimer = null
|
||||
void this.connect()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
private handleResponse(response: ToolResponse): void {
|
||||
if (this.responseCallback) {
|
||||
const callback = this.responseCallback;
|
||||
this.responseCallback = null;
|
||||
callback(response);
|
||||
const callback = this.responseCallback
|
||||
this.responseCallback = null
|
||||
callback(response)
|
||||
}
|
||||
}
|
||||
|
||||
public setNotificationHandler(
|
||||
handler: (notification: Notification) => void,
|
||||
): void {
|
||||
this.notificationHandler = handler;
|
||||
this.notificationHandler = handler
|
||||
}
|
||||
|
||||
public async ensureConnected(): Promise<boolean> {
|
||||
const { serverName } = this.context;
|
||||
const { serverName } = this.context
|
||||
|
||||
if (this.connected && this.socket) {
|
||||
return true;
|
||||
return true
|
||||
}
|
||||
|
||||
if (!this.socket && !this.connecting) {
|
||||
await this.connect();
|
||||
await this.connect()
|
||||
}
|
||||
|
||||
// Wait for connection with timeout
|
||||
return new Promise((resolve, reject) => {
|
||||
let checkTimeoutId: NodeJS.Timeout | null = null;
|
||||
let checkTimeoutId: NodeJS.Timeout | null = null
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (checkTimeoutId) {
|
||||
clearTimeout(checkTimeoutId);
|
||||
clearTimeout(checkTimeoutId)
|
||||
}
|
||||
reject(
|
||||
new SocketConnectionError(
|
||||
`[${serverName}] Connection attempt timed out after 5000ms`,
|
||||
),
|
||||
);
|
||||
}, 5000);
|
||||
)
|
||||
}, 5000)
|
||||
|
||||
const checkConnection = () => {
|
||||
if (this.connected) {
|
||||
clearTimeout(timeout);
|
||||
resolve(true);
|
||||
clearTimeout(timeout)
|
||||
resolve(true)
|
||||
} else {
|
||||
checkTimeoutId = setTimeout(checkConnection, 500);
|
||||
checkTimeoutId = setTimeout(checkConnection, 500)
|
||||
}
|
||||
};
|
||||
checkConnection();
|
||||
});
|
||||
}
|
||||
checkConnection()
|
||||
})
|
||||
}
|
||||
|
||||
private async sendRequest(
|
||||
request: ToolRequest,
|
||||
timeoutMs = 30000,
|
||||
): Promise<ToolResponse> {
|
||||
const { serverName } = this.context;
|
||||
const { serverName } = this.context
|
||||
|
||||
if (!this.socket) {
|
||||
throw new SocketConnectionError(
|
||||
`[${serverName}] Cannot send request: not connected`,
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
const socket = this.socket;
|
||||
const socket = this.socket
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.responseCallback = null;
|
||||
this.responseCallback = null
|
||||
reject(
|
||||
new SocketConnectionError(
|
||||
`[${serverName}] Tool request timed out after ${timeoutMs}ms`,
|
||||
),
|
||||
);
|
||||
}, timeoutMs);
|
||||
)
|
||||
}, timeoutMs)
|
||||
|
||||
this.responseCallback = (response) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(response);
|
||||
};
|
||||
this.responseCallback = response => {
|
||||
clearTimeout(timeout)
|
||||
resolve(response)
|
||||
}
|
||||
|
||||
const requestJson = JSON.stringify(request);
|
||||
const requestBytes = Buffer.from(requestJson, "utf-8");
|
||||
const requestJson = JSON.stringify(request)
|
||||
const requestBytes = Buffer.from(requestJson, 'utf-8')
|
||||
|
||||
const lengthPrefix = Buffer.allocUnsafe(4);
|
||||
lengthPrefix.writeUInt32LE(requestBytes.length, 0);
|
||||
const lengthPrefix = Buffer.allocUnsafe(4)
|
||||
lengthPrefix.writeUInt32LE(requestBytes.length, 0)
|
||||
|
||||
const message = Buffer.concat([lengthPrefix, requestBytes]);
|
||||
socket.write(message);
|
||||
});
|
||||
const message = Buffer.concat([lengthPrefix, requestBytes])
|
||||
socket.write(message)
|
||||
})
|
||||
}
|
||||
|
||||
public async callTool(
|
||||
@@ -330,15 +327,15 @@ class McpSocketClient {
|
||||
_permissionOverrides?: PermissionOverrides,
|
||||
): Promise<unknown> {
|
||||
const request: ToolRequest = {
|
||||
method: "execute_tool",
|
||||
method: 'execute_tool',
|
||||
params: {
|
||||
client_id: this.context.clientTypeId,
|
||||
tool: name,
|
||||
args,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return this.sendRequestWithRetry(request);
|
||||
return this.sendRequestWithRetry(request)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -349,23 +346,23 @@ class McpSocketClient {
|
||||
* and retry once.
|
||||
*/
|
||||
private async sendRequestWithRetry(request: ToolRequest): Promise<unknown> {
|
||||
const { serverName, logger } = this.context;
|
||||
const { serverName, logger } = this.context
|
||||
|
||||
try {
|
||||
return await this.sendRequest(request);
|
||||
return await this.sendRequest(request)
|
||||
} catch (error) {
|
||||
if (!(error instanceof SocketConnectionError)) {
|
||||
throw error;
|
||||
throw error
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${serverName}] Connection error, forcing reconnect and retrying: ${error.message}`,
|
||||
);
|
||||
)
|
||||
|
||||
this.closeSocket();
|
||||
await this.ensureConnected();
|
||||
this.closeSocket()
|
||||
await this.ensureConnected()
|
||||
|
||||
return await this.sendRequest(request);
|
||||
return await this.sendRequest(request)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,109 +374,109 @@ class McpSocketClient {
|
||||
}
|
||||
|
||||
public isConnected(): boolean {
|
||||
return this.connected;
|
||||
return this.connected
|
||||
}
|
||||
|
||||
private closeSocket(): void {
|
||||
if (this.socket) {
|
||||
this.socket.removeAllListeners();
|
||||
this.socket.end();
|
||||
this.socket.destroy();
|
||||
this.socket = null;
|
||||
this.socket.removeAllListeners()
|
||||
this.socket.end()
|
||||
this.socket.destroy()
|
||||
this.socket = null
|
||||
}
|
||||
this.connected = false;
|
||||
this.connecting = false;
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
|
||||
this.closeSocket();
|
||||
this.reconnectAttempts = 0;
|
||||
this.responseBuffer = Buffer.alloc(0);
|
||||
this.responseCallback = null;
|
||||
this.closeSocket()
|
||||
this.reconnectAttempts = 0
|
||||
this.responseBuffer = Buffer.alloc(0)
|
||||
this.responseCallback = null
|
||||
}
|
||||
|
||||
public disconnect(): void {
|
||||
this.cleanup();
|
||||
this.cleanup()
|
||||
}
|
||||
|
||||
private async validateSocketSecurity(socketPath: string): Promise<void> {
|
||||
const { serverName, logger } = this.context;
|
||||
if (platform() === "win32") {
|
||||
return;
|
||||
const { serverName, logger } = this.context
|
||||
if (platform() === 'win32') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
// Validate the parent directory permissions if it's the socket directory
|
||||
// (not /tmp itself, which has mode 1777 for legacy single-socket paths)
|
||||
const dirPath = dirname(socketPath);
|
||||
const dirBasename = dirPath.split("/").pop() || "";
|
||||
const isSocketDir = dirBasename.startsWith("claude-mcp-browser-bridge-");
|
||||
const dirPath = dirname(socketPath)
|
||||
const dirBasename = dirPath.split('/').pop() || ''
|
||||
const isSocketDir = dirBasename.startsWith('claude-mcp-browser-bridge-')
|
||||
if (isSocketDir) {
|
||||
try {
|
||||
const dirStats = await fsPromises.stat(dirPath);
|
||||
const dirStats = await fsPromises.stat(dirPath)
|
||||
if (dirStats.isDirectory()) {
|
||||
const dirMode = dirStats.mode & 0o777;
|
||||
const dirMode = dirStats.mode & 0o777
|
||||
if (dirMode !== 0o700) {
|
||||
throw new Error(
|
||||
`[${serverName}] Insecure socket directory permissions: ${dirMode.toString(
|
||||
8,
|
||||
)} (expected 0700). Directory may have been tampered with.`,
|
||||
);
|
||||
)
|
||||
}
|
||||
const currentUid = process.getuid?.();
|
||||
const currentUid = process.getuid?.()
|
||||
if (currentUid !== undefined && dirStats.uid !== currentUid) {
|
||||
throw new Error(
|
||||
`Socket directory not owned by current user (uid: ${currentUid}, dir uid: ${dirStats.uid}). ` +
|
||||
`Potential security risk.`,
|
||||
);
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (dirError) {
|
||||
if ((dirError as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw dirError;
|
||||
if ((dirError as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw dirError
|
||||
}
|
||||
// Directory doesn't exist yet - native host will create it
|
||||
}
|
||||
}
|
||||
|
||||
const stats = await fsPromises.stat(socketPath);
|
||||
const stats = await fsPromises.stat(socketPath)
|
||||
|
||||
if (!stats.isSocket()) {
|
||||
throw new Error(
|
||||
`[${serverName}] Path exists but it's not a socket: ${socketPath}`,
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
const mode = stats.mode & 0o777;
|
||||
const mode = stats.mode & 0o777
|
||||
if (mode !== 0o600) {
|
||||
throw new Error(
|
||||
`[${serverName}] Insecure socket permissions: ${mode.toString(
|
||||
8,
|
||||
)} (expected 0600). Socket may have been tampered with.`,
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
const currentUid = process.getuid?.();
|
||||
const currentUid = process.getuid?.()
|
||||
if (currentUid !== undefined && stats.uid !== currentUid) {
|
||||
throw new Error(
|
||||
`Socket not owned by current user (uid: ${currentUid}, socket uid: ${stats.uid}). ` +
|
||||
`Potential security risk.`,
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${serverName}] Socket security validation passed`);
|
||||
logger.info(`[${serverName}] Socket security validation passed`)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
logger.info(
|
||||
`[${serverName}] Socket not found, will be created by server`,
|
||||
);
|
||||
return;
|
||||
)
|
||||
return
|
||||
}
|
||||
throw error;
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -487,7 +484,7 @@ class McpSocketClient {
|
||||
export function createMcpSocketClient(
|
||||
context: ClaudeForChromeContext,
|
||||
): McpSocketClient {
|
||||
return new McpSocketClient(context);
|
||||
return new McpSocketClient(context)
|
||||
}
|
||||
|
||||
export type { McpSocketClient };
|
||||
export type { McpSocketClient }
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import {
|
||||
createMcpSocketClient,
|
||||
SocketConnectionError,
|
||||
} from "./mcpSocketClient.js";
|
||||
import type { McpSocketClient } from "./mcpSocketClient.js";
|
||||
} from './mcpSocketClient.js'
|
||||
import type { McpSocketClient } from './mcpSocketClient.js'
|
||||
import type {
|
||||
ClaudeForChromeContext,
|
||||
PermissionMode,
|
||||
PermissionOverrides,
|
||||
} from "./types.js";
|
||||
} from './types.js'
|
||||
|
||||
/**
|
||||
* Manages connections to multiple Chrome native host sockets (one per Chrome profile).
|
||||
@@ -18,26 +18,29 @@ import type {
|
||||
* built from tabs_context_mcp responses.
|
||||
*/
|
||||
export class McpSocketPool {
|
||||
private clients: Map<string, McpSocketClient> = new Map();
|
||||
private tabRoutes: Map<number, string> = new Map();
|
||||
private context: ClaudeForChromeContext;
|
||||
private clients: Map<string, McpSocketClient> = new Map()
|
||||
private tabRoutes: Map<number, string> = new Map()
|
||||
private context: ClaudeForChromeContext
|
||||
private notificationHandler:
|
||||
| ((notification: { method: string; params?: Record<string, unknown> }) => void)
|
||||
| null = null;
|
||||
| ((notification: {
|
||||
method: string
|
||||
params?: Record<string, unknown>
|
||||
}) => void)
|
||||
| null = null
|
||||
|
||||
constructor(context: ClaudeForChromeContext) {
|
||||
this.context = context;
|
||||
this.context = context
|
||||
}
|
||||
|
||||
public setNotificationHandler(
|
||||
handler: (notification: {
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
method: string
|
||||
params?: Record<string, unknown>
|
||||
}) => void,
|
||||
): void {
|
||||
this.notificationHandler = handler;
|
||||
this.notificationHandler = handler
|
||||
for (const client of this.clients.values()) {
|
||||
client.setNotificationHandler(handler);
|
||||
client.setNotificationHandler(handler)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,32 +48,30 @@ export class McpSocketPool {
|
||||
* Discover available sockets and ensure at least one is connected.
|
||||
*/
|
||||
public async ensureConnected(): Promise<boolean> {
|
||||
const { logger, serverName } = this.context;
|
||||
const { logger, serverName } = this.context
|
||||
|
||||
this.refreshClients();
|
||||
this.refreshClients()
|
||||
|
||||
// Try to connect any disconnected clients
|
||||
const connectPromises: Promise<boolean>[] = [];
|
||||
const connectPromises: Promise<boolean>[] = []
|
||||
for (const client of this.clients.values()) {
|
||||
if (!client.isConnected()) {
|
||||
connectPromises.push(
|
||||
client.ensureConnected().catch(() => false),
|
||||
);
|
||||
connectPromises.push(client.ensureConnected().catch(() => false))
|
||||
}
|
||||
}
|
||||
|
||||
if (connectPromises.length > 0) {
|
||||
await Promise.all(connectPromises);
|
||||
await Promise.all(connectPromises)
|
||||
}
|
||||
|
||||
const connectedCount = this.getConnectedClients().length;
|
||||
const connectedCount = this.getConnectedClients().length
|
||||
if (connectedCount === 0) {
|
||||
logger.info(`[${serverName}] No connected sockets in pool`);
|
||||
return false;
|
||||
logger.info(`[${serverName}] No connected sockets in pool`)
|
||||
return false
|
||||
}
|
||||
|
||||
logger.info(`[${serverName}] Socket pool: ${connectedCount} connected`);
|
||||
return true;
|
||||
logger.info(`[${serverName}] Socket pool: ${connectedCount} connected`)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,57 +83,57 @@ export class McpSocketPool {
|
||||
args: Record<string, unknown>,
|
||||
_permissionOverrides?: PermissionOverrides,
|
||||
): Promise<unknown> {
|
||||
if (name === "tabs_context_mcp") {
|
||||
return this.callTabsContext(args);
|
||||
if (name === 'tabs_context_mcp') {
|
||||
return this.callTabsContext(args)
|
||||
}
|
||||
|
||||
// Route by tabId if present
|
||||
const tabId = args.tabId as number | undefined;
|
||||
const tabId = args.tabId as number | undefined
|
||||
if (tabId !== undefined) {
|
||||
const socketPath = this.tabRoutes.get(tabId);
|
||||
const socketPath = this.tabRoutes.get(tabId)
|
||||
if (socketPath) {
|
||||
const client = this.clients.get(socketPath);
|
||||
const client = this.clients.get(socketPath)
|
||||
if (client?.isConnected()) {
|
||||
return client.callTool(name, args);
|
||||
return client.callTool(name, args)
|
||||
}
|
||||
}
|
||||
// Tab route not found or client disconnected — fall through to any connected
|
||||
}
|
||||
|
||||
// Fallback: use first connected client
|
||||
const connected = this.getConnectedClients();
|
||||
const connected = this.getConnectedClients()
|
||||
if (connected.length === 0) {
|
||||
throw new SocketConnectionError(
|
||||
`[${this.context.serverName}] No connected sockets available`,
|
||||
);
|
||||
)
|
||||
}
|
||||
return connected[0]!.callTool(name, args);
|
||||
return connected[0]!.callTool(name, args)
|
||||
}
|
||||
|
||||
public async setPermissionMode(
|
||||
mode: PermissionMode,
|
||||
allowedDomains?: string[],
|
||||
): Promise<void> {
|
||||
const connected = this.getConnectedClients();
|
||||
const connected = this.getConnectedClients()
|
||||
await Promise.all(
|
||||
connected.map((client) => client.setPermissionMode(mode, allowedDomains)),
|
||||
);
|
||||
connected.map(client => client.setPermissionMode(mode, allowedDomains)),
|
||||
)
|
||||
}
|
||||
|
||||
public isConnected(): boolean {
|
||||
return this.getConnectedClients().length > 0;
|
||||
return this.getConnectedClients().length > 0
|
||||
}
|
||||
|
||||
public disconnect(): void {
|
||||
for (const client of this.clients.values()) {
|
||||
client.disconnect();
|
||||
client.disconnect()
|
||||
}
|
||||
this.clients.clear();
|
||||
this.tabRoutes.clear();
|
||||
this.clients.clear()
|
||||
this.tabRoutes.clear()
|
||||
}
|
||||
|
||||
private getConnectedClients(): McpSocketClient[] {
|
||||
return [...this.clients.values()].filter((c) => c.isConnected());
|
||||
return [...this.clients.values()].filter(c => c.isConnected())
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,173 +143,173 @@ export class McpSocketPool {
|
||||
private async callTabsContext(
|
||||
args: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
const { logger, serverName } = this.context;
|
||||
const connected = this.getConnectedClients();
|
||||
const { logger, serverName } = this.context
|
||||
const connected = this.getConnectedClients()
|
||||
|
||||
if (connected.length === 0) {
|
||||
throw new SocketConnectionError(
|
||||
`[${serverName}] No connected sockets available`,
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// If only one client, skip merging overhead
|
||||
if (connected.length === 1) {
|
||||
const result = await connected[0]!.callTool("tabs_context_mcp", args);
|
||||
this.updateTabRoutes(result, this.getSocketPathForClient(connected[0]!));
|
||||
return result;
|
||||
const result = await connected[0]!.callTool('tabs_context_mcp', args)
|
||||
this.updateTabRoutes(result, this.getSocketPathForClient(connected[0]!))
|
||||
return result
|
||||
}
|
||||
|
||||
// Query all connected clients in parallel
|
||||
const results = await Promise.allSettled(
|
||||
connected.map(async (client) => {
|
||||
const result = await client.callTool("tabs_context_mcp", args);
|
||||
const socketPath = this.getSocketPathForClient(client);
|
||||
return { result, socketPath };
|
||||
connected.map(async client => {
|
||||
const result = await client.callTool('tabs_context_mcp', args)
|
||||
const socketPath = this.getSocketPathForClient(client)
|
||||
return { result, socketPath }
|
||||
}),
|
||||
);
|
||||
)
|
||||
|
||||
// Merge tab results
|
||||
const mergedTabs: unknown[] = [];
|
||||
this.tabRoutes.clear();
|
||||
const mergedTabs: unknown[] = []
|
||||
this.tabRoutes.clear()
|
||||
|
||||
for (const settledResult of results) {
|
||||
if (settledResult.status !== "fulfilled") {
|
||||
if (settledResult.status !== 'fulfilled') {
|
||||
logger.info(
|
||||
`[${serverName}] tabs_context_mcp failed on one socket: ${settledResult.reason}`,
|
||||
);
|
||||
continue;
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const { result, socketPath } = settledResult.value;
|
||||
this.updateTabRoutes(result, socketPath);
|
||||
const { result, socketPath } = settledResult.value
|
||||
this.updateTabRoutes(result, socketPath)
|
||||
|
||||
const tabs = this.extractTabs(result);
|
||||
const tabs = this.extractTabs(result)
|
||||
if (tabs) {
|
||||
mergedTabs.push(...tabs);
|
||||
mergedTabs.push(...tabs)
|
||||
}
|
||||
}
|
||||
|
||||
// Return merged result in the same format as the extension response
|
||||
if (mergedTabs.length > 0) {
|
||||
const tabListText = mergedTabs
|
||||
.map((t) => {
|
||||
const tab = t as { tabId: number; title: string; url: string };
|
||||
return ` • tabId ${tab.tabId}: "${tab.title}" (${tab.url})`;
|
||||
.map(t => {
|
||||
const tab = t as { tabId: number; title: string; url: string }
|
||||
return ` • tabId ${tab.tabId}: "${tab.title}" (${tab.url})`
|
||||
})
|
||||
.join("\n");
|
||||
.join('\n')
|
||||
|
||||
return {
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
type: 'text',
|
||||
text: JSON.stringify({ availableTabs: mergedTabs }),
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
type: 'text',
|
||||
text: `\n\nTab Context:\n- Available tabs:\n${tabListText}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: return first successful result as-is
|
||||
for (const settledResult of results) {
|
||||
if (settledResult.status === "fulfilled") {
|
||||
return settledResult.value.result;
|
||||
if (settledResult.status === 'fulfilled') {
|
||||
return settledResult.value.result
|
||||
}
|
||||
}
|
||||
|
||||
throw new SocketConnectionError(
|
||||
`[${serverName}] All sockets failed for tabs_context_mcp`,
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract tab objects from a tool response to update routing table.
|
||||
*/
|
||||
private updateTabRoutes(result: unknown, socketPath: string): void {
|
||||
const tabs = this.extractTabs(result);
|
||||
if (!tabs) return;
|
||||
const tabs = this.extractTabs(result)
|
||||
if (!tabs) return
|
||||
|
||||
for (const tab of tabs) {
|
||||
if (typeof tab === "object" && tab !== null && "tabId" in tab) {
|
||||
const tabId = (tab as { tabId: number }).tabId;
|
||||
this.tabRoutes.set(tabId, socketPath);
|
||||
if (typeof tab === 'object' && tab !== null && 'tabId' in tab) {
|
||||
const tabId = (tab as { tabId: number }).tabId
|
||||
this.tabRoutes.set(tabId, socketPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extractTabs(result: unknown): unknown[] | null {
|
||||
if (!result || typeof result !== "object") return null;
|
||||
if (!result || typeof result !== 'object') return null
|
||||
|
||||
// Response format: { result: { content: [{ type: "text", text: "{\"availableTabs\":[...],\"tabGroupId\":...}" }] } }
|
||||
const asResponse = result as {
|
||||
result?: { content?: Array<{ type: string; text?: string }> };
|
||||
};
|
||||
const content = asResponse.result?.content;
|
||||
if (!content || !Array.isArray(content)) return null;
|
||||
result?: { content?: Array<{ type: string; text?: string }> }
|
||||
}
|
||||
const content = asResponse.result?.content
|
||||
if (!content || !Array.isArray(content)) return null
|
||||
|
||||
for (const item of content) {
|
||||
if (item.type === "text" && item.text) {
|
||||
if (item.type === 'text' && item.text) {
|
||||
try {
|
||||
const parsed = JSON.parse(item.text);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
const parsed = JSON.parse(item.text)
|
||||
if (Array.isArray(parsed)) return parsed
|
||||
// Handle { availableTabs: [...] } format
|
||||
if (parsed && Array.isArray(parsed.availableTabs)) {
|
||||
return parsed.availableTabs;
|
||||
return parsed.availableTabs
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
private getSocketPathForClient(client: McpSocketClient): string {
|
||||
for (const [path, c] of this.clients.entries()) {
|
||||
if (c === client) return path;
|
||||
if (c === client) return path
|
||||
}
|
||||
return "";
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan for available sockets and create/remove clients as needed.
|
||||
*/
|
||||
private refreshClients(): void {
|
||||
const socketPaths = this.getAvailableSocketPaths();
|
||||
const { logger, serverName } = this.context;
|
||||
const socketPaths = this.getAvailableSocketPaths()
|
||||
const { logger, serverName } = this.context
|
||||
|
||||
// Add new clients for newly discovered sockets
|
||||
for (const path of socketPaths) {
|
||||
if (!this.clients.has(path)) {
|
||||
logger.info(`[${serverName}] Adding socket to pool: ${path}`);
|
||||
logger.info(`[${serverName}] Adding socket to pool: ${path}`)
|
||||
const clientContext: ClaudeForChromeContext = {
|
||||
...this.context,
|
||||
socketPath: path,
|
||||
getSocketPath: undefined,
|
||||
getSocketPaths: undefined,
|
||||
};
|
||||
const client = createMcpSocketClient(clientContext);
|
||||
client.disableAutoReconnect = true;
|
||||
if (this.notificationHandler) {
|
||||
client.setNotificationHandler(this.notificationHandler);
|
||||
}
|
||||
this.clients.set(path, client);
|
||||
const client = createMcpSocketClient(clientContext)
|
||||
client.disableAutoReconnect = true
|
||||
if (this.notificationHandler) {
|
||||
client.setNotificationHandler(this.notificationHandler)
|
||||
}
|
||||
this.clients.set(path, client)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove clients for sockets that no longer exist
|
||||
for (const [path, client] of this.clients.entries()) {
|
||||
if (!socketPaths.includes(path)) {
|
||||
logger.info(`[${serverName}] Removing stale socket from pool: ${path}`);
|
||||
client.disconnect();
|
||||
this.clients.delete(path);
|
||||
logger.info(`[${serverName}] Removing stale socket from pool: ${path}`)
|
||||
client.disconnect()
|
||||
this.clients.delete(path)
|
||||
for (const [tabId, socketPath] of this.tabRoutes.entries()) {
|
||||
if (socketPath === path) {
|
||||
this.tabRoutes.delete(tabId);
|
||||
this.tabRoutes.delete(tabId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,12 +317,12 @@ export class McpSocketPool {
|
||||
}
|
||||
|
||||
private getAvailableSocketPaths(): string[] {
|
||||
return this.context.getSocketPaths?.() ?? [];
|
||||
return this.context.getSocketPaths?.() ?? []
|
||||
}
|
||||
}
|
||||
|
||||
export function createMcpSocketPool(
|
||||
context: ClaudeForChromeContext,
|
||||
): McpSocketPool {
|
||||
return new McpSocketPool(context);
|
||||
return new McpSocketPool(context)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
|
||||
|
||||
import { SocketConnectionError } from "./mcpSocketClient.js";
|
||||
import { SocketConnectionError } from './mcpSocketClient.js'
|
||||
import type {
|
||||
ClaudeForChromeContext,
|
||||
PermissionMode,
|
||||
PermissionOverrides,
|
||||
SocketClient,
|
||||
} from "./types.js";
|
||||
} from './types.js'
|
||||
|
||||
export const handleToolCall = async (
|
||||
context: ClaudeForChromeContext,
|
||||
@@ -16,21 +16,21 @@ export const handleToolCall = async (
|
||||
permissionOverrides?: PermissionOverrides,
|
||||
): Promise<CallToolResult> => {
|
||||
// Handle permission mode changes locally (not forwarded to extension)
|
||||
if (name === "set_permission_mode") {
|
||||
return handleSetPermissionMode(socketClient, args);
|
||||
if (name === 'set_permission_mode') {
|
||||
return handleSetPermissionMode(socketClient, args)
|
||||
}
|
||||
|
||||
// Handle switch_browser outside the normal tool call flow (manages its own connection)
|
||||
if (name === "switch_browser") {
|
||||
return handleSwitchBrowser(context, socketClient);
|
||||
if (name === 'switch_browser') {
|
||||
return handleSwitchBrowser(context, socketClient)
|
||||
}
|
||||
|
||||
try {
|
||||
const isConnected = await socketClient.ensureConnected();
|
||||
const isConnected = await socketClient.ensureConnected()
|
||||
|
||||
context.logger.silly(
|
||||
`[${context.serverName}] Server is connected: ${isConnected}. Received tool call: ${name} with args: ${JSON.stringify(args)}.`,
|
||||
);
|
||||
)
|
||||
|
||||
if (isConnected) {
|
||||
return await handleToolCallConnected(
|
||||
@@ -39,28 +39,28 @@ export const handleToolCall = async (
|
||||
name,
|
||||
args,
|
||||
permissionOverrides,
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
return handleToolCallDisconnected(context);
|
||||
return handleToolCallDisconnected(context)
|
||||
} catch (error) {
|
||||
context.logger.info(`[${context.serverName}] Error calling tool:`, error);
|
||||
context.logger.info(`[${context.serverName}] Error calling tool:`, error)
|
||||
|
||||
if (error instanceof SocketConnectionError) {
|
||||
return handleToolCallDisconnected(context);
|
||||
return handleToolCallDisconnected(context)
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
type: 'text',
|
||||
text: `Error calling tool, please try again. : ${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function handleToolCallConnected(
|
||||
context: ClaudeForChromeContext,
|
||||
@@ -69,119 +69,119 @@ async function handleToolCallConnected(
|
||||
args: Record<string, unknown>,
|
||||
permissionOverrides?: PermissionOverrides,
|
||||
): Promise<CallToolResult> {
|
||||
const response = await socketClient.callTool(name, args, permissionOverrides);
|
||||
const response = await socketClient.callTool(name, args, permissionOverrides)
|
||||
|
||||
context.logger.silly(
|
||||
`[${context.serverName}] Received result from socket bridge: ${JSON.stringify(response)}`,
|
||||
);
|
||||
)
|
||||
|
||||
if (response === null || response === undefined) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Tool execution completed" }],
|
||||
};
|
||||
content: [{ type: 'text', text: 'Tool execution completed' }],
|
||||
}
|
||||
}
|
||||
|
||||
// Response will have either result or error field
|
||||
const { result, error } = response as {
|
||||
result?: { content: unknown[] | string };
|
||||
error?: { content: unknown[] | string };
|
||||
};
|
||||
result?: { content: unknown[] | string }
|
||||
error?: { content: unknown[] | string }
|
||||
}
|
||||
|
||||
// Determine which field has the content and whether it's an error
|
||||
const contentData = error || result;
|
||||
const isError = !!error;
|
||||
const contentData = error || result
|
||||
const isError = !!error
|
||||
|
||||
if (!contentData) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Tool execution completed" }],
|
||||
};
|
||||
content: [{ type: 'text', text: 'Tool execution completed' }],
|
||||
}
|
||||
}
|
||||
|
||||
if (isError && isAuthenticationError(contentData.content)) {
|
||||
context.onAuthenticationError();
|
||||
context.onAuthenticationError()
|
||||
}
|
||||
|
||||
const { content } = contentData;
|
||||
const { content } = contentData
|
||||
|
||||
if (content && Array.isArray(content)) {
|
||||
if (isError) {
|
||||
return {
|
||||
content: content.map((item: unknown) => {
|
||||
if (typeof item === "object" && item !== null && "type" in item) {
|
||||
return item;
|
||||
if (typeof item === 'object' && item !== null && 'type' in item) {
|
||||
return item
|
||||
}
|
||||
|
||||
return { type: "text", text: String(item) };
|
||||
return { type: 'text', text: String(item) }
|
||||
}),
|
||||
isError: true,
|
||||
} as CallToolResult;
|
||||
} as CallToolResult
|
||||
}
|
||||
|
||||
const convertedContent = content.map((item: unknown) => {
|
||||
if (
|
||||
typeof item === "object" &&
|
||||
typeof item === 'object' &&
|
||||
item !== null &&
|
||||
"type" in item &&
|
||||
"source" in item
|
||||
'type' in item &&
|
||||
'source' in item
|
||||
) {
|
||||
const typedItem = item;
|
||||
const typedItem = item
|
||||
if (
|
||||
typedItem.type === "image" &&
|
||||
typeof typedItem.source === "object" &&
|
||||
typedItem.type === 'image' &&
|
||||
typeof typedItem.source === 'object' &&
|
||||
typedItem.source !== null &&
|
||||
"data" in typedItem.source
|
||||
'data' in typedItem.source
|
||||
) {
|
||||
return {
|
||||
type: "image",
|
||||
type: 'image',
|
||||
data: typedItem.source.data,
|
||||
mimeType:
|
||||
"media_type" in typedItem.source
|
||||
? typedItem.source.media_type || "image/png"
|
||||
: "image/png",
|
||||
};
|
||||
'media_type' in typedItem.source
|
||||
? typedItem.source.media_type || 'image/png'
|
||||
: 'image/png',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof item === "object" && item !== null && "type" in item) {
|
||||
return item;
|
||||
if (typeof item === 'object' && item !== null && 'type' in item) {
|
||||
return item
|
||||
}
|
||||
|
||||
return { type: "text", text: String(item) };
|
||||
});
|
||||
return { type: 'text', text: String(item) }
|
||||
})
|
||||
|
||||
return {
|
||||
content: convertedContent,
|
||||
isError,
|
||||
} as CallToolResult;
|
||||
} as CallToolResult
|
||||
}
|
||||
|
||||
// Handle string content
|
||||
if (typeof content === "string") {
|
||||
if (typeof content === 'string') {
|
||||
return {
|
||||
content: [{ type: "text", text: content }],
|
||||
content: [{ type: 'text', text: content }],
|
||||
isError,
|
||||
} as CallToolResult;
|
||||
} as CallToolResult
|
||||
}
|
||||
|
||||
// Fallback for unexpected result format
|
||||
context.logger.warn(
|
||||
`[${context.serverName}] Unexpected result format from socket bridge`,
|
||||
response,
|
||||
);
|
||||
)
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(response) }],
|
||||
content: [{ type: 'text', text: JSON.stringify(response) }],
|
||||
isError,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function handleToolCallDisconnected(
|
||||
context: ClaudeForChromeContext,
|
||||
): CallToolResult {
|
||||
const text = context.onToolCallDisconnected();
|
||||
const text = context.onToolCallDisconnected()
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
};
|
||||
content: [{ type: 'text', text }],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -194,28 +194,28 @@ async function handleSetPermissionMode(
|
||||
): Promise<CallToolResult> {
|
||||
// Validate permission mode at runtime
|
||||
const validModes = [
|
||||
"ask",
|
||||
"skip_all_permission_checks",
|
||||
"follow_a_plan",
|
||||
] as const;
|
||||
const mode = args.mode as string | undefined;
|
||||
'ask',
|
||||
'skip_all_permission_checks',
|
||||
'follow_a_plan',
|
||||
] as const
|
||||
const mode = args.mode as string | undefined
|
||||
const permissionMode: PermissionMode =
|
||||
mode && validModes.includes(mode as PermissionMode)
|
||||
? (mode as PermissionMode)
|
||||
: "ask";
|
||||
: 'ask'
|
||||
|
||||
if (socketClient.setPermissionMode) {
|
||||
await socketClient.setPermissionMode(
|
||||
permissionMode,
|
||||
args.allowed_domains as string[] | undefined,
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `Permission mode set to: ${permissionMode}` },
|
||||
{ type: 'text', text: `Permission mode set to: ${permissionMode}` },
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -230,50 +230,50 @@ async function handleSwitchBrowser(
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Browser switching is only available with bridge connections.",
|
||||
type: 'text',
|
||||
text: 'Browser switching is only available with bridge connections.',
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const isConnected = await socketClient.ensureConnected();
|
||||
const isConnected = await socketClient.ensureConnected()
|
||||
if (!isConnected) {
|
||||
return handleToolCallDisconnected(context);
|
||||
return handleToolCallDisconnected(context)
|
||||
}
|
||||
|
||||
const result = (await socketClient.switchBrowser?.()) ?? null;
|
||||
const result = (await socketClient.switchBrowser?.()) ?? null
|
||||
|
||||
if (result === "no_other_browsers") {
|
||||
if (result === 'no_other_browsers') {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "No other browsers available to switch to. Open Chrome with the Claude extension in another browser to switch.",
|
||||
type: 'text',
|
||||
text: 'No other browsers available to switch to. Open Chrome with the Claude extension in another browser to switch.',
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (result) {
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `Connected to browser "${result.name}".` },
|
||||
{ type: 'text', text: `Connected to browser "${result.name}".` },
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "No browser responded within the timeout. Make sure Chrome is open with the Claude extension installed, then try again.",
|
||||
type: 'text',
|
||||
text: 'No browser responded within the timeout. Make sure Chrome is open with the Claude extension installed, then try again.',
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -282,20 +282,20 @@ async function handleSwitchBrowser(
|
||||
function isAuthenticationError(content: unknown[] | string): boolean {
|
||||
const errorText = Array.isArray(content)
|
||||
? content
|
||||
.map((item) => {
|
||||
if (typeof item === "string") return item;
|
||||
.map(item => {
|
||||
if (typeof item === 'string') return item
|
||||
if (
|
||||
typeof item === "object" &&
|
||||
typeof item === 'object' &&
|
||||
item !== null &&
|
||||
"text" in item &&
|
||||
typeof item.text === "string"
|
||||
'text' in item &&
|
||||
typeof item.text === 'string'
|
||||
) {
|
||||
return item.text;
|
||||
return item.text
|
||||
}
|
||||
return "";
|
||||
return ''
|
||||
})
|
||||
.join(" ")
|
||||
: String(content);
|
||||
.join(' ')
|
||||
: String(content)
|
||||
|
||||
return errorText.toLowerCase().includes("re-authenticated");
|
||||
return errorText.toLowerCase().includes('re-authenticated')
|
||||
}
|
||||
|
||||
@@ -1,64 +1,64 @@
|
||||
export interface Logger {
|
||||
info: (message: string, ...args: unknown[]) => void;
|
||||
error: (message: string, ...args: unknown[]) => void;
|
||||
warn: (message: string, ...args: unknown[]) => void;
|
||||
debug: (message: string, ...args: unknown[]) => void;
|
||||
silly: (message: string, ...args: unknown[]) => void;
|
||||
info: (message: string, ...args: unknown[]) => void
|
||||
error: (message: string, ...args: unknown[]) => void
|
||||
warn: (message: string, ...args: unknown[]) => void
|
||||
debug: (message: string, ...args: unknown[]) => void
|
||||
silly: (message: string, ...args: unknown[]) => void
|
||||
}
|
||||
|
||||
export type PermissionMode =
|
||||
| "ask"
|
||||
| "skip_all_permission_checks"
|
||||
| "follow_a_plan";
|
||||
| 'ask'
|
||||
| 'skip_all_permission_checks'
|
||||
| 'follow_a_plan'
|
||||
|
||||
export interface BridgeConfig {
|
||||
/** Bridge WebSocket base URL (e.g., wss://bridge.claudeusercontent.com) */
|
||||
url: string;
|
||||
url: string
|
||||
/** Returns the user's account UUID for the connection path */
|
||||
getUserId: () => Promise<string | undefined>;
|
||||
getUserId: () => Promise<string | undefined>
|
||||
/** Returns a valid OAuth token for bridge authentication */
|
||||
getOAuthToken: () => Promise<string | undefined>;
|
||||
getOAuthToken: () => Promise<string | undefined>
|
||||
/** Optional dev user ID for local development (bypasses OAuth) */
|
||||
devUserId?: string;
|
||||
devUserId?: string
|
||||
}
|
||||
|
||||
/** Metadata about a connected Chrome extension instance. */
|
||||
export interface ChromeExtensionInfo {
|
||||
deviceId: string;
|
||||
osPlatform?: string;
|
||||
connectedAt: number;
|
||||
name?: string;
|
||||
deviceId: string
|
||||
osPlatform?: string
|
||||
connectedAt: number
|
||||
name?: string
|
||||
}
|
||||
|
||||
export interface ClaudeForChromeContext {
|
||||
serverName: string;
|
||||
logger: Logger;
|
||||
socketPath: string;
|
||||
serverName: string
|
||||
logger: Logger
|
||||
socketPath: string
|
||||
// Optional dynamic resolver for socket path. When provided, called on each
|
||||
// connection attempt to handle runtime conditions (e.g., TMPDIR mismatch).
|
||||
getSocketPath?: () => string;
|
||||
getSocketPath?: () => string
|
||||
// Optional resolver returning all available socket paths (for multi-profile support).
|
||||
// When provided, a socket pool connects to all sockets and routes by tab ID.
|
||||
getSocketPaths?: () => string[];
|
||||
clientTypeId: string; // "desktop" | "claude-code"
|
||||
onToolCallDisconnected: () => string;
|
||||
onAuthenticationError: () => void;
|
||||
isDisabled?: () => boolean;
|
||||
getSocketPaths?: () => string[]
|
||||
clientTypeId: string // "desktop" | "claude-code"
|
||||
onToolCallDisconnected: () => string
|
||||
onAuthenticationError: () => void
|
||||
isDisabled?: () => boolean
|
||||
/** Bridge WebSocket configuration. When provided, uses bridge instead of socket. */
|
||||
bridgeConfig?: BridgeConfig;
|
||||
bridgeConfig?: BridgeConfig
|
||||
/** If set, permission mode is sent to the extension immediately on bridge connection. */
|
||||
initialPermissionMode?: PermissionMode;
|
||||
initialPermissionMode?: PermissionMode
|
||||
/** Optional callback to track telemetry events for bridge connections */
|
||||
trackEvent?: <K extends string>(
|
||||
eventName: K,
|
||||
metadata: Record<string, unknown> | null,
|
||||
) => void;
|
||||
) => void
|
||||
/** Called when user pairs with an extension via the browser pairing flow. */
|
||||
onExtensionPaired?: (deviceId: string, name: string) => void;
|
||||
onExtensionPaired?: (deviceId: string, name: string) => void
|
||||
/** Returns the previously paired deviceId, if any. */
|
||||
getPersistedDeviceId?: () => string | undefined;
|
||||
getPersistedDeviceId?: () => string | undefined
|
||||
/** Called when a remote extension is auto-selected (only option available). */
|
||||
onRemoteExtensionWarning?: (ext: ChromeExtensionInfo) => void;
|
||||
onRemoteExtensionWarning?: (ext: ChromeExtensionInfo) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,69 +66,69 @@ export interface ClaudeForChromeContext {
|
||||
* via navigator.userAgentData.platform.
|
||||
*/
|
||||
export function localPlatformLabel(): string {
|
||||
return process.platform === "darwin"
|
||||
? "macOS"
|
||||
: process.platform === "win32"
|
||||
? "Windows"
|
||||
: "Linux";
|
||||
return process.platform === 'darwin'
|
||||
? 'macOS'
|
||||
: process.platform === 'win32'
|
||||
? 'Windows'
|
||||
: 'Linux'
|
||||
}
|
||||
|
||||
/** Permission request forwarded from the extension to the desktop for user approval. */
|
||||
export interface BridgePermissionRequest {
|
||||
/** Links to the pending tool_call */
|
||||
toolUseId: string;
|
||||
toolUseId: string
|
||||
/** Unique ID for this permission request */
|
||||
requestId: string;
|
||||
requestId: string
|
||||
/** Tool type, e.g. "navigate", "click", "execute_javascript" */
|
||||
toolType: string;
|
||||
toolType: string
|
||||
/** The URL/domain context */
|
||||
url: string;
|
||||
url: string
|
||||
/** Additional action data (click coordinates, text, etc.) */
|
||||
actionData?: Record<string, unknown>;
|
||||
actionData?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Desktop response to a bridge permission request. */
|
||||
export interface BridgePermissionResponse {
|
||||
requestId: string;
|
||||
allowed: boolean;
|
||||
requestId: string
|
||||
allowed: boolean
|
||||
}
|
||||
|
||||
/** Per-call permission overrides, allowing each session to use its own permission state. */
|
||||
export interface PermissionOverrides {
|
||||
permissionMode: PermissionMode;
|
||||
allowedDomains?: string[];
|
||||
permissionMode: PermissionMode
|
||||
allowedDomains?: string[]
|
||||
/** Callback invoked when the extension requests user permission via the bridge. */
|
||||
onPermissionRequest?: (request: BridgePermissionRequest) => Promise<boolean>;
|
||||
onPermissionRequest?: (request: BridgePermissionRequest) => Promise<boolean>
|
||||
}
|
||||
|
||||
/** Shared interface for McpSocketClient and McpSocketPool */
|
||||
export interface SocketClient {
|
||||
ensureConnected(): Promise<boolean>;
|
||||
ensureConnected(): Promise<boolean>
|
||||
callTool(
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
permissionOverrides?: PermissionOverrides,
|
||||
): Promise<unknown>;
|
||||
isConnected(): boolean;
|
||||
disconnect(): void;
|
||||
): Promise<unknown>
|
||||
isConnected(): boolean
|
||||
disconnect(): void
|
||||
setNotificationHandler(
|
||||
handler: (notification: {
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
method: string
|
||||
params?: Record<string, unknown>
|
||||
}) => void,
|
||||
): void;
|
||||
): void
|
||||
/** Set permission mode for the current session. Only effective on BridgeClient. */
|
||||
setPermissionMode?(
|
||||
mode: PermissionMode,
|
||||
allowedDomains?: string[],
|
||||
): Promise<void>;
|
||||
): Promise<void>
|
||||
/** Switch to a different browser. Only available on BridgeClient. */
|
||||
switchBrowser?(): Promise<
|
||||
| {
|
||||
deviceId: string;
|
||||
name: string;
|
||||
deviceId: string
|
||||
name: string
|
||||
}
|
||||
| "no_other_browsers"
|
||||
| 'no_other_browsers'
|
||||
| null
|
||||
>;
|
||||
>
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@ant/computer-use-input",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts"
|
||||
"name": "@ant/computer-use-input",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts"
|
||||
}
|
||||
|
||||
@@ -12,19 +12,46 @@ import type { FrontmostAppInfo, InputBackend } from '../types.js'
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
const KEY_MAP: Record<string, number> = {
|
||||
return: 36, enter: 36, tab: 48, space: 49, delete: 51, backspace: 51,
|
||||
escape: 53, esc: 53,
|
||||
left: 123, right: 124, down: 125, up: 126,
|
||||
f1: 122, f2: 120, f3: 99, f4: 118, f5: 96, f6: 97,
|
||||
f7: 98, f8: 100, f9: 101, f10: 109, f11: 103, f12: 111,
|
||||
home: 115, end: 119, pageup: 116, pagedown: 121,
|
||||
return: 36,
|
||||
enter: 36,
|
||||
tab: 48,
|
||||
space: 49,
|
||||
delete: 51,
|
||||
backspace: 51,
|
||||
escape: 53,
|
||||
esc: 53,
|
||||
left: 123,
|
||||
right: 124,
|
||||
down: 125,
|
||||
up: 126,
|
||||
f1: 122,
|
||||
f2: 120,
|
||||
f3: 99,
|
||||
f4: 118,
|
||||
f5: 96,
|
||||
f6: 97,
|
||||
f7: 98,
|
||||
f8: 100,
|
||||
f9: 101,
|
||||
f10: 109,
|
||||
f11: 103,
|
||||
f12: 111,
|
||||
home: 115,
|
||||
end: 119,
|
||||
pageup: 116,
|
||||
pagedown: 121,
|
||||
}
|
||||
|
||||
const MODIFIER_MAP: Record<string, string> = {
|
||||
command: 'command down', cmd: 'command down', meta: 'command down', super: 'command down',
|
||||
command: 'command down',
|
||||
cmd: 'command down',
|
||||
meta: 'command down',
|
||||
super: 'command down',
|
||||
shift: 'shift down',
|
||||
option: 'option down', alt: 'option down',
|
||||
control: 'control down', ctrl: 'control down',
|
||||
option: 'option down',
|
||||
alt: 'option down',
|
||||
control: 'control down',
|
||||
ctrl: 'control down',
|
||||
}
|
||||
|
||||
async function osascript(script: string): Promise<string> {
|
||||
@@ -35,13 +62,23 @@ async function osascript(script: string): Promise<string> {
|
||||
}
|
||||
|
||||
async function jxa(script: string): Promise<string> {
|
||||
const { stdout } = await execFileAsync('osascript', ['-l', 'JavaScript', '-e', script], {
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
const { stdout } = await execFileAsync(
|
||||
'osascript',
|
||||
['-l', 'JavaScript', '-e', script],
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
},
|
||||
)
|
||||
return stdout.trim()
|
||||
}
|
||||
|
||||
function buildMouseJxa(eventType: string, x: number, y: number, btn: number, clickState?: number): string {
|
||||
function buildMouseJxa(
|
||||
eventType: string,
|
||||
x: number,
|
||||
y: number,
|
||||
btn: number,
|
||||
clickState?: number,
|
||||
): string {
|
||||
let script = `ObjC.import("CoreGraphics"); var p = $.CGPointMake(${x},${y}); var e = $.CGEventCreateMouseEvent(null, $.${eventType}, p, ${btn});`
|
||||
if (clickState !== undefined) {
|
||||
script += ` $.CGEventSetIntegerValueField(e, $.kCGMouseEventClickState, ${clickState});`
|
||||
@@ -61,11 +98,13 @@ export const key: InputBackend['key'] = async (keyName, action) => {
|
||||
if (keyCode !== undefined) {
|
||||
await osascript(`tell application "System Events" to key code ${keyCode}`)
|
||||
} else {
|
||||
await osascript(`tell application "System Events" to keystroke "${keyName.length === 1 ? keyName : lower}"`)
|
||||
await osascript(
|
||||
`tell application "System Events" to keystroke "${keyName.length === 1 ? keyName : lower}"`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const keys: InputBackend['keys'] = async (parts) => {
|
||||
export const keys: InputBackend['keys'] = async parts => {
|
||||
const modifiers: string[] = []
|
||||
let finalKey: string | null = null
|
||||
for (const part of parts) {
|
||||
@@ -78,23 +117,43 @@ export const keys: InputBackend['keys'] = async (parts) => {
|
||||
const keyCode = KEY_MAP[lower]
|
||||
const modStr = modifiers.length > 0 ? ` using {${modifiers.join(', ')}}` : ''
|
||||
if (keyCode !== undefined) {
|
||||
await osascript(`tell application "System Events" to key code ${keyCode}${modStr}`)
|
||||
await osascript(
|
||||
`tell application "System Events" to key code ${keyCode}${modStr}`,
|
||||
)
|
||||
} else {
|
||||
await osascript(`tell application "System Events" to keystroke "${finalKey.length === 1 ? finalKey : lower}"${modStr}`)
|
||||
await osascript(
|
||||
`tell application "System Events" to keystroke "${finalKey.length === 1 ? finalKey : lower}"${modStr}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const mouseLocation: InputBackend['mouseLocation'] = async () => {
|
||||
const result = await jxa('ObjC.import("CoreGraphics"); var e = $.CGEventCreate(null); var p = $.CGEventGetLocation(e); p.x + "," + p.y')
|
||||
const result = await jxa(
|
||||
'ObjC.import("CoreGraphics"); var e = $.CGEventCreate(null); var p = $.CGEventGetLocation(e); p.x + "," + p.y',
|
||||
)
|
||||
const [xStr, yStr] = result.split(',')
|
||||
return { x: Math.round(Number(xStr)), y: Math.round(Number(yStr)) }
|
||||
}
|
||||
|
||||
export const mouseButton: InputBackend['mouseButton'] = async (button, action, count) => {
|
||||
export const mouseButton: InputBackend['mouseButton'] = async (
|
||||
button,
|
||||
action,
|
||||
count,
|
||||
) => {
|
||||
const pos = await mouseLocation()
|
||||
const btn = button === 'left' ? 0 : button === 'right' ? 1 : 2
|
||||
const downType = btn === 0 ? 'kCGEventLeftMouseDown' : btn === 1 ? 'kCGEventRightMouseDown' : 'kCGEventOtherMouseDown'
|
||||
const upType = btn === 0 ? 'kCGEventLeftMouseUp' : btn === 1 ? 'kCGEventRightMouseUp' : 'kCGEventOtherMouseUp'
|
||||
const downType =
|
||||
btn === 0
|
||||
? 'kCGEventLeftMouseDown'
|
||||
: btn === 1
|
||||
? 'kCGEventRightMouseDown'
|
||||
: 'kCGEventOtherMouseDown'
|
||||
const upType =
|
||||
btn === 0
|
||||
? 'kCGEventLeftMouseUp'
|
||||
: btn === 1
|
||||
? 'kCGEventRightMouseUp'
|
||||
: 'kCGEventOtherMouseUp'
|
||||
|
||||
if (action === 'click') {
|
||||
for (let i = 0; i < (count ?? 1); i++) {
|
||||
@@ -108,28 +167,39 @@ export const mouseButton: InputBackend['mouseButton'] = async (button, action, c
|
||||
}
|
||||
}
|
||||
|
||||
export const mouseScroll: InputBackend['mouseScroll'] = async (amount, direction) => {
|
||||
const script = direction === 'vertical'
|
||||
? `ObjC.import("CoreGraphics"); var e = $.CGEventCreateScrollWheelEvent(null, 0, 1, ${amount}); $.CGEventPost($.kCGHIDEventTap, e);`
|
||||
: `ObjC.import("CoreGraphics"); var e = $.CGEventCreateScrollWheelEvent(null, 0, 2, 0, ${amount}); $.CGEventPost($.kCGHIDEventTap, e);`
|
||||
export const mouseScroll: InputBackend['mouseScroll'] = async (
|
||||
amount,
|
||||
direction,
|
||||
) => {
|
||||
const script =
|
||||
direction === 'vertical'
|
||||
? `ObjC.import("CoreGraphics"); var e = $.CGEventCreateScrollWheelEvent(null, 0, 1, ${amount}); $.CGEventPost($.kCGHIDEventTap, e);`
|
||||
: `ObjC.import("CoreGraphics"); var e = $.CGEventCreateScrollWheelEvent(null, 0, 2, 0, ${amount}); $.CGEventPost($.kCGHIDEventTap, e);`
|
||||
await jxa(script)
|
||||
}
|
||||
|
||||
export const typeText: InputBackend['typeText'] = async (text) => {
|
||||
export const typeText: InputBackend['typeText'] = async text => {
|
||||
const escaped = text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
||||
await osascript(`tell application "System Events" to keystroke "${escaped}"`)
|
||||
}
|
||||
|
||||
export const getFrontmostAppInfo: InputBackend['getFrontmostAppInfo'] = () => {
|
||||
try {
|
||||
const output = execFileSync('osascript', ['-e', `
|
||||
const output = execFileSync(
|
||||
'osascript',
|
||||
[
|
||||
'-e',
|
||||
`
|
||||
tell application "System Events"
|
||||
set frontApp to first application process whose frontmost is true
|
||||
set appName to name of frontApp
|
||||
set bundleId to bundle identifier of frontApp
|
||||
return bundleId & "|" & appName
|
||||
end tell
|
||||
`], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }).trim()
|
||||
`,
|
||||
],
|
||||
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] },
|
||||
).trim()
|
||||
if (!output || !output.includes('|')) return null
|
||||
const [bundleId, appName] = output.split('|', 2)
|
||||
return { bundleId: bundleId!, appName: appName! }
|
||||
|
||||
@@ -32,23 +32,75 @@ async function runAsync(cmd: string[]): Promise<string> {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const KEY_MAP: Record<string, string> = {
|
||||
return: 'Return', enter: 'Return', tab: 'Tab', space: 'space',
|
||||
backspace: 'BackSpace', delete: 'Delete', escape: 'Escape', esc: 'Escape',
|
||||
left: 'Left', up: 'Up', right: 'Right', down: 'Down',
|
||||
home: 'Home', end: 'End', pageup: 'Prior', pagedown: 'Next',
|
||||
f1: 'F1', f2: 'F2', f3: 'F3', f4: 'F4', f5: 'F5', f6: 'F6',
|
||||
f7: 'F7', f8: 'F8', f9: 'F9', f10: 'F10', f11: 'F11', f12: 'F12',
|
||||
shift: 'shift', lshift: 'shift', rshift: 'shift',
|
||||
control: 'ctrl', ctrl: 'ctrl', lcontrol: 'ctrl', rcontrol: 'ctrl',
|
||||
alt: 'alt', option: 'alt', lalt: 'alt', ralt: 'alt',
|
||||
win: 'super', meta: 'super', command: 'super', cmd: 'super', super: 'super',
|
||||
insert: 'Insert', printscreen: 'Print', pause: 'Pause',
|
||||
numlock: 'Num_Lock', capslock: 'Caps_Lock', scrolllock: 'Scroll_Lock',
|
||||
return: 'Return',
|
||||
enter: 'Return',
|
||||
tab: 'Tab',
|
||||
space: 'space',
|
||||
backspace: 'BackSpace',
|
||||
delete: 'Delete',
|
||||
escape: 'Escape',
|
||||
esc: 'Escape',
|
||||
left: 'Left',
|
||||
up: 'Up',
|
||||
right: 'Right',
|
||||
down: 'Down',
|
||||
home: 'Home',
|
||||
end: 'End',
|
||||
pageup: 'Prior',
|
||||
pagedown: 'Next',
|
||||
f1: 'F1',
|
||||
f2: 'F2',
|
||||
f3: 'F3',
|
||||
f4: 'F4',
|
||||
f5: 'F5',
|
||||
f6: 'F6',
|
||||
f7: 'F7',
|
||||
f8: 'F8',
|
||||
f9: 'F9',
|
||||
f10: 'F10',
|
||||
f11: 'F11',
|
||||
f12: 'F12',
|
||||
shift: 'shift',
|
||||
lshift: 'shift',
|
||||
rshift: 'shift',
|
||||
control: 'ctrl',
|
||||
ctrl: 'ctrl',
|
||||
lcontrol: 'ctrl',
|
||||
rcontrol: 'ctrl',
|
||||
alt: 'alt',
|
||||
option: 'alt',
|
||||
lalt: 'alt',
|
||||
ralt: 'alt',
|
||||
win: 'super',
|
||||
meta: 'super',
|
||||
command: 'super',
|
||||
cmd: 'super',
|
||||
super: 'super',
|
||||
insert: 'Insert',
|
||||
printscreen: 'Print',
|
||||
pause: 'Pause',
|
||||
numlock: 'Num_Lock',
|
||||
capslock: 'Caps_Lock',
|
||||
scrolllock: 'Scroll_Lock',
|
||||
}
|
||||
|
||||
const MODIFIER_KEYS = new Set([
|
||||
'shift', 'lshift', 'rshift', 'control', 'ctrl', 'lcontrol', 'rcontrol',
|
||||
'alt', 'option', 'lalt', 'ralt', 'win', 'meta', 'command', 'cmd', 'super',
|
||||
'shift',
|
||||
'lshift',
|
||||
'rshift',
|
||||
'control',
|
||||
'ctrl',
|
||||
'lcontrol',
|
||||
'rcontrol',
|
||||
'alt',
|
||||
'option',
|
||||
'lalt',
|
||||
'ralt',
|
||||
'win',
|
||||
'meta',
|
||||
'command',
|
||||
'cmd',
|
||||
'super',
|
||||
])
|
||||
|
||||
function mapKey(name: string): string {
|
||||
@@ -68,7 +120,13 @@ function mouseButtonNum(button: 'left' | 'right' | 'middle'): string {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const moveMouse: InputBackend['moveMouse'] = async (x, y, _animated) => {
|
||||
run(['xdotool', 'mousemove', '--sync', String(Math.round(x)), String(Math.round(y))])
|
||||
run([
|
||||
'xdotool',
|
||||
'mousemove',
|
||||
'--sync',
|
||||
String(Math.round(x)),
|
||||
String(Math.round(y)),
|
||||
])
|
||||
}
|
||||
|
||||
export const mouseLocation: InputBackend['mouseLocation'] = async () => {
|
||||
@@ -82,7 +140,11 @@ export const mouseLocation: InputBackend['mouseLocation'] = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
export const mouseButton: InputBackend['mouseButton'] = async (button, action, count) => {
|
||||
export const mouseButton: InputBackend['mouseButton'] = async (
|
||||
button,
|
||||
action,
|
||||
count,
|
||||
) => {
|
||||
const btn = mouseButtonNum(button)
|
||||
if (action === 'click') {
|
||||
const n = count ?? 1
|
||||
@@ -94,7 +156,10 @@ export const mouseButton: InputBackend['mouseButton'] = async (button, action, c
|
||||
}
|
||||
}
|
||||
|
||||
export const mouseScroll: InputBackend['mouseScroll'] = async (amount, direction) => {
|
||||
export const mouseScroll: InputBackend['mouseScroll'] = async (
|
||||
amount,
|
||||
direction,
|
||||
) => {
|
||||
// xdotool click 4=scroll up, 5=scroll down, 6=scroll left, 7=scroll right
|
||||
// Positive amount = down/right, negative = up/left
|
||||
if (direction === 'vertical') {
|
||||
@@ -121,7 +186,7 @@ export const key: InputBackend['key'] = async (keyName, action) => {
|
||||
}
|
||||
}
|
||||
|
||||
export const keys: InputBackend['keys'] = async (parts) => {
|
||||
export const keys: InputBackend['keys'] = async parts => {
|
||||
// xdotool key accepts "modifier+modifier+key" format
|
||||
const modifiers: string[] = []
|
||||
let finalKey: string | null = null
|
||||
@@ -139,7 +204,7 @@ export const keys: InputBackend['keys'] = async (parts) => {
|
||||
run(['xdotool', 'key', combo])
|
||||
}
|
||||
|
||||
export const typeText: InputBackend['typeText'] = async (text) => {
|
||||
export const typeText: InputBackend['typeText'] = async text => {
|
||||
run(['xdotool', 'type', '--delay', '12', text])
|
||||
}
|
||||
|
||||
@@ -157,16 +222,23 @@ export const getFrontmostAppInfo: InputBackend['getFrontmostAppInfo'] = () => {
|
||||
let exePath = ''
|
||||
try {
|
||||
exePath = run(['readlink', '-f', `/proc/${pid}/exe`])
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
// Read the process name from /proc/comm
|
||||
let appName = ''
|
||||
try {
|
||||
appName = run(['cat', `/proc/${pid}/comm`])
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
if (!exePath && !appName) return null
|
||||
return { bundleId: exePath || `/proc/${pid}/exe`, appName: appName || 'unknown' }
|
||||
return {
|
||||
bundleId: exePath || `/proc/${pid}/exe`,
|
||||
appName: appName || 'unknown',
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -92,43 +92,112 @@ public class CuWin32 {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const VK_MAP: Record<string, number> = {
|
||||
return: 0x0D, enter: 0x0D, tab: 0x09, space: 0x20,
|
||||
backspace: 0x08, delete: 0x2E, escape: 0x1B, esc: 0x1B,
|
||||
left: 0x25, up: 0x26, right: 0x27, down: 0x28,
|
||||
home: 0x24, end: 0x23, pageup: 0x21, pagedown: 0x22,
|
||||
f1: 0x70, f2: 0x71, f3: 0x72, f4: 0x73, f5: 0x74, f6: 0x75,
|
||||
f7: 0x76, f8: 0x77, f9: 0x78, f10: 0x79, f11: 0x7A, f12: 0x7B,
|
||||
shift: 0xA0, lshift: 0xA0, rshift: 0xA1,
|
||||
control: 0xA2, ctrl: 0xA2, lcontrol: 0xA2, rcontrol: 0xA3,
|
||||
alt: 0xA4, option: 0xA4, lalt: 0xA4, ralt: 0xA5,
|
||||
win: 0x5B, meta: 0x5B, command: 0x5B, cmd: 0x5B, super: 0x5B,
|
||||
insert: 0x2D, printscreen: 0x2C, pause: 0x13,
|
||||
numlock: 0x90, capslock: 0x14, scrolllock: 0x91,
|
||||
return: 0x0d,
|
||||
enter: 0x0d,
|
||||
tab: 0x09,
|
||||
space: 0x20,
|
||||
backspace: 0x08,
|
||||
delete: 0x2e,
|
||||
escape: 0x1b,
|
||||
esc: 0x1b,
|
||||
left: 0x25,
|
||||
up: 0x26,
|
||||
right: 0x27,
|
||||
down: 0x28,
|
||||
home: 0x24,
|
||||
end: 0x23,
|
||||
pageup: 0x21,
|
||||
pagedown: 0x22,
|
||||
f1: 0x70,
|
||||
f2: 0x71,
|
||||
f3: 0x72,
|
||||
f4: 0x73,
|
||||
f5: 0x74,
|
||||
f6: 0x75,
|
||||
f7: 0x76,
|
||||
f8: 0x77,
|
||||
f9: 0x78,
|
||||
f10: 0x79,
|
||||
f11: 0x7a,
|
||||
f12: 0x7b,
|
||||
shift: 0xa0,
|
||||
lshift: 0xa0,
|
||||
rshift: 0xa1,
|
||||
control: 0xa2,
|
||||
ctrl: 0xa2,
|
||||
lcontrol: 0xa2,
|
||||
rcontrol: 0xa3,
|
||||
alt: 0xa4,
|
||||
option: 0xa4,
|
||||
lalt: 0xa4,
|
||||
ralt: 0xa5,
|
||||
win: 0x5b,
|
||||
meta: 0x5b,
|
||||
command: 0x5b,
|
||||
cmd: 0x5b,
|
||||
super: 0x5b,
|
||||
insert: 0x2d,
|
||||
printscreen: 0x2c,
|
||||
pause: 0x13,
|
||||
numlock: 0x90,
|
||||
capslock: 0x14,
|
||||
scrolllock: 0x91,
|
||||
}
|
||||
|
||||
const MODIFIER_KEYS = new Set(['shift', 'lshift', 'rshift', 'control', 'ctrl', 'lcontrol', 'rcontrol', 'alt', 'option', 'lalt', 'ralt', 'win', 'meta', 'command', 'cmd', 'super'])
|
||||
const MODIFIER_KEYS = new Set([
|
||||
'shift',
|
||||
'lshift',
|
||||
'rshift',
|
||||
'control',
|
||||
'ctrl',
|
||||
'lcontrol',
|
||||
'rcontrol',
|
||||
'alt',
|
||||
'option',
|
||||
'lalt',
|
||||
'ralt',
|
||||
'win',
|
||||
'meta',
|
||||
'command',
|
||||
'cmd',
|
||||
'super',
|
||||
])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const moveMouse: InputBackend['moveMouse'] = async (x, y, _animated) => {
|
||||
ps(`${WIN32_TYPES}; [CuWin32]::SetCursorPos(${Math.round(x)}, ${Math.round(y)}) | Out-Null`)
|
||||
ps(
|
||||
`${WIN32_TYPES}; [CuWin32]::SetCursorPos(${Math.round(x)}, ${Math.round(y)}) | Out-Null`,
|
||||
)
|
||||
}
|
||||
|
||||
export const mouseLocation: InputBackend['mouseLocation'] = async () => {
|
||||
const out = ps(`${WIN32_TYPES}; $p = New-Object CuWin32+POINT; [CuWin32]::GetCursorPos([ref]$p) | Out-Null; "$($p.X),$($p.Y)"`)
|
||||
const out = ps(
|
||||
`${WIN32_TYPES}; $p = New-Object CuWin32+POINT; [CuWin32]::GetCursorPos([ref]$p) | Out-Null; "$($p.X),$($p.Y)"`,
|
||||
)
|
||||
const [xStr, yStr] = out.split(',')
|
||||
return { x: Number(xStr), y: Number(yStr) }
|
||||
}
|
||||
|
||||
export const mouseButton: InputBackend['mouseButton'] = async (button, action, count) => {
|
||||
const downFlag = button === 'left' ? 'MOUSEEVENTF_LEFTDOWN'
|
||||
: button === 'right' ? 'MOUSEEVENTF_RIGHTDOWN'
|
||||
: 'MOUSEEVENTF_MIDDLEDOWN'
|
||||
const upFlag = button === 'left' ? 'MOUSEEVENTF_LEFTUP'
|
||||
: button === 'right' ? 'MOUSEEVENTF_RIGHTUP'
|
||||
: 'MOUSEEVENTF_MIDDLEUP'
|
||||
export const mouseButton: InputBackend['mouseButton'] = async (
|
||||
button,
|
||||
action,
|
||||
count,
|
||||
) => {
|
||||
const downFlag =
|
||||
button === 'left'
|
||||
? 'MOUSEEVENTF_LEFTDOWN'
|
||||
: button === 'right'
|
||||
? 'MOUSEEVENTF_RIGHTDOWN'
|
||||
: 'MOUSEEVENTF_MIDDLEDOWN'
|
||||
const upFlag =
|
||||
button === 'left'
|
||||
? 'MOUSEEVENTF_LEFTUP'
|
||||
: button === 'right'
|
||||
? 'MOUSEEVENTF_RIGHTUP'
|
||||
: 'MOUSEEVENTF_MIDDLEUP'
|
||||
|
||||
if (action === 'click') {
|
||||
const n = count ?? 1
|
||||
@@ -136,17 +205,29 @@ export const mouseButton: InputBackend['mouseButton'] = async (button, action, c
|
||||
for (let i = 0; i < n; i++) {
|
||||
clicks += `$i.mi.dwFlags=[CuWin32]::${downFlag}; [CuWin32]::SendInput(1, @($i), [Runtime.InteropServices.Marshal]::SizeOf($i)) | Out-Null; $i.mi.dwFlags=[CuWin32]::${upFlag}; [CuWin32]::SendInput(1, @($i), [Runtime.InteropServices.Marshal]::SizeOf($i)) | Out-Null; `
|
||||
}
|
||||
ps(`${WIN32_TYPES}; $i = New-Object CuWin32+INPUT; $i.type=[CuWin32]::INPUT_MOUSE; ${clicks}`)
|
||||
ps(
|
||||
`${WIN32_TYPES}; $i = New-Object CuWin32+INPUT; $i.type=[CuWin32]::INPUT_MOUSE; ${clicks}`,
|
||||
)
|
||||
} else if (action === 'press') {
|
||||
ps(`${WIN32_TYPES}; $i = New-Object CuWin32+INPUT; $i.type=[CuWin32]::INPUT_MOUSE; $i.mi.dwFlags=[CuWin32]::${downFlag}; [CuWin32]::SendInput(1, @($i), [Runtime.InteropServices.Marshal]::SizeOf($i)) | Out-Null`)
|
||||
ps(
|
||||
`${WIN32_TYPES}; $i = New-Object CuWin32+INPUT; $i.type=[CuWin32]::INPUT_MOUSE; $i.mi.dwFlags=[CuWin32]::${downFlag}; [CuWin32]::SendInput(1, @($i), [Runtime.InteropServices.Marshal]::SizeOf($i)) | Out-Null`,
|
||||
)
|
||||
} else {
|
||||
ps(`${WIN32_TYPES}; $i = New-Object CuWin32+INPUT; $i.type=[CuWin32]::INPUT_MOUSE; $i.mi.dwFlags=[CuWin32]::${upFlag}; [CuWin32]::SendInput(1, @($i), [Runtime.InteropServices.Marshal]::SizeOf($i)) | Out-Null`)
|
||||
ps(
|
||||
`${WIN32_TYPES}; $i = New-Object CuWin32+INPUT; $i.type=[CuWin32]::INPUT_MOUSE; $i.mi.dwFlags=[CuWin32]::${upFlag}; [CuWin32]::SendInput(1, @($i), [Runtime.InteropServices.Marshal]::SizeOf($i)) | Out-Null`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const mouseScroll: InputBackend['mouseScroll'] = async (amount, direction) => {
|
||||
const flag = direction === 'vertical' ? 'MOUSEEVENTF_WHEEL' : 'MOUSEEVENTF_HWHEEL'
|
||||
ps(`${WIN32_TYPES}; $i = New-Object CuWin32+INPUT; $i.type=[CuWin32]::INPUT_MOUSE; $i.mi.dwFlags=[CuWin32]::${flag}; $i.mi.mouseData=${amount * 120}; [CuWin32]::SendInput(1, @($i), [Runtime.InteropServices.Marshal]::SizeOf($i)) | Out-Null`)
|
||||
export const mouseScroll: InputBackend['mouseScroll'] = async (
|
||||
amount,
|
||||
direction,
|
||||
) => {
|
||||
const flag =
|
||||
direction === 'vertical' ? 'MOUSEEVENTF_WHEEL' : 'MOUSEEVENTF_HWHEEL'
|
||||
ps(
|
||||
`${WIN32_TYPES}; $i = New-Object CuWin32+INPUT; $i.type=[CuWin32]::INPUT_MOUSE; $i.mi.dwFlags=[CuWin32]::${flag}; $i.mi.mouseData=${amount * 120}; [CuWin32]::SendInput(1, @($i), [Runtime.InteropServices.Marshal]::SizeOf($i)) | Out-Null`,
|
||||
)
|
||||
}
|
||||
|
||||
export const key: InputBackend['key'] = async (keyName, action) => {
|
||||
@@ -154,15 +235,19 @@ export const key: InputBackend['key'] = async (keyName, action) => {
|
||||
const vk = VK_MAP[lower]
|
||||
const flags = action === 'release' ? '2' : '0'
|
||||
if (vk !== undefined) {
|
||||
ps(`${WIN32_TYPES}; [CuWin32]::keybd_event(${vk}, 0, ${flags}, [UIntPtr]::Zero)`)
|
||||
ps(
|
||||
`${WIN32_TYPES}; [CuWin32]::keybd_event(${vk}, 0, ${flags}, [UIntPtr]::Zero)`,
|
||||
)
|
||||
} else if (keyName.length === 1) {
|
||||
// Single character — use VkKeyScan to resolve
|
||||
const charCode = keyName.charCodeAt(0)
|
||||
ps(`${WIN32_TYPES}; $vk = [CuWin32]::VkKeyScan([char]${charCode}) -band 0xFF; [CuWin32]::keybd_event([byte]$vk, 0, ${flags}, [UIntPtr]::Zero)`)
|
||||
ps(
|
||||
`${WIN32_TYPES}; $vk = [CuWin32]::VkKeyScan([char]${charCode}) -band 0xFF; [CuWin32]::keybd_event([byte]$vk, 0, ${flags}, [UIntPtr]::Zero)`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const keys: InputBackend['keys'] = async (parts) => {
|
||||
export const keys: InputBackend['keys'] = async parts => {
|
||||
const modifiers: number[] = []
|
||||
let finalKey: string | null = null
|
||||
|
||||
@@ -196,9 +281,11 @@ export const keys: InputBackend['keys'] = async (parts) => {
|
||||
ps(script)
|
||||
}
|
||||
|
||||
export const typeText: InputBackend['typeText'] = async (text) => {
|
||||
export const typeText: InputBackend['typeText'] = async text => {
|
||||
const escaped = text.replace(/'/g, "''")
|
||||
ps(`Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait('${escaped}')`)
|
||||
ps(
|
||||
`Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait('${escaped}')`,
|
||||
)
|
||||
}
|
||||
|
||||
export const getFrontmostAppInfo: InputBackend['getFrontmostAppInfo'] = () => {
|
||||
|
||||
@@ -15,8 +15,15 @@ export interface InputBackend {
|
||||
key(key: string, action: 'press' | 'release'): Promise<void>
|
||||
keys(parts: string[]): Promise<void>
|
||||
mouseLocation(): Promise<{ x: number; y: number }>
|
||||
mouseButton(button: 'left' | 'right' | 'middle', action: 'click' | 'press' | 'release', count?: number): Promise<void>
|
||||
mouseScroll(amount: number, direction: 'vertical' | 'horizontal'): Promise<void>
|
||||
mouseButton(
|
||||
button: 'left' | 'right' | 'middle',
|
||||
action: 'click' | 'press' | 'release',
|
||||
count?: number,
|
||||
): Promise<void>
|
||||
mouseScroll(
|
||||
amount: number,
|
||||
direction: 'vertical' | 'horizontal',
|
||||
): Promise<void>
|
||||
typeText(text: string): Promise<void>
|
||||
getFrontmostAppInfo(): FrontmostAppInfo | null
|
||||
}
|
||||
@@ -60,5 +67,7 @@ export class ComputerUseInputAPI {
|
||||
declare isSupported: true
|
||||
}
|
||||
|
||||
interface ComputerUseInputUnsupported { isSupported: false }
|
||||
interface ComputerUseInputUnsupported {
|
||||
isSupported: false
|
||||
}
|
||||
export type ComputerUseInput = ComputerUseInputAPI | ComputerUseInputUnsupported
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export interface FrontmostAppInfo {
|
||||
bundleId: string // macOS: bundle ID, Windows: exe path
|
||||
bundleId: string // macOS: bundle ID, Windows: exe path
|
||||
appName: string
|
||||
}
|
||||
|
||||
@@ -13,7 +13,10 @@ export interface InputBackend {
|
||||
action: 'click' | 'press' | 'release',
|
||||
count?: number,
|
||||
): Promise<void>
|
||||
mouseScroll(amount: number, direction: 'vertical' | 'horizontal'): Promise<void>
|
||||
mouseScroll(
|
||||
amount: number,
|
||||
direction: 'vertical' | 'horizontal',
|
||||
): Promise<void>
|
||||
typeText(text: string): Promise<void>
|
||||
getFrontmostAppInfo(): FrontmostAppInfo | null
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "@ant/computer-use-mcp",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./sentinelApps": "./src/sentinelApps.ts",
|
||||
"./types": "./src/types.ts"
|
||||
}
|
||||
"name": "@ant/computer-use-mcp",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./sentinelApps": "./src/sentinelApps.ts",
|
||||
"./types": "./src/types.ts"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
* duplicated as a string literal below rather than imported.
|
||||
*/
|
||||
|
||||
export type DeniedCategory = "browser" | "terminal" | "trading";
|
||||
export type DeniedCategory = 'browser' | 'terminal' | 'trading'
|
||||
|
||||
/**
|
||||
* Map a category to its hardcoded tier. Return-type is the string-literal
|
||||
@@ -44,54 +44,54 @@ export type DeniedCategory = "browser" | "terminal" | "trading";
|
||||
*/
|
||||
export function categoryToTier(
|
||||
category: DeniedCategory | null,
|
||||
): "read" | "click" | "full" {
|
||||
if (category === "browser" || category === "trading") return "read";
|
||||
if (category === "terminal") return "click";
|
||||
return "full";
|
||||
): 'read' | 'click' | 'full' {
|
||||
if (category === 'browser' || category === 'trading') return 'read'
|
||||
if (category === 'terminal') return 'click'
|
||||
return 'full'
|
||||
}
|
||||
|
||||
// ─── Bundle-ID deny sets (macOS) ─────────────────────────────────────────
|
||||
|
||||
const BROWSER_BUNDLE_IDS: ReadonlySet<string> = new Set([
|
||||
// Apple
|
||||
"com.apple.Safari",
|
||||
"com.apple.SafariTechnologyPreview",
|
||||
'com.apple.Safari',
|
||||
'com.apple.SafariTechnologyPreview',
|
||||
// Google
|
||||
"com.google.Chrome",
|
||||
"com.google.Chrome.beta",
|
||||
"com.google.Chrome.dev",
|
||||
"com.google.Chrome.canary",
|
||||
'com.google.Chrome',
|
||||
'com.google.Chrome.beta',
|
||||
'com.google.Chrome.dev',
|
||||
'com.google.Chrome.canary',
|
||||
// Microsoft
|
||||
"com.microsoft.edgemac",
|
||||
"com.microsoft.edgemac.Beta",
|
||||
"com.microsoft.edgemac.Dev",
|
||||
"com.microsoft.edgemac.Canary",
|
||||
'com.microsoft.edgemac',
|
||||
'com.microsoft.edgemac.Beta',
|
||||
'com.microsoft.edgemac.Dev',
|
||||
'com.microsoft.edgemac.Canary',
|
||||
// Mozilla
|
||||
"org.mozilla.firefox",
|
||||
"org.mozilla.firefoxdeveloperedition",
|
||||
"org.mozilla.nightly",
|
||||
'org.mozilla.firefox',
|
||||
'org.mozilla.firefoxdeveloperedition',
|
||||
'org.mozilla.nightly',
|
||||
// Chromium-based
|
||||
"org.chromium.Chromium",
|
||||
"com.brave.Browser",
|
||||
"com.brave.Browser.beta",
|
||||
"com.brave.Browser.nightly",
|
||||
"com.operasoftware.Opera",
|
||||
"com.operasoftware.OperaGX",
|
||||
"com.operasoftware.OperaDeveloper",
|
||||
"com.vivaldi.Vivaldi",
|
||||
'org.chromium.Chromium',
|
||||
'com.brave.Browser',
|
||||
'com.brave.Browser.beta',
|
||||
'com.brave.Browser.nightly',
|
||||
'com.operasoftware.Opera',
|
||||
'com.operasoftware.OperaGX',
|
||||
'com.operasoftware.OperaDeveloper',
|
||||
'com.vivaldi.Vivaldi',
|
||||
// The Browser Company
|
||||
"company.thebrowser.Browser", // Arc
|
||||
"company.thebrowser.dia", // Dia (agentic)
|
||||
'company.thebrowser.Browser', // Arc
|
||||
'company.thebrowser.dia', // Dia (agentic)
|
||||
// Privacy-focused
|
||||
"org.torproject.torbrowser",
|
||||
"com.duckduckgo.macos.browser",
|
||||
"ru.yandex.desktop.yandex-browser",
|
||||
'org.torproject.torbrowser',
|
||||
'com.duckduckgo.macos.browser',
|
||||
'ru.yandex.desktop.yandex-browser',
|
||||
// Agentic / AI browsers — newer entrants with LLM integrations
|
||||
"ai.perplexity.comet",
|
||||
"com.sigmaos.sigmaos.macos", // SigmaOS
|
||||
'ai.perplexity.comet',
|
||||
'com.sigmaos.sigmaos.macos', // SigmaOS
|
||||
// Webkit-based misc
|
||||
"com.kagi.kagimacOS", // Orion
|
||||
]);
|
||||
'com.kagi.kagimacOS', // Orion
|
||||
])
|
||||
|
||||
/**
|
||||
* Terminals + IDEs with integrated terminals. Supersets
|
||||
@@ -101,66 +101,66 @@ const BROWSER_BUNDLE_IDS: ReadonlySet<string> = new Set([
|
||||
*/
|
||||
const TERMINAL_BUNDLE_IDS: ReadonlySet<string> = new Set([
|
||||
// Dedicated terminals
|
||||
"com.apple.Terminal",
|
||||
"com.googlecode.iterm2",
|
||||
"dev.warp.Warp-Stable",
|
||||
"dev.warp.Warp-Beta",
|
||||
"com.github.wez.wezterm",
|
||||
"org.alacritty",
|
||||
"io.alacritty", // pre-v0.11.0 (renamed 2022-07) — kept for legacy installs
|
||||
"net.kovidgoyal.kitty",
|
||||
"co.zeit.hyper",
|
||||
"com.mitchellh.ghostty",
|
||||
"org.tabby",
|
||||
"com.termius-dmg.mac", // Termius
|
||||
'com.apple.Terminal',
|
||||
'com.googlecode.iterm2',
|
||||
'dev.warp.Warp-Stable',
|
||||
'dev.warp.Warp-Beta',
|
||||
'com.github.wez.wezterm',
|
||||
'org.alacritty',
|
||||
'io.alacritty', // pre-v0.11.0 (renamed 2022-07) — kept for legacy installs
|
||||
'net.kovidgoyal.kitty',
|
||||
'co.zeit.hyper',
|
||||
'com.mitchellh.ghostty',
|
||||
'org.tabby',
|
||||
'com.termius-dmg.mac', // Termius
|
||||
// IDEs with integrated terminals — we can't distinguish "type in the
|
||||
// editor" from "type in the integrated terminal" via screenshot+click.
|
||||
// VS Code family
|
||||
"com.microsoft.VSCode",
|
||||
"com.microsoft.VSCodeInsiders",
|
||||
"com.vscodium", // VSCodium
|
||||
"com.todesktop.230313mzl4w4u92", // Cursor
|
||||
"com.exafunction.windsurf", // Windsurf / Codeium
|
||||
"dev.zed.Zed",
|
||||
"dev.zed.Zed-Preview",
|
||||
'com.microsoft.VSCode',
|
||||
'com.microsoft.VSCodeInsiders',
|
||||
'com.vscodium', // VSCodium
|
||||
'com.todesktop.230313mzl4w4u92', // Cursor
|
||||
'com.exafunction.windsurf', // Windsurf / Codeium
|
||||
'dev.zed.Zed',
|
||||
'dev.zed.Zed-Preview',
|
||||
// JetBrains family (all have integrated terminals)
|
||||
"com.jetbrains.intellij",
|
||||
"com.jetbrains.intellij.ce",
|
||||
"com.jetbrains.pycharm",
|
||||
"com.jetbrains.pycharm.ce",
|
||||
"com.jetbrains.WebStorm",
|
||||
"com.jetbrains.CLion",
|
||||
"com.jetbrains.goland",
|
||||
"com.jetbrains.rubymine",
|
||||
"com.jetbrains.PhpStorm",
|
||||
"com.jetbrains.datagrip",
|
||||
"com.jetbrains.rider",
|
||||
"com.jetbrains.AppCode",
|
||||
"com.jetbrains.rustrover",
|
||||
"com.jetbrains.fleet",
|
||||
"com.google.android.studio", // Android Studio (JetBrains-based)
|
||||
'com.jetbrains.intellij',
|
||||
'com.jetbrains.intellij.ce',
|
||||
'com.jetbrains.pycharm',
|
||||
'com.jetbrains.pycharm.ce',
|
||||
'com.jetbrains.WebStorm',
|
||||
'com.jetbrains.CLion',
|
||||
'com.jetbrains.goland',
|
||||
'com.jetbrains.rubymine',
|
||||
'com.jetbrains.PhpStorm',
|
||||
'com.jetbrains.datagrip',
|
||||
'com.jetbrains.rider',
|
||||
'com.jetbrains.AppCode',
|
||||
'com.jetbrains.rustrover',
|
||||
'com.jetbrains.fleet',
|
||||
'com.google.android.studio', // Android Studio (JetBrains-based)
|
||||
// Other IDEs
|
||||
"com.axosoft.gitkraken", // GitKraken has an integrated terminal panel. Also keeps the "kraken" trading-substring from miscategorizing it — bundle-ID wins.
|
||||
"com.sublimetext.4",
|
||||
"com.sublimetext.3",
|
||||
"org.vim.MacVim",
|
||||
"com.neovim.neovim",
|
||||
"org.gnu.Emacs",
|
||||
'com.axosoft.gitkraken', // GitKraken has an integrated terminal panel. Also keeps the "kraken" trading-substring from miscategorizing it — bundle-ID wins.
|
||||
'com.sublimetext.4',
|
||||
'com.sublimetext.3',
|
||||
'org.vim.MacVim',
|
||||
'com.neovim.neovim',
|
||||
'org.gnu.Emacs',
|
||||
// Xcode's previous carve-out (full tier for Interface Builder / simulator)
|
||||
// was reversed — at tier "click" IB and simulator taps still work (both are
|
||||
// plain clicks) while the integrated terminal is blocked from keyboard input.
|
||||
"com.apple.dt.Xcode",
|
||||
"org.eclipse.platform.ide",
|
||||
"org.netbeans.ide",
|
||||
"com.microsoft.visual-studio", // Visual Studio for Mac
|
||||
'com.apple.dt.Xcode',
|
||||
'org.eclipse.platform.ide',
|
||||
'org.netbeans.ide',
|
||||
'com.microsoft.visual-studio', // Visual Studio for Mac
|
||||
// AppleScript/automation execution surfaces — same threat as terminals:
|
||||
// type(script) → key("cmd+r") runs arbitrary code. Added after #28011
|
||||
// removed the osascript MCP server, making CU the only tool-call route
|
||||
// to AppleScript.
|
||||
"com.apple.ScriptEditor2",
|
||||
"com.apple.Automator",
|
||||
"com.apple.shortcuts",
|
||||
]);
|
||||
'com.apple.ScriptEditor2',
|
||||
'com.apple.Automator',
|
||||
'com.apple.shortcuts',
|
||||
])
|
||||
|
||||
/**
|
||||
* Trading / crypto platforms — granted at tier `"read"` so the agent can see
|
||||
@@ -178,29 +178,29 @@ const TERMINAL_BUNDLE_IDS: ReadonlySet<string> = new Set([
|
||||
const TRADING_BUNDLE_IDS: ReadonlySet<string> = new Set([
|
||||
// Verified via Homebrew quit/zap stanzas + mdls + electron-builder source.
|
||||
// Trading
|
||||
"com.webull.desktop.v1", // Webull (direct download, Qt)
|
||||
"com.webull.trade.mac.v1", // Webull (Mac App Store)
|
||||
"com.tastytrade.desktop",
|
||||
"com.tradingview.tradingviewapp.desktop",
|
||||
"com.fidelity.activetrader", // Fidelity Trader+ (new)
|
||||
"com.fmr.activetrader", // Fidelity Active Trader Pro (legacy)
|
||||
'com.webull.desktop.v1', // Webull (direct download, Qt)
|
||||
'com.webull.trade.mac.v1', // Webull (Mac App Store)
|
||||
'com.tastytrade.desktop',
|
||||
'com.tradingview.tradingviewapp.desktop',
|
||||
'com.fidelity.activetrader', // Fidelity Trader+ (new)
|
||||
'com.fmr.activetrader', // Fidelity Active Trader Pro (legacy)
|
||||
// Interactive Brokers TWS — install4j wrapper; Homebrew quit stanza is
|
||||
// authoritative for this exact value but install4j IDs can drift across
|
||||
// major versions — name-substring "trader workstation" is the fallback.
|
||||
"com.install4j.5889-6375-8446-2021",
|
||||
'com.install4j.5889-6375-8446-2021',
|
||||
// Crypto
|
||||
"com.binance.BinanceDesktop",
|
||||
"com.electron.exodus",
|
||||
'com.binance.BinanceDesktop',
|
||||
'com.electron.exodus',
|
||||
// Electrum uses PyInstaller with bundle_identifier=None → defaults to
|
||||
// org.pythonmac.unspecified.<AppName>. Confirmed in spesmilo/electrum
|
||||
// source + Homebrew zap. IntuneBrew's "org.electrum.electrum" is a fork.
|
||||
"org.pythonmac.unspecified.Electrum",
|
||||
"com.ledger.live",
|
||||
"io.trezor.TrezorSuite",
|
||||
'org.pythonmac.unspecified.Electrum',
|
||||
'com.ledger.live',
|
||||
'io.trezor.TrezorSuite',
|
||||
// No native macOS app (name-substring only): Schwab, E*TRADE, TradeStation,
|
||||
// Robinhood, NinjaTrader, Coinbase, Kraken, Bloomberg. thinkorswim
|
||||
// install4j ID drifts per-install — substring safer.
|
||||
]);
|
||||
])
|
||||
|
||||
// ─── Policy-deny (not a tier — cannot be granted at all) ─────────────────
|
||||
//
|
||||
@@ -215,78 +215,78 @@ const TRADING_BUNDLE_IDS: ReadonlySet<string> = new Set([
|
||||
const POLICY_DENIED_BUNDLE_IDS: ReadonlySet<string> = new Set([
|
||||
// Verified via Homebrew quit/zap + mdls /System/Applications + IntuneBrew.
|
||||
// Apple built-ins
|
||||
"com.apple.TV",
|
||||
"com.apple.Music",
|
||||
"com.apple.iBooksX",
|
||||
"com.apple.podcasts",
|
||||
'com.apple.TV',
|
||||
'com.apple.Music',
|
||||
'com.apple.iBooksX',
|
||||
'com.apple.podcasts',
|
||||
// Music
|
||||
"com.spotify.client",
|
||||
"com.amazon.music",
|
||||
"com.tidal.desktop",
|
||||
"com.deezer.deezer-desktop",
|
||||
"com.pandora.desktop",
|
||||
"com.electron.pocket-casts", // direct-download Electron wrapper
|
||||
"au.com.shiftyjelly.PocketCasts", // Mac App Store
|
||||
'com.spotify.client',
|
||||
'com.amazon.music',
|
||||
'com.tidal.desktop',
|
||||
'com.deezer.deezer-desktop',
|
||||
'com.pandora.desktop',
|
||||
'com.electron.pocket-casts', // direct-download Electron wrapper
|
||||
'au.com.shiftyjelly.PocketCasts', // Mac App Store
|
||||
// Video
|
||||
"tv.plex.desktop",
|
||||
"tv.plex.htpc",
|
||||
"tv.plex.plexamp",
|
||||
"com.amazon.aiv.AIVApp", // Prime Video (iOS-on-Apple-Silicon)
|
||||
'tv.plex.desktop',
|
||||
'tv.plex.htpc',
|
||||
'tv.plex.plexamp',
|
||||
'com.amazon.aiv.AIVApp', // Prime Video (iOS-on-Apple-Silicon)
|
||||
// Ebooks
|
||||
"net.kovidgoyal.calibre",
|
||||
"com.amazon.Kindle", // legacy desktop, discontinued
|
||||
"com.amazon.Lassen", // current Mac App Store (iOS-on-Mac)
|
||||
"com.kobo.desktop.Kobo",
|
||||
'net.kovidgoyal.calibre',
|
||||
'com.amazon.Kindle', // legacy desktop, discontinued
|
||||
'com.amazon.Lassen', // current Mac App Store (iOS-on-Mac)
|
||||
'com.kobo.desktop.Kobo',
|
||||
// No native macOS app (name-substring only): Netflix, Disney+, Hulu,
|
||||
// HBO Max, Peacock, Paramount+, YouTube, Crunchyroll, Tubi, Vudu,
|
||||
// Audible, Reddit, NYTimes. Their iOS apps don't opt into iPad-on-Mac.
|
||||
]);
|
||||
])
|
||||
|
||||
const POLICY_DENIED_NAME_SUBSTRINGS: readonly string[] = [
|
||||
// Video streaming
|
||||
"netflix",
|
||||
"disney+",
|
||||
"hulu",
|
||||
"prime video",
|
||||
"apple tv",
|
||||
"peacock",
|
||||
"paramount+",
|
||||
'netflix',
|
||||
'disney+',
|
||||
'hulu',
|
||||
'prime video',
|
||||
'apple tv',
|
||||
'peacock',
|
||||
'paramount+',
|
||||
// "plex" is too generic — would match "Perplexity". Covered by
|
||||
// tv.plex.* bundle IDs on macOS.
|
||||
"tubi",
|
||||
"crunchyroll",
|
||||
"vudu",
|
||||
'tubi',
|
||||
'crunchyroll',
|
||||
'vudu',
|
||||
// E-readers / audiobooks
|
||||
"kindle",
|
||||
"apple books",
|
||||
"kobo",
|
||||
"play books",
|
||||
"calibre",
|
||||
"libby",
|
||||
"readium",
|
||||
"audible",
|
||||
"libro.fm",
|
||||
"speechify",
|
||||
'kindle',
|
||||
'apple books',
|
||||
'kobo',
|
||||
'play books',
|
||||
'calibre',
|
||||
'libby',
|
||||
'readium',
|
||||
'audible',
|
||||
'libro.fm',
|
||||
'speechify',
|
||||
// Music
|
||||
"spotify",
|
||||
"apple music",
|
||||
"amazon music",
|
||||
"youtube music",
|
||||
"tidal",
|
||||
"deezer",
|
||||
"pandora",
|
||||
"pocket casts",
|
||||
'spotify',
|
||||
'apple music',
|
||||
'amazon music',
|
||||
'youtube music',
|
||||
'tidal',
|
||||
'deezer',
|
||||
'pandora',
|
||||
'pocket casts',
|
||||
// Publisher / social apps (from the same blocklist tab)
|
||||
"naver",
|
||||
"reddit",
|
||||
"sony music",
|
||||
"vegas pro",
|
||||
"pitchfork",
|
||||
"economist",
|
||||
"nytimes",
|
||||
'naver',
|
||||
'reddit',
|
||||
'sony music',
|
||||
'vegas pro',
|
||||
'pitchfork',
|
||||
'economist',
|
||||
'nytimes',
|
||||
// Skipped (too generic for substring matching — need bundle ID):
|
||||
// HBO Max / Max, YouTube (non-Music), Nook, Sony Catalyst, Wired
|
||||
];
|
||||
]
|
||||
|
||||
/**
|
||||
* Policy-level auto-deny. Unlike `userDeniedBundleIds` (per-user Settings
|
||||
@@ -298,19 +298,19 @@ export function isPolicyDenied(
|
||||
bundleId: string | undefined,
|
||||
displayName: string,
|
||||
): boolean {
|
||||
if (bundleId && POLICY_DENIED_BUNDLE_IDS.has(bundleId)) return true;
|
||||
const lower = displayName.toLowerCase();
|
||||
if (bundleId && POLICY_DENIED_BUNDLE_IDS.has(bundleId)) return true
|
||||
const lower = displayName.toLowerCase()
|
||||
for (const sub of POLICY_DENIED_NAME_SUBSTRINGS) {
|
||||
if (lower.includes(sub)) return true;
|
||||
if (lower.includes(sub)) return true
|
||||
}
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
|
||||
export function getDeniedCategory(bundleId: string): DeniedCategory | null {
|
||||
if (BROWSER_BUNDLE_IDS.has(bundleId)) return "browser";
|
||||
if (TERMINAL_BUNDLE_IDS.has(bundleId)) return "terminal";
|
||||
if (TRADING_BUNDLE_IDS.has(bundleId)) return "trading";
|
||||
return null;
|
||||
if (BROWSER_BUNDLE_IDS.has(bundleId)) return 'browser'
|
||||
if (TERMINAL_BUNDLE_IDS.has(bundleId)) return 'terminal'
|
||||
if (TRADING_BUNDLE_IDS.has(bundleId)) return 'trading'
|
||||
return null
|
||||
}
|
||||
|
||||
// ─── Display-name fallback (cross-platform) ──────────────────────────────
|
||||
@@ -325,160 +325,160 @@ export function getDeniedCategory(bundleId: string): DeniedCategory | null {
|
||||
* first match, but groupings are by category for readability).
|
||||
*/
|
||||
const BROWSER_NAME_SUBSTRINGS: readonly string[] = [
|
||||
"safari",
|
||||
"chrome",
|
||||
"firefox",
|
||||
"microsoft edge",
|
||||
"brave",
|
||||
"opera",
|
||||
"vivaldi",
|
||||
"chromium",
|
||||
'safari',
|
||||
'chrome',
|
||||
'firefox',
|
||||
'microsoft edge',
|
||||
'brave',
|
||||
'opera',
|
||||
'vivaldi',
|
||||
'chromium',
|
||||
// Arc/Dia: the canonical display name is just "Arc"/"Dia" — too short for
|
||||
// substring matching (false-positives: "Arcade", "Diagram"). Covered by
|
||||
// bundle ID on macOS. The "... browser" entries below catch natural-language
|
||||
// phrasings ("the arc browser") but NOT the canonical short name.
|
||||
"arc browser",
|
||||
"tor browser",
|
||||
"duckduckgo",
|
||||
"yandex",
|
||||
"orion browser",
|
||||
'arc browser',
|
||||
'tor browser',
|
||||
'duckduckgo',
|
||||
'yandex',
|
||||
'orion browser',
|
||||
// Agentic / AI browsers
|
||||
"comet", // Perplexity's browser — "Comet" substring risks false positives
|
||||
'comet', // Perplexity's browser — "Comet" substring risks false positives
|
||||
// but leaving for now; "comet" in an app name is rare
|
||||
"sigmaos",
|
||||
"dia browser",
|
||||
];
|
||||
'sigmaos',
|
||||
'dia browser',
|
||||
]
|
||||
|
||||
const TERMINAL_NAME_SUBSTRINGS: readonly string[] = [
|
||||
// macOS / cross-platform terminals
|
||||
"terminal", // catches Terminal, Windows Terminal (NOT iTerm — separate entry)
|
||||
"iterm",
|
||||
"wezterm",
|
||||
"alacritty",
|
||||
"kitty",
|
||||
"ghostty",
|
||||
"tabby",
|
||||
"termius",
|
||||
'terminal', // catches Terminal, Windows Terminal (NOT iTerm — separate entry)
|
||||
'iterm',
|
||||
'wezterm',
|
||||
'alacritty',
|
||||
'kitty',
|
||||
'ghostty',
|
||||
'tabby',
|
||||
'termius',
|
||||
// AppleScript runners — see bundle-ID comment above. "shortcuts" is too
|
||||
// generic for substring matching (many apps have "shortcuts" in the name);
|
||||
// covered by bundle ID only, like warp/hyper.
|
||||
"script editor",
|
||||
"automator",
|
||||
'script editor',
|
||||
'automator',
|
||||
// NOTE: "warp" and "hyper" are too generic for substring matching —
|
||||
// they'd false-positive on "Warpaint" or "Hyperion". Covered by bundle ID
|
||||
// (dev.warp.Warp-Stable, co.zeit.hyper) for macOS; Windows exe-name
|
||||
// matching can be added when Windows CU ships.
|
||||
// Windows shells (activate when the darwin gate lifts)
|
||||
"powershell",
|
||||
"cmd.exe",
|
||||
"command prompt",
|
||||
"git bash",
|
||||
"conemu",
|
||||
"cmder",
|
||||
'powershell',
|
||||
'cmd.exe',
|
||||
'command prompt',
|
||||
'git bash',
|
||||
'conemu',
|
||||
'cmder',
|
||||
// IDEs (VS Code family)
|
||||
"visual studio code",
|
||||
"visual studio", // catches VS for Mac + Windows
|
||||
"vscode",
|
||||
"vs code",
|
||||
"vscodium",
|
||||
"cursor", // Cursor IDE — "cursor" is generic but IDE is the only common app
|
||||
"windsurf",
|
||||
'visual studio code',
|
||||
'visual studio', // catches VS for Mac + Windows
|
||||
'vscode',
|
||||
'vs code',
|
||||
'vscodium',
|
||||
'cursor', // Cursor IDE — "cursor" is generic but IDE is the only common app
|
||||
'windsurf',
|
||||
// Zed: display name is just "Zed" — too short for substring matching
|
||||
// (false-positives). Covered by bundle ID (dev.zed.Zed) on macOS.
|
||||
// IDEs (JetBrains family)
|
||||
"intellij",
|
||||
"pycharm",
|
||||
"webstorm",
|
||||
"clion",
|
||||
"goland",
|
||||
"rubymine",
|
||||
"phpstorm",
|
||||
"datagrip",
|
||||
"rider",
|
||||
"appcode",
|
||||
"rustrover",
|
||||
"fleet",
|
||||
"android studio",
|
||||
'intellij',
|
||||
'pycharm',
|
||||
'webstorm',
|
||||
'clion',
|
||||
'goland',
|
||||
'rubymine',
|
||||
'phpstorm',
|
||||
'datagrip',
|
||||
'rider',
|
||||
'appcode',
|
||||
'rustrover',
|
||||
'fleet',
|
||||
'android studio',
|
||||
// Other IDEs
|
||||
"sublime text",
|
||||
"macvim",
|
||||
"neovim",
|
||||
"emacs",
|
||||
"xcode",
|
||||
"eclipse",
|
||||
"netbeans",
|
||||
];
|
||||
'sublime text',
|
||||
'macvim',
|
||||
'neovim',
|
||||
'emacs',
|
||||
'xcode',
|
||||
'eclipse',
|
||||
'netbeans',
|
||||
]
|
||||
|
||||
const TRADING_NAME_SUBSTRINGS: readonly string[] = [
|
||||
// Trading — brokerage apps. Sourced from the ACP CU-apps blocklist xlsx
|
||||
// ("Read Only" tab). Name-substring safe for proper nouns below; generic
|
||||
// names (IG, Delta, HTX) are skipped and need bundle-ID matching once
|
||||
// verified.
|
||||
"bloomberg",
|
||||
"ameritrade",
|
||||
"thinkorswim",
|
||||
"schwab",
|
||||
"fidelity",
|
||||
"e*trade",
|
||||
"interactive brokers",
|
||||
"trader workstation", // Interactive Brokers TWS
|
||||
"tradestation",
|
||||
"webull",
|
||||
"robinhood",
|
||||
"tastytrade",
|
||||
"ninjatrader",
|
||||
"tradingview",
|
||||
"moomoo",
|
||||
"tradezero",
|
||||
"prorealtime",
|
||||
"plus500",
|
||||
"saxotrader",
|
||||
"oanda",
|
||||
"metatrader",
|
||||
"forex.com",
|
||||
"avaoptions",
|
||||
"ctrader",
|
||||
"jforex",
|
||||
"iq option",
|
||||
"olymp trade",
|
||||
"binomo",
|
||||
"pocket option",
|
||||
"raceoption",
|
||||
"expertoption",
|
||||
"quotex",
|
||||
"naga",
|
||||
"morgan stanley",
|
||||
"ubs neo",
|
||||
"eikon", // Thomson Reuters / LSEG Workspace
|
||||
'bloomberg',
|
||||
'ameritrade',
|
||||
'thinkorswim',
|
||||
'schwab',
|
||||
'fidelity',
|
||||
'e*trade',
|
||||
'interactive brokers',
|
||||
'trader workstation', // Interactive Brokers TWS
|
||||
'tradestation',
|
||||
'webull',
|
||||
'robinhood',
|
||||
'tastytrade',
|
||||
'ninjatrader',
|
||||
'tradingview',
|
||||
'moomoo',
|
||||
'tradezero',
|
||||
'prorealtime',
|
||||
'plus500',
|
||||
'saxotrader',
|
||||
'oanda',
|
||||
'metatrader',
|
||||
'forex.com',
|
||||
'avaoptions',
|
||||
'ctrader',
|
||||
'jforex',
|
||||
'iq option',
|
||||
'olymp trade',
|
||||
'binomo',
|
||||
'pocket option',
|
||||
'raceoption',
|
||||
'expertoption',
|
||||
'quotex',
|
||||
'naga',
|
||||
'morgan stanley',
|
||||
'ubs neo',
|
||||
'eikon', // Thomson Reuters / LSEG Workspace
|
||||
// Crypto — exchanges, wallets, portfolio trackers
|
||||
"coinbase",
|
||||
"kraken",
|
||||
"binance",
|
||||
"okx",
|
||||
"bybit",
|
||||
'coinbase',
|
||||
'kraken',
|
||||
'binance',
|
||||
'okx',
|
||||
'bybit',
|
||||
// "gate.io" is too generic — the ".io" TLD suffix is common in app names
|
||||
// (e.g., "Draw.io"). Needs bundle-ID matching once verified.
|
||||
"phemex",
|
||||
"stormgain",
|
||||
"crypto.com",
|
||||
'phemex',
|
||||
'stormgain',
|
||||
'crypto.com',
|
||||
// "exodus" is too generic — it's a common noun and would match unrelated
|
||||
// apps/games. Needs bundle-ID matching once verified.
|
||||
"electrum",
|
||||
"ledger live",
|
||||
"trezor",
|
||||
"guarda",
|
||||
"atomic wallet",
|
||||
"bitpay",
|
||||
"bisq",
|
||||
"koinly",
|
||||
"cointracker",
|
||||
"blockfi",
|
||||
"stripe cli",
|
||||
'electrum',
|
||||
'ledger live',
|
||||
'trezor',
|
||||
'guarda',
|
||||
'atomic wallet',
|
||||
'bitpay',
|
||||
'bisq',
|
||||
'koinly',
|
||||
'cointracker',
|
||||
'blockfi',
|
||||
'stripe cli',
|
||||
// Crypto games / metaverse (same trade-execution risk model)
|
||||
"decentraland",
|
||||
"axie infinity",
|
||||
"gods unchained",
|
||||
];
|
||||
'decentraland',
|
||||
'axie infinity',
|
||||
'gods unchained',
|
||||
]
|
||||
|
||||
/**
|
||||
* Display-name substring match. Called when bundle-ID resolution returned
|
||||
@@ -491,20 +491,20 @@ const TRADING_NAME_SUBSTRINGS: readonly string[] = [
|
||||
export function getDeniedCategoryByDisplayName(
|
||||
name: string,
|
||||
): DeniedCategory | null {
|
||||
const lower = name.toLowerCase();
|
||||
const lower = name.toLowerCase()
|
||||
// Trading first — proper-noun-only set, most specific. "Bloomberg Terminal"
|
||||
// contains "terminal" and would miscategorize if TERMINAL_NAME_SUBSTRINGS
|
||||
// ran first.
|
||||
for (const sub of TRADING_NAME_SUBSTRINGS) {
|
||||
if (lower.includes(sub)) return "trading";
|
||||
if (lower.includes(sub)) return 'trading'
|
||||
}
|
||||
for (const sub of BROWSER_NAME_SUBSTRINGS) {
|
||||
if (lower.includes(sub)) return "browser";
|
||||
if (lower.includes(sub)) return 'browser'
|
||||
}
|
||||
for (const sub of TERMINAL_NAME_SUBSTRINGS) {
|
||||
if (lower.includes(sub)) return "terminal";
|
||||
if (lower.includes(sub)) return 'terminal'
|
||||
}
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -520,10 +520,10 @@ export function getDeniedCategoryForApp(
|
||||
displayName: string,
|
||||
): DeniedCategory | null {
|
||||
if (bundleId) {
|
||||
const byId = getDeniedCategory(bundleId);
|
||||
if (byId) return byId;
|
||||
const byId = getDeniedCategory(bundleId)
|
||||
if (byId) return byId
|
||||
}
|
||||
return getDeniedCategoryByDisplayName(displayName);
|
||||
return getDeniedCategoryByDisplayName(displayName)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -537,8 +537,8 @@ export function getDeniedCategoryForApp(
|
||||
export function getDefaultTierForApp(
|
||||
bundleId: string | undefined,
|
||||
displayName: string,
|
||||
): "read" | "click" | "full" {
|
||||
return categoryToTier(getDeniedCategoryForApp(bundleId, displayName));
|
||||
): 'read' | 'click' | 'full' {
|
||||
return categoryToTier(getDeniedCategoryForApp(bundleId, displayName))
|
||||
}
|
||||
|
||||
export const _test = {
|
||||
@@ -550,4 +550,4 @@ export const _test = {
|
||||
TERMINAL_NAME_SUBSTRINGS,
|
||||
TRADING_NAME_SUBSTRINGS,
|
||||
POLICY_DENIED_NAME_SUBSTRINGS,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -116,9 +116,17 @@ export interface ComputerExecutor {
|
||||
|
||||
// ── Window management (Windows only, optional) ──────────────────────────
|
||||
/** Perform a window management action on the bound window. Win32 API only — no global shortcuts. */
|
||||
manageWindow?(action: string, opts?: { x?: number; y?: number; width?: number; height?: number }): Promise<boolean>
|
||||
manageWindow?(
|
||||
action: string,
|
||||
opts?: { x?: number; y?: number; width?: number; height?: number },
|
||||
): Promise<boolean>
|
||||
/** Get the current window rect of the bound window */
|
||||
getWindowRect?(): Promise<{ x: number; y: number; width: number; height: number } | null>
|
||||
getWindowRect?(): Promise<{
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
} | null>
|
||||
|
||||
// ── Element-targeted actions (Windows UIA, optional) ────────────────────
|
||||
/** Open terminal and launch an agent CLI */
|
||||
@@ -129,17 +137,32 @@ export interface ComputerExecutor {
|
||||
workingDirectory?: string
|
||||
}): Promise<{ hwnd: string; title: string; launched: boolean } | null>
|
||||
/** Bind to a window by hwnd/title/pid. Returns bound window info or null. */
|
||||
bindToWindow?(query: { hwnd?: string; title?: string; pid?: number }): Promise<{ hwnd: string; title: string; pid: number } | null>
|
||||
bindToWindow?(query: {
|
||||
hwnd?: string
|
||||
title?: string
|
||||
pid?: number
|
||||
}): Promise<{ hwnd: string; title: string; pid: number } | null>
|
||||
/** Unbind from the current window */
|
||||
unbindFromWindow?(): Promise<void>
|
||||
/** Cheap binding-state check for window-targeted routing decisions. */
|
||||
hasBoundWindow?(): Promise<boolean>
|
||||
/** Get current binding status */
|
||||
getBindingStatus?(): Promise<{ bound: boolean; hwnd?: string; title?: string; pid?: number; rect?: { x: number; y: number; width: number; height: number } } | null>
|
||||
getBindingStatus?(): Promise<{
|
||||
bound: boolean
|
||||
hwnd?: string
|
||||
title?: string
|
||||
pid?: number
|
||||
rect?: { x: number; y: number; width: number; height: number }
|
||||
} | null>
|
||||
/** List all visible windows */
|
||||
listVisibleWindows?(): Promise<Array<{ hwnd: string; pid: number; title: string }>>
|
||||
listVisibleWindows?(): Promise<
|
||||
Array<{ hwnd: string; pid: number; title: string }>
|
||||
>
|
||||
/** Control the status indicator overlay */
|
||||
statusIndicator?(action: 'show' | 'hide' | 'status', message?: string): Promise<{ active: boolean; message?: string }>
|
||||
statusIndicator?(
|
||||
action: 'show' | 'hide' | 'status',
|
||||
message?: string,
|
||||
): Promise<{ active: boolean; message?: string }>
|
||||
/** Virtual keyboard — send keys/text/combos to bound window only */
|
||||
virtualKeyboard?(opts: {
|
||||
action: 'type' | 'combo' | 'press' | 'release' | 'hold'
|
||||
@@ -149,12 +172,26 @@ export interface ComputerExecutor {
|
||||
}): Promise<boolean>
|
||||
/** Virtual mouse — click/move/drag on bound window only */
|
||||
virtualMouse?(opts: {
|
||||
action: 'click' | 'double_click' | 'right_click' | 'move' | 'drag' | 'down' | 'up'
|
||||
x: number; y: number
|
||||
startX?: number; startY?: number
|
||||
action:
|
||||
| 'click'
|
||||
| 'double_click'
|
||||
| 'right_click'
|
||||
| 'move'
|
||||
| 'drag'
|
||||
| 'down'
|
||||
| 'up'
|
||||
x: number
|
||||
y: number
|
||||
startX?: number
|
||||
startY?: number
|
||||
}): Promise<boolean>
|
||||
/** Mouse wheel scroll at client coordinates (works on Excel, browsers, modern UI) */
|
||||
mouseWheel?(x: number, y: number, delta: number, horizontal?: boolean): Promise<boolean>
|
||||
mouseWheel?(
|
||||
x: number,
|
||||
y: number,
|
||||
delta: number,
|
||||
horizontal?: boolean,
|
||||
): Promise<boolean>
|
||||
/** Activate the bound window (foreground + click to focus) */
|
||||
activateWindow?(clickX?: number, clickY?: number): Promise<boolean>
|
||||
/** Handle a terminal prompt (yes/no/select/type + enter) */
|
||||
@@ -165,7 +202,14 @@ export interface ComputerExecutor {
|
||||
text?: string
|
||||
}): Promise<boolean>
|
||||
/** Click an element by name/role/automationId via UI Automation */
|
||||
clickElement?(query: { name?: string; role?: string; automationId?: string }): Promise<boolean>
|
||||
clickElement?(query: {
|
||||
name?: string
|
||||
role?: string
|
||||
automationId?: string
|
||||
}): Promise<boolean>
|
||||
/** Type text into an element by name/role/automationId via UI Automation ValuePattern */
|
||||
typeIntoElement?(query: { name?: string; role?: string; automationId?: string }, text: string): Promise<boolean>
|
||||
typeIntoElement?(
|
||||
query: { name?: string; role?: string; automationId?: string },
|
||||
text: string,
|
||||
): Promise<boolean>
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
*/
|
||||
|
||||
export interface ResizeParams {
|
||||
pxPerToken: number;
|
||||
maxTargetPx: number;
|
||||
maxTargetTokens: number;
|
||||
pxPerToken: number
|
||||
maxTargetPx: number
|
||||
maxTargetTokens: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,11 +27,11 @@ export const API_RESIZE_PARAMS: ResizeParams = {
|
||||
pxPerToken: 28,
|
||||
maxTargetPx: 1568,
|
||||
maxTargetTokens: 1568,
|
||||
};
|
||||
}
|
||||
|
||||
/** ceil(px / pxPerToken). Matches resize.rs:74-76 (which uses integer ceil-div). */
|
||||
export function nTokensForPx(px: number, pxPerToken: number): number {
|
||||
return Math.floor((px - 1) / pxPerToken) + 1;
|
||||
return Math.floor((px - 1) / pxPerToken) + 1
|
||||
}
|
||||
|
||||
function nTokensForImg(
|
||||
@@ -39,7 +39,7 @@ function nTokensForImg(
|
||||
height: number,
|
||||
pxPerToken: number,
|
||||
): number {
|
||||
return nTokensForPx(width, pxPerToken) * nTokensForPx(height, pxPerToken);
|
||||
return nTokensForPx(width, pxPerToken) * nTokensForPx(height, pxPerToken)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,47 +62,47 @@ export function targetImageSize(
|
||||
height: number,
|
||||
params: ResizeParams,
|
||||
): [number, number] {
|
||||
const { pxPerToken, maxTargetPx, maxTargetTokens } = params;
|
||||
const { pxPerToken, maxTargetPx, maxTargetTokens } = params
|
||||
|
||||
if (
|
||||
width <= maxTargetPx &&
|
||||
height <= maxTargetPx &&
|
||||
nTokensForImg(width, height, pxPerToken) <= maxTargetTokens
|
||||
) {
|
||||
return [width, height];
|
||||
return [width, height]
|
||||
}
|
||||
|
||||
// Normalize to landscape for the search; transpose result back.
|
||||
if (height > width) {
|
||||
const [w, h] = targetImageSize(height, width, params);
|
||||
return [h, w];
|
||||
const [w, h] = targetImageSize(height, width, params)
|
||||
return [h, w]
|
||||
}
|
||||
|
||||
const aspectRatio = width / height;
|
||||
const aspectRatio = width / height
|
||||
|
||||
// Loop invariant: lowerBoundWidth is always valid, upperBoundWidth is
|
||||
// always invalid. ~12 iterations for a 4000px image.
|
||||
let upperBoundWidth = width;
|
||||
let lowerBoundWidth = 1;
|
||||
let upperBoundWidth = width
|
||||
let lowerBoundWidth = 1
|
||||
|
||||
for (;;) {
|
||||
if (lowerBoundWidth + 1 === upperBoundWidth) {
|
||||
return [
|
||||
lowerBoundWidth,
|
||||
Math.max(Math.round(lowerBoundWidth / aspectRatio), 1),
|
||||
];
|
||||
]
|
||||
}
|
||||
|
||||
const middleWidth = Math.floor((lowerBoundWidth + upperBoundWidth) / 2);
|
||||
const middleHeight = Math.max(Math.round(middleWidth / aspectRatio), 1);
|
||||
const middleWidth = Math.floor((lowerBoundWidth + upperBoundWidth) / 2)
|
||||
const middleHeight = Math.max(Math.round(middleWidth / aspectRatio), 1)
|
||||
|
||||
if (
|
||||
middleWidth <= maxTargetPx &&
|
||||
nTokensForImg(middleWidth, middleHeight, pxPerToken) <= maxTargetTokens
|
||||
) {
|
||||
lowerBoundWidth = middleWidth;
|
||||
lowerBoundWidth = middleWidth
|
||||
} else {
|
||||
upperBoundWidth = middleWidth;
|
||||
upperBoundWidth = middleWidth
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export type {
|
||||
ResolvePrepareCaptureResult,
|
||||
RunningApp,
|
||||
ScreenshotResult,
|
||||
} from "./executor.js";
|
||||
} from './executor.js'
|
||||
|
||||
export type {
|
||||
AppGrant,
|
||||
@@ -25,15 +25,15 @@ export type {
|
||||
ScreenshotDims,
|
||||
TeachStepRequest,
|
||||
TeachStepResult,
|
||||
} from "./types.js";
|
||||
} from './types.js'
|
||||
|
||||
export { DEFAULT_GRANT_FLAGS } from "./types.js";
|
||||
export { DEFAULT_GRANT_FLAGS } from './types.js'
|
||||
|
||||
export {
|
||||
SENTINEL_BUNDLE_IDS,
|
||||
getSentinelCategory,
|
||||
} from "./sentinelApps.js";
|
||||
export type { SentinelCategory } from "./sentinelApps.js";
|
||||
} from './sentinelApps.js'
|
||||
export type { SentinelCategory } from './sentinelApps.js'
|
||||
|
||||
export {
|
||||
categoryToTier,
|
||||
@@ -42,28 +42,28 @@ export {
|
||||
getDeniedCategoryByDisplayName,
|
||||
getDeniedCategoryForApp,
|
||||
isPolicyDenied,
|
||||
} from "./deniedApps.js";
|
||||
export type { DeniedCategory } from "./deniedApps.js";
|
||||
} from './deniedApps.js'
|
||||
export type { DeniedCategory } from './deniedApps.js'
|
||||
|
||||
export { isSystemKeyCombo, normalizeKeySequence } from "./keyBlocklist.js";
|
||||
export { isSystemKeyCombo, normalizeKeySequence } from './keyBlocklist.js'
|
||||
|
||||
export { ALL_SUB_GATES_OFF, ALL_SUB_GATES_ON } from "./subGates.js";
|
||||
export { ALL_SUB_GATES_OFF, ALL_SUB_GATES_ON } from './subGates.js'
|
||||
|
||||
export { API_RESIZE_PARAMS, targetImageSize } from "./imageResize.js";
|
||||
export type { ResizeParams } from "./imageResize.js";
|
||||
export { API_RESIZE_PARAMS, targetImageSize } from './imageResize.js'
|
||||
export type { ResizeParams } from './imageResize.js'
|
||||
|
||||
export { defersLockAcquire, handleToolCall } from "./toolCalls.js";
|
||||
export { defersLockAcquire, handleToolCall } from './toolCalls.js'
|
||||
export type {
|
||||
CuCallTelemetry,
|
||||
CuCallToolResult,
|
||||
CuErrorKind,
|
||||
} from "./toolCalls.js";
|
||||
} from './toolCalls.js'
|
||||
|
||||
export { bindSessionContext, createComputerUseMcpServer } from "./mcpServer.js";
|
||||
export { buildComputerUseTools } from "./tools.js";
|
||||
export { bindSessionContext, createComputerUseMcpServer } from './mcpServer.js'
|
||||
export { buildComputerUseTools } from './tools.js'
|
||||
|
||||
export {
|
||||
comparePixelAtLocation,
|
||||
validateClickTarget,
|
||||
} from "./pixelCompare.js";
|
||||
export type { CropRawPatchFn, PixelCompareResult } from "./pixelCompare.js";
|
||||
} from './pixelCompare.js'
|
||||
export type { CropRawPatchFn, PixelCompareResult } from './pixelCompare.js'
|
||||
|
||||
@@ -21,32 +21,32 @@
|
||||
*/
|
||||
const CANONICAL_MODIFIER: Readonly<Record<string, string>> = {
|
||||
// Key::Meta — "meta"|"super"|"command"|"cmd"|"windows"|"win"
|
||||
meta: "meta",
|
||||
super: "meta",
|
||||
command: "meta",
|
||||
cmd: "meta",
|
||||
windows: "meta",
|
||||
win: "meta",
|
||||
meta: 'meta',
|
||||
super: 'meta',
|
||||
command: 'meta',
|
||||
cmd: 'meta',
|
||||
windows: 'meta',
|
||||
win: 'meta',
|
||||
// Key::Control + LControl + RControl
|
||||
ctrl: "ctrl",
|
||||
control: "ctrl",
|
||||
lctrl: "ctrl",
|
||||
lcontrol: "ctrl",
|
||||
rctrl: "ctrl",
|
||||
rcontrol: "ctrl",
|
||||
ctrl: 'ctrl',
|
||||
control: 'ctrl',
|
||||
lctrl: 'ctrl',
|
||||
lcontrol: 'ctrl',
|
||||
rctrl: 'ctrl',
|
||||
rcontrol: 'ctrl',
|
||||
// Key::Shift + LShift + RShift
|
||||
shift: "shift",
|
||||
lshift: "shift",
|
||||
rshift: "shift",
|
||||
shift: 'shift',
|
||||
lshift: 'shift',
|
||||
rshift: 'shift',
|
||||
// Key::Alt and Key::Option — distinct Rust variants but same keycode on
|
||||
// darwin (kVK_Option). Collapse: cmd+alt+escape and cmd+option+escape
|
||||
// both Force Quit.
|
||||
alt: "alt",
|
||||
option: "alt",
|
||||
};
|
||||
alt: 'alt',
|
||||
option: 'alt',
|
||||
}
|
||||
|
||||
/** Sort order for canonicals. ctrl < alt < shift < meta. */
|
||||
const MODIFIER_ORDER = ["ctrl", "alt", "shift", "meta"];
|
||||
const MODIFIER_ORDER = ['ctrl', 'alt', 'shift', 'meta']
|
||||
|
||||
/**
|
||||
* Canonical-form entries only. Every modifier must be a CANONICAL_MODIFIER
|
||||
@@ -54,21 +54,21 @@ const MODIFIER_ORDER = ["ctrl", "alt", "shift", "meta"];
|
||||
* The self-consistency test enforces this.
|
||||
*/
|
||||
const BLOCKED_DARWIN = new Set([
|
||||
"meta+q", // Cmd+Q — quit frontmost app
|
||||
"shift+meta+q", // Cmd+Shift+Q — log out
|
||||
"alt+meta+escape", // Cmd+Option+Esc — Force Quit dialog
|
||||
"meta+tab", // Cmd+Tab — app switcher
|
||||
"meta+space", // Cmd+Space — Spotlight
|
||||
"ctrl+meta+q", // Ctrl+Cmd+Q — lock screen
|
||||
]);
|
||||
'meta+q', // Cmd+Q — quit frontmost app
|
||||
'shift+meta+q', // Cmd+Shift+Q — log out
|
||||
'alt+meta+escape', // Cmd+Option+Esc — Force Quit dialog
|
||||
'meta+tab', // Cmd+Tab — app switcher
|
||||
'meta+space', // Cmd+Space — Spotlight
|
||||
'ctrl+meta+q', // Ctrl+Cmd+Q — lock screen
|
||||
])
|
||||
|
||||
const BLOCKED_WIN32 = new Set([
|
||||
"ctrl+alt+delete", // Secure Attention Sequence
|
||||
"alt+f4", // close window
|
||||
"alt+tab", // window switcher
|
||||
"meta+l", // Win+L — lock
|
||||
"meta+d", // Win+D — show desktop
|
||||
]);
|
||||
'ctrl+alt+delete', // Secure Attention Sequence
|
||||
'alt+f4', // close window
|
||||
'alt+tab', // window switcher
|
||||
'meta+l', // Win+L — lock
|
||||
'meta+d', // Win+D — show desktop
|
||||
])
|
||||
|
||||
/**
|
||||
* Partition into sorted-canonical modifiers and non-modifier keys.
|
||||
@@ -78,25 +78,25 @@ const BLOCKED_WIN32 = new Set([
|
||||
function partitionKeys(seq: string): { mods: string[]; keys: string[] } {
|
||||
const parts = seq
|
||||
.toLowerCase()
|
||||
.split("+")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
const mods: string[] = [];
|
||||
const keys: string[] = [];
|
||||
.split('+')
|
||||
.map(p => p.trim())
|
||||
.filter(Boolean)
|
||||
const mods: string[] = []
|
||||
const keys: string[] = []
|
||||
for (const p of parts) {
|
||||
const canonical = CANONICAL_MODIFIER[p];
|
||||
const canonical = CANONICAL_MODIFIER[p]
|
||||
if (canonical !== undefined) {
|
||||
mods.push(canonical);
|
||||
mods.push(canonical)
|
||||
} else {
|
||||
keys.push(p);
|
||||
keys.push(p)
|
||||
}
|
||||
}
|
||||
// Dedupe: "cmd+command+q" → "meta+q", not "meta+meta+q".
|
||||
const uniqueMods = [...new Set(mods)];
|
||||
const uniqueMods = [...new Set(mods)]
|
||||
uniqueMods.sort(
|
||||
(a, b) => MODIFIER_ORDER.indexOf(a) - MODIFIER_ORDER.indexOf(b),
|
||||
);
|
||||
return { mods: uniqueMods, keys };
|
||||
)
|
||||
return { mods: uniqueMods, keys }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,8 +104,8 @@ function partitionKeys(seq: string): { mods: string[]; keys: string[] } {
|
||||
* canonical, dedupe, sort modifiers, non-modifiers last.
|
||||
*/
|
||||
export function normalizeKeySequence(seq: string): string {
|
||||
const { mods, keys } = partitionKeys(seq);
|
||||
return [...mods, ...keys].join("+");
|
||||
const { mods, keys } = partitionKeys(seq)
|
||||
return [...mods, ...keys].join('+')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,26 +123,26 @@ export function normalizeKeySequence(seq: string): string {
|
||||
*/
|
||||
export function isSystemKeyCombo(
|
||||
seq: string,
|
||||
platform: "darwin" | "win32",
|
||||
platform: 'darwin' | 'win32',
|
||||
): boolean {
|
||||
const blocklist = platform === "darwin" ? BLOCKED_DARWIN : BLOCKED_WIN32;
|
||||
const { mods, keys } = partitionKeys(seq);
|
||||
const prefix = mods.length > 0 ? mods.join("+") + "+" : "";
|
||||
const blocklist = platform === 'darwin' ? BLOCKED_DARWIN : BLOCKED_WIN32
|
||||
const { mods, keys } = partitionKeys(seq)
|
||||
const prefix = mods.length > 0 ? mods.join('+') + '+' : ''
|
||||
|
||||
// No non-modifier keys (e.g. "cmd+shift" as click-modifiers) — check the
|
||||
// whole thing. Never matches (no blocklist entry is modifier-only) but
|
||||
// keeps the contract simple: every call reaches a .has().
|
||||
if (keys.length === 0) {
|
||||
return blocklist.has(mods.join("+"));
|
||||
return blocklist.has(mods.join('+'))
|
||||
}
|
||||
|
||||
// mods + each key. Any hit blocks the whole sequence.
|
||||
for (const key of keys) {
|
||||
if (blocklist.has(prefix + key)) {
|
||||
return true;
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
|
||||
export const _test = {
|
||||
@@ -150,4 +150,4 @@ export const _test = {
|
||||
BLOCKED_DARWIN,
|
||||
BLOCKED_WIN32,
|
||||
MODIFIER_ORDER,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,21 +17,21 @@
|
||||
* is the same either way.
|
||||
*/
|
||||
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
} from '@modelcontextprotocol/sdk/types.js'
|
||||
|
||||
import type { ScreenshotResult } from "./executor.js";
|
||||
import type { CuCallToolResult } from "./toolCalls.js";
|
||||
import type { ScreenshotResult } from './executor.js'
|
||||
import type { CuCallToolResult } from './toolCalls.js'
|
||||
import {
|
||||
defersLockAcquire,
|
||||
handleToolCall,
|
||||
resetMouseButtonHeld,
|
||||
} from "./toolCalls.js";
|
||||
import { buildComputerUseTools } from "./tools.js";
|
||||
} from './toolCalls.js'
|
||||
import { buildComputerUseTools } from './tools.js'
|
||||
import type {
|
||||
AppGrant,
|
||||
ComputerUseHostAdapter,
|
||||
@@ -40,12 +40,12 @@ import type {
|
||||
CoordinateMode,
|
||||
CuGrantFlags,
|
||||
CuPermissionResponse,
|
||||
} from "./types.js";
|
||||
import { DEFAULT_GRANT_FLAGS } from "./types.js";
|
||||
} from './types.js'
|
||||
import { DEFAULT_GRANT_FLAGS } from './types.js'
|
||||
|
||||
const DEFAULT_LOCK_HELD_MESSAGE =
|
||||
"Another Claude session is currently using the computer. Wait for that " +
|
||||
"session to finish, or find a non-computer-use approach.";
|
||||
'Another Claude session is currently using the computer. Wait for that ' +
|
||||
'session to finish, or find a non-computer-use approach.'
|
||||
|
||||
/**
|
||||
* Dedupe `granted` into `existing` on bundleId, spread truthy-only flags over
|
||||
@@ -60,20 +60,20 @@ function mergePermissionResponse(
|
||||
existingFlags: CuGrantFlags,
|
||||
response: CuPermissionResponse,
|
||||
): { apps: AppGrant[]; flags: CuGrantFlags } {
|
||||
const seen = new Set(existing.map((a) => a.bundleId));
|
||||
const seen = new Set(existing.map(a => a.bundleId))
|
||||
const apps = [
|
||||
...existing,
|
||||
...response.granted.filter((g) => !seen.has(g.bundleId)),
|
||||
];
|
||||
...response.granted.filter(g => !seen.has(g.bundleId)),
|
||||
]
|
||||
const truthyFlags = Object.fromEntries(
|
||||
Object.entries(response.flags).filter(([, v]) => v === true),
|
||||
);
|
||||
)
|
||||
const flags: CuGrantFlags = {
|
||||
...DEFAULT_GRANT_FLAGS,
|
||||
...existingFlags,
|
||||
...truthyFlags,
|
||||
};
|
||||
return { apps, flags };
|
||||
}
|
||||
return { apps, flags }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,53 +91,53 @@ export function bindSessionContext(
|
||||
coordinateMode: CoordinateMode,
|
||||
ctx: ComputerUseSessionContext,
|
||||
): (name: string, args: unknown) => Promise<CuCallToolResult> {
|
||||
const { logger, serverName } = adapter;
|
||||
const { logger, serverName } = adapter
|
||||
|
||||
// Screenshot blob persists here across calls — NOT on `ctx`. Hosts hold
|
||||
// onto the returned dispatcher; that's the identity that matters.
|
||||
let lastScreenshot: ScreenshotResult | undefined;
|
||||
let lastScreenshot: ScreenshotResult | undefined
|
||||
|
||||
const wrapPermission = ctx.onPermissionRequest
|
||||
? async (
|
||||
req: Parameters<NonNullable<typeof ctx.onPermissionRequest>>[0],
|
||||
signal: AbortSignal,
|
||||
): Promise<CuPermissionResponse> => {
|
||||
const response = await ctx.onPermissionRequest!(req, signal);
|
||||
const response = await ctx.onPermissionRequest!(req, signal)
|
||||
const { apps, flags } = mergePermissionResponse(
|
||||
ctx.getAllowedApps(),
|
||||
ctx.getGrantFlags(),
|
||||
response,
|
||||
);
|
||||
)
|
||||
logger.debug(
|
||||
`[${serverName}] permission result: granted=${response.granted.length} denied=${response.denied.length}`,
|
||||
);
|
||||
ctx.onAllowedAppsChanged?.(apps, flags);
|
||||
return response;
|
||||
)
|
||||
ctx.onAllowedAppsChanged?.(apps, flags)
|
||||
return response
|
||||
}
|
||||
: undefined;
|
||||
: undefined
|
||||
|
||||
const wrapTeachPermission = ctx.onTeachPermissionRequest
|
||||
? async (
|
||||
req: Parameters<NonNullable<typeof ctx.onTeachPermissionRequest>>[0],
|
||||
signal: AbortSignal,
|
||||
): Promise<CuPermissionResponse> => {
|
||||
const response = await ctx.onTeachPermissionRequest!(req, signal);
|
||||
const response = await ctx.onTeachPermissionRequest!(req, signal)
|
||||
logger.debug(
|
||||
`[${serverName}] teach permission result: granted=${response.granted.length} denied=${response.denied.length}`,
|
||||
);
|
||||
)
|
||||
// Teach doesn't request grant flags — preserve existing.
|
||||
const { apps } = mergePermissionResponse(
|
||||
ctx.getAllowedApps(),
|
||||
ctx.getGrantFlags(),
|
||||
response,
|
||||
);
|
||||
)
|
||||
ctx.onAllowedAppsChanged?.(apps, {
|
||||
...DEFAULT_GRANT_FLAGS,
|
||||
...ctx.getGrantFlags(),
|
||||
});
|
||||
return response;
|
||||
})
|
||||
return response
|
||||
}
|
||||
: undefined;
|
||||
: undefined
|
||||
|
||||
return async (name, args) => {
|
||||
// ─── Async lock gate ─────────────────────────────────────────────────
|
||||
@@ -146,18 +146,18 @@ export function bindSessionContext(
|
||||
// cross-process locks (O_EXCL file) await the real primitive here
|
||||
// instead of pre-computing + feeding a fake sync result.
|
||||
if (ctx.checkCuLock) {
|
||||
const lock = await ctx.checkCuLock();
|
||||
const lock = await ctx.checkCuLock()
|
||||
if (lock.holder !== undefined && !lock.isSelf) {
|
||||
const text =
|
||||
ctx.formatLockHeldMessage?.(lock.holder) ?? DEFAULT_LOCK_HELD_MESSAGE;
|
||||
ctx.formatLockHeldMessage?.(lock.holder) ?? DEFAULT_LOCK_HELD_MESSAGE
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
content: [{ type: 'text', text }],
|
||||
isError: true,
|
||||
telemetry: { error_kind: "cu_lock_held" },
|
||||
};
|
||||
telemetry: { error_kind: 'cu_lock_held' },
|
||||
}
|
||||
}
|
||||
if (lock.holder === undefined && !defersLockAcquire(name)) {
|
||||
await ctx.acquireCuLock?.();
|
||||
await ctx.acquireCuLock?.()
|
||||
// Re-check: the awaits above yield the microtask queue, so another
|
||||
// session's check+acquire can interleave with ours. Hosts where
|
||||
// acquire is a no-op when already held (Cowork's CuLockManager) give
|
||||
@@ -165,21 +165,21 @@ export function bindSessionContext(
|
||||
// proceeding. The CLI's O_EXCL file lock would surface this as a throw from
|
||||
// acquire instead; this re-check is a belt-and-suspenders for that
|
||||
// path too.
|
||||
const recheck = await ctx.checkCuLock();
|
||||
const recheck = await ctx.checkCuLock()
|
||||
if (recheck.holder !== undefined && !recheck.isSelf) {
|
||||
const text =
|
||||
ctx.formatLockHeldMessage?.(recheck.holder) ??
|
||||
DEFAULT_LOCK_HELD_MESSAGE;
|
||||
DEFAULT_LOCK_HELD_MESSAGE
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
content: [{ type: 'text', text }],
|
||||
isError: true,
|
||||
telemetry: { error_kind: "cu_lock_held" },
|
||||
};
|
||||
telemetry: { error_kind: 'cu_lock_held' },
|
||||
}
|
||||
}
|
||||
// Fresh holder → any prior session's mouseButtonHeld is stale.
|
||||
// Mirrors what Gate-3 does on the acquire branch. After the
|
||||
// re-check so we only clear module state when we actually won.
|
||||
resetMouseButtonHeld();
|
||||
resetMouseButtonHeld()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,12 +189,12 @@ export function bindSessionContext(
|
||||
// isEmpty → skip.
|
||||
const dimsFallback = lastScreenshot
|
||||
? undefined
|
||||
: ctx.getLastScreenshotDims?.();
|
||||
: ctx.getLastScreenshotDims?.()
|
||||
|
||||
// Per-call AbortController for dialog dismissal. Aborted in `finally` —
|
||||
// if handleToolCall finishes (MCP timeout, throw) before the user
|
||||
// answers, the host's dialog handler sees the abort and tears down.
|
||||
const dialogAbort = new AbortController();
|
||||
const dialogAbort = new AbortController()
|
||||
|
||||
const overrides: ComputerUseOverrides = {
|
||||
allowedApps: [...ctx.getAllowedApps()],
|
||||
@@ -206,12 +206,12 @@ export function bindSessionContext(
|
||||
displayResolvedForApps: ctx.getDisplayResolvedForApps?.(),
|
||||
lastScreenshot:
|
||||
lastScreenshot ??
|
||||
(dimsFallback ? { ...dimsFallback, base64: "" } : undefined),
|
||||
(dimsFallback ? { ...dimsFallback, base64: '' } : undefined),
|
||||
onPermissionRequest: wrapPermission
|
||||
? (req) => wrapPermission(req, dialogAbort.signal)
|
||||
? req => wrapPermission(req, dialogAbort.signal)
|
||||
: undefined,
|
||||
onTeachPermissionRequest: wrapTeachPermission
|
||||
? (req) => wrapTeachPermission(req, dialogAbort.signal)
|
||||
? req => wrapTeachPermission(req, dialogAbort.signal)
|
||||
: undefined,
|
||||
onAppsHidden: ctx.onAppsHidden,
|
||||
getClipboardStash: ctx.getClipboardStash,
|
||||
@@ -228,28 +228,28 @@ export function bindSessionContext(
|
||||
checkCuLock: undefined,
|
||||
acquireCuLock: undefined,
|
||||
isAborted: ctx.isAborted,
|
||||
};
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`[${serverName}] tool=${name} allowedApps=${overrides.allowedApps.length} coordMode=${coordinateMode}`,
|
||||
);
|
||||
)
|
||||
|
||||
// ─── Dispatch ────────────────────────────────────────────────────────
|
||||
try {
|
||||
const result = await handleToolCall(adapter, name, args, overrides);
|
||||
const result = await handleToolCall(adapter, name, args, overrides)
|
||||
|
||||
if (result.screenshot) {
|
||||
lastScreenshot = result.screenshot;
|
||||
const { base64: _blob, ...dims } = result.screenshot;
|
||||
logger.debug(`[${serverName}] screenshot dims: ${JSON.stringify(dims)}`);
|
||||
ctx.onScreenshotCaptured?.(dims);
|
||||
lastScreenshot = result.screenshot
|
||||
const { base64: _blob, ...dims } = result.screenshot
|
||||
logger.debug(`[${serverName}] screenshot dims: ${JSON.stringify(dims)}`)
|
||||
ctx.onScreenshotCaptured?.(dims)
|
||||
}
|
||||
|
||||
return result;
|
||||
return result
|
||||
} finally {
|
||||
dialogAbort.abort();
|
||||
dialogAbort.abort()
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createComputerUseMcpServer(
|
||||
@@ -257,35 +257,36 @@ export function createComputerUseMcpServer(
|
||||
coordinateMode: CoordinateMode,
|
||||
context?: ComputerUseSessionContext,
|
||||
): Server {
|
||||
const { serverName, logger } = adapter;
|
||||
const { serverName, logger } = adapter
|
||||
|
||||
const server = new Server(
|
||||
{ name: serverName, version: "0.1.3" },
|
||||
{ name: serverName, version: '0.1.3' },
|
||||
{ capabilities: { tools: {}, logging: {} } },
|
||||
);
|
||||
)
|
||||
|
||||
const tools = buildComputerUseTools(
|
||||
adapter.executor.capabilities,
|
||||
coordinateMode,
|
||||
);
|
||||
)
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () =>
|
||||
adapter.isDisabled() ? { tools: [] } : { tools },
|
||||
);
|
||||
)
|
||||
|
||||
if (context) {
|
||||
const dispatch = bindSessionContext(adapter, coordinateMode, context);
|
||||
const dispatch = bindSessionContext(adapter, coordinateMode, context)
|
||||
server.setRequestHandler(
|
||||
CallToolRequestSchema,
|
||||
async (request): Promise<CallToolResult> => {
|
||||
const { screenshot: _s, telemetry: _t, ...result } = await dispatch(
|
||||
request.params.name,
|
||||
request.params.arguments ?? {},
|
||||
);
|
||||
return result;
|
||||
const {
|
||||
screenshot: _s,
|
||||
telemetry: _t,
|
||||
...result
|
||||
} = await dispatch(request.params.name, request.params.arguments ?? {})
|
||||
return result
|
||||
},
|
||||
);
|
||||
return server;
|
||||
)
|
||||
return server
|
||||
}
|
||||
|
||||
// Legacy: no context → stub handler. Reached only if something calls the
|
||||
@@ -296,18 +297,18 @@ export function createComputerUseMcpServer(
|
||||
async (request): Promise<CallToolResult> => {
|
||||
logger.warn(
|
||||
`[${serverName}] tool call "${request.params.name}" reached the stub handler — no session context bound. Per-session state unavailable.`,
|
||||
);
|
||||
)
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "This computer-use server instance is not wired to a session. Per-session app permissions are not available on this code path.",
|
||||
type: 'text',
|
||||
text: 'This computer-use server instance is not wired to a session. Per-session app permissions are not available on this code path.',
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
return server;
|
||||
return server
|
||||
}
|
||||
|
||||
@@ -19,28 +19,28 @@
|
||||
* this package never imports it — the crop is a function parameter.
|
||||
*/
|
||||
|
||||
import type { ScreenshotResult } from "./executor.js";
|
||||
import type { Logger } from "./types.js";
|
||||
import type { ScreenshotResult } from './executor.js'
|
||||
import type { Logger } from './types.js'
|
||||
|
||||
/** Injected by the host. See `ComputerUseHostAdapter.cropRawPatch`. */
|
||||
export type CropRawPatchFn = (
|
||||
jpegBase64: string,
|
||||
rect: { x: number; y: number; width: number; height: number },
|
||||
) => Buffer | null;
|
||||
) => Buffer | null
|
||||
|
||||
/** 9×9 is empirically the sweet spot — large enough to catch a tooltip
|
||||
* appearing, small enough to not false-positive on surrounding animation.
|
||||
**/
|
||||
const DEFAULT_GRID_SIZE = 9;
|
||||
const DEFAULT_GRID_SIZE = 9
|
||||
|
||||
export interface PixelCompareResult {
|
||||
/** true → click may proceed. false → patch changed, abort the click. */
|
||||
valid: boolean;
|
||||
valid: boolean
|
||||
/** true → validation did not run (cold start, sub-gate off, or internal
|
||||
* error). The caller MUST treat this identically to `valid: true`. */
|
||||
skipped: boolean;
|
||||
skipped: boolean
|
||||
/** Populated when valid === false. Returned to the model verbatim. */
|
||||
warning?: string;
|
||||
warning?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,22 +57,22 @@ function computeCropRect(
|
||||
yPercent: number,
|
||||
gridSize: number,
|
||||
): { x: number; y: number; width: number; height: number } | null {
|
||||
if (!imgW || !imgH) return null;
|
||||
if (!imgW || !imgH) return null
|
||||
|
||||
const clampedX = Math.max(0, Math.min(100, xPercent));
|
||||
const clampedY = Math.max(0, Math.min(100, yPercent));
|
||||
const clampedX = Math.max(0, Math.min(100, xPercent))
|
||||
const clampedY = Math.max(0, Math.min(100, yPercent))
|
||||
|
||||
const centerX = Math.round((clampedX / 100.0) * imgW);
|
||||
const centerY = Math.round((clampedY / 100.0) * imgH);
|
||||
const centerX = Math.round((clampedX / 100.0) * imgW)
|
||||
const centerY = Math.round((clampedY / 100.0) * imgH)
|
||||
|
||||
const halfGrid = Math.floor(gridSize / 2);
|
||||
const cropX = Math.max(0, centerX - halfGrid);
|
||||
const cropY = Math.max(0, centerY - halfGrid);
|
||||
const cropW = Math.min(gridSize, imgW - cropX);
|
||||
const cropH = Math.min(gridSize, imgH - cropY);
|
||||
if (cropW <= 0 || cropH <= 0) return null;
|
||||
const halfGrid = Math.floor(gridSize / 2)
|
||||
const cropX = Math.max(0, centerX - halfGrid)
|
||||
const cropY = Math.max(0, centerY - halfGrid)
|
||||
const cropW = Math.min(gridSize, imgW - cropX)
|
||||
const cropH = Math.min(gridSize, imgH - cropY)
|
||||
if (cropW <= 0 || cropH <= 0) return null
|
||||
|
||||
return { x: cropX, y: cropY, width: cropW, height: cropH };
|
||||
return { x: cropX, y: cropY, width: cropW, height: cropH }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,17 +98,17 @@ export function comparePixelAtLocation(
|
||||
xPercent,
|
||||
yPercent,
|
||||
gridSize,
|
||||
);
|
||||
if (!rect) return false;
|
||||
)
|
||||
if (!rect) return false
|
||||
|
||||
const patch1 = crop(lastScreenshot.base64, rect);
|
||||
const patch2 = crop(freshScreenshot.base64, rect);
|
||||
if (!patch1 || !patch2) return false;
|
||||
const patch1 = crop(lastScreenshot.base64, rect)
|
||||
const patch2 = crop(freshScreenshot.base64, rect)
|
||||
if (!patch1 || !patch2) return false
|
||||
|
||||
// Direct buffer equality. Note: nativeImage.toBitmap() gives BGRA, sharp's
|
||||
// .raw() gave RGB.
|
||||
// Doesn't matter — we're comparing two same-format buffers for equality.
|
||||
return patch1.equals(patch2);
|
||||
return patch1.equals(patch2)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,13 +135,13 @@ export async function validateClickTarget(
|
||||
gridSize: number = DEFAULT_GRID_SIZE,
|
||||
): Promise<PixelCompareResult> {
|
||||
if (!lastScreenshot) {
|
||||
return { valid: true, skipped: true };
|
||||
return { valid: true, skipped: true }
|
||||
}
|
||||
|
||||
try {
|
||||
const fresh = await takeFreshScreenshot();
|
||||
const fresh = await takeFreshScreenshot()
|
||||
if (!fresh) {
|
||||
return { valid: true, skipped: true };
|
||||
return { valid: true, skipped: true }
|
||||
}
|
||||
|
||||
const pixelsMatch = comparePixelAtLocation(
|
||||
@@ -151,21 +151,21 @@ export async function validateClickTarget(
|
||||
xPercent,
|
||||
yPercent,
|
||||
gridSize,
|
||||
);
|
||||
)
|
||||
|
||||
if (pixelsMatch) {
|
||||
return { valid: true, skipped: false };
|
||||
return { valid: true, skipped: false }
|
||||
}
|
||||
return {
|
||||
valid: false,
|
||||
skipped: false,
|
||||
warning:
|
||||
"Screen content at the target location changed since the last screenshot. Take a new screenshot before clicking.",
|
||||
};
|
||||
'Screen content at the target location changed since the last screenshot. Take a new screenshot before clicking.',
|
||||
}
|
||||
} catch (err) {
|
||||
// Skip validation on technical errors, execute action anyway.
|
||||
// Battle-tested: validation failure must never block the click.
|
||||
logger.debug("[pixelCompare] validation error, skipping", err);
|
||||
return { valid: true, skipped: true };
|
||||
logger.debug('[pixelCompare] validation error, skipping', err)
|
||||
return { valid: true, skipped: true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,33 +11,33 @@
|
||||
|
||||
/** These apps can execute arbitrary shell commands. */
|
||||
const SHELL_ACCESS_BUNDLE_IDS = new Set([
|
||||
"com.apple.Terminal",
|
||||
"com.googlecode.iterm2",
|
||||
"com.microsoft.VSCode",
|
||||
"dev.warp.Warp-Stable",
|
||||
"com.github.wez.wezterm",
|
||||
"io.alacritty",
|
||||
"net.kovidgoyal.kitty",
|
||||
"com.jetbrains.intellij",
|
||||
"com.jetbrains.pycharm",
|
||||
]);
|
||||
'com.apple.Terminal',
|
||||
'com.googlecode.iterm2',
|
||||
'com.microsoft.VSCode',
|
||||
'dev.warp.Warp-Stable',
|
||||
'com.github.wez.wezterm',
|
||||
'io.alacritty',
|
||||
'net.kovidgoyal.kitty',
|
||||
'com.jetbrains.intellij',
|
||||
'com.jetbrains.pycharm',
|
||||
])
|
||||
|
||||
/** Finder in the allowlist ≈ browse + open-any-file. */
|
||||
const FILESYSTEM_ACCESS_BUNDLE_IDS = new Set(["com.apple.finder"]);
|
||||
const FILESYSTEM_ACCESS_BUNDLE_IDS = new Set(['com.apple.finder'])
|
||||
|
||||
const SYSTEM_SETTINGS_BUNDLE_IDS = new Set(["com.apple.systempreferences"]);
|
||||
const SYSTEM_SETTINGS_BUNDLE_IDS = new Set(['com.apple.systempreferences'])
|
||||
|
||||
export const SENTINEL_BUNDLE_IDS: ReadonlySet<string> = new Set([
|
||||
...SHELL_ACCESS_BUNDLE_IDS,
|
||||
...FILESYSTEM_ACCESS_BUNDLE_IDS,
|
||||
...SYSTEM_SETTINGS_BUNDLE_IDS,
|
||||
]);
|
||||
])
|
||||
|
||||
export type SentinelCategory = "shell" | "filesystem" | "system_settings";
|
||||
export type SentinelCategory = 'shell' | 'filesystem' | 'system_settings'
|
||||
|
||||
export function getSentinelCategory(bundleId: string): SentinelCategory | null {
|
||||
if (SHELL_ACCESS_BUNDLE_IDS.has(bundleId)) return "shell";
|
||||
if (FILESYSTEM_ACCESS_BUNDLE_IDS.has(bundleId)) return "filesystem";
|
||||
if (SYSTEM_SETTINGS_BUNDLE_IDS.has(bundleId)) return "system_settings";
|
||||
return null;
|
||||
if (SHELL_ACCESS_BUNDLE_IDS.has(bundleId)) return 'shell'
|
||||
if (FILESYSTEM_ACCESS_BUNDLE_IDS.has(bundleId)) return 'filesystem'
|
||||
if (SYSTEM_SETTINGS_BUNDLE_IDS.has(bundleId)) return 'system_settings'
|
||||
return null
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user