mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-16 05:15:51 +00:00
Compare commits
136 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2da6514095 | ||
|
|
f17b7c7163 | ||
|
|
7f694168d0 | ||
|
|
a3505aeec4 | ||
|
|
73a18c30db | ||
|
|
ae6ae6cfb0 | ||
|
|
91ee1428fa | ||
|
|
d52300ff44 | ||
|
|
79b472f9d1 | ||
|
|
bdea5a2632 | ||
|
|
3683f22529 | ||
|
|
4e4111be92 | ||
|
|
e86573ac2f | ||
|
|
3e1c6bcc3f | ||
|
|
cf26c73e39 | ||
|
|
d6bfc34b71 | ||
|
|
91b9366f64 | ||
|
|
042e186b0b | ||
|
|
0d8f494c4b | ||
|
|
5d7e54751a | ||
|
|
52a9cc0414 | ||
|
|
f268b16b31 | ||
|
|
e5782e732c | ||
|
|
4e1e681a46 | ||
|
|
a7d9a220bf | ||
|
|
dab0783941 | ||
|
|
0c53796d15 | ||
|
|
4b44047931 | ||
|
|
88d4c3ba24 | ||
|
|
ca0c3265e6 | ||
|
|
70baa6f7db | ||
|
|
dfa7aa1d29 | ||
|
|
c445f43f8d | ||
|
|
3ea64eeb0f | ||
|
|
33949ce5a2 | ||
|
|
35bc4f395d | ||
|
|
379e40f12a | ||
|
|
1b47333d72 | ||
|
|
2c660daf2c | ||
|
|
4d62f6312a | ||
|
|
dc8ce1bbb0 | ||
|
|
3fff2a0743 | ||
|
|
dd2bd12626 | ||
|
|
ca630488c6 | ||
|
|
f05a6c9fdd | ||
|
|
767d6fae06 | ||
|
|
6e598fc4c9 | ||
|
|
e88dcb2f9e | ||
|
|
7d88b4df97 | ||
|
|
fec8ec6abd | ||
|
|
5bf3c93895 | ||
|
|
a15340f555 | ||
|
|
eb62b4704e | ||
|
|
81ecd82b65 | ||
|
|
919011a372 | ||
|
|
3923af4834 | ||
|
|
14dc54a093 | ||
|
|
258cc720f4 | ||
|
|
fc0bebf6b3 | ||
|
|
eca1acc662 | ||
|
|
6f80e96fee | ||
|
|
a7a9659ddd | ||
|
|
dee2ffd638 | ||
|
|
26245e0bd0 | ||
|
|
cd70c1b7fd | ||
|
|
ced5080019 | ||
|
|
522a1a366d | ||
|
|
0da5ec09e8 | ||
|
|
1f8f90eb62 | ||
|
|
27825293bb | ||
|
|
5916ecffdc | ||
|
|
96f6d2c7d5 | ||
|
|
2b84333913 | ||
|
|
ecac0ab978 | ||
|
|
ba97889c93 | ||
|
|
714ef13e68 | ||
|
|
2f95d1a395 | ||
|
|
918defad44 | ||
|
|
4e5a0dd22f | ||
|
|
c17edcb12e | ||
|
|
7a2ade0a02 | ||
|
|
a50971f26f | ||
|
|
41f733a60f | ||
|
|
282f2f4367 | ||
|
|
1e53943e06 | ||
|
|
a0141c1ba5 | ||
|
|
c16fc62877 | ||
|
|
eb6fbe518e | ||
|
|
354c11f035 | ||
|
|
d3a607e4e5 | ||
|
|
ec5dfed19e | ||
|
|
c33d5dcb1a | ||
|
|
f49c7d7e8c | ||
|
|
0c9fd37e01 | ||
|
|
5b1a52b8e0 | ||
|
|
b5d1dbc9d0 | ||
|
|
ad104449f9 | ||
|
|
02694918b5 | ||
|
|
e548369f14 | ||
|
|
3ae973c7e2 | ||
|
|
7631c0f463 | ||
|
|
4fa758240d | ||
|
|
c99021d5a3 | ||
|
|
affc826b78 | ||
|
|
d720580e75 | ||
|
|
bd6707ad14 | ||
|
|
e8f417e59f | ||
|
|
f83198e506 | ||
|
|
462fe69d80 | ||
|
|
ab7556e355 | ||
|
|
ea06f50749 | ||
|
|
840d4574da | ||
|
|
6a3fd223fc | ||
|
|
765569b3cf | ||
|
|
ad1f90a00e | ||
|
|
dfe25b1885 | ||
|
|
6fefff2ef2 | ||
|
|
419d1e8bcc | ||
|
|
fc9faa2af2 | ||
|
|
21b2854537 | ||
|
|
fa5329db20 | ||
|
|
c9f95fc34d | ||
|
|
a67b4a40b0 | ||
|
|
913702d97e | ||
|
|
86d2c8f9e8 | ||
|
|
131465097f | ||
|
|
ca086b045a | ||
|
|
52d8b83b24 | ||
|
|
8cb1a15ca5 | ||
|
|
8cef1b6a93 | ||
|
|
3707c3c0ba | ||
|
|
4c0b2aaedb | ||
|
|
fdb2442ad4 | ||
|
|
e3264a1691 | ||
|
|
6738a76152 | ||
|
|
7ae94327fb |
12
.claude/skills/interview/SKILL.md
Normal file
12
.claude/skills/interview/SKILL.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: interview
|
||||
description: "Interview me about my requirements"
|
||||
---
|
||||
|
||||
Analyze these requirements "$ARGUMENTS" and interview me in detail using the AskUserQuestionTool about literally anything: technical implementation, UI & UX, concerns, tradeoffs, etc. but make sure the questions are not obvious.
|
||||
Be very in-depth and continue interviewing me continually until it's complete, then proceed in plan mode.
|
||||
|
||||
Rules:
|
||||
|
||||
- Every question MUST have a recommended option: place it first in options, append "(推荐)" to its label, and start its description with the recommendation reason.
|
||||
- All user-facing text (question, header, label, description) MUST be in Chinese.
|
||||
325
.claude/skills/teach-me/SKILL.md
Normal file
325
.claude/skills/teach-me/SKILL.md
Normal file
@@ -0,0 +1,325 @@
|
||||
---
|
||||
name: teach-me
|
||||
description: "Personalized 1-on-1 AI tutor. Diagnoses level, builds learning path, teaches via guided questions, tracks misconceptions. Use when user wants to learn/study/understand a topic, says 'teach me', 'help me understand', or invokes /teach-me."
|
||||
---
|
||||
|
||||
# Teach Me
|
||||
|
||||
Personalized mastery tutor. Diagnose, question, advance on understanding.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
/teach-me Python decorators
|
||||
/teach-me 量子力学 --level beginner
|
||||
/teach-me React hooks --resume
|
||||
```
|
||||
|
||||
## Arguments
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `<topic>` | Subject to learn (required, or prompted) |
|
||||
| `--level <level>` | Starting level: beginner, intermediate, advanced (default: diagnose) |
|
||||
| `--resume` | Resume previous session from `.claude/skills/teach-me/records/{topic-slug}/` |
|
||||
|
||||
## Core Rules
|
||||
|
||||
1. **Minimize lecturing, but don't be dogmatic.** Prefer questions that lead to discovery. For complete beginners with zero context, a brief 1-2 sentence framing is acceptable before asking.
|
||||
2. **Diagnose first.** Always probe current understanding before teaching.
|
||||
3. **Mastery gate.** Advance to next concept only when the learner can explain it clearly and apply it.
|
||||
4. **1-2 questions per round.** No more.
|
||||
5. **Patience + rigor.** Encouraging tone, but never hand-wave past gaps.
|
||||
6. **Language follows user.** Match the user's language. Technical terms can stay in English.
|
||||
7. **Always use AskUserQuestion.** Every question to the learner MUST use AskUserQuestion with predefined options. Never ask open-ended plain-text questions — users need options to anchor their thinking. Even conceptual/deep questions should offer 3-4 options plus let the user pick "Other" for free-form input. Options serve as scaffolding, not just convenience.
|
||||
|
||||
## Output Directory
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
**Slug**: Topic in kebab-case, 2-5 words. Example: "Python decorators" → `python-decorators`
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
Input → [Load Profile] → [Diagnose] → [Build Concept List] → [Tutor Loop] → [Session End]
|
||||
```
|
||||
|
||||
### Step 0: Parse Input
|
||||
|
||||
1. Extract topic. If none, use AskUserQuestion to ask what they want to learn (provide common categories as options).
|
||||
2. Detect language from user input.
|
||||
3. Load learner profile if `.claude/skills/teach-me/records/learner-profile.md` exists.
|
||||
4. Check for existing session:
|
||||
- If `--resume`: read `session.md`, restore state, continue.
|
||||
- If exists without `--resume`: use AskUserQuestion to ask whether to resume or start fresh.
|
||||
5. Create output directory: `.claude/skills/teach-me/records/{topic-slug}/`
|
||||
|
||||
### Step 1: Diagnose Level
|
||||
|
||||
Ask 2-3 questions to calibrate understanding, all via AskUserQuestion with predefined options.
|
||||
|
||||
If learner profile exists, use it to skip known strengths and probe known weak areas.
|
||||
|
||||
If `--level` provided, use as hint but still ask 1-2 probing questions.
|
||||
|
||||
**Example for "Python decorators"**:
|
||||
|
||||
Round 1 (AskUserQuestion):
|
||||
```
|
||||
header: "Level check"
|
||||
question: "Which of these Python concepts are you comfortable with?"
|
||||
multiSelect: true
|
||||
options:
|
||||
- label: "Functions as values"
|
||||
- label: "Closures"
|
||||
- label: "The @ syntax"
|
||||
- label: "Writing custom decorators"
|
||||
```
|
||||
|
||||
Round 2 (AskUserQuestion — conceptual question with options as scaffolding):
|
||||
```
|
||||
header: "Understanding"
|
||||
question: "When Python sees @my_decorator above a function, what do you think happens?"
|
||||
multiSelect: false
|
||||
options:
|
||||
- label: "It replaces the function with a new one"
|
||||
description: "The decorator wraps or replaces the original function"
|
||||
- label: "It's just syntax sugar for calling the decorator"
|
||||
description: "@decorator is equivalent to func = decorator(func)"
|
||||
- label: "It modifies the function in-place"
|
||||
description: "The original function object is changed directly"
|
||||
- label: "I'm not sure"
|
||||
description: "No worries, we'll figure it out together"
|
||||
```
|
||||
|
||||
### Step 2: Build Concept List
|
||||
|
||||
Decompose topic into 5-15 atomic concepts, ordered by dependency. Save to `session.md`:
|
||||
|
||||
```markdown
|
||||
# Session: {topic}
|
||||
- Level: {diagnosed}
|
||||
- Started: {timestamp}
|
||||
|
||||
## Concepts
|
||||
1. ✅ Functions as first-class objects (mastered)
|
||||
2. 🔵 Higher-order functions (in progress)
|
||||
3. ⬜ Closures
|
||||
4. ⬜ Decorator basics
|
||||
...
|
||||
|
||||
## Misconceptions
|
||||
- [concept]: "{what learner said}" → likely root cause: {analysis}
|
||||
|
||||
## Log
|
||||
- [timestamp] Diagnosed: intermediate
|
||||
- [timestamp] Concept 1: pre-existing knowledge, skipped
|
||||
- [timestamp] Concept 2: started
|
||||
```
|
||||
|
||||
Use simple status: ✅ mastered | 🔵 in progress | ⬜ not started | ❌ needs review
|
||||
|
||||
Present the concept list to the learner as a brief text outline so they see the path ahead.
|
||||
|
||||
### Step 3: Tutor Loop
|
||||
|
||||
For each concept:
|
||||
|
||||
#### 3a. Introduce (Brief)
|
||||
|
||||
Set context with 1-2 sentences max, then ask an opening question via AskUserQuestion. Options serve as thinking scaffolds:
|
||||
|
||||
Example for "closures":
|
||||
```
|
||||
header: "Closures"
|
||||
question: "A closure is a function that remembers variables from where it was created. Why might that be useful?"
|
||||
multiSelect: false
|
||||
options:
|
||||
- label: "To create private state"
|
||||
description: "Keep variables hidden from outside code"
|
||||
- label: "To pass data between functions"
|
||||
description: "Share information without global variables"
|
||||
- label: "To cache expensive computations"
|
||||
description: "Remember results for reuse"
|
||||
- label: "I'm not sure yet"
|
||||
description: "We'll explore this together"
|
||||
```
|
||||
|
||||
#### 3b. Question Cycle
|
||||
|
||||
ALL questions use AskUserQuestion. Design options that probe understanding — include a mix of correct, partially correct, and common-wrong-answer distractors. The user can always use "Other" for free-form input when they have a specific idea.
|
||||
|
||||
**Option design tips**:
|
||||
- Include 1-2 correct answers (split nuance into separate options)
|
||||
- Include 1 distractor based on a common misconception
|
||||
- Include "I'm not sure" or "Let me think about it" as a safe option
|
||||
- Use descriptions to add hints or context to each option
|
||||
|
||||
**Interleaving** (every 3-4 questions): Mix a previously mastered concept into the current question's options naturally. Don't announce it as review.
|
||||
|
||||
Example (learning closures, already mastered higher-order functions):
|
||||
```
|
||||
header: "Prediction"
|
||||
question: "Here's a function that takes a callback and returns a new function. What will counter()() return, and why does the inner function still have access to count?"
|
||||
multiSelect: false
|
||||
options:
|
||||
- label: "0, because count starts at 0"
|
||||
description: "The inner function reads the initial value"
|
||||
- label: "1, because count was incremented before returning"
|
||||
description: "Closure captures the live variable, not a copy"
|
||||
- label: "Error, because count is out of scope"
|
||||
description: "The outer function already returned, so count is gone"
|
||||
- label: "Undefined behavior"
|
||||
description: "Depends on how the function was defined"
|
||||
```
|
||||
|
||||
#### 3c. Respond to Answers
|
||||
|
||||
| Answer Quality | Response |
|
||||
|----------------|----------|
|
||||
| Correct + good explanation | Brief acknowledgment, harder follow-up via AskUserQuestion |
|
||||
| Correct but shallow | "Good. Can you explain *why*?" — as AskUserQuestion with why-options |
|
||||
| Partially correct | "On the right track with [part]." — follow up with a more targeted AskUserQuestion |
|
||||
| Incorrect | "Interesting. Let's step back." — simpler AskUserQuestion to re-anchor |
|
||||
| "I don't know" / "Not sure" | "That's fine." — give a concrete example, then ask via AskUserQuestion with simpler options |
|
||||
|
||||
**Hint escalation**: rephrase → simpler question → concrete example → point to principle → walk through minimal example together.
|
||||
|
||||
#### 3d. Misconception Tracking
|
||||
|
||||
On incorrect or partially correct answers, diagnose the underlying wrong mental model:
|
||||
|
||||
1. Present a counter-example via AskUserQuestion — ask the learner to predict what happens, where the wrong mental model leads to a clearly wrong answer:
|
||||
```
|
||||
header: "Check this"
|
||||
question: "Given [counter-example], what do you think the output will be?"
|
||||
multiSelect: false
|
||||
options:
|
||||
- label: "[wrong prediction from their mental model]"
|
||||
description: "Based on what we discussed earlier"
|
||||
- label: "[correct prediction]"
|
||||
description: "A different perspective"
|
||||
- label: "[another wrong prediction]"
|
||||
description: "Yet another possibility"
|
||||
- label: "I need to think more"
|
||||
description: "Take your time"
|
||||
```
|
||||
2. Record in session.md under `## Misconceptions`
|
||||
3. When the learner sees the contradiction (their model predicts the wrong thing), guide them to articulate why.
|
||||
4. A misconception is resolved when the learner articulates why their old thinking was wrong AND handles a new scenario correctly.
|
||||
|
||||
Never say "that's a misconception." Let them discover it.
|
||||
|
||||
#### 3e. Mastery Check
|
||||
|
||||
After 3-5 question rounds, assess qualitatively. The learner demonstrates mastery when they can:
|
||||
|
||||
- Explain the concept in their own words
|
||||
- Apply it to a new scenario
|
||||
- Distinguish it from similar concepts
|
||||
- Find errors in incorrect usage
|
||||
|
||||
If not ready: identify the specific gap and cycle back with targeted questions.
|
||||
|
||||
#### 3f. Practice Phase
|
||||
|
||||
Before marking mastered, give a small hands-on task via AskUserQuestion. Present the task as a code/output prediction or scenario choice:
|
||||
|
||||
- **Programming**: Show a small code snippet and ask what it outputs or which fix is correct:
|
||||
```
|
||||
header: "Practice"
|
||||
question: "Here's a buggy decorator. What's wrong with it?"
|
||||
multiSelect: false
|
||||
options:
|
||||
- label: "Missing return wrapper"
|
||||
description: "The decorator doesn't return the inner function"
|
||||
- label: "Wrong function signature"
|
||||
description: "The wrapper doesn't accept *args, **kwargs"
|
||||
- label: "Missing @functools.wraps"
|
||||
description: "Metadata from the original function is lost"
|
||||
- label: "I'd like to try writing one from scratch"
|
||||
description: "Use 'Other' to write your own code"
|
||||
```
|
||||
- **Non-programming**: Ask to identify which scenario best applies the concept:
|
||||
```
|
||||
header: "Apply it"
|
||||
question: "Which real-world scenario best demonstrates [concept]?"
|
||||
multiSelect: false
|
||||
options:
|
||||
- label: "[scenario A]"
|
||||
- label: "[scenario B]"
|
||||
- label: "[scenario C]"
|
||||
- label: "I have my own example"
|
||||
description: "Use 'Other' to share your own"
|
||||
```
|
||||
|
||||
Keep it 2-5 minutes. Pass = mastered. Fail = diagnose gap, cycle back.
|
||||
|
||||
#### 3g. Sync Progress (Every Round)
|
||||
|
||||
Update `session.md` after each round:
|
||||
- Change concept status if applicable
|
||||
- Add new misconceptions or resolve existing ones
|
||||
- Append to log
|
||||
|
||||
### Step 4: Session End
|
||||
|
||||
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):
|
||||
|
||||
```markdown
|
||||
# Learner Profile
|
||||
Updated: {timestamp}
|
||||
|
||||
## Style
|
||||
- Learns best with: {concrete examples / abstract principles / visual ...}
|
||||
- Pace: {fast / moderate / needs-time}
|
||||
|
||||
## Patterns
|
||||
- Tends to confuse X with Y
|
||||
- Recurring difficulty with: {area}
|
||||
|
||||
## Topics
|
||||
- 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.
|
||||
|
||||
## Resuming Sessions
|
||||
|
||||
On `--resume`:
|
||||
|
||||
1. Read `session.md` and `learner-profile.md`
|
||||
2. Quick check on 1-2 previously mastered concepts via AskUserQuestion:
|
||||
```
|
||||
header: "Quick review"
|
||||
question: "Last time you mastered [concept X]. Can you recall which of these is true about it?"
|
||||
multiSelect: false
|
||||
options:
|
||||
- label: "[correct statement]"
|
||||
- label: "[plausible distractor]"
|
||||
- label: "[plausible distractor]"
|
||||
- label: "I forgot this one"
|
||||
description: "No worries, we'll revisit it"
|
||||
```
|
||||
3. If forgotten, mark as ❌ needs review and revisit before continuing
|
||||
4. Recap: "Last time you mastered [X]. You were working on [Y]."
|
||||
5. Continue from first in-progress or not-started concept
|
||||
|
||||
## Notes
|
||||
|
||||
- Keep it conversational, not mechanical
|
||||
- Vary question types: predict, compare, debug, extend, teach-back, connect
|
||||
- Slow down when struggling, speed up when flying
|
||||
- Interleaving should feel natural, not like a pop quiz
|
||||
- Wrong answers are more informative than right ones — never rush past them
|
||||
235
.claude/skills/teach-me/references/pedagogy.md
Normal file
235
.claude/skills/teach-me/references/pedagogy.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# Pedagogy Guide
|
||||
|
||||
## Bloom's 2-Sigma Effect
|
||||
|
||||
Benjamin Bloom (1984) found that students tutored 1-on-1 with mastery learning performed 2 standard deviations above conventional classroom students. The two key ingredients:
|
||||
|
||||
1. **Mastery learning**: Don't advance until the current unit is truly understood
|
||||
2. **1-on-1 tutoring**: Adapt pace, style, and content to the individual learner
|
||||
|
||||
## Socratic Method Integration
|
||||
|
||||
Never lecture. Instead:
|
||||
- Ask questions that lead the learner to discover the answer
|
||||
- When they're stuck, don't explain — ask a simpler question
|
||||
- When they answer correctly, don't just confirm — ask them to explain why
|
||||
|
||||
## Question Design Patterns
|
||||
|
||||
### Diagnostic Questions (Step 1)
|
||||
|
||||
Purpose: Quickly map what the learner knows and doesn't know.
|
||||
|
||||
| Type | Example | Probes |
|
||||
|------|---------|--------|
|
||||
| Vocabulary check | "What does [term] mean to you?" | Do they know the words? |
|
||||
| Concept sorting | "Which of these are examples of X?" (AskUserQuestion) | Can they categorize? |
|
||||
| Prediction | "What do you think happens when...?" | Intuition level |
|
||||
| Explain-back | "Explain [concept] as if to a 10-year-old" | Depth of understanding |
|
||||
|
||||
### Teaching Questions (Step 3)
|
||||
|
||||
| Pattern | When | Example |
|
||||
|---------|------|---------|
|
||||
| **Predict** | Introducing new behavior | "What will this code print?" |
|
||||
| **Compare** | Distinguishing similar concepts | "How is X different from Y?" |
|
||||
| **Debug** | Testing careful reading | "This code has a bug. Can you find it?" |
|
||||
| **Extend** | Testing transfer | "Now how would you modify this to also handle...?" |
|
||||
| **Teach-back** | Confirming mastery | "Explain to me how [concept] works" |
|
||||
| **Connect** | Building knowledge graph | "How does [new concept] relate to [previous concept]?" |
|
||||
|
||||
### Mastery Check Questions (Step 3g)
|
||||
|
||||
These should be synthesis-level:
|
||||
- Combine the current concept with 1-2 previous concepts
|
||||
- Require application, not just recall
|
||||
- Include at least one novel scenario not seen during teaching
|
||||
|
||||
### Interleaving Questions (Step 3b)
|
||||
|
||||
Interleaving means mixing questions about old concepts into the current learning flow. Research (Rohrer & Taylor 2007, Dunlosky et al. 2013) shows interleaved practice improves long-term retention by ~43% compared to blocked practice.
|
||||
|
||||
**Why it works**: Interleaving forces the learner to discriminate between concepts ("which tool applies here?"), which is a higher cognitive demand than applying a known concept. This discrimination practice is what builds durable, flexible knowledge.
|
||||
|
||||
**How to design interleaving questions**:
|
||||
- The question must require BOTH the old concept and the current concept
|
||||
- Don't announce it as review — embed it naturally
|
||||
- Prioritize concepts that are easily confused with the current one
|
||||
- If the learner fails the old-concept part, it's a signal the old concept is decaying — note it for spaced repetition
|
||||
|
||||
| Interleaving Pattern | Example |
|
||||
|---------------------|---------|
|
||||
| **Combine** | "Use both [old concept] and [new concept] to solve this" |
|
||||
| **Discriminate** | "Would you use [old concept] or [new concept] here? Why?" |
|
||||
| **Contrast** | "This looks similar to [old concept]. What's different?" |
|
||||
| **Layer** | "We used [old concept] to do X. Now add [new concept] on top." |
|
||||
|
||||
## Mastery Scoring (Calibrated)
|
||||
|
||||
### Rubric-Based Assessment
|
||||
|
||||
Do NOT score based on vague impression. Use these 4 criteria for each mastery check question:
|
||||
|
||||
| Criterion | Weight | What to look for |
|
||||
|-----------|--------|------------------|
|
||||
| **Accurate** | 1 point | Factually/logically correct answer |
|
||||
| **Explained** | 1 point | Learner articulates the WHY, not just the WHAT |
|
||||
| **Novel application** | 1 point | Can apply to a scenario not seen during teaching |
|
||||
| **Discrimination** | 1 point | Can distinguish from similar/related concepts |
|
||||
|
||||
Score per question = criteria met / 4. Concept mastery requires >= 3/4 on each mastery check question AND >= 80% overall concept score.
|
||||
|
||||
### Self-Assessment Calibration
|
||||
|
||||
Ask the learner to self-assess BEFORE revealing your evaluation. Compare:
|
||||
|
||||
| Self vs Rubric | What it means | Action |
|
||||
|----------------|---------------|--------|
|
||||
| Both high | Good metacognition, true mastery | Proceed to practice phase |
|
||||
| Self HIGH, rubric LOW | **Fluency illusion** — most dangerous | Flag explicitly, show evidence of gaps |
|
||||
| Self LOW, rubric HIGH | Under-confidence | Reassure with specific evidence |
|
||||
| Both low | Honest awareness of gaps | Cycle back, adjust approach |
|
||||
|
||||
**Fluency illusion** (Bjork, 1994): The feeling of understanding that comes from familiarity rather than actual comprehension. Common triggers: seeing a worked example and thinking "I could do that", recognizing terminology without being able to apply it, confusing passive exposure with active mastery.
|
||||
|
||||
### Qualitative Signals
|
||||
|
||||
Beyond the rubric, these signals indicate genuine mastery:
|
||||
- Learner can explain concept in their own words
|
||||
- Learner can give novel examples
|
||||
- Learner can identify errors in incorrect examples
|
||||
- Learner can connect concept to broader context
|
||||
|
||||
## Misconception Handling
|
||||
|
||||
### Why Misconceptions Matter More Than Gaps
|
||||
|
||||
A gap in knowledge ("I don't know X") is easy to fill — just teach X. A misconception ("I know X, but my version of X is wrong") is far harder because the wrong model must be dismantled before the correct one can take hold. Research (Vosniadou 2013, Chi 2005) shows that misconceptions are the #1 barrier to learning in most domains.
|
||||
|
||||
### Types of Misconceptions
|
||||
|
||||
| Type | Example | Why it's sticky |
|
||||
|------|---------|----------------|
|
||||
| **Overgeneralization** | "All functions return values" | Correct in many cases, fails in edge cases |
|
||||
| **False analogy** | "Electricity flows like water" | Useful at first, breaks down at depth |
|
||||
| **Vocabulary confusion** | "Parameter and argument are the same" | Language reinforces the error daily |
|
||||
| **Causal reversal** | "Practice makes talent" (vs talent enables practice) | Correlation mistaken for causation |
|
||||
| **Incomplete model** | "Closures copy variables" (actually capture references) | Partially correct, fails under mutation |
|
||||
|
||||
### The Counter-Example Method
|
||||
|
||||
The most effective way to dislodge a misconception is NOT to say "that's wrong." It's to construct a scenario where the wrong model makes a clear, testable prediction — and then show reality contradicts it.
|
||||
|
||||
Steps:
|
||||
1. **Identify** the wrong model from the learner's answer
|
||||
2. **Construct** a scenario where the wrong model predicts outcome A
|
||||
3. **Ask** the learner to predict the outcome (they'll predict A)
|
||||
4. **Reveal** that the actual outcome is B
|
||||
5. **Ask** the learner to explain the discrepancy
|
||||
6. **Wait** — let the learner wrestle with the contradiction. Do NOT explain immediately.
|
||||
7. **Guide** toward the correct model only after they've engaged with the contradiction
|
||||
|
||||
### Misconception Resolution Criteria
|
||||
|
||||
A misconception is resolved ONLY when BOTH conditions are met:
|
||||
1. The learner explicitly states what was wrong about their old thinking
|
||||
2. The learner correctly handles a new scenario that would have triggered the old misconception
|
||||
|
||||
Getting the right answer once is NOT enough — they must also articulate why the old answer was wrong.
|
||||
|
||||
## Spaced Repetition
|
||||
|
||||
### The Forgetting Curve
|
||||
|
||||
Ebbinghaus (1885) demonstrated that without review, memory decays exponentially:
|
||||
- After 1 hour: ~50% forgotten
|
||||
- After 1 day: ~70% forgotten
|
||||
- After 1 week: ~90% forgotten
|
||||
|
||||
The only way to counteract this is **spaced review** — re-testing at increasing intervals.
|
||||
|
||||
### Interval Schedule
|
||||
|
||||
Sigma uses a simplified SM-2 inspired schedule:
|
||||
|
||||
| Event | Next Review Interval |
|
||||
|-------|---------------------|
|
||||
| Concept first mastered | 1 day |
|
||||
| Review: correct | Double the interval (1d → 2d → 4d → 8d → 16d → 32d) |
|
||||
| Review: incorrect | Reset to 1 day |
|
||||
| Maximum interval | 32 days |
|
||||
|
||||
### Review Question Design
|
||||
|
||||
Review questions should be:
|
||||
- **Brief**: 1 question per concept, not a full mastery check
|
||||
- **Application-level**: Not "what is X?" but "use X to solve this small problem"
|
||||
- **Connected**: Where possible, connect the review concept to the current concept being learned (this also serves as interleaving)
|
||||
|
||||
### Session Review Protocol
|
||||
|
||||
On `--resume`, before continuing new content:
|
||||
1. Identify all mastered concepts where `days_since_review >= review_interval`
|
||||
2. Sort by most overdue first
|
||||
3. Review max 5 concepts per session (don't turn the session into all review)
|
||||
4. Adjust intervals based on results
|
||||
5. If a concept drops back to `in-progress`, address it before continuing forward
|
||||
|
||||
## Deliberate Practice
|
||||
|
||||
### Understanding ≠ Ability
|
||||
|
||||
Ericsson's research on expert performance (1993) established that knowing how something works is fundamentally different from being able to do it. The gap between declarative knowledge ("I can explain decorators") and procedural knowledge ("I can write a decorator") requires practice to bridge.
|
||||
|
||||
### Practice Task Design
|
||||
|
||||
Good practice tasks for Sigma:
|
||||
|
||||
| Property | Good | Bad |
|
||||
|----------|------|-----|
|
||||
| **Size** | 2-5 minutes | 30-minute project |
|
||||
| **Scope** | Tests one concept | Tests everything at once |
|
||||
| **Novelty** | New scenario, same concept | Repeat of a teaching example |
|
||||
| **Output** | Learner produces something | Learner answers more questions |
|
||||
| **Feedback** | Clear right/wrong signal | Ambiguous quality |
|
||||
|
||||
### Practice vs More Questions
|
||||
|
||||
Practice is NOT more Q&A. The key differences:
|
||||
|
||||
| Dimension | Questions (3b) | Practice (3h) |
|
||||
|-----------|----------------|---------------|
|
||||
| Mode | Reactive (answer what's asked) | Generative (produce something new) |
|
||||
| Cognitive load | Recognition + recall | Planning + execution + self-monitoring |
|
||||
| Output | Words | Artifact (code, design, example, explanation) |
|
||||
| Feedback | Immediate from tutor | Self-discovered through doing |
|
||||
|
||||
### The Generation Effect
|
||||
|
||||
Slamecka & Graf (1978) showed that information the learner generates themselves is remembered 2-3x better than information they read. Practice tasks leverage this effect — the learner constructs knowledge through the act of doing.
|
||||
|
||||
## Adaptive Pacing
|
||||
|
||||
| Signal | Action |
|
||||
|--------|--------|
|
||||
| Answers quickly and correctly | Skip to harder questions, consider merging concepts |
|
||||
| Answers correctly but slowly | Proceed normally, give time |
|
||||
| Partially correct | Ask follow-up probing questions before moving on |
|
||||
| Consistently wrong | Break down into sub-concepts, use more concrete examples |
|
||||
| Frustrated | Switch to a visual aid, use analogy, acknowledge difficulty |
|
||||
| Bored | Increase difficulty, introduce real-world application |
|
||||
|
||||
## Visual Aid Selection
|
||||
|
||||
Use the right format for the right purpose:
|
||||
|
||||
| Need | Format | When |
|
||||
|------|--------|------|
|
||||
| Show relationships | Excalidraw concept map | Concepts have dependencies or hierarchy |
|
||||
| Walk through process | HTML step-by-step | Code execution, algorithm steps |
|
||||
| Abstract idea | Generated image (nano-banana-pro) | Metaphors, mental models |
|
||||
| Compare options | HTML table/grid | Feature comparison, trade-offs |
|
||||
| Show flow/logic | Excalidraw flowchart | Decision trees, control flow |
|
||||
| Summarize progress | HTML dashboard | Milestones, session end |
|
||||
|
||||
Don't generate visuals for every round — use them when they genuinely help understanding or when the learner seems stuck.
|
||||
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.githooks
|
||||
.github
|
||||
docs
|
||||
*.md
|
||||
packages/remote-control-server/data/*.db
|
||||
packages/remote-control-server/data/*.db-wal
|
||||
packages/remote-control-server/data/*.db-shm
|
||||
.claude
|
||||
0
.githooks/pre-commit
Executable file → Normal file
0
.githooks/pre-commit
Executable file → Normal file
75
.github/workflows/release-rcs.yml
vendored
Normal file
75
.github/workflows/release-rcs.yml
vendored
Normal file
@@ -0,0 +1,75 @@
|
||||
name: Release RCS Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'rcs-v*'
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository_owner }}/remote-control-server
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Extract version
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF_NAME#rcs-v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Generate tags
|
||||
id: tags
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.VERSION }}"
|
||||
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
|
||||
TAGS="${IMAGE}:${VERSION}"
|
||||
IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION"
|
||||
if [ -n "$MAJOR" ] && [ -n "$MINOR" ]; then
|
||||
TAGS="${TAGS},${IMAGE}:${MAJOR}.${MINOR}"
|
||||
fi
|
||||
TAGS="${TAGS},${IMAGE}:latest"
|
||||
echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: packages/remote-control-server/Dockerfile
|
||||
push: false
|
||||
load: true
|
||||
tags: ${{ steps.tags.outputs.tags }}
|
||||
build-args: VERSION=${{ steps.version.outputs.VERSION }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Verify image
|
||||
run: |
|
||||
IMAGE_TAG=$(echo "${{ steps.tags.outputs.tags }}" | cut -d',' -f1)
|
||||
docker run -d --name rcs-test -p 3000:3000 "$IMAGE_TAG"
|
||||
sleep 5
|
||||
curl -sf http://localhost:3000/health || { docker logs rcs-test; exit 1; }
|
||||
docker stop rcs-test
|
||||
docker rm rcs-test
|
||||
|
||||
- name: Push Docker image
|
||||
run: |
|
||||
IFS=',' read -ra TAGS <<< "${{ steps.tags.outputs.tags }}"
|
||||
for TAG in "${TAGS[@]}"; do
|
||||
docker push "$TAG"
|
||||
done
|
||||
31
.github/workflows/update-contributors.yml
vendored
Normal file
31
.github/workflows/update-contributors.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
name: Update Contributors
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
schedule:
|
||||
- cron: '0 0 * * *' # 每天更新一次
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: jaywcjlove/github-action-contributors@main
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
output: "contributors.svg"
|
||||
repository: ${{ github.repository }}
|
||||
|
||||
- uses: stefanzweifel/git-auto-commit-action@v5
|
||||
with:
|
||||
commit_message: "docs: update contributors"
|
||||
file_pattern: "contributors.svg"
|
||||
branch: main
|
||||
22
.gitignore
vendored
22
.gitignore
vendored
@@ -8,4 +8,24 @@ coverage
|
||||
.vscode
|
||||
*.suo
|
||||
*.lock
|
||||
src/utils/vendor/
|
||||
src/utils/vendor/
|
||||
|
||||
# AI tool runtime directories
|
||||
.agents/
|
||||
.codex/
|
||||
.omx/
|
||||
|
||||
# Binary / screenshot files (root only)
|
||||
/*.png
|
||||
*.bmp
|
||||
|
||||
# Agent / tool state dirs
|
||||
.swarm/
|
||||
.agents/__pycache__/
|
||||
|
||||
# Python bytecode
|
||||
__pycache__/
|
||||
*.pyc
|
||||
logs
|
||||
|
||||
data
|
||||
|
||||
22
.vscode/launch.json
vendored
22
.vscode/launch.json
vendored
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "bun",
|
||||
"request": "attach",
|
||||
"name": "Attach to Bun (TUI debug)",
|
||||
"url": "ws://localhost:8888/2dc3gzl5xot",
|
||||
"stopOnEntry": false,
|
||||
"internalConsoleOptions": "neverOpen"
|
||||
}
|
||||
]
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "bun",
|
||||
"request": "attach",
|
||||
"name": "Attach to Claude Code",
|
||||
"url": "ws://localhost:8888/2dc3gzl5xot",
|
||||
"stopOnEntry": false,
|
||||
"internalConsoleOptions": "neverOpen"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
27
.vscode/tasks.json
vendored
Normal file
27
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Start Claude Code TUI",
|
||||
"type": "shell",
|
||||
"command": "bun run dev:inspect",
|
||||
"isBackground": true,
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"focus": true,
|
||||
"panel": "dedicated",
|
||||
"clear": true
|
||||
},
|
||||
"problemMatcher": {
|
||||
"pattern": {
|
||||
"regexp": "^$"
|
||||
},
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": ".*Bun Inspector.*",
|
||||
"endsPattern": ".*Listening:.*"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
77
CLAUDE.md
77
CLAUDE.md
@@ -51,8 +51,8 @@ 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。默认启用 `AGENT_TRIGGERS_REMOTE` feature。构建后自动替换 `import.meta.require` 为 Node.js 兼容版本(产物 bun/node 都可运行)。
|
||||
- **Dev mode**: `scripts/dev.ts` 通过 Bun `-d` flag 注入 `MACRO.*` defines,运行 `src/entrypoints/cli.tsx`。默认启用 `BUDDY`、`TRANSCRIPT_CLASSIFIER`、`BRIDGE_MODE`、`AGENT_TRIGGERS_REMOTE` 四个 feature。
|
||||
- **Build**: `build.ts` 执行 `Bun.build()` with `splitting: true`,入口 `src/entrypoints/cli.tsx`,输出 `dist/cli.js` + chunk files。默认启用 `AGENT_TRIGGERS_REMOTE`、`CHICAGO_MCP`、`VOICE_MODE` feature。构建后自动替换 `import.meta.require` 为 Node.js 兼容版本(产物 bun/node 都可运行)。
|
||||
- **Dev mode**: `scripts/dev.ts` 通过 Bun `-d` flag 注入 `MACRO.*` defines,运行 `src/entrypoints/cli.tsx`。默认启用 `BUDDY`、`TRANSCRIPT_CLASSIFIER`、`BRIDGE_MODE`、`AGENT_TRIGGERS_REMOTE`、`CHICAGO_MCP`、`VOICE_MODE` 六个 feature。
|
||||
- **Module system**: ESM (`"type": "module"`), TSX with `react-jsx` transform.
|
||||
- **Monorepo**: Bun workspaces — internal packages live in `packages/` resolved via `workspace:*`.
|
||||
- **Lint/Format**: Biome (`biome.json`)。`bun run lint` / `bun run lint:fix` / `bun run format`。
|
||||
@@ -132,8 +132,8 @@ Feature flags control which functionality is enabled at runtime:
|
||||
|
||||
- **在代码中使用**: 统一通过 `import { feature } from 'bun:bundle'` 导入,调用 `feature('FLAG_NAME')` 返回 `boolean`。**不要**在 `cli.tsx` 或其他文件里自己定义 `feature` 函数或覆盖这个 import。
|
||||
- **启用方式**: 通过环境变量 `FEATURE_<FLAG_NAME>=1`。例如 `FEATURE_BUDDY=1 bun run dev` 启用 BUDDY 功能。
|
||||
- **Dev 默认 features**: `BUDDY`、`TRANSCRIPT_CLASSIFIER`、`BRIDGE_MODE`、`AGENT_TRIGGERS_REMOTE`(见 `scripts/dev.ts`)。
|
||||
- **Build 默认 features**: `AGENT_TRIGGERS_REMOTE`(见 `build.ts`)。
|
||||
- **Dev 默认 features**: `BUDDY`、`TRANSCRIPT_CLASSIFIER`、`BRIDGE_MODE`、`AGENT_TRIGGERS_REMOTE`、`CHICAGO_MCP`、`VOICE_MODE`(见 `scripts/dev.ts`)。
|
||||
- **Build 默认 features**: `AGENT_TRIGGERS_REMOTE`、`CHICAGO_MCP`、`VOICE_MODE`(见 `build.ts`)。
|
||||
- **常见 flag**: `BUDDY`, `DAEMON`, `BRIDGE_MODE`, `BG_SESSIONS`, `PROACTIVE`, `KAIROS`, `VOICE_MODE`, `FORK_SUBAGENT`, `SSH_REMOTE`, `DIRECT_CONNECT`, `TEMPLATES`, `CHICAGO_MCP`, `BYOC_ENVIRONMENT_RUNNER`, `SELF_HOSTED_RUNNER`, `COORDINATOR_MODE`, `UDS_INBOX`, `LODESTONE`, `ABLATION_BASELINE` 等。
|
||||
- **类型声明**: `src/types/internal-modules.d.ts` 中声明了 `bun:bundle` 模块的 `feature` 函数签名。
|
||||
|
||||
@@ -143,13 +143,76 @@ Feature flags control which functionality is enabled at runtime:
|
||||
|
||||
| Module | Status |
|
||||
|--------|--------|
|
||||
| Computer Use (`@ant/*`) | Stub packages in `packages/@ant/` |
|
||||
| `*-napi` packages (audio, image, url, modifiers) | Stubs in `packages/` (except `color-diff-napi` which is fully implemented) |
|
||||
| Computer Use (`@ant/*`) | Restored — `computer-use-swift`, `computer-use-input`, `computer-use-mcp`, `claude-for-chrome-mcp` 均有完整实现,macOS + Windows 可用,Linux 后端待完成 |
|
||||
| `*-napi` packages | `audio-capture-napi`、`image-processor-napi` 已恢复实现;`color-diff-napi` 完整实现;`url-handler-napi`、`modifiers-napi` 仍为 stub |
|
||||
| Voice Mode | Restored — `src/voice/`、`src/hooks/useVoiceIntegration.tsx`、`src/services/voiceStreamSTT.ts` 等,Push-to-Talk 语音输入(需 Anthropic OAuth) |
|
||||
| OpenAI 兼容层 | Restored — `src/services/api/openai/`,支持 Ollama/DeepSeek/vLLM 等任意 OpenAI 协议端点,通过 `CLAUDE_CODE_USE_OPENAI=1` 启用 |
|
||||
| Analytics / GrowthBook / Sentry | Empty implementations |
|
||||
| Magic Docs / Voice Mode / LSP Server | Removed |
|
||||
| Magic Docs / LSP Server | Removed |
|
||||
| Plugins / Marketplace | Removed |
|
||||
| MCP OAuth | Simplified |
|
||||
|
||||
### Computer Use
|
||||
|
||||
Feature flag `CHICAGO_MCP`,dev/build 默认启用。实现跨平台屏幕操控(macOS + Windows 可用,Linux 待完成)。
|
||||
|
||||
- **`packages/@ant/computer-use-mcp/`** — MCP server,注册截图/键鼠/剪贴板/应用管理工具
|
||||
- **`packages/@ant/computer-use-input/`** — 键鼠模拟,dispatcher + per-platform backend(`backends/darwin.ts`、`win32.ts`、`linux.ts`)
|
||||
- **`packages/@ant/computer-use-swift/`** — 截图 + 应用管理,同样 dispatcher + per-platform backend
|
||||
- **`packages/@ant/claude-for-chrome-mcp/`** — Chrome 浏览器控制(独立于 Computer Use,通过 `--chrome` CLI 参数启用)
|
||||
|
||||
详见 `docs/features/computer-use.md`。
|
||||
|
||||
### Voice Mode
|
||||
|
||||
Feature flag `VOICE_MODE`,dev/build 默认启用。Push-to-Talk 语音输入,音频通过 WebSocket 流式传输到 Anthropic STT(Nova 3)。需要 Anthropic OAuth(非 API key)。
|
||||
|
||||
- **`src/voice/voiceModeEnabled.ts`** — 三层门控(feature flag + GrowthBook + OAuth auth)
|
||||
- **`src/hooks/useVoice.ts`** — React hook 管理录音状态和 WebSocket 连接
|
||||
- **`src/services/voiceStreamSTT.ts`** — STT WebSocket 流式传输
|
||||
|
||||
详见 `docs/features/voice-mode.md`。
|
||||
|
||||
### OpenAI 兼容层
|
||||
|
||||
通过 `CLAUDE_CODE_USE_OPENAI=1` 环境变量启用,支持任意 OpenAI Chat Completions 协议端点(Ollama、DeepSeek、vLLM 等)。流适配器模式:在 `queryModel()` 中将 Anthropic 格式请求转为 OpenAI 格式,再将 SSE 流转换回 `BetaRawMessageStreamEvent`,下游代码完全不改。
|
||||
|
||||
- **`src/services/api/openai/`** — client、消息/工具转换、流适配、模型映射
|
||||
- **`src/utils/model/providers.ts`** — 添加 `'openai'` provider 类型(最高优先级)
|
||||
|
||||
关键环境变量:`CLAUDE_CODE_USE_OPENAI`、`OPENAI_API_KEY`、`OPENAI_BASE_URL`、`OPENAI_MODEL`、`OPENAI_DEFAULT_OPUS_MODEL`、`OPENAI_DEFAULT_SONNET_MODEL`、`OPENAI_DEFAULT_HAIKU_MODEL`。详见 `docs/plans/openai-compatibility.md`。
|
||||
|
||||
### Gemini 兼容层
|
||||
|
||||
通过 `CLAUDE_CODE_USE_GEMINI=1` 环境变量或 `modelType: "gemini"` 设置启用,支持 Google Gemini API。独立的环境变量体系,不与 OpenAI 或 Anthropic 配置混杂。
|
||||
|
||||
- **`src/services/api/gemini/`** — client、模型映射、类型定义
|
||||
- **`src/utils/model/providers.ts`** — 添加 `'gemini'` provider 类型
|
||||
- **`src/utils/managedEnvConstants.ts`** — Gemini 专用的 managed env vars
|
||||
|
||||
关键环境变量:
|
||||
- `CLAUDE_CODE_USE_GEMINI` - 启用 Gemini provider
|
||||
- `GEMINI_API_KEY` - API 密钥(必填)
|
||||
- `GEMINI_BASE_URL` - API 端点(可选,默认 `https://generativelanguage.googleapis.com/v1beta`)
|
||||
- `GEMINI_MODEL` - 直接指定模型(最高优先级)
|
||||
- `GEMINI_DEFAULT_HAIKU_MODEL` / `GEMINI_DEFAULT_SONNET_MODEL` / `GEMINI_DEFAULT_OPUS_MODEL` - 按能力级别映射
|
||||
- `GEMINI_DEFAULT_HAIKU_MODEL_NAME` / `DESCRIPTION` / `SUPPORTED_CAPABILITIES` - 显示名称和描述
|
||||
- `GEMINI_SMALL_FAST_MODEL` - 快速任务使用的模型(可选)
|
||||
|
||||
模型映射优先级(`src/services/api/gemini/modelMapping.ts`):
|
||||
1. `GEMINI_MODEL` - 直接覆盖
|
||||
2. `GEMINI_DEFAULT_*_MODEL` - 独立配置(推荐)
|
||||
3. `ANTHROPIC_DEFAULT_*_MODEL` - 向后兼容 fallback(已废弃)
|
||||
4. 原样返回 Anthropic 模型名
|
||||
|
||||
使用示例:
|
||||
```bash
|
||||
export CLAUDE_CODE_USE_GEMINI=1
|
||||
export GEMINI_API_KEY="your-api-key"
|
||||
export GEMINI_DEFAULT_SONNET_MODEL="gemini-2.5-flash"
|
||||
export GEMINI_DEFAULT_OPUS_MODEL="gemini-2.5-pro"
|
||||
```
|
||||
|
||||
### Key Type Files
|
||||
|
||||
- **`src/types/global.d.ts`** — Declares `MACRO`, `BUILD_TARGET`, `BUILD_ENV` and internal Anthropic-only identifiers.
|
||||
|
||||
518
DEV-LOG.md
518
DEV-LOG.md
@@ -1,5 +1,522 @@
|
||||
# DEV-LOG
|
||||
|
||||
## Daemon + Remote Control Server 还原 (2026-04-07)
|
||||
|
||||
**分支**: `feat/daemon-remote-control-server`
|
||||
|
||||
### 背景
|
||||
|
||||
`src/commands.ts` 注册了 `remoteControlServer` 命令(双重门控 `feature('DAEMON') && feature('BRIDGE_MODE')`),但 `src/commands/remoteControlServer/` 目录缺失,`src/daemon/main.ts` 和 `src/daemon/workerRegistry.ts` 均为 stub。官方 CLI 2.1.92 中情况一致——Anthropic 已预留注册点和底层 `runBridgeHeadless()` 实现,但中间层(daemon supervisor + command 入口)未发布。
|
||||
|
||||
通过逐级反向追踪调用链还原完整实现:
|
||||
```
|
||||
/remote-control-server (slash command)
|
||||
→ spawn: claude daemon start
|
||||
→ daemonMain() (supervisor,管理 worker 生命周期)
|
||||
→ spawn: claude --daemon-worker=remoteControl
|
||||
→ runDaemonWorker('remoteControl')
|
||||
→ runBridgeHeadless(opts, signal) ← 已有完整实现
|
||||
→ runBridgeLoop() → 接受远程会话
|
||||
```
|
||||
|
||||
### 实现
|
||||
|
||||
#### 1. Worker Registry(`src/daemon/workerRegistry.ts`)
|
||||
|
||||
从 stub 还原为 worker 分发器:
|
||||
- `runDaemonWorker(kind)` 按 `kind` 分发到不同 worker 实现
|
||||
- `runRemoteControlWorker()` 从环境变量(`DAEMON_WORKER_*`)读取配置,构造 `HeadlessBridgeOpts`,调用 `runBridgeHeadless()`
|
||||
- 区分 permanent(`EXIT_CODE_PERMANENT = 78`)和 transient 错误,supervisor 据此决定重试或 park
|
||||
- SIGTERM/SIGINT 信号处理,通过 `AbortController` 传递给 bridge loop
|
||||
|
||||
#### 2. Daemon Supervisor(`src/daemon/main.ts`)
|
||||
|
||||
从 stub 还原为完整 supervisor 进程:
|
||||
- `daemonMain(args)` 支持子命令:`start`(启动)、`status`、`stop`、`--help`
|
||||
- `runSupervisor()` spawn `remoteControl` worker 子进程,通过环境变量传递配置
|
||||
- 指数退避重启(2s → 120s),10s 内连续崩溃 5 次则 park worker
|
||||
- permanent exit code(78)直接 park,不重试
|
||||
- graceful shutdown:SIGTERM → 转发给 worker → 30s grace → SIGKILL
|
||||
- CLI 参数支持:`--dir`、`--spawn-mode`、`--capacity`、`--permission-mode`、`--sandbox`、`--name`
|
||||
|
||||
#### 3. Remote Control Server 命令(`src/commands/remoteControlServer/`)
|
||||
|
||||
**`index.ts`** — Command 注册:
|
||||
- 类型 `local-jsx`,名称 `/remote-control-server`,别名 `/rcs`
|
||||
- 双 feature 门控:`feature('DAEMON') && feature('BRIDGE_MODE')` + `isBridgeEnabled()`
|
||||
- lazy load `remoteControlServer.tsx`
|
||||
|
||||
**`remoteControlServer.tsx`** — REPL 内 UI:
|
||||
- 首次调用:前置检查(bridge 可用性 + OAuth token)→ spawn daemon 子进程
|
||||
- 再次调用:弹出管理对话框(停止/重启/继续),显示 PID 和最近 5 行日志
|
||||
- 模块级 state 跨调用保持 daemon 进程引用
|
||||
- graceful stop:SIGTERM → 10s grace → SIGKILL
|
||||
|
||||
#### 4. Feature Flag 启用
|
||||
|
||||
`build.ts` / `scripts/dev.ts`:`DEFAULT_BUILD_FEATURES` / `DEFAULT_FEATURES` 新增 `DAEMON`
|
||||
|
||||
DAEMON 仅有编译时 feature flag 门控,无 GrowthBook gate。
|
||||
|
||||
### 与 `/remote-control` 的区别
|
||||
|
||||
| | `/remote-control` | `/remote-control-server` (daemon) |
|
||||
|---|---|---|
|
||||
| 模式 | 单会话,REPL 内交互式 bridge | 多会话,daemon 持久化服务器 |
|
||||
| 生命周期 | 跟 REPL 会话绑定 | 独立后台进程,崩溃自动重启 |
|
||||
| 并发 | 1 个远程连接 | 默认 4 个,可配置 `--capacity` |
|
||||
| 隔离 | 共享当前目录 | 支持 `worktree` 模式隔离 |
|
||||
| 底层 | `initReplBridge()` | `runBridgeHeadless()` → `runBridgeLoop()` |
|
||||
|
||||
### 修改文件
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `build.ts` | `DEFAULT_BUILD_FEATURES` 新增 `DAEMON` |
|
||||
| `scripts/dev.ts` | `DEFAULT_FEATURES` 新增 `DAEMON` |
|
||||
| `src/daemon/main.ts` | 从 stub 还原为 supervisor 实现 |
|
||||
| `src/daemon/workerRegistry.ts` | 从 stub 还原为 worker 分发器 |
|
||||
| `src/commands/remoteControlServer/index.ts` | **新增** command 注册 |
|
||||
| `src/commands/remoteControlServer/remoteControlServer.tsx` | **新增** REPL UI |
|
||||
|
||||
### 验证
|
||||
|
||||
| 项目 | 结果 |
|
||||
|------|------|
|
||||
| `bun run build` | ✅ 成功 (490 files) |
|
||||
| tsc 新文件检查 | ✅ 无新增类型错误 |
|
||||
|
||||
### 使用方式
|
||||
|
||||
```bash
|
||||
# CLI 直接启动 daemon
|
||||
bun run dev daemon start
|
||||
bun run dev daemon start --spawn-mode=worktree --capacity=8
|
||||
|
||||
# REPL 内
|
||||
/remote-control-server # 或 /rcs
|
||||
```
|
||||
|
||||
前提:需要 Anthropic OAuth 登录(`claude login`)。
|
||||
|
||||
---
|
||||
|
||||
## /ultraplan 启用 + GrowthBook Fallback 加固 + Away Summary 改进 (2026-04-06)
|
||||
|
||||
**分支**: `feat/ultraplan-enablement`
|
||||
**Commit**: `feat: enable /ultraplan and harden GrowthBook fallback chain`
|
||||
|
||||
### 背景
|
||||
|
||||
`/ultraplan` 是 Claude Code 的高级多代理规划功能:将任务发送到 Claude Code on the web(CCR),由 Opus 进行深度规划,计划完成后返回终端供用户审批和执行。此功能被 3 层门控锁定:`feature('ULTRAPLAN')` 编译 flag + `isEnabled: () => USER_TYPE === 'ant'` + `INTERNAL_ONLY_COMMANDS` 列表。
|
||||
|
||||
另外发现 GrowthBook fallback 链在 config 未初始化时会抛异常跳过 `LOCAL_GATE_DEFAULTS`,以及 Away Summary 在不支持 DECSET 1004 focus 事件的终端(CMD/PowerShell)上不工作。
|
||||
|
||||
### 实现
|
||||
|
||||
#### 1. Ultraplan 启用
|
||||
|
||||
- `build.ts` / `scripts/dev.ts`: 添加 `ULTRAPLAN` 到默认编译 flag
|
||||
- `src/commands.ts`: 将 ultraplan 从 `INTERNAL_ONLY_COMMANDS` 移入公开 `COMMANDS` 列表
|
||||
- `src/commands/ultraplan.tsx`: `isEnabled` 改为 `() => true`
|
||||
- `src/screens/REPL.tsx`: 添加 `UltraplanChoiceDialog`、`UltraplanLaunchDialog`、`launchUltraplan` 的 import(HEAD 版使用但未 import,构建报 `not defined`)
|
||||
|
||||
#### 2. 反编译 UltraplanChoiceDialog / UltraplanLaunchDialog
|
||||
|
||||
REPL.tsx 引用这两个组件但代码库中不存在。从官方 CLI 2.1.92 的 `cli.js` 中定位 minified 函数 `M15`(UltraplanChoiceDialog)和 `P15`(UltraplanLaunchDialog),通过符号映射表反编译为可读 TSX。
|
||||
|
||||
**`src/components/ultraplan/UltraplanChoiceDialog.tsx`** — 远程计划批准后的选择对话框:
|
||||
- 3 个选项:Implement here(注入当前会话)/ Start new session(清空会话重开)/ Cancel(保存到 .md 文件)
|
||||
- 可滚动计划预览(ctrl+u/d 翻页,鼠标滚轮),自适应终端高度
|
||||
- 选择后标记远程 task 完成、清除 `ultraplanPendingChoice` 状态、归档远程 CCR session
|
||||
|
||||
**`src/components/ultraplan/UltraplanLaunchDialog.tsx`** — 启动确认对话框:
|
||||
- 显示功能说明、时间估计(~10–30 min)、服务条款链接
|
||||
- 处理 Remote Control bridge 冲突(选择 run 时自动断开 bridge)
|
||||
- 首次使用时持久化 `hasSeenUltraplanTerms` 到全局配置
|
||||
|
||||
反编译要点:剥离 React Compiler `_c(N)` 缓存数组,还原为标准 `useMemo`/`useCallback`;`useFocusedInputDialog()` 注册 hook 省略(REPL 内部计算 `focusedInputDialog`);GrowthBook 配置查询替换为本地默认值。
|
||||
|
||||
#### 3. GrowthBook Fallback 加固
|
||||
|
||||
`src/services/analytics/growthbook.ts`:
|
||||
- `getFeatureValue_CACHED_MAY_BE_STALE`: 将 `getLocalGateDefault()` 查找移到 try/catch 外层
|
||||
- `checkStatsigFeatureGate_CACHED_MAY_BE_STALE`: 同上,config 读取包裹在 try/catch 中
|
||||
|
||||
修复前:config 未初始化 → `getGlobalConfig()` 抛异常 → catch 直接返回 `defaultValue` → 跳过 `LOCAL_GATE_DEFAULTS`
|
||||
修复后:config 未初始化 → catch 静默 → 继续查 `LOCAL_GATE_DEFAULTS` → 有默认值就用,没有才 fallback
|
||||
|
||||
#### 4. Away Summary 改进(Windows 终端兼容)
|
||||
|
||||
**问题**:Away Summary(`feature('AWAY_SUMMARY')` + `tengu_sedge_lantern` gate,上一轮已启用)依赖 DECSET 1004 终端 focus 事件检测用户是否离开。但 Windows 的 CMD 和 PowerShell 不支持此协议,`getTerminalFocusState()` 始终返回 `'unknown'`,原逻辑对 `'unknown'` 状态执行 no-op,导致 Windows 用户永远无法触发离开摘要。
|
||||
|
||||
**修改**:`src/hooks/useAwaySummary.ts`
|
||||
|
||||
1. **focus 状态处理**:`'unknown'` 现在视同 `'blurred'`(可能已离开),订阅时即启动 idle timer(5 分钟)
|
||||
2. **idle-based 在场检测**:新增 `isLoading` 转换监听作为用户活跃信号替代 focus 事件:
|
||||
- 用户发起新 turn(`isLoading` → `true`)→ 说明在场,取消 idle timer + abort 进行中的生成
|
||||
- turn 结束(`isLoading` → `false`)→ 重启 idle timer
|
||||
- timer 到期且无进行中 turn → 触发 away summary 生成
|
||||
3. **兼容性**:仅在 `getTerminalFocusState() === 'unknown'` 时激活 idle 逻辑,支持 DECSET 1004 的终端(iTerm2、Windows Terminal、kitty 等)仍走原有 blur/focus 路径
|
||||
|
||||
**效果**:Windows CMD/PowerShell 用户离开终端 5 分钟后,系统自动调用 API 生成摘要并作为 `away_summary` 类型的系统消息追加到对话流中,用户回来时直接在 UI 中看到,无需执行任何命令
|
||||
|
||||
#### 5. Cron 定时任务管理技能
|
||||
|
||||
`src/skills/bundled/cronManage.ts`(**新增**)+ `src/skills/bundled/index.ts`:
|
||||
|
||||
KAIROS 定时任务系统(`tengu_kairos_cron` gate,已在上一轮 GrowthBook 启用中开启)提供了 `ScheduleCronTool` 来创建定时任务,但缺少用户可调用的 list/delete 技能。新增两个 bundled skill 补全管理闭环:
|
||||
|
||||
| 技能 | 用法 | 功能 |
|
||||
|------|------|------|
|
||||
| `/cron-list` | `/cron-list` | 调用 `CronListTool` 列出所有定时任务,表格显示 ID、Schedule、Prompt、Recurring、Durable |
|
||||
| `/cron-delete` | `/cron-delete <job-id>` | 调用 `CronDeleteTool` 按 ID 取消指定定时任务 |
|
||||
|
||||
两个技能均受 `isKairosCronEnabled()` 门控(`feature('AGENT_TRIGGERS') && tengu_kairos_cron` gate),与 `ScheduleCronTool` 保持一致。
|
||||
|
||||
#### 6. Fullscreen 门控修复
|
||||
|
||||
- `src/utils/fullscreen.ts`: `isFullscreenEnvEnabled()` 从无条件返回 `true` 改为 `process.env.USER_TYPE === 'ant'`,避免非 ant 用户意外触发全屏模式
|
||||
|
||||
### 修改文件
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `build.ts` | `DEFAULT_BUILD_FEATURES` 新增 `ULTRAPLAN` |
|
||||
| `scripts/dev.ts` | `DEFAULT_FEATURES` 新增 `ULTRAPLAN` |
|
||||
| `src/commands.ts` | ultraplan 移入公开命令列表 |
|
||||
| `src/commands/ultraplan.tsx` | `isEnabled` 移除 ant-only 限制 |
|
||||
| `src/components/ultraplan/UltraplanChoiceDialog.tsx` | **新增** 从 2.1.92 反编译 |
|
||||
| `src/components/ultraplan/UltraplanLaunchDialog.tsx` | **新增** 从 2.1.92 反编译 |
|
||||
| `src/screens/REPL.tsx` | 添加 3 个 import |
|
||||
| `src/services/analytics/growthbook.ts` | fallback 链加固 |
|
||||
| `src/hooks/useAwaySummary.ts` | idle-based 离开检测 |
|
||||
| `src/skills/bundled/index.ts` | 注册 cron 技能 |
|
||||
| `src/skills/bundled/cronManage.ts` | **新增** cron list/delete 技能 |
|
||||
| `src/utils/fullscreen.ts` | fullscreen 门控修复 |
|
||||
|
||||
### 验证
|
||||
|
||||
| 项目 | 结果 |
|
||||
|------|------|
|
||||
| `bun run build` | ✅ 成功 (480 files) |
|
||||
| `bun run lint` | ✅ 仅已有 biome-ignore 警告 |
|
||||
| `/ultraplan` 手动测试 | ✅ 命令注册可见、能启动远程会话、能接收回传计划并显示 ChoiceDialog |
|
||||
|
||||
### Ultraplan 工作流
|
||||
|
||||
```
|
||||
/ultraplan <prompt>
|
||||
→ UltraplanLaunchDialog 确认
|
||||
→ teleportToRemote 创建 CCR 远程会话
|
||||
→ pollForApprovedExitPlanMode 轮询(3s 间隔,30min 超时)
|
||||
→ ExitPlanModeScanner 解析事件流
|
||||
→ 计划 approved → UltraplanChoiceDialog 显示选择
|
||||
→ Implement here / Start new session / Cancel
|
||||
```
|
||||
|
||||
需要 Anthropic OAuth(`/login`)。远程会话在 claude.ai/code 上运行。
|
||||
|
||||
---
|
||||
|
||||
## GrowthBook Local Gate Defaults + P0/P1 Feature Enablement (2026-04-06)
|
||||
|
||||
**分支**: `feat/growthbook-enablement`
|
||||
|
||||
### 背景
|
||||
|
||||
Claude Code 使用 GrowthBook(Anthropic 自建 proxy at api.anthropic.com)进行远程功能开关控制,代码中使用 `tengu_*` 前缀命名。在反编译版本中 GrowthBook 不启动(analytics 空实现),导致 70+ 个功能被 gate 拦截。
|
||||
|
||||
经 4 个并行研究代理深度分析,确认**所有被 gate 控制的功能代码都是真实现**(非 stub)。
|
||||
|
||||
### 实现方案
|
||||
|
||||
**Commit 1** (`feat`): 在 `growthbook.ts` 中添加 `LOCAL_GATE_DEFAULTS` 映射表(25+ boolean gates + 2 object config gates),修改 4 个 getter 函数在 `isGrowthBookEnabled() === false` 时查找本地默认值。
|
||||
|
||||
**Commit 2** (`fix`): 发现 `LOCAL_GATE_DEFAULTS` 在有 API key 的用户环境下无效——因为 `isGrowthBookEnabled()` 返回 `true`(analytics 未禁用),代码走 GrowthBook 路径但缓存为空,直接返回 `defaultValue` 跳过了本地默认值。修复:在 3 个 getter 函数的缓存 miss 路径中插入 `LOCAL_GATE_DEFAULTS` 查找。同时修复 `tengu_onyx_plover` 值类型(`JSON.stringify` → 直接对象)和新增 `tengu_kairos_brief_config` 对象型 gate。
|
||||
|
||||
修复后的 fallback 链:
|
||||
```
|
||||
env overrides → config overrides → [GrowthBook 启用?]
|
||||
→ 内存缓存 → 磁盘缓存 → LOCAL_GATE_DEFAULTS → defaultValue
|
||||
```
|
||||
|
||||
可通过 `CLAUDE_CODE_DISABLE_LOCAL_GATES=1` 环境变量一键禁用。
|
||||
|
||||
### 启用的功能
|
||||
|
||||
**P0 — 纯本地功能(7 个 gate):**
|
||||
|
||||
| Gate | 功能 |
|
||||
|------|------|
|
||||
| `tengu_keybinding_customization_release` | 自定义快捷键(~/.claude/keybindings.json) |
|
||||
| `tengu_streaming_tool_execution2` | 流式工具执行(边收边执行) |
|
||||
| `tengu_kairos_cron` | 定时任务系统 |
|
||||
| `tengu_amber_json_tools` | Token 高效 JSON 工具格式(省 ~4.5%) |
|
||||
| `tengu_immediate_model_command` | 运行中即时切换模型 |
|
||||
| `tengu_basalt_3kr` | MCP 指令增量传输 |
|
||||
| `tengu_pebble_leaf_prune` | 会话存储叶剪枝优化 |
|
||||
|
||||
**P1 — API 依赖功能(8 个 gate):**
|
||||
|
||||
| Gate | 功能 |
|
||||
|------|------|
|
||||
| `tengu_session_memory` | 会话记忆(跨会话上下文持久化) |
|
||||
| `tengu_passport_quail` | 自动记忆提取 |
|
||||
| `tengu_chomp_inflection` | 提示建议 |
|
||||
| `tengu_hive_evidence` | 验证代理(对抗性验证) |
|
||||
| `tengu_kairos_brief` | Brief 精简输出模式 |
|
||||
| `tengu_sedge_lantern` | 离开摘要 |
|
||||
| `tengu_onyx_plover` | 自动梦境(记忆巩固) |
|
||||
| `tengu_willow_mode` | 空闲返回提示 |
|
||||
|
||||
**Kill Switch(10 个 gate 保持 true):**
|
||||
|
||||
`tengu_turtle_carbon`、`tengu_amber_stoat`、`tengu_amber_flint`、`tengu_slim_subagent_claudemd`、`tengu_birch_trellis`、`tengu_collage_kaleidoscope`、`tengu_compact_cache_prefix`、`tengu_kairos_cron_durable`、`tengu_attribution_header`、`tengu_slate_prism`
|
||||
|
||||
**新增编译 flag:**
|
||||
|
||||
| Flag | build.ts | dev.ts | 用途 |
|
||||
|------|:--------:|:------:|------|
|
||||
| `AGENT_TRIGGERS` | ON | ON | 定时任务系统 |
|
||||
| `EXTRACT_MEMORIES` | ON | ON | 自动记忆提取 |
|
||||
| `VERIFICATION_AGENT` | ON | ON | 对抗性验证代理 |
|
||||
| `KAIROS_BRIEF` | ON | ON | Brief 精简模式 |
|
||||
| `AWAY_SUMMARY` | ON | ON | 离开摘要 |
|
||||
| `ULTRATHINK` | ON | ON | Ultrathink 扩展思考(双重门控修复) |
|
||||
| `BUILTIN_EXPLORE_PLAN_AGENTS` | ON | ON | 内置 Explore/Plan agents(双重门控修复) |
|
||||
| `LODESTONE` | ON | ON | Deep link 协议注册(双重门控修复) |
|
||||
|
||||
**排除的编译 flag:**
|
||||
- `KAIROS` — 拉入 `useProactive.js`(缺失文件),`KAIROS_BRIEF` 足够
|
||||
- `TERMINAL_PANEL` — 拉入 `TerminalCaptureTool`(缺失文件)
|
||||
|
||||
**双重门控修复说明:**
|
||||
部分功能同时被编译 flag 和 GrowthBook gate 控制(双重门控),仅开 GrowthBook gate 不够。
|
||||
审计发现 3 个被卡住的:`ULTRATHINK`、`BUILTIN_EXPLORE_PLAN_AGENTS`、`LODESTONE`。
|
||||
|
||||
### 修改文件
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `build.ts` | `DEFAULT_BUILD_FEATURES` 新增 8 个编译 flag |
|
||||
| `scripts/dev.ts` | `DEFAULT_FEATURES` 新增 8 个编译 flag |
|
||||
| `src/services/analytics/growthbook.ts` | 新增 `LOCAL_GATE_DEFAULTS` 映射(27 gates)+ `getLocalGateDefault()` + 修改 4 个 getter 的 fallback 链 |
|
||||
| `scripts/verify-gates.ts` | 新增 gate 验证脚本(30 gates) |
|
||||
| `docs/features/growthbook-enablement-plan.md` | 完整研究报告和启用计划 |
|
||||
| `docs/features/feature-flags-audit-complete.md` | 更新启用状态表 |
|
||||
|
||||
### 验证
|
||||
|
||||
| 项目 | 结果 |
|
||||
|------|------|
|
||||
| `bun run build` | ✅ 成功 (481 files) |
|
||||
| `bun test` | ✅ 2106 pass / 23 fail(均为已有问题)/ 0 新增失败 |
|
||||
| `verify-gates.ts` | ✅ 30/30 PASS |
|
||||
| `/brief` 手动测试 | ✅ 可用(fallback 修复后) |
|
||||
|
||||
---
|
||||
|
||||
## Enable SHOT_STATS, TOKEN_BUDGET, PROMPT_CACHE_BREAK_DETECTION (2026-04-05)
|
||||
|
||||
**PR**: [claude-code-best/claude-code#140](https://github.com/claude-code-best/claude-code/pull/140)
|
||||
**分支**: `feat/enable-safe-feature-flags`
|
||||
|
||||
对 22 个被标记为 "COMPLETE" 的编译时 feature flag 进行实际源码验证(6 个并行子代理 + Codex CLI 独立复核),发现审计报告存在大量误判。最终确认仅 3 个 flag 为真正 compile-only,安全启用。
|
||||
|
||||
**验证流程:**
|
||||
|
||||
1. 6 个并行子代理分别检查每个 flag 的 `feature('FLAG_NAME')` 引用点、依赖模块完整性、外部服务依赖
|
||||
2. Codex CLI (v0.118.0, 240K tokens) 独立复核,将原 7 个 "compile-only" 进一步缩减为 3 个
|
||||
3. 3 个专项代理逐一验证代码路径完整性和运行时安全性
|
||||
|
||||
**新启用的 3 个 flag:**
|
||||
|
||||
| Flag | 功能 | 用户可感知效果 |
|
||||
|------|------|---------------|
|
||||
| `SHOT_STATS` | shot 分布统计 | `/stats` 面板显示 shot 分布和 one-shot rate |
|
||||
| `TOKEN_BUDGET` | token 预算目标 | 支持 `+500k` / `spend 2M tokens` 语法,自动续写直到达标,带进度条 |
|
||||
| `PROMPT_CACHE_BREAK_DETECTION` | cache key 变化检测 | 内部诊断,`--debug` 模式可见,写 diff 到临时目录 |
|
||||
|
||||
**修改文件:**
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `build.ts` | `DEFAULT_BUILD_FEATURES` 新增 3 个 flag |
|
||||
| `scripts/dev.ts` | `DEFAULT_FEATURES` 新增 3 个 flag |
|
||||
| `package.json` / `bun.lock` | 新增 `openai` 依赖(OpenAI 兼容层需要) |
|
||||
|
||||
**新增文档:**
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `docs/features/feature-flags-codex-review.md` | Codex 独立复核报告:修正后的 5 类分类、恢复优先级、三轴分类标准建议 |
|
||||
| `docs/features/feature-flags-audit-complete.md` | 标记所有已启用 flag 的状态(`[build: ON]` / `[dev: ON]`) |
|
||||
|
||||
**Codex 复核关键发现:**
|
||||
|
||||
- 原 22 个 "COMPLETE" flag 中,8 个核心模块是 stub,3 个依赖远程服务
|
||||
- `TEAMMEM`、`AGENT_TRIGGERS`、`EXTRACT_MEMORIES`、`KAIROS_BRIEF` 被降级为"有条件可用"(受 GrowthBook 门控)
|
||||
- 建议审计分类标准改为三轴:实现完整度 × 激活条件 × 运行风险
|
||||
- 恢复优先级:REACTIVE_COMPACT > BG_SESSIONS > PROACTIVE > CONTEXT_COLLAPSE
|
||||
|
||||
**验证结果:**
|
||||
|
||||
- `bun run build` → 475 files ✅
|
||||
- `bun test` → 零新增失败 ✅
|
||||
- 3 个 flag 代码路径全部完整,无缺失依赖,无 crash 风险 ✅
|
||||
|
||||
---
|
||||
|
||||
## /dream 手动触发 + DreamTask 类型补全 (2026-04-04)
|
||||
|
||||
将 `/dream` 命令从 KAIROS feature gate 中解耦,作为 bundled skill 无条件注册;补全 DreamTask 类型存根。
|
||||
|
||||
**新增文件:**
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `src/skills/bundled/dream.ts` | `/dream` skill 注册,调用 `buildConsolidationPrompt()` 生成整理提示词 |
|
||||
|
||||
**修改文件:**
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `src/skills/bundled/index.ts` | 导入并注册 `registerDreamSkill()` |
|
||||
| `src/components/tasks/src/tasks/DreamTask/DreamTask.ts` | `any` 存根 → 从 `src/tasks/DreamTask/DreamTask.js` 重新导出完整类型 |
|
||||
|
||||
**新增文档:**
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `docs/features/auto-dream.md` | Auto Dream 原理、触发机制、使用场景完整说明 |
|
||||
|
||||
---
|
||||
|
||||
## Computer Use macOS 适配修复 (2026-04-04)
|
||||
|
||||
**分支**: `feature/computer-use/mac-support`
|
||||
|
||||
- **darwin.ts** — 应用枚举改用 Spotlight `mdfind` + `mdls`,获取真实 bundleId(旧方案合成 `com.app.xxx`),覆盖 `/Applications` + `/System/Applications` + CoreServices
|
||||
- **index.ts** — 新增 `hotkey` backend fallback,非原生模块不崩溃
|
||||
- **toolCalls.ts** — `resolveRequestedApps()` 新增子串模糊匹配(`"Chrome"` → `"Google Chrome"`)
|
||||
- **hostAdapter.ts** — `ensureOsPermissions()` 检查 `cu.tcc` 存在性,跨平台 JS backend 安全降级
|
||||
- **测试**: 17 个 MCP 工具中 10 个完全通过,6 个在 full tier 应用上通过(IDE click tier 受限为预期行为),`screenshot` 未返回图片(疑似屏幕录制权限问题)
|
||||
|
||||
---
|
||||
|
||||
## Computer Use Windows 增强:窗口绑定截图 + UI Automation + OCR (2026-04-03)
|
||||
|
||||
|
||||
在三平台基础实现之上,利用 Windows 原生 API 增强 Computer Use 的 Windows 专属能力。
|
||||
|
||||
**新增文件:**
|
||||
|
||||
| 文件 | 行数 | 说明 |
|
||||
|------|------|------|
|
||||
| `src/utils/computerUse/win32/windowCapture.ts` | — | `PrintWindow` 窗口绑定截图,支持被遮挡/后台窗口 |
|
||||
| `src/utils/computerUse/win32/windowEnum.ts` | — | `EnumWindows` 精确窗口枚举(HWND + PID + 标题) |
|
||||
| `src/utils/computerUse/win32/uiAutomation.ts` | — | `IUIAutomation` UI 元素树读取、按钮点击、文本写入、坐标识别 |
|
||||
| `src/utils/computerUse/win32/ocr.ts` | — | `Windows.Media.Ocr` 截图+文字识别(英语+中文) |
|
||||
|
||||
**修改文件:**
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `packages/@ant/computer-use-swift/src/backends/win32.ts` | `listRunning` 改用 EnumWindows;新增 `captureWindowTarget` 窗口级截图 |
|
||||
|
||||
**验证结果(Windows x64):**
|
||||
- 窗口枚举:38 个可见窗口 ✅
|
||||
- 窗口截图:VS Code 2575x1415, 444KB ✅(PrintWindow, 即使被遮挡)
|
||||
- UI Automation:坐标元素识别 ✅
|
||||
- OCR:识别 VS Code 界面文字,34 行 ✅
|
||||
|
||||
---
|
||||
|
||||
## Enable Computer Use — macOS + Windows + Linux (2026-04-03)
|
||||
|
||||
恢复 Computer Use 屏幕操控功能。参考项目仅 macOS,本次扩展为三平台支持。
|
||||
|
||||
**Phase 1 — MCP server stub 替换:**
|
||||
从参考项目复制 `@ant/computer-use-mcp` 完整实现(12 文件,6517 行)。
|
||||
|
||||
**Phase 2 — 移除 src/ 中 8 处 macOS 硬编码:**
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `src/main.tsx:1605` | 去掉 `getPlatform() === 'macos'` |
|
||||
| `src/utils/computerUse/swiftLoader.ts` | 移除 darwin-only throw |
|
||||
| `src/utils/computerUse/executor.ts` | 平台守卫扩展为 darwin+win32+linux;剪贴板按平台分发(pbcopy→PowerShell→xclip);paste 快捷键 command→ctrl |
|
||||
| `src/utils/computerUse/drainRunLoop.ts` | 非 darwin 直接执行 fn() |
|
||||
| `src/utils/computerUse/escHotkey.ts` | 非 darwin 返回 false(Ctrl+C fallback) |
|
||||
| `src/utils/computerUse/hostAdapter.ts` | 非 darwin 权限检查返回 granted |
|
||||
| `src/utils/computerUse/common.ts` | platform + screenshotFiltering 动态化 |
|
||||
| `src/utils/computerUse/gates.ts` | enabled:true + hasRequiredSubscription→true |
|
||||
|
||||
**Phase 3 — input/swift 包 dispatcher + backends 三平台架构:**
|
||||
|
||||
```
|
||||
packages/@ant/computer-use-{input,swift}/src/
|
||||
├── index.ts ← dispatcher
|
||||
├── types.ts ← 共享接口
|
||||
└── backends/
|
||||
├── darwin.ts ← macOS AppleScript(原样拆出,不改逻辑)
|
||||
├── win32.ts ← Windows PowerShell
|
||||
└── linux.ts ← Linux xdotool/scrot/xrandr/wmctrl
|
||||
```
|
||||
|
||||
**编译开关:** `CHICAGO_MCP` 加入 DEFAULT_FEATURES + DEFAULT_BUILD_FEATURES
|
||||
|
||||
**验证结果(Windows x64):**
|
||||
- `isSupported: true` ✅
|
||||
- 鼠标定位 + 前台窗口信息 ✅
|
||||
- 双显示器检测 2560x1440 × 2 ✅
|
||||
- 全屏截图 3MB base64 ✅
|
||||
- `bun run build` 463 files ✅
|
||||
|
||||
---
|
||||
|
||||
## Enable Voice Mode / VOICE_MODE (2026-04-03)
|
||||
|
||||
恢复 `/voice` 语音输入功能。`src/` 下所有 voice 相关源码已与官方一致(0 行差异),问题出在:① `VOICE_MODE` 编译开关未开,命令不显示;② `audio-capture-napi` 是 SoX 子进程 stub(Windows 不支持),缺少官方原生 `.node` 二进制。
|
||||
|
||||
**新增文件:**
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `vendor/audio-capture/{platform}/audio-capture.node` | 6 个平台的原生音频二进制(cpal,来自参考项目) |
|
||||
| `vendor/audio-capture-src/index.ts` | 原生模块加载器(按 `${arch}-${platform}` 动态 require `.node`) |
|
||||
|
||||
---
|
||||
|
||||
## Enable Claude in Chrome MCP (2026-04-03)
|
||||
|
||||
恢复 Chrome 浏览器控制功能。`src/` 下所有 claudeInChrome 相关源码已与官方一致(0 行差异),问题出在 `@ant/claude-for-chrome-mcp` 包是 6 行 stub(返回空工具列表和 null server)。
|
||||
|
||||
**替换文件:**
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `packages/@ant/claude-for-chrome-mcp/src/index.ts` | 6 行 stub → 15 行完整导出 |
|
||||
|
||||
**新增文件:**
|
||||
|
||||
| 文件 | 行数 | 说明 |
|
||||
|------|------|------|
|
||||
| `packages/@ant/claude-for-chrome-mcp/src/types.ts` | 134 | 类型定义 |
|
||||
| `packages/@ant/claude-for-chrome-mcp/src/browserTools.ts` | 546 | 17 个浏览器工具定义 |
|
||||
| `packages/@ant/claude-for-chrome-mcp/src/mcpServer.ts` | 96 | MCP Server |
|
||||
| `packages/@ant/claude-for-chrome-mcp/src/mcpSocketClient.ts` | 493 | Unix Socket 客户端 |
|
||||
| `packages/@ant/claude-for-chrome-mcp/src/mcpSocketPool.ts` | 327 | 多 Profile 连接池 |
|
||||
| `packages/@ant/claude-for-chrome-mcp/src/bridgeClient.ts` | 1126 | Bridge WebSocket 客户端 |
|
||||
| `packages/@ant/claude-for-chrome-mcp/src/toolCalls.ts` | 301 | 工具调用路由 |
|
||||
|
||||
**不需要 feature flag,不需要改 dev.ts/build.ts,不改 src/ 下任何文件。**
|
||||
|
||||
**运行时依赖:** Chrome 浏览器 + Claude in Chrome 扩展(https://claude.ai/chrome)
|
||||
|
||||
---
|
||||
|
||||
## OpenAI 接口兼容 (2026-04-03)
|
||||
|
||||
**分支**: `feature/openai`
|
||||
@@ -285,3 +802,4 @@ GrowthBook 功能开关系统原为 Anthropic 内部构建设计,硬编码 SDK
|
||||
```
|
||||
|
||||
非空字段才写入,保存后立即生效(`onDone()` 触发 `onChangeAPIKey()` 刷新 API 客户端)。
|
||||
|
||||
|
||||
38
Friends.md
Normal file
38
Friends.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# 社区项目 & Blog 合集
|
||||
|
||||
> 每日更新,欢迎自荐!
|
||||
|
||||
## 工具 & 应用
|
||||
|
||||
| 项目 | 描述 | 作者 |
|
||||
|------|------|------|
|
||||
| [4qtask.vercel.app](https://4qtask.vercel.app/) | 免费四象限时间管理工具 | @kevinhuky |
|
||||
| [kaying.studio](https://kaying.studio/) | 个人 AI 工具箱 | @kayingai |
|
||||
| [supsub.ai](https://supsub.ai/) | 高效阅读工具 | @hidumou |
|
||||
| [x-video-download.net](https://x-video-download.net/) | 视频下载工具 | @syakadou |
|
||||
| [1openapi.com](https://1openapi.com/) | API 中转站 | @thinker007 |
|
||||
| [claw-z.com](https://claw-z.com/) | 一键部署 OpenClaw AI Agent(场景驱动、全面管理) | @uhhc |
|
||||
| [gemini-watermark-remover.net](https://gemini-watermark-remover.net/) | Gemini 水印移除工具 | @syakadou |
|
||||
|
||||
## GitHub 开源项目
|
||||
|
||||
| 项目 | 描述 | 作者 |
|
||||
|------|------|------|
|
||||
| [VersperClaw](https://github.com/versperai/VersperClaw) | 全自动科研流 | @versperai |
|
||||
| [claude-reviews-claude](https://github.com/openedclaude/claude-reviews-claude) | 原汤化原食——Claude 如何看待眼中的老己 | @openedclaude |
|
||||
| [agentica](https://github.com/shibing624/agentica) | 自研 Agent 框架,借鉴 claude-code 多 Agent 处理 | @shibing624 |
|
||||
| [macman](https://github.com/tonngw/macman) | Mac 从 0 到 1 保姆级配置教程 | @tonngw |
|
||||
| [SuperSpec](https://github.com/asasugar/SuperSpec) | SDD / Spec-Driven Development | @asasugar |
|
||||
| [adnify](https://github.com/adnaan-worker/adnify) | 高颜值高定制化 AI 编辑器 | @adnaan-worker |
|
||||
| [another-rule-engine](https://github.com/eatmoreduck/another-rule-engine) | 基于 Groovy 的开源多功能决策引擎 | @eatmoreduck |
|
||||
| [creative_master](https://github.com/chatabc/creative_master) | AI 驱动的创意灵感管理工具 | @chatabc |
|
||||
| [RapidDoc](https://github.com/RapidAI/RapidDoc) | Office 文件解析工具转 Markdown(支持 PDF/Image/Word/PPT/Excel) | @hzkitt |
|
||||
| [token-share](https://github.com/leemysw/token-share) | macOS 原生菜单栏 LLM API 网关,支持 OpenAI 与 Anthropic 协议间的实时互译与流式转发 | @leemysw |
|
||||
| [feishu-docx](https://github.com/leemysw/feishu-docx) | 飞书知识库导出、写入与云空间管理工具(支持 Markdown、公众号导入、CLI、TUI) | @leemysw |
|
||||
| [web-search-fast](https://github.com/uk0/web-search-fast) | 快速网页搜索 | @uk0 |
|
||||
|
||||
## Blog
|
||||
|
||||
| 链接 | 作者 |
|
||||
|------|------|
|
||||
| [blog.xiaohuangyu.space](https://blog.xiaohuangyu.space/) | @eatmoreduck |
|
||||
121
README.md
121
README.md
@@ -12,57 +12,55 @@
|
||||
|
||||
牢 A (Anthropic) 官方 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI 工具的源码反编译/逆向还原项目。目标是将 Claude Code 大部分功能及工程化能力复现 (问就是老佛爷已经付过钱了)。虽然很难绷, 但是它叫做 CCB(踩踩背)...
|
||||
|
||||
[文档在这里, 支持投稿 PR](https://ccb.agent-aura.top/)
|
||||
[文档在这里, 支持投稿 PR](https://ccb.agent-aura.top/) | [留影文档在这里](./Friends.md) | [Discord 群组](https://discord.gg/qZU6zS7Q)
|
||||
|
||||
[Discord 群组](https://discord.gg/qZU6zS7Q)
|
||||
- ✅ [x] V4 — 测试补全、[Buddy](https://ccb.agent-aura.top/docs/features/buddy)、[Auto Mode](https://ccb.agent-aura.top/docs/safety/auto-mode)、环境变量 Feature 开关
|
||||
- ✅ [x] V5 — [Sentry](https://ccb.agent-aura.top/docs/internals/sentry-setup) / [GrowthBook](https://ccb.agent-aura.top/docs/internals/growthbook-adapter) 企业监控、[自定义 Login](https://ccb.agent-aura.top/docs/features/custom-platform-login)、[OpenAI 兼容](https://ccb.agent-aura.top/docs/plans/openai-compatibility)、[Web Search](https://ccb.agent-aura.top/docs/features/web-browser-tool)、[Computer Use](https://ccb.agent-aura.top/docs/features/computer-use) / [Chrome Use](https://ccb.agent-aura.top/docs/features/claude-in-chrome-mcp)、[Voice Mode](https://ccb.agent-aura.top/docs/features/voice-mode)、[Bridge Mode](https://ccb.agent-aura.top/docs/features/bridge-mode)、[/dream 记忆整理](https://ccb.agent-aura.top/docs/features/auto-dream)
|
||||
- 🔮 [ ] V6 — 大规模重构石山代码,全面模块分包(全新分支,main 封存为历史版本)
|
||||
|
||||
赞助商占位符
|
||||
- 🚀 [想要启动项目](#快速开始源码版)
|
||||
- 🐛 [想要调试项目](#vs-code-调试)
|
||||
- 📖 [想要学习项目](#teach-me-学习项目)
|
||||
|
||||
- [x] v1 会完成跑通及基本的类型检查通过;
|
||||
- [x] V2 会完整实现工程化配套设施;
|
||||
- [ ] Biome 格式化可能不会先实施, 避免代码冲突
|
||||
- [x] 构建流水线完成, 产物 Node/Bun 都可以运行
|
||||
- [x] V3 会写大量文档, 完善文档站点
|
||||
- [x] V4 会完成大量的测试文件, 以提高稳定性
|
||||
- [x] Buddy 小宠物回来啦 [文档](https://ccb.agent-aura.top/docs/features/buddy)
|
||||
- [x] Auto Mode 回归 [文档](https://ccb.agent-aura.top/docs/safety/auto-mode)
|
||||
- [x] 所有 Feature 现在可以通过环境变量配置, 而不是垃圾的 bun --feature
|
||||
- [x] V5 支持企业级的监控上报功能, 补全缺失的工具, 解除限制
|
||||
- [x] 移除牢 A 的反蒸馏代码!!!
|
||||
- [x] 补全 web search 能力(用的 Bing 搜索)!!! [文档](https://ccb.agent-aura.top/docs/features/web-browser-tool)
|
||||
- [x] 支持 Debug [文档](https://ccb.agent-aura.top/docs/features/debug-mode)
|
||||
- [x] 关闭自动更新;
|
||||
- [x] 添加自定义 sentry 错误上报支持 [文档](https://ccb.agent-aura.top/docs/internals/sentry-setup)
|
||||
- [x] 添加自定义 GrowthBook 支持 (GB 也是开源的, 现在你可以配置一个自定义的遥控平台) [文档](https://ccb.agent-aura.top/docs/internals/growthbook-adapter)
|
||||
- [x] 自定义 login 模式, 大家可以用这个配置 Claude 的模型!
|
||||
- [x] 修复搜索工具的 rg 缺失问题(需要重新 bun i)
|
||||
- [ ] OpenAI 接口兼容! /login 然后配置 OpenAI 平台即可!
|
||||
- [ ] V6 大规模重构石山代码, 全面模块分包
|
||||
- [ ] V6 将会为全新分支, 届时 main 分支将会封存为历史版本
|
||||
|
||||
> 我不知道这个项目还会存在多久, Star + Fork + git clone + .zip 包最稳健; 说白了就是扛旗项目, 看看能走多远
|
||||
>
|
||||
> 这个项目更新很快, 后台有 Opus 持续优化, 几乎几个小时就有新变化;
|
||||
>
|
||||
> Claude 已经烧了 1000$ 以上, 没钱了, 换成 GLM 继续玩; @zai-org GLM 5.1 非常可以;
|
||||
>
|
||||
## ⚡ 快速开始(安装版)
|
||||
|
||||
## 快速开始
|
||||
不用克隆仓库, 从 NPM 下载后, 直接使用
|
||||
|
||||
### 环境要求
|
||||
```sh
|
||||
bun i -g claude-code-best
|
||||
bun pm -g trust claude-code-best
|
||||
ccb # 直接打开 claude code
|
||||
```
|
||||
|
||||
⚠️ 国内对 github 网络较差的, 需要先设置这个环境变量
|
||||
|
||||
```bash
|
||||
DEFAULT_RELEASE_BASE=https://ghproxy.net/https://github.com/microsoft/ripgrep-prebuilt/releases/download/v15.0.1
|
||||
```
|
||||
|
||||
## ⚡ 快速开始(源码版)
|
||||
|
||||
### ⚙️ 环境要求
|
||||
|
||||
一定要最新版本的 bun 啊, 不然一堆奇奇怪怪的 BUG!!! bun upgrade!!!
|
||||
|
||||
- [Bun](https://bun.sh/) >= 1.3.11
|
||||
- 常规的配置 CC 的方式, 各大提供商都有自己的配置方式
|
||||
- 📦 [Bun](https://bun.sh/) >= 1.3.11
|
||||
- ⚙️ 常规的配置 CC 的方式, 各大提供商都有自己的配置方式
|
||||
|
||||
### 安装
|
||||
### 📥 安装
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
### 运行
|
||||
⚠️ 国内对 github 网络较差的,可以使用这个环境变量
|
||||
|
||||
```bash
|
||||
DEFAULT_RELEASE_BASE=https://ghproxy.net/https://github.com/microsoft/ripgrep-prebuilt/releases/download/v15.0.1
|
||||
```
|
||||
|
||||
### ▶️ 运行
|
||||
|
||||
```bash
|
||||
# 开发模式, 看到版本号 888 说明就是对了
|
||||
@@ -78,13 +76,14 @@ bun run build
|
||||
|
||||
如果遇到 bug 请直接提一个 issues, 我们优先解决
|
||||
|
||||
### 新人配置 /login
|
||||
### 👤 新人配置 /login
|
||||
|
||||
首次运行后,在 REPL 中输入 `/login` 命令进入登录配置界面,选择 **Custom Platform** 即可对接第三方 API 兼容服务(无需 Anthropic 官方账号)。
|
||||
首次运行后,在 REPL 中输入 `/login` 命令进入登录配置界面,选择 **Anthropic Compatible** 即可对接第三方 API 兼容服务(无需 Anthropic 官方账号)。
|
||||
选择 OpenAI 和 Gemini 对应的栏目都是支持相应协议的
|
||||
|
||||
需要填写的字段:
|
||||
|
||||
| 字段 | 说明 | 示例 |
|
||||
| 📌 字段 | 📝 说明 | 💡 示例 |
|
||||
|------|------|------|
|
||||
| Base URL | API 服务地址 | `https://api.example.com/v1` |
|
||||
| API Key | 认证密钥 | `sk-xxx` |
|
||||
@@ -92,25 +91,10 @@ bun run build
|
||||
| Sonnet Model | 均衡模型 ID | `claude-sonnet-4-6` |
|
||||
| Opus Model | 高性能模型 ID | `claude-opus-4-6` |
|
||||
|
||||
- **Tab / Shift+Tab** 切换字段,**Enter** 确认并跳到下一个,最后一个字段按 Enter 保存
|
||||
- 模型字段会自动读取当前环境变量预填
|
||||
- 配置保存到 `~/.claude/settings.json` 的 `env` 字段,保存后立即生效
|
||||
- ⌨️ **Tab / Shift+Tab** 切换字段,**Enter** 确认并跳到下一个,最后一个字段按 Enter 保存
|
||||
|
||||
也可以直接编辑 `~/.claude/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.example.com/v1",
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-xxx",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-haiku-4-5-20251001",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-4-6",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-opus-4-6"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 支持所有 Anthropic API 兼容服务(如 OpenRouter、AWS Bedrock 代理等),只要接口兼容 Messages API 即可。
|
||||
> ℹ️ 支持所有 Anthropic API 兼容服务(如 OpenRouter、AWS Bedrock 代理等),只要接口兼容 Messages API 即可。
|
||||
|
||||
## Feature Flags
|
||||
|
||||
@@ -139,6 +123,29 @@ TUI (REPL) 模式需要真实终端,无法直接通过 VS Code launch 启动
|
||||
- F5 → 选择 **"Attach to Bun (TUI debug)"**
|
||||
|
||||
|
||||
## Teach Me 学习项目
|
||||
|
||||
我们新加了一个 teach-me skills, 通过问答式引导帮你理解这个项目的任何模块。(调整 [sigma skill 而来](https://github.com/sanyuan0704/sanyuan-skills))
|
||||
|
||||
```bash
|
||||
# 在 REPL 中直接输入
|
||||
/teach-me Claude Code 架构
|
||||
/teach-me React Ink 终端渲染 --level beginner
|
||||
/teach-me Tool 系统 --resume
|
||||
```
|
||||
|
||||
### 它能做什么
|
||||
|
||||
- **诊断水平** — 自动评估你对相关概念的掌握程度,跳过已知的、聚焦薄弱的
|
||||
- **构建学习路径** — 将主题拆解为 5-15 个原子概念,按依赖排序逐步推进
|
||||
- **苏格拉底式提问** — 用选项引导思考,而非直接给答案
|
||||
- **错误概念追踪** — 发现并纠正深层误解
|
||||
- **断点续学** — `--resume` 从上次进度继续
|
||||
|
||||
### 学习记录
|
||||
|
||||
学习进度保存在 `.claude/skills/teach-me/` 目录下,支持跨主题学习者档案。
|
||||
|
||||
## 相关文档及网站
|
||||
|
||||
- **在线文档(Mintlify)**: [ccb.agent-aura.top](https://ccb.agent-aura.top/) — 文档源码位于 [`docs/`](docs/) 目录,欢迎投稿 PR
|
||||
@@ -147,7 +154,7 @@ TUI (REPL) 模式需要真实终端,无法直接通过 VS Code launch 启动
|
||||
## Contributors
|
||||
|
||||
<a href="https://github.com/claude-code-best/claude-code/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=claude-code-best/claude-code" />
|
||||
<img src="contributors.svg" alt="Contributors" />
|
||||
</a>
|
||||
|
||||
## Star History
|
||||
|
||||
@@ -74,7 +74,7 @@ If you encounter a bug, please open an issue — we'll prioritize it.
|
||||
|
||||
### First-time Setup /login
|
||||
|
||||
After the first run, enter `/login` in the REPL to access the login configuration screen. Select **Custom Platform** to connect to third-party API-compatible services (no Anthropic account required).
|
||||
After the first run, enter `/login` in the REPL to access the login configuration screen. Select **Anthropic Compatible** to connect to third-party API-compatible services (no Anthropic account required).
|
||||
|
||||
Fields to fill in:
|
||||
|
||||
|
||||
26
TODO.md
26
TODO.md
@@ -1,26 +0,0 @@
|
||||
# TODO
|
||||
|
||||
尽可能实现下面的包, 使得与主包的关系完全吻合
|
||||
|
||||
## Packages
|
||||
|
||||
- [x] `url-handler-napi` — URL 处理 NAPI 模块 (签名修正,保持 null fallback)
|
||||
- [x] `modifiers-napi` — 修饰键检测 NAPI 模块 (Bun FFI + Carbon)
|
||||
- [x] `audio-capture-napi` — 音频捕获 NAPI 模块 (SoX/arecord)
|
||||
- [x] `color-diff-napi` — 颜色差异计算 NAPI 模块 (纯 TS 实现)
|
||||
- [x] `image-processor-napi` — 图像处理 NAPI 模块 (sharp + osascript 剪贴板)
|
||||
|
||||
- [x] `@ant/computer-use-swift` — Computer Use Swift 原生模块 (macOS JXA/screencapture 实现)
|
||||
- [x] `@ant/computer-use-mcp` — Computer Use MCP 服务 (类型安全 stub + sentinel apps + targetImageSize)
|
||||
- [x] `@ant/computer-use-input` — Computer Use 输入模块 (macOS AppleScript/JXA 实现)
|
||||
<!-- - [ ] `@ant/claude-for-chrome-mcp` — Chrome MCP 扩展 -->
|
||||
|
||||
## 工程化能力
|
||||
|
||||
- [x] 代码格式化与校验
|
||||
- [x] 冗余代码检查
|
||||
- [x] git hook 的配置
|
||||
- [x] 代码健康度检查
|
||||
- [x] Biome lint 规则调优(适配反编译代码,关闭格式化避免大规模 diff)
|
||||
- [x] 单元测试基础设施搭建 (test runner 配置)
|
||||
- [x] CI/CD 流水线 (GitHub Actions)
|
||||
123
build.ts
123
build.ts
@@ -1,76 +1,101 @@
|
||||
import { readdir, readFile, writeFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { getMacroDefines } from "./scripts/defines.ts";
|
||||
import { readdir, readFile, writeFile, cp } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { getMacroDefines } from './scripts/defines.ts'
|
||||
|
||||
const outdir = "dist";
|
||||
const outdir = 'dist'
|
||||
|
||||
// Step 1: Clean output directory
|
||||
const { rmSync } = await import("fs");
|
||||
rmSync(outdir, { recursive: true, force: true });
|
||||
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 = ["AGENT_TRIGGERS_REMOTE"];
|
||||
const DEFAULT_BUILD_FEATURES = [
|
||||
'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',
|
||||
]
|
||||
|
||||
// Collect FEATURE_* env vars → Bun.build features
|
||||
const envFeatures = Object.keys(process.env)
|
||||
.filter(k => k.startsWith("FEATURE_"))
|
||||
.map(k => k.replace("FEATURE_", ""));
|
||||
const features = [...new Set([...DEFAULT_BUILD_FEATURES, ...envFeatures])];
|
||||
.filter(k => k.startsWith('FEATURE_'))
|
||||
.map(k => k.replace('FEATURE_', ''))
|
||||
const features = [...new Set([...DEFAULT_BUILD_FEATURES, ...envFeatures])]
|
||||
|
||||
// Step 2: Bundle with splitting
|
||||
const result = await Bun.build({
|
||||
entrypoints: ["src/entrypoints/cli.tsx"],
|
||||
outdir,
|
||||
target: "bun",
|
||||
splitting: true,
|
||||
define: getMacroDefines(),
|
||||
features,
|
||||
});
|
||||
entrypoints: ['src/entrypoints/cli.tsx'],
|
||||
outdir,
|
||||
target: 'bun',
|
||||
splitting: true,
|
||||
define: getMacroDefines(),
|
||||
features,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
console.error("Build failed:");
|
||||
for (const log of result.logs) {
|
||||
console.error(log);
|
||||
}
|
||||
process.exit(1);
|
||||
console.error('Build failed:')
|
||||
for (const log of result.logs) {
|
||||
console.error(log)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Step 3: Post-process — replace Bun-only `import.meta.require` with Node.js compatible version
|
||||
const files = await readdir(outdir);
|
||||
const IMPORT_META_REQUIRE = "var __require = import.meta.require;";
|
||||
const COMPAT_REQUIRE = `var __require = typeof import.meta.require === "function" ? import.meta.require : (await import("module")).createRequire(import.meta.url);`;
|
||||
const files = await readdir(outdir)
|
||||
const IMPORT_META_REQUIRE = 'var __require = import.meta.require;'
|
||||
const COMPAT_REQUIRE = `var __require = typeof import.meta.require === "function" ? import.meta.require : (await import("module")).createRequire(import.meta.url);`
|
||||
|
||||
let patched = 0;
|
||||
let patched = 0
|
||||
for (const file of files) {
|
||||
if (!file.endsWith(".js")) continue;
|
||||
const filePath = join(outdir, file);
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
if (content.includes(IMPORT_META_REQUIRE)) {
|
||||
await writeFile(
|
||||
filePath,
|
||||
content.replace(IMPORT_META_REQUIRE, COMPAT_REQUIRE),
|
||||
);
|
||||
patched++;
|
||||
}
|
||||
if (!file.endsWith('.js')) continue
|
||||
const filePath = join(outdir, file)
|
||||
const content = await readFile(filePath, 'utf-8')
|
||||
if (content.includes(IMPORT_META_REQUIRE)) {
|
||||
await writeFile(
|
||||
filePath,
|
||||
content.replace(IMPORT_META_REQUIRE, COMPAT_REQUIRE),
|
||||
)
|
||||
patched++
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Bundled ${result.outputs.length} files to ${outdir}/ (patched ${patched} for Node.js compat)`,
|
||||
);
|
||||
`Bundled ${result.outputs.length} files to ${outdir}/ (patched ${patched} for Node.js compat)`,
|
||||
)
|
||||
|
||||
// Step 4: Bundle download-ripgrep script as standalone JS for postinstall
|
||||
// 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 5: Bundle download-ripgrep script as standalone JS for postinstall
|
||||
const rgScript = await Bun.build({
|
||||
entrypoints: ["scripts/download-ripgrep.ts"],
|
||||
outdir,
|
||||
target: "node",
|
||||
});
|
||||
entrypoints: ['scripts/download-ripgrep.ts'],
|
||||
outdir,
|
||||
target: 'node',
|
||||
})
|
||||
if (!rgScript.success) {
|
||||
console.error("Failed to bundle download-ripgrep script:");
|
||||
for (const log of rgScript.logs) {
|
||||
console.error(log);
|
||||
}
|
||||
// Non-fatal — postinstall fallback to bun run scripts/download-ripgrep.ts
|
||||
console.error('Failed to bundle download-ripgrep script:')
|
||||
for (const log of rgScript.logs) {
|
||||
console.error(log)
|
||||
}
|
||||
// Non-fatal — postinstall fallback to bun run scripts/download-ripgrep.ts
|
||||
} else {
|
||||
console.log(`Bundled download-ripgrep script to ${outdir}/`);
|
||||
console.log(`Bundled download-ripgrep script to ${outdir}/`)
|
||||
}
|
||||
|
||||
382
bun.lock
382
bun.lock
@@ -17,6 +17,7 @@
|
||||
"@anthropic-ai/sandbox-runtime": "^0.0.44",
|
||||
"@anthropic-ai/sdk": "^0.80.0",
|
||||
"@anthropic-ai/vertex-sdk": "^0.14.4",
|
||||
"@anthropic/ink": "workspace:*",
|
||||
"@aws-sdk/client-bedrock": "^3.1020.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.1020.0",
|
||||
"@aws-sdk/client-sts": "^3.1020.0",
|
||||
@@ -89,7 +90,7 @@
|
||||
"lru-cache": "^11.2.7",
|
||||
"marked": "^17.0.5",
|
||||
"modifiers-napi": "workspace:*",
|
||||
"openai": "^4.73.0",
|
||||
"openai": "^6.33.0",
|
||||
"p-map": "^7.0.4",
|
||||
"picomatch": "^4.0.4",
|
||||
"plist": "^3.1.0",
|
||||
@@ -138,6 +139,29 @@
|
||||
"name": "@ant/computer-use-swift",
|
||||
"version": "1.0.0",
|
||||
},
|
||||
"packages/@ant/ink": {
|
||||
"name": "@anthropic/ink",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"auto-bind": "^5.0.1",
|
||||
"bidi-js": "^1.0.3",
|
||||
"chalk": "^5.6.2",
|
||||
"cli-boxes": "^4.0.1",
|
||||
"emoji-regex": "^10.6.0",
|
||||
"figures": "^6.1.0",
|
||||
"get-east-asian-width": "^1.5.0",
|
||||
"indent-string": "^5.0.0",
|
||||
"lodash-es": "^4.17.23",
|
||||
"react": "^19.2.4",
|
||||
"react-reconciler": "^0.33.0",
|
||||
"signal-exit": "^4.1.0",
|
||||
"strip-ansi": "^7.2.0",
|
||||
"supports-hyperlinks": "^4.4.0",
|
||||
"type-fest": "^5.5.0",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"wrap-ansi": "^10.0.0",
|
||||
},
|
||||
},
|
||||
"packages/audio-capture-napi": {
|
||||
"name": "audio-capture-napi",
|
||||
"version": "1.0.0",
|
||||
@@ -160,6 +184,26 @@
|
||||
"name": "modifiers-napi",
|
||||
"version": "1.0.0",
|
||||
},
|
||||
"packages/remote-control-server": {
|
||||
"name": "@anthropic/remote-control-server",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"hono": "^4.7.0",
|
||||
"uuid": "^11.0.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0",
|
||||
},
|
||||
},
|
||||
"packages/url-handler-napi": {
|
||||
"name": "url-handler-napi",
|
||||
"version": "1.0.0",
|
||||
@@ -190,6 +234,10 @@
|
||||
|
||||
"@anthropic-ai/vertex-sdk": ["@anthropic-ai/vertex-sdk@0.14.4", "https://registry.npmmirror.com/@anthropic-ai/vertex-sdk/-/vertex-sdk-0.14.4.tgz", { "dependencies": { "@anthropic-ai/sdk": ">=0.50.3 <1", "google-auth-library": "^9.4.2" } }, "sha512-BZUPRWghZxfSFtAxU563wH+jfWBPoedAwsVxG35FhmNsjeV8tyfN+lFriWhCpcZApxA4NdT6Soov+PzfnxxD5g=="],
|
||||
|
||||
"@anthropic/ink": ["@anthropic/ink@workspace:packages/@ant/ink"],
|
||||
|
||||
"@anthropic/remote-control-server": ["@anthropic/remote-control-server@workspace:packages/remote-control-server"],
|
||||
|
||||
"@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "https://registry.npmmirror.com/@aws-crypto/crc32/-/crc32-5.2.0.tgz", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
|
||||
|
||||
"@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "https://registry.npmmirror.com/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="],
|
||||
@@ -290,8 +338,46 @@
|
||||
|
||||
"@azure/msal-node": ["@azure/msal-node@5.1.1", "https://registry.npmmirror.com/@azure/msal-node/-/msal-node-5.1.1.tgz", { "dependencies": { "@azure/msal-common": "16.4.0", "jsonwebtoken": "^9.0.0", "uuid": "^8.3.0" } }, "sha512-71grXU6+5hl+3CL3joOxlj/AW6rmhthuTlG0fRqsTrhPArQBpZuUFzCIlKOGdcafLUa/i1hBdV78ZxJdlvRA+g=="],
|
||||
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.0", "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.0.tgz", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/compat-data@7.29.0", "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.0.tgz", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
|
||||
|
||||
"@babel/core": ["@babel/core@7.29.0", "https://registry.npmmirror.com/@babel/core/-/core-7.29.0.tgz", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
|
||||
|
||||
"@babel/generator": ["@babel/generator@7.29.1", "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.1.tgz", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
|
||||
|
||||
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
|
||||
|
||||
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
|
||||
|
||||
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
|
||||
|
||||
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
|
||||
|
||||
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||
|
||||
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
|
||||
|
||||
"@babel/helpers": ["@babel/helpers@7.29.2", "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.2.tgz", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.2", "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.2.tgz", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.29.2", "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.2.tgz", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.28.6", "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
|
||||
|
||||
"@babel/traverse": ["@babel/traverse@7.29.0", "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.0.tgz", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.0", "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
|
||||
"@biomejs/biome": ["@biomejs/biome@2.4.10", "https://registry.npmmirror.com/@biomejs/biome/-/biome-2.4.10.tgz", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.10", "@biomejs/cli-darwin-x64": "2.4.10", "@biomejs/cli-linux-arm64": "2.4.10", "@biomejs/cli-linux-arm64-musl": "2.4.10", "@biomejs/cli-linux-x64": "2.4.10", "@biomejs/cli-linux-x64-musl": "2.4.10", "@biomejs/cli-win32-arm64": "2.4.10", "@biomejs/cli-win32-x64": "2.4.10" }, "bin": { "biome": "bin/biome" } }, "sha512-xxA3AphFQ1geij4JTHXv4EeSTda1IFn22ye9LdyVPoJU19fNVl0uzfEuhsfQ4Yue/0FaLs2/ccVi4UDiE7R30w=="],
|
||||
|
||||
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.10", "https://registry.npmmirror.com/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.10.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-vuzzI1cWqDVzOMIkYyHbKqp+AkQq4K7k+UCXWpkYcY/HDn1UxdsbsfgtVpa40shem8Kax4TLDLlx8kMAecgqiw=="],
|
||||
@@ -318,6 +404,58 @@
|
||||
|
||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||
|
||||
"@fastify/otel": ["@fastify/otel@0.18.0", "https://registry.npmmirror.com/@fastify/otel/-/otel-0.18.0.tgz", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.212.0", "@opentelemetry/semantic-conventions": "^1.28.0", "minimatch": "^10.2.4" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0" } }, "sha512-3TASCATfw+ctICSb4ymrv7iCm0qJ0N9CarB+CZ7zIJ7KqNbwI5JjyDL1/sxoC0ccTO1Zyd1iQ+oqncPg5FJXaA=="],
|
||||
|
||||
"@growthbook/growthbook": ["@growthbook/growthbook@1.6.5", "https://registry.npmmirror.com/@growthbook/growthbook/-/growthbook-1.6.5.tgz", { "dependencies": { "dom-mutator": "^0.6.0" } }, "sha512-mUaMsgeUTpRIUOTn33EUXHRK6j7pxBjwqH4WpQyq+pukjd1AIzWlEa6w7i6bInJUcweGgP2beXZmaP6b6UPn7A=="],
|
||||
@@ -406,6 +544,16 @@
|
||||
|
||||
"@inquirer/type": ["@inquirer/type@2.0.0", "https://registry.npmmirror.com/@inquirer/type/-/type-2.0.0.tgz", { "dependencies": { "mute-stream": "^1.0.0" } }, "sha512-XvJRx+2KR3YXyYtPUUy+qd9i7p+GO9Ko6VIIpWlBrpWwXDv8WLFeHTxz35CfQFUiBMLXlGHhGzys7lqit9gWag=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "https://registry.npmmirror.com/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
|
||||
|
||||
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "https://registry.npmmirror.com/@mixmark-io/domino/-/domino-2.2.0.tgz", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
|
||||
@@ -622,6 +770,58 @@
|
||||
|
||||
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "https://registry.npmmirror.com/@protobufjs/utf8/-/utf8-1.1.0.tgz", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", { "os": "freebsd", "cpu": "arm64" }, "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", { "os": "linux", "cpu": "arm" }, "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", { "os": "linux", "cpu": "arm" }, "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", { "os": "linux", "cpu": "none" }, "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", { "os": "linux", "cpu": "none" }, "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", { "os": "linux", "cpu": "none" }, "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", { "os": "linux", "cpu": "none" }, "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", { "os": "openbsd", "cpu": "x64" }, "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", { "os": "none", "cpu": "arm64" }, "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="],
|
||||
|
||||
"@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "https://registry.npmmirror.com/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
|
||||
|
||||
"@sentry/core": ["@sentry/core@10.47.0", "https://registry.npmmirror.com/@sentry/core/-/core-10.47.0.tgz", {}, "sha512-nsYRAx3EWezDut+Zl+UwwP07thh9uY7CfSAi2whTdcJl5hu1nSp2z8bba7Vq/MGbNLnazkd3A+GITBEML924JA=="],
|
||||
@@ -724,14 +924,54 @@
|
||||
|
||||
"@smithy/uuid": ["@smithy/uuid@1.1.2", "https://registry.npmmirror.com/@smithy/uuid/-/uuid-1.1.2.tgz", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.2.2", "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.2.2.tgz", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "https://registry.npmmirror.com/@tailwindcss/oxide/-/oxide-4.2.2.tgz", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="],
|
||||
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.2", "https://registry.npmmirror.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", { "os": "android", "cpu": "arm64" }, "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.2", "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.2", "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw=="],
|
||||
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.2", "https://registry.npmmirror.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", { "os": "linux", "cpu": "arm" }, "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.2", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.2", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.2", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", { "os": "linux", "cpu": "x64" }, "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.2", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", { "os": "linux", "cpu": "x64" }, "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.2", "https://registry.npmmirror.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.2", "https://registry.npmmirror.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.2", "https://registry.npmmirror.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA=="],
|
||||
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.2.2", "https://registry.npmmirror.com/@tailwindcss/vite/-/vite-4.2.2.tgz", { "dependencies": { "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "tailwindcss": "4.2.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w=="],
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@types/babel__core": ["@types/babel__core@7.20.5", "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||
|
||||
"@types/babel__generator": ["@types/babel__generator@7.27.0", "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
||||
|
||||
"@types/babel__template": ["@types/babel__template@7.4.4", "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
|
||||
|
||||
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.11", "https://registry.npmmirror.com/@types/bun/-/bun-1.3.11.tgz", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
|
||||
|
||||
"@types/cacache": ["@types/cacache@20.0.1", "https://registry.npmmirror.com/@types/cacache/-/cacache-20.0.1.tgz", { "dependencies": { "@types/node": "*", "minipass": "*" } }, "sha512-QlKW3AFoFr/hvPHwFHMIVUH/ZCYeetBNou3PCmxu5LaNDvrtBlPJtIA6uhmU9JRt9oxj7IYoqoLcpxtzpPiTcw=="],
|
||||
|
||||
"@types/connect": ["@types/connect@3.4.38", "https://registry.npmmirror.com/@types/connect/-/connect-3.4.38.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/lodash": ["@types/lodash@4.17.24", "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.24.tgz", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="],
|
||||
|
||||
"@types/lodash-es": ["@types/lodash-es@4.17.12", "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", { "dependencies": { "@types/lodash": "*" } }, "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ=="],
|
||||
@@ -742,8 +982,6 @@
|
||||
|
||||
"@types/node": ["@types/node@25.5.0", "https://registry.npmmirror.com/@types/node/-/node-25.5.0.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
||||
|
||||
"@types/node-fetch": ["@types/node-fetch@2.6.13", "https://registry.npmmirror.com/@types/node-fetch/-/node-fetch-2.6.13.tgz", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
|
||||
|
||||
"@types/pg": ["@types/pg@8.15.6", "https://registry.npmmirror.com/@types/pg/-/pg-8.15.6.tgz", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ=="],
|
||||
|
||||
"@types/pg-pool": ["@types/pg-pool@2.0.7", "https://registry.npmmirror.com/@types/pg-pool/-/pg-pool-2.0.7.tgz", { "dependencies": { "@types/pg": "*" } }, "sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng=="],
|
||||
@@ -752,6 +990,8 @@
|
||||
|
||||
"@types/react": ["@types/react@19.2.14", "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.2.3.tgz", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@types/react-reconciler": ["@types/react-reconciler@0.33.0", "https://registry.npmmirror.com/@types/react-reconciler/-/react-reconciler-0.33.0.tgz", { "peerDependencies": { "@types/react": "*" } }, "sha512-HZOXsKT0tGI9LlUw2LuedXsVeB88wFa536vVL0M6vE8zN63nI+sSr1ByxmPToP5K5bukaVscyeCJcF9guVNJ1g=="],
|
||||
|
||||
"@types/sharp": ["@types/sharp@0.32.0", "https://registry.npmmirror.com/@types/sharp/-/sharp-0.32.0.tgz", { "dependencies": { "sharp": "*" } }, "sha512-OOi3kL+FZDnPhVzsfD37J88FNeZh6gQsGcLc95NbeURRGvmSjeXiDcyWzF2o3yh/gQAUn2uhh/e+CPCa5nwAxw=="],
|
||||
@@ -760,13 +1000,15 @@
|
||||
|
||||
"@types/turndown": ["@types/turndown@5.0.6", "https://registry.npmmirror.com/@types/turndown/-/turndown-5.0.6.tgz", {}, "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg=="],
|
||||
|
||||
"@types/uuid": ["@types/uuid@10.0.0", "https://registry.npmmirror.com/@types/uuid/-/uuid-10.0.0.tgz", {}, "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ=="],
|
||||
|
||||
"@types/wrap-ansi": ["@types/wrap-ansi@3.0.0", "https://registry.npmmirror.com/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz", {}, "sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g=="],
|
||||
|
||||
"@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.4", "https://registry.npmmirror.com/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.4.tgz", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-CI0NhTrz4EBaa0U+HaaUZrJhPoso8sG7ZFya8uQoBA57fjzrjRSv87ekCjLZOFExN+gXE/z0xuN2QfH4H2HrLQ=="],
|
||||
|
||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.12", "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.12.tgz", {}, "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg=="],
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||
|
||||
"abort-controller": ["abort-controller@3.0.0", "https://registry.npmmirror.com/abort-controller/-/abort-controller-3.0.0.tgz", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.12", "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.12.tgz", {}, "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg=="],
|
||||
|
||||
"accepts": ["accepts@2.0.0", "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
@@ -776,8 +1018,6 @@
|
||||
|
||||
"agent-base": ["agent-base@8.0.0", "https://registry.npmmirror.com/agent-base/-/agent-base-8.0.0.tgz", {}, "sha512-QT8i0hCz6C/KQ+KTAbSNwCHDGdmUJl2tp2ZpNlGSWCfhUNVbYG2WLE3MdZGBAgXPV4GAvjGMxo+C1hroyxmZEg=="],
|
||||
|
||||
"agentkeepalive": ["agentkeepalive@4.6.0", "https://registry.npmmirror.com/agentkeepalive/-/agentkeepalive-4.6.0.tgz", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
|
||||
|
||||
"ajv": ["ajv@8.18.0", "https://registry.npmmirror.com/ajv/-/ajv-8.18.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
|
||||
|
||||
"ajv-formats": ["ajv-formats@3.0.1", "https://registry.npmmirror.com/ajv-formats/-/ajv-formats-3.0.1.tgz", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||
@@ -804,6 +1044,8 @@
|
||||
|
||||
"base64-js": ["base64-js@1.5.1", "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.15", "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.15.tgz", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-1nfKCq9wuAZFTkA2ey/3OXXx7GzFjLdkTiFVNwlJ9WqdI706CZRIhEqjuwanjMIja+84jDLa9rcyZDPDiVkASQ=="],
|
||||
|
||||
"bidi-js": ["bidi-js@1.0.3", "https://registry.npmmirror.com/bidi-js/-/bidi-js-1.0.3.tgz", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
|
||||
|
||||
"bignumber.js": ["bignumber.js@9.3.1", "https://registry.npmmirror.com/bignumber.js/-/bignumber.js-9.3.1.tgz", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
|
||||
@@ -816,6 +1058,8 @@
|
||||
|
||||
"braces": ["braces@3.0.3", "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.2", "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.2.tgz", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
|
||||
|
||||
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "https://registry.npmmirror.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.11", "https://registry.npmmirror.com/bun-types/-/bun-types-1.3.11.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
||||
@@ -832,6 +1076,8 @@
|
||||
|
||||
"camelcase": ["camelcase@5.3.1", "https://registry.npmmirror.com/camelcase/-/camelcase-5.3.1.tgz", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001786", "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001786.tgz", {}, "sha512-4oxTZEvqmLLrERwxO76yfKM7acZo310U+v4kqexI2TL1DkkUEMT8UijrxxcnVdxR3qkVf5awGRX+4Z6aPHVKrA=="],
|
||||
|
||||
"chalk": ["chalk@5.6.2", "https://registry.npmmirror.com/chalk/-/chalk-5.6.2.tgz", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"chardet": ["chardet@0.7.0", "https://registry.npmmirror.com/chardet/-/chardet-0.7.0.tgz", {}, "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA=="],
|
||||
@@ -868,6 +1114,8 @@
|
||||
|
||||
"content-type": ["content-type@1.0.5", "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"convert-to-spaces": ["convert-to-spaces@2.0.1", "https://registry.npmmirror.com/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="],
|
||||
|
||||
"cookie": ["cookie@0.7.2", "https://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
@@ -912,10 +1160,14 @@
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.331", "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", {}, "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@10.6.0", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-10.6.0.tgz", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||
|
||||
"encodeurl": ["encodeurl@2.0.0", "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.20.1", "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="],
|
||||
|
||||
"env-paths": ["env-paths@4.0.0", "https://registry.npmmirror.com/env-paths/-/env-paths-4.0.0.tgz", { "dependencies": { "is-safe-filename": "^0.1.0" } }, "sha512-pxP8eL2SwwaTRi/KHYwLYXinDs7gL3jxFcBYmEdYfZmZXbaVDvdppd0XBU8qVz03rDfKZMXg1omHCbsJjZrMsw=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
@@ -926,6 +1178,8 @@
|
||||
|
||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.12", "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
@@ -934,8 +1188,6 @@
|
||||
|
||||
"etag": ["etag@1.8.1", "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||
|
||||
"event-target-shim": ["event-target-shim@5.0.1", "https://registry.npmmirror.com/event-target-shim/-/event-target-shim-5.0.1.tgz", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
|
||||
|
||||
"eventsource": ["eventsource@3.0.7", "https://registry.npmmirror.com/eventsource/-/eventsource-3.0.7.tgz", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
|
||||
|
||||
"eventsource-parser": ["eventsource-parser@3.0.6", "https://registry.npmmirror.com/eventsource-parser/-/eventsource-parser-3.0.6.tgz", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
|
||||
@@ -964,6 +1216,8 @@
|
||||
|
||||
"fd-package-json": ["fd-package-json@2.0.0", "https://registry.npmmirror.com/fd-package-json/-/fd-package-json-2.0.0.tgz", { "dependencies": { "walk-up-path": "^4.0.0" } }, "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fetch-blob": ["fetch-blob@3.2.0", "https://registry.npmmirror.com/fetch-blob/-/fetch-blob-3.2.0.tgz", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
||||
|
||||
"fflate": ["fflate@0.8.2", "https://registry.npmmirror.com/fflate/-/fflate-0.8.2.tgz", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
|
||||
@@ -982,12 +1236,8 @@
|
||||
|
||||
"form-data": ["form-data@4.0.5", "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
||||
|
||||
"form-data-encoder": ["form-data-encoder@1.7.2", "https://registry.npmmirror.com/form-data-encoder/-/form-data-encoder-1.7.2.tgz", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="],
|
||||
|
||||
"formatly": ["formatly@0.3.0", "https://registry.npmmirror.com/formatly/-/formatly-0.3.0.tgz", { "dependencies": { "fd-package-json": "^2.0.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w=="],
|
||||
|
||||
"formdata-node": ["formdata-node@4.4.1", "https://registry.npmmirror.com/formdata-node/-/formdata-node-4.4.1.tgz", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="],
|
||||
|
||||
"formdata-polyfill": ["formdata-polyfill@4.0.10", "https://registry.npmmirror.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
|
||||
|
||||
"forwarded": ["forwarded@0.2.0", "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||
@@ -1000,6 +1250,8 @@
|
||||
|
||||
"fs-minipass": ["fs-minipass@3.0.3", "https://registry.npmmirror.com/fs-minipass/-/fs-minipass-3.0.3.tgz", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"fuse.js": ["fuse.js@7.1.0", "https://registry.npmmirror.com/fuse.js/-/fuse.js-7.1.0.tgz", {}, "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ=="],
|
||||
@@ -1010,6 +1262,8 @@
|
||||
|
||||
"gcp-metadata": ["gcp-metadata@8.1.2", "https://registry.npmmirror.com/gcp-metadata/-/gcp-metadata-8.1.2.tgz", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"get-caller-file": ["get-caller-file@2.0.5", "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||
|
||||
"get-east-asian-width": ["get-east-asian-width@1.5.0", "https://registry.npmmirror.com/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="],
|
||||
@@ -1058,8 +1312,6 @@
|
||||
|
||||
"human-signals": ["human-signals@8.0.1", "https://registry.npmmirror.com/human-signals/-/human-signals-8.0.1.tgz", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
|
||||
|
||||
"humanize-ms": ["humanize-ms@1.2.1", "https://registry.npmmirror.com/humanize-ms/-/humanize-ms-1.2.1.tgz", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.7.2", "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.2.tgz", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
|
||||
"ignore": ["ignore@7.0.5", "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
@@ -1108,6 +1360,10 @@
|
||||
|
||||
"jose": ["jose@6.2.2", "https://registry.npmmirror.com/jose/-/jose-6.2.2.tgz", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json-bigint": ["json-bigint@1.0.0", "https://registry.npmmirror.com/json-bigint/-/json-bigint-1.0.0.tgz", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
|
||||
|
||||
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "https://registry.npmmirror.com/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
|
||||
@@ -1116,6 +1372,8 @@
|
||||
|
||||
"json-schema-typed": ["json-schema-typed@8.0.2", "https://registry.npmmirror.com/json-schema-typed/-/json-schema-typed-8.0.2.tgz", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"jsonc-parser": ["jsonc-parser@3.3.1", "https://registry.npmmirror.com/jsonc-parser/-/jsonc-parser-3.3.1.tgz", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
|
||||
|
||||
"jsonfile": ["jsonfile@6.2.0", "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.2.0.tgz", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
|
||||
@@ -1128,6 +1386,30 @@
|
||||
|
||||
"knip": ["knip@6.1.1", "https://registry.npmmirror.com/knip/-/knip-6.1.1.tgz", { "dependencies": { "@nodelib/fs.walk": "^1.2.3", "fast-glob": "^3.3.3", "formatly": "^0.3.0", "get-tsconfig": "4.13.7", "jiti": "^2.6.0", "minimist": "^1.2.8", "oxc-parser": "^0.121.0", "oxc-resolver": "^11.19.1", "picocolors": "^1.1.1", "picomatch": "^4.0.1", "smol-toml": "^1.6.1", "strip-json-comments": "5.0.3", "unbash": "^2.2.0", "yaml": "^2.8.2", "zod": "^4.1.11" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-BC/kbdxwCgv+p/3YkGbtlLxbOXhQDuR+CeKKFEpJyKb3BFwG1gZa+CMWSqAnPi+kUexz74m327d3zWxyn2fMew=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"locate-path": ["locate-path@5.0.0", "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
|
||||
"lodash-es": ["lodash-es@4.17.23", "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.23.tgz", {}, "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg=="],
|
||||
@@ -1154,6 +1436,8 @@
|
||||
|
||||
"lru-cache": ["lru-cache@11.2.7", "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.2.7.tgz", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"marked": ["marked@17.0.5", "https://registry.npmmirror.com/marked/-/marked-17.0.5.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-6hLvc0/JEbRjRgzI6wnT2P1XuM1/RrrDEX0kPt0N7jGm1133g6X7DlxFasUIx+72aKAr904GTxhSLDrd5DIlZg=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
@@ -1192,14 +1476,18 @@
|
||||
|
||||
"mz": ["mz@2.7.0", "https://registry.npmmirror.com/mz/-/mz-2.7.0.tgz", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"negotiator": ["negotiator@1.0.0", "https://registry.npmmirror.com/negotiator/-/negotiator-1.0.0.tgz", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
|
||||
"node-domexception": ["node-domexception@1.0.0", "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
|
||||
|
||||
"node-fetch": ["node-fetch@2.7.0", "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
|
||||
"node-fetch": ["node-fetch@3.3.2", "https://registry.npmmirror.com/node-fetch/-/node-fetch-3.3.2.tgz", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
|
||||
|
||||
"node-forge": ["node-forge@1.4.0", "https://registry.npmmirror.com/node-forge/-/node-forge-1.4.0.tgz", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.37", "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.37.tgz", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="],
|
||||
|
||||
"npm-run-path": ["npm-run-path@6.0.0", "https://registry.npmmirror.com/npm-run-path/-/npm-run-path-6.0.0.tgz", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
@@ -1212,7 +1500,7 @@
|
||||
|
||||
"open": ["open@10.2.0", "https://registry.npmmirror.com/open/-/open-10.2.0.tgz", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
|
||||
|
||||
"openai": ["openai@4.104.0", "https://registry.npmmirror.com/openai/-/openai-4.104.0.tgz", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA=="],
|
||||
"openai": ["openai@6.33.0", "https://registry.npmmirror.com/openai/-/openai-6.33.0.tgz", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-xAYN1W3YsDXJWA5F277135YfkEk6H7D3D6vWwRhJ3OEkzRgcyK8z/P5P9Gyi/wB4N8kK9kM5ZjprfvyHagKmpw=="],
|
||||
|
||||
"os-tmpdir": ["os-tmpdir@1.0.2", "https://registry.npmmirror.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz", {}, "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g=="],
|
||||
|
||||
@@ -1262,6 +1550,8 @@
|
||||
|
||||
"pngjs": ["pngjs@5.0.0", "https://registry.npmmirror.com/pngjs/-/pngjs-5.0.0.tgz", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
|
||||
|
||||
"postcss": ["postcss@8.5.8", "https://registry.npmmirror.com/postcss/-/postcss-8.5.8.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||
|
||||
"postgres-array": ["postgres-array@2.0.0", "https://registry.npmmirror.com/postgres-array/-/postgres-array-2.0.0.tgz", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
|
||||
|
||||
"postgres-bytea": ["postgres-bytea@1.0.1", "https://registry.npmmirror.com/postgres-bytea/-/postgres-bytea-1.0.1.tgz", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="],
|
||||
@@ -1296,8 +1586,12 @@
|
||||
|
||||
"react-compiler-runtime": ["react-compiler-runtime@1.0.0", "https://registry.npmmirror.com/react-compiler-runtime/-/react-compiler-runtime-1.0.0.tgz", { "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0 || ^0.0.0-experimental" } }, "sha512-rRfjYv66HlG8896yPUDONgKzG5BxZD1nV9U6rkm+7VCuvQc903C4MjcoZR4zPw53IKSOX9wMQVpA1IAbRtzQ7w=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.4", "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.4.tgz", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
|
||||
|
||||
"react-reconciler": ["react-reconciler@0.33.0", "https://registry.npmmirror.com/react-reconciler/-/react-reconciler-0.33.0.tgz", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="],
|
||||
|
||||
"react-refresh": ["react-refresh@0.17.0", "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||
|
||||
"readdirp": ["readdirp@5.0.0", "https://registry.npmmirror.com/readdirp/-/readdirp-5.0.0.tgz", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
||||
|
||||
"require-directory": ["require-directory@2.1.1", "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
@@ -1314,6 +1608,8 @@
|
||||
|
||||
"reusify": ["reusify@1.1.0", "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||
|
||||
"rollup": ["rollup@4.60.1", "https://registry.npmmirror.com/rollup/-/rollup-4.60.1.tgz", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="],
|
||||
|
||||
"router": ["router@2.2.0", "https://registry.npmmirror.com/router/-/router-2.2.0.tgz", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
||||
|
||||
"run-applescript": ["run-applescript@7.1.0", "https://registry.npmmirror.com/run-applescript/-/run-applescript-7.1.0.tgz", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="],
|
||||
@@ -1358,6 +1654,8 @@
|
||||
|
||||
"smol-toml": ["smol-toml@1.6.1", "https://registry.npmmirror.com/smol-toml/-/smol-toml-1.6.1.tgz", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"ssri": ["ssri@13.0.1", "https://registry.npmmirror.com/ssri/-/ssri-13.0.1.tgz", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ=="],
|
||||
|
||||
"stack-utils": ["stack-utils@2.0.6", "https://registry.npmmirror.com/stack-utils/-/stack-utils-2.0.6.tgz", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="],
|
||||
@@ -1380,10 +1678,16 @@
|
||||
|
||||
"tagged-tag": ["tagged-tag@1.0.0", "https://registry.npmmirror.com/tagged-tag/-/tagged-tag-1.0.0.tgz", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.2.2", "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.2.2.tgz", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="],
|
||||
|
||||
"tapable": ["tapable@2.3.2", "https://registry.npmmirror.com/tapable/-/tapable-2.3.2.tgz", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="],
|
||||
|
||||
"thenify": ["thenify@3.3.1", "https://registry.npmmirror.com/thenify/-/thenify-3.3.1.tgz", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
|
||||
|
||||
"thenify-all": ["thenify-all@1.6.0", "https://registry.npmmirror.com/thenify-all/-/thenify-all-1.6.0.tgz", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"tmp": ["tmp@0.0.33", "https://registry.npmmirror.com/tmp/-/tmp-0.0.33.tgz", { "dependencies": { "os-tmpdir": "~1.0.2" } }, "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw=="],
|
||||
|
||||
"to-regex-range": ["to-regex-range@5.0.1", "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||
@@ -1418,14 +1722,18 @@
|
||||
|
||||
"unpipe": ["unpipe@1.0.0", "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"url-handler-napi": ["url-handler-napi@workspace:packages/url-handler-napi"],
|
||||
|
||||
"usehooks-ts": ["usehooks-ts@3.1.1", "https://registry.npmmirror.com/usehooks-ts/-/usehooks-ts-3.1.1.tgz", { "dependencies": { "lodash.debounce": "^4.0.8" }, "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA=="],
|
||||
|
||||
"uuid": ["uuid@8.3.2", "https://registry.npmmirror.com/uuid/-/uuid-8.3.2.tgz", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
||||
"uuid": ["uuid@11.1.0", "https://registry.npmmirror.com/uuid/-/uuid-11.1.0.tgz", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"vite": ["vite@6.4.2", "https://registry.npmmirror.com/vite/-/vite-6.4.2.tgz", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],
|
||||
|
||||
"vscode-jsonrpc": ["vscode-jsonrpc@8.2.1", "https://registry.npmmirror.com/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", {}, "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ=="],
|
||||
|
||||
"vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "https://registry.npmmirror.com/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="],
|
||||
@@ -1434,7 +1742,7 @@
|
||||
|
||||
"walk-up-path": ["walk-up-path@4.0.0", "https://registry.npmmirror.com/walk-up-path/-/walk-up-path-4.0.0.tgz", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="],
|
||||
|
||||
"web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "https://registry.npmmirror.com/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="],
|
||||
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "https://registry.npmmirror.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@3.0.1", "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||
|
||||
@@ -1486,6 +1794,8 @@
|
||||
|
||||
"@anthropic-ai/vertex-sdk/google-auth-library": ["google-auth-library@9.15.1", "https://registry.npmmirror.com/google-auth-library/-/google-auth-library-9.15.1.tgz", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^6.1.1", "gcp-metadata": "^6.1.0", "gtoken": "^7.0.0", "jws": "^4.0.0" } }, "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng=="],
|
||||
|
||||
"@anthropic/remote-control-server/typescript": ["typescript@5.9.3", "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"@aws-crypto/crc32/@aws-crypto/util": ["@aws-crypto/util@5.2.0", "https://registry.npmmirror.com/@aws-crypto/util/-/util-5.2.0.tgz", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
|
||||
|
||||
"@aws-crypto/sha256-browser/@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "https://registry.npmmirror.com/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="],
|
||||
@@ -1632,6 +1942,14 @@
|
||||
|
||||
"@aws-sdk/xml-builder/@smithy/types": ["@smithy/types@4.13.1", "https://registry.npmmirror.com/@smithy/types/-/types-4.13.1.tgz", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g=="],
|
||||
|
||||
"@azure/msal-node/uuid": ["uuid@8.3.2", "https://registry.npmmirror.com/uuid/-/uuid-8.3.2.tgz", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
||||
|
||||
"@babel/core/semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@fastify/otel/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.212.0", "https://registry.npmmirror.com/@opentelemetry/instrumentation/-/instrumentation-0.212.0.tgz", { "dependencies": { "@opentelemetry/api-logs": "0.212.0", "import-in-the-middle": "^2.0.6", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg=="],
|
||||
|
||||
"@grpc/proto-loader/yargs": ["yargs@17.7.2", "https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||
@@ -1756,6 +2074,18 @@
|
||||
|
||||
"@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.2", "https://registry.npmmirror.com/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.1", "https://registry.npmmirror.com/@emnapi/core/-/core-1.9.1.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.1", "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.9.1.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.2", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@typespec/ts-http-runtime/https-proxy-agent": ["https-proxy-agent@7.0.6", "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"ansi-escapes/type-fest": ["type-fest@0.21.3", "https://registry.npmmirror.com/type-fest/-/type-fest-0.21.3.tgz", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
|
||||
@@ -1772,14 +2102,10 @@
|
||||
|
||||
"external-editor/iconv-lite": ["iconv-lite@0.4.24", "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.4.24.tgz", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
||||
|
||||
"fetch-blob/web-streams-polyfill": ["web-streams-polyfill@3.3.3", "https://registry.npmmirror.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
||||
|
||||
"form-data/mime-types": ["mime-types@2.1.35", "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"gaxios/https-proxy-agent": ["https-proxy-agent@7.0.6", "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"gaxios/node-fetch": ["node-fetch@3.3.2", "https://registry.npmmirror.com/node-fetch/-/node-fetch-3.3.2.tgz", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
|
||||
|
||||
"gtoken/gaxios": ["gaxios@6.7.1", "https://registry.npmmirror.com/gaxios/-/gaxios-6.7.1.tgz", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "is-stream": "^2.0.0", "node-fetch": "^2.6.9", "uuid": "^9.0.1" } }, "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ=="],
|
||||
|
||||
"http-proxy-agent/agent-base": ["agent-base@7.1.4", "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
@@ -1794,8 +2120,6 @@
|
||||
|
||||
"npm-run-path/path-key": ["path-key@4.0.0", "https://registry.npmmirror.com/path-key/-/path-key-4.0.0.tgz", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
|
||||
|
||||
"openai/@types/node": ["@types/node@18.19.130", "https://registry.npmmirror.com/@types/node/-/node-18.19.130.tgz", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
||||
|
||||
"parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "https://registry.npmmirror.com/parse5/-/parse5-6.0.1.tgz", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="],
|
||||
|
||||
"proper-lockfile/signal-exit": ["signal-exit@3.0.7", "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
@@ -1850,6 +2174,8 @@
|
||||
|
||||
"@aws-sdk/nested-clients/@smithy/util-base64/@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.2", "https://registry.npmmirror.com/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q=="],
|
||||
|
||||
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"@fastify/otel/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.212.0", "https://registry.npmmirror.com/@opentelemetry/api-logs/-/api-logs-0.212.0.tgz", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-TEEVrLbNROUkYY51sBJGk7lO/OLjuepch8+hmpM6ffMJQ2z/KVCjdHuCFX6fJj8OkJP2zckPjrJzQtXU3IAsFg=="],
|
||||
|
||||
"@fastify/otel/@opentelemetry/instrumentation/import-in-the-middle": ["import-in-the-middle@2.0.6", "https://registry.npmmirror.com/import-in-the-middle/-/import-in-the-middle-2.0.6.tgz", { "dependencies": { "acorn": "^8.15.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw=="],
|
||||
@@ -1932,6 +2258,8 @@
|
||||
|
||||
"gtoken/gaxios/is-stream": ["is-stream@2.0.1", "https://registry.npmmirror.com/is-stream/-/is-stream-2.0.1.tgz", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
|
||||
|
||||
"gtoken/gaxios/node-fetch": ["node-fetch@2.7.0", "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
|
||||
|
||||
"gtoken/gaxios/uuid": ["uuid@9.0.1", "https://registry.npmmirror.com/uuid/-/uuid-9.0.1.tgz", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
|
||||
|
||||
"image-processor-napi/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "https://registry.npmmirror.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
|
||||
@@ -1972,8 +2300,6 @@
|
||||
|
||||
"image-processor-napi/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "https://registry.npmmirror.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
|
||||
|
||||
"openai/@types/node/undici-types": ["undici-types@5.26.5", "https://registry.npmmirror.com/undici-types/-/undici-types-5.26.5.tgz", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||
|
||||
"qrcode/yargs/cliui": ["cliui@6.0.0", "https://registry.npmmirror.com/cliui/-/cliui-6.0.0.tgz", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
|
||||
|
||||
"qrcode/yargs/string-width": ["string-width@4.2.3", "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
@@ -1992,6 +2318,8 @@
|
||||
|
||||
"@anthropic-ai/vertex-sdk/google-auth-library/gaxios/is-stream": ["is-stream@2.0.1", "https://registry.npmmirror.com/is-stream/-/is-stream-2.0.1.tgz", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
|
||||
|
||||
"@anthropic-ai/vertex-sdk/google-auth-library/gaxios/node-fetch": ["node-fetch@2.7.0", "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
|
||||
|
||||
"@anthropic-ai/vertex-sdk/google-auth-library/gaxios/uuid": ["uuid@9.0.1", "https://registry.npmmirror.com/uuid/-/uuid-9.0.1.tgz", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
|
||||
|
||||
"@anthropic-ai/vertex-sdk/google-auth-library/gcp-metadata/google-logging-utils": ["google-logging-utils@0.0.2", "https://registry.npmmirror.com/google-logging-utils/-/google-logging-utils-0.0.2.tgz", {}, "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ=="],
|
||||
|
||||
44
contributors.svg
Normal file
44
contributors.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 1.3 MiB |
@@ -48,15 +48,16 @@ const messagesForCompact = microcompactResult.messages
|
||||
MicroCompact 不压缩整个对话,而是**清除旧工具输出的内容**。它维护一个白名单:
|
||||
|
||||
```typescript
|
||||
// src/services/compact/microCompact.ts:41-48
|
||||
const COMPACTABLE_TOOLS = new Set([
|
||||
'Read', // 文件读取
|
||||
'Bash', // 命令输出
|
||||
'Grep', // 搜索结果
|
||||
'Glob', // 文件列表
|
||||
'WebSearch', // 搜索结果
|
||||
'WebFetch', // 网页内容
|
||||
'Edit', // 编辑输出
|
||||
'Write', // 写入输出
|
||||
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' - 写入输出
|
||||
])
|
||||
```
|
||||
|
||||
@@ -201,6 +202,31 @@ boundaryMarker.compactMetadata.preservedSegment = {
|
||||
|
||||
这在会话恢复时帮助加载器正确重建消息链,避免重复压缩已保留的消息。
|
||||
|
||||
### Microcompact Boundary
|
||||
|
||||
Microcompact 操作使用单独的 boundary 类型,与全量压缩的 `compact_boundary` 不同:
|
||||
|
||||
```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
|
||||
|
||||
## PTL 紧急降级:Prompt Too Long
|
||||
|
||||
当压缩后仍然超出 token 限制(`PROMPT_TOO_LONG` 错误),系统会进入紧急降级路径:
|
||||
|
||||
@@ -88,6 +88,36 @@ DANGEROUS_uncachedSystemPromptSection(
|
||||
|
||||
`appendSystemPrompt` 始终追加到末尾(Override 除外)。
|
||||
|
||||
## Provider 系统概述
|
||||
|
||||
Claude Code 支持多种 API 提供商,分为两大类:
|
||||
|
||||
| 类别 | 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
|
||||
```
|
||||
|
||||
## 缓存策略:分块、标记、命中
|
||||
|
||||
这是 System Prompt 设计中最精密的部分。
|
||||
@@ -121,6 +151,30 @@ MCP 工具列表在会话中可能变化(连接/断开),破坏了跨组织
|
||||
|
||||
这是缓存效率最高的模式。`SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 之前的静态内容(Intro、Rules、Tone & Style 等)对所有用户相同,可跨组织缓存。
|
||||
|
||||
### Boundary 插入条件
|
||||
|
||||
`SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 标记**仅在特定条件**下插入:
|
||||
|
||||
```typescript
|
||||
// src/utils/betas.ts:226-229
|
||||
export function shouldUseGlobalCacheScope(): boolean {
|
||||
return (
|
||||
getAPIProvider() === 'firstParty' &&
|
||||
!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS)
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
```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 缺失)
|
||||
|
||||
```
|
||||
@@ -250,3 +304,65 @@ Header 始终 `cacheScope: null`——它因版本和指纹不同而变化,不
|
||||
4. `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 标记允许 `splitSysPromptPrefix()` 精确地将静态区标记为 `scope: 'global'`,动态区不标记或标记为 `scope: 'org'`
|
||||
|
||||
这是 Claude Code 在 token 成本优化上的核心设计——一次典型的 System Prompt 约 20K+ tokens,通过缓存分块可以节省 30-50% 的输入 token 费用。
|
||||
|
||||
## 兼容层:OpenAI 与 Gemini
|
||||
|
||||
Claude Code 提供了 OpenAI 和 Gemini 协议的兼容层,允许使用非 Anthropic 端点。
|
||||
|
||||
### OpenAI 兼容层
|
||||
|
||||
通过 `CLAUDE_CODE_USE_OPENAI=1` 启用,支持任意 OpenAI Chat Completions 协议端点(Ollama、DeepSeek、vLLM 等)。
|
||||
|
||||
实现采用**流适配器模式**:
|
||||
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` 中的相关章节。
|
||||
|
||||
|
||||
@@ -64,6 +64,33 @@ function roughTokenCountEstimation(content: string, bytesPerToken = 4): number {
|
||||
|
||||
精确计数在关键决策点使用(压缩前后对比、warning 判断),近似估算在热路径使用(每轮循环的 shouldAutoCompact 检查)。
|
||||
|
||||
### 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 阈值判断**:基于估算而非精确计数
|
||||
|
||||
```typescript
|
||||
// src/services/tokenEstimation.ts - 近似估算函数
|
||||
function roughTokenCountEstimation(content: string, bytesPerToken = 4): number {
|
||||
return Math.round(content.length / bytesPerToken)
|
||||
}
|
||||
```
|
||||
|
||||
源码路径:`src/services/tokenEstimation.ts`
|
||||
|
||||
## 自动压缩的触发阈值
|
||||
|
||||
```
|
||||
|
||||
2008
docs/feature-flags-audit-complete.md
Normal file
2008
docs/feature-flags-audit-complete.md
Normal file
File diff suppressed because it is too large
Load Diff
182
docs/features/auto-dream.md
Normal file
182
docs/features/auto-dream.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# Auto Dream — 自动记忆整理
|
||||
|
||||
## 概述
|
||||
|
||||
Auto Dream 是 Claude Code 的后台记忆整合机制。它在会话间自动审查、组织和修剪持久化记忆文件,确保未来会话能快速获得准确的上下文。
|
||||
|
||||
记忆系统存储在文件系统中(默认 `~/.claude/projects/<project-slug>/memory/`),由 `MEMORY.md` 索引文件和若干主题文件(如 `user_language.md`、`project_overview.md`)组成。随着会话积累,记忆会变得过时、冗余或矛盾——Dream 负责清理这些堆积。
|
||||
|
||||
## 架构
|
||||
|
||||
### 核心模块
|
||||
|
||||
| 模块 | 路径 | 职责 |
|
||||
|------|------|------|
|
||||
| 调度器 | `src/services/autoDream/autoDream.ts` | 时间/会话/锁三重门控,触发 forked agent |
|
||||
| 配置 | `src/services/autoDream/config.ts` | 读取 `isAutoDreamEnabled()` 开关 |
|
||||
| 提示词 | `src/services/autoDream/consolidationPrompt.ts` | 构建 4 阶段整理提示词 |
|
||||
| 锁文件 | `src/services/autoDream/consolidationLock.ts` | PID 锁 + mtime 作为 `lastConsolidatedAt` |
|
||||
| 任务 UI | `src/tasks/DreamTask/DreamTask.ts` | 后台任务注册,footer pill + Shift+Down 可见 |
|
||||
| 手动入口 | `src/skills/bundled/dream.ts` | `/dream` 命令,无条件可用 |
|
||||
|
||||
### 记忆路径解析
|
||||
|
||||
优先级(`src/memdir/paths.ts`):
|
||||
|
||||
1. `CLAUDE_COWORK_MEMORY_PATH_OVERRIDE` 环境变量(完整路径覆盖)
|
||||
2. `autoMemoryDirectory` 设置项(`settings.json`,支持 `~/` 展开)
|
||||
3. 默认:`<memoryBase>/projects/<sanitized-git-root>/memory/`
|
||||
|
||||
其中 `memoryBase` = `CLAUDE_CODE_REMOTE_MEMORY_DIR` 或 `~/.claude`。
|
||||
|
||||
## 触发机制
|
||||
|
||||
### 自动触发(Auto Dream)
|
||||
|
||||
每个对话轮次结束后,`executeAutoDream()` 按顺序检查三重门控:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Gate 1: 全局开关 │
|
||||
│ isAutoMemoryEnabled() && isAutoDreamEnabled() │
|
||||
│ 排除: KAIROS 模式 / Remote 模式 │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ Gate 2: 时间门控 │
|
||||
│ hoursSince(lastConsolidatedAt) >= minHours │
|
||||
│ 默认: 24 小时 │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ Gate 3: 会话门控 │
|
||||
│ sessionsTouchedSince(lastConsolidatedAt) >= minSessions │
|
||||
│ 默认: 5 个会话(排除当前会话) │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ Lock: PID 锁文件 │
|
||||
│ .consolidate-lock (mtime = lastConsolidatedAt) │
|
||||
│ 死进程检测 + 1 小时过期 │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
全部通过后,以 **forked agent**(受限子代理)方式运行整理任务:
|
||||
|
||||
- Bash 工具限制为只读命令(`ls`、`grep`、`cat` 等)
|
||||
- 只能读写记忆目录内的文件
|
||||
- 用户可在 Shift+Down 后台任务面板中查看进度或终止
|
||||
|
||||
### 手动触发(`/dream` 命令)
|
||||
|
||||
通过 `/dream` 命令随时触发,无门控限制:
|
||||
|
||||
- 在主循环中运行(非 forked agent),拥有完整工具权限
|
||||
- 用户可实时观察操作过程
|
||||
- 执行前自动更新锁文件 mtime
|
||||
|
||||
### 配置开关
|
||||
|
||||
| 开关 | 位置 | 作用 |
|
||||
|------|------|------|
|
||||
| `autoDreamEnabled` | `settings.json` | `true`/`false` 显式开关 |
|
||||
| `autoMemoryEnabled` | `settings.json` | 总开关,关闭后所有记忆功能禁用 |
|
||||
| `CLAUDE_CODE_DISABLE_AUTO_MEMORY` | 环境变量 | `1`/`true` 关闭所有记忆功能 |
|
||||
| `tengu_onyx_plover` | GrowthBook | 官方远程配置,控制 `enabled`/`minHours`/`minSessions` |
|
||||
|
||||
默认值(无 GrowthBook 连接时):
|
||||
|
||||
```typescript
|
||||
minHours: 24 // 距上次整理至少 24 小时
|
||||
minSessions: 5 // 至少有 5 个新会话
|
||||
```
|
||||
|
||||
## 整理流程(4 阶段)
|
||||
|
||||
Dream agent 执行的提示词包含 4 个阶段:
|
||||
|
||||
### Phase 1 — 定位(Orient)
|
||||
|
||||
- `ls` 记忆目录,查看现有文件
|
||||
- 读取 `MEMORY.md` 索引
|
||||
- 浏览现有主题文件,避免重复创建
|
||||
|
||||
### Phase 2 — 采集信号(Gather)
|
||||
|
||||
按优先级收集新信息:
|
||||
|
||||
1. **日志文件**(`logs/YYYY/MM/YYYY-MM-DD.md`,KAIROS 模式下的追加式日志)
|
||||
2. **过时记忆** — 与当前代码库状态矛盾的事实
|
||||
3. **会话记录** — 窄关键词 grep JSONL 文件(不全文读取)
|
||||
|
||||
### Phase 3 — 整合(Consolidate)
|
||||
|
||||
- 合并新信号到现有主题文件,而非创建近似重复
|
||||
- 将相对日期("昨天"、"上周")转为绝对日期
|
||||
- 删除被推翻的事实
|
||||
|
||||
### Phase 4 — 修剪与索引(Prune)
|
||||
|
||||
- `MEMORY.md` 保持在 200 行以内、25KB 以内
|
||||
- 每条索引项一行,不超过 150 字符
|
||||
- 移除过时/错误/被取代的指针
|
||||
|
||||
## 记忆类型
|
||||
|
||||
记忆系统使用 4 种类型(`src/memdir/memoryTypes.ts`):
|
||||
|
||||
| 类型 | 用途 | 示例 |
|
||||
|------|------|------|
|
||||
| `user` | 用户角色、偏好、知识 | 用户是高级后端工程师,偏好中文交流 |
|
||||
| `feedback` | 工作方式指导 | 不要 mock 数据库测试;代码审查用 bundled PR |
|
||||
| `project` | 项目上下文(非代码可推导的) | 合并冻结从 3 月 5 日开始;认证重写是合规需求 |
|
||||
| `reference` | 外部系统指针 | Linear INGEST 项目跟踪 pipeline bugs |
|
||||
|
||||
**不保存的内容**:代码模式、架构、文件路径(可从代码推导);Git 历史(`git log` 权威);调试方案(代码中已有)。
|
||||
|
||||
## 锁文件机制
|
||||
|
||||
`.consolidate-lock` 文件位于记忆目录内:
|
||||
|
||||
- **文件内容**:持有者 PID
|
||||
- **mtime**:即 `lastConsolidatedAt` 时间戳
|
||||
- **过期**:1 小时(防 PID 复用)
|
||||
- **竞态处理**:双进程同时写入时,后读验证 PID,失败者退出
|
||||
- **回滚**:forked agent 失败或被用户终止时,mtime 回退到获取前的值
|
||||
|
||||
## 使用场景
|
||||
|
||||
### 场景 1:日常开发中的自动整理
|
||||
|
||||
开发者连续多天使用 Claude Code 处理不同任务。Auto Dream 在积累 5+ 个会话且距上次整理 24 小时后自动触发,整合分散在多次会话中的用户偏好和项目决策。
|
||||
|
||||
### 场景 2:手动整理记忆
|
||||
|
||||
用户发现 Claude 重复犯相同错误或遗忘之前的决策。输入 `/dream` 立即触发整理,无需等待自动触发周期。
|
||||
|
||||
### 场景 3:新会话快速上下文
|
||||
|
||||
新会话启动时,`MEMORY.md` 被加载到上下文中。经过 Dream 整理的记忆文件结构清晰、信息准确,让 Claude 快速了解用户和项目。
|
||||
|
||||
### 场景 4:KAIROS 模式下的日志蒸馏
|
||||
|
||||
KAIROS(长驻助手模式)中,agent 以追加方式写入日期日志文件。Dream 负责将这些日志蒸馏为主题文件和 `MEMORY.md` 索引。
|
||||
|
||||
## 与其他系统的关系
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌──────────────┐ ┌───────────────┐
|
||||
│ 会话交互 │────▶│ 记忆写入 │────▶│ MEMORY.md │
|
||||
│ (主 agent) │ │ (即时保存) │ │ + 主题文件 │
|
||||
└─────────────┘ └──────────────┘ └───────┬───────┘
|
||||
│
|
||||
┌───────────────────────────────────────┘
|
||||
▼
|
||||
┌──────────────┐ ┌──────────────┐
|
||||
│ Auto Dream │────▶│ 整理/修剪 │
|
||||
│ (后台触发) │ │ 去重/纠错 │
|
||||
└──────────────┘ └──────────────┘
|
||||
▲
|
||||
┌──────────────┐
|
||||
│ /dream 命令 │
|
||||
│ (手动触发) │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
- **extractMemories**(`src/services/extractMemories/`):每轮次结束时从对话中提取新记忆并写入。Dream 不负责提取,只负责整理。
|
||||
- **CLAUDE.md**:项目级指令文件,加载到上下文中但不属于记忆系统。
|
||||
- **Team Memory**(`TEAMMEM` feature):团队共享记忆目录,与个人记忆使用相同的 Dream 机制。
|
||||
137
docs/features/claude-in-chrome-mcp.md
Normal file
137
docs/features/claude-in-chrome-mcp.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# Claude in Chrome — 用户操作指南
|
||||
|
||||
## 1. 功能简介
|
||||
|
||||
Claude in Chrome 让 Claude Code 直接控制你的 Chrome 浏览器。你可以用自然语言让 Claude 帮你:
|
||||
|
||||
- 打开网页、导航、前进后退
|
||||
- 填写表单、上传图片
|
||||
- 截图、录制 GIF
|
||||
- 读取页面内容(DOM、纯文本)
|
||||
- 执行 JavaScript
|
||||
- 监控网络请求和控制台日志
|
||||
- 管理标签页
|
||||
|
||||
## 2. 前置条件
|
||||
|
||||
| 条件 | 说明 |
|
||||
|------|------|
|
||||
| Claude Code 订阅 | 需要 Claude Pro、Max 或 Team 订阅,浏览器插件功能不向免费用户开放 |
|
||||
| Chrome 浏览器 | 需已安装 Google Chrome |
|
||||
| Claude in Chrome 扩展 | 从 Chrome Web Store 安装(`claude.ai/chrome`) |
|
||||
| Claude Code CLI | 已通过 `bun run dev` 或构建产物运行 |
|
||||
|
||||
## 3. 启用方式
|
||||
|
||||
### Dev 模式
|
||||
|
||||
```bash
|
||||
bun run dev -- --chrome
|
||||
```
|
||||
|
||||
启动后 Claude 会自动检测 Chrome 扩展是否已安装,并注册浏览器控制工具。
|
||||
|
||||
### 构建产物
|
||||
|
||||
```bash
|
||||
node dist/cli.js --chrome
|
||||
```
|
||||
|
||||
### 禁用
|
||||
|
||||
```bash
|
||||
bun run dev -- --no-chrome
|
||||
```
|
||||
|
||||
或在 REPL 中通过 `/chrome` 命令切换启用/禁用状态。
|
||||
|
||||
### 通过配置默认启用
|
||||
|
||||
在 Claude Code 设置中将 `claudeInChromeDefaultEnabled` 设为 `true`,以后启动无需加 `--chrome` 参数。
|
||||
|
||||
## 4. 使用流程
|
||||
|
||||
1. **启动 CLI** — 加 `--chrome` 参数启动 Claude Code
|
||||
2. **确认连接** — REPL 中输入 `/chrome`,查看扩展状态是否显示 "Installed / Connected"
|
||||
3. **开始对话** — 正常与 Claude 对话,当需要操作浏览器时直接说,例如:
|
||||
- "打开 https://example.com 并截图"
|
||||
- "在当前页面搜索关键词 xxx"
|
||||
- "填写登录表单,用户名 admin"
|
||||
- "帮我录制当前操作的 GIF"
|
||||
4. **权限审批** — 首次执行浏览器操作时,Claude 会请求你的确认
|
||||
5. **操作完成** — Claude 完成操作后会返回结果(截图、文本、执行结果等)
|
||||
|
||||
## 5. 可用操作
|
||||
|
||||
### 页面交互
|
||||
|
||||
| 操作 | 说明 |
|
||||
|------|------|
|
||||
| `navigate` | 导航到指定 URL,或前进/后退 |
|
||||
| `computer` | 鼠标点击、移动、拖拽、键盘输入、截图等(13 种 action) |
|
||||
| `form_input` | 填写表单字段 |
|
||||
| `upload_image` | 上传图片到文件输入框或拖拽区域 |
|
||||
| `javascript_tool` | 在页面上下文执行 JavaScript |
|
||||
|
||||
### 页面读取
|
||||
|
||||
| 操作 | 说明 |
|
||||
|------|------|
|
||||
| `read_page` | 获取页面可访问性树(DOM 结构) |
|
||||
| `get_page_text` | 提取页面纯文本内容 |
|
||||
| `find` | 用自然语言搜索页面元素 |
|
||||
|
||||
### 标签页管理
|
||||
|
||||
| 操作 | 说明 |
|
||||
|------|------|
|
||||
| `tabs_context_mcp` | 获取当前标签组信息 |
|
||||
| `tabs_create_mcp` | 创建新标签页 |
|
||||
|
||||
### 监控与调试
|
||||
|
||||
| 操作 | 说明 |
|
||||
|------|------|
|
||||
| `read_console_messages` | 读取浏览器控制台日志 |
|
||||
| `read_network_requests` | 读取网络请求记录 |
|
||||
|
||||
### 其他
|
||||
|
||||
| 操作 | 说明 |
|
||||
|------|------|
|
||||
| `resize_window` | 调整浏览器窗口尺寸 |
|
||||
| `gif_creator` | 录制 GIF 并导出 |
|
||||
| `shortcuts_list` | 列出可用快捷方式 |
|
||||
| `shortcuts_execute` | 执行快捷方式 |
|
||||
| `update_plan` | 向你提交操作计划供审批 |
|
||||
| `switch_browser` | 切换到其他 Chrome 浏览器(仅 Bridge 模式) |
|
||||
|
||||
## 6. 通信模式
|
||||
|
||||
Claude in Chrome 支持两种与浏览器通信的方式:
|
||||
|
||||
### 本地 Socket(默认)
|
||||
|
||||
Chrome 扩展通过 Native Messaging Host 与 CLI 建立 Unix socket 连接。适用于本地开发,无需额外配置。
|
||||
|
||||
### Bridge WebSocket
|
||||
|
||||
通过 Anthropic 的 bridge 服务中转,支持远程操控浏览器。需要 claude.ai OAuth 登录。
|
||||
|
||||
## 7. 常见问题
|
||||
|
||||
### 扩展显示未安装
|
||||
|
||||
确认已从 Chrome Web Store 安装 "Claude in Chrome" 扩展,安装后重启浏览器。
|
||||
|
||||
### 工具未出现在工具列表
|
||||
|
||||
检查启动时是否加了 `--chrome` 参数,或通过 `/chrome` 命令确认状态。
|
||||
|
||||
### 连接超时
|
||||
|
||||
确保 Chrome 浏览器正在运行且扩展已启用。Native Messaging Host 在扩展安装时自动注册,如果重装过扩展需要重启浏览器。
|
||||
|
||||
### 不使用 Chrome 功能时
|
||||
|
||||
不带 `--chrome` 参数正常启动即可,不会加载任何浏览器相关模块,不影响其他功能。
|
||||
325
docs/features/computer-use-architecture-v2.md
Normal file
325
docs/features/computer-use-architecture-v2.md
Normal file
@@ -0,0 +1,325 @@
|
||||
# Computer Use 架构修正方案 v2
|
||||
|
||||
更新时间:2026-04-04
|
||||
|
||||
## 1. 当前架构的问题
|
||||
|
||||
### 问题 A:平台代码混在错误的包里
|
||||
|
||||
`@ant/computer-use-swift` 是 macOS Swift 原生模块的包装器,但我们把 Windows(`backends/win32.ts`)和 Linux(`backends/linux.ts`)的截图/应用管理代码塞进了这个包。"swift" 在名字里就意味着 macOS,后期维护者无法区分。
|
||||
|
||||
`@ant/computer-use-input` 同样——原本是 macOS enigo Rust 模块,我们也往里面塞了 win32/linux 后端。
|
||||
|
||||
### 问题 B:输入方式不对
|
||||
|
||||
当前 Windows 后端(`packages/@ant/computer-use-input/src/backends/win32.ts`)使用 `SetCursorPos` + `SendInput` + `keybd_event`——这是**全局输入**:
|
||||
|
||||
- 鼠标真的会移动到屏幕上
|
||||
- 键盘真的打到当前前台窗口
|
||||
- **会影响用户当前的操作**
|
||||
|
||||
绑定窗口句柄后,应该用 `SendMessage`/`PostMessage` 向目标 HWND 发送消息:
|
||||
|
||||
- `WM_CHAR` — 发送字符,不移动光标
|
||||
- `WM_KEYDOWN`/`WM_KEYUP` — 发送按键
|
||||
- `WM_LBUTTONDOWN`/`WM_LBUTTONUP` — 发送鼠标点击(窗口客户区相对坐标)
|
||||
- `PrintWindow` — 截取窗口内容,不需要窗口在前台
|
||||
- **不抢焦点、不影响用户当前操作**
|
||||
|
||||
已验证:向记事本 `SendMessage(WM_CHAR)` 成功写入文字,记事本在后台,终端保持前台。
|
||||
|
||||
### 问题 C:截图是公共能力,不属于 swift
|
||||
|
||||
截图(screenshot)、显示器枚举(display)、应用管理(apps)是所有平台都需要的公共能力,不应该放在 `@ant/computer-use-swift`(macOS 专属包名)里。
|
||||
|
||||
## 2. 修正后的架构
|
||||
|
||||
### 2.1 分层原则
|
||||
|
||||
```
|
||||
packages/@ant/ ← macOS 原生模块包装器(不放其他平台代码)
|
||||
├── computer-use-input/ ← macOS: enigo .node 键鼠(仅 darwin)
|
||||
├── computer-use-swift/ ← macOS: Swift .node 截图/应用(仅 darwin)
|
||||
└── computer-use-mcp/ ← 跨平台: MCP server + 工具定义(不改)
|
||||
|
||||
src/utils/computerUse/
|
||||
├── platforms/ ← 新增: 跨平台抽象层
|
||||
│ ├── types.ts ← 公共接口: InputPlatform, ScreenshotPlatform, AppsPlatform, DisplayPlatform
|
||||
│ ├── index.ts ← 平台分发器: 按 process.platform 加载后端
|
||||
│ ├── darwin.ts ← macOS: 委托给 @ant/computer-use-{input,swift}
|
||||
│ ├── win32.ts ← Windows: SendMessage 输入 + PrintWindow 截图 + EnumWindows + UIA + OCR
|
||||
│ └── linux.ts ← Linux: xdotool + scrot + xrandr + wmctrl
|
||||
│
|
||||
├── win32/ ← Windows 专属增强能力(不在公共接口中)
|
||||
│ ├── windowCapture.ts ← PrintWindow 窗口绑定截图
|
||||
│ ├── windowEnum.ts ← EnumWindows 窗口枚举
|
||||
│ ├── windowMessage.ts ← SendMessage/PostMessage 无焦点输入(新增)
|
||||
│ ├── uiAutomation.ts ← IUIAutomation UI 元素操作
|
||||
│ └── ocr.ts ← Windows.Media.Ocr 文字识别
|
||||
│
|
||||
├── executor.ts ← 改: 通过 platforms/ 获取平台实现,不直接调 @ant 包
|
||||
├── swiftLoader.ts ← 改: 仅 darwin 使用
|
||||
├── inputLoader.ts ← 改: 仅 darwin 使用
|
||||
└── ...其他文件不动
|
||||
```
|
||||
|
||||
### 2.2 公共接口(`platforms/types.ts`)
|
||||
|
||||
```typescript
|
||||
/** 窗口标识 — 跨平台 */
|
||||
export interface WindowHandle {
|
||||
id: string // macOS: bundleId, Windows: HWND string, Linux: window ID
|
||||
pid: number
|
||||
title: string
|
||||
exePath?: string // Windows/Linux: 进程路径
|
||||
}
|
||||
|
||||
/** 输入平台接口 — 两种模式 */
|
||||
export interface InputPlatform {
|
||||
// 模式 A: 全局输入(macOS/Linux 默认,向前台窗口发送)
|
||||
moveMouse(x: number, y: number): Promise<void>
|
||||
click(x: number, y: number, button: 'left' | 'right' | 'middle'): Promise<void>
|
||||
typeText(text: string): Promise<void>
|
||||
key(name: string, action: 'press' | 'release'): Promise<void>
|
||||
keys(combo: string[]): Promise<void>
|
||||
scroll(amount: number, direction: 'vertical' | 'horizontal'): Promise<void>
|
||||
mouseLocation(): Promise<{ x: number; y: number }>
|
||||
|
||||
// 模式 B: 窗口绑定输入(Windows SendMessage,不抢焦点)
|
||||
sendChar?(hwnd: string, char: string): Promise<void>
|
||||
sendKey?(hwnd: string, vk: number, action: 'down' | 'up'): Promise<void>
|
||||
sendClick?(hwnd: string, x: number, y: number, button: 'left' | 'right'): Promise<void>
|
||||
sendText?(hwnd: string, text: string): Promise<void>
|
||||
}
|
||||
|
||||
/** 截图平台接口 */
|
||||
export interface ScreenshotPlatform {
|
||||
// 全屏截图
|
||||
captureScreen(displayId?: number): Promise<ScreenshotResult>
|
||||
// 区域截图
|
||||
captureRegion(x: number, y: number, w: number, h: number): Promise<ScreenshotResult>
|
||||
// 窗口截图(Windows: PrintWindow,macOS: SCContentFilter,Linux: xdotool+import)
|
||||
captureWindow?(hwnd: string): Promise<ScreenshotResult | null>
|
||||
}
|
||||
|
||||
/** 显示器平台接口 */
|
||||
export interface DisplayPlatform {
|
||||
listAll(): DisplayInfo[]
|
||||
getSize(displayId?: number): DisplayInfo
|
||||
}
|
||||
|
||||
/** 应用管理平台接口 */
|
||||
export interface AppsPlatform {
|
||||
listRunning(): WindowHandle[]
|
||||
listInstalled(): Promise<InstalledApp[]>
|
||||
open(name: string): Promise<void>
|
||||
getFrontmostApp(): FrontmostAppInfo | null
|
||||
findWindowByTitle(title: string): WindowHandle | null
|
||||
}
|
||||
|
||||
export interface ScreenshotResult {
|
||||
base64: string
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface DisplayInfo {
|
||||
width: number
|
||||
height: number
|
||||
scaleFactor: number
|
||||
displayId: number
|
||||
}
|
||||
|
||||
export interface InstalledApp {
|
||||
id: string // macOS: bundleId, Windows: exe path, Linux: .desktop name
|
||||
displayName: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export interface FrontmostAppInfo {
|
||||
id: string
|
||||
appName: string
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 平台分发器(`platforms/index.ts`)
|
||||
|
||||
```typescript
|
||||
import type { InputPlatform, ScreenshotPlatform, DisplayPlatform, AppsPlatform } from './types.js'
|
||||
|
||||
export interface Platform {
|
||||
input: InputPlatform
|
||||
screenshot: ScreenshotPlatform
|
||||
display: DisplayPlatform
|
||||
apps: AppsPlatform
|
||||
}
|
||||
|
||||
export function loadPlatform(): Platform {
|
||||
switch (process.platform) {
|
||||
case 'darwin':
|
||||
return require('./darwin.js').platform
|
||||
case 'win32':
|
||||
return require('./win32.js').platform
|
||||
case 'linux':
|
||||
return require('./linux.js').platform
|
||||
default:
|
||||
throw new Error(`Computer Use not supported on ${process.platform}`)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 各平台实现
|
||||
|
||||
**`platforms/darwin.ts`** — 委托给 @ant 包(保持兼容):
|
||||
```typescript
|
||||
// macOS: 通过 @ant/computer-use-input 和 @ant/computer-use-swift
|
||||
// 这两个包的 darwin 后端保留不动
|
||||
import { requireComputerUseInput } from '../inputLoader.js'
|
||||
import { requireComputerUseSwift } from '../swiftLoader.js'
|
||||
|
||||
export const platform = {
|
||||
input: { /* 委托给 requireComputerUseInput() */ },
|
||||
screenshot: { /* 委托给 requireComputerUseSwift().screenshot */ },
|
||||
display: { /* 委托给 requireComputerUseSwift().display */ },
|
||||
apps: { /* 委托给 requireComputerUseSwift().apps */ },
|
||||
}
|
||||
```
|
||||
|
||||
**`platforms/win32.ts`** — 使用 `src/utils/computerUse/win32/` 模块:
|
||||
```typescript
|
||||
// Windows: SendMessage 输入 + PrintWindow 截图 + EnumWindows 应用
|
||||
import { sendChar, sendKey, sendClick, sendText } from '../win32/windowMessage.js'
|
||||
import { captureWindow } from '../win32/windowCapture.js'
|
||||
import { listWindows } from '../win32/windowEnum.js'
|
||||
// ... PowerShell P/Invoke 全局输入作为 fallback
|
||||
|
||||
export const platform = {
|
||||
input: {
|
||||
// 全局模式: PowerShell SetCursorPos/SendInput(fallback)
|
||||
// 窗口模式: SendMessage(首选)
|
||||
sendChar, sendKey, sendClick, sendText, // 窗口绑定
|
||||
moveMouse, click, typeText, ... // 全局 fallback
|
||||
},
|
||||
screenshot: {
|
||||
captureScreen, // CopyFromScreen
|
||||
captureRegion, // CopyFromScreen(rect)
|
||||
captureWindow, // PrintWindow(不抢焦点)
|
||||
},
|
||||
display: { /* Screen.AllScreens */ },
|
||||
apps: { /* EnumWindows */ },
|
||||
}
|
||||
```
|
||||
|
||||
**`platforms/linux.ts`** — 使用 xdotool/scrot:
|
||||
```typescript
|
||||
// Linux: xdotool + scrot + xrandr + wmctrl
|
||||
export const platform = {
|
||||
input: { /* xdotool mousemove/click/key/type */ },
|
||||
screenshot: { /* scrot */ },
|
||||
display: { /* xrandr */ },
|
||||
apps: { /* wmctrl + ps */ },
|
||||
}
|
||||
```
|
||||
|
||||
### 2.5 executor.ts 改造
|
||||
|
||||
```typescript
|
||||
// 之前: 直接调 requireComputerUseSwift() 和 requireComputerUseInput()
|
||||
// 之后: 通过 platforms/ 统一获取
|
||||
|
||||
import { loadPlatform } from './platforms/index.js'
|
||||
|
||||
const platform = loadPlatform()
|
||||
|
||||
// 截图
|
||||
platform.screenshot.captureScreen()
|
||||
platform.screenshot.captureWindow(hwnd) // 窗口绑定
|
||||
|
||||
// 输入(窗口绑定模式,不抢焦点)
|
||||
platform.input.sendText?.(hwnd, 'Hello')
|
||||
platform.input.sendClick?.(hwnd, 100, 200, 'left')
|
||||
|
||||
// 输入(全局模式,fallback)
|
||||
platform.input.moveMouse(500, 500)
|
||||
platform.input.click(500, 500, 'left')
|
||||
```
|
||||
|
||||
## 3. Windows 输入模式对比
|
||||
|
||||
| 方式 | API | 抢焦点 | 移鼠标 | 窗口可最小化 | 适用场景 |
|
||||
|------|-----|--------|--------|-------------|---------|
|
||||
| **全局输入** | `SetCursorPos` + `SendInput` | ✅ 抢 | ✅ 动 | ❌ 不行 | 需要坐标点击(fallback) |
|
||||
| **窗口消息** | `SendMessage(WM_CHAR/WM_KEYDOWN)` | ❌ 不抢 | ❌ 不动 | ✅ 可以 | 打字、按键(首选) |
|
||||
| **窗口消息** | `SendMessage(WM_LBUTTONDOWN)` | ❌ 不抢 | ❌ 不动 | ⚠️ 部分 | 窗口内点击 |
|
||||
| **窗口截图** | `PrintWindow(hwnd, PW_RENDERFULLCONTENT)` | ❌ 不抢 | ❌ 不动 | ✅ 可以 | 窗口截图 |
|
||||
| **UI 操作** | `UIAutomation InvokePattern` | ❌ 不抢 | ❌ 不动 | ✅ 可以 | 按钮点击、文本写入 |
|
||||
|
||||
**策略**:优先用窗口消息 + UIAutomation(不干扰用户),全局输入作为 fallback。
|
||||
|
||||
## 4. 需要新增的文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `src/utils/computerUse/platforms/types.ts` | 公共接口定义 |
|
||||
| `src/utils/computerUse/platforms/index.ts` | 平台分发器 |
|
||||
| `src/utils/computerUse/platforms/darwin.ts` | macOS: 委托给 @ant 包 |
|
||||
| `src/utils/computerUse/platforms/win32.ts` | Windows: 组合 win32/ 下各模块 |
|
||||
| `src/utils/computerUse/platforms/linux.ts` | Linux: xdotool/scrot |
|
||||
| `src/utils/computerUse/win32/windowMessage.ts` | **新增**: SendMessage 无焦点输入 |
|
||||
|
||||
## 5. 需要移除/清理的文件
|
||||
|
||||
| 文件 | 操作 | 原因 |
|
||||
|------|------|------|
|
||||
| `packages/@ant/computer-use-input/src/backends/win32.ts` | 删除 | Windows 代码不应在 macOS 包里 |
|
||||
| `packages/@ant/computer-use-input/src/backends/linux.ts` | 删除 | Linux 代码不应在 macOS 包里 |
|
||||
| `packages/@ant/computer-use-swift/src/backends/win32.ts` | 删除 | 同上 |
|
||||
| `packages/@ant/computer-use-swift/src/backends/linux.ts` | 删除 | 同上 |
|
||||
| `packages/@ant/computer-use-input/src/types.ts` | 删除 | 移到 platforms/types.ts |
|
||||
| `packages/@ant/computer-use-swift/src/types.ts` | 删除 | 移到 platforms/types.ts |
|
||||
|
||||
## 6. 需要修改的文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `packages/@ant/computer-use-input/src/index.ts` | 恢复为仅 darwin dispatcher(去掉 win32/linux case) |
|
||||
| `packages/@ant/computer-use-swift/src/index.ts` | 恢复为仅 darwin dispatcher(去掉 win32/linux case) |
|
||||
| `src/utils/computerUse/executor.ts` | 通过 `platforms/` 获取平台实现,不直接调 @ant 包 |
|
||||
| `src/utils/computerUse/swiftLoader.ts` | 仅 darwin 加载 |
|
||||
| `src/utils/computerUse/inputLoader.ts` | 仅 darwin 加载 |
|
||||
|
||||
## 7. @ant 包的定位(修正后)
|
||||
|
||||
| 包 | 职责 | 平台 |
|
||||
|---|------|------|
|
||||
| `@ant/computer-use-input` | macOS enigo 键鼠原生模块包装 | **仅 darwin** |
|
||||
| `@ant/computer-use-swift` | macOS Swift 截图/应用原生模块包装 | **仅 darwin** |
|
||||
| `@ant/computer-use-mcp` | MCP Server + 工具定义 + 调用路由 | **跨平台**(不含平台代码) |
|
||||
|
||||
Windows/Linux 的平台实现全部在 `src/utils/computerUse/platforms/` 和 `src/utils/computerUse/win32/` 中。
|
||||
|
||||
## 8. 执行顺序
|
||||
|
||||
```
|
||||
Phase 1: 创建 platforms/ 抽象层
|
||||
├── platforms/types.ts(公共接口)
|
||||
├── platforms/index.ts(分发器)
|
||||
└── platforms/darwin.ts(委托 @ant 包)
|
||||
|
||||
Phase 2: 创建 Windows 平台实现
|
||||
├── win32/windowMessage.ts(SendMessage 无焦点输入)
|
||||
└── platforms/win32.ts(组合 win32/ 各模块)
|
||||
|
||||
Phase 3: 创建 Linux 平台实现
|
||||
└── platforms/linux.ts(xdotool/scrot)
|
||||
|
||||
Phase 4: 改造 executor.ts
|
||||
└── 通过 platforms/ 获取实现,不直接调 @ant
|
||||
|
||||
Phase 5: 清理 @ant 包
|
||||
├── 删除 @ant/computer-use-input/src/backends/{win32,linux}.ts
|
||||
├── 删除 @ant/computer-use-swift/src/backends/{win32,linux}.ts
|
||||
└── 恢复 index.ts 为 darwin-only
|
||||
|
||||
Phase 6: 验证 + PR
|
||||
```
|
||||
277
docs/features/computer-use-mcp-test-report.md
Normal file
277
docs/features/computer-use-mcp-test-report.md
Normal file
@@ -0,0 +1,277 @@
|
||||
# Computer Use MCP 工具测试报告
|
||||
|
||||
> 测试日期: 2026-04-04
|
||||
> 测试环境: macOS Darwin 25.4.0, Cursor (IDE tier: click)
|
||||
> MCP Server: `@ant/computer-use-mcp`
|
||||
|
||||
## 工具总览
|
||||
|
||||
共 17 个工具(含 batch 复合操作),分为 5 大类:
|
||||
|
||||
| 类别 | 工具 | 数量 |
|
||||
|------|------|------|
|
||||
| 截图/显示 | `screenshot`, `switch_display`, `zoom` | 3 |
|
||||
| 鼠标操作 | `left_click`, `right_click`, `double_click`, `triple_click`, `middle_click`, `left_click_drag`, `mouse_move` | 7 |
|
||||
| 键盘操作 | `key`, `type`, `hold_key` | 3 |
|
||||
| 状态查询 | `cursor_position`, `request_access` | 2 |
|
||||
| 复合/辅助 | `computer_batch`, `wait` | 2 |
|
||||
|
||||
---
|
||||
|
||||
## 测试结果
|
||||
|
||||
### 1. 权限管理
|
||||
|
||||
#### `request_access` — 请求应用访问权限
|
||||
|
||||
| 项目 | 结果 |
|
||||
|------|------|
|
||||
| 状态 | ✅ 通过 |
|
||||
| 行为 | 弹出系统对话框请求用户授权,支持批量申请多个应用 |
|
||||
| 返回 | `{ granted: [...], denied: [...], tierGuidance: "..." }` |
|
||||
| 权限分级 | `click`(仅点击), `full`(完整控制) |
|
||||
| 说明 | IDE 类应用(Cursor、VSCode、Terminal)默认授予 `click` tier,限制键盘输入和右键操作;系统应用(System Settings)授予 `full` tier |
|
||||
|
||||
#### 已授权应用
|
||||
|
||||
| 应用 | Tier | 能力 |
|
||||
|------|------|------|
|
||||
| Cursor | click | 可见 + 纯左键点击(无键盘输入、右键、修饰键点击、拖拽) |
|
||||
| Terminal | click | 同上 |
|
||||
| System Settings | full | 完整控制(键鼠、拖拽等) |
|
||||
| Finder | — | 已授权 |
|
||||
|
||||
---
|
||||
|
||||
### 2. 截图与显示
|
||||
|
||||
#### `screenshot` — 截取屏幕截图
|
||||
|
||||
| 项目 | 结果 |
|
||||
|------|------|
|
||||
| 状态 | ⚠️ 部分通过 |
|
||||
| 执行 | 工具成功执行,返回 `ok: true` |
|
||||
| 图片 | **未返回可视图片内容**(output 为空字符串) |
|
||||
| `save_to_disk` | 设置后仍无输出 |
|
||||
| 分析 | 可能原因:(1) macOS 屏幕录制权限未授予;(2) 当前前台应用未被过滤导致截图为空;(3) MCP 传输层未正确编码图片数据 |
|
||||
| 建议 | 检查 **系统设置 → 隐私与安全性 → 屏幕录制** 是否授权给运行 Claude Code 的应用 |
|
||||
|
||||
#### `switch_display` — 切换显示器
|
||||
|
||||
| 项目 | 结果 |
|
||||
|------|------|
|
||||
| 状态 | ✅ 通过 |
|
||||
| 行为 | 接受显示器名称或 `"auto"`(自动选择) |
|
||||
| 返回 | 确认消息 |
|
||||
|
||||
#### `zoom` — 区域放大截图
|
||||
|
||||
| 项目 | 结果 |
|
||||
|------|------|
|
||||
| 状态 | ⏭️ 跳过 |
|
||||
| 原因 | 依赖 `screenshot` 返回的图片坐标,截图未返回图片无法测试 |
|
||||
|
||||
---
|
||||
|
||||
### 3. 鼠标操作
|
||||
|
||||
> 以下测试在 Cursor 窗口上执行(tier: click)
|
||||
|
||||
#### `mouse_move` — 移动鼠标
|
||||
|
||||
| 项目 | 结果 |
|
||||
|------|------|
|
||||
| 状态 | ✅ 通过 |
|
||||
| 输入 | `coordinate: [500, 500]` |
|
||||
| 返回 | `"Moved."` |
|
||||
|
||||
#### `left_click` — 左键单击
|
||||
|
||||
| 项目 | 结果 |
|
||||
|------|------|
|
||||
| 状态 | ✅ 通过 |
|
||||
| 输入 | `coordinate: [500, 500]` |
|
||||
| 返回 | `"Clicked."` |
|
||||
|
||||
#### `double_click` — 双击
|
||||
|
||||
| 项目 | 结果 |
|
||||
|------|------|
|
||||
| 状态 | ✅ 通过 |
|
||||
| 输入 | `coordinate: [500, 500]` |
|
||||
| 返回 | `"Clicked."` |
|
||||
|
||||
#### `triple_click` — 三击
|
||||
|
||||
| 项目 | 结果 |
|
||||
|------|------|
|
||||
| 状态 | ✅ 通过 |
|
||||
| 输入 | `coordinate: [500, 500]` |
|
||||
| 返回 | `"Clicked."` |
|
||||
|
||||
#### `right_click` — 右键点击
|
||||
|
||||
| 项目 | 结果 |
|
||||
|------|------|
|
||||
| 状态 | ⚠️ 受 tier 限制 |
|
||||
| Cursor (click tier) | ❌ 被拒绝 — `"Code" is granted at tier "click" — right-click, middle-click, and clicks with modifier keys require tier "full"` |
|
||||
| Finder (full tier) | ✅ 通过 — 返回 `"Clicked."` |
|
||||
| 结论 | 功能正常,IDE 安全限制符合预期 |
|
||||
|
||||
#### `middle_click` — 中键点击
|
||||
|
||||
| 项目 | 结果 |
|
||||
|------|------|
|
||||
| 状态 | ⚠️ 受 tier 限制 |
|
||||
| Cursor (click tier) | ❌ 被拒绝 — 同 `right_click`,需要 full tier |
|
||||
| Finder (full tier) | ✅ 通过 — 返回 `"Clicked."` |
|
||||
| 结论 | 功能正常,IDE 安全限制符合预期 |
|
||||
|
||||
#### `left_click_drag` — 拖拽
|
||||
|
||||
| 项目 | 结果 |
|
||||
|------|------|
|
||||
| 状态 | ⚠️ 受 tier 限制 |
|
||||
| Cursor (click tier) | ❌ 被拒绝 — 拖拽被视为修饰键点击,需要 full tier |
|
||||
| Finder (full tier) | ✅ 通过 — 返回 `"Dragged."` |
|
||||
| 结论 | 功能正常,IDE 安全限制符合预期 |
|
||||
|
||||
#### `scroll` — 滚轮滚动
|
||||
|
||||
| 项目 | 结果 |
|
||||
|------|------|
|
||||
| 状态 | ✅ 通过 |
|
||||
| 输入 | `coordinate: [500, 500]`, `scroll_direction: "down"`, `scroll_amount: 3` |
|
||||
| 返回 | `"Scrolled."` |
|
||||
| 反向 | ✅ `scroll_direction: "up"` 也通过 |
|
||||
|
||||
---
|
||||
|
||||
### 4. 键盘操作
|
||||
|
||||
> 以下测试在 Cursor 窗口上执行(tier: click)— 所有键盘操作均被拒绝
|
||||
|
||||
#### `key` — 按键/快捷键
|
||||
|
||||
| 项目 | 结果 |
|
||||
|------|------|
|
||||
| 状态 | ⚠️ 受 tier 限制 |
|
||||
| Cursor (click tier) | ❌ 被拒绝 — IDE tier 限制键盘输入 |
|
||||
| Finder (full tier) | ✅ 通过 — `escape` 按键成功,返回 `"Key pressed."` |
|
||||
| 结论 | 功能正常,IDE 安全限制符合预期 |
|
||||
|
||||
#### `type` — 输入文本
|
||||
|
||||
| 项目 | 结果 |
|
||||
|------|------|
|
||||
| 状态 | ⚠️ 受 tier 限制 |
|
||||
| Cursor (click tier) | ❌ 被拒绝 — IDE tier 限制文本输入 |
|
||||
| Finder (full tier) | ✅ 通过 — 输入 `"hello"` 成功,返回 `"Typed 5 grapheme(s)."` |
|
||||
| 结论 | 功能正常,IDE 安全限制符合预期 |
|
||||
|
||||
#### `hold_key` — 按住按键
|
||||
|
||||
| 项目 | 结果 |
|
||||
|------|------|
|
||||
| 状态 | ⚠️ 受 tier 限制 |
|
||||
| Cursor (click tier) | ❌ 被拒绝 — IDE tier 限制键盘输入 |
|
||||
| Finder (full tier) | ✅ 通过 — 按住 `shift` 1 秒成功,返回 `"Key held."` |
|
||||
| 结论 | 功能正常,IDE 安全限制符合预期 |
|
||||
|
||||
---
|
||||
|
||||
### 5. 状态查询
|
||||
|
||||
#### `cursor_position` — 获取鼠标位置
|
||||
|
||||
| 项目 | 结果 |
|
||||
|------|------|
|
||||
| 状态 | ✅ 通过 |
|
||||
| 返回 | `{"x": null, "y": null, "coordinateSpace": "image_pixels"}` |
|
||||
| 说明 | 坐标为 null 是因为没有成功截图,无参考坐标系 |
|
||||
|
||||
---
|
||||
|
||||
### 6. 复合/辅助操作
|
||||
|
||||
#### `computer_batch` — 批量执行操作
|
||||
|
||||
| 项目 | 结果 |
|
||||
|------|------|
|
||||
| 状态 | ✅ 通过 |
|
||||
| 行为 | 按顺序执行操作列表,遇到失败则停止后续操作 |
|
||||
| 返回 | `{ completed: [...], failed: {...}, remaining: N }` |
|
||||
| 特点 | 单次 API 调用执行多个操作,减少往返延迟 |
|
||||
| 错误处理 | 失败的操作会中断后续操作,返回已完成和剩余数量 |
|
||||
|
||||
#### `wait` — 等待
|
||||
|
||||
| 项目 | 结果 |
|
||||
|------|------|
|
||||
| 状态 | ✅ 通过 |
|
||||
| 输入 | `duration: 1` (秒) |
|
||||
| 返回 | `"Waited 1s."` |
|
||||
| 最大值 | 100 秒 |
|
||||
|
||||
---
|
||||
|
||||
## 汇总统计
|
||||
|
||||
| 状态 | 数量 | 工具 |
|
||||
|------|------|------|
|
||||
| ✅ 通过 | 10 | `request_access`, `switch_display`, `mouse_move`, `left_click`, `double_click`, `triple_click`, `scroll`, `cursor_position`, `computer_batch`, `wait` |
|
||||
| ⚠️ 部分通过 | 7 | `screenshot`(执行成功但无图片返回), `right_click`, `middle_click`, `left_click_drag`, `key`, `type`, `hold_key`(均在 full tier 应用上通过,IDE click tier 限制是预期行为) |
|
||||
| ❌ 被拒绝 | 0 | — |
|
||||
| ⏭️ 跳过 | 1 | `zoom`(依赖截图) |
|
||||
|
||||
---
|
||||
|
||||
## 已知问题
|
||||
|
||||
### P0: 截图无图片返回
|
||||
|
||||
`screenshot` 工具执行成功但未返回图片内容,导致:
|
||||
- 无法获取屏幕坐标参考
|
||||
- `cursor_position` 返回 null 坐标
|
||||
- `zoom` 无法使用
|
||||
- 所有点击操作只能盲点(无截图验证)
|
||||
|
||||
**可能原因**:
|
||||
1. macOS 屏幕录制权限未授予
|
||||
2. MCP 图片传输/编码问题
|
||||
3. 截图内容被安全过滤机制过滤
|
||||
|
||||
**建议排查**: 检查 `系统设置 → 隐私与安全性 → 屏幕录制` 权限。
|
||||
|
||||
### P1: IDE 应用键盘操作受限 — ✅ 已确认功能正常
|
||||
|
||||
IDE 类应用(Cursor、VSCode、Terminal)被限制在 `click` tier,无法执行:
|
||||
- 键盘输入(`key`, `type`, `hold_key`)
|
||||
- 右键/中键点击(`right_click`, `middle_click`)
|
||||
- 拖拽操作(`left_click_drag`)
|
||||
|
||||
这是安全设计,防止 AI 操控 IDE 终端。**在 full tier 应用(Finder、System Settings)上,以上 6 个操作均测试通过,功能完全正常。**
|
||||
|
||||
---
|
||||
|
||||
## 权限模型说明
|
||||
|
||||
Computer Use MCP 采用分级权限模型:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Tier: full │
|
||||
│ - 所有鼠标操作(左键、右键、中键、拖拽) │
|
||||
│ - 键盘输入(type, key, hold_key) │
|
||||
│ - 适用于: 系统应用、Finder 等 │
|
||||
├─────────────────────────────────────────┤
|
||||
│ Tier: click │
|
||||
│ - 仅纯左键点击 │
|
||||
│ - 滚轮滚动 │
|
||||
│ - 适用于: IDE、Terminal 等 │
|
||||
├─────────────────────────────────────────┤
|
||||
│ 未授权 │
|
||||
│ - 所有操作被拒绝 │
|
||||
│ - 需通过 request_access 申请 │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
496
docs/features/computer-use-tools-reference.md
Normal file
496
docs/features/computer-use-tools-reference.md
Normal file
@@ -0,0 +1,496 @@
|
||||
# Computer Use 工具参考文档
|
||||
|
||||
## 概览
|
||||
|
||||
Computer Use 提供 37 个工具,分为三类:
|
||||
|
||||
| 分类 | 平台 | 工具数 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| 通用工具 | 全平台 | 24 | 官方 Computer Use 标准能力 |
|
||||
| Windows 专属工具 | Win32 | 10 | 绑定窗口模式下的增强能力 |
|
||||
| 教学工具 | 全平台 | 3 | 分步引导模式(需 teachMode 开启) |
|
||||
|
||||
---
|
||||
|
||||
## 一、通用工具(24 个)
|
||||
|
||||
全平台可用。未绑定窗口时,操作对象是整个屏幕。
|
||||
|
||||
### 权限与会话
|
||||
|
||||
| 工具 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `request_access` | `apps[]`, `reason`, `clipboardRead?`, `clipboardWrite?`, `systemKeyCombos?` | 请求操作应用的权限。所有其他工具的前置条件 |
|
||||
| `list_granted_applications` | — | 列出当前会话已授权的应用 |
|
||||
|
||||
### 截图与显示
|
||||
|
||||
| 工具 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `screenshot` | `save_to_disk?` | 截取当前屏幕。绑定窗口时截取绑定窗口(PrintWindow)。返回图片 + GUI 元素列表(Windows) |
|
||||
| `zoom` | `region: [x1,y1,x2,y2]` | 截取指定区域的高分辨率图片。坐标基于最近一次全屏截图 |
|
||||
| `switch_display` | `display` | 切换截图的目标显示器 |
|
||||
|
||||
### 鼠标操作
|
||||
|
||||
| 工具 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `left_click` | `coordinate: [x,y]`, `text?` (修饰键) | 左键点击。`text` 可传 "shift"/"ctrl"/"alt" 实现组合点击 |
|
||||
| `double_click` | `coordinate`, `text?` | 双击 |
|
||||
| `triple_click` | `coordinate`, `text?` | 三击(选整行) |
|
||||
| `right_click` | `coordinate`, `text?` | 右键点击 |
|
||||
| `middle_click` | `coordinate`, `text?` | 中键点击 |
|
||||
| `mouse_move` | `coordinate` | 移动鼠标(不点击) |
|
||||
| `left_click_drag` | `coordinate` (终点), `start_coordinate?` (起点) | 拖拽 |
|
||||
| `left_mouse_down` | — | 按下左键不松 |
|
||||
| `left_mouse_up` | — | 松开左键 |
|
||||
| `cursor_position` | — | 获取当前鼠标位置 |
|
||||
|
||||
### 键盘操作
|
||||
|
||||
| 工具 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `type` | `text` | 输入文字 |
|
||||
| `key` | `text` (如 "ctrl+s"), `repeat?` | 按键/组合键 |
|
||||
| `hold_key` | `text`, `duration` (秒) | 按住键指定时长 |
|
||||
|
||||
### 滚动
|
||||
|
||||
| 工具 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `scroll` | `coordinate`, `scroll_direction`, `scroll_amount` | 滚动。方向: up/down/left/right |
|
||||
|
||||
### 应用管理
|
||||
|
||||
| 工具 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `open_application` | `app` | 打开应用。Windows 上自动绑定窗口 |
|
||||
|
||||
### 剪贴板
|
||||
|
||||
| 工具 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `read_clipboard` | — | 读取剪贴板文字 |
|
||||
| `write_clipboard` | `text` | 写入剪贴板 |
|
||||
|
||||
### 其他
|
||||
|
||||
| 工具 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `wait` | `duration` (秒) | 等待 |
|
||||
| `computer_batch` | `actions[]` | 批量执行多个动作(减少 API 往返) |
|
||||
|
||||
---
|
||||
|
||||
## 二、Windows 专属工具(10 个)
|
||||
|
||||
仅 Windows 平台可见。核心能力:**绑定窗口后的独立操作——不抢占用户鼠标键盘**。
|
||||
|
||||
### 工作模式
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ 未绑定模式 │
|
||||
│ 使用通用工具 (left_click/type/key/scroll) │
|
||||
│ 操作对象:整个屏幕 │
|
||||
│ 输入方式:全局 SendInput(会移动真实鼠标) │
|
||||
└──────────────────────────────────────────────────┘
|
||||
│
|
||||
bind_window / open_application
|
||||
▼
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ 绑定窗口模式 │
|
||||
│ 使用 Win32 工具 (virtual_mouse/virtual_keyboard) │
|
||||
│ 操作对象:绑定的窗口 │
|
||||
│ 输入方式:SendMessageW(不动真实鼠标/键盘) │
|
||||
│ 可视化:DWM 绿色边框 + 虚拟光标 + 状态指示器 │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 窗口绑定
|
||||
|
||||
| 工具 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `bind_window` | `action`: list/bind/unbind/status | 窗口绑定管理 |
|
||||
|
||||
**动作详情:**
|
||||
|
||||
| action | 参数 | 说明 |
|
||||
|--------|------|------|
|
||||
| `list` | — | 列出所有可见窗口(hwnd、pid、title) |
|
||||
| `bind` | `title?`, `hwnd?`, `pid?` | 绑定到指定窗口。设置 DWM 绿色边框 + 启动虚拟光标 + 启动状态指示器 + 短暂激活窗口确保可接收输入 |
|
||||
| `unbind` | — | 解除绑定,恢复全屏模式 |
|
||||
| `status` | — | 查看当前绑定状态(hwnd、title、pid、窗口矩形) |
|
||||
|
||||
### 窗口管理
|
||||
|
||||
| 工具 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `window_management` | `action`, `x?`, `y?`, `width?`, `height?` | 窗口操作(Win32 API,不走全局快捷键) |
|
||||
|
||||
**动作详情:**
|
||||
|
||||
| action | 说明 |
|
||||
|--------|------|
|
||||
| `minimize` | ShowWindow(SW_MINIMIZE) |
|
||||
| `maximize` | ShowWindow(SW_MAXIMIZE) |
|
||||
| `restore` | ShowWindow(SW_RESTORE) — 恢复最小化/最大化 |
|
||||
| `close` | SendMessage(WM_CLOSE) — 优雅关闭 |
|
||||
| `focus` | SetForegroundWindow + BringWindowToTop — 激活窗口 |
|
||||
| `move_offscreen` | SetWindowPos(-32000,-32000) — 移到屏幕外(仍可 SendMessage/PrintWindow) |
|
||||
| `move_resize` | SetWindowPos — 移动/缩放到指定位置和大小 |
|
||||
| `get_rect` | GetWindowRect — 获取当前位置和大小 |
|
||||
|
||||
### 虚拟鼠标
|
||||
|
||||
| 工具 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `virtual_mouse` | `action`, `coordinate: [x,y]`, `start_coordinate?` | 在绑定窗口内操作虚拟鼠标 |
|
||||
|
||||
**动作详情:**
|
||||
|
||||
| action | 说明 |
|
||||
|--------|------|
|
||||
| `click` | 左键点击。虚拟光标移动到坐标 + 闪烁动画 |
|
||||
| `double_click` | 双击 |
|
||||
| `right_click` | 右键点击 |
|
||||
| `move` | 移动虚拟光标(不点击) |
|
||||
| `drag` | 按住 → 移动 → 松开。需 `start_coordinate` 指定起点 |
|
||||
| `down` | 按下左键不松 |
|
||||
| `up` | 松开左键 |
|
||||
|
||||
**与通用鼠标工具的区别:**
|
||||
|
||||
| | 通用 (`left_click` 等) | `virtual_mouse` |
|
||||
|---|---|---|
|
||||
| 输入方式 | SendInput(全局) | SendMessageW(窗口级) |
|
||||
| 真实鼠标 | 会移动 | **不动** |
|
||||
| 用户干扰 | 有 | **无** |
|
||||
| 适用场景 | 未绑定时 | **绑定后** |
|
||||
|
||||
### 虚拟键盘
|
||||
|
||||
| 工具 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `virtual_keyboard` | `action`, `text`, `duration?`, `repeat?` | 在绑定窗口内操作虚拟键盘 |
|
||||
|
||||
**动作详情:**
|
||||
|
||||
| action | text 含义 | 说明 |
|
||||
|--------|----------|------|
|
||||
| `type` | 要输入的文字 | SendMessageW(WM_CHAR),支持 Unicode 中文/emoji |
|
||||
| `combo` | 组合键 (如 "ctrl+s") | WM_KEYDOWN/UP 序列 |
|
||||
| `press` | 单个键名 | 按下不松(配合 release 使用) |
|
||||
| `release` | 单个键名 | 松开按键 |
|
||||
| `hold` | 键名或组合 | 按住指定秒数后松开 |
|
||||
|
||||
**与通用键盘工具的区别:**
|
||||
|
||||
| | 通用 (`type`/`key`) | `virtual_keyboard` |
|
||||
|---|---|---|
|
||||
| 输入方式 | SendInput(全局) | SendMessageW(窗口级) |
|
||||
| 物理键盘 | 会冲突 | **不冲突** |
|
||||
| 适用场景 | 未绑定时 | **绑定后** |
|
||||
|
||||
**注意:** SendMessageW 对 Windows Terminal (ConPTY) 等现代应用无效。这些应用需要使用通用工具 + 窗口激活方式操作。
|
||||
|
||||
### 鼠标滚轮
|
||||
|
||||
| 工具 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `mouse_wheel` | `coordinate: [x,y]`, `delta`, `direction?` | WM_MOUSEWHEEL 鼠标中键滚轮 |
|
||||
|
||||
**参数说明:**
|
||||
- `delta`: 正值=向上,负值=向下。每 1 单位 ≈ 3 行
|
||||
- `direction`: "vertical"(默认)或 "horizontal"
|
||||
- `coordinate`: 滚轮作用点——决定哪个面板/区域接收滚动
|
||||
|
||||
**与通用 `scroll` 的区别:**
|
||||
|
||||
| | `scroll` | `mouse_wheel` |
|
||||
|---|---|---|
|
||||
| 原理 | WM_VSCROLL/WM_HSCROLL | **WM_MOUSEWHEEL** |
|
||||
| Excel | ❌ | ✅ |
|
||||
| 浏览器 | ❌ | ✅ |
|
||||
| 代码编辑器 | ❌ | ✅ |
|
||||
|
||||
### 元素级操作
|
||||
|
||||
| 工具 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `click_element` | `name?`, `role?`, `automationId?` | 按无障碍名称/角色点击 GUI 元素 |
|
||||
| `type_into_element` | `name?`, `role?`, `automationId?`, `text` | 按名称向元素输入文字 |
|
||||
|
||||
**工作原理:**
|
||||
1. 通过 UI Automation 在绑定窗口中查找匹配元素
|
||||
2. `click_element`: 先尝试 InvokePattern(按钮/菜单),失败则 SendMessage 点击 BoundingRect 中心
|
||||
3. `type_into_element`: 先尝试 ValuePattern 直接设值,失败则点击聚焦 + WM_CHAR 输入
|
||||
|
||||
**适用场景:**
|
||||
- 截图中看到元素名称但坐标不精确时
|
||||
- Accessibility Snapshot 列出了元素的 name/automationId 时
|
||||
- 比坐标点击更可靠(不受窗口缩放/DPI 影响)
|
||||
|
||||
### 终端交互
|
||||
|
||||
| 工具 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `prompt_respond` | `response_type`, `arrow_direction?`, `arrow_count?`, `text?` | 处理终端 Yes/No/选择提示 |
|
||||
|
||||
**response_type 详情:**
|
||||
|
||||
| response_type | 操作 | 场景 |
|
||||
|---------------|------|------|
|
||||
| `yes` | 发送 'y' + Enter | npm "Continue? (y/n)" |
|
||||
| `no` | 发送 'n' + Enter | 拒绝确认 |
|
||||
| `enter` | 发送 Enter | 接受默认选项 |
|
||||
| `escape` | 发送 Escape | 取消操作 |
|
||||
| `select` | ↑/↓ 箭头 × N + Enter | inquirer 选择菜单 |
|
||||
| `type` | 输入文字 + Enter | 文本输入提示 |
|
||||
|
||||
### 状态指示器
|
||||
|
||||
| 工具 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `status_indicator` | `action`: show/hide/status, `message?` | 控制绑定窗口底部的浮动状态标签 |
|
||||
|
||||
---
|
||||
|
||||
## 三、教学工具(3 个)
|
||||
|
||||
需要 `teachMode` 开启。
|
||||
|
||||
| 工具 | 说明 |
|
||||
|------|------|
|
||||
| `request_teach_access` | 请求教学引导模式权限 |
|
||||
| `teach_step` | 显示一步引导提示,等用户点 Next |
|
||||
| `teach_batch` | 批量排队多步引导 |
|
||||
|
||||
---
|
||||
|
||||
## 操作流程
|
||||
|
||||
### 流程 1:全屏操作(未绑定)
|
||||
|
||||
```
|
||||
request_access(apps=["Notepad"])
|
||||
open_application(app="Notepad") ← 自动绑定窗口
|
||||
screenshot ← PrintWindow 截图 + GUI 元素列表
|
||||
left_click(coordinate=[500, 300]) ← 全局 SendInput
|
||||
type(text="hello world") ← 全局 SendInput
|
||||
key(text="ctrl+s") ← 全局 SendInput
|
||||
```
|
||||
|
||||
### 流程 2:绑定窗口操作(推荐,不干扰用户)
|
||||
|
||||
```
|
||||
request_access(apps=["Notepad"])
|
||||
bind_window(action="list") ← 列出所有窗口
|
||||
bind_window(action="bind", title="记事本") ← 绑定 + 绿色边框 + 虚拟光标
|
||||
screenshot ← PrintWindow 截取绑定窗口
|
||||
virtual_mouse(action="click", coordinate=[500, 300]) ← SendMessageW,不动真实鼠标
|
||||
virtual_keyboard(action="type", text="hello world") ← SendMessageW,不动物理键盘
|
||||
virtual_keyboard(action="combo", text="ctrl+s") ← 保存
|
||||
mouse_wheel(coordinate=[500, 400], delta=-5) ← 向下滚动
|
||||
bind_window(action="unbind") ← 解除绑定
|
||||
```
|
||||
|
||||
### 流程 3:按元素名称操作
|
||||
|
||||
```
|
||||
bind_window(action="bind", title="记事本")
|
||||
screenshot ← 返回截图 + GUI elements 列表
|
||||
click_element(name="保存", role="Button") ← UI Automation 查找并点击
|
||||
type_into_element(role="Edit", text="new content")
|
||||
```
|
||||
|
||||
### 流程 4:终端交互
|
||||
|
||||
```
|
||||
bind_window(action="bind", title="PowerShell")
|
||||
screenshot
|
||||
prompt_respond(response_type="yes") ← 回答 y + Enter
|
||||
prompt_respond(response_type="select", arrow_direction="down", arrow_count=2) ← 选第3项
|
||||
```
|
||||
|
||||
### 流程 5:Excel/浏览器滚动
|
||||
|
||||
```
|
||||
bind_window(action="bind", title="Excel")
|
||||
screenshot
|
||||
mouse_wheel(coordinate=[600, 400], delta=-10) ← 向下滚动 10 格
|
||||
mouse_wheel(coordinate=[600, 400], delta=5, direction="horizontal") ← 向右滚动
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 应用兼容性
|
||||
|
||||
| 应用类型 | SendMessageW (virtual_*) | 元素操作 (click_element) | 注意 |
|
||||
|---------|--------------------------|------------------------|------|
|
||||
| 传统 Win32 (记事本/写字板) | ✅ | ✅ | 完美支持 |
|
||||
| Office (Excel/Word) | ✅ (COM 自动化) | ✅ | 通过 COM API |
|
||||
| WPF 应用 | ✅ | ✅ | 标准 UIA 支持 |
|
||||
| Electron/Chrome | ⚠️ 部分 | ⚠️ 部分 | 内部渲染不走 Win32 消息 |
|
||||
| UWP/WinUI (Windows Terminal) | ❌ | ❌ | ConPTY 不接受 SendMessageW |
|
||||
| 浏览器网页内容 | ❌ | ❌ | 需要全局 SendInput |
|
||||
|
||||
**对于不支持 SendMessageW 的应用**,使用通用工具 (`left_click`/`type`/`key`) + `window_management(action="focus")` 先激活窗口。
|
||||
|
||||
---
|
||||
|
||||
## 绑定窗口时的可视化
|
||||
|
||||
绑定窗口后自动启动三层可视化:
|
||||
|
||||
1. **DWM 绿色边框** — 窗口自身的边框颜色变绿,零偏移
|
||||
2. **虚拟鼠标光标** — 红色箭头图标,跟随 virtual_mouse 操作移动,点击时闪烁
|
||||
3. **状态指示器** — 窗口底部浮动标签,显示当前操作(通过 status_indicator 控制)
|
||||
|
||||
---
|
||||
|
||||
## Accessibility Snapshot
|
||||
|
||||
每次 `screenshot` 时,如果窗口已绑定,会自动附带 GUI 元素列表:
|
||||
|
||||
```
|
||||
GUI elements in this window:
|
||||
[Button] "Save" (120,50 80x30) enabled
|
||||
[Edit] "" (200,80 400x25) enabled value="hello" id=textBox1
|
||||
[MenuItem] "File" (10,0 40x25) enabled
|
||||
[MenuItem] "Edit" (50,0 40x25) enabled
|
||||
[CheckBox] "Auto-save" (300,50 100x20) enabled id=chkAutoSave
|
||||
```
|
||||
|
||||
模型同时收到 **截图图片 + 结构化元素列表**,可以选择:
|
||||
- 用坐标操作:`virtual_mouse(action="click", coordinate=[120, 50])`
|
||||
- 用名称操作:`click_element(name="Save")`
|
||||
|
||||
---
|
||||
|
||||
## UI Automation Control Patterns 参考
|
||||
|
||||
`click_element` / `type_into_element` 底层使用 UI Automation Control Patterns。当前已实现的和可扩展的:
|
||||
|
||||
| Pattern | 用途 | 当前状态 | 可用于 |
|
||||
|---------|------|---------|--------|
|
||||
| `InvokePattern` | 触发点击 | ✅ 已实现 (`click_element`) | 按钮、菜单项、链接 |
|
||||
| `ValuePattern` | 读写文本值 | ✅ 已实现 (`type_into_element`) | 文本框、组合框 |
|
||||
| `TogglePattern` | 切换状态 | ❌ 未实现 | 复选框、开关 |
|
||||
| `SelectionPattern` | 选择项目 | ❌ 未实现 | 下拉菜单、列表 |
|
||||
| `ScrollPattern` | 编程滚动 | ❌ 未实现(用 `mouse_wheel` 替代) | 列表、树、面板 |
|
||||
| `ExpandCollapsePattern` | 展开/折叠 | ❌ 未实现 | 树节点、折叠面板 |
|
||||
| `WindowPattern` | 窗口操作 | ❌ 未实现(用 `window_management` 替代) | 窗口最大化/关闭 |
|
||||
| `TextPattern` | 读取文档文本 | ❌ 未实现 | 文档、富文本 |
|
||||
| `GridPattern` | 表格操作 | ❌ 未实现 | Excel 单元格、数据网格 |
|
||||
| `TablePattern` | 表格结构 | ❌ 未实现 | 表头、行列关系 |
|
||||
| `RangeValuePattern` | 范围值操作 | ❌ 未实现 | 滑块、进度条 |
|
||||
| `TransformPattern` | 移动/缩放 | ❌ 未实现 | 可拖拽元素 |
|
||||
|
||||
**扩展路线:** 优先实现 `TogglePattern`(复选框)和 `SelectionPattern`(下拉菜单),这两个在表单自动化中最常用。
|
||||
|
||||
---
|
||||
|
||||
## 屏幕截取技术方案对比
|
||||
|
||||
当前使用 Python Bridge (mss) 进行截图,底层是 GDI BitBlt。三种方案对比:
|
||||
|
||||
| 方案 | API | 当前状态 | 性能 | 优势 | 限制 |
|
||||
|------|-----|---------|------|------|------|
|
||||
| **GDI BitBlt** | `BitBlt` / `PrintWindow` | ✅ 当前使用 (mss/bridge.py) | ~300ms | 简单稳定,支持后台窗口 (PrintWindow) | 不支持硬件加速内容、DPI 处理复杂 |
|
||||
| **DXGI Desktop Duplication** | `IDXGIOutputDuplication` | ❌ 未实现 | ~16ms (60fps) | 硬件加速,支持 HDR,GPU 直接读取 | 不支持单窗口截取,需 D3D11 |
|
||||
| **Windows.Graphics.Capture** | `GraphicsCaptureItem` | ❌ 未实现 | ~16ms | 最新 API,支持单窗口/单显示器,系统级权限管理 | Win10 1903+,首次需用户确认 |
|
||||
|
||||
### 推荐升级路径
|
||||
|
||||
```
|
||||
当前: GDI BitBlt (mss) ─── 全屏 ~300ms, 窗口 ~300ms (PrintWindow)
|
||||
│
|
||||
├─ 近期: DXGI Desktop Duplication ─── 全屏 ~16ms, 但不支持单窗口
|
||||
│
|
||||
└─ 远期: Windows.Graphics.Capture ─── 全屏 + 单窗口都 ~16ms
|
||||
```
|
||||
|
||||
### DXGI Desktop Duplication 实现要点
|
||||
|
||||
```python
|
||||
# bridge.py 中可添加 DXGI 截图(通过 d3dshot 或 dxcam 库)
|
||||
import dxcam # pip install dxcam
|
||||
|
||||
camera = dxcam.create()
|
||||
frame = camera.grab() # numpy array, ~5ms
|
||||
# 转为 JPEG base64 发送
|
||||
```
|
||||
|
||||
### Windows.Graphics.Capture 实现要点
|
||||
|
||||
```python
|
||||
# 需要 WinRT Python 绑定
|
||||
# pip install winrt-Windows.Graphics.Capture winrt-Windows.Graphics.DirectX
|
||||
# 限制:首次调用需要用户在系统弹窗中确认权限
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 输入方式技术矩阵
|
||||
|
||||
不同应用类型需要不同的输入方式:
|
||||
|
||||
| 输入方式 | API | 优势 | 限制 | 适用应用 |
|
||||
|---------|-----|------|------|---------|
|
||||
| **SendMessageW** | `WM_CHAR` / `WM_KEYDOWN` | 不抢焦点,不动真实键鼠 | 现代应用不支持 | Win32 传统应用 (记事本/Office/WPF) |
|
||||
| **SendInput** | `INPUT` 结构体 | 所有应用都支持 | **必须前台焦点**,会干扰用户 | 所有应用(通用后备) |
|
||||
| **WriteConsoleInput** | 控制台 API | 直接写入控制台缓冲区 | 需要 AttachConsole(可能被拒绝) | cmd/PowerShell(非 Windows Terminal) |
|
||||
| **UI Automation** | `InvokePattern` / `ValuePattern` | 语义级操作,最可靠 | 部分应用不暴露 UIA 接口 | 支持 UIA 的应用 |
|
||||
| **COM Automation** | Excel/Word COM | 完全编程控制 | 仅 Office 应用 | Excel / Word |
|
||||
| **剪贴板 + 粘贴** | `SetClipboardData` + `Ctrl+V` | 绕过输入限制 | 会覆盖用户剪贴板 | 通用后备 |
|
||||
|
||||
### 按应用类型的推荐输入策略
|
||||
|
||||
| 应用类型 | 首选 | 后备 | 说明 |
|
||||
|---------|------|------|------|
|
||||
| 传统 Win32 (记事本/写字板) | SendMessageW | UIA ValuePattern | 虚拟输入完美工作 |
|
||||
| Office (Excel/Word) | COM Automation | SendMessageW | COM 提供结构化操作 |
|
||||
| WPF 应用 | SendMessageW | UIA | 标准 Win32 消息循环 |
|
||||
| Electron/Chrome 应用 | UIA | 剪贴板粘贴 | 内部渲染不走 Win32 |
|
||||
| Windows Terminal (ConPTY) | SendInput (需前台) | 剪贴板粘贴 | ConPTY 不接受外部消息 |
|
||||
| UWP/WinUI 应用 | SendInput (需前台) | UIA | XAML 渲染不走 Win32 消息 |
|
||||
|
||||
---
|
||||
|
||||
## 已知限制与待解决
|
||||
|
||||
| 限制 | 影响 | 计划 |
|
||||
|------|------|------|
|
||||
| Windows Terminal 不接受 SendMessageW | 虚拟键盘/鼠标对终端无效 | 自动检测应用类型,终端类切换到 SendInput + 短暂激活 |
|
||||
| PrintWindow 截不到 alternate screen buffer | Ink REPL 画面截不到 | 切换到 Windows.Graphics.Capture |
|
||||
| Accessibility Snapshot 对大应用慢 (>30s) | Excel 等复杂应用超时 | 限制遍历深度 + 超时保护 |
|
||||
| DWM 边框对自定义标题栏应用可能无效 | 某些 Electron 应用看不到边框 | 检测并回退到叠加窗口方案 |
|
||||
| 虚拟光标是 PowerShell WinForms 进程 | 启动慢 (~1s),资源占用 | 考虑用 Win32 原生窗口替代 |
|
||||
|
||||
---
|
||||
|
||||
## 技术路线图
|
||||
|
||||
### Phase 1(当前)— 基础功能
|
||||
- ✅ SendMessageW 虚拟输入
|
||||
- ✅ PrintWindow/mss 截图
|
||||
- ✅ UI Automation (InvokePattern + ValuePattern)
|
||||
- ✅ Accessibility Snapshot
|
||||
- ✅ DWM 边框指示
|
||||
- ✅ Python Bridge
|
||||
|
||||
### Phase 2(近期)— 兼容性增强
|
||||
- ⬜ 应用类型自动检测(Win32 vs Terminal vs UWP)
|
||||
- ⬜ 终端类应用自动切换 SendInput + 短暂激活
|
||||
- ⬜ TogglePattern / SelectionPattern 支持
|
||||
- ⬜ DXGI Desktop Duplication 高速截图
|
||||
- ⬜ Accessibility Snapshot 超时保护
|
||||
|
||||
### Phase 3(远期)— 高级能力
|
||||
- ⬜ Windows.Graphics.Capture(单窗口实时截图)
|
||||
- ⬜ 截图元素标注(在截图上标记 ID 数字)
|
||||
- ⬜ 浏览器 DOM 提取(绑定浏览器时提取网页结构)
|
||||
- ⬜ GridPattern / TablePattern(Excel 单元格级操作)
|
||||
- ⬜ TextPattern(文档内容读取)
|
||||
- ⬜ 多窗口协同操作
|
||||
315
docs/features/computer-use-windows-enhancement.md
Normal file
315
docs/features/computer-use-windows-enhancement.md
Normal file
@@ -0,0 +1,315 @@
|
||||
# Computer Use Windows 增强实施计划
|
||||
|
||||
更新时间:2026-04-03
|
||||
依赖文档:`docs/features/windows-ai-desktop-control.md`、`docs/features/computer-use.md`
|
||||
|
||||
## 1. 目标
|
||||
|
||||
在已有的 PowerShell 子进程方案基础上,利用 Windows 原生 API 增强 Computer Use 的 Windows 实现,解决 3 个核心问题:
|
||||
|
||||
1. **窗口绑定截图**:当前 `CopyFromScreen` 只能全屏截图,无法对指定窗口截图(尤其是被遮挡/最小化窗口)
|
||||
2. **UI 结构感知**:当前只能通过坐标点击,无法像 macOS Accessibility 那样理解 UI 元素树
|
||||
3. **性能**:每次 PowerShell 启动约 273ms,剪贴板/窗口枚举等高频操作需要更快的方式
|
||||
|
||||
## 2. 已验证的 Windows API 能力
|
||||
|
||||
以下 API 全部通过 PowerShell P/Invoke 实测通过:
|
||||
|
||||
| 能力 | API | 验证结果 |
|
||||
|------|-----|---------|
|
||||
| 窗口绑定截图 | `PrintWindow(hwnd, hdc, PW_RENDERFULLCONTENT)` | ✅ VS Code 342KB, Chrome 273KB |
|
||||
| 枚举窗口+HWND | `EnumWindows` + `GetWindowText` + `GetWindowThreadProcessId` | ✅ 38 个窗口,含 HWND/PID/标题 |
|
||||
| UI 元素树 | `System.Windows.Automation.AutomationElement` | ✅ 记事本 39 个元素 |
|
||||
| UI 写值 | `ValuePattern.SetValue()` | ✅ 成功写入记事本文本 |
|
||||
| UI 点击 | `InvokePattern.Invoke()` | ✅ 按钮可程序化点击 |
|
||||
| 坐标元素识别 | `AutomationElement.FromPoint(x, y)` | ✅ 返回元素类型+名称 |
|
||||
| OCR | `Windows.Media.Ocr.OcrEngine` | ✅ 英语+中文引擎可用 |
|
||||
| 全局热键 | `RegisterHotKey` | ✅ API 可调 |
|
||||
| 剪贴板直接操作 | `System.Windows.Forms.Clipboard` | ✅ 读/写/图片检测 |
|
||||
| Shell 启动 | `ShellExecute` | ✅ 打开文件/URL/应用 |
|
||||
|
||||
## 3. 架构设计
|
||||
|
||||
### 3.1 文件结构
|
||||
|
||||
在现有 `backends/win32.ts` 基础上新增 Windows 专属模块:
|
||||
|
||||
```
|
||||
packages/@ant/computer-use-input/src/
|
||||
├── backends/
|
||||
│ ├── darwin.ts ← 不动
|
||||
│ ├── win32.ts ← 增强:直接 Win32 API 替代部分 PowerShell
|
||||
│ └── linux.ts ← 不动
|
||||
|
||||
packages/@ant/computer-use-swift/src/
|
||||
├── backends/
|
||||
│ ├── darwin.ts ← 不动
|
||||
│ ├── win32.ts ← 增强:PrintWindow 窗口截图 + EnumWindows
|
||||
│ └── linux.ts ← 不动
|
||||
|
||||
packages/@ant/computer-use-mcp/src/
|
||||
│ └── tools.ts ← 增加 Windows 专属工具定义(UI Automation、OCR)
|
||||
|
||||
src/utils/computerUse/
|
||||
│ └── win32/ ← 新增目录:Windows 专属能力
|
||||
│ ├── uiAutomation.ts ← UI 元素树、点击、写值
|
||||
│ ├── ocr.ts ← 截图 + OCR 文字识别
|
||||
│ ├── windowCapture.ts ← PrintWindow 窗口绑定截图
|
||||
│ └── windowEnum.ts ← EnumWindows 窗口枚举
|
||||
```
|
||||
|
||||
### 3.2 分层
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ Computer Use MCP Tools │
|
||||
│ screenshot / click / type / request_access │
|
||||
│ + Windows 专属: ui_tree / ocr / window_cap │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ src/utils/computerUse/ │
|
||||
│ executor.ts → 按平台 dispatch │
|
||||
│ win32/ → Windows 专属能力模块 │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ packages/@ant/computer-use-{input,swift} │
|
||||
│ backends/win32.ts → PowerShell + Win32 API │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ Windows Native API │
|
||||
│ PrintWindow / EnumWindows / UI Automation │
|
||||
│ SendInput / Clipboard / OCR / ShellExecute │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 4. 实施计划
|
||||
|
||||
### Phase A:窗口绑定截图(解决核心问题)
|
||||
|
||||
**问题**:当前 `CopyFromScreen` 只能全屏截图,无法对指定窗口截图。
|
||||
**方案**:用 `PrintWindow` + `FindWindow` 实现窗口级截图。
|
||||
|
||||
| 步骤 | 文件 | 改动 |
|
||||
|------|------|------|
|
||||
| A.1 | `src/utils/computerUse/win32/windowCapture.ts` | 新建:`captureWindow(title)` 用 PrintWindow 截取指定窗口 |
|
||||
| A.2 | `src/utils/computerUse/win32/windowEnum.ts` | 新建:`listWindows()` 用 EnumWindows 返回 {hwnd, pid, title}[] |
|
||||
| A.3 | `packages/@ant/computer-use-swift/src/backends/win32.ts` | `screenshot.captureExcluding` 增加按窗口截图能力 |
|
||||
| A.4 | `packages/@ant/computer-use-swift/src/backends/win32.ts` | `apps.listRunning` 用 EnumWindows 替代 Get-Process(返回 HWND) |
|
||||
|
||||
**PowerShell 脚本核心**:
|
||||
|
||||
```powershell
|
||||
# PrintWindow 截取指定窗口
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
Add-Type -ReferencedAssemblies System.Drawing @'
|
||||
using System; using System.Runtime.InteropServices; using System.Drawing; using System.Drawing.Imaging;
|
||||
public class WinCap {
|
||||
[DllImport("user32.dll", CharSet=CharSet.Unicode)]
|
||||
public static extern IntPtr FindWindow(string c, string t);
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool GetWindowRect(IntPtr h, out RECT r);
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool PrintWindow(IntPtr h, IntPtr hdc, uint f);
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct RECT { public int L, T, R, B; }
|
||||
// ... CaptureByTitle(string title) → base64
|
||||
}
|
||||
'@
|
||||
```
|
||||
|
||||
**验证标准**:
|
||||
- 能按窗口标题截图
|
||||
- 被遮挡的窗口也能截图
|
||||
- 返回 base64 + width + height
|
||||
|
||||
### Phase B:UI Automation(Windows 专属新能力)
|
||||
|
||||
**问题**:macOS 有 Accessibility API 可以读取/操作 UI 元素,Windows 当前只能坐标点击。
|
||||
**方案**:用 `System.Windows.Automation` 实现 UI 树读取和元素操作。
|
||||
|
||||
| 步骤 | 文件 | 改动 |
|
||||
|------|------|------|
|
||||
| B.1 | `src/utils/computerUse/win32/uiAutomation.ts` | 新建:核心 UIA 操作封装 |
|
||||
| B.2 | `packages/@ant/computer-use-mcp/src/tools.ts` | 增加 Windows 专属工具定义 |
|
||||
|
||||
**uiAutomation.ts 导出函数**:
|
||||
|
||||
```typescript
|
||||
// 获取窗口的 UI 元素树
|
||||
getUITree(windowTitle: string, depth: number): UIElement[]
|
||||
|
||||
// 按名称/类型/AutomationId 查找元素
|
||||
findElement(windowTitle: string, query: {name?, controlType?, automationId?}): UIElement | null
|
||||
|
||||
// 点击元素(InvokePattern)
|
||||
clickElement(windowTitle: string, automationId: string): boolean
|
||||
|
||||
// 设置元素值(ValuePattern)
|
||||
setValue(windowTitle: string, automationId: string, value: string): boolean
|
||||
|
||||
// 获取坐标处的元素
|
||||
elementAtPoint(x: number, y: number): UIElement | null
|
||||
```
|
||||
|
||||
**UIElement 类型**:
|
||||
```typescript
|
||||
interface UIElement {
|
||||
name: string
|
||||
controlType: string // Button, Edit, Text, List, etc.
|
||||
automationId: string
|
||||
boundingRect: { x: number, y: number, w: number, h: number }
|
||||
isEnabled: boolean
|
||||
value?: string // ValuePattern 可用时
|
||||
children?: UIElement[]
|
||||
}
|
||||
```
|
||||
|
||||
**PowerShell 脚本核心**:
|
||||
```powershell
|
||||
Add-Type -AssemblyName UIAutomationClient
|
||||
Add-Type -AssemblyName UIAutomationTypes
|
||||
|
||||
# 读取 UI 树
|
||||
$root = [AutomationElement]::RootElement
|
||||
$window = $root.FindFirst([TreeScope]::Children,
|
||||
[PropertyCondition]::new([AutomationElement]::NameProperty, $title))
|
||||
$elements = $window.FindAll([TreeScope]::Descendants, [Condition]::TrueCondition)
|
||||
|
||||
# 写入文本
|
||||
$element.GetCurrentPattern([ValuePattern]::Pattern).SetValue($text)
|
||||
|
||||
# 点击按钮
|
||||
$element.GetCurrentPattern([InvokePattern]::Pattern).Invoke()
|
||||
```
|
||||
|
||||
**验证标准**:
|
||||
- 能读取记事本的 UI 树(按钮、文本框、菜单)
|
||||
- 能向文本框写入内容
|
||||
- 能点击按钮
|
||||
- 能识别坐标处的元素
|
||||
|
||||
### Phase C:OCR 屏幕文字识别
|
||||
|
||||
**问题**:截图后 AI 只能看到图片,无法直接读取文字。
|
||||
**方案**:用 `Windows.Media.Ocr` 对截图进行文字识别。
|
||||
|
||||
| 步骤 | 文件 | 改动 |
|
||||
|------|------|------|
|
||||
| C.1 | `src/utils/computerUse/win32/ocr.ts` | 新建:截图 + OCR 识别 |
|
||||
| C.2 | `packages/@ant/computer-use-mcp/src/tools.ts` | 增加 `screen_ocr` 工具定义 |
|
||||
|
||||
**ocr.ts 导出函数**:
|
||||
```typescript
|
||||
// 对屏幕区域 OCR
|
||||
ocrRegion(x: number, y: number, w: number, h: number, lang?: string): OcrResult
|
||||
|
||||
// 对指定窗口 OCR
|
||||
ocrWindow(windowTitle: string, lang?: string): OcrResult
|
||||
|
||||
interface OcrResult {
|
||||
text: string
|
||||
lines: { text: string, bounds: {x,y,w,h} }[]
|
||||
language: string
|
||||
}
|
||||
```
|
||||
|
||||
**已确认可用语言**:英语 (en-US) + 中文 (zh-Hans-CN)
|
||||
|
||||
**验证标准**:
|
||||
- 能识别屏幕区域中的英文和中文
|
||||
- 返回文字内容 + 每行的位置信息
|
||||
|
||||
### Phase D:高频操作性能优化
|
||||
|
||||
**问题**:每次 PowerShell 启动 273ms,鼠标移动等高频操作太慢。
|
||||
**方案**:用 .NET `System.Windows.Forms.Clipboard` 等直接 API 替代 PowerShell 子进程。
|
||||
|
||||
| 步骤 | 文件 | 改动 |
|
||||
|------|------|------|
|
||||
| D.1 | `src/utils/computerUse/executor.ts` | 剪贴板操作用直接 API 替代 PowerShell |
|
||||
| D.2 | 考虑驻留 PowerShell 进程 | 通过 stdin/stdout 交互,摊平启动成本 |
|
||||
|
||||
**剪贴板直接 API**(不需要 PowerShell 子进程):
|
||||
```powershell
|
||||
# 读:50ms → <1ms
|
||||
[System.Windows.Forms.Clipboard]::GetText()
|
||||
|
||||
# 写:50ms → <1ms
|
||||
[System.Windows.Forms.Clipboard]::SetText($text)
|
||||
|
||||
# 图片检测
|
||||
[System.Windows.Forms.Clipboard]::ContainsImage()
|
||||
```
|
||||
|
||||
### Phase E:`request_access` Windows 适配
|
||||
|
||||
**问题**:`request_access` 依赖 macOS bundleId 识别应用,Windows 没有这个概念。
|
||||
**方案**:在 Windows 上用 exe 路径 + 窗口标题替代 bundleId。
|
||||
|
||||
| 步骤 | 文件 | 改动 |
|
||||
|------|------|------|
|
||||
| E.1 | `packages/@ant/computer-use-mcp/src/toolCalls.ts` | `resolveRequestedApps` 在 Windows 上用 exe 路径匹配 |
|
||||
| E.2 | `packages/@ant/computer-use-mcp/src/sentinelApps.ts` | 增加 Windows 危险应用列表(cmd.exe, powershell.exe 等) |
|
||||
| E.3 | `packages/@ant/computer-use-mcp/src/deniedApps.ts` | 增加 Windows 浏览器/终端识别规则 |
|
||||
| E.4 | `src/utils/computerUse/hostAdapter.ts` | `ensureOsPermissions` Windows 上检查 UAC 状态 |
|
||||
|
||||
**Windows 应用标识映射**:
|
||||
```
|
||||
macOS bundleId → Windows 等价
|
||||
com.apple.Safari → C:\Program Files\...\msedge.exe(或窗口标题匹配)
|
||||
com.google.Chrome → chrome.exe
|
||||
com.apple.Terminal → WindowsTerminal.exe / cmd.exe
|
||||
```
|
||||
|
||||
### Phase F:全局热键(ESC 拦截)
|
||||
|
||||
**问题**:当前非 darwin 直接跳过 ESC 热键,用 Ctrl+C 替代。
|
||||
**方案**:用 `RegisterHotKey` 或 `SetWindowsHookEx(WH_KEYBOARD_LL)` 实现。
|
||||
|
||||
| 步骤 | 文件 | 改动 |
|
||||
|------|------|------|
|
||||
| F.1 | `src/utils/computerUse/escHotkey.ts` | Windows 分支:RegisterHotKey 注册 ESC |
|
||||
|
||||
**优先级低**——当前 Ctrl+C fallback 可用,ESC 热键是体验优化。
|
||||
|
||||
## 5. 执行优先级
|
||||
|
||||
```
|
||||
Phase A: 窗口绑定截图 ← P0 核心需求,解决"操作其他界面"
|
||||
Phase B: UI Automation ← P0 核心能力,AI 理解 UI 结构
|
||||
Phase C: OCR ← P1 增值能力,AI 读屏幕文字
|
||||
Phase D: 性能优化 ← P1 体验优化,高频操作提速
|
||||
Phase E: request_access 适配 ← P1 功能完整性,权限模型适配
|
||||
Phase F: ESC 热键 ← P2 体验优化,可后做
|
||||
```
|
||||
|
||||
## 6. 每个 Phase 的改动量估算
|
||||
|
||||
| Phase | 新增文件 | 修改文件 | 新增代码行 | 风险 |
|
||||
|-------|---------|---------|-----------|------|
|
||||
| A 窗口截图 | 2 | 1 | ~200 | 低 |
|
||||
| B UI Automation | 1 | 1 | ~300 | 中 |
|
||||
| C OCR | 1 | 1 | ~150 | 低 |
|
||||
| D 性能优化 | 0 | 2 | ~50 | 低 |
|
||||
| E request_access | 0 | 3 | ~100 | 中 |
|
||||
| F ESC 热键 | 0 | 1 | ~50 | 低 |
|
||||
| **总计** | **4** | **9** | **~850** | — |
|
||||
|
||||
## 7. 不动的文件
|
||||
|
||||
- `backends/darwin.ts`(两个包都不动)
|
||||
- `backends/linux.ts`(两个包都不动)
|
||||
- `src/utils/computerUse/` 中 macOS 相关代码路径不动
|
||||
- `packages/@ant/computer-use-mcp/src/` 中已复制的参考项目代码不动(只追加 Windows 工具)
|
||||
|
||||
## 8. 与 macOS/Linux 方案的对比
|
||||
|
||||
| 能力 | macOS | Windows (增强后) | Linux |
|
||||
|------|-------|-----------------|-------|
|
||||
| 截图方式 | SCContentFilter (per-app) | **PrintWindow (per-window)** | scrot (全屏/区域) |
|
||||
| UI 结构 | Accessibility API | **UI Automation** | 无 |
|
||||
| OCR | 无内置 | **Windows.Media.Ocr** | 无内置 |
|
||||
| 键鼠 | CGEvent + enigo | SendInput + keybd_event | xdotool |
|
||||
| 窗口管理 | NSWorkspace | **EnumWindows + Win32** | wmctrl |
|
||||
| 剪贴板 | pbcopy/pbpaste | **Clipboard 直接 API** | xclip |
|
||||
| ESC 热键 | CGEventTap | RegisterHotKey | 无 |
|
||||
| 应用标识 | bundleId | exe 路径 + 窗口标题 | /proc + wmctrl |
|
||||
|
||||
**Windows 增强后将在 UI Automation 和 OCR 方面超过 macOS 方案**——这两项 macOS 原始实现也没有(Anthropic 用的是截图 + Claude 视觉理解,没有结构化 UI 数据)。
|
||||
197
docs/features/computer-use.md
Normal file
197
docs/features/computer-use.md
Normal file
@@ -0,0 +1,197 @@
|
||||
# Computer Use — macOS / Windows / Linux 跨平台实施计划
|
||||
|
||||
更新时间:2026-04-03
|
||||
参考项目:`E:\源码\claude-code-source-main\claude-code-source-main`
|
||||
|
||||
## 1. 现状
|
||||
|
||||
参考项目的 Computer Use **仅支持 macOS**——从入口到底层全部写死 darwin。我们的项目在 Phase 1-3 中已经完成了:
|
||||
|
||||
- ✅ `@ant/computer-use-mcp` stub 替换为完整实现(12 文件)
|
||||
- ✅ `@ant/computer-use-input` 拆为 dispatcher + backends(darwin + win32)
|
||||
- ✅ `@ant/computer-use-swift` 拆为 dispatcher + backends(darwin + win32)
|
||||
- ✅ `CHICAGO_MCP` 编译开关已开
|
||||
- ❌ `src/` 层有 6 处 macOS 硬编码阻塞
|
||||
|
||||
## 2. 阻塞点全景
|
||||
|
||||
### 2.1 入口层
|
||||
|
||||
| # | 文件:行号 | 阻塞代码 | 影响 |
|
||||
|---|----------|---------|------|
|
||||
| 1 | `src/main.tsx:1605` | `getPlatform() === 'macos'` | 整个 CU 初始化被跳过 |
|
||||
|
||||
### 2.2 加载层
|
||||
|
||||
| # | 文件:行号 | 阻塞代码 | 影响 |
|
||||
|---|----------|---------|------|
|
||||
| 2 | `src/utils/computerUse/swiftLoader.ts:16` | `process.platform !== 'darwin'` → throw | 截图、应用管理全部不可用 |
|
||||
| 3 | `src/utils/computerUse/executor.ts:263` | `process.platform !== 'darwin'` → throw | 整个 executor 工厂函数不可用 |
|
||||
|
||||
### 2.3 macOS 特有依赖
|
||||
|
||||
| # | 文件:行号 | 依赖 | macOS 实现 | 需要替代方案 |
|
||||
|---|----------|------|-----------|------------|
|
||||
| 4 | `executor.ts:70-88` | 剪贴板 | `pbcopy`/`pbpaste` | Win: PowerShell `Get/Set-Clipboard`;Linux: `xclip`/`wl-copy` |
|
||||
| 5 | `drainRunLoop.ts:21` | CFRunLoop pump | `cu._drainMainRunLoop()` | 非 darwin:直接执行 fn(),不需要 pump |
|
||||
| 6 | `escHotkey.ts:28` | ESC 热键 | CGEventTap | 非 darwin:返回 false(已有 Ctrl+C fallback) |
|
||||
| 7 | `hostAdapter.ts:48-54` | 系统权限 | TCC accessibility + screenRecording | Win:直接 granted;Linux:检查 xdotool |
|
||||
| 8 | `common.ts:56` | 平台标识 | `platform: 'darwin'` 硬编码 | 动态获取 |
|
||||
| 9 | `executor.ts:180` | 粘贴快捷键 | `command+v` | Win/Linux:`ctrl+v` |
|
||||
|
||||
### 2.4 缺失的 Linux 后端
|
||||
|
||||
| 包 | macOS | Windows | Linux |
|
||||
|---|-------|---------|-------|
|
||||
| `computer-use-input/backends/` | ✅ darwin.ts | ✅ win32.ts | ❌ 需新建 linux.ts |
|
||||
| `computer-use-swift/backends/` | ✅ darwin.ts | ✅ win32.ts | ❌ 需新建 linux.ts |
|
||||
|
||||
## 3. 每个平台的能力依赖
|
||||
|
||||
### 3.1 computer-use-input(键鼠)
|
||||
|
||||
| 功能 | macOS | Windows | Linux |
|
||||
|------|-------|---------|-------|
|
||||
| 鼠标移动 | CGEvent JXA | SetCursorPos P/Invoke | xdotool mousemove |
|
||||
| 鼠标点击 | CGEvent JXA | SendInput P/Invoke | xdotool click |
|
||||
| 鼠标滚轮 | CGEvent JXA | SendInput MOUSEEVENTF_WHEEL | xdotool scroll |
|
||||
| 键盘按键 | System Events osascript | keybd_event P/Invoke | xdotool key |
|
||||
| 组合键 | System Events osascript | keybd_event 组合 | xdotool key combo |
|
||||
| 文本输入 | System Events keystroke | SendKeys.SendWait | xdotool type |
|
||||
| 前台应用 | System Events osascript | GetForegroundWindow P/Invoke | xdotool getactivewindow + /proc |
|
||||
| 工具依赖 | osascript(内置) | powershell(内置) | xdotool(需安装) |
|
||||
|
||||
### 3.2 computer-use-swift(截图 + 应用管理)
|
||||
|
||||
| 功能 | macOS | Windows | Linux |
|
||||
|------|-------|---------|-------|
|
||||
| 全屏截图 | screencapture | CopyFromScreen | gnome-screenshot / scrot / grim |
|
||||
| 区域截图 | screencapture -R | CopyFromScreen(rect) | gnome-screenshot -a / scrot -a / grim -g |
|
||||
| 显示器列表 | CGGetActiveDisplayList JXA | Screen.AllScreens | xrandr --query |
|
||||
| 运行中应用 | System Events JXA | Get-Process | wmctrl -l / ps |
|
||||
| 打开应用 | osascript activate | Start-Process | xdg-open / gtk-launch |
|
||||
| 隐藏/显示 | System Events visibility | ShowWindow/SetForegroundWindow | wmctrl -c / xdotool |
|
||||
| 工具依赖 | screencapture + osascript | powershell | xdotool + scrot/grim + wmctrl |
|
||||
|
||||
### 3.3 executor 层
|
||||
|
||||
| 功能 | macOS | Windows | Linux |
|
||||
|------|-------|---------|-------|
|
||||
| drainRunLoop | CFRunLoop pump | 不需要 | 不需要 |
|
||||
| ESC 热键 | CGEventTap | 跳过(Ctrl+C fallback) | 跳过(Ctrl+C fallback) |
|
||||
| 剪贴板读 | pbpaste | `powershell Get-Clipboard` | xclip -o / wl-paste |
|
||||
| 剪贴板写 | pbcopy | `powershell Set-Clipboard` | xclip / wl-copy |
|
||||
| 粘贴快捷键 | command+v | ctrl+v | ctrl+v |
|
||||
| 终端检测 | __CFBundleIdentifier | WT_SESSION / TERM_PROGRAM | TERM_PROGRAM |
|
||||
| 系统权限 | TCC check | 直接 granted | 检查 xdotool 安装 |
|
||||
|
||||
## 4. 执行步骤
|
||||
|
||||
### Phase 1:已完成 ✅
|
||||
|
||||
- [x] `@ant/computer-use-mcp` stub → 完整实现
|
||||
- [x] `@ant/computer-use-input` dispatcher + darwin/win32 backends
|
||||
- [x] `@ant/computer-use-swift` dispatcher + darwin/win32 backends
|
||||
- [x] `CHICAGO_MCP` 编译开关
|
||||
|
||||
### Phase 2:移除 6 处 macOS 硬编码(解锁 macOS + Windows)
|
||||
|
||||
**改动原则:macOS 代码路径不变,只在每处 darwin 守卫后加 win32/linux 分支。**
|
||||
|
||||
| 步骤 | 文件 | 改动 |
|
||||
|------|------|------|
|
||||
| 2.1 | `src/main.tsx:1605` | `getPlatform() === 'macos'` → 去掉平台限制,或改为 `!== 'unknown'` |
|
||||
| 2.2 | `src/utils/computerUse/swiftLoader.ts:16-18` | 移除 `process.platform !== 'darwin'` throw。`@ant/computer-use-swift/index.ts` 已有跨平台 dispatch |
|
||||
| 2.3 | `src/utils/computerUse/executor.ts:263-267` | 移除 `process.platform !== 'darwin'` throw。改为检查 input/swift isSupported |
|
||||
| 2.4 | `src/utils/computerUse/executor.ts:70-88` | 剪贴板函数按平台分发:darwin→pbcopy/pbpaste,win32→PowerShell Get/Set-Clipboard,linux→xclip |
|
||||
| 2.5 | `src/utils/computerUse/executor.ts:180` | `typeViaClipboard` 中 `command+v` → 非 darwin 时用 `ctrl+v` |
|
||||
| 2.6 | `src/utils/computerUse/executor.ts:273` | `const cu = requireComputerUseSwift()` → 改为 `new ComputerUseAPI()`(从 package 直接实例化,不走 swiftLoader throw) |
|
||||
| 2.7 | `src/utils/computerUse/drainRunLoop.ts` | 开头加 `if (process.platform !== 'darwin') return fn()` |
|
||||
| 2.8 | `src/utils/computerUse/escHotkey.ts` | `registerEscHotkey` 非 darwin 返回 false(已有 Ctrl+C fallback) |
|
||||
| 2.9 | `src/utils/computerUse/hostAdapter.ts:48-54` | `ensureOsPermissions` 非 darwin 返回 `{ granted: true }` |
|
||||
| 2.10 | `src/utils/computerUse/common.ts:56` | `platform: 'darwin'` → `platform: process.platform === 'win32' ? 'windows' : process.platform === 'linux' ? 'linux' : 'darwin'` |
|
||||
| 2.11 | `src/utils/computerUse/common.ts:55` | `screenshotFiltering: 'native'` → 非 darwin 时 `'none'`(Windows/Linux 截图不支持 per-app 过滤) |
|
||||
| 2.12 | `src/utils/computerUse/gates.ts:13` | `enabled: false` → `enabled: true`(无 GrowthBook 时默认可用) |
|
||||
| 2.13 | `src/utils/computerUse/gates.ts:39-43` | `hasRequiredSubscription()` → 直接返回 `true` |
|
||||
|
||||
### Phase 3:新增 Linux 后端
|
||||
|
||||
| 步骤 | 文件 | 内容 |
|
||||
|------|------|------|
|
||||
| 3.1 | `packages/@ant/computer-use-input/src/backends/linux.ts` | xdotool 键鼠(mousemove/click/key/type/getactivewindow) |
|
||||
| 3.2 | `packages/@ant/computer-use-swift/src/backends/linux.ts` | scrot/grim 截图 + xrandr 显示器 + wmctrl 窗口管理 |
|
||||
| 3.3 | `packages/@ant/computer-use-input/src/index.ts` | dispatcher 加 `case 'linux'` |
|
||||
| 3.4 | `packages/@ant/computer-use-swift/src/index.ts` | dispatcher 加 `case 'linux'` |
|
||||
|
||||
### Phase 4:验证
|
||||
|
||||
| 测试项 | macOS | Windows | Linux |
|
||||
|--------|-------|---------|-------|
|
||||
| build 成功 | ✅ | 验证 | 验证 |
|
||||
| MCP 工具列表非空 | 验证 | 验证 | 验证 |
|
||||
| 鼠标移动 | 验证 | ✅ 已通过 | 验证 |
|
||||
| 截图 | 验证 | ✅ 已通过 | 验证 |
|
||||
| 键盘输入 | 验证 | 验证 | 验证 |
|
||||
| 前台窗口 | 验证 | ✅ 已通过 | 验证 |
|
||||
| 剪贴板 | 验证 | 验证 | 验证 |
|
||||
|
||||
## 5. 文件改动总览
|
||||
|
||||
### 不动的文件(14 个)
|
||||
|
||||
`cleanup.ts`、`computerUseLock.ts`、`wrapper.tsx`、`toolRendering.tsx`、`mcpServer.ts`、`setup.ts`、`appNames.ts`、`inputLoader.ts`、`src/services/mcp/client.ts`、`@ant/computer-use-mcp/src/*`(Phase 1 已完成)、`backends/darwin.ts`(两个包都不动)
|
||||
|
||||
### 改 src/ 的文件(8 个)
|
||||
|
||||
| 文件 | 改动量 | 风险 |
|
||||
|------|--------|------|
|
||||
| `main.tsx` | 1 行 | 低 |
|
||||
| `swiftLoader.ts` | 2 行 | 低 |
|
||||
| `executor.ts` | ~40 行(剪贴板分发 + 平台守卫 + paste 快捷键) | **中** |
|
||||
| `drainRunLoop.ts` | 1 行 | 低 |
|
||||
| `escHotkey.ts` | 3 行 | 低 |
|
||||
| `hostAdapter.ts` | 5 行 | 低 |
|
||||
| `common.ts` | 3 行 | 低 |
|
||||
| `gates.ts` | 3 行 | 低 |
|
||||
|
||||
### 新增文件(2 个)
|
||||
|
||||
| 文件 | 行数估算 |
|
||||
|------|---------|
|
||||
| `packages/@ant/computer-use-input/src/backends/linux.ts` | ~150 行 |
|
||||
| `packages/@ant/computer-use-swift/src/backends/linux.ts` | ~200 行 |
|
||||
|
||||
## 6. Linux 依赖工具
|
||||
|
||||
| 工具 | 用途 | 安装命令(Ubuntu) |
|
||||
|------|------|-------------------|
|
||||
| `xdotool` | 键鼠模拟 + 窗口管理 | `sudo apt install xdotool` |
|
||||
| `scrot` 或 `gnome-screenshot` | 截图 | `sudo apt install scrot` |
|
||||
| `xrandr` | 显示器信息 | 通常已预装 |
|
||||
| `xclip` | 剪贴板 | `sudo apt install xclip` |
|
||||
| `wmctrl` | 窗口列表/切换 | `sudo apt install wmctrl` |
|
||||
|
||||
Wayland 环境需要替代工具:`ydotool`(替代 xdotool)、`grim`(替代 scrot)、`wl-clipboard`(替代 xclip)。初期可先只支持 X11,Wayland 标记为 todo。
|
||||
|
||||
## 7. 执行顺序建议
|
||||
|
||||
```
|
||||
Phase 2(解锁 macOS + Windows)
|
||||
├── 2.1-2.3 移除 3 处硬编码 throw/skip
|
||||
├── 2.4-2.5 剪贴板 + 粘贴快捷键平台分发
|
||||
├── 2.6 swiftLoader → 直接实例化
|
||||
├── 2.7-2.9 drainRunLoop / escHotkey / permissions 平台分支
|
||||
├── 2.10-2.11 common.ts 平台标识动态化
|
||||
├── 2.12-2.13 gates.ts 默认值
|
||||
└── 验证 Windows
|
||||
|
||||
Phase 3(Linux 后端)
|
||||
├── 3.1 input/backends/linux.ts
|
||||
├── 3.2 swift/backends/linux.ts
|
||||
├── 3.3-3.4 dispatcher 加 linux case
|
||||
└── 验证 Linux
|
||||
|
||||
Phase 4(集成验证 + PR)
|
||||
```
|
||||
|
||||
每个 Phase 可独立验证、独立提交。Phase 2 完成后 macOS + Windows 可用,Phase 3 完成后三平台全部可用。
|
||||
2014
docs/features/feature-flags-audit-complete.md
Normal file
2014
docs/features/feature-flags-audit-complete.md
Normal file
File diff suppressed because it is too large
Load Diff
160
docs/features/feature-flags-codex-review.md
Normal file
160
docs/features/feature-flags-codex-review.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# 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 模式可见 |
|
||||
334
docs/features/growthbook-enablement-plan.md
Normal file
334
docs/features/growthbook-enablement-plan.md
Normal file
@@ -0,0 +1,334 @@
|
||||
# GrowthBook 功能启用计划
|
||||
|
||||
> 编制日期: 2026-04-06
|
||||
> 基于: feature-flags-codex-review.md + 4 个并行研究代理的深度分析
|
||||
> 前提: 我们是付费订阅用户,拥有有效的 Anthropic API key
|
||||
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
Claude Code 使用三层门控系统:
|
||||
1. **编译时 feature flag** — `feature('FLAG_NAME')` from `bun:bundle`
|
||||
2. **GrowthBook 远程开关** — `tengu_*` 前缀,通过 SDK 连接 Anthropic 服务端
|
||||
3. **运行时环境变量** — `USER_TYPE`、`CLAUDE_CODE_*` 等
|
||||
|
||||
在我们的反编译版本中,GrowthBook 不启动(analytics 链空实现),导致所有 `tengu_*` 检查默认返回 `false`。
|
||||
|
||||
**核心发现:所有被 GrowthBook 门控的功能代码都是真实现,没有 stub。**
|
||||
|
||||
---
|
||||
|
||||
## 启用方式说明
|
||||
|
||||
### 方式 1:硬编码绕过(推荐先用)
|
||||
在 `src/services/analytics/growthbook.ts` 的 `getFeatureValueInternal()` 函数中添加默认值映射。
|
||||
|
||||
### 方式 2:自建 GrowthBook 服务器
|
||||
```bash
|
||||
docker run -p 3100:3100 growthbook/growthbook
|
||||
# 设置环境变量
|
||||
CLAUDE_GB_ADAPTER_URL=http://localhost:3100
|
||||
CLAUDE_GB_ADAPTER_KEY=sdk-xxx
|
||||
```
|
||||
|
||||
### 方式 3:恢复原生 1P 连接
|
||||
让 `is1PEventLoggingEnabled()` 返回 `true`,连接 Anthropic 的 GrowthBook 服务端。
|
||||
注意:会发送使用统计(不含代码/对话内容)。
|
||||
|
||||
---
|
||||
|
||||
## 优先级 P0:纯本地功能(零外部依赖,立即可用)
|
||||
|
||||
这些功能不需要 API 调用,开启 gate 即可工作。
|
||||
|
||||
### P0-1. 自定义快捷键
|
||||
- **Gate**: `tengu_keybinding_customization_release` → `true`
|
||||
- **编译 flag**: 无(已内置)
|
||||
- **代码量**: 473 行,完整实现
|
||||
- **功能**: 加载 `~/.claude/keybindings.json`,支持热重载、重复键检测、结构验证
|
||||
- **效果**: 用户可自定义所有快捷键
|
||||
- **风险**: 无
|
||||
|
||||
### P0-2. 流式工具执行
|
||||
- **Gate**: `tengu_streaming_tool_execution2` → `true`
|
||||
- **编译 flag**: 无(已内置)
|
||||
- **代码量**: 577 行(StreamingToolExecutor),完整实现
|
||||
- **功能**: API 响应还在流式返回时就开始执行工具,减少等待时间
|
||||
- **效果**: 显著提升交互速度
|
||||
- **风险**: 低(生产级代码,有错误处理)
|
||||
|
||||
### P0-3. 定时任务系统
|
||||
- **Gate**: `tengu_kairos_cron` → `true`(额外:`tengu_kairos_cron_durable` 默认 `true`)
|
||||
- **编译 flag**: `AGENT_TRIGGERS`(需新增)或 `AGENT_TRIGGERS_REMOTE`(已启用)
|
||||
- **代码量**: 1025 行(cronTasks + cronScheduler),完整实现
|
||||
- **功能**: 本地 cron 调度,支持一次性/周期性任务、防雷群效应 jitter、自动过期
|
||||
- **效果**: 可设置定时执行的 Claude 任务
|
||||
- **风险**: 低
|
||||
|
||||
### P0-4. Agent 团队 / Swarm
|
||||
- **Gate**: `tengu_amber_flint` → `true`(这是 kill switch,默认已 `true`)
|
||||
- **编译 flag**: 无(已内置)
|
||||
- **代码量**: 45 行(gate 层),实际 swarm 实现在 teammate tools 中
|
||||
- **功能**: 多 agent 协作,需额外设置 `--agent-teams` 或 `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`
|
||||
- **效果**: 允许创建和管理 agent 团队
|
||||
- **风险**: 无(kill switch 默认就是 true)
|
||||
|
||||
### P0-5. Token 高效 JSON 工具格式
|
||||
- **Gate**: `tengu_amber_json_tools` → `true`
|
||||
- **编译 flag**: 无(已内置)
|
||||
- **代码量**: betas.ts 中几行 gate 检查
|
||||
- **功能**: 启用 FC v3 格式,减少约 4.5% 的输出 token
|
||||
- **效果**: 省钱
|
||||
- **风险**: 低(需要模型支持该 beta header)
|
||||
|
||||
### P0-6. Ultrathink 扩展思考
|
||||
- **Gate**: `tengu_turtle_carbon` → `true`(默认已 `true`,kill switch)
|
||||
- **编译 flag**: 无
|
||||
- **功能**: 通过关键词触发扩展思考模式
|
||||
- **效果**: 已默认启用,确保不被远程关闭即可
|
||||
- **风险**: 无
|
||||
|
||||
### P0-7. 即时模型切换
|
||||
- **Gate**: `tengu_immediate_model_command` → `true`
|
||||
- **编译 flag**: 无
|
||||
- **功能**: 在 query 运行过程中即时执行 `/model`、`/fast`、`/effort` 命令
|
||||
- **效果**: 无需等当前任务完成就能切换
|
||||
- **风险**: 低
|
||||
|
||||
---
|
||||
|
||||
## 优先级 P1:需要 Claude API 的功能(有 API key 即可用)
|
||||
|
||||
这些功能需要调用 Claude API(使用 forked subagent 或 queryModel),有订阅即可。
|
||||
|
||||
### P1-1. 会话记忆
|
||||
- **Gate**: `tengu_session_memory` → `true`(配置:`tengu_sm_config` → `{}`)
|
||||
- **编译 flag**: 无(已内置)
|
||||
- **代码量**: 1127 行,完整实现
|
||||
- **功能**: 跨会话上下文持久化。用 forked agent 定期提取会话笔记到 markdown 文件
|
||||
- **效果**: Claude 记住跨会话的工作上下文
|
||||
- **依赖**: Claude API(forked subagent)
|
||||
- **风险**: 低(额外 API token 消耗)
|
||||
|
||||
### P1-2. 自动记忆提取
|
||||
- **Gate**: `tengu_passport_quail` → `true`(相关:`tengu_moth_copse`、`tengu_coral_fern`)
|
||||
- **编译 flag**: `EXTRACT_MEMORIES`(需新增)
|
||||
- **代码量**: 616 行,完整实现
|
||||
- **功能**: 对话中自动提取持久记忆到 `~/.claude/projects/<path>/memory/`
|
||||
- **效果**: 自动构建项目知识库
|
||||
- **依赖**: Claude API(forked subagent)
|
||||
- **风险**: 低
|
||||
|
||||
### P1-3. 提示建议
|
||||
- **Gate**: `tengu_chomp_inflection` → `true`
|
||||
- **编译 flag**: 无(已内置)
|
||||
- **代码量**: 525 行,完整实现
|
||||
- **功能**: 自动生成下一步操作建议,带投机预取(speculation prefetch)
|
||||
- **效果**: 更流畅的交互体验
|
||||
- **依赖**: Claude API(forked subagent)
|
||||
- **风险**: 低(额外 API 消耗,但有缓存感知)
|
||||
|
||||
### P1-4. 验证代理
|
||||
- **Gate**: `tengu_hive_evidence` → `true`
|
||||
- **编译 flag**: `VERIFICATION_AGENT`(需新增)
|
||||
- **代码量**: 153 行(agent 定义),完整实现
|
||||
- **功能**: 对抗性验证 agent,主动尝试打破你的实现(只读模式)
|
||||
- **效果**: 自动化代码验证
|
||||
- **依赖**: Claude API(subagent)
|
||||
- **风险**: 低(只读,不修改代码)
|
||||
|
||||
### P1-5. Brief 模式
|
||||
- **Gate**: `tengu_kairos_brief` → `true`
|
||||
- **编译 flag**: `KAIROS` 或 `KAIROS_BRIEF`(需新增)
|
||||
- **代码量**: 335 行,完整实现
|
||||
- **功能**: `/brief` 命令切换精简输出模式
|
||||
- **效果**: 减少冗余输出
|
||||
- **依赖**: Claude API
|
||||
- **风险**: 低
|
||||
|
||||
### P1-6. 离开摘要
|
||||
- **Gate**: `tengu_sedge_lantern` → `true`
|
||||
- **编译 flag**: `AWAY_SUMMARY`(需新增)
|
||||
- **代码量**: 176 行,完整实现
|
||||
- **功能**: 离开终端 5 分钟后返回时自动总结期间发生了什么
|
||||
- **效果**: 快速恢复上下文
|
||||
- **依赖**: Claude API + 终端焦点事件支持
|
||||
- **风险**: 低
|
||||
|
||||
### P1-7. 自动梦境
|
||||
- **Gate**: `tengu_onyx_plover` → `{"enabled": true}`
|
||||
- **编译 flag**: 无(已内置,但检查 auto-memory 是否启用)
|
||||
- **代码量**: 349 行,完整实现
|
||||
- **功能**: 后台自动整理/巩固记忆(等同于自动执行 `/dream`)
|
||||
- **效果**: 记忆自动保持整洁有序
|
||||
- **依赖**: Claude API(forked subagent)+ auto-memory 启用
|
||||
- **风险**: 低
|
||||
|
||||
### P1-8. 空闲返回提示
|
||||
- **Gate**: `tengu_willow_mode` → `"dialog"` 或 `"hint"`
|
||||
- **编译 flag**: 无
|
||||
- **功能**: 对话太大且缓存过期时,提示用户开新会话
|
||||
- **效果**: 避免在过期缓存上浪费 token
|
||||
- **风险**: 无
|
||||
|
||||
---
|
||||
|
||||
## 优先级 P2:增强型功能(提升体验但非必须)
|
||||
|
||||
### P2-1. MCP 指令增量传输
|
||||
- **Gate**: `tengu_basalt_3kr` → `true`
|
||||
- **功能**: 只发送变化的 MCP 指令而非全量
|
||||
- **效果**: 减少 token 消耗
|
||||
- **风险**: 低
|
||||
|
||||
### P2-2. 叶剪枝优化
|
||||
- **Gate**: `tengu_pebble_leaf_prune` → `true`
|
||||
- **功能**: 会话存储中移除死胡同消息分支
|
||||
- **效果**: 减少存储和加载时间
|
||||
- **风险**: 低
|
||||
|
||||
### P2-3. 消息合并
|
||||
- **Gate**: `tengu_chair_sermon` → `true`
|
||||
- **功能**: 合并相邻的 tool_result + text 块
|
||||
- **效果**: 减少 token 消耗
|
||||
- **风险**: 低
|
||||
|
||||
### P2-4. 深度链接
|
||||
- **Gate**: `tengu_lodestone_enabled` → `true`
|
||||
- **功能**: 注册 `claude://` URL 协议处理器
|
||||
- **效果**: 可从浏览器直接打开 Claude Code
|
||||
- **风险**: 低
|
||||
|
||||
### P2-5. Agent 自动转后台
|
||||
- **Gate**: `tengu_auto_background_agents` → `true`
|
||||
- **功能**: Agent 任务运行 120s 后自动转为后台
|
||||
- **效果**: 不再阻塞主交互
|
||||
- **风险**: 低
|
||||
|
||||
### P2-6. 细粒度工具状态
|
||||
- **Gate**: `tengu_fgts` → `true`
|
||||
- **功能**: 系统提示中包含细粒度工具状态信息
|
||||
- **效果**: 模型更好地理解工具可用性
|
||||
- **风险**: 低
|
||||
|
||||
### P2-7. 文件操作 git diff
|
||||
- **Gate**: `tengu_quartz_lantern` → `true`
|
||||
- **功能**: 文件写入/编辑时计算 git diff(仅远程会话)
|
||||
- **效果**: 更好的变更追踪
|
||||
- **风险**: 低
|
||||
|
||||
---
|
||||
|
||||
## 优先级 P3:需要自建服务或 Anthropic OAuth
|
||||
|
||||
### P3-1. 团队记忆
|
||||
- **Gate**: `tengu_herring_clock` → `true`
|
||||
- **编译 flag**: `TEAMMEM`(需新增)
|
||||
- **代码量**: 1180+ 行,完整实现
|
||||
- **功能**: 跨 agent 共享记忆,同步到 Anthropic API
|
||||
- **依赖**: Anthropic OAuth + GitHub remote
|
||||
- **状态**: 需要 Anthropic 的 `/api/claude_code/team_memory` 端点
|
||||
- **可行性**: 除非自建兼容 API,否则无法使用
|
||||
|
||||
### P3-2. 设置同步
|
||||
- **Gate**: `tengu_enable_settings_sync_push` + `tengu_strap_foyer` → `true`
|
||||
- **编译 flag**: `UPLOAD_USER_SETTINGS` / `DOWNLOAD_USER_SETTINGS`(需新增)
|
||||
- **代码量**: 582 行,完整实现
|
||||
- **功能**: 跨设备设置同步
|
||||
- **依赖**: Anthropic OAuth + `/api/claude_code/user_settings`
|
||||
- **可行性**: 同上
|
||||
|
||||
### P3-3. Bridge 远程控制
|
||||
- **Gate**: `tengu_ccr_bridge` → `true`(已有编译 flag `BRIDGE_MODE` dev 模式启用)
|
||||
- **代码量**: 12,619 行,完整实现
|
||||
- **功能**: claude.ai 网页端远程控制 CLI
|
||||
- **依赖**: claude.ai 订阅 + WebSocket 后端
|
||||
- **可行性**: 需要 Anthropic 的 CCR 后端
|
||||
|
||||
### P3-4. 远程定时 Agent
|
||||
- **Gate**: `tengu_surreal_dali` → `true`
|
||||
- **功能**: 创建在远程执行的定时 agent
|
||||
- **依赖**: Anthropic CCR 基础设施
|
||||
- **可行性**: 需要远程服务
|
||||
|
||||
---
|
||||
|
||||
## Kill Switch 清单(确保不被远程关闭)
|
||||
|
||||
这些 gate 默认为 `true`,是 kill switch。应确保它们保持 `true`:
|
||||
|
||||
| Gate | 默认 | 控制什么 |
|
||||
|---|---|---|
|
||||
| `tengu_turtle_carbon` | `true` | Ultrathink 扩展思考 |
|
||||
| `tengu_amber_stoat` | `true` | 内置 Explore/Plan agent |
|
||||
| `tengu_amber_flint` | `true` | Agent 团队/Swarm |
|
||||
| `tengu_slim_subagent_claudemd` | `true` | 子 agent 精简 CLAUDE.md |
|
||||
| `tengu_birch_trellis` | `true` | tree-sitter bash 安全分析 |
|
||||
| `tengu_collage_kaleidoscope` | `true` | macOS 剪贴板图片读取 |
|
||||
| `tengu_compact_cache_prefix` | `true` | 压缩时复用 prompt cache |
|
||||
| `tengu_kairos_cron_durable` | `true` | 持久化 cron 任务 |
|
||||
| `tengu_attribution_header` | `true` | API 请求署名 |
|
||||
| `tengu_slate_prism` | `true` | Agent 进度摘要 |
|
||||
|
||||
---
|
||||
|
||||
## 需要新增的编译 flag
|
||||
|
||||
以下编译时 flag 尚未在 `build.ts` / `scripts/dev.ts` 中启用,但功能代码完整:
|
||||
|
||||
| Flag | 用于 | 优先级 |
|
||||
|---|---|---|
|
||||
| `AGENT_TRIGGERS` | 定时任务系统(P0-3) | P0 |
|
||||
| `EXTRACT_MEMORIES` | 自动记忆提取(P1-2) | P1 |
|
||||
| `VERIFICATION_AGENT` | 验证代理(P1-4) | P1 |
|
||||
| `KAIROS` 或 `KAIROS_BRIEF` | Brief 模式(P1-5) | P1 |
|
||||
| `AWAY_SUMMARY` | 离开摘要(P1-6) | P1 |
|
||||
| `TEAMMEM` | 团队记忆(P3-1) | P3 |
|
||||
|
||||
---
|
||||
|
||||
## 实施路线图
|
||||
|
||||
### Phase 1:硬编码 P0 纯本地 gate(最快见效)
|
||||
1. 在 growthbook.ts 添加默认值映射
|
||||
2. 在 build.ts / dev.ts 添加 `AGENT_TRIGGERS` 编译 flag
|
||||
3. 验证 7 个 P0 功能正常工作
|
||||
4. 预计工作量:1-2 小时
|
||||
|
||||
### Phase 2:启用 P1 API 依赖功能
|
||||
1. 添加编译 flag:`EXTRACT_MEMORIES`、`VERIFICATION_AGENT`、`KAIROS_BRIEF`、`AWAY_SUMMARY`
|
||||
2. 添加 P1 gate 默认值
|
||||
3. 验证 8 个 P1 功能正常工作
|
||||
4. 预计工作量:2-3 小时
|
||||
|
||||
### Phase 3:评估自建 GrowthBook(可选)
|
||||
1. Docker 部署 GrowthBook 服务器
|
||||
2. 迁移硬编码值到 GrowthBook 后台管理
|
||||
3. 获得 Web UI 管理所有 flag 的能力
|
||||
4. 预计工作量:半天
|
||||
|
||||
### Phase 4:评估远程功能(可选)
|
||||
1. 研究是否可以使用 Anthropic OAuth
|
||||
2. 评估团队记忆、设置同步的自建可行性
|
||||
3. 预计工作量:待评估
|
||||
|
||||
---
|
||||
|
||||
## 隐私说明
|
||||
|
||||
### 硬编码绕过(方案 A)
|
||||
- **零数据外发**
|
||||
- GrowthBook SDK 不启动
|
||||
- 完全离线运行
|
||||
|
||||
### 自建 GrowthBook(方案 B)
|
||||
- 数据仅发送到你自己的服务器
|
||||
- Anthropic 无法获取任何数据
|
||||
- 可通过 Web UI 实时管理所有 flag
|
||||
|
||||
### 恢复原生 1P(方案 C)
|
||||
- 会发送使用统计到 `api.anthropic.com`
|
||||
- **不发送**:代码、对话内容、API key
|
||||
- **会发送**:邮箱、设备 ID、机器指纹、仓库哈希、订阅类型
|
||||
- 可用 `DISABLE_TELEMETRY=1` 关闭遥测(但同时关闭 GrowthBook)
|
||||
190
docs/openai-task-tools.md
Normal file
190
docs/openai-task-tools.md
Normal file
@@ -0,0 +1,190 @@
|
||||
# OpenAI兼容模型中task工具使用指南
|
||||
|
||||
## 问题描述
|
||||
|
||||
当使用OpenAI兼容模型(如DeepSeek、Ollama、vLLM等)时,调用task工具(TaskGet、TaskCreate、TaskUpdate、TaskList)可能会出现以下错误:
|
||||
|
||||
```
|
||||
Error: InputValidationError: TaskGet failed due to the following issues:
|
||||
The required parameter `taskId` is missing
|
||||
An unexpected parameter `task_id` was provided
|
||||
|
||||
This tool's schema was not sent to the API — it was not in the discovered-tool set derived from message history. Without the schema in your prompt, typed parameters (arrays, numbers, booleans) get emitted as strings and the client-side parser rejects them. Load the tool first: call ToolSearch with query "select:TaskGet", then retry this call.
|
||||
```
|
||||
|
||||
## 问题原因
|
||||
|
||||
### 1. 延迟加载工具(Deferred Tools)
|
||||
task工具都是延迟加载的(`shouldDefer: true`),这意味着:
|
||||
- 工具的模式(schema)不会在初始API调用中发送
|
||||
- 需要先通过`ToolSearch`工具发现
|
||||
- 只有在被发现后,工具模式才会被发送给API
|
||||
|
||||
### 2. 参数名转换问题
|
||||
- task工具使用驼峰命名:`taskId`
|
||||
- OpenAI兼容模型可能输出蛇形命名:`task_id`
|
||||
- 当工具模式没有被发送时,模型会猜测参数名,可能导致不匹配
|
||||
|
||||
## 解决方案
|
||||
|
||||
### 方案1:先使用ToolSearch(推荐)
|
||||
在使用task工具之前,先调用`ToolSearch`工具:
|
||||
|
||||
```javascript
|
||||
// 第一步:发现task工具
|
||||
ToolSearch("select:TaskGet,TaskCreate,TaskUpdate,TaskList")
|
||||
|
||||
// 第二步:正常使用task工具
|
||||
TaskCreate({ subject: "任务标题", description: "任务描述" })
|
||||
TaskGet({ taskId: "1" })
|
||||
TaskUpdate({ taskId: "1", status: "completed" })
|
||||
TaskList()
|
||||
```
|
||||
|
||||
### 方案2:批量发现所有task工具
|
||||
```javascript
|
||||
// 一次性发现所有task工具
|
||||
ToolSearch("select:TaskGet,TaskCreate,TaskUpdate,TaskList")
|
||||
|
||||
// 然后可以任意使用task工具
|
||||
const task = await TaskCreate({ subject: "新任务", description: "任务描述" })
|
||||
console.log(`创建的任务ID: ${task.id}`)
|
||||
|
||||
const taskList = await TaskList()
|
||||
console.log(`当前有 ${taskList.tasks.length} 个任务`)
|
||||
```
|
||||
|
||||
### 方案3:单独发现特定工具
|
||||
```javascript
|
||||
// 只发现需要的工具
|
||||
ToolSearch("select:TaskGet")
|
||||
|
||||
// 然后使用该工具
|
||||
TaskGet({ taskId: "1" })
|
||||
```
|
||||
|
||||
## 参数名注意事项
|
||||
|
||||
在使用OpenAI兼容模型时,请注意参数名格式:
|
||||
|
||||
### ✅ 正确(驼峰命名)
|
||||
```javascript
|
||||
TaskGet({ taskId: "1" })
|
||||
TaskCreate({ subject: "标题", description: "描述" })
|
||||
TaskUpdate({ taskId: "1", status: "completed" })
|
||||
```
|
||||
|
||||
### ❌ 错误(蛇形命名)
|
||||
```javascript
|
||||
TaskGet({ task_id: "1" }) // 错误:应该使用taskId
|
||||
TaskCreate({ subject: "标题", description: "描述" }) // 正确
|
||||
TaskUpdate({ task_id: "1", status: "completed" }) // 错误:应该使用taskId
|
||||
```
|
||||
|
||||
## 常见问题解答
|
||||
|
||||
### Q1: 为什么需要先使用ToolSearch?
|
||||
A: task工具是延迟加载的,它们的模式只有在被`ToolSearch`工具发现后才会发送给API。没有工具模式,模型无法知道正确的参数名和类型。
|
||||
|
||||
### Q2: 每次会话都需要使用ToolSearch吗?
|
||||
A: 是的,每次新的会话都需要先使用ToolSearch发现工具。工具发现状态不会在会话之间保留。
|
||||
|
||||
### Q3: 使用Anthropic官方模型也需要这样吗?
|
||||
A: 通常不需要。Anthropic官方模型对延迟加载工具的处理更智能,但为了兼容性,建议在使用task工具前都先使用ToolSearch。
|
||||
|
||||
### Q4: 可以一次性发现所有工具吗?
|
||||
A: 可以,使用`ToolSearch("select:TaskGet,TaskCreate,TaskUpdate,TaskList")`可以一次性发现所有task工具。
|
||||
|
||||
### Q5: 如果忘记使用ToolSearch会怎样?
|
||||
A: 会收到参数验证错误,提示需要先使用ToolSearch。按照错误信息的指导操作即可。
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **会话开始时发现工具**:在开始使用task工具前,先调用ToolSearch
|
||||
2. **批量发现**:一次性发现所有需要的task工具
|
||||
3. **检查参数名**:确保使用正确的驼峰命名参数
|
||||
4. **查看错误信息**:如果遇到错误,仔细阅读错误信息中的指导
|
||||
|
||||
## 示例工作流
|
||||
|
||||
```javascript
|
||||
// 1. 开始新会话
|
||||
// 2. 发现task工具
|
||||
ToolSearch("select:TaskGet,TaskCreate,TaskUpdate,TaskList")
|
||||
|
||||
// 3. 创建任务
|
||||
const newTask = await TaskCreate({
|
||||
subject: "修复OpenAI兼容性问题",
|
||||
description: "解决task工具在OpenAI兼容模型下的参数名问题"
|
||||
})
|
||||
|
||||
// 4. 获取任务详情
|
||||
const taskDetails = await TaskGet({ taskId: newTask.id })
|
||||
|
||||
// 5. 更新任务状态
|
||||
await TaskUpdate({
|
||||
taskId: newTask.id,
|
||||
status: "in_progress",
|
||||
activeForm: "修复OpenAI兼容性问题"
|
||||
})
|
||||
|
||||
// 6. 查看所有任务
|
||||
const allTasks = await TaskList()
|
||||
console.log(`当前有 ${allTasks.tasks.length} 个任务`)
|
||||
|
||||
// 7. 完成任务
|
||||
await TaskUpdate({
|
||||
taskId: newTask.id,
|
||||
status: "completed"
|
||||
})
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 错误:参数名不匹配
|
||||
**症状**:`taskId`参数缺失,发现`task_id`参数
|
||||
**解决**:确保使用驼峰命名的`taskId`,而不是蛇形命名的`task_id`
|
||||
|
||||
### 错误:工具模式未发送
|
||||
**症状**:`This tool's schema was not sent to the API`
|
||||
**解决**:先使用`ToolSearch("select:工具名")`发现工具
|
||||
|
||||
### 错误:工具不可用
|
||||
**症状**:工具调用失败,没有具体错误信息
|
||||
**解决**:检查工具是否启用(通过`isTodoV2Enabled()`),确保环境变量设置正确
|
||||
|
||||
## 相关配置
|
||||
|
||||
### 环境变量
|
||||
```bash
|
||||
# 启用OpenAI兼容模式
|
||||
export CLAUDE_CODE_USE_OPENAI=1
|
||||
export OPENAI_API_KEY=your-api-key
|
||||
export OPENAI_BASE_URL=https://api.deepseek.com
|
||||
|
||||
# 配置模型映射
|
||||
export OPENAI_DEFAULT_SONNET_MODEL=deepseek-chat
|
||||
export OPENAI_DEFAULT_OPUS_MODEL=deepseek-chat
|
||||
export OPENAI_DEFAULT_HAIKU_MODEL=deepseek-chat
|
||||
```
|
||||
|
||||
### 设置文件
|
||||
通过`/login`命令配置OpenAI兼容模式后,设置会保存在`~/.claude/settings.json`:
|
||||
```json
|
||||
{
|
||||
"modelType": "openai",
|
||||
"openai": {
|
||||
"baseURL": "https://api.deepseek.com",
|
||||
"apiKey": "your-api-key",
|
||||
"models": {
|
||||
"haiku": "deepseek-chat",
|
||||
"sonnet": "deepseek-chat",
|
||||
"opus": "deepseek-chat"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
在使用OpenAI兼容模型时,task工具需要先通过`ToolSearch`发现才能正常使用。遵循"先发现,后使用"的原则,并注意参数名的正确格式(驼峰命名),可以确保task工具在OpenAI兼容模型下正常工作。
|
||||
@@ -14,7 +14,9 @@ claude-code 支持通过 OpenAI Chat Completions API(`/v1/chat/completions`)
|
||||
| `OPENAI_API_KEY` | 是 | API key(Ollama 等可设为任意值) |
|
||||
| `OPENAI_BASE_URL` | 推荐 | 端点 URL(如 `http://localhost:11434/v1`) |
|
||||
| `OPENAI_MODEL` | 可选 | 覆盖所有请求的模型名(跳过映射) |
|
||||
| `OPENAI_MODEL_MAP` | 可选 | JSON 映射,如 `{"claude-sonnet-4-6":"gpt-4o"}` |
|
||||
| `OPENAI_DEFAULT_OPUS_MODEL` | 可选 | 覆盖 opus 家族对应的模型(如 `o3`, `o3-mini`, `o1-pro`) |
|
||||
| `OPENAI_DEFAULT_SONNET_MODEL` | 可选 | 覆盖 sonnet 家族对应的模型(如 `gpt-4o`, `gpt-4.1`) |
|
||||
| `OPENAI_DEFAULT_HAIKU_MODEL` | 可选 | 覆盖 haiku 家族对应的模型(如 `gpt-4o-mini`, `gpt-4.0-mini`) |
|
||||
| `OPENAI_ORG_ID` | 可选 | Organization ID |
|
||||
| `OPENAI_PROJECT_ID` | 可选 | Project ID |
|
||||
|
||||
@@ -49,11 +51,12 @@ OPENAI_BASE_URL=https://your-one-api.example.com/v1 \
|
||||
OPENAI_MODEL=gpt-4o \
|
||||
bun run dev
|
||||
|
||||
# 自定义模型映射
|
||||
# 自定义模型映射(使用家族变量)
|
||||
CLAUDE_CODE_USE_OPENAI=1 \
|
||||
OPENAI_API_KEY=sk-xxx \
|
||||
OPENAI_BASE_URL=https://my-gateway.example.com/v1 \
|
||||
OPENAI_MODEL_MAP='{"claude-sonnet-4-6":"gpt-4o-2024-11-20","claude-haiku-4-5":"gpt-4o-mini"}' \
|
||||
OPENAI_DEFAULT_SONNET_MODEL="gpt-4o-2024-11-20" \
|
||||
OPENAI_DEFAULT_HAIKU_MODEL="gpt-4o-mini" \
|
||||
bun run dev
|
||||
```
|
||||
|
||||
@@ -85,9 +88,10 @@ queryModel() [claude.ts]
|
||||
`resolveOpenAIModel()` 的解析顺序:
|
||||
|
||||
1. `OPENAI_MODEL` 环境变量 → 直接使用,覆盖所有
|
||||
2. `OPENAI_MODEL_MAP` JSON 查表 → 自定义映射
|
||||
3. 内置默认映射(见下表)
|
||||
4. 以上都不匹配 → 原名透传
|
||||
2. `OPENAI_DEFAULT_{FAMILY}_MODEL` 变量(如 `OPENAI_DEFAULT_SONNET_MODEL`)→ 按模型家族覆盖
|
||||
3. `ANTHROPIC_DEFAULT_{FAMILY}_MODEL` 变量(向后兼容)
|
||||
4. 内置默认映射(见下表)
|
||||
5. 以上都不匹配 → 原名透传
|
||||
|
||||
### 内置模型映射
|
||||
|
||||
|
||||
@@ -1,215 +1,564 @@
|
||||
---
|
||||
title: "沙箱机制 - 权限之外的第二道防线"
|
||||
description: "深入 Claude Code 沙箱机制:文件系统隔离、网络限制和资源约束,即使命令通过权限审批,沙箱仍可限制其行为范围。"
|
||||
keywords: ["沙箱", "sandbox", "文件隔离", "安全沙箱", "命令隔离"]
|
||||
title: "沙箱机制 - 权限系统之外的第二道防线"
|
||||
description: "系统性梳理 Claude Code 的沙箱设计:什么时候会进沙箱、什么时候不会、如何与权限系统联动、默认限制了什么、不同平台下行为有什么差异,以及用户在被拦截时会看到什么。"
|
||||
keywords: ["沙箱", "sandbox", "权限", "Bash", "PowerShell", "bubblewrap", "sandbox-exec", "纵深防御"]
|
||||
---
|
||||
|
||||
## 权限之外的第二道防线
|
||||
## 一句话结论
|
||||
|
||||
权限系统决定"这条命令能不能执行",沙箱决定"执行时能做到什么程度"。
|
||||
这个项目里的沙箱不是用来替代权限系统,而是用来给 **shell 命令** 再套一层 OS 级能力边界:
|
||||
|
||||
即使一条命令通过了权限审批,沙箱仍然可以限制它的行为。两者构成纵深防御的两层:
|
||||
- **权限层**(应用级):在工具调用前检查,决定是否弹窗审批
|
||||
- **沙箱层**(OS 级):在进程级别强制约束,即使 AI 生成了恶意命令也无法突破
|
||||
- 权限系统决定:这次工具调用要不要执行
|
||||
- 沙箱决定:就算执行了,这个子进程最多能碰到哪些文件、哪些网络目标
|
||||
|
||||
## 执行链路:从用户输入到沙箱包裹
|
||||
两者组合起来,才构成真正的 Defense-in-Depth。
|
||||
|
||||
一条 Bash 命令的完整执行路径如下:
|
||||
## 实现分层:仓库里的适配器,加底层运行时
|
||||
|
||||
```
|
||||
用户输入 → BashTool.call()
|
||||
→ shouldUseSandbox(input) ─── 是否需要沙箱?
|
||||
→ Shell.exec(command, { shouldUseSandbox })
|
||||
→ SandboxManager.wrapWithSandbox(command)
|
||||
→ spawn(wrapped_command) ─── 实际进程创建
|
||||
这个项目的“沙箱实现”其实分成两层:
|
||||
|
||||
- 这一层仓库自己负责:策略、配置转换、启停判断、命令包裹、清理和权限联动
|
||||
- 真正做 OS 级隔离的是外部运行时 `@anthropic-ai/sandbox-runtime`
|
||||
|
||||
在 `src/utils/sandbox/sandbox-adapter.ts` 里,可以很清楚地看到这条边界:项目导入 `SandboxManager as BaseSandboxManager`、`SandboxViolationStore` 等运行时对象,然后在外面再包一层符合 Claude Code 自身权限模型的适配器。
|
||||
|
||||
底层隔离在不同平台上的落地也不是同一套实现:
|
||||
|
||||
- macOS 走 `sandbox-exec`
|
||||
- Linux / WSL2 走 `bubblewrap + seccomp`
|
||||
- Windows 原生不支持这套 shell 沙箱
|
||||
|
||||
所以如果只看这个仓库,容易误以为“沙箱都是它自己做的”。更准确的说法是:这个仓库决定**该不该启、该怎么配、该怎么接进工具链**,真正的 OS 级约束由外部 runtime 执行。
|
||||
|
||||
## 它到底解决什么问题
|
||||
|
||||
如果只有应用层权限系统,Claude Code 需要在命令执行前尽量判断:
|
||||
|
||||
- 这条命令是不是只读
|
||||
- 会不会写危险路径
|
||||
- 会不会连到外网
|
||||
- 会不会通过复合命令、重定向、子进程、解释器脚本绕过检查
|
||||
|
||||
这些检查都很有价值,但它们本质上仍然是“执行前推断”。而 shell 命令的真实副作用经常取决于运行时行为:
|
||||
|
||||
- `bash script.sh`
|
||||
- `python -c "..."`
|
||||
- `make`
|
||||
- `npm install`
|
||||
- 某个命令再启动另一个子进程
|
||||
|
||||
沙箱的作用,就是把这些运行时行为的能力范围压缩到一个明确边界内。即使应用层检查漏了,命令也不能随意写系统目录或访问不允许的网络目标。
|
||||
|
||||
## 为什么“拦住它”本身就是价值
|
||||
|
||||
很多人第一次看到沙箱会直觉觉得:
|
||||
|
||||
> 如果连 `/etc/hosts` 这种文件都默认不让我改,那沙箱是不是没什么用?
|
||||
|
||||
这个项目的答案正好相反。沙箱不是为了让 `/etc/...` 这种系统路径也能随便改,而是为了把 shell 命令的能力压缩到一个可接受的安全边界里:
|
||||
|
||||
- 权限系统负责判断“要不要执行”
|
||||
- 沙箱负责限制“就算执行了,最多能做到什么”
|
||||
|
||||
`/etc/...` 被默认拦住,说明这条边界真的在生效,而不是说明沙箱没价值。更具体地说,沙箱至少补上了 4 件权限系统单独做不好的事。
|
||||
|
||||
### 1. 给 shell 一个 OS 级兜底
|
||||
|
||||
`src/utils/bash/ast.ts` 开头就写得很明确:Bash AST 分析不是沙箱,它只是在判断我们能不能可靠地理解命令结构,不能阻止危险命令真的运行。
|
||||
|
||||
这就是为什么应用层再聪明,也很难仅靠“执行前推断”覆盖完整风险面。像下面这些命令,真实副作用都要到运行时才完全展开:
|
||||
|
||||
- `bash script.sh`
|
||||
- `python -c "..."`
|
||||
- `make`
|
||||
- `npm install`
|
||||
- 一个命令再起新的子进程
|
||||
|
||||
沙箱的价值就在这里。即使前面的分析漏了,进程到了 OS 层以后,仍然只能写允许目录、访问允许域名,真正把 shell 的能力压缩进运行时边界。
|
||||
|
||||
### 2. 让“安全边界内”的命令可以少弹窗甚至自动放行
|
||||
|
||||
默认沙箱白名单里就包含当前工作目录和 Claude 临时目录,这也是为什么工作区内的大多数开发命令都能顺畅运行:
|
||||
|
||||
- `npm test`
|
||||
- `rg`
|
||||
- `git status`
|
||||
- 工作区内的构建、测试和生成文件
|
||||
|
||||
项目专门提供了 `autoAllowBashIfSandboxed`。它的核心思路不是“更大胆地信任模型”,而是“既然命令已经被 OS 级边界收紧,就没必要再让用户为大量低风险 Bash 命令反复点确认”。
|
||||
|
||||
换句话说,没有沙箱的话,系统通常只剩两种都不太理想的选择:
|
||||
|
||||
- 频繁弹窗,让工作流很碎
|
||||
- 更激进地信任应用层判断,把风险全压在静态分析上
|
||||
|
||||
### 3. 把“出错”的后果从系统级破坏,降成一次受限失败
|
||||
|
||||
这也是 Defense-in-Depth 最实际的一层收益。模型偶尔会出错,应用层规则也可能有漏判。沙箱的意义不是假设前面永远正确,而是即使前面偶尔判错,后果也尽量可控。
|
||||
|
||||
例如这类命令:
|
||||
|
||||
- `sudo tee /etc/hosts`
|
||||
- `mv ... ~/.ssh/...`
|
||||
- `curl 外网 | bash`
|
||||
|
||||
如果它们发生在没有运行时约束的环境里,可能就是直接修改系统、用户配置或把未知脚本落到机器上。放进沙箱之后,更常见的结果会变成:因为写权限或网络权限不满足而失败。它不是“什么都没发生”,而是把一次潜在的系统级破坏降成一次受限失败。
|
||||
|
||||
### 4. 拦截运行时绕过和逃逸路径
|
||||
|
||||
这个仓库在 `src/utils/sandbox/sandbox-adapter.ts` 里专门把一些高风险路径额外加入 `denyWrite`,例如:
|
||||
|
||||
- `settings.json`
|
||||
- `.claude/skills`
|
||||
- 一些 bare git repo 相关路径
|
||||
|
||||
它还专门处理 bare git repo 逃逸这一类攻击面。它们的意义不是“让更多命令通过”,而是“即使命令已经执行,也别让它顺手把护栏本身拆掉”,避免通过改配置、改技能、改 git 结构来扩大后续权限。
|
||||
|
||||
所以更准确的表述不是:
|
||||
|
||||
- “沙箱把 `/etc` 拦了,所以没用”
|
||||
|
||||
而是:
|
||||
|
||||
- “沙箱把 shell 的默认权限收缩到工作区和白名单里,因此系统级路径默认写不了;正因为这样,项目才敢把一大批工作区内命令自动放行。”
|
||||
|
||||
## 设计边界:它保护什么,不保护什么
|
||||
|
||||
### 保护对象
|
||||
|
||||
- Bash / shell 命令执行
|
||||
- 在支持平台上的 PowerShell 执行
|
||||
- shell 子进程的文件系统写入范围
|
||||
- shell 子进程的网络访问范围
|
||||
- 一些已知的高风险路径和沙箱逃逸向量
|
||||
|
||||
### 不直接保护的对象
|
||||
|
||||
- `FileEditTool` / `FileWriteTool` 这类直接文件工具
|
||||
- 纯应用层的权限弹窗和规则匹配
|
||||
- Bash AST 解析本身
|
||||
|
||||
尤其要注意一点:Bash AST 分析不是沙箱。源码自己写得很明确,它只回答“我们能不能可信地提取 argv 结构”,并不负责阻止危险命令真正运行。
|
||||
|
||||
## 哪些场景会走沙箱
|
||||
|
||||
### 1. 启动阶段先判断“沙箱能不能用”
|
||||
|
||||
沙箱不是等到第一条命令执行时才临时判断的。REPL / CLI 启动时,就会先检查当前环境是否真的具备沙箱条件。核心判断包括:
|
||||
|
||||
1. 当前平台是否受底层 runtime 支持
|
||||
2. 依赖是否齐全
|
||||
3. `sandbox.enabled` 是否打开
|
||||
4. 当前平台是否落在 `enabledPlatforms` 范围内
|
||||
|
||||
如果用户显式开启了沙箱,但当前环境不满足条件,启动期会先给出 warning;如果同时配置了 `sandbox.failIfUnavailable`,则会直接拒绝启动,而不是悄悄降级成无沙箱模式。
|
||||
|
||||
另外,启动时不只是“看一眼能不能用”,而是真的会调用初始化流程,把当前设置转换成 runtime 配置并交给底层 `BaseSandboxManager.initialize(...)`。后续如果设置变化,还会通过 `updateConfig(...)` 热更新,而不是要求重启整个会话。
|
||||
|
||||
### 2. BashTool 默认会走
|
||||
|
||||
只要满足下面条件,Bash 命令默认会进入沙箱:
|
||||
|
||||
1. 当前平台支持沙箱
|
||||
2. 沙箱依赖齐全
|
||||
3. `sandbox.enabled` 打开
|
||||
4. 当前平台在 `enabledPlatforms` 范围内
|
||||
5. 这条命令没有被显式排除
|
||||
6. 这次调用没有被允许以 `dangerouslyDisableSandbox` 绕过
|
||||
|
||||
对应入口在 `src/tools/BashTool/shouldUseSandbox.ts` 和 `src/utils/sandbox/sandbox-adapter.ts`。
|
||||
|
||||
### 3. PowerShell 只在支持平台上走
|
||||
|
||||
PowerShell 的处理要更细一点:
|
||||
|
||||
- Linux / macOS / WSL2:可以走沙箱
|
||||
- Windows 原生:不支持沙箱,直接返回 `shouldUseSandbox: false`
|
||||
|
||||
也就是说,Windows 原生上的 PowerShell 只能依赖权限系统,不会有 OS 级沙箱兜底。
|
||||
|
||||
### 4. Hook 命令会复用“网络专用沙箱”
|
||||
|
||||
Hook 不是完整复用 Bash 那套文件系统限制,而是额外套了一层 **network-only sandbox**:
|
||||
|
||||
- 重点拦网络访问
|
||||
- 文件系统不额外收紧到 Bash 那个程度
|
||||
|
||||
这是因为 Hook 往往不是模型直接下发的 Bash 工具调用,而是系统/插件的外部扩展点。
|
||||
|
||||
## 哪些场景不会走沙箱
|
||||
|
||||
### 1. FileEditTool / FileWriteTool
|
||||
|
||||
这类工具不是靠 shell 修改文件,而是直接在应用层做文件 I/O,所以它们不通过 `Shell.exec()`,自然也不会被 `wrapWithSandbox()` 包裹。
|
||||
|
||||
它们走的是另一条链路:
|
||||
|
||||
- `checkWritePermissionForTool()`
|
||||
- `checkPathSafetyForAutoEdit()`
|
||||
- 工作目录检查
|
||||
- allow/ask/deny 规则
|
||||
|
||||
因此:
|
||||
|
||||
- “shell 改 `/etc/hosts`”通常是沙箱在 OS 层拦
|
||||
- “FileEdit 改 `/etc/hosts`”通常是权限系统在应用层拦
|
||||
|
||||
### 2. 明确排除的命令
|
||||
|
||||
如果命中 `sandbox.excludedCommands`,这条命令会直接跳过沙箱。
|
||||
|
||||
支持三类模式:
|
||||
|
||||
- 精确匹配
|
||||
- 前缀匹配
|
||||
- 通配符匹配
|
||||
|
||||
### 3. 允许 unsandboxed fallback 的命令
|
||||
|
||||
如果:
|
||||
|
||||
- 这次调用显式设置了 `dangerouslyDisableSandbox: true`
|
||||
- 并且策略允许 `allowUnsandboxedCommands`
|
||||
|
||||
那它也可以不进沙箱。
|
||||
|
||||
这个设计是有意保留的,但命名也故意写得很重:`dangerouslyDisableSandbox`,提醒这是例外路径,不应当成为默认习惯。
|
||||
|
||||
## 完整执行链路
|
||||
|
||||
可以把整个过程拆成两段来看:启动期先把沙箱准备好,命令期再决定“这条命令要不要进去”。
|
||||
|
||||
### 启动期链路
|
||||
|
||||
```text
|
||||
REPL / CLI 启动
|
||||
-> isSandboxingEnabled()
|
||||
-> convertToSandboxRuntimeConfig(settings)
|
||||
-> BaseSandboxManager.initialize(runtimeConfig, callback)
|
||||
-> 设置变化时 BaseSandboxManager.updateConfig(newConfig)
|
||||
```
|
||||
|
||||
关键判定发生在 `shouldUseSandbox()`(`src/tools/BashTool/shouldUseSandbox.ts`),它执行以下检查:
|
||||
这一段回答的是:当前会话里有没有一个可用、已初始化、能处理网络授权回调的沙箱 runtime。
|
||||
|
||||
1. **全局开关**:`SandboxManager.isSandboxingEnabled()` — 检查平台支持 + 依赖完整性 + 用户设置
|
||||
2. **显式跳过**:如果 `dangerouslyDisableSandbox: true` 且策略允许(`allowUnsandboxedCommands`),则不走沙箱
|
||||
3. **排除列表**:用户可在 `settings.json` 中配置 `sandbox.excludedCommands`,匹配的命令跳过沙箱
|
||||
4. **默认行为**:以上条件都不满足时,**进入沙箱**
|
||||
### 命令期链路
|
||||
|
||||
## `shouldUseSandbox()` 判定逻辑详解
|
||||
典型 Bash 执行链路如下:
|
||||
|
||||
```typescript
|
||||
// src/tools/BashTool/shouldUseSandbox.ts
|
||||
function shouldUseSandbox(input: Partial<SandboxInput>): boolean {
|
||||
// 1. 全局未启用 → 直接跳过
|
||||
if (!SandboxManager.isSandboxingEnabled()) return false
|
||||
|
||||
// 2. 显式禁用 + 策略允许 → 跳过
|
||||
if (input.dangerouslyDisableSandbox &&
|
||||
SandboxManager.areUnsandboxedCommandsAllowed()) return false
|
||||
|
||||
// 3. 无命令 → 跳过
|
||||
if (!input.command) return false
|
||||
|
||||
// 4. 匹配排除列表 → 跳过
|
||||
if (containsExcludedCommand(input.command)) return false
|
||||
|
||||
// 5. 其他情况 → 必须沙箱化
|
||||
return true
|
||||
}
|
||||
```text
|
||||
用户请求
|
||||
-> BashTool.checkPermissions()
|
||||
-> shouldUseSandbox(input)
|
||||
-> Shell.exec(command, { shouldUseSandbox: true/false })
|
||||
-> SandboxManager.wrapWithSandbox(...)
|
||||
-> spawn(wrapped command)
|
||||
-> 运行结束后 cleanupAfterCommand()
|
||||
```
|
||||
|
||||
`containsExcludedCommand()` 的匹配机制值得注意——它不只是简单的前缀匹配,而是支持三种模式:
|
||||
这里真正把命令“包进沙箱”的关键点是 `Shell.exec()`。它会在真正 `spawn(...)` 之前调用 `SandboxManager.wrapWithSandbox(...)`,把原始命令改写成底层 runtime 可执行的沙箱命令串。命令结束后如果本次是 sandboxed execution,再调用 `cleanupAfterCommand()` 清理运行时残留。
|
||||
|
||||
| 模式 | 示例 | 匹配行为 |
|
||||
|------|------|----------|
|
||||
| **精确匹配** | `npm run lint` | 完全相等 |
|
||||
| **前缀匹配** | `npm run test:*` | 前缀 + 空格或完全相等 |
|
||||
| **通配符** | `docker*` | 使用 `matchWildcardPattern` |
|
||||
其中有两个容易混淆的判定点:
|
||||
|
||||
对于复合命令(如 `docker ps && curl evil.com`),系统会先拆分为子命令,逐一检查。还会迭代剥离环境变量前缀(`FOO=bar bazel ...`)和包装命令(`timeout 30 bazel ...`),直到不动点——防止通过嵌套包装绕过。
|
||||
### 判定点 A:要不要进沙箱
|
||||
|
||||
## 沙箱的配置模型
|
||||
这是 `shouldUseSandbox()` 的职责。
|
||||
|
||||
沙箱配置来自 `settings.json` 中的 `sandbox` 字段(`src/entrypoints/sandboxTypes.ts`):
|
||||
它回答的是:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"sandbox": {
|
||||
"enabled": true, // 主开关
|
||||
"autoAllowBashIfSandboxed": true, // 沙箱中的命令自动允许(跳过审批)
|
||||
"allowUnsandboxedCommands": true, // 是否允许 dangerouslyDisableSandbox
|
||||
"failIfUnavailable": false, // 沙箱依赖缺失时是否报错退出
|
||||
|
||||
"network": {
|
||||
"allowedDomains": ["github.com"], // 网络白名单
|
||||
"deniedDomains": [], // 网络黑名单
|
||||
"allowLocalBinding": true, // 允许 localhost 绑定
|
||||
"httpProxyPort": 8888 // HTTP 代理端口(MITM)
|
||||
},
|
||||
|
||||
"filesystem": {
|
||||
"allowWrite": ["~/projects"], // 额外可写路径
|
||||
"denyWrite": ["~/.ssh"], // 禁止写入路径
|
||||
"denyRead": [], // 禁止读取路径
|
||||
"allowRead": [] // 在 denyRead 中重新放行
|
||||
},
|
||||
|
||||
"excludedCommands": ["docker", "npm:*"] // 不走沙箱的命令
|
||||
}
|
||||
}
|
||||
```
|
||||
> 这条命令要不要被 OS 级沙箱包起来执行?
|
||||
|
||||
`SandboxSettingsSchema` 定义了完整的 Zod 验证规则,包含一些未公开的设置如 `enabledPlatforms`(限制沙箱只在特定平台生效)。
|
||||
### 判定点 B:这条命令要不要弹权限确认
|
||||
|
||||
## 平台实现差异
|
||||
这是权限系统和 Bash 权限检查的职责。
|
||||
|
||||
### macOS:sandbox-exec(Seatbelt)
|
||||
它回答的是:
|
||||
|
||||
macOS 使用 Apple 的 Seatbelt 沙箱(`sandbox-exec` 命令),这是 macOS 原生的进程隔离机制。
|
||||
> 这条命令在应用层看来,是 `allow`、`ask` 还是 `deny`?
|
||||
|
||||
执行流程:
|
||||
1. `SandboxManager.wrapWithSandbox()` 调用 `@anthropic-ai/sandbox-runtime` 的 `BaseSandboxManager`
|
||||
2. 运行时生成 Seatbelt profile(基于配置中的网络/文件系统规则)
|
||||
3. 通过 `sandbox-exec -p <profile> -- <command>` 包裹原始命令
|
||||
4. Seatbelt 在内核级别强制执行约束
|
||||
这两个判定点是并列协作的,不是互相替代的。
|
||||
|
||||
网络隔离的实现方式:
|
||||
- 通过代理端口拦截 HTTP/HTTPS 请求
|
||||
- 域名白名单/黑名单在代理层过滤
|
||||
- Unix socket 可单独配置允许路径
|
||||
## 默认沙箱到底限制了什么
|
||||
|
||||
### Linux:bubblewrap(bwrap)+ seccomp
|
||||
沙箱运行时配置最终由 `convertToSandboxRuntimeConfig()` 生成。它会把项目自己的设置、权限规则和安全加固逻辑,转换成底层运行时需要的配置。
|
||||
|
||||
Linux 使用 `bubblewrap`(bwrap)创建命名空间隔离,配合 seccomp 过滤系统调用:
|
||||
这一步很关键,因为这个项目的沙箱配置不是一份静态表,而是从 Claude Code 自己的权限系统里“翻译”出来的。
|
||||
|
||||
依赖项(`apt install`):
|
||||
| 包 | 作用 |
|
||||
|----|------|
|
||||
| `bubblewrap` | 创建 mount/PID/network 命名空间 |
|
||||
| `socat` | 网络代理(HTTP/SOCKS) |
|
||||
| `libseccomp` / seccomp filter | 过滤 Unix socket 系统调用 |
|
||||
### 这些限制是怎么从权限系统推导出来的
|
||||
|
||||
bwrap 的实现差异:
|
||||
- **不支持 glob 路径模式**(macOS 的 Seatbelt 支持)— Linux 上带 glob 的权限规则会触发警告
|
||||
- 执行后会在当前目录留下 0 字节的 mount-point 文件(如 `.bashrc`),需要 `cleanupAfterCommand()` 清理
|
||||
- seccomp 无法按路径过滤 Unix socket(只能全允许或全拒绝),与 macOS 的按路径放行形成差异
|
||||
- `WebFetch(domain:...)` 和 `sandbox.network.allowedDomains` 会被合并成网络白名单
|
||||
- `Edit(...)` / `Read(...)` 这类权限规则会被翻译成文件系统读写限制
|
||||
- `sandbox.filesystem.allowWrite` / `allowRead` / `denyWrite` / `denyRead` 会继续叠加到最终 runtime 配置上
|
||||
|
||||
### 平台支持矩阵
|
||||
也就是说,沙箱不是独立维护另一套完全平行的安全策略,而是把“Claude 认为哪些路径或域名应该被允许”落地成 OS 级约束。
|
||||
|
||||
| 特性 | macOS | Linux | WSL |
|
||||
|------|-------|-------|-----|
|
||||
| 沙箱引擎 | sandbox-exec (Seatbelt) | bubblewrap + seccomp | 仅 WSL2 |
|
||||
| 文件 glob | ✅ 完整支持 | ⚠️ 仅 `/**` 后缀 | 同 Linux |
|
||||
| 网络 Unix socket 按路径 | ✅ | ❌ | ❌ |
|
||||
| 依赖检查 | ripgrep | bwrap + socat + ripgrep + seccomp | 同 Linux |
|
||||
### 文件系统默认写入范围
|
||||
|
||||
## 沙箱初始化流程
|
||||
默认 `allowWrite` 只有两类:
|
||||
|
||||
```
|
||||
REPL/SDK 启动
|
||||
→ main.tsx → init.ts
|
||||
→ SandboxManager.initialize(sandboxAskCallback)
|
||||
→ detectWorktreeMainRepoPath() // 检测 git worktree,放行主仓库 .git
|
||||
→ convertToSandboxRuntimeConfig() // 构建 SandboxRuntimeConfig
|
||||
→ BaseSandboxManager.initialize() // 启动底层运行时
|
||||
→ settingsChangeDetector.subscribe() // 订阅设置变更,动态更新配置
|
||||
```
|
||||
- 当前工作目录 `.`
|
||||
- Claude 的临时目录
|
||||
|
||||
`convertToSandboxRuntimeConfig()`(`src/utils/sandbox/sandbox-adapter.ts`)完成从用户设置到运行时配置的转换:
|
||||
这意味着:
|
||||
|
||||
1. **网络规则**:从 `WebFetch(domain:...)` 权限规则提取域名 → `allowedDomains`
|
||||
2. **文件系统规则**:从 `Edit(...)` / `Read(...)` 权限规则提取路径 → `allowWrite` / `denyWrite` / `denyRead`
|
||||
3. **安全加固**:
|
||||
- 自动将项目目录加入 `allowWrite`
|
||||
- 自动将 `settings.json` 路径加入 `denyWrite`(防止沙箱逃逸)
|
||||
- 自动将 `.claude/skills` 加入 `denyWrite`(防止技能注入)
|
||||
- 检测 bare git repo 攻击向量,对 `HEAD`/`objects`/`refs` 做保护
|
||||
- 工作区内的构建、测试、生成临时文件通常能正常运行
|
||||
- 根路径如 `/etc/...`、`/usr/...`、`/var/...` 默认不在写白名单里
|
||||
|
||||
## `dangerouslyDisableSandbox` 的设计权衡
|
||||
### 文件系统额外写入来源
|
||||
|
||||
这个参数的命名本身就传达了设计意图——它不是"关闭沙箱",而是"**危险地禁用沙箱**"。
|
||||
额外允许写入的路径,主要来自这些来源:
|
||||
|
||||
双重保险机制:
|
||||
1. **调用侧**:模型在 BashTool 的 `inputSchema` 中可以设置 `dangerouslyDisableSandbox: true`
|
||||
2. **策略侧**:管理员可通过 `allowUnsandboxedCommands: false` 完全禁止此参数(企业部署场景)
|
||||
- `sandbox.filesystem.allowWrite`
|
||||
- `Edit(...)` 规则推导出的路径
|
||||
- `/add-dir` 或 `--add-dir` 增加的目录
|
||||
- git worktree 主仓库所需路径
|
||||
|
||||
```typescript
|
||||
// 即使 AI 请求了 dangerouslyDisableSandbox,策略层仍可覆盖
|
||||
if (input.dangerouslyDisableSandbox &&
|
||||
SandboxManager.areUnsandboxedCommandsAllowed()) {
|
||||
return false // 只有策略允许时才真正跳过沙箱
|
||||
}
|
||||
```
|
||||
这里还有一个很容易漏掉的细节:适配层会专门处理 worktree 主仓库和 bare git repo 这种仓库级特殊路径,避免在隔离后把正常开发流程误伤,或者反过来留下逃逸面。
|
||||
|
||||
`autoAllowBashIfSandboxed` 进一步补充了这个模型:当启用时,**在沙箱中的命令自动获得执行许可**,无需逐条审批。这基于一个信任假设——如果 OS 级沙箱已经限制了命令的能力,那么应用层的逐条审批就变得多余。
|
||||
### 强制 deny 的路径
|
||||
|
||||
## 沙箱违规处理
|
||||
即使有别的配置,项目还会额外加固一些高风险路径,例如:
|
||||
|
||||
当命令尝试违反沙箱约束时:
|
||||
- settings 文件
|
||||
- `.claude/skills`
|
||||
- 一些 bare git repo 相关路径
|
||||
|
||||
1. 运行时捕获违规事件(文件/网络访问被拒绝)
|
||||
2. `SandboxManager.annotateStderrWithSandboxFailures()` 在输出中注入 `<sandbox_violations>` 标签
|
||||
3. UI 层通过 `removeSandboxViolationTags()` 清理显示
|
||||
4. 违规事件通过 `SandboxViolationStore` 持久化,可用于审计
|
||||
这样做的原因是:这些路径一旦可写,攻击者可能反过来修改 Claude Code 自己的配置、技能或 git 行为,从而扩大权限。
|
||||
|
||||
## 完整执行链路示例
|
||||
### 网络限制
|
||||
|
||||
以 `npm install` 为例:
|
||||
网络白名单来自两部分:
|
||||
|
||||
```
|
||||
1. 用户在 REPL 中输入 → Claude 决定调用 BashTool
|
||||
2. BashTool.validateInput() → 通过
|
||||
3. BashTool.checkPermissions() → 检查权限规则
|
||||
├── autoAllowBashIfSandboxed = true 且沙箱可用 → 自动允许
|
||||
└── 否则 → 弹窗请用户确认
|
||||
4. BashTool.call() → runShellCommand()
|
||||
5. shouldUseSandbox({ command: "npm install" })
|
||||
├── SandboxManager.isSandboxingEnabled() → true
|
||||
├── dangerouslyDisableSandbox → undefined
|
||||
└── containsExcludedCommand() → false(除非用户配置了排除 npm)
|
||||
→ 结果: true,需要沙箱
|
||||
6. Shell.exec() → SandboxManager.wrapWithSandbox("npm install")
|
||||
├── macOS: sandbox-exec -p <generated-profile> -- bash -c 'npm install'
|
||||
└── Linux: bwrap ... bash -c 'npm install'
|
||||
7. spawn(wrapped_command) → 子进程在沙箱内执行
|
||||
8. 执行完成 → SandboxManager.cleanupAfterCommand()
|
||||
├── 清理 bwrap 残留文件(Linux)
|
||||
└── scrubBareGitRepoFiles()(安全清理)
|
||||
9. 结果返回给 Claude → 展示给用户
|
||||
```
|
||||
- `sandbox.network.allowedDomains`
|
||||
- `WebFetch(domain:...)` 这类权限规则
|
||||
|
||||
被允许的域名会进入沙箱网络配置;不在白名单里的访问,在运行时会被拦截或触发额外的网络授权流程。
|
||||
|
||||
## `autoAllowBashIfSandboxed` 的真实意义
|
||||
|
||||
这是沙箱设计里最值得注意的开关之一。
|
||||
|
||||
它表达的是这样一个信任假设:
|
||||
|
||||
> 如果命令已经被 OS 级沙箱约束在安全边界内,那么应用层就没有必要再对大量低风险 Bash 命令逐条弹确认框。
|
||||
|
||||
因此,当这个开关开启时:
|
||||
|
||||
- 命令会先检查显式 `deny` / `ask` 规则
|
||||
- 如果没有命中这些硬规则
|
||||
- 且命令确实会在沙箱里执行
|
||||
- 那么 BashTool 可以直接自动允许它运行
|
||||
|
||||
这里还有一个边界条件特别值得写清楚:它只对“真正会进沙箱的命令”生效。像这些情况,仍然不能直接吃到这个 shortcut:
|
||||
|
||||
- 命中了 `excludedCommands`
|
||||
- 显式使用了 `dangerouslyDisableSandbox: true`
|
||||
- 当前平台根本不支持沙箱
|
||||
|
||||
这些命令依然要遵守正常的 `ask` 规则,因为它们没有拿到 OS 级约束带来的那层安全兜底。
|
||||
|
||||
这也是沙箱存在的一个核心产品价值:不是让更多危险操作通过,而是让更多**受限范围内的常规命令**可以无感运行。
|
||||
|
||||
## 为什么“沙箱把 `/etc` 拦了”反而说明它有用
|
||||
|
||||
前面的“四个核心价值”解释的是原理,这里把结论再落回最常见的直觉疑问上:为什么一个默认不让你写 `/etc` 的系统,反而更值得信任?
|
||||
|
||||
因为 Claude Code 日常最常跑的不是系统管理命令,而是开发命令。例如:
|
||||
|
||||
- `npm test`
|
||||
- `npm install`
|
||||
- `cargo build`
|
||||
- `pytest`
|
||||
- `rg`
|
||||
- `git status`
|
||||
|
||||
这些命令本来就应该只在工作区和少量临时目录里活动。沙箱把 shell 的默认能力收缩到这个范围后,项目才敢在应用层减少弹窗、启用 `autoAllowBashIfSandboxed`、提高自动化程度。
|
||||
|
||||
所以这个问题的正确落点不是“它为什么不帮我改 `/etc`”,而是“它能不能在不碰 `/etc` 的前提下,让大量正常开发命令更安全、更顺滑地运行”。从这个角度看,`/etc` 默认写不了并不是缺点,而是整个自动化体验成立的前提。
|
||||
|
||||
## 平台差异
|
||||
|
||||
### macOS
|
||||
|
||||
- 底层使用 `sandbox-exec`
|
||||
- 路径和网络规则通过 Seatbelt profile 落地
|
||||
- 属于原生 OS 级进程隔离
|
||||
|
||||
### Linux
|
||||
|
||||
- 底层使用 `bubblewrap + seccomp`
|
||||
- 会建立 mount / PID / network 等隔离
|
||||
- Linux 上对 glob 路径的支持比 macOS 弱一些
|
||||
- 某些运行后残留需要在 `cleanupAfterCommand()` 中清理
|
||||
|
||||
### WSL
|
||||
|
||||
- 只支持 WSL2
|
||||
- WSL1 视为不支持平台
|
||||
|
||||
### Windows 原生
|
||||
|
||||
- 原生 PowerShell/Bash 不支持这个沙箱体系
|
||||
- 因此只能依赖权限系统和工具级检查
|
||||
|
||||
这也是为什么你前面问“改 C 盘文件会不会走沙箱”时,答案会分成:
|
||||
|
||||
- Windows 原生:通常不走
|
||||
- Linux/macOS/WSL2:shell 才可能走
|
||||
|
||||
## 工作区内外:应用层与沙箱层如何配合
|
||||
|
||||
### 工作区内路径
|
||||
|
||||
工作区内路径通常有两层保护:
|
||||
|
||||
1. 应用层权限检查
|
||||
2. 沙箱默认允许写当前工作目录
|
||||
|
||||
这使得“工作区内构建/测试/格式化/生成文件”成为最顺滑的一条路径。
|
||||
|
||||
### 工作区外路径
|
||||
|
||||
工作区外路径则更严格:
|
||||
|
||||
- 应用层通常会视为高风险,要求确认或阻止
|
||||
- 即使应用层允许,如果不在沙箱白名单里,运行时也会失败
|
||||
|
||||
这就形成了双保险。
|
||||
|
||||
### Linux 根路径 `/etc/...`
|
||||
|
||||
对于 Linux 上的根路径文件,通常会出现两种情况:
|
||||
|
||||
- **shell 路径**:命令会进沙箱,但沙箱默认没有 `/etc` 写权限,所以运行时被拦
|
||||
- **文件工具路径**:不走沙箱,而是在应用层直接被文件权限检查拦住
|
||||
|
||||
## 用户真的会看到什么
|
||||
|
||||
被拦截并不是同一种体验,至少有三类。
|
||||
|
||||
### 1. 执行前的权限确认
|
||||
|
||||
如果应用层在执行前就判定为 `ask`,用户会看到标准权限对话框:
|
||||
|
||||
- Bash 权限确认
|
||||
- FileEdit / FileWrite 权限确认
|
||||
- 其他工具自己的权限确认 UI
|
||||
|
||||
这种提示发生在命令还没真正运行之前。
|
||||
|
||||
### 2. 执行中的沙箱违规
|
||||
|
||||
如果命令已经进入沙箱,运行时才触发违规:
|
||||
|
||||
- 命令会失败
|
||||
- stderr 会被附加 `<sandbox_violations>` 标签供模型理解
|
||||
- UI 会清理这些标签再显示给用户
|
||||
- 同时 `SandboxViolationStore` 会记录违规事件
|
||||
|
||||
这意味着用户通常能看到:
|
||||
|
||||
- 命令失败本身
|
||||
- 以及“最近有多少次 sandbox blocked”之类的界面提示
|
||||
|
||||
### 3. 网络越界请求
|
||||
|
||||
网络是个特例。
|
||||
|
||||
当沙箱外的 host 访问需要额外确认时,项目会弹出一个专门的网络授权对话框,例如:
|
||||
|
||||
- `Network request outside of sandbox`
|
||||
|
||||
这里和文件系统运行时拦截不同,它有明确的交互式授权 UI。
|
||||
|
||||
## 为什么文件系统越界通常不弹“再放行一次”
|
||||
|
||||
这是一个非常有意的设计选择。
|
||||
|
||||
对文件系统来说,项目更倾向于:
|
||||
|
||||
- 执行前在应用层 ask
|
||||
- 或者执行后让命令直接因沙箱失败
|
||||
|
||||
而不是在运行到一半时再弹出一个“是否允许写这个系统路径”的新对话框。
|
||||
|
||||
这样做的好处是:
|
||||
|
||||
- 边界更稳定
|
||||
- 用户心智更清晰
|
||||
- 不容易把 shell 运行时逐步升级成越来越宽松的环境
|
||||
|
||||
网络访问则更适合做按 host 的临时授权,因此单独做了授权对话框。
|
||||
|
||||
## 常见误区
|
||||
|
||||
### 误区 1:沙箱会保护所有文件修改
|
||||
|
||||
不是。它主要保护 **shell 子进程**。
|
||||
|
||||
直接文件编辑工具走的是应用层权限系统,不是 shell 沙箱。
|
||||
|
||||
### 误区 2:只要启用了沙箱,就不会再需要权限系统
|
||||
|
||||
不是。沙箱只限制进程能力,不负责解释用户意图、路径安全语义、工具模式、审批体验。
|
||||
|
||||
项目之所以还保留复杂的 `allow / ask / deny` 体系,就是因为两者职责不同。
|
||||
|
||||
### 误区 3:如果某个危险操作被沙箱拦住,就说明应用层检查没价值
|
||||
|
||||
不是。应用层检查的价值在于:
|
||||
|
||||
- 更早提示
|
||||
- 更好的用户体验
|
||||
- 更细的语义判断
|
||||
- 对不走 shell 的工具同样生效
|
||||
|
||||
而沙箱负责的是最终兜底。
|
||||
|
||||
## 推荐的阅读路径
|
||||
|
||||
如果你想继续顺着源码深入,推荐按下面顺序看:
|
||||
|
||||
1. `src/tools/BashTool/shouldUseSandbox.ts`
|
||||
2. `src/utils/Shell.ts`
|
||||
3. `src/utils/sandbox/sandbox-adapter.ts`
|
||||
4. `src/utils/permissions/permissions.ts`
|
||||
5. `src/tools/BashTool/bashPermissions.ts`
|
||||
6. `src/utils/permissions/pathValidation.ts`
|
||||
7. `src/utils/permissions/filesystem.ts`
|
||||
|
||||
按这条线读,会更容易把“权限系统”和“沙箱系统”在脑中拆开。
|
||||
|
||||
## FAQ
|
||||
|
||||
### Q1:Linux 下 `echo hi > /etc/hosts` 会怎样?
|
||||
|
||||
如果是 BashTool:
|
||||
|
||||
- 通常会进沙箱
|
||||
- 默认沙箱不允许写 `/etc`
|
||||
- 所以命令会在运行时失败
|
||||
|
||||
如果是 FileEditTool:
|
||||
|
||||
- 不进沙箱
|
||||
- 通常会在应用层文件权限检查里先被拦下
|
||||
|
||||
### Q2:Windows 下改 `C:\Windows\System32\drivers\etc\hosts` 会怎样?
|
||||
|
||||
在 Windows 原生环境里,通常没有这套 shell 沙箱兜底,所以主要依赖应用层权限系统和工具自己的检查逻辑。
|
||||
|
||||
### Q3:既然沙箱这么强,为什么还保留 `dangerouslyDisableSandbox`?
|
||||
|
||||
因为有些真实开发任务确实需要越过默认边界,例如:
|
||||
|
||||
- 访问未加入白名单的工具链目录
|
||||
- 调试系统级环境
|
||||
- 做管理员明确允许的例外操作
|
||||
|
||||
但项目把这个入口做得非常显眼,也允许管理员通过策略直接禁掉,避免它变成默认路径。
|
||||
|
||||
### Q4:什么时候最能感受到沙箱的价值?
|
||||
|
||||
当你开启 `autoAllowBashIfSandboxed` 时最明显。
|
||||
|
||||
这时大量工作区内命令可以少弹窗甚至不弹窗,但即使模型偶尔给出过界命令,系统级写入和网络能力仍然被边界限制住。
|
||||
|
||||
@@ -152,7 +152,8 @@
|
||||
"pages": [
|
||||
"docs/features/token-budget",
|
||||
"docs/features/context-collapse",
|
||||
"docs/features/workflow-scripts"
|
||||
"docs/features/workflow-scripts",
|
||||
"docs/features/auto-dream"
|
||||
]
|
||||
},
|
||||
"docs/features/tier3-stubs"
|
||||
|
||||
13
package.json
13
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-code-best",
|
||||
"version": "1.0.3",
|
||||
"version": "1.1.0",
|
||||
"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>",
|
||||
@@ -34,7 +34,8 @@
|
||||
],
|
||||
"files": [
|
||||
"dist",
|
||||
"scripts/download-ripgrep.ts"
|
||||
"scripts/download-ripgrep.ts",
|
||||
"scripts/postinstall.cjs"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "bun run build.ts",
|
||||
@@ -48,12 +49,13 @@
|
||||
"test": "bun test",
|
||||
"check:unused": "knip-bun",
|
||||
"health": "bun run scripts/health-check.ts",
|
||||
"postinstall": "node dist/download-ripgrep.js || bun run scripts/download-ripgrep.ts || true",
|
||||
"docs:dev": "npx mintlify dev"
|
||||
"postinstall": "node scripts/postinstall.cjs",
|
||||
"docs:dev": "npx mintlify dev",
|
||||
"rcs": "bun run scripts/rcs.ts"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"openai": "^4.73.0",
|
||||
"openai": "^6.33.0",
|
||||
"@alcalzone/ansi-tokenize": "^0.3.0",
|
||||
"@ant/claude-for-chrome-mcp": "workspace:*",
|
||||
"@ant/computer-use-input": "workspace:*",
|
||||
@@ -130,6 +132,7 @@
|
||||
"highlight.js": "^11.11.1",
|
||||
"https-proxy-agent": "^8.0.0",
|
||||
"ignore": "^7.0.5",
|
||||
"@anthropic/ink": "workspace:*",
|
||||
"image-processor-napi": "workspace:*",
|
||||
"indent-string": "^5.0.0",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
|
||||
1126
packages/@ant/claude-for-chrome-mcp/src/bridgeClient.ts
Normal file
1126
packages/@ant/claude-for-chrome-mcp/src/bridgeClient.ts
Normal file
File diff suppressed because it is too large
Load Diff
546
packages/@ant/claude-for-chrome-mcp/src/browserTools.ts
Normal file
546
packages/@ant/claude-for-chrome-mcp/src/browserTools.ts
Normal file
@@ -0,0 +1,546 @@
|
||||
export const BROWSER_TOOLS = [
|
||||
{
|
||||
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",
|
||||
properties: {
|
||||
action: {
|
||||
type: "string",
|
||||
description: "Must be set to 'javascript_exec'",
|
||||
},
|
||||
text: {
|
||||
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",
|
||||
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"],
|
||||
},
|
||||
},
|
||||
{
|
||||
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",
|
||||
properties: {
|
||||
filter: {
|
||||
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",
|
||||
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",
|
||||
description:
|
||||
"Maximum depth of the tree to traverse (default: 15). Use a smaller depth if output is too large.",
|
||||
},
|
||||
ref_id: {
|
||||
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.",
|
||||
},
|
||||
max_chars: {
|
||||
type: "number",
|
||||
description:
|
||||
"Maximum characters for output (default: 50000). Set to a higher value if your client can handle large outputs.",
|
||||
},
|
||||
},
|
||||
required: ["tabId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
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",
|
||||
properties: {
|
||||
query: {
|
||||
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",
|
||||
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"],
|
||||
},
|
||||
},
|
||||
{
|
||||
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",
|
||||
properties: {
|
||||
ref: {
|
||||
type: "string",
|
||||
description:
|
||||
'Element reference ID from the read_page tool (e.g., "ref_1", "ref_2")',
|
||||
},
|
||||
value: {
|
||||
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",
|
||||
},
|
||||
tabId: {
|
||||
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"],
|
||||
},
|
||||
},
|
||||
{
|
||||
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",
|
||||
properties: {
|
||||
action: {
|
||||
type: "string",
|
||||
enum: [
|
||||
"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.",
|
||||
},
|
||||
coordinate: {
|
||||
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.",
|
||||
},
|
||||
text: {
|
||||
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",
|
||||
minimum: 0,
|
||||
maximum: 30,
|
||||
description:
|
||||
"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`.",
|
||||
},
|
||||
scroll_amount: {
|
||||
type: "number",
|
||||
minimum: 1,
|
||||
maximum: 10,
|
||||
description:
|
||||
"The number of scroll wheel ticks. Optional for `scroll`, defaults to 3.",
|
||||
},
|
||||
start_coordinate: {
|
||||
type: "array",
|
||||
items: { type: "number" },
|
||||
minItems: 2,
|
||||
maxItems: 2,
|
||||
description:
|
||||
"(x, y): The starting coordinates for `left_click_drag`.",
|
||||
},
|
||||
region: {
|
||||
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.",
|
||||
},
|
||||
repeat: {
|
||||
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.",
|
||||
},
|
||||
ref: {
|
||||
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",
|
||||
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",
|
||||
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"],
|
||||
},
|
||||
},
|
||||
{
|
||||
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",
|
||||
properties: {
|
||||
url: {
|
||||
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",
|
||||
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"],
|
||||
},
|
||||
},
|
||||
{
|
||||
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",
|
||||
properties: {
|
||||
width: {
|
||||
type: "number",
|
||||
description: "Target window width in pixels",
|
||||
},
|
||||
height: {
|
||||
type: "number",
|
||||
description: "Target window height in pixels",
|
||||
},
|
||||
tabId: {
|
||||
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"],
|
||||
},
|
||||
},
|
||||
{
|
||||
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",
|
||||
properties: {
|
||||
action: {
|
||||
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",
|
||||
description:
|
||||
"Tab ID to identify which tab group this operation applies to",
|
||||
},
|
||||
download: {
|
||||
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",
|
||||
description:
|
||||
"Optional filename for exported GIF (default: 'recording-[timestamp].gif'). For 'export' action only.",
|
||||
},
|
||||
options: {
|
||||
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",
|
||||
description:
|
||||
"Show orange circles at click locations (default: true)",
|
||||
},
|
||||
showDragPaths: {
|
||||
type: "boolean",
|
||||
description: "Show red arrows for drag actions (default: true)",
|
||||
},
|
||||
showActionLabels: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Show black labels describing actions (default: true)",
|
||||
},
|
||||
showProgressBar: {
|
||||
type: "boolean",
|
||||
description: "Show orange progress bar at bottom (default: true)",
|
||||
},
|
||||
showWatermark: {
|
||||
type: "boolean",
|
||||
description: "Show Claude logo watermark (default: true)",
|
||||
},
|
||||
quality: {
|
||||
type: "number",
|
||||
description:
|
||||
"GIF compression quality, 1-30 (lower = better quality, slower encoding). Default: 10",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["action", "tabId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
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.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
imageId: {
|
||||
type: "string",
|
||||
description:
|
||||
"ID of a previously captured screenshot (from the computer tool's screenshot action) or a user-uploaded image",
|
||||
},
|
||||
ref: {
|
||||
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",
|
||||
items: {
|
||||
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.",
|
||||
},
|
||||
tabId: {
|
||||
type: "number",
|
||||
description:
|
||||
"Tab ID where the target element is located. This is where the image will be uploaded to.",
|
||||
},
|
||||
filename: {
|
||||
type: "string",
|
||||
description:
|
||||
'Optional filename for the uploaded file (default: "image.png")',
|
||||
},
|
||||
},
|
||||
required: ["imageId", "tabId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
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",
|
||||
properties: {
|
||||
tabId: {
|
||||
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"],
|
||||
},
|
||||
},
|
||||
{
|
||||
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.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
createIfEmpty: {
|
||||
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.",
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
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.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
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.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
domains: {
|
||||
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 },
|
||||
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.",
|
||||
},
|
||||
},
|
||||
required: ["domains", "approach"],
|
||||
},
|
||||
},
|
||||
{
|
||||
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",
|
||||
properties: {
|
||||
tabId: {
|
||||
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",
|
||||
description:
|
||||
"If true, only return error and exception messages. Default is false (return all message types).",
|
||||
},
|
||||
clear: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"If true, clear the console messages after reading to avoid duplicates on subsequent calls. Default is false.",
|
||||
},
|
||||
pattern: {
|
||||
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",
|
||||
description:
|
||||
"Maximum number of messages to return. Defaults to 100. Increase only if you need more results.",
|
||||
},
|
||||
},
|
||||
required: ["tabId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
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",
|
||||
properties: {
|
||||
tabId: {
|
||||
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",
|
||||
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",
|
||||
description:
|
||||
"If true, clear the network requests after reading to avoid duplicates on subsequent calls. Default is false.",
|
||||
},
|
||||
limit: {
|
||||
type: "number",
|
||||
description:
|
||||
"Maximum number of requests to return. Defaults to 100. Increase only if you need more results.",
|
||||
},
|
||||
},
|
||||
required: ["tabId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
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.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
tabId: {
|
||||
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"],
|
||||
},
|
||||
},
|
||||
{
|
||||
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.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
tabId: {
|
||||
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",
|
||||
},
|
||||
command: {
|
||||
type: "string",
|
||||
description:
|
||||
"The command name of the shortcut to execute (e.g., 'debug', 'summarize'). Do not include the leading slash.",
|
||||
},
|
||||
},
|
||||
required: ["tabId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
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",
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1,11 +1,15 @@
|
||||
export const BROWSER_TOOLS: any[] = []
|
||||
|
||||
export class ClaudeForChromeContext {}
|
||||
|
||||
export class Logger {}
|
||||
|
||||
export type PermissionMode = any
|
||||
|
||||
export function createClaudeForChromeMcpServer(..._args: any[]): any {
|
||||
return null
|
||||
}
|
||||
export { BridgeClient, createBridgeClient } from "./bridgeClient.js";
|
||||
export { BROWSER_TOOLS } from "./browserTools.js";
|
||||
export {
|
||||
createChromeSocketClient,
|
||||
createClaudeForChromeMcpServer,
|
||||
} from "./mcpServer.js";
|
||||
export { localPlatformLabel } from "./types.js";
|
||||
export type {
|
||||
BridgeConfig,
|
||||
ChromeExtensionInfo,
|
||||
ClaudeForChromeContext,
|
||||
Logger,
|
||||
PermissionMode,
|
||||
SocketClient,
|
||||
} from "./types.js";
|
||||
|
||||
96
packages/@ant/claude-for-chrome-mcp/src/mcpServer.ts
Normal file
96
packages/@ant/claude-for-chrome-mcp/src/mcpServer.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
} 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";
|
||||
|
||||
/**
|
||||
* Create the socket/bridge client for the Chrome extension MCP server.
|
||||
* Exported so Desktop can share a single instance between the registered
|
||||
* MCP server and the InternalMcpServerManager (CCD sessions).
|
||||
*/
|
||||
export function createChromeSocketClient(
|
||||
context: ClaudeForChromeContext,
|
||||
): SocketClient {
|
||||
return context.bridgeConfig
|
||||
? createBridgeClient(context)
|
||||
: context.getSocketPaths
|
||||
? createMcpSocketPool(context)
|
||||
: createMcpSocketClient(context);
|
||||
}
|
||||
|
||||
export function createClaudeForChromeMcpServer(
|
||||
context: ClaudeForChromeContext,
|
||||
existingSocketClient?: SocketClient,
|
||||
): Server {
|
||||
const { serverName, logger } = context;
|
||||
|
||||
// Choose transport: bridge (WebSocket) > socket pool (multi-profile) > single socket.
|
||||
const socketClient =
|
||||
existingSocketClient ?? createChromeSocketClient(context);
|
||||
|
||||
const server = new Server(
|
||||
{
|
||||
name: serverName,
|
||||
version: "1.0.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
logging: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
if (context.isDisabled?.()) {
|
||||
return { tools: [] };
|
||||
}
|
||||
return {
|
||||
tools: context.bridgeConfig
|
||||
? BROWSER_TOOLS
|
||||
: BROWSER_TOOLS.filter((t) => t.name !== "switch_browser"),
|
||||
};
|
||||
});
|
||||
|
||||
server.setRequestHandler(
|
||||
CallToolRequestSchema,
|
||||
async (request): Promise<CallToolResult> => {
|
||||
logger.info(`[${serverName}] Executing tool: ${request.params.name}`);
|
||||
|
||||
return handleToolCall(
|
||||
context,
|
||||
socketClient,
|
||||
request.params.name,
|
||||
request.params.arguments || {},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
socketClient.setNotificationHandler((notification) => {
|
||||
logger.info(
|
||||
`[${serverName}] Forwarding MCP notification: ${notification.method}`,
|
||||
);
|
||||
server
|
||||
.notification({
|
||||
method: notification.method,
|
||||
params: notification.params,
|
||||
})
|
||||
.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;
|
||||
}
|
||||
493
packages/@ant/claude-for-chrome-mcp/src/mcpSocketClient.ts
Normal file
493
packages/@ant/claude-for-chrome-mcp/src/mcpSocketClient.ts
Normal file
@@ -0,0 +1,493 @@
|
||||
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";
|
||||
|
||||
export class SocketConnectionError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "SocketConnectionError";
|
||||
}
|
||||
}
|
||||
|
||||
interface ToolRequest {
|
||||
method: string; // "execute_tool"
|
||||
params?: {
|
||||
client_id?: string; // "desktop" | "claude-code"
|
||||
tool?: string;
|
||||
args?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
interface ToolResponse {
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface Notification {
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type SocketMessage = ToolResponse | Notification;
|
||||
|
||||
function isToolResponse(message: SocketMessage): message is ToolResponse {
|
||||
return "result" in message || "error" in message;
|
||||
}
|
||||
|
||||
function isNotification(message: SocketMessage): message is Notification {
|
||||
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 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;
|
||||
// When true, disables automatic reconnection. Used by McpSocketPool which
|
||||
// manages reconnection externally by rescanning available sockets.
|
||||
public disableAutoReconnect = false;
|
||||
|
||||
constructor(context: ClaudeForChromeContext) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
private async connect(): Promise<void> {
|
||||
const { serverName, logger } = this.context;
|
||||
|
||||
if (this.connecting) {
|
||||
logger.info(
|
||||
`[${serverName}] Already connecting, skipping duplicate attempt`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.closeSocket();
|
||||
this.connecting = true;
|
||||
|
||||
const socketPath =
|
||||
this.context.getSocketPath?.() ?? this.context.socketPath;
|
||||
logger.info(`[${serverName}] Attempting to connect to: ${socketPath}`);
|
||||
|
||||
try {
|
||||
await this.validateSocketSecurity(socketPath);
|
||||
} catch (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;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}, 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("data", (data: Buffer) => {
|
||||
this.responseBuffer = Buffer.concat([this.responseBuffer, data]);
|
||||
|
||||
while (this.responseBuffer.length >= 4) {
|
||||
const length = this.responseBuffer.readUInt32LE(0);
|
||||
|
||||
if (this.responseBuffer.length < 4 + length) {
|
||||
break;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if (isNotification(message)) {
|
||||
logger.info(
|
||||
`[${serverName}] Received notification: ${message.method}`,
|
||||
);
|
||||
if (this.notificationHandler) {
|
||||
this.notificationHandler(message);
|
||||
}
|
||||
} else if (isToolResponse(message)) {
|
||||
logger.info(`[${serverName}] Received tool response: ${message}`);
|
||||
this.handleResponse(message);
|
||||
} else {
|
||||
logger.info(`[${serverName}] Received unknown message: ${message}`);
|
||||
}
|
||||
} catch (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;
|
||||
|
||||
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
|
||||
].includes(error.code)
|
||||
) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("close", () => {
|
||||
clearTimeout(connectTimeout);
|
||||
this.connected = false;
|
||||
this.connecting = false;
|
||||
this.scheduleReconnect();
|
||||
});
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
const { serverName, logger } = this.context;
|
||||
|
||||
if (this.disableAutoReconnect) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.reconnectTimer) {
|
||||
logger.info(`[${serverName}] Reconnect already scheduled, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
if (this.reconnectAttempts > maxTotalAttempts) {
|
||||
logger.info(
|
||||
`[${serverName}] Giving up after ${maxTotalAttempts} attempts. Will retry on next tool call.`,
|
||||
);
|
||||
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),
|
||||
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);
|
||||
}
|
||||
|
||||
private handleResponse(response: ToolResponse): void {
|
||||
if (this.responseCallback) {
|
||||
const callback = this.responseCallback;
|
||||
this.responseCallback = null;
|
||||
callback(response);
|
||||
}
|
||||
}
|
||||
|
||||
public setNotificationHandler(
|
||||
handler: (notification: Notification) => void,
|
||||
): void {
|
||||
this.notificationHandler = handler;
|
||||
}
|
||||
|
||||
public async ensureConnected(): Promise<boolean> {
|
||||
const { serverName } = this.context;
|
||||
|
||||
if (this.connected && this.socket) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!this.socket && !this.connecting) {
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
// Wait for connection with timeout
|
||||
return new Promise((resolve, reject) => {
|
||||
let checkTimeoutId: NodeJS.Timeout | null = null;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (checkTimeoutId) {
|
||||
clearTimeout(checkTimeoutId);
|
||||
}
|
||||
reject(
|
||||
new SocketConnectionError(
|
||||
`[${serverName}] Connection attempt timed out after 5000ms`,
|
||||
),
|
||||
);
|
||||
}, 5000);
|
||||
|
||||
const checkConnection = () => {
|
||||
if (this.connected) {
|
||||
clearTimeout(timeout);
|
||||
resolve(true);
|
||||
} else {
|
||||
checkTimeoutId = setTimeout(checkConnection, 500);
|
||||
}
|
||||
};
|
||||
checkConnection();
|
||||
});
|
||||
}
|
||||
|
||||
private async sendRequest(
|
||||
request: ToolRequest,
|
||||
timeoutMs = 30000,
|
||||
): Promise<ToolResponse> {
|
||||
const { serverName } = this.context;
|
||||
|
||||
if (!this.socket) {
|
||||
throw new SocketConnectionError(
|
||||
`[${serverName}] Cannot send request: not connected`,
|
||||
);
|
||||
}
|
||||
|
||||
const socket = this.socket;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.responseCallback = null;
|
||||
reject(
|
||||
new SocketConnectionError(
|
||||
`[${serverName}] Tool request timed out after ${timeoutMs}ms`,
|
||||
),
|
||||
);
|
||||
}, timeoutMs);
|
||||
|
||||
this.responseCallback = (response) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(response);
|
||||
};
|
||||
|
||||
const requestJson = JSON.stringify(request);
|
||||
const requestBytes = Buffer.from(requestJson, "utf-8");
|
||||
|
||||
const lengthPrefix = Buffer.allocUnsafe(4);
|
||||
lengthPrefix.writeUInt32LE(requestBytes.length, 0);
|
||||
|
||||
const message = Buffer.concat([lengthPrefix, requestBytes]);
|
||||
socket.write(message);
|
||||
});
|
||||
}
|
||||
|
||||
public async callTool(
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
_permissionOverrides?: PermissionOverrides,
|
||||
): Promise<unknown> {
|
||||
const request: ToolRequest = {
|
||||
method: "execute_tool",
|
||||
params: {
|
||||
client_id: this.context.clientTypeId,
|
||||
tool: name,
|
||||
args,
|
||||
},
|
||||
};
|
||||
|
||||
return this.sendRequestWithRetry(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request with automatic retry on connection errors.
|
||||
*
|
||||
* On connection error or timeout, the native host may be a zombie (connected
|
||||
* to dead Chrome). Force reconnect to pick up a fresh native host process
|
||||
* and retry once.
|
||||
*/
|
||||
private async sendRequestWithRetry(request: ToolRequest): Promise<unknown> {
|
||||
const { serverName, logger } = this.context;
|
||||
|
||||
try {
|
||||
return await this.sendRequest(request);
|
||||
} catch (error) {
|
||||
if (!(error instanceof SocketConnectionError)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${serverName}] Connection error, forcing reconnect and retrying: ${error.message}`,
|
||||
);
|
||||
|
||||
this.closeSocket();
|
||||
await this.ensureConnected();
|
||||
|
||||
return await this.sendRequest(request);
|
||||
}
|
||||
}
|
||||
|
||||
public async setPermissionMode(
|
||||
_mode: PermissionMode,
|
||||
_allowedDomains?: string[],
|
||||
): Promise<void> {
|
||||
// No-op: permission mode is only supported over the bridge (WebSocket) transport
|
||||
}
|
||||
|
||||
public isConnected(): boolean {
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
private closeSocket(): void {
|
||||
if (this.socket) {
|
||||
this.socket.removeAllListeners();
|
||||
this.socket.end();
|
||||
this.socket.destroy();
|
||||
this.socket = null;
|
||||
}
|
||||
this.connected = false;
|
||||
this.connecting = false;
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
|
||||
this.closeSocket();
|
||||
this.reconnectAttempts = 0;
|
||||
this.responseBuffer = Buffer.alloc(0);
|
||||
this.responseCallback = null;
|
||||
}
|
||||
|
||||
public disconnect(): void {
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
private async validateSocketSecurity(socketPath: string): Promise<void> {
|
||||
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-");
|
||||
if (isSocketDir) {
|
||||
try {
|
||||
const dirStats = await fsPromises.stat(dirPath);
|
||||
if (dirStats.isDirectory()) {
|
||||
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?.();
|
||||
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;
|
||||
}
|
||||
// Directory doesn't exist yet - native host will create it
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
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?.();
|
||||
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`);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
logger.info(
|
||||
`[${serverName}] Socket not found, will be created by server`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createMcpSocketClient(
|
||||
context: ClaudeForChromeContext,
|
||||
): McpSocketClient {
|
||||
return new McpSocketClient(context);
|
||||
}
|
||||
|
||||
export type { McpSocketClient };
|
||||
327
packages/@ant/claude-for-chrome-mcp/src/mcpSocketPool.ts
Normal file
327
packages/@ant/claude-for-chrome-mcp/src/mcpSocketPool.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
import {
|
||||
createMcpSocketClient,
|
||||
SocketConnectionError,
|
||||
} from "./mcpSocketClient.js";
|
||||
import type { McpSocketClient } from "./mcpSocketClient.js";
|
||||
import type {
|
||||
ClaudeForChromeContext,
|
||||
PermissionMode,
|
||||
PermissionOverrides,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* Manages connections to multiple Chrome native host sockets (one per Chrome profile).
|
||||
* Routes tool calls to the correct socket based on tab ID.
|
||||
*
|
||||
* For `tabs_context_mcp`: queries all connected sockets and merges results.
|
||||
* For other tools: routes based on the `tabId` argument using a routing table
|
||||
* 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 notificationHandler:
|
||||
| ((notification: { method: string; params?: Record<string, unknown> }) => void)
|
||||
| null = null;
|
||||
|
||||
constructor(context: ClaudeForChromeContext) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
public setNotificationHandler(
|
||||
handler: (notification: {
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
}) => void,
|
||||
): void {
|
||||
this.notificationHandler = handler;
|
||||
for (const client of this.clients.values()) {
|
||||
client.setNotificationHandler(handler);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover available sockets and ensure at least one is connected.
|
||||
*/
|
||||
public async ensureConnected(): Promise<boolean> {
|
||||
const { logger, serverName } = this.context;
|
||||
|
||||
this.refreshClients();
|
||||
|
||||
// Try to connect any disconnected clients
|
||||
const connectPromises: Promise<boolean>[] = [];
|
||||
for (const client of this.clients.values()) {
|
||||
if (!client.isConnected()) {
|
||||
connectPromises.push(
|
||||
client.ensureConnected().catch(() => false),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (connectPromises.length > 0) {
|
||||
await Promise.all(connectPromises);
|
||||
}
|
||||
|
||||
const connectedCount = this.getConnectedClients().length;
|
||||
if (connectedCount === 0) {
|
||||
logger.info(`[${serverName}] No connected sockets in pool`);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info(`[${serverName}] Socket pool: ${connectedCount} connected`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call a tool, routing to the correct socket based on tab ID.
|
||||
* For tabs_context_mcp, queries all sockets and merges results.
|
||||
*/
|
||||
public async callTool(
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
_permissionOverrides?: PermissionOverrides,
|
||||
): Promise<unknown> {
|
||||
if (name === "tabs_context_mcp") {
|
||||
return this.callTabsContext(args);
|
||||
}
|
||||
|
||||
// Route by tabId if present
|
||||
const tabId = args.tabId as number | undefined;
|
||||
if (tabId !== undefined) {
|
||||
const socketPath = this.tabRoutes.get(tabId);
|
||||
if (socketPath) {
|
||||
const client = this.clients.get(socketPath);
|
||||
if (client?.isConnected()) {
|
||||
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();
|
||||
if (connected.length === 0) {
|
||||
throw new SocketConnectionError(
|
||||
`[${this.context.serverName}] No connected sockets available`,
|
||||
);
|
||||
}
|
||||
return connected[0]!.callTool(name, args);
|
||||
}
|
||||
|
||||
public async setPermissionMode(
|
||||
mode: PermissionMode,
|
||||
allowedDomains?: string[],
|
||||
): Promise<void> {
|
||||
const connected = this.getConnectedClients();
|
||||
await Promise.all(
|
||||
connected.map((client) => client.setPermissionMode(mode, allowedDomains)),
|
||||
);
|
||||
}
|
||||
|
||||
public isConnected(): boolean {
|
||||
return this.getConnectedClients().length > 0;
|
||||
}
|
||||
|
||||
public disconnect(): void {
|
||||
for (const client of this.clients.values()) {
|
||||
client.disconnect();
|
||||
}
|
||||
this.clients.clear();
|
||||
this.tabRoutes.clear();
|
||||
}
|
||||
|
||||
private getConnectedClients(): McpSocketClient[] {
|
||||
return [...this.clients.values()].filter((c) => c.isConnected());
|
||||
}
|
||||
|
||||
/**
|
||||
* Query all connected sockets for tabs and merge results.
|
||||
* Updates the tab routing table.
|
||||
*/
|
||||
private async callTabsContext(
|
||||
args: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
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;
|
||||
}
|
||||
|
||||
// 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 };
|
||||
}),
|
||||
);
|
||||
|
||||
// Merge tab results
|
||||
const mergedTabs: unknown[] = [];
|
||||
this.tabRoutes.clear();
|
||||
|
||||
for (const settledResult of results) {
|
||||
if (settledResult.status !== "fulfilled") {
|
||||
logger.info(
|
||||
`[${serverName}] tabs_context_mcp failed on one socket: ${settledResult.reason}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { result, socketPath } = settledResult.value;
|
||||
this.updateTabRoutes(result, socketPath);
|
||||
|
||||
const tabs = this.extractTabs(result);
|
||||
if (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})`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return {
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({ availableTabs: mergedTabs }),
|
||||
},
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extractTabs(result: unknown): unknown[] | 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;
|
||||
|
||||
for (const item of content) {
|
||||
if (item.type === "text" && item.text) {
|
||||
try {
|
||||
const parsed = JSON.parse(item.text);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
// Handle { availableTabs: [...] } format
|
||||
if (parsed && Array.isArray(parsed.availableTabs)) {
|
||||
return parsed.availableTabs;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private getSocketPathForClient(client: McpSocketClient): string {
|
||||
for (const [path, c] of this.clients.entries()) {
|
||||
if (c === client) return path;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan for available sockets and create/remove clients as needed.
|
||||
*/
|
||||
private refreshClients(): void {
|
||||
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}`);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
for (const [tabId, socketPath] of this.tabRoutes.entries()) {
|
||||
if (socketPath === path) {
|
||||
this.tabRoutes.delete(tabId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getAvailableSocketPaths(): string[] {
|
||||
return this.context.getSocketPaths?.() ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
export function createMcpSocketPool(
|
||||
context: ClaudeForChromeContext,
|
||||
): McpSocketPool {
|
||||
return new McpSocketPool(context);
|
||||
}
|
||||
301
packages/@ant/claude-for-chrome-mcp/src/toolCalls.ts
Normal file
301
packages/@ant/claude-for-chrome-mcp/src/toolCalls.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
import { SocketConnectionError } from "./mcpSocketClient.js";
|
||||
import type {
|
||||
ClaudeForChromeContext,
|
||||
PermissionMode,
|
||||
PermissionOverrides,
|
||||
SocketClient,
|
||||
} from "./types.js";
|
||||
|
||||
export const handleToolCall = async (
|
||||
context: ClaudeForChromeContext,
|
||||
socketClient: SocketClient,
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
permissionOverrides?: PermissionOverrides,
|
||||
): Promise<CallToolResult> => {
|
||||
// Handle permission mode changes locally (not forwarded to extension)
|
||||
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);
|
||||
}
|
||||
|
||||
try {
|
||||
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(
|
||||
context,
|
||||
socketClient,
|
||||
name,
|
||||
args,
|
||||
permissionOverrides,
|
||||
);
|
||||
}
|
||||
|
||||
return handleToolCallDisconnected(context);
|
||||
} catch (error) {
|
||||
context.logger.info(`[${context.serverName}] Error calling tool:`, error);
|
||||
|
||||
if (error instanceof SocketConnectionError) {
|
||||
return handleToolCallDisconnected(context);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error calling tool, please try again. : ${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
async function handleToolCallConnected(
|
||||
context: ClaudeForChromeContext,
|
||||
socketClient: SocketClient,
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
permissionOverrides?: PermissionOverrides,
|
||||
): Promise<CallToolResult> {
|
||||
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" }],
|
||||
};
|
||||
}
|
||||
|
||||
// Response will have either result or error field
|
||||
const { result, error } = response as {
|
||||
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;
|
||||
|
||||
if (!contentData) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Tool execution completed" }],
|
||||
};
|
||||
}
|
||||
|
||||
if (isError && isAuthenticationError(contentData.content)) {
|
||||
context.onAuthenticationError();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
return { type: "text", text: String(item) };
|
||||
}),
|
||||
isError: true,
|
||||
} as CallToolResult;
|
||||
}
|
||||
|
||||
const convertedContent = content.map((item: unknown) => {
|
||||
if (
|
||||
typeof item === "object" &&
|
||||
item !== null &&
|
||||
"type" in item &&
|
||||
"source" in item
|
||||
) {
|
||||
const typedItem = item;
|
||||
if (
|
||||
typedItem.type === "image" &&
|
||||
typeof typedItem.source === "object" &&
|
||||
typedItem.source !== null &&
|
||||
"data" in typedItem.source
|
||||
) {
|
||||
return {
|
||||
type: "image",
|
||||
data: typedItem.source.data,
|
||||
mimeType:
|
||||
"media_type" in typedItem.source
|
||||
? typedItem.source.media_type || "image/png"
|
||||
: "image/png",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof item === "object" && item !== null && "type" in item) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return { type: "text", text: String(item) };
|
||||
});
|
||||
|
||||
return {
|
||||
content: convertedContent,
|
||||
isError,
|
||||
} as CallToolResult;
|
||||
}
|
||||
|
||||
// Handle string content
|
||||
if (typeof content === "string") {
|
||||
return {
|
||||
content: [{ type: "text", text: content }],
|
||||
isError,
|
||||
} 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) }],
|
||||
isError,
|
||||
};
|
||||
}
|
||||
|
||||
function handleToolCallDisconnected(
|
||||
context: ClaudeForChromeContext,
|
||||
): CallToolResult {
|
||||
const text = context.onToolCallDisconnected();
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle set_permission_mode tool call locally.
|
||||
* This is security-sensitive as it controls whether permission prompts are shown.
|
||||
*/
|
||||
async function handleSetPermissionMode(
|
||||
socketClient: SocketClient,
|
||||
args: Record<string, unknown>,
|
||||
): 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;
|
||||
const permissionMode: PermissionMode =
|
||||
mode && validModes.includes(mode as PermissionMode)
|
||||
? (mode as PermissionMode)
|
||||
: "ask";
|
||||
|
||||
if (socketClient.setPermissionMode) {
|
||||
await socketClient.setPermissionMode(
|
||||
permissionMode,
|
||||
args.allowed_domains as string[] | undefined,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `Permission mode set to: ${permissionMode}` },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle switch_browser tool call. Broadcasts a pairing request and blocks
|
||||
* until a browser responds or timeout.
|
||||
*/
|
||||
async function handleSwitchBrowser(
|
||||
context: ClaudeForChromeContext,
|
||||
socketClient: SocketClient,
|
||||
): Promise<CallToolResult> {
|
||||
if (!context.bridgeConfig) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Browser switching is only available with bridge connections.",
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const isConnected = await socketClient.ensureConnected();
|
||||
if (!isConnected) {
|
||||
return handleToolCallDisconnected(context);
|
||||
}
|
||||
|
||||
const result = (await socketClient.switchBrowser?.()) ?? null;
|
||||
|
||||
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.",
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (result) {
|
||||
return {
|
||||
content: [
|
||||
{ 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.",
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the error content indicates an authentication issue
|
||||
*/
|
||||
function isAuthenticationError(content: unknown[] | string): boolean {
|
||||
const errorText = Array.isArray(content)
|
||||
? content
|
||||
.map((item) => {
|
||||
if (typeof item === "string") return item;
|
||||
if (
|
||||
typeof item === "object" &&
|
||||
item !== null &&
|
||||
"text" in item &&
|
||||
typeof item.text === "string"
|
||||
) {
|
||||
return item.text;
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.join(" ")
|
||||
: String(content);
|
||||
|
||||
return errorText.toLowerCase().includes("re-authenticated");
|
||||
}
|
||||
134
packages/@ant/claude-for-chrome-mcp/src/types.ts
Normal file
134
packages/@ant/claude-for-chrome-mcp/src/types.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export type PermissionMode =
|
||||
| "ask"
|
||||
| "skip_all_permission_checks"
|
||||
| "follow_a_plan";
|
||||
|
||||
export interface BridgeConfig {
|
||||
/** Bridge WebSocket base URL (e.g., wss://bridge.claudeusercontent.com) */
|
||||
url: string;
|
||||
/** Returns the user's account UUID for the connection path */
|
||||
getUserId: () => Promise<string | undefined>;
|
||||
/** Returns a valid OAuth token for bridge authentication */
|
||||
getOAuthToken: () => Promise<string | undefined>;
|
||||
/** Optional dev user ID for local development (bypasses OAuth) */
|
||||
devUserId?: string;
|
||||
}
|
||||
|
||||
/** Metadata about a connected Chrome extension instance. */
|
||||
export interface ChromeExtensionInfo {
|
||||
deviceId: string;
|
||||
osPlatform?: string;
|
||||
connectedAt: number;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface ClaudeForChromeContext {
|
||||
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;
|
||||
// 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;
|
||||
/** Bridge WebSocket configuration. When provided, uses bridge instead of socket. */
|
||||
bridgeConfig?: BridgeConfig;
|
||||
/** If set, permission mode is sent to the extension immediately on bridge connection. */
|
||||
initialPermissionMode?: PermissionMode;
|
||||
/** Optional callback to track telemetry events for bridge connections */
|
||||
trackEvent?: <K extends string>(
|
||||
eventName: K,
|
||||
metadata: Record<string, unknown> | null,
|
||||
) => void;
|
||||
/** Called when user pairs with an extension via the browser pairing flow. */
|
||||
onExtensionPaired?: (deviceId: string, name: string) => void;
|
||||
/** Returns the previously paired deviceId, if any. */
|
||||
getPersistedDeviceId?: () => string | undefined;
|
||||
/** Called when a remote extension is auto-selected (only option available). */
|
||||
onRemoteExtensionWarning?: (ext: ChromeExtensionInfo) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Node's process.platform to the platform string reported by Chrome extensions
|
||||
* via navigator.userAgentData.platform.
|
||||
*/
|
||||
export function localPlatformLabel(): string {
|
||||
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;
|
||||
/** Unique ID for this permission request */
|
||||
requestId: string;
|
||||
/** Tool type, e.g. "navigate", "click", "execute_javascript" */
|
||||
toolType: string;
|
||||
/** The URL/domain context */
|
||||
url: string;
|
||||
/** Additional action data (click coordinates, text, etc.) */
|
||||
actionData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Desktop response to a bridge permission request. */
|
||||
export interface BridgePermissionResponse {
|
||||
requestId: string;
|
||||
allowed: boolean;
|
||||
}
|
||||
|
||||
/** Per-call permission overrides, allowing each session to use its own permission state. */
|
||||
export interface PermissionOverrides {
|
||||
permissionMode: PermissionMode;
|
||||
allowedDomains?: string[];
|
||||
/** Callback invoked when the extension requests user permission via the bridge. */
|
||||
onPermissionRequest?: (request: BridgePermissionRequest) => Promise<boolean>;
|
||||
}
|
||||
|
||||
/** Shared interface for McpSocketClient and McpSocketPool */
|
||||
export interface SocketClient {
|
||||
ensureConnected(): Promise<boolean>;
|
||||
callTool(
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
permissionOverrides?: PermissionOverrides,
|
||||
): Promise<unknown>;
|
||||
isConnected(): boolean;
|
||||
disconnect(): void;
|
||||
setNotificationHandler(
|
||||
handler: (notification: {
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
}) => void,
|
||||
): void;
|
||||
/** Set permission mode for the current session. Only effective on BridgeClient. */
|
||||
setPermissionMode?(
|
||||
mode: PermissionMode,
|
||||
allowedDomains?: string[],
|
||||
): Promise<void>;
|
||||
/** Switch to a different browser. Only available on BridgeClient. */
|
||||
switchBrowser?(): Promise<
|
||||
| {
|
||||
deviceId: string;
|
||||
name: string;
|
||||
}
|
||||
| "no_other_browsers"
|
||||
| null
|
||||
>;
|
||||
}
|
||||
137
packages/@ant/computer-use-input/src/backends/darwin.ts
Normal file
137
packages/@ant/computer-use-input/src/backends/darwin.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* macOS backend for computer-use-input
|
||||
*
|
||||
* Uses AppleScript (osascript) and JXA (JavaScript for Automation) to control
|
||||
* mouse and keyboard via CoreGraphics events and System Events.
|
||||
*/
|
||||
|
||||
import { $ } from 'bun'
|
||||
import type { FrontmostAppInfo, InputBackend } from '../types.js'
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
const MODIFIER_MAP: Record<string, string> = {
|
||||
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',
|
||||
}
|
||||
|
||||
async function osascript(script: string): Promise<string> {
|
||||
const result = await $`osascript -e ${script}`.quiet().nothrow().text()
|
||||
return result.trim()
|
||||
}
|
||||
|
||||
async function jxa(script: string): Promise<string> {
|
||||
const result = await $`osascript -l JavaScript -e ${script}`.quiet().nothrow().text()
|
||||
return result.trim()
|
||||
}
|
||||
|
||||
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});`
|
||||
}
|
||||
script += ` $.CGEventPost($.kCGHIDEventTap, e);`
|
||||
return script
|
||||
}
|
||||
|
||||
export const moveMouse: InputBackend['moveMouse'] = async (x, y, _animated) => {
|
||||
await jxa(buildMouseJxa('kCGEventMouseMoved', x, y, 0))
|
||||
}
|
||||
|
||||
export const key: InputBackend['key'] = async (keyName, action) => {
|
||||
if (action === 'release') return
|
||||
const lower = keyName.toLowerCase()
|
||||
const keyCode = KEY_MAP[lower]
|
||||
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}"`)
|
||||
}
|
||||
}
|
||||
|
||||
export const keys: InputBackend['keys'] = async (parts) => {
|
||||
const modifiers: string[] = []
|
||||
let finalKey: string | null = null
|
||||
for (const part of parts) {
|
||||
const mod = MODIFIER_MAP[part.toLowerCase()]
|
||||
if (mod) modifiers.push(mod)
|
||||
else finalKey = part
|
||||
}
|
||||
if (!finalKey) return
|
||||
const lower = finalKey.toLowerCase()
|
||||
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}`)
|
||||
} else {
|
||||
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 [xStr, yStr] = result.split(',')
|
||||
return { x: Math.round(Number(xStr)), y: Math.round(Number(yStr)) }
|
||||
}
|
||||
|
||||
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'
|
||||
|
||||
if (action === 'click') {
|
||||
for (let i = 0; i < (count ?? 1); i++) {
|
||||
await jxa(buildMouseJxa(downType, pos.x, pos.y, btn, i + 1))
|
||||
await jxa(buildMouseJxa(upType, pos.x, pos.y, btn, i + 1))
|
||||
}
|
||||
} else if (action === 'press') {
|
||||
await jxa(buildMouseJxa(downType, pos.x, pos.y, btn))
|
||||
} else {
|
||||
await jxa(buildMouseJxa(upType, pos.x, pos.y, btn))
|
||||
}
|
||||
}
|
||||
|
||||
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) => {
|
||||
const escaped = text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
||||
await osascript(`tell application "System Events" to keystroke "${escaped}"`)
|
||||
}
|
||||
|
||||
export const getFrontmostAppInfo: InputBackend['getFrontmostAppInfo'] = () => {
|
||||
try {
|
||||
const result = Bun.spawnSync({
|
||||
cmd: ['osascript', '-e', `
|
||||
tell application "System Events"
|
||||
set frontApp to first application process whose frontmost is true
|
||||
set appName to name of frontApp
|
||||
set bundleId to bundle identifier of frontApp
|
||||
return bundleId & "|" & appName
|
||||
end tell
|
||||
`],
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
const output = new TextDecoder().decode(result.stdout).trim()
|
||||
if (!output || !output.includes('|')) return null
|
||||
const [bundleId, appName] = output.split('|', 2)
|
||||
return { bundleId: bundleId!, appName: appName! }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
173
packages/@ant/computer-use-input/src/backends/linux.ts
Normal file
173
packages/@ant/computer-use-input/src/backends/linux.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Linux backend for computer-use-input
|
||||
*
|
||||
* Uses xdotool for mouse and keyboard simulation.
|
||||
* Requires: xdotool (apt install xdotool)
|
||||
*/
|
||||
|
||||
import type { FrontmostAppInfo, InputBackend } from '../types.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shell helper — run a command and return trimmed stdout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function run(cmd: string[]): string {
|
||||
const result = Bun.spawnSync({
|
||||
cmd,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
return new TextDecoder().decode(result.stdout).trim()
|
||||
}
|
||||
|
||||
async function runAsync(cmd: string[]): Promise<string> {
|
||||
const proc = Bun.spawn(cmd, { stdout: 'pipe', stderr: 'pipe' })
|
||||
const out = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
return out.trim()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// xdotool key name mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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',
|
||||
}
|
||||
|
||||
const MODIFIER_KEYS = new Set([
|
||||
'shift', 'lshift', 'rshift', 'control', 'ctrl', 'lcontrol', 'rcontrol',
|
||||
'alt', 'option', 'lalt', 'ralt', 'win', 'meta', 'command', 'cmd', 'super',
|
||||
])
|
||||
|
||||
function mapKey(name: string): string {
|
||||
return KEY_MAP[name.toLowerCase()] ?? name
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// xdotool mouse button mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function mouseButtonNum(button: 'left' | 'right' | 'middle'): string {
|
||||
return button === 'left' ? '1' : button === 'right' ? '3' : '2'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const moveMouse: InputBackend['moveMouse'] = async (x, y, _animated) => {
|
||||
run(['xdotool', 'mousemove', '--sync', String(Math.round(x)), String(Math.round(y))])
|
||||
}
|
||||
|
||||
export const mouseLocation: InputBackend['mouseLocation'] = async () => {
|
||||
const out = run(['xdotool', 'getmouselocation'])
|
||||
// Output format: "x:123 y:456 screen:0 window:12345678"
|
||||
const xMatch = out.match(/x:(\d+)/)
|
||||
const yMatch = out.match(/y:(\d+)/)
|
||||
return {
|
||||
x: xMatch ? Number(xMatch[1]) : 0,
|
||||
y: yMatch ? Number(yMatch[1]) : 0,
|
||||
}
|
||||
}
|
||||
|
||||
export const mouseButton: InputBackend['mouseButton'] = async (button, action, count) => {
|
||||
const btn = mouseButtonNum(button)
|
||||
if (action === 'click') {
|
||||
const n = count ?? 1
|
||||
run(['xdotool', 'click', '--repeat', String(n), btn])
|
||||
} else if (action === 'press') {
|
||||
run(['xdotool', 'mousedown', btn])
|
||||
} else {
|
||||
run(['xdotool', 'mouseup', btn])
|
||||
}
|
||||
}
|
||||
|
||||
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') {
|
||||
const btn = amount >= 0 ? '5' : '4'
|
||||
const repeats = Math.abs(Math.round(amount))
|
||||
if (repeats > 0) {
|
||||
run(['xdotool', 'click', '--repeat', String(repeats), btn])
|
||||
}
|
||||
} else {
|
||||
const btn = amount >= 0 ? '7' : '6'
|
||||
const repeats = Math.abs(Math.round(amount))
|
||||
if (repeats > 0) {
|
||||
run(['xdotool', 'click', '--repeat', String(repeats), btn])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const key: InputBackend['key'] = async (keyName, action) => {
|
||||
const mapped = mapKey(keyName)
|
||||
if (action === 'press') {
|
||||
run(['xdotool', 'keydown', mapped])
|
||||
} else {
|
||||
run(['xdotool', 'keyup', mapped])
|
||||
}
|
||||
}
|
||||
|
||||
export const keys: InputBackend['keys'] = async (parts) => {
|
||||
// xdotool key accepts "modifier+modifier+key" format
|
||||
const modifiers: string[] = []
|
||||
let finalKey: string | null = null
|
||||
|
||||
for (const part of parts) {
|
||||
if (MODIFIER_KEYS.has(part.toLowerCase())) {
|
||||
modifiers.push(mapKey(part))
|
||||
} else {
|
||||
finalKey = part
|
||||
}
|
||||
}
|
||||
if (!finalKey) return
|
||||
|
||||
const combo = [...modifiers, mapKey(finalKey)].join('+')
|
||||
run(['xdotool', 'key', combo])
|
||||
}
|
||||
|
||||
export const typeText: InputBackend['typeText'] = async (text) => {
|
||||
run(['xdotool', 'type', '--delay', '12', text])
|
||||
}
|
||||
|
||||
export const getFrontmostAppInfo: InputBackend['getFrontmostAppInfo'] = () => {
|
||||
try {
|
||||
const windowId = run(['xdotool', 'getactivewindow'])
|
||||
if (!windowId) return null
|
||||
|
||||
const pidStr = run(['xdotool', 'getwindowpid', windowId])
|
||||
if (!pidStr) return null
|
||||
|
||||
const pid = pidStr.trim()
|
||||
|
||||
// Read the executable path from /proc
|
||||
let exePath = ''
|
||||
try {
|
||||
exePath = run(['readlink', '-f', `/proc/${pid}/exe`])
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Read the process name from /proc/comm
|
||||
let appName = ''
|
||||
try {
|
||||
appName = run(['cat', `/proc/${pid}/comm`])
|
||||
} catch { /* ignore */ }
|
||||
|
||||
if (!exePath && !appName) return null
|
||||
return { bundleId: exePath || `/proc/${pid}/exe`, appName: appName || 'unknown' }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
218
packages/@ant/computer-use-input/src/backends/win32.ts
Normal file
218
packages/@ant/computer-use-input/src/backends/win32.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Windows backend for computer-use-input
|
||||
*
|
||||
* Uses PowerShell with Win32 P/Invoke (SetCursorPos, SendInput, keybd_event,
|
||||
* GetForegroundWindow) to control mouse and keyboard.
|
||||
*
|
||||
* All P/Invoke types are compiled once at module load and reused across calls.
|
||||
*/
|
||||
|
||||
import type { FrontmostAppInfo, InputBackend } from '../types.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PowerShell helper — run a script and return trimmed stdout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ps(script: string): string {
|
||||
const result = Bun.spawnSync({
|
||||
cmd: ['powershell', '-NoProfile', '-NonInteractive', '-Command', script],
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
return new TextDecoder().decode(result.stdout).trim()
|
||||
}
|
||||
|
||||
async function psAsync(script: string): Promise<string> {
|
||||
const proc = Bun.spawn(
|
||||
['powershell', '-NoProfile', '-NonInteractive', '-Command', script],
|
||||
{ stdout: 'pipe', stderr: 'pipe' },
|
||||
)
|
||||
const out = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
return out.trim()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// P/Invoke type definitions (compiled once, cached by PowerShell session)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const WIN32_TYPES = `
|
||||
Add-Type -Language CSharp @'
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Diagnostics;
|
||||
|
||||
public class CuWin32 {
|
||||
// --- Cursor ---
|
||||
[DllImport("user32.dll")] public static extern bool SetCursorPos(int X, int Y);
|
||||
[DllImport("user32.dll")] public static extern bool GetCursorPos(out POINT p);
|
||||
[StructLayout(LayoutKind.Sequential)] public struct POINT { public int X; public int Y; }
|
||||
|
||||
// --- SendInput ---
|
||||
[StructLayout(LayoutKind.Sequential)] public struct MOUSEINPUT {
|
||||
public int dx; public int dy; public int mouseData; public uint dwFlags; public uint time; public IntPtr dwExtraInfo;
|
||||
}
|
||||
[StructLayout(LayoutKind.Explicit)] public struct INPUT {
|
||||
[FieldOffset(0)] public uint type;
|
||||
[FieldOffset(4)] public MOUSEINPUT mi;
|
||||
}
|
||||
[StructLayout(LayoutKind.Sequential)] public struct KEYBDINPUT {
|
||||
public ushort wVk; public ushort wScan; public uint dwFlags; public uint time; public IntPtr dwExtraInfo;
|
||||
}
|
||||
[StructLayout(LayoutKind.Explicit)] public struct KINPUT {
|
||||
[FieldOffset(0)] public uint type;
|
||||
[FieldOffset(4)] public KEYBDINPUT ki;
|
||||
}
|
||||
[DllImport("user32.dll", SetLastError=true)] public static extern uint SendInput(uint n, INPUT[] i, int cb);
|
||||
[DllImport("user32.dll", SetLastError=true)] public static extern uint SendInput(uint n, KINPUT[] i, int cb);
|
||||
|
||||
// --- Keyboard ---
|
||||
[DllImport("user32.dll")] public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo);
|
||||
[DllImport("user32.dll")] public static extern short VkKeyScan(char ch);
|
||||
|
||||
// --- Window ---
|
||||
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
||||
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);
|
||||
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder sb, int max);
|
||||
|
||||
// Constants
|
||||
public const uint INPUT_MOUSE = 0, INPUT_KEYBOARD = 1;
|
||||
public const uint MOUSEEVENTF_LEFTDOWN = 0x0002, MOUSEEVENTF_LEFTUP = 0x0004;
|
||||
public const uint MOUSEEVENTF_RIGHTDOWN = 0x0008, MOUSEEVENTF_RIGHTUP = 0x0010;
|
||||
public const uint MOUSEEVENTF_MIDDLEDOWN = 0x0020, MOUSEEVENTF_MIDDLEUP = 0x0040;
|
||||
public const uint MOUSEEVENTF_WHEEL = 0x0800, MOUSEEVENTF_HWHEEL = 0x1000;
|
||||
public const uint KEYEVENTF_KEYUP = 0x0002;
|
||||
}
|
||||
'@
|
||||
`
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Virtual key code mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
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`)
|
||||
}
|
||||
|
||||
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 [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'
|
||||
|
||||
if (action === 'click') {
|
||||
const n = count ?? 1
|
||||
let clicks = ''
|
||||
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}`)
|
||||
} 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`)
|
||||
} 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`)
|
||||
}
|
||||
}
|
||||
|
||||
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) => {
|
||||
const lower = keyName.toLowerCase()
|
||||
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)`)
|
||||
} 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)`)
|
||||
}
|
||||
}
|
||||
|
||||
export const keys: InputBackend['keys'] = async (parts) => {
|
||||
const modifiers: number[] = []
|
||||
let finalKey: string | null = null
|
||||
|
||||
for (const part of parts) {
|
||||
const lower = part.toLowerCase()
|
||||
if (MODIFIER_KEYS.has(lower)) {
|
||||
const vk = VK_MAP[lower]
|
||||
if (vk !== undefined) modifiers.push(vk)
|
||||
} else {
|
||||
finalKey = part
|
||||
}
|
||||
}
|
||||
if (!finalKey) return
|
||||
|
||||
// Build script: press modifiers → press key → release key → release modifiers
|
||||
let script = WIN32_TYPES + '; '
|
||||
for (const vk of modifiers) {
|
||||
script += `[CuWin32]::keybd_event(${vk}, 0, 0, [UIntPtr]::Zero); `
|
||||
}
|
||||
const lower = finalKey.toLowerCase()
|
||||
const vk = VK_MAP[lower]
|
||||
if (vk !== undefined) {
|
||||
script += `[CuWin32]::keybd_event(${vk}, 0, 0, [UIntPtr]::Zero); [CuWin32]::keybd_event(${vk}, 0, 2, [UIntPtr]::Zero); `
|
||||
} else if (finalKey.length === 1) {
|
||||
const charCode = finalKey.charCodeAt(0)
|
||||
script += `$vk = [CuWin32]::VkKeyScan([char]${charCode}) -band 0xFF; [CuWin32]::keybd_event([byte]$vk, 0, 0, [UIntPtr]::Zero); [CuWin32]::keybd_event([byte]$vk, 0, 2, [UIntPtr]::Zero); `
|
||||
}
|
||||
for (const mk of modifiers.reverse()) {
|
||||
script += `[CuWin32]::keybd_event(${mk}, 0, 2, [UIntPtr]::Zero); `
|
||||
}
|
||||
ps(script)
|
||||
}
|
||||
|
||||
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}')`)
|
||||
}
|
||||
|
||||
export const getFrontmostAppInfo: InputBackend['getFrontmostAppInfo'] = () => {
|
||||
try {
|
||||
const out = ps(`${WIN32_TYPES}
|
||||
$hwnd = [CuWin32]::GetForegroundWindow()
|
||||
$procId = [uint32]0
|
||||
[CuWin32]::GetWindowThreadProcessId($hwnd, [ref]$procId) | Out-Null
|
||||
$proc = Get-Process -Id $procId -ErrorAction SilentlyContinue
|
||||
"$($proc.MainModule.FileName)|$($proc.ProcessName)"`)
|
||||
if (!out || !out.includes('|')) return null
|
||||
const [exePath, appName] = out.split('|', 2)
|
||||
return { bundleId: exePath!, appName: appName! }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,183 +1,64 @@
|
||||
/**
|
||||
* @ant/computer-use-input — macOS 键鼠模拟实现
|
||||
* @ant/computer-use-input — macOS keyboard & mouse simulation (enigo)
|
||||
*
|
||||
* 使用 macOS 原生工具实现:
|
||||
* - AppleScript (osascript) — 应用信息、键盘输入
|
||||
* - CGEvent via AppleScript-ObjC bridge — 鼠标操作、位置查询
|
||||
*
|
||||
* 仅 macOS 支持。其他平台返回 { isSupported: false }
|
||||
* This package wraps the macOS-only native enigo .node module.
|
||||
* For Windows/Linux, use src/utils/computerUse/platforms/ instead.
|
||||
*/
|
||||
|
||||
import { $ } from 'bun'
|
||||
|
||||
interface FrontmostAppInfo {
|
||||
export interface FrontmostAppInfo {
|
||||
bundleId: string
|
||||
appName: string
|
||||
}
|
||||
|
||||
// AppleScript key code mapping
|
||||
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,
|
||||
export interface InputBackend {
|
||||
moveMouse(x: number, y: number, animated: boolean): Promise<void>
|
||||
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>
|
||||
typeText(text: string): Promise<void>
|
||||
getFrontmostAppInfo(): FrontmostAppInfo | null
|
||||
}
|
||||
|
||||
const MODIFIER_MAP: Record<string, string> = {
|
||||
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',
|
||||
}
|
||||
|
||||
async function osascript(script: string): Promise<string> {
|
||||
const result = await $`osascript -e ${script}`.quiet().nothrow().text()
|
||||
return result.trim()
|
||||
}
|
||||
|
||||
async function jxa(script: string): Promise<string> {
|
||||
const result = await $`osascript -l JavaScript -e ${script}`.quiet().nothrow().text()
|
||||
return result.trim()
|
||||
}
|
||||
|
||||
function jxaSync(script: string): string {
|
||||
const result = Bun.spawnSync({
|
||||
cmd: ['osascript', '-l', 'JavaScript', '-e', script],
|
||||
stdout: 'pipe', stderr: 'pipe',
|
||||
})
|
||||
return new TextDecoder().decode(result.stdout).trim()
|
||||
}
|
||||
|
||||
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});`
|
||||
}
|
||||
script += ` $.CGEventPost($.kCGHIDEventTap, e);`
|
||||
return script
|
||||
}
|
||||
|
||||
// ---- Implementation functions ----
|
||||
|
||||
async function moveMouse(x: number, y: number, _animated: boolean): Promise<void> {
|
||||
await jxa(buildMouseJxa('kCGEventMouseMoved', x, y, 0))
|
||||
}
|
||||
|
||||
async function key(keyName: string, action: 'press' | 'release'): Promise<void> {
|
||||
if (action === 'release') return
|
||||
const lower = keyName.toLowerCase()
|
||||
const keyCode = KEY_MAP[lower]
|
||||
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}"`)
|
||||
}
|
||||
}
|
||||
|
||||
async function keys(parts: string[]): Promise<void> {
|
||||
const modifiers: string[] = []
|
||||
let finalKey: string | null = null
|
||||
for (const part of parts) {
|
||||
const mod = MODIFIER_MAP[part.toLowerCase()]
|
||||
if (mod) modifiers.push(mod)
|
||||
else finalKey = part
|
||||
}
|
||||
if (!finalKey) return
|
||||
const lower = finalKey.toLowerCase()
|
||||
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}`)
|
||||
} else {
|
||||
await osascript(`tell application "System Events" to keystroke "${finalKey.length === 1 ? finalKey : lower}"${modStr}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function mouseLocation(): Promise<{ x: number; y: number }> {
|
||||
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)) }
|
||||
}
|
||||
|
||||
async function mouseButton(
|
||||
button: 'left' | 'right' | 'middle',
|
||||
action: 'click' | 'press' | 'release',
|
||||
count?: number,
|
||||
): Promise<void> {
|
||||
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'
|
||||
|
||||
if (action === 'click') {
|
||||
for (let i = 0; i < (count ?? 1); i++) {
|
||||
await jxa(buildMouseJxa(downType, pos.x, pos.y, btn, i + 1))
|
||||
await jxa(buildMouseJxa(upType, pos.x, pos.y, btn, i + 1))
|
||||
}
|
||||
} else if (action === 'press') {
|
||||
await jxa(buildMouseJxa(downType, pos.x, pos.y, btn))
|
||||
} else {
|
||||
await jxa(buildMouseJxa(upType, pos.x, pos.y, btn))
|
||||
}
|
||||
}
|
||||
|
||||
async function mouseScroll(amount: number, direction: 'vertical' | 'horizontal'): Promise<void> {
|
||||
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)
|
||||
}
|
||||
|
||||
async function typeText(text: string): Promise<void> {
|
||||
const escaped = text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
||||
await osascript(`tell application "System Events" to keystroke "${escaped}"`)
|
||||
}
|
||||
|
||||
function getFrontmostAppInfo(): FrontmostAppInfo | null {
|
||||
function loadBackend(): InputBackend | null {
|
||||
try {
|
||||
const result = Bun.spawnSync({
|
||||
cmd: ['osascript', '-e', `
|
||||
tell application "System Events"
|
||||
set frontApp to first application process whose frontmost is true
|
||||
set appName to name of frontApp
|
||||
set bundleId to bundle identifier of frontApp
|
||||
return bundleId & "|" & appName
|
||||
end tell
|
||||
`],
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
const output = new TextDecoder().decode(result.stdout).trim()
|
||||
if (!output || !output.includes('|')) return null
|
||||
const [bundleId, appName] = output.split('|', 2)
|
||||
return { bundleId: bundleId!, appName: appName! }
|
||||
if (process.platform === 'darwin') {
|
||||
return require('./backends/darwin.js') as InputBackend
|
||||
} else if (process.platform === 'win32') {
|
||||
return require('./backends/win32.js') as InputBackend
|
||||
} else if (process.platform === 'linux') {
|
||||
return require('./backends/linux.js') as InputBackend
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ---- Exports ----
|
||||
const backend = loadBackend()
|
||||
|
||||
export const isSupported = backend !== null
|
||||
export const moveMouse = backend?.moveMouse
|
||||
export const key = backend?.key
|
||||
export const keys = backend?.keys
|
||||
export const mouseLocation = backend?.mouseLocation
|
||||
export const mouseButton = backend?.mouseButton
|
||||
export const mouseScroll = backend?.mouseScroll
|
||||
export const typeText = backend?.typeText
|
||||
export const getFrontmostAppInfo = backend?.getFrontmostAppInfo ?? (() => null)
|
||||
|
||||
export class ComputerUseInputAPI {
|
||||
declare moveMouse: (x: number, y: number, animated: boolean) => Promise<void>
|
||||
declare key: (key: string, action: 'press' | 'release') => Promise<void>
|
||||
declare keys: (parts: string[]) => Promise<void>
|
||||
declare mouseLocation: () => Promise<{ x: number; y: number }>
|
||||
declare mouseButton: (button: 'left' | 'right' | 'middle', action: 'click' | 'press' | 'release', count?: number) => Promise<void>
|
||||
declare mouseScroll: (amount: number, direction: 'vertical' | 'horizontal') => Promise<void>
|
||||
declare typeText: (text: string) => Promise<void>
|
||||
declare getFrontmostAppInfo: () => FrontmostAppInfo | null
|
||||
declare moveMouse: InputBackend['moveMouse']
|
||||
declare key: InputBackend['key']
|
||||
declare keys: InputBackend['keys']
|
||||
declare mouseLocation: InputBackend['mouseLocation']
|
||||
declare mouseButton: InputBackend['mouseButton']
|
||||
declare mouseScroll: InputBackend['mouseScroll']
|
||||
declare typeText: InputBackend['typeText']
|
||||
declare getFrontmostAppInfo: InputBackend['getFrontmostAppInfo']
|
||||
declare isSupported: true
|
||||
}
|
||||
|
||||
interface ComputerUseInputUnsupported {
|
||||
isSupported: false
|
||||
}
|
||||
|
||||
interface ComputerUseInputUnsupported { isSupported: false }
|
||||
export type ComputerUseInput = ComputerUseInputAPI | ComputerUseInputUnsupported
|
||||
|
||||
// Plain object with all methods as own properties — compatible with require()
|
||||
export const isSupported = process.platform === 'darwin'
|
||||
export { moveMouse, key, keys, mouseLocation, mouseButton, mouseScroll, typeText, getFrontmostAppInfo }
|
||||
|
||||
19
packages/@ant/computer-use-input/src/types.ts
Normal file
19
packages/@ant/computer-use-input/src/types.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export interface FrontmostAppInfo {
|
||||
bundleId: string // macOS: bundle ID, Windows: exe path
|
||||
appName: string
|
||||
}
|
||||
|
||||
export interface InputBackend {
|
||||
moveMouse(x: number, y: number, animated: boolean): Promise<void>
|
||||
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>
|
||||
typeText(text: string): Promise<void>
|
||||
getFrontmostAppInfo(): FrontmostAppInfo | null
|
||||
}
|
||||
553
packages/@ant/computer-use-mcp/src/deniedApps.ts
Normal file
553
packages/@ant/computer-use-mcp/src/deniedApps.ts
Normal file
@@ -0,0 +1,553 @@
|
||||
/**
|
||||
* App category lookup for tiered CU permissions. Three categories land at a
|
||||
* restricted tier instead of `"full"`:
|
||||
*
|
||||
* - **browser** → `"read"` tier — visible in screenshots, NO interaction.
|
||||
* The model can read an already-open page but must use the Claude-in-Chrome
|
||||
* MCP for navigation/clicking/typing.
|
||||
* - **terminal** → `"click"` tier — visible + clickable, NO typing. The
|
||||
* model can click a Run button or scroll test output in an IDE, but can't
|
||||
* type into the integrated terminal. Use the Bash tool for shell work.
|
||||
* - **trading** → `"read"` tier — same restrictions as browsers, but no
|
||||
* CiC-MCP alternative exists. For platforms where a stray click can
|
||||
* execute a trade or send a message to a counterparty.
|
||||
*
|
||||
* Uncategorized apps default to `"full"`. See `getDefaultTierForApp`.
|
||||
*
|
||||
* Identification is two-layered:
|
||||
* 1. Bundle ID match (macOS-only; `InstalledApp.bundleId` is a
|
||||
* CFBundleIdentifier and meaningless on Windows). Fast, exact, the
|
||||
* primary mechanism while CU is darwin-gated.
|
||||
* 2. Display-name substring match (cross-platform fallback). Catches
|
||||
* unresolved requests ("Chrome" when Chrome isn't installed) AND will
|
||||
* be the primary mechanism on Windows/Linux where there's no bundle ID.
|
||||
* Windows-relevant names (PowerShell, cmd, Windows Terminal) are
|
||||
* included now so they activate the moment the darwin gate lifts.
|
||||
*
|
||||
* Keep this file **import-free** (like sentinelApps.ts) — the renderer may
|
||||
* import it via a package.json subpath export, and pulling in
|
||||
* `@modelcontextprotocol/sdk` (a devDep) through the index → mcpServer chain
|
||||
* would fail module resolution in Next.js. The `CuAppPermTier` type is
|
||||
* duplicated as a string literal below rather than imported.
|
||||
*/
|
||||
|
||||
export type DeniedCategory = "browser" | "terminal" | "trading";
|
||||
|
||||
/**
|
||||
* Map a category to its hardcoded tier. Return-type is the string-literal
|
||||
* union inline (this file is import-free; see header comment). The
|
||||
* authoritative type is `CuAppPermTier` in types.ts — keep in sync.
|
||||
*
|
||||
* Not bijective — both `"browser"` and `"trading"` map to `"read"`. Copy
|
||||
* that differs by category (the "use CiC" hint is browser-only) must check
|
||||
* the category, not just the tier.
|
||||
*/
|
||||
export function categoryToTier(
|
||||
category: DeniedCategory | null,
|
||||
): "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",
|
||||
// Google
|
||||
"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",
|
||||
// Mozilla
|
||||
"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",
|
||||
// The Browser Company
|
||||
"company.thebrowser.Browser", // Arc
|
||||
"company.thebrowser.dia", // Dia (agentic)
|
||||
// Privacy-focused
|
||||
"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
|
||||
// Webkit-based misc
|
||||
"com.kagi.kagimacOS", // Orion
|
||||
]);
|
||||
|
||||
/**
|
||||
* Terminals + IDEs with integrated terminals. Supersets
|
||||
* `SHELL_ACCESS_BUNDLE_IDS` from sentinelApps.ts — terminals proceed to the
|
||||
* approval dialog at tier "click", and the sentinel warning renders
|
||||
* alongside the tier badge.
|
||||
*/
|
||||
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
|
||||
// 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",
|
||||
// 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)
|
||||
// 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",
|
||||
// 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
|
||||
// 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",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Trading / crypto platforms — granted at tier `"read"` so the agent can see
|
||||
* balances and prices but can't click into an order, transfer, or IB chat.
|
||||
* Bundle IDs populated from Homebrew cask `uninstall.quit` stanzas as they're
|
||||
* verified; the name-substring fallback below is the primary check. Bloomberg
|
||||
* Terminal has no native macOS build per their FAQ (web/Citrix only).
|
||||
*
|
||||
* Budgeting/accounting apps (Quicken, YNAB, QuickBooks, etc.) are NOT listed
|
||||
* here — they default to tier `"full"`. The risk model for brokerage/crypto
|
||||
* (a stray click can execute a trade) doesn't apply to budgeting apps; the
|
||||
* Cowork system prompt carries the soft instruction to never execute trades
|
||||
* or transfer money on the user's behalf.
|
||||
*/
|
||||
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)
|
||||
// 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",
|
||||
// Crypto
|
||||
"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",
|
||||
// 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) ─────────────────
|
||||
//
|
||||
// Streaming / ebook / music apps and a handful of publisher apps. These
|
||||
// are auto-denied before the approval dialog — no tier can be granted.
|
||||
// Rationale is copyright / content-control (the agent has no legitimate
|
||||
// need to screenshot Netflix or click Play on Spotify).
|
||||
//
|
||||
// Sourced from the ACP CU-apps blocklist xlsx ("Full block" tab). See
|
||||
// /tmp/extract_cu_blocklist.py for the extraction script.
|
||||
|
||||
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",
|
||||
// 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
|
||||
// Video
|
||||
"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",
|
||||
// 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+",
|
||||
// "plex" is too generic — would match "Perplexity". Covered by
|
||||
// tv.plex.* bundle IDs on macOS.
|
||||
"tubi",
|
||||
"crunchyroll",
|
||||
"vudu",
|
||||
// E-readers / audiobooks
|
||||
"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",
|
||||
// Publisher / social apps (from the same blocklist tab)
|
||||
"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
|
||||
* page), this is baked into the build. `buildAccessRequest` strips these
|
||||
* before the approval dialog with "blocked by policy" guidance; the agent
|
||||
* is told to not retry.
|
||||
*/
|
||||
export function isPolicyDenied(
|
||||
bundleId: string | undefined,
|
||||
displayName: string,
|
||||
): boolean {
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
// ─── Display-name fallback (cross-platform) ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Lowercase substrings checked against the requested display name. Catches:
|
||||
* - Unresolved requests (app not installed, Spotlight miss)
|
||||
* - Future Windows/Linux support where bundleId is meaningless
|
||||
*
|
||||
* Matched via `.includes()` on `name.toLowerCase()`. Entries are ordered
|
||||
* by specificity (more-specific first is irrelevant since we return on
|
||||
* first match, but groupings are by category for readability).
|
||||
*/
|
||||
const BROWSER_NAME_SUBSTRINGS: readonly string[] = [
|
||||
"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",
|
||||
// Agentic / AI browsers
|
||||
"comet", // Perplexity's browser — "Comet" substring risks false positives
|
||||
// but leaving for now; "comet" in an app name is rare
|
||||
"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",
|
||||
// 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",
|
||||
// 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",
|
||||
// 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",
|
||||
// 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",
|
||||
// Other IDEs
|
||||
"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
|
||||
// Crypto — exchanges, wallets, portfolio trackers
|
||||
"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",
|
||||
// "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",
|
||||
// Crypto games / metaverse (same trade-execution risk model)
|
||||
"decentraland",
|
||||
"axie infinity",
|
||||
"gods unchained",
|
||||
];
|
||||
|
||||
/**
|
||||
* Display-name substring match. Called when bundle-ID resolution returned
|
||||
* nothing (`resolved === undefined`) or when no bundle-ID deny-list entry
|
||||
* matched. Returns the category for the first matching substring, or null.
|
||||
*
|
||||
* Case-insensitive, substring — so `"Google Chrome"`, `"chrome"`, and
|
||||
* `"Chrome Canary"` all match the `"chrome"` entry.
|
||||
*/
|
||||
export function getDeniedCategoryByDisplayName(
|
||||
name: string,
|
||||
): DeniedCategory | null {
|
||||
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";
|
||||
}
|
||||
for (const sub of BROWSER_NAME_SUBSTRINGS) {
|
||||
if (lower.includes(sub)) return "browser";
|
||||
}
|
||||
for (const sub of TERMINAL_NAME_SUBSTRINGS) {
|
||||
if (lower.includes(sub)) return "terminal";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined check — bundle ID first (exact, fast), then display-name
|
||||
* fallback. This is the function tool-call handlers should use.
|
||||
*
|
||||
* `bundleId` may be undefined (unresolved request — model asked for an app
|
||||
* that isn't installed or Spotlight didn't find). In that case only the
|
||||
* display-name check runs.
|
||||
*/
|
||||
export function getDeniedCategoryForApp(
|
||||
bundleId: string | undefined,
|
||||
displayName: string,
|
||||
): DeniedCategory | null {
|
||||
if (bundleId) {
|
||||
const byId = getDeniedCategory(bundleId);
|
||||
if (byId) return byId;
|
||||
}
|
||||
return getDeniedCategoryByDisplayName(displayName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default tier for an app at grant time. Wraps `getDeniedCategoryForApp` +
|
||||
* `categoryToTier`. Browsers → `"read"`, terminals/IDEs → `"click"`,
|
||||
* everything else → `"full"`.
|
||||
*
|
||||
* Called by `buildAccessRequest` to populate `ResolvedAppRequest.proposedTier`
|
||||
* before the approval dialog shows.
|
||||
*/
|
||||
export function getDefaultTierForApp(
|
||||
bundleId: string | undefined,
|
||||
displayName: string,
|
||||
): "read" | "click" | "full" {
|
||||
return categoryToTier(getDeniedCategoryForApp(bundleId, displayName));
|
||||
}
|
||||
|
||||
export const _test = {
|
||||
BROWSER_BUNDLE_IDS,
|
||||
TERMINAL_BUNDLE_IDS,
|
||||
TRADING_BUNDLE_IDS,
|
||||
POLICY_DENIED_BUNDLE_IDS,
|
||||
BROWSER_NAME_SUBSTRINGS,
|
||||
TERMINAL_NAME_SUBSTRINGS,
|
||||
TRADING_NAME_SUBSTRINGS,
|
||||
POLICY_DENIED_NAME_SUBSTRINGS,
|
||||
};
|
||||
168
packages/@ant/computer-use-mcp/src/executor.ts
Normal file
168
packages/@ant/computer-use-mcp/src/executor.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
export interface DisplayGeometry {
|
||||
displayId: number
|
||||
width: number
|
||||
height: number
|
||||
scaleFactor: number
|
||||
originX: number
|
||||
originY: number
|
||||
}
|
||||
|
||||
export interface ScreenshotResult {
|
||||
base64: string
|
||||
width: number
|
||||
height: number
|
||||
displayWidth: number
|
||||
displayHeight: number
|
||||
originX: number
|
||||
originY: number
|
||||
displayId?: number
|
||||
/** Accessibility snapshot — structured GUI element tree as model-friendly text. Windows only. */
|
||||
accessibilityText?: string
|
||||
}
|
||||
|
||||
export interface FrontmostApp {
|
||||
bundleId: string
|
||||
displayName: string
|
||||
}
|
||||
|
||||
export interface InstalledApp {
|
||||
bundleId: string
|
||||
displayName: string
|
||||
path: string
|
||||
iconDataUrl?: string
|
||||
}
|
||||
|
||||
export interface RunningApp {
|
||||
bundleId: string
|
||||
displayName: string
|
||||
pid?: number
|
||||
}
|
||||
|
||||
export interface ResolvePrepareCaptureResult extends ScreenshotResult {
|
||||
hidden: string[]
|
||||
activated?: string
|
||||
displayId: number
|
||||
}
|
||||
|
||||
export interface ComputerExecutorCapabilities {
|
||||
screenshotFiltering: 'native' | 'none'
|
||||
platform: 'darwin' | 'win32'
|
||||
hostBundleId: string
|
||||
}
|
||||
|
||||
export interface ComputerExecutor {
|
||||
capabilities: ComputerExecutorCapabilities
|
||||
prepareForAction(
|
||||
allowlistBundleIds: string[],
|
||||
displayId?: number,
|
||||
): Promise<string[]>
|
||||
previewHideSet(
|
||||
allowlistBundleIds: string[],
|
||||
displayId?: number,
|
||||
): Promise<Array<{ bundleId: string; displayName: string }>>
|
||||
getDisplaySize(displayId?: number): Promise<DisplayGeometry>
|
||||
listDisplays(): Promise<DisplayGeometry[]>
|
||||
findWindowDisplays(
|
||||
bundleIds: string[],
|
||||
): Promise<Array<{ bundleId: string; displayIds: number[] }>>
|
||||
resolvePrepareCapture(opts: {
|
||||
allowedBundleIds: string[]
|
||||
preferredDisplayId?: number
|
||||
autoResolve: boolean
|
||||
doHide?: boolean
|
||||
}): Promise<ResolvePrepareCaptureResult>
|
||||
screenshot(opts: {
|
||||
allowedBundleIds: string[]
|
||||
displayId?: number
|
||||
}): Promise<ScreenshotResult>
|
||||
zoom(
|
||||
regionLogical: { x: number; y: number; w: number; h: number },
|
||||
allowedBundleIds: string[],
|
||||
displayId?: number,
|
||||
): Promise<{ base64: string; width: number; height: number }>
|
||||
key(keySequence: string, repeat?: number): Promise<void>
|
||||
holdKey(keyNames: string[], durationMs: number): Promise<void>
|
||||
type(text: string, opts: { viaClipboard: boolean }): Promise<void>
|
||||
readClipboard(): Promise<string>
|
||||
writeClipboard(text: string): Promise<void>
|
||||
moveMouse(x: number, y: number): Promise<void>
|
||||
click(
|
||||
x: number,
|
||||
y: number,
|
||||
button: 'left' | 'right' | 'middle',
|
||||
count: 1 | 2 | 3,
|
||||
modifiers?: string[],
|
||||
): Promise<void>
|
||||
mouseDown(): Promise<void>
|
||||
mouseUp(): Promise<void>
|
||||
getCursorPosition(): Promise<{ x: number; y: number }>
|
||||
drag(
|
||||
from: { x: number; y: number } | undefined,
|
||||
to: { x: number; y: number },
|
||||
): Promise<void>
|
||||
scroll(x: number, y: number, dx: number, dy: number): Promise<void>
|
||||
getFrontmostApp(): Promise<FrontmostApp | null>
|
||||
appUnderPoint(
|
||||
x: number,
|
||||
y: number,
|
||||
): Promise<{ bundleId: string; displayName: string } | null>
|
||||
listInstalledApps(): Promise<InstalledApp[]>
|
||||
getAppIcon(path: string): Promise<string | undefined>
|
||||
listRunningApps(): Promise<RunningApp[]>
|
||||
openApp(bundleId: string): Promise<void>
|
||||
|
||||
// ── 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>
|
||||
/** Get the current window rect of the bound window */
|
||||
getWindowRect?(): Promise<{ x: number; y: number; width: number; height: number } | null>
|
||||
|
||||
// ── Element-targeted actions (Windows UIA, optional) ────────────────────
|
||||
/** Open terminal and launch an agent CLI */
|
||||
openTerminal?(opts: {
|
||||
agent: 'claude' | 'codex' | 'gemini' | 'custom'
|
||||
command?: string
|
||||
terminal?: 'wt' | 'powershell' | 'cmd'
|
||||
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>
|
||||
/** 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>
|
||||
/** List all visible windows */
|
||||
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 }>
|
||||
/** Virtual keyboard — send keys/text/combos to bound window only */
|
||||
virtualKeyboard?(opts: {
|
||||
action: 'type' | 'combo' | 'press' | 'release' | 'hold'
|
||||
text: string
|
||||
duration?: number
|
||||
repeat?: number
|
||||
}): 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
|
||||
}): 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>
|
||||
/** Activate the bound window (foreground + click to focus) */
|
||||
activateWindow?(clickX?: number, clickY?: number): Promise<boolean>
|
||||
/** Handle a terminal prompt (yes/no/select/type + enter) */
|
||||
respondToPrompt?(opts: {
|
||||
responseType: 'yes' | 'no' | 'enter' | 'escape' | 'select' | 'type'
|
||||
arrowDirection?: 'up' | 'down'
|
||||
arrowCount?: number
|
||||
text?: string
|
||||
}): Promise<boolean>
|
||||
/** Click an element by name/role/automationId via UI Automation */
|
||||
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>
|
||||
}
|
||||
108
packages/@ant/computer-use-mcp/src/imageResize.ts
Normal file
108
packages/@ant/computer-use-mcp/src/imageResize.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Port of the API's image transcoder target-size algorithm. Pre-sizing
|
||||
* screenshots to this function's output means the API's early-return fires
|
||||
* (tokens ≤ max) and the image is NOT resized server-side — so the model
|
||||
* sees exactly the dimensions in `ScreenshotResult.width/height` and
|
||||
* `scaleCoord` stays coherent.
|
||||
*
|
||||
* Rust reference: api/api/image_transcoder/rust_transcoder/src/utils/resize.rs
|
||||
* Sibling TS port: apps/claude-browser-use/src/utils/imageResize.ts (identical
|
||||
* algorithm, lives in the Chrome extension tree — not a shared package).
|
||||
*
|
||||
* See COORDINATES.md for why this matters for click accuracy.
|
||||
*/
|
||||
|
||||
export interface ResizeParams {
|
||||
pxPerToken: number;
|
||||
maxTargetPx: number;
|
||||
maxTargetTokens: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Production defaults — match `resize.rs:160-164` and Chrome's
|
||||
* `CDPService.ts:638-642`. Vision encoder uses 28px tiles; 1568 is both
|
||||
* the long-edge cap (56 tiles) AND the token budget.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
function nTokensForImg(
|
||||
width: number,
|
||||
height: number,
|
||||
pxPerToken: number,
|
||||
): number {
|
||||
return nTokensForPx(width, pxPerToken) * nTokensForPx(height, pxPerToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Binary-search along the width dimension for the largest image that:
|
||||
* - preserves the input aspect ratio
|
||||
* - has long edge ≤ maxTargetPx
|
||||
* - has ceil(w/pxPerToken) × ceil(h/pxPerToken) ≤ maxTargetTokens
|
||||
*
|
||||
* Returns [width, height]. No-op if input already satisfies all three.
|
||||
*
|
||||
* The long-edge constraint alone (what we used to use) is insufficient on
|
||||
* squarer-than-16:9 displays: 1568×1014 (MBP 16" AR) is 56×37 = 2072 tokens,
|
||||
* over budget, and gets server-resized to 1372×887 — model then clicks in
|
||||
* 1372-space but scaleCoord assumed 1568-space → ~14% coord error.
|
||||
*
|
||||
* Matches resize.rs:91-155 exactly (verified against its test vectors).
|
||||
*/
|
||||
export function targetImageSize(
|
||||
width: number,
|
||||
height: number,
|
||||
params: ResizeParams,
|
||||
): [number, number] {
|
||||
const { pxPerToken, maxTargetPx, maxTargetTokens } = params;
|
||||
|
||||
if (
|
||||
width <= maxTargetPx &&
|
||||
height <= maxTargetPx &&
|
||||
nTokensForImg(width, height, pxPerToken) <= maxTargetTokens
|
||||
) {
|
||||
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 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;
|
||||
|
||||
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);
|
||||
|
||||
if (
|
||||
middleWidth <= maxTargetPx &&
|
||||
nTokensForImg(middleWidth, middleHeight, pxPerToken) <= maxTargetTokens
|
||||
) {
|
||||
lowerBoundWidth = middleWidth;
|
||||
} else {
|
||||
upperBoundWidth = middleWidth;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,163 +1,69 @@
|
||||
/**
|
||||
* @ant/computer-use-mcp — Stub 实现
|
||||
*
|
||||
* 提供类型安全的 stub,所有函数返回合理的默认值。
|
||||
* 在 feature('CHICAGO_MCP') = false 时不会被实际调用,
|
||||
* 但确保 import 不报错且类型正确。
|
||||
*/
|
||||
|
||||
import type {
|
||||
ComputerUseHostAdapter,
|
||||
CoordinateMode,
|
||||
GrantFlags,
|
||||
Logger,
|
||||
} from './types'
|
||||
|
||||
// Re-export types from types.ts
|
||||
export type { CoordinateMode, Logger } from './types'
|
||||
export type {
|
||||
ComputerUseConfig,
|
||||
ComputerExecutor,
|
||||
DisplayGeometry,
|
||||
FrontmostApp,
|
||||
InstalledApp,
|
||||
ResolvePrepareCaptureResult,
|
||||
RunningApp,
|
||||
ScreenshotResult,
|
||||
} from "./executor.js";
|
||||
|
||||
export type {
|
||||
AppGrant,
|
||||
CuAppPermTier,
|
||||
ComputerUseHostAdapter,
|
||||
ComputerUseOverrides,
|
||||
ComputerUseSessionContext,
|
||||
CoordinateMode,
|
||||
CuGrantFlags,
|
||||
CuPermissionRequest,
|
||||
CuPermissionResponse,
|
||||
CuSubGates,
|
||||
} from './types'
|
||||
export { DEFAULT_GRANT_FLAGS } from './types'
|
||||
CuTeachPermissionRequest,
|
||||
Logger,
|
||||
ResolvedAppRequest,
|
||||
ScreenshotDims,
|
||||
TeachStepRequest,
|
||||
TeachStepResult,
|
||||
} from "./types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types (defined here for callers that import from the main entry)
|
||||
// ---------------------------------------------------------------------------
|
||||
export { DEFAULT_GRANT_FLAGS } from "./types.js";
|
||||
|
||||
export interface DisplayGeometry {
|
||||
width: number
|
||||
height: number
|
||||
displayId?: number
|
||||
originX?: number
|
||||
originY?: number
|
||||
}
|
||||
export {
|
||||
SENTINEL_BUNDLE_IDS,
|
||||
getSentinelCategory,
|
||||
} from "./sentinelApps.js";
|
||||
export type { SentinelCategory } from "./sentinelApps.js";
|
||||
|
||||
export interface FrontmostApp {
|
||||
bundleId: string
|
||||
displayName: string
|
||||
}
|
||||
export {
|
||||
categoryToTier,
|
||||
getDefaultTierForApp,
|
||||
getDeniedCategory,
|
||||
getDeniedCategoryByDisplayName,
|
||||
getDeniedCategoryForApp,
|
||||
isPolicyDenied,
|
||||
} from "./deniedApps.js";
|
||||
export type { DeniedCategory } from "./deniedApps.js";
|
||||
|
||||
export interface InstalledApp {
|
||||
bundleId: string
|
||||
displayName: string
|
||||
path: string
|
||||
}
|
||||
export { isSystemKeyCombo, normalizeKeySequence } from "./keyBlocklist.js";
|
||||
|
||||
export interface RunningApp {
|
||||
bundleId: string
|
||||
displayName: string
|
||||
}
|
||||
export { ALL_SUB_GATES_OFF, ALL_SUB_GATES_ON } from "./subGates.js";
|
||||
|
||||
export interface ScreenshotResult {
|
||||
base64: string
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
export { API_RESIZE_PARAMS, targetImageSize } from "./imageResize.js";
|
||||
export type { ResizeParams } from "./imageResize.js";
|
||||
|
||||
export type ResolvePrepareCaptureResult = ScreenshotResult
|
||||
export { defersLockAcquire, handleToolCall } from "./toolCalls.js";
|
||||
export type {
|
||||
CuCallTelemetry,
|
||||
CuCallToolResult,
|
||||
CuErrorKind,
|
||||
} from "./toolCalls.js";
|
||||
|
||||
export interface ScreenshotDims {
|
||||
width: number
|
||||
height: number
|
||||
displayWidth: number
|
||||
displayHeight: number
|
||||
displayId: number
|
||||
originX: number
|
||||
originY: number
|
||||
}
|
||||
export { bindSessionContext, createComputerUseMcpServer } from "./mcpServer.js";
|
||||
export { buildComputerUseTools } from "./tools.js";
|
||||
|
||||
export interface CuCallToolResultContent {
|
||||
type: 'image' | 'text'
|
||||
data?: string
|
||||
mimeType?: string
|
||||
text?: string
|
||||
}
|
||||
|
||||
export interface CuCallToolResult {
|
||||
content: CuCallToolResultContent[]
|
||||
telemetry: {
|
||||
error_kind?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type ComputerUseSessionContext = Record<string, unknown>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API_RESIZE_PARAMS — 默认的截图缩放参数
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const API_RESIZE_PARAMS = {
|
||||
maxWidth: 1280,
|
||||
maxHeight: 800,
|
||||
maxPixels: 1280 * 800,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ComputerExecutor — stub class
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class ComputerExecutor {
|
||||
capabilities: Record<string, boolean> = {}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Functions — 返回合理默认值的 stub
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 计算目标截图尺寸。
|
||||
* 在物理宽高和 API 限制之间取最优尺寸。
|
||||
*/
|
||||
export function targetImageSize(
|
||||
physW: number,
|
||||
physH: number,
|
||||
_params?: typeof API_RESIZE_PARAMS,
|
||||
): [number, number] {
|
||||
const maxW = _params?.maxWidth ?? 1280
|
||||
const maxH = _params?.maxHeight ?? 800
|
||||
const scale = Math.min(1, maxW / physW, maxH / physH)
|
||||
return [Math.round(physW * scale), Math.round(physH * scale)]
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定会话上下文,返回工具调度函数。
|
||||
* Stub 返回一个始终返回空结果的调度器。
|
||||
*/
|
||||
export function bindSessionContext(
|
||||
_adapter: ComputerUseHostAdapter,
|
||||
_coordinateMode: CoordinateMode,
|
||||
_ctx: ComputerUseSessionContext,
|
||||
): (name: string, args: unknown) => Promise<CuCallToolResult> {
|
||||
return async (_name: string, _args: unknown) => ({
|
||||
content: [],
|
||||
telemetry: {},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 Computer Use 工具定义列表。
|
||||
* Stub 返回空数组(无工具)。
|
||||
*/
|
||||
export function buildComputerUseTools(
|
||||
_capabilities?: Record<string, boolean>,
|
||||
_coordinateMode?: CoordinateMode,
|
||||
_installedAppNames?: string[],
|
||||
): Array<{ name: string; description: string; inputSchema: Record<string, unknown> }> {
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Computer Use MCP server。
|
||||
* Stub 返回 null(服务未启用)。
|
||||
*/
|
||||
export function createComputerUseMcpServer(
|
||||
_adapter?: ComputerUseHostAdapter,
|
||||
_coordinateMode?: CoordinateMode,
|
||||
): null {
|
||||
return null
|
||||
}
|
||||
export {
|
||||
comparePixelAtLocation,
|
||||
validateClickTarget,
|
||||
} from "./pixelCompare.js";
|
||||
export type { CropRawPatchFn, PixelCompareResult } from "./pixelCompare.js";
|
||||
|
||||
153
packages/@ant/computer-use-mcp/src/keyBlocklist.ts
Normal file
153
packages/@ant/computer-use-mcp/src/keyBlocklist.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Key combos that cross app boundaries or terminate processes. Gated behind
|
||||
* the `systemKeyCombos` grant flag. When that flag is off, the `key` tool
|
||||
* rejects these and returns a tool error telling the model to request the
|
||||
* flag; all other combos work normally.
|
||||
*
|
||||
* Matching is canonicalized: every modifier alias the Rust executor accepts
|
||||
* collapses to one canonical name. Without this, `command+q` / `meta+q` /
|
||||
* `cmd+alt+escape` bypass the gate — see keyBlocklist.test.ts for the three
|
||||
* bypass forms and the Rust parity check that catches future alias drift.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Every modifier alias enigo_wrap.rs accepts (two copies: :351-359, :564-572),
|
||||
* mapped to one canonical per Key:: variant. Left/right variants collapse —
|
||||
* the blocklist doesn't distinguish which Ctrl.
|
||||
*
|
||||
* Canonical names are Rust's own variant names lowercased. Blocklist entries
|
||||
* below use ONLY these. "meta" reads odd for Cmd+Q but it's honest: Rust
|
||||
* sends Key::Meta, which is Cmd on darwin and Win on win32.
|
||||
*/
|
||||
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",
|
||||
// Key::Control + LControl + RControl
|
||||
ctrl: "ctrl",
|
||||
control: "ctrl",
|
||||
lctrl: "ctrl",
|
||||
lcontrol: "ctrl",
|
||||
rctrl: "ctrl",
|
||||
rcontrol: "ctrl",
|
||||
// Key::Shift + LShift + RShift
|
||||
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",
|
||||
};
|
||||
|
||||
/** Sort order for canonicals. ctrl < alt < shift < meta. */
|
||||
const MODIFIER_ORDER = ["ctrl", "alt", "shift", "meta"];
|
||||
|
||||
/**
|
||||
* Canonical-form entries only. Every modifier must be a CANONICAL_MODIFIER
|
||||
* *value* (not key), modifiers must be in MODIFIER_ORDER, non-modifier last.
|
||||
* 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
|
||||
]);
|
||||
|
||||
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
|
||||
]);
|
||||
|
||||
/**
|
||||
* Partition into sorted-canonical modifiers and non-modifier keys.
|
||||
* Shared by normalizeKeySequence (join for display) and isSystemKeyCombo
|
||||
* (check mods+each-key to catch the cmd+q+a suffix bypass).
|
||||
*/
|
||||
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[] = [];
|
||||
for (const p of parts) {
|
||||
const canonical = CANONICAL_MODIFIER[p];
|
||||
if (canonical !== undefined) {
|
||||
mods.push(canonical);
|
||||
} else {
|
||||
keys.push(p);
|
||||
}
|
||||
}
|
||||
// Dedupe: "cmd+command+q" → "meta+q", not "meta+meta+q".
|
||||
const uniqueMods = [...new Set(mods)];
|
||||
uniqueMods.sort(
|
||||
(a, b) => MODIFIER_ORDER.indexOf(a) - MODIFIER_ORDER.indexOf(b),
|
||||
);
|
||||
return { mods: uniqueMods, keys };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize "Cmd + Shift + Q" → "shift+meta+q": lowercase, trim, alias →
|
||||
* canonical, dedupe, sort modifiers, non-modifiers last.
|
||||
*/
|
||||
export function normalizeKeySequence(seq: string): string {
|
||||
const { mods, keys } = partitionKeys(seq);
|
||||
return [...mods, ...keys].join("+");
|
||||
}
|
||||
|
||||
/**
|
||||
* True if the sequence would fire a blocked OS shortcut.
|
||||
*
|
||||
* Checks mods + EACH non-modifier key individually, not just the full
|
||||
* joined string. `cmd+q+a` → Rust presses Cmd, then Q (Cmd+Q fires here),
|
||||
* then A. Exact-match against "meta+q+a" misses; checking "meta+q" and
|
||||
* "meta+a" separately catches the Q.
|
||||
*
|
||||
* Modifiers-only sequences ("cmd+shift") are checked as-is — no key to
|
||||
* pair with, and no blocklist entry is modifier-only, so this is a no-op
|
||||
* that falls through to false. Covers the click-modifier case where
|
||||
* `left_click(text="cmd")` is legitimate.
|
||||
*/
|
||||
export function isSystemKeyCombo(
|
||||
seq: string,
|
||||
platform: "darwin" | "win32",
|
||||
): boolean {
|
||||
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("+"));
|
||||
}
|
||||
|
||||
// mods + each key. Any hit blocks the whole sequence.
|
||||
for (const key of keys) {
|
||||
if (blocklist.has(prefix + key)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const _test = {
|
||||
CANONICAL_MODIFIER,
|
||||
BLOCKED_DARWIN,
|
||||
BLOCKED_WIN32,
|
||||
MODIFIER_ORDER,
|
||||
};
|
||||
313
packages/@ant/computer-use-mcp/src/mcpServer.ts
Normal file
313
packages/@ant/computer-use-mcp/src/mcpServer.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* MCP server factory + session-context binder.
|
||||
*
|
||||
* Two entry points:
|
||||
*
|
||||
* `bindSessionContext` — the wrapper closure. Takes a `ComputerUseSessionContext`
|
||||
* (getters + callbacks backed by host session state), returns a dispatcher.
|
||||
* Reusable by both the MCP CallTool handler here AND Cowork's
|
||||
* `InternalServerDefinition.handleToolCall` (which doesn't go through MCP).
|
||||
* This replaces the duplicated wrapper closures in apps/desktop/…/serverDef.ts
|
||||
* and the Claude Code CLI's CU host wrapper — both did the same thing: build `ComputerUseOverrides`
|
||||
* fresh from getters, call `handleToolCall`, stash screenshot, merge permissions.
|
||||
*
|
||||
* `createComputerUseMcpServer` — the Server object. When `context` is provided,
|
||||
* the CallTool handler is real (uses `bindSessionContext`). When not, it's the
|
||||
* legacy stub that returns a not-wired error. The tool-schema ListTools handler
|
||||
* is the same either way.
|
||||
*/
|
||||
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.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";
|
||||
import type {
|
||||
AppGrant,
|
||||
ComputerUseHostAdapter,
|
||||
ComputerUseOverrides,
|
||||
ComputerUseSessionContext,
|
||||
CoordinateMode,
|
||||
CuGrantFlags,
|
||||
CuPermissionResponse,
|
||||
} 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.";
|
||||
|
||||
/**
|
||||
* Dedupe `granted` into `existing` on bundleId, spread truthy-only flags over
|
||||
* defaults+existing. Truthy-only: a subsequent `request_access` that doesn't
|
||||
* request clipboard can't revoke an earlier clipboard grant — revocation lives
|
||||
* in a Settings page, not here.
|
||||
*
|
||||
* Same merge both hosts implemented independently today.
|
||||
*/
|
||||
function mergePermissionResponse(
|
||||
existing: readonly AppGrant[],
|
||||
existingFlags: CuGrantFlags,
|
||||
response: CuPermissionResponse,
|
||||
): { apps: AppGrant[]; flags: CuGrantFlags } {
|
||||
const seen = new Set(existing.map((a) => a.bundleId));
|
||||
const apps = [
|
||||
...existing,
|
||||
...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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind session state to a reusable dispatcher. The returned function is the
|
||||
* wrapper closure: async lock gate → build overrides fresh → `handleToolCall`
|
||||
* → stash screenshot → strip piggybacked fields.
|
||||
*
|
||||
* The last-screenshot blob is held in a closure cell here (not on `ctx`), so
|
||||
* hosts don't need to guarantee `ctx` object identity across calls — they just
|
||||
* need to hold onto the returned dispatcher. Cowork caches per
|
||||
* `InternalServerContext` in a WeakMap; the CLI host constructs once at server creation.
|
||||
*/
|
||||
export function bindSessionContext(
|
||||
adapter: ComputerUseHostAdapter,
|
||||
coordinateMode: CoordinateMode,
|
||||
ctx: ComputerUseSessionContext,
|
||||
): (name: string, args: unknown) => Promise<CuCallToolResult> {
|
||||
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;
|
||||
|
||||
const wrapPermission = ctx.onPermissionRequest
|
||||
? async (
|
||||
req: Parameters<NonNullable<typeof ctx.onPermissionRequest>>[0],
|
||||
signal: AbortSignal,
|
||||
): Promise<CuPermissionResponse> => {
|
||||
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;
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const wrapTeachPermission = ctx.onTeachPermissionRequest
|
||||
? async (
|
||||
req: Parameters<NonNullable<typeof ctx.onTeachPermissionRequest>>[0],
|
||||
signal: AbortSignal,
|
||||
): Promise<CuPermissionResponse> => {
|
||||
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;
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return async (name, args) => {
|
||||
// ─── Async lock gate ─────────────────────────────────────────────────
|
||||
// Replaces the sync Gate-3 in `handleToolCall` — we pass
|
||||
// `checkCuLock: undefined` below so it no-ops. Hosts with
|
||||
// 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();
|
||||
if (lock.holder !== undefined && !lock.isSelf) {
|
||||
const text =
|
||||
ctx.formatLockHeldMessage?.(lock.holder) ?? DEFAULT_LOCK_HELD_MESSAGE;
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
isError: true,
|
||||
telemetry: { error_kind: "cu_lock_held" },
|
||||
};
|
||||
}
|
||||
if (lock.holder === undefined && !defersLockAcquire(name)) {
|
||||
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
|
||||
// no signal that we lost — verify we're now the holder before
|
||||
// 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();
|
||||
if (recheck.holder !== undefined && !recheck.isSelf) {
|
||||
const text =
|
||||
ctx.formatLockHeldMessage?.(recheck.holder) ??
|
||||
DEFAULT_LOCK_HELD_MESSAGE;
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
isError: true,
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Build overrides fresh ───────────────────────────────────────────
|
||||
// Blob-first; dims-fallback with base64:"" when the closure cell is
|
||||
// unset (cross-respawn). scaleCoord reads dims; pixelCompare sees "" →
|
||||
// isEmpty → skip.
|
||||
const dimsFallback = lastScreenshot
|
||||
? undefined
|
||||
: 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 overrides: ComputerUseOverrides = {
|
||||
allowedApps: [...ctx.getAllowedApps()],
|
||||
grantFlags: ctx.getGrantFlags(),
|
||||
userDeniedBundleIds: ctx.getUserDeniedBundleIds(),
|
||||
coordinateMode,
|
||||
selectedDisplayId: ctx.getSelectedDisplayId(),
|
||||
displayPinnedByModel: ctx.getDisplayPinnedByModel?.(),
|
||||
displayResolvedForApps: ctx.getDisplayResolvedForApps?.(),
|
||||
lastScreenshot:
|
||||
lastScreenshot ??
|
||||
(dimsFallback ? { ...dimsFallback, base64: "" } : undefined),
|
||||
onPermissionRequest: wrapPermission
|
||||
? (req) => wrapPermission(req, dialogAbort.signal)
|
||||
: undefined,
|
||||
onTeachPermissionRequest: wrapTeachPermission
|
||||
? (req) => wrapTeachPermission(req, dialogAbort.signal)
|
||||
: undefined,
|
||||
onAppsHidden: ctx.onAppsHidden,
|
||||
getClipboardStash: ctx.getClipboardStash,
|
||||
onClipboardStashChanged: ctx.onClipboardStashChanged,
|
||||
onResolvedDisplayUpdated: ctx.onResolvedDisplayUpdated,
|
||||
onDisplayPinned: ctx.onDisplayPinned,
|
||||
onDisplayResolvedForApps: ctx.onDisplayResolvedForApps,
|
||||
onTeachModeActivated: ctx.onTeachModeActivated,
|
||||
onTeachStep: ctx.onTeachStep,
|
||||
onTeachWorking: ctx.onTeachWorking,
|
||||
getTeachModeActive: ctx.getTeachModeActive,
|
||||
// Undefined → handleToolCall's sync Gate-3 no-ops. The async gate
|
||||
// above already ran.
|
||||
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);
|
||||
|
||||
if (result.screenshot) {
|
||||
lastScreenshot = result.screenshot;
|
||||
const { base64: _blob, ...dims } = result.screenshot;
|
||||
logger.debug(`[${serverName}] screenshot dims: ${JSON.stringify(dims)}`);
|
||||
ctx.onScreenshotCaptured?.(dims);
|
||||
}
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
dialogAbort.abort();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createComputerUseMcpServer(
|
||||
adapter: ComputerUseHostAdapter,
|
||||
coordinateMode: CoordinateMode,
|
||||
context?: ComputerUseSessionContext,
|
||||
): Server {
|
||||
const { serverName, logger } = adapter;
|
||||
|
||||
const server = new Server(
|
||||
{ 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);
|
||||
server.setRequestHandler(
|
||||
CallToolRequestSchema,
|
||||
async (request): Promise<CallToolResult> => {
|
||||
const { screenshot: _s, telemetry: _t, ...result } = await dispatch(
|
||||
request.params.name,
|
||||
request.params.arguments ?? {},
|
||||
);
|
||||
return result;
|
||||
},
|
||||
);
|
||||
return server;
|
||||
}
|
||||
|
||||
// Legacy: no context → stub handler. Reached only if something calls the
|
||||
// server over MCP transport WITHOUT going through a binder (a wiring
|
||||
// regression). Clear error instead of silent failure.
|
||||
server.setRequestHandler(
|
||||
CallToolRequestSchema,
|
||||
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.",
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
171
packages/@ant/computer-use-mcp/src/pixelCompare.ts
Normal file
171
packages/@ant/computer-use-mcp/src/pixelCompare.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Staleness guard ported from the Vercept acquisition.
|
||||
*
|
||||
* Compares the model's last-seen screenshot against a fresh-right-now
|
||||
* screenshot at the click target, so the model never clicks pixels it hasn't
|
||||
* seen. If the 9×9 patch around the target differs, the click is aborted and
|
||||
* the model is told to re-screenshot. This is NOT a popup detector.
|
||||
*
|
||||
* Semantics preserved exactly:
|
||||
* - Skip on no `lastScreenshot` (cold start) — click proceeds.
|
||||
* - Skip on any internal error (crop throws, screenshot fails, etc.) —
|
||||
* click proceeds. Validation failure must never block the action.
|
||||
* - 9×9 exact byte equality on raw pixel bytes. No fuzzing, no tolerance.
|
||||
* - Compare in percentage coords so Retina scale doesn't matter.
|
||||
*
|
||||
* JPEG decode + crop is INJECTED via `ComputerUseHostAdapter.cropRawPatch`.
|
||||
* The original used `sharp` (LGPL, native `.node` addon); we inject Electron's
|
||||
* `nativeImage` (Chromium decoders, BSD, nothing to bundle) from the host, so
|
||||
* this package never imports it — the crop is a function parameter.
|
||||
*/
|
||||
|
||||
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;
|
||||
|
||||
/** 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;
|
||||
|
||||
export interface PixelCompareResult {
|
||||
/** true → click may proceed. false → patch changed, abort the click. */
|
||||
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;
|
||||
/** Populated when valid === false. Returned to the model verbatim. */
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the crop rect for a patch centered on (xPercent, yPercent).
|
||||
*
|
||||
* Dimensions come from ScreenshotResult.width/height (physical pixels). Both
|
||||
* screenshots have the same dimensions (same display, consecutive captures),
|
||||
* so the rect is the same for both.
|
||||
*/
|
||||
function computeCropRect(
|
||||
imgW: number,
|
||||
imgH: number,
|
||||
xPercent: number,
|
||||
yPercent: number,
|
||||
gridSize: number,
|
||||
): { x: number; y: number; width: number; height: number } | 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 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;
|
||||
|
||||
return { x: cropX, y: cropY, width: cropW, height: cropH };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare the same patch location between two screenshots.
|
||||
*
|
||||
* @returns true when the raw pixel bytes are identical. false on any
|
||||
* difference, or on any internal error (the caller treats an error here as
|
||||
* `skipped`, so the false is harmless).
|
||||
*/
|
||||
export function comparePixelAtLocation(
|
||||
crop: CropRawPatchFn,
|
||||
lastScreenshot: ScreenshotResult,
|
||||
freshScreenshot: ScreenshotResult,
|
||||
xPercent: number,
|
||||
yPercent: number,
|
||||
gridSize: number = DEFAULT_GRID_SIZE,
|
||||
): boolean {
|
||||
// Both screenshots are of the same display — use the fresh one's
|
||||
// dimensions (less likely to be stale than last's).
|
||||
const rect = computeCropRect(
|
||||
freshScreenshot.width,
|
||||
freshScreenshot.height,
|
||||
xPercent,
|
||||
yPercent,
|
||||
gridSize,
|
||||
);
|
||||
if (!rect) 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Battle-tested click-target validation ported from the Vercept acquisition,
|
||||
* with the fresh-screenshot capture delegated to the caller (we don't have
|
||||
* a global `SystemActions.takeScreenshot()` — the executor is injected).
|
||||
*
|
||||
* Skip conditions (any of these → `{ valid: true, skipped: true }`):
|
||||
* - `lastScreenshot` is undefined (cold start).
|
||||
* - `takeFreshScreenshot()` throws or returns null.
|
||||
* - Injected crop function returns null (decode failure).
|
||||
* - Any other exception.
|
||||
*
|
||||
* The caller decides whether to invoke this at all (sub-gate check lives
|
||||
* in toolCalls.ts, not here).
|
||||
*/
|
||||
export async function validateClickTarget(
|
||||
crop: CropRawPatchFn,
|
||||
lastScreenshot: ScreenshotResult | undefined,
|
||||
xPercent: number,
|
||||
yPercent: number,
|
||||
takeFreshScreenshot: () => Promise<ScreenshotResult | null>,
|
||||
logger: Logger,
|
||||
gridSize: number = DEFAULT_GRID_SIZE,
|
||||
): Promise<PixelCompareResult> {
|
||||
if (!lastScreenshot) {
|
||||
return { valid: true, skipped: true };
|
||||
}
|
||||
|
||||
try {
|
||||
const fresh = await takeFreshScreenshot();
|
||||
if (!fresh) {
|
||||
return { valid: true, skipped: true };
|
||||
}
|
||||
|
||||
const pixelsMatch = comparePixelAtLocation(
|
||||
crop,
|
||||
lastScreenshot,
|
||||
fresh,
|
||||
xPercent,
|
||||
yPercent,
|
||||
gridSize,
|
||||
);
|
||||
|
||||
if (pixelsMatch) {
|
||||
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.",
|
||||
};
|
||||
} 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 };
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,43 @@
|
||||
/**
|
||||
* Sentinel apps — 需要特殊权限警告的应用列表
|
||||
* Bundle IDs that are escalations-in-disguise. The approval UI shows a warning
|
||||
* badge for these; they are NOT blocked. Power users may legitimately want the
|
||||
* model controlling a terminal.
|
||||
*
|
||||
* 包含终端、文件管理器、系统设置等敏感应用。
|
||||
* Computer Use 操作这些应用时会显示额外警告。
|
||||
* Imported by the renderer via the `./sentinelApps` subpath (package.json
|
||||
* `exports`), which keeps Next.js from reaching index.ts → mcpServer.ts →
|
||||
* @modelcontextprotocol/sdk (devDep, would fail module resolution). Keep
|
||||
* this file import-free so the subpath stays clean.
|
||||
*/
|
||||
|
||||
type SentinelCategory = 'shell' | 'filesystem' | 'system_settings'
|
||||
/** 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",
|
||||
]);
|
||||
|
||||
const SENTINEL_MAP: Record<string, SentinelCategory> = {
|
||||
// Shell / Terminal
|
||||
'com.apple.Terminal': 'shell',
|
||||
'com.googlecode.iterm2': 'shell',
|
||||
'dev.warp.Warp-Stable': 'shell',
|
||||
'io.alacritty': 'shell',
|
||||
'com.github.wez.wezterm': 'shell',
|
||||
'net.kovidgoyal.kitty': 'shell',
|
||||
'co.zeit.hyper': 'shell',
|
||||
/** Finder in the allowlist ≈ browse + open-any-file. */
|
||||
const FILESYSTEM_ACCESS_BUNDLE_IDS = new Set(["com.apple.finder"]);
|
||||
|
||||
// Filesystem
|
||||
'com.apple.finder': 'filesystem',
|
||||
const SYSTEM_SETTINGS_BUNDLE_IDS = new Set(["com.apple.systempreferences"]);
|
||||
|
||||
// System Settings
|
||||
'com.apple.systempreferences': 'system_settings',
|
||||
'com.apple.SystemPreferences': 'system_settings',
|
||||
}
|
||||
export const SENTINEL_BUNDLE_IDS: ReadonlySet<string> = new Set([
|
||||
...SHELL_ACCESS_BUNDLE_IDS,
|
||||
...FILESYSTEM_ACCESS_BUNDLE_IDS,
|
||||
...SYSTEM_SETTINGS_BUNDLE_IDS,
|
||||
]);
|
||||
|
||||
export const sentinelApps: string[] = Object.keys(SENTINEL_MAP)
|
||||
export type SentinelCategory = "shell" | "filesystem" | "system_settings";
|
||||
|
||||
export function getSentinelCategory(bundleId: string): SentinelCategory | null {
|
||||
return SENTINEL_MAP[bundleId] ?? 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;
|
||||
}
|
||||
|
||||
19
packages/@ant/computer-use-mcp/src/subGates.ts
Normal file
19
packages/@ant/computer-use-mcp/src/subGates.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { CuSubGates } from './types.js'
|
||||
|
||||
export const ALL_SUB_GATES_ON: CuSubGates = {
|
||||
pixelValidation: true,
|
||||
clipboardPasteMultiline: true,
|
||||
mouseAnimation: true,
|
||||
hideBeforeAction: true,
|
||||
autoTargetDisplay: true,
|
||||
clipboardGuard: true,
|
||||
}
|
||||
|
||||
export const ALL_SUB_GATES_OFF: CuSubGates = {
|
||||
pixelValidation: false,
|
||||
clipboardPasteMultiline: false,
|
||||
mouseAnimation: false,
|
||||
hideBeforeAction: false,
|
||||
autoTargetDisplay: false,
|
||||
clipboardGuard: false,
|
||||
}
|
||||
4208
packages/@ant/computer-use-mcp/src/toolCalls.ts
Normal file
4208
packages/@ant/computer-use-mcp/src/toolCalls.ts
Normal file
File diff suppressed because it is too large
Load Diff
1053
packages/@ant/computer-use-mcp/src/tools.ts
Normal file
1053
packages/@ant/computer-use-mcp/src/tools.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,70 +1,622 @@
|
||||
/**
|
||||
* @ant/computer-use-mcp — Types
|
||||
*
|
||||
* 从调用侧反推的真实类型定义,替代 any stub。
|
||||
*/
|
||||
import type {
|
||||
ComputerExecutor,
|
||||
InstalledApp,
|
||||
ScreenshotResult,
|
||||
} from "./executor.js";
|
||||
|
||||
export type CoordinateMode = 'pixels' | 'normalized'
|
||||
|
||||
export interface CuSubGates {
|
||||
pixelValidation: boolean
|
||||
clipboardPasteMultiline: boolean
|
||||
mouseAnimation: boolean
|
||||
hideBeforeAction: boolean
|
||||
autoTargetDisplay: boolean
|
||||
clipboardGuard: boolean
|
||||
}
|
||||
/** `ScreenshotResult` without the base64 blob. The shape hosts persist for
|
||||
* cross-respawn `scaleCoord` survival. */
|
||||
export type ScreenshotDims = Omit<ScreenshotResult, "base64">;
|
||||
|
||||
/** Shape mirrors claude-for-chrome-mcp/src/types.ts:1-7 */
|
||||
export interface Logger {
|
||||
silly(message: string, ...args: unknown[]): void
|
||||
debug(message: string, ...args: unknown[]): void
|
||||
info(message: string, ...args: unknown[]): void
|
||||
warn(message: string, ...args: unknown[]): void
|
||||
error(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 interface CuPermissionRequest {
|
||||
apps: Array<{ bundleId: string; displayName: string }>
|
||||
requestedFlags: GrantFlags
|
||||
reason: string
|
||||
tccState: { accessibility: boolean; screenRecording: boolean }
|
||||
willHide: string[]
|
||||
/**
|
||||
* Per-app permission tier. Hardcoded by category at grant time — the
|
||||
* approval dialog displays the tier but the user cannot change it (for now).
|
||||
*
|
||||
* - `"read"` — visible in screenshots, NO interaction (no clicks, no typing).
|
||||
* Browsers land here: the model can read a page that's already open, but
|
||||
* must use the Claude-in-Chrome MCP for any navigation/clicking. Trading
|
||||
* platforms land here too (no CiC alternative — the model asks the user).
|
||||
* - `"click"` — visible + plain left-click, scroll. NO typing/keys,
|
||||
* NO right/middle-click, NO modifier-clicks, NO drag-drop (all text-
|
||||
* injection vectors). Terminals/IDEs land here: the model can click a
|
||||
* Run button or scroll test output, but `type("rm -rf /")` is blocked
|
||||
* and so is right-click→Paste and dragging text onto the terminal.
|
||||
* - `"full"` — visible + click + type/key/paste. Everything else.
|
||||
*
|
||||
* Enforced in `runInputActionGates` via the frontmost-app check: keyboard
|
||||
* actions require `"full"`, mouse actions require `"click"` or higher.
|
||||
*/
|
||||
export type CuAppPermTier = "read" | "click" | "full";
|
||||
|
||||
/**
|
||||
* A single app the user has approved for the current session. Session-scoped
|
||||
* only — there is no "once" or "forever" scope (unlike Chrome's per-domain
|
||||
* three-way). CU has no natural "once" unit; one task = hundreds of clicks.
|
||||
* Mirrors how `chromeAllowedDomains` is a plain `string[]` with no per-item
|
||||
* scope.
|
||||
*/
|
||||
export interface AppGrant {
|
||||
bundleId: string;
|
||||
displayName: string;
|
||||
/** Epoch ms. For Settings-page display ("Granted 3m ago"). */
|
||||
grantedAt: number;
|
||||
/** Undefined → `"full"` (back-compat for pre-tier grants persisted in
|
||||
* session state). */
|
||||
tier?: CuAppPermTier;
|
||||
}
|
||||
|
||||
export interface GrantFlags {
|
||||
clipboardRead: boolean
|
||||
clipboardWrite: boolean
|
||||
systemKeyCombos: boolean
|
||||
/** Orthogonal to the app allowlist. */
|
||||
export interface CuGrantFlags {
|
||||
clipboardRead: boolean;
|
||||
clipboardWrite: boolean;
|
||||
/**
|
||||
* When false, the `key` tool rejects combos in `keyBlocklist.ts`
|
||||
* (cmd+q, cmd+tab, cmd+space, cmd+shift+q, ctrl+alt+delete). All other
|
||||
* key sequences work regardless.
|
||||
*/
|
||||
systemKeyCombos: boolean;
|
||||
}
|
||||
|
||||
export interface CuPermissionResponse {
|
||||
granted: string[]
|
||||
denied: string[]
|
||||
flags: GrantFlags
|
||||
}
|
||||
|
||||
export const DEFAULT_GRANT_FLAGS: GrantFlags = {
|
||||
export const DEFAULT_GRANT_FLAGS: CuGrantFlags = {
|
||||
clipboardRead: false,
|
||||
clipboardWrite: false,
|
||||
systemKeyCombos: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Host picks via GrowthBook JSON feature `chicago_coordinate_mode`, baked
|
||||
* into tool param descriptions at server-construction time. The model sees
|
||||
* ONE convention and never learns the other exists. `normalized_0_100`
|
||||
* sidesteps the Retina scaleFactor bug class entirely.
|
||||
*/
|
||||
export type CoordinateMode = "pixels" | "normalized_0_100";
|
||||
|
||||
/**
|
||||
* Independent kill switches for subtle/risky ported behaviors. Read from
|
||||
* GrowthBook by the host adapter, consulted in `toolCalls.ts`.
|
||||
*/
|
||||
export interface CuSubGates {
|
||||
/** 9×9 exact-byte staleness guard before click. */
|
||||
pixelValidation: boolean;
|
||||
/** Route `type("foo\nbar")` through clipboard instead of keystroke-by-keystroke. */
|
||||
clipboardPasteMultiline: boolean;
|
||||
/**
|
||||
* Ease-out-cubic mouse glide at 60fps, distance-proportional duration
|
||||
* (2000 px/sec, capped at 0.5s). Adds up to ~0.5s latency
|
||||
* per click. When off, cursor teleports instantly.
|
||||
*/
|
||||
mouseAnimation: boolean;
|
||||
/**
|
||||
* Pre-action sequence: hide non-allowlisted apps, then defocus us (from the
|
||||
* Vercept acquisition). When off, the
|
||||
* frontmost gate fires in the normal case and the model gets stuck — this
|
||||
* is the A/B-test-the-old-broken-behavior switch.
|
||||
*/
|
||||
hideBeforeAction: boolean;
|
||||
/**
|
||||
* Auto-resolve the target display before each screenshot when the
|
||||
* selected display has no allowed-app windows. When on, `handleScreenshot`
|
||||
* uses the atomic Swift path; off → sticks with `selectedDisplayId`.
|
||||
*/
|
||||
autoTargetDisplay: boolean;
|
||||
/**
|
||||
* Stash+clear the clipboard while a tier-"click" app is frontmost.
|
||||
* Closes the gap where a click-tier terminal/IDE has a UI Paste button
|
||||
* that's plain-left-clickable — without this, the tier "click"
|
||||
* keyboard block can be routed around by clicking Paste. Restored when
|
||||
* a non-"click" app becomes frontmost, or at turn end.
|
||||
*/
|
||||
clipboardGuard: boolean;
|
||||
}
|
||||
|
||||
export interface ComputerUseConfig {
|
||||
coordinateMode: CoordinateMode
|
||||
enabledTools: string[]
|
||||
// ----------------------------------------------------------------------------
|
||||
// Permission request/response (mirror of BridgePermissionRequest, types.ts:77-94)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** One entry per app the model asked for, after name → bundle ID resolution. */
|
||||
export interface ResolvedAppRequest {
|
||||
/** What the model asked for (e.g. "Slack", "com.tinyspeck.slackmacgap"). */
|
||||
requestedName: string;
|
||||
/** The resolved InstalledApp if found, else undefined (shown greyed in the UI). */
|
||||
resolved?: InstalledApp;
|
||||
/** Shell-access-equivalent bundle IDs get a UI warning. See sentinelApps.ts. */
|
||||
isSentinel: boolean;
|
||||
/** Already in the allowlist → skip the checkbox, return in `granted` immediately. */
|
||||
alreadyGranted: boolean;
|
||||
/** Hardcoded tier for this app (browser→"read", terminal→"click", else "full").
|
||||
* The dialog displays this read-only; the renderer passes it through
|
||||
* verbatim in the AppGrant. */
|
||||
proposedTier: CuAppPermTier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for the renderer approval dialog. Rides through the existing
|
||||
* `ToolPermissionRequest.input: unknown` field
|
||||
* (packages/utils/desktop/bridge/common/claude.web.ts:1262) — no IPC schema
|
||||
* change needed.
|
||||
*/
|
||||
export interface CuPermissionRequest {
|
||||
requestId: string;
|
||||
/** Model-provided reason string. Shown prominently in the approval UI. */
|
||||
reason: string;
|
||||
apps: ResolvedAppRequest[];
|
||||
/** What the model asked for. User can toggle independently of apps. */
|
||||
requestedFlags: Partial<CuGrantFlags>;
|
||||
/**
|
||||
* For the "On Windows, Claude can see all apps..." footnote. Taken from
|
||||
* `executor.capabilities.screenshotFiltering` so the renderer doesn't
|
||||
* need to know about platforms.
|
||||
*/
|
||||
screenshotFiltering: "native" | "none";
|
||||
/**
|
||||
* Present only when TCC permissions are NOT yet granted. When present,
|
||||
* the renderer shows a TCC toggle panel (two rows: Accessibility, Screen
|
||||
* Recording) INSTEAD OF the app list. Clicking a row's "Request" button
|
||||
* triggers the OS prompt; the store polls on window-focus and flips the
|
||||
* toggle when the grant is detected. macOS itself prompts the user to
|
||||
* restart after granting Screen Recording — we don't.
|
||||
*/
|
||||
tccState?: {
|
||||
accessibility: boolean;
|
||||
screenRecording: boolean;
|
||||
};
|
||||
/**
|
||||
* Apps with windows on the CU display that aren't in the requested
|
||||
* allowlist. These will be hidden the first time Claude takes an action.
|
||||
* Computed at request_access time — may be slightly stale by the time the
|
||||
* user clicks Allow, but it's a preview, not a contract. Absent when
|
||||
* empty so the renderer can skip the section cleanly.
|
||||
*/
|
||||
willHide?: Array<{ bundleId: string; displayName: string }>;
|
||||
/**
|
||||
* `chicagoAutoUnhide` app preference at request time. The renderer picks
|
||||
* between "...then restored when Claude is done" and "...will be hidden"
|
||||
* copy. Absent when `willHide` is absent (same condition).
|
||||
*/
|
||||
autoUnhideEnabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the renderer stuffs into `updatedInput._cuGrants` when the user clicks
|
||||
* "Allow for this session" (mirror of the `_allowAllSites` sentinel at
|
||||
* LocalAgentModeSessionManager.ts:2794).
|
||||
*/
|
||||
export interface CuPermissionResponse {
|
||||
granted: AppGrant[];
|
||||
/** Bundle IDs the user unchecked, or apps that weren't installed. */
|
||||
denied: Array<{ bundleId: string; reason: "user_denied" | "not_installed" }>;
|
||||
flags: CuGrantFlags;
|
||||
/**
|
||||
* Whether the user clicked Allow in THIS dialog. Only set by the
|
||||
* teach-mode handler — regular request_access doesn't need it (the
|
||||
* session manager's `result.behavior` gates the merge there). Needed
|
||||
* because when all requested apps are already granted (skipDialogGrants
|
||||
* non-empty, needDialog empty), Allow and Deny produce identical
|
||||
* `{granted:[], denied:[]}` payloads and the tool handler can't tell
|
||||
* them apart without this. Undefined → legacy/regular path, do not
|
||||
* gate on it.
|
||||
*/
|
||||
userConsented?: boolean;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Host adapter (mirror of ClaudeForChromeContext, types.ts:33-62)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Process-lifetime singleton dependencies. Everything that does NOT vary per
|
||||
* tool call. Built once by `apps/desktop/src/main/nest-only/chicago/hostAdapter.ts`.
|
||||
* No Electron imports in this package — the host injects everything.
|
||||
*/
|
||||
export interface ComputerUseHostAdapter {
|
||||
serverName: string
|
||||
logger: Logger
|
||||
executor: ComputerExecutor
|
||||
ensureOsPermissions(): Promise<{ granted: true } | { granted: false; accessibility: boolean; screenRecording: boolean }>
|
||||
isDisabled(): boolean
|
||||
getSubGates(): CuSubGates
|
||||
getAutoUnhideEnabled(): boolean
|
||||
cropRawPatch?(base64: string, x: number, y: number, w: number, h: number): Promise<string>
|
||||
serverName: string;
|
||||
logger: Logger;
|
||||
executor: ComputerExecutor;
|
||||
|
||||
/**
|
||||
* TCC state check — Accessibility + Screen Recording on macOS. Pure check,
|
||||
* no dialog, no relaunch. When either is missing, `request_access` threads
|
||||
* the state through to the renderer which shows a toggle panel; all other
|
||||
* tools return a tool error.
|
||||
*/
|
||||
ensureOsPermissions(): Promise<
|
||||
| { granted: true }
|
||||
| { granted: false; accessibility: boolean; screenRecording: boolean }
|
||||
>;
|
||||
|
||||
/** The Settings-page kill switch (`chicagoEnabled` app preference). */
|
||||
isDisabled(): boolean;
|
||||
|
||||
/**
|
||||
* The `chicagoAutoUnhide` app preference. Consumed by `buildAccessRequest`
|
||||
* to populate `CuPermissionRequest.autoUnhideEnabled` so the renderer's
|
||||
* "will be hidden" copy can say "then restored" only when true.
|
||||
*/
|
||||
getAutoUnhideEnabled(): boolean;
|
||||
|
||||
/**
|
||||
* Sub-gates re-read on every tool call so GrowthBook flips take effect
|
||||
* mid-session without restart.
|
||||
*/
|
||||
getSubGates(): CuSubGates;
|
||||
|
||||
/**
|
||||
* JPEG decode + crop + raw pixel bytes, for the PixelCompare staleness guard.
|
||||
* Injected so this package stays Electron-free. The host implements it via
|
||||
* `nativeImage.createFromBuffer(jpeg).crop(rect).toBitmap()` — Chromium's
|
||||
* decoders, BSD-licensed, no `.node` binary.
|
||||
*
|
||||
* Returns null on decode/crop failure — caller treats null as `skipped`,
|
||||
* click proceeds (validation failure must never block the action).
|
||||
*/
|
||||
cropRawPatch(
|
||||
jpegBase64: string,
|
||||
rect: { x: number; y: number; width: number; height: number },
|
||||
): Buffer | null;
|
||||
}
|
||||
|
||||
export interface ComputerExecutor {
|
||||
capabilities: Record<string, boolean>
|
||||
// ----------------------------------------------------------------------------
|
||||
// Session context (getter/callback bag for bindSessionContext)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Per-session state binding for `bindSessionContext`. Hosts build this once
|
||||
* per session with getters that read fresh from their session store and
|
||||
* callbacks that write back. The returned dispatcher builds
|
||||
* `ComputerUseOverrides` from these getters on every call.
|
||||
*
|
||||
* Callbacks must be set at construction time — `bindSessionContext` reads
|
||||
* them once at bind, not per call.
|
||||
*
|
||||
* The lock hooks are **async** — `bindSessionContext` awaits them before
|
||||
* `handleToolCall`, then passes `checkCuLock: undefined` in overrides so the
|
||||
* sync Gate-3 in `handleToolCall` no-ops. Hosts with in-memory sync locks
|
||||
* (Cowork) wrap them trivially; hosts with cross-process locks (the CLI's
|
||||
* O_EXCL file) call the real async primitive directly.
|
||||
*/
|
||||
export interface ComputerUseSessionContext {
|
||||
// ── Read state fresh per call ──────────────────────────────────────
|
||||
|
||||
getAllowedApps(): readonly AppGrant[];
|
||||
getGrantFlags(): CuGrantFlags;
|
||||
/** Per-user auto-deny list (Settings page). Empty array = none. */
|
||||
getUserDeniedBundleIds(): readonly string[];
|
||||
getSelectedDisplayId(): number | undefined;
|
||||
getDisplayPinnedByModel?(): boolean;
|
||||
getDisplayResolvedForApps?(): string | undefined;
|
||||
getTeachModeActive?(): boolean;
|
||||
/** Dims-only fallback when `lastScreenshot` is unset (cross-respawn).
|
||||
* `bindSessionContext` reconstructs `{...dims, base64: ""}` so scaleCoord
|
||||
* works and pixelCompare correctly skips. */
|
||||
getLastScreenshotDims?(): ScreenshotDims | undefined;
|
||||
|
||||
// ── Write-back callbacks ───────────────────────────────────────────
|
||||
|
||||
/** Shows the approval dialog. Host routes to its UI, awaits user. The
|
||||
* signal is aborted if the tool call finishes before the user answers
|
||||
* (MCP timeout, etc.) — hosts dismiss the dialog on abort. */
|
||||
onPermissionRequest?(
|
||||
req: CuPermissionRequest,
|
||||
signal: AbortSignal,
|
||||
): Promise<CuPermissionResponse>;
|
||||
/** Teach-mode sibling of `onPermissionRequest`. */
|
||||
onTeachPermissionRequest?(
|
||||
req: CuTeachPermissionRequest,
|
||||
signal: AbortSignal,
|
||||
): Promise<CuPermissionResponse>;
|
||||
/** Called by `bindSessionContext` after merging a permission response into
|
||||
* the allowlist (dedupe on bundleId, truthy-only flag spread). Host
|
||||
* persists for resume survival. */
|
||||
onAllowedAppsChanged?(apps: readonly AppGrant[], flags: CuGrantFlags): void;
|
||||
onAppsHidden?(bundleIds: string[]): void;
|
||||
/** Reads the session's clipboardGuard stash. undefined → no stash held. */
|
||||
getClipboardStash?(): string | undefined;
|
||||
/** Writes the clipboardGuard stash. undefined clears it. */
|
||||
onClipboardStashChanged?(stash: string | undefined): void;
|
||||
onResolvedDisplayUpdated?(displayId: number): void;
|
||||
onDisplayPinned?(displayId: number | undefined): void;
|
||||
onDisplayResolvedForApps?(sortedBundleIdsKey: string): void;
|
||||
/** Called after each screenshot. Host persists for respawn survival. */
|
||||
onScreenshotCaptured?(dims: ScreenshotDims): void;
|
||||
onTeachModeActivated?(): void;
|
||||
onTeachStep?(req: TeachStepRequest): Promise<TeachStepResult>;
|
||||
onTeachWorking?(): void;
|
||||
|
||||
// ── Lock (async) ───────────────────────────────────────────────────
|
||||
|
||||
/** At most one session uses CU at a time. Awaited by `bindSessionContext`
|
||||
* before dispatch. Undefined → no lock gating (proceed). */
|
||||
checkCuLock?(): Promise<{ holder: string | undefined; isSelf: boolean }>;
|
||||
/** Take the lock. Called when `checkCuLock` returned `holder: undefined`
|
||||
* on a non-deferring tool. Host emits enter-CU signals here. */
|
||||
acquireCuLock?(): Promise<void>;
|
||||
/** Host-specific lock-held error text. Default is the package's generic
|
||||
* message. The CLI host includes the holder session-ID prefix. */
|
||||
formatLockHeldMessage?(holder: string): string;
|
||||
|
||||
/** User-abort signal. Passed through to `ComputerUseOverrides.isAborted`
|
||||
* for the mid-loop checks in handleComputerBatch / handleType. See that
|
||||
* field for semantics. */
|
||||
isAborted?(): boolean;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Per-call overrides (mirror of PermissionOverrides, types.ts:97-102)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Built FRESH on every tool call by `bindSessionContext` from
|
||||
* `ComputerUseSessionContext` getters. This is what lets a singleton MCP
|
||||
* server carry per-session state — the state lives on the host's session
|
||||
* store, not the server.
|
||||
*/
|
||||
export interface ComputerUseOverrides {
|
||||
allowedApps: AppGrant[];
|
||||
grantFlags: CuGrantFlags;
|
||||
coordinateMode: CoordinateMode;
|
||||
|
||||
/**
|
||||
* User-configured auto-deny list (Settings → Desktop app → Computer Use).
|
||||
* Bundle IDs
|
||||
* here are stripped from request_access BEFORE the approval dialog — they
|
||||
* never reach the user for approval regardless of tier. The response tells
|
||||
* the agent to ask the user to remove the app from their deny list in
|
||||
* Settings if access is genuinely needed.
|
||||
*
|
||||
* Per-USER, persists across restarts (read from appPreferences per call,
|
||||
* not session state). Contrast with `allowedApps` which is per-session.
|
||||
* Empty array = no user-configured denies (the default).
|
||||
*/
|
||||
userDeniedBundleIds: readonly string[];
|
||||
|
||||
/**
|
||||
* Display CU operates on; read fresh per call. `scaleCoord` uses the
|
||||
* `originX/Y` snapshotted in `lastScreenshot`, so mid-session switches
|
||||
* only affect the NEXT screenshot/prepare call.
|
||||
*/
|
||||
selectedDisplayId?: number;
|
||||
|
||||
/**
|
||||
* The `request_access` tool handler calls this and awaits. The wrapper
|
||||
* closure in serverDef.ts (mirroring InternalMcpServerManager.ts:131-177)
|
||||
* routes through `handleToolPermission` → IPC → renderer ChicagoApproval.
|
||||
* When it resolves, the wrapper side-effectfully mutates
|
||||
* `InternalServerContext.cuAllowedApps` BEFORE returning here.
|
||||
*
|
||||
* Undefined when the session wasn't wired with a permission handler (e.g.
|
||||
* a future headless mode). `request_access` returns a tool error in that case.
|
||||
*/
|
||||
onPermissionRequest?: (req: CuPermissionRequest) => Promise<CuPermissionResponse>;
|
||||
|
||||
/**
|
||||
* For the pixel-validation staleness guard. The model's-last-screenshot,
|
||||
* stashed by serverDef.ts after each `screenshot` tool call. Undefined on
|
||||
* cold start → pixel validation skipped (click proceeds).
|
||||
*/
|
||||
lastScreenshot?: ScreenshotResult;
|
||||
|
||||
/**
|
||||
* Fired after every `prepareForAction` with the bundle IDs it just hid.
|
||||
* The wrapper closure in serverDef.ts accumulates these into
|
||||
* `Session.cuHiddenDuringTurn` via a write-through callback (same pattern
|
||||
* as `onCuPermissionUpdated`). At turn end (`sdkMessage.type === "result"`),
|
||||
* if the `chicagoAutoUnhide` setting is on, everything in the set is
|
||||
* unhidden. Set is cleared regardless of the setting so it doesn't leak
|
||||
* across turns.
|
||||
*
|
||||
* Undefined when the session wasn't wired with a tracker — unhide just
|
||||
* doesn't happen.
|
||||
*/
|
||||
onAppsHidden?: (bundleIds: string[]) => void;
|
||||
|
||||
/**
|
||||
* Reads the clipboardGuard stash from session state. `undefined` means no
|
||||
* stash is held — `syncClipboardStash` stashes on first entry to click-tier
|
||||
* and clears on restore. Sibling of the `cuHiddenDuringTurn` getter pattern
|
||||
* — state lives on the host's session, not module-level here.
|
||||
*/
|
||||
getClipboardStash?: () => string | undefined;
|
||||
|
||||
/**
|
||||
* Writes the clipboardGuard stash to session state. `undefined` clears.
|
||||
* Sibling of `onAppsHidden` — the wrapper closure writes through to
|
||||
* `Session.cuClipboardStash`. At turn end the host reads + clears it
|
||||
* directly and restores via Electron's `clipboard.writeText` (no nest-only
|
||||
* import surface).
|
||||
*/
|
||||
onClipboardStashChanged?: (stash: string | undefined) => void;
|
||||
|
||||
/**
|
||||
* Write the resolver's picked display back to session so teach overlay
|
||||
* positioning and subsequent non-resolver calls use the same display.
|
||||
* Fired by `handleScreenshot` in the atomic `autoTargetDisplay` path when
|
||||
* `resolvePrepareCapture`'s pick differs from `selectedDisplayId`.
|
||||
* Fire-and-forget.
|
||||
*/
|
||||
onResolvedDisplayUpdated?: (displayId: number) => void;
|
||||
|
||||
/**
|
||||
* Set when the model explicitly picked a display via `switch_display`.
|
||||
* When true, `handleScreenshot` passes `autoResolve: false` so the Swift
|
||||
* resolver honors `selectedDisplayId` directly (straight cuDisplayInfo
|
||||
* passthrough) instead of running the co-location/chase chain. The
|
||||
* resolver's Step 2 ("host + allowed co-located → host") otherwise
|
||||
* overrides any `selectedDisplayId` whenever an allowed app shares the
|
||||
* host's monitor.
|
||||
*/
|
||||
displayPinnedByModel?: boolean;
|
||||
|
||||
/**
|
||||
* Write the model's explicit display pick to session. `displayId:
|
||||
* undefined` clears both `selectedDisplayId` and the pin (back to auto).
|
||||
* Sibling of `onResolvedDisplayUpdated` but also sets the pin flag —
|
||||
* the two are semantically distinct (resolver-picked vs model-picked).
|
||||
*/
|
||||
onDisplayPinned?: (displayId: number | undefined) => void;
|
||||
|
||||
/**
|
||||
* Sorted comma-joined bundle-ID set the display was last auto-resolved
|
||||
* for. `handleScreenshot` compares this to the current allowed set and
|
||||
* only passes `autoResolve: true` when they differ — so the resolver
|
||||
* doesn't yank the display on every screenshot, only when the app set
|
||||
* has changed since the last resolve (or manual switch).
|
||||
*/
|
||||
displayResolvedForApps?: string;
|
||||
|
||||
/**
|
||||
* Records which app set the current display selection was made for. Fired
|
||||
* alongside `onResolvedDisplayUpdated` when the resolver picks, so the next
|
||||
* screenshot sees a matching set and skips auto-resolve.
|
||||
*/
|
||||
onDisplayResolvedForApps?: (sortedBundleIdsKey: string) => void;
|
||||
|
||||
/**
|
||||
* Global CU lock — at most one session actively uses CU at a time. Checked
|
||||
* in `handleToolCall` after kill-switch/TCC, before dispatch. Every CU tool
|
||||
* including `request_access` goes through it.
|
||||
*
|
||||
* - `holder === undefined` → lock is free, safe to acquire
|
||||
* - `isSelf === true` → this session already holds it (no-op, proceed)
|
||||
* - `holder !== undefined && !isSelf` → blocked, return tool error
|
||||
*
|
||||
* `undefined` callback → lock system not wired (e.g. CCD). Proceed without
|
||||
* gating — absence of the mechanism ≠ locked out.
|
||||
*
|
||||
* The host manages release (on session idle/stop/archive) — this package
|
||||
* never releases.
|
||||
*/
|
||||
checkCuLock?: () => { holder: string | undefined; isSelf: boolean };
|
||||
|
||||
/**
|
||||
* Take the lock for this session. `handleToolCall` calls this exactly once
|
||||
* per turn, on the FIRST CU tool call when `checkCuLock().holder` is
|
||||
* undefined. No-op if already held (defensive — the check should have
|
||||
* short-circuited). Host emits an event the overlay listens to.
|
||||
*/
|
||||
acquireCuLock?: () => void;
|
||||
|
||||
/**
|
||||
* User-abort signal. Checked mid-iteration inside `handleComputerBatch`
|
||||
* and `handleType`'s grapheme loop so an in-flight batch/type stops
|
||||
* promptly on overlay Stop instead of running to completion after the
|
||||
* host has already abandoned the tool result.
|
||||
*
|
||||
* Undefined → never aborts (e.g. unwired host). Live per-check read —
|
||||
* same lazy-getter pattern as `checkCuLock`.
|
||||
*/
|
||||
isAborted?: () => boolean;
|
||||
|
||||
// ── Teach mode ───────────────────────────────────────────────────────
|
||||
// Wired only when the host's teachModeEnabled gate is on. All five
|
||||
// undefined → `request_teach_access` / `teach_step` return tool errors
|
||||
// and teach mode is effectively off.
|
||||
|
||||
/**
|
||||
* Sibling of `onPermissionRequest`. Same blocking-await-on-renderer-dialog
|
||||
* semantics, but routes to ComputerUseTeachApproval.tsx (which explains
|
||||
* the window-hides-during-guide behavior) instead of ComputerUseApproval.
|
||||
* The wrapper closure in serverDef.ts writes grants through to session state
|
||||
* via `onCuPermissionUpdated` exactly as `onPermissionRequest` does.
|
||||
*/
|
||||
onTeachPermissionRequest?: (
|
||||
req: CuTeachPermissionRequest,
|
||||
) => Promise<CuPermissionResponse>;
|
||||
|
||||
/**
|
||||
* Called by `handleRequestTeachAccess` after the user approves and at least
|
||||
* one app was granted. Host sets `session.teachModeActive = true`, emits
|
||||
* `teachModeChanged` → teach controller hides the main window and shows the
|
||||
* fullscreen overlay. Cleared by the host on turn end (`transitionTo("idle")`)
|
||||
* alongside the CU lock release.
|
||||
*/
|
||||
onTeachModeActivated?: () => void;
|
||||
|
||||
/**
|
||||
* Read by `handleRequestAccess` and `handleRequestTeachAccess` to
|
||||
* short-circuit with a clear tool error when teach mode is active. The
|
||||
* main window is hidden during teach mode, so permission dialogs render
|
||||
* invisibly and handleToolPermission blocks forever on an invisible
|
||||
* prompt. Better to tell the model to exit teach mode first. Getter
|
||||
* (not a boolean field) because teach mode state lives on the session,
|
||||
* not on this per-call overrides object.
|
||||
*/
|
||||
getTeachModeActive?: () => boolean;
|
||||
|
||||
/**
|
||||
* Called by `handleTeachStep` with the scaled anchor + text. Host stores
|
||||
* the resolver, emits `teachStepRequested` → teach controller pushes the
|
||||
* payload to the overlay → user reads, clicks Next → IPC → host calls the
|
||||
* stored resolver → this promise resolves. `{action: "exit"}` when the user
|
||||
* clicks Exit (or the turn is interrupted) — `handleTeachStep` short-circuits
|
||||
* without executing actions.
|
||||
*
|
||||
* Same blocking-promise pattern as `onPermissionRequest`, but resolved by
|
||||
* the teach overlay's own preload (not the main renderer's tool-approval UI).
|
||||
*/
|
||||
onTeachStep?: (req: TeachStepRequest) => Promise<TeachStepResult>;
|
||||
|
||||
/**
|
||||
* Called immediately after `onTeachStep` resolves with "next", before
|
||||
* action dispatch begins. Host emits `teachStepWorking` → overlay flips to
|
||||
* the spinner state (Next button gone, Exit stays, "Working…" + rotating
|
||||
* notch). The next `onTeachStep` call replaces the spinner with the new
|
||||
* tooltip content.
|
||||
*/
|
||||
onTeachWorking?: () => void;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Teach mode (guided-tour tooltips with Next-button action execution)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Payload the host pushes to the teach overlay BrowserWindow. Built by
|
||||
* `handleTeachStep` in toolCalls.ts from the model's `teach_step` args.
|
||||
*
|
||||
* `anchorLogical` here is POST-`scaleCoord` — **full-display** logical
|
||||
* macOS points (origin = monitor top-left, menu bar included, since
|
||||
* cuDisplayInfo returns CGDisplayBounds). The overlay window is positioned
|
||||
* at `workArea.{x,y}` (excludes menu bar/Dock), so `updateTeachStep` in
|
||||
* teach/window.ts subtracts the workArea offset before IPC so the HTML's
|
||||
* CSS coords match.
|
||||
*/
|
||||
export interface TeachStepRequest {
|
||||
explanation: string;
|
||||
nextPreview: string;
|
||||
/** Full-display logical points. Undefined → overlay centers the tooltip, hides the arrow. */
|
||||
anchorLogical?: { x: number; y: number };
|
||||
}
|
||||
|
||||
export type TeachStepResult = { action: "next" } | { action: "exit" };
|
||||
|
||||
/**
|
||||
* Payload for the renderer's ComputerUseTeachApproval dialog. Rides through
|
||||
* `ToolPermissionRequest.input: unknown` same as `CuPermissionRequest`.
|
||||
* Separate type (not a flag on `CuPermissionRequest`) so the two approval
|
||||
* components can narrow independently and the teach dialog is free to drop
|
||||
* fields it doesn't render (no grant-flag checkboxes in teach mode).
|
||||
*/
|
||||
export interface CuTeachPermissionRequest {
|
||||
requestId: string;
|
||||
/** Model-provided reason. Shown in the dialog headline ("guide you through {reason}"). */
|
||||
reason: string;
|
||||
apps: ResolvedAppRequest[];
|
||||
screenshotFiltering: "native" | "none";
|
||||
/** Present only when TCC is ungranted — same semantics as `CuPermissionRequest.tccState`. */
|
||||
tccState?: {
|
||||
accessibility: boolean;
|
||||
screenRecording: boolean;
|
||||
};
|
||||
willHide?: Array<{ bundleId: string; displayName: string }>;
|
||||
/** Same semantics as `CuPermissionRequest.autoUnhideEnabled`. */
|
||||
autoUnhideEnabled?: boolean;
|
||||
}
|
||||
|
||||
266
packages/@ant/computer-use-swift/src/backends/darwin.ts
Normal file
266
packages/@ant/computer-use-swift/src/backends/darwin.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* macOS backend for computer-use-swift
|
||||
*
|
||||
* Uses AppleScript/JXA/screencapture for display info, app management,
|
||||
* and screenshots.
|
||||
*/
|
||||
|
||||
import { readFileSync, unlinkSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import type {
|
||||
AppInfo, AppsAPI, DisplayAPI, DisplayGeometry, InstalledApp,
|
||||
PrepareDisplayResult, RunningApp, ScreenshotAPI, ScreenshotResult,
|
||||
SwiftBackend, WindowDisplayInfo,
|
||||
} from '../types.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function jxaSync(script: string): string {
|
||||
const result = Bun.spawnSync({
|
||||
cmd: ['osascript', '-l', 'JavaScript', '-e', script],
|
||||
stdout: 'pipe', stderr: 'pipe',
|
||||
})
|
||||
return new TextDecoder().decode(result.stdout).trim()
|
||||
}
|
||||
|
||||
function osascriptSync(script: string): string {
|
||||
const result = Bun.spawnSync({
|
||||
cmd: ['osascript', '-e', script],
|
||||
stdout: 'pipe', stderr: 'pipe',
|
||||
})
|
||||
return new TextDecoder().decode(result.stdout).trim()
|
||||
}
|
||||
|
||||
async function osascript(script: string): Promise<string> {
|
||||
const proc = Bun.spawn(['osascript', '-e', script], {
|
||||
stdout: 'pipe', stderr: 'pipe',
|
||||
})
|
||||
const text = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
return text.trim()
|
||||
}
|
||||
|
||||
async function jxa(script: string): Promise<string> {
|
||||
const proc = Bun.spawn(['osascript', '-l', 'JavaScript', '-e', script], {
|
||||
stdout: 'pipe', stderr: 'pipe',
|
||||
})
|
||||
const text = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
return text.trim()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DisplayAPI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const display: DisplayAPI = {
|
||||
getSize(displayId?: number): DisplayGeometry {
|
||||
const all = this.listAll()
|
||||
if (displayId !== undefined) {
|
||||
const found = all.find(d => d.displayId === displayId)
|
||||
if (found) return found
|
||||
}
|
||||
return all[0] ?? { width: 1920, height: 1080, scaleFactor: 2, displayId: 1 }
|
||||
},
|
||||
|
||||
listAll(): DisplayGeometry[] {
|
||||
try {
|
||||
const raw = jxaSync(`
|
||||
ObjC.import("CoreGraphics");
|
||||
var displays = $.CGDisplayCopyAllDisplayModes ? [] : [];
|
||||
var active = $.CGGetActiveDisplayList(10, null, Ref());
|
||||
var countRef = Ref();
|
||||
$.CGGetActiveDisplayList(0, null, countRef);
|
||||
var count = countRef[0];
|
||||
var idBuf = Ref();
|
||||
$.CGGetActiveDisplayList(count, idBuf, countRef);
|
||||
var result = [];
|
||||
for (var i = 0; i < count; i++) {
|
||||
var did = idBuf[i];
|
||||
var w = $.CGDisplayPixelsWide(did);
|
||||
var h = $.CGDisplayPixelsHigh(did);
|
||||
var mode = $.CGDisplayCopyDisplayMode(did);
|
||||
var pw = $.CGDisplayModeGetPixelWidth(mode);
|
||||
var sf = pw > 0 && w > 0 ? pw / w : 2;
|
||||
result.push({width: w, height: h, scaleFactor: sf, displayId: did});
|
||||
}
|
||||
JSON.stringify(result);
|
||||
`)
|
||||
return (JSON.parse(raw) as DisplayGeometry[]).map(d => ({
|
||||
width: Number(d.width), height: Number(d.height),
|
||||
scaleFactor: Number(d.scaleFactor), displayId: Number(d.displayId),
|
||||
}))
|
||||
} catch {
|
||||
try {
|
||||
const raw = jxaSync(`
|
||||
ObjC.import("AppKit");
|
||||
var screens = $.NSScreen.screens;
|
||||
var result = [];
|
||||
for (var i = 0; i < screens.count; i++) {
|
||||
var s = screens.objectAtIndex(i);
|
||||
var frame = s.frame;
|
||||
var desc = s.deviceDescription;
|
||||
var screenNumber = desc.objectForKey($("NSScreenNumber")).intValue;
|
||||
var backingFactor = s.backingScaleFactor;
|
||||
result.push({
|
||||
width: Math.round(frame.size.width),
|
||||
height: Math.round(frame.size.height),
|
||||
scaleFactor: backingFactor,
|
||||
displayId: screenNumber
|
||||
});
|
||||
}
|
||||
JSON.stringify(result);
|
||||
`)
|
||||
return (JSON.parse(raw) as DisplayGeometry[]).map(d => ({
|
||||
width: Number(d.width), height: Number(d.height),
|
||||
scaleFactor: Number(d.scaleFactor), displayId: Number(d.displayId),
|
||||
}))
|
||||
} catch {
|
||||
return [{ width: 1920, height: 1080, scaleFactor: 2, displayId: 1 }]
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AppsAPI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const apps: AppsAPI = {
|
||||
async prepareDisplay(_allowlistBundleIds, _surrogateHost, _displayId) {
|
||||
return { activated: '', hidden: [] }
|
||||
},
|
||||
|
||||
async previewHideSet(_bundleIds, _displayId) {
|
||||
return []
|
||||
},
|
||||
|
||||
async findWindowDisplays(bundleIds) {
|
||||
return bundleIds.map(bundleId => ({ bundleId, displayIds: [1] }))
|
||||
},
|
||||
|
||||
async appUnderPoint(_x, _y) {
|
||||
try {
|
||||
const result = await jxa(`
|
||||
ObjC.import("CoreGraphics");
|
||||
ObjC.import("AppKit");
|
||||
var pt = $.CGPointMake(${_x}, ${_y});
|
||||
var app = $.NSWorkspace.sharedWorkspace.frontmostApplication;
|
||||
JSON.stringify({bundleId: app.bundleIdentifier.js, displayName: app.localizedName.js});
|
||||
`)
|
||||
return JSON.parse(result)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
async listInstalled() {
|
||||
try {
|
||||
// Use mdls to enumerate apps and get real bundle identifiers.
|
||||
// The previous AppleScript approach generated fake bundle IDs
|
||||
// (com.app.display-name) which prevented request_access from matching
|
||||
// apps by their real bundle ID (e.g. com.google.Chrome).
|
||||
const dirs = ['/Applications', '~/Applications', '/System/Applications']
|
||||
const allApps: InstalledApp[] = []
|
||||
for (const dir of dirs) {
|
||||
const expanded = dir.startsWith('~') ? join(process.env.HOME ?? '~', dir.slice(1)) : dir
|
||||
const proc = Bun.spawn(
|
||||
['bash', '-c', `for f in "${expanded}"/*.app; do [ -d "$f" ] || continue; bid=$(mdls -name kMDItemCFBundleIdentifier "$f" 2>/dev/null | sed 's/.*= "//;s/"//'); name=$(basename "$f" .app); echo "$f|$name|$bid"; done`],
|
||||
{ stdout: 'pipe', stderr: 'pipe' },
|
||||
)
|
||||
const text = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
for (const line of text.split('\n').filter(Boolean)) {
|
||||
const [path, displayName, bundleId] = line.split('|', 3)
|
||||
if (path && displayName && bundleId && bundleId !== '(null)') {
|
||||
allApps.push({ bundleId, displayName, path })
|
||||
}
|
||||
}
|
||||
}
|
||||
// Deduplicate by bundleId (prefer /Applications over ~/Applications)
|
||||
const seen = new Set<string>()
|
||||
return allApps.filter(app => {
|
||||
if (seen.has(app.bundleId)) return false
|
||||
seen.add(app.bundleId)
|
||||
return true
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
},
|
||||
|
||||
iconDataUrl(_path) {
|
||||
return null
|
||||
},
|
||||
|
||||
listRunning() {
|
||||
try {
|
||||
const raw = jxaSync(`
|
||||
var apps = Application("System Events").applicationProcesses.whose({backgroundOnly: false});
|
||||
var result = [];
|
||||
for (var i = 0; i < apps.length; i++) {
|
||||
try {
|
||||
var a = apps[i];
|
||||
result.push({bundleId: a.bundleIdentifier(), displayName: a.name()});
|
||||
} catch(e) {}
|
||||
}
|
||||
JSON.stringify(result);
|
||||
`)
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
},
|
||||
|
||||
async open(bundleId) {
|
||||
await osascript(`tell application id "${bundleId}" to activate`)
|
||||
},
|
||||
|
||||
async unhide(bundleIds) {
|
||||
for (const bundleId of bundleIds) {
|
||||
await osascript(`
|
||||
tell application "System Events"
|
||||
set visible of application process (name of application process whose bundle identifier is "${bundleId}") to true
|
||||
end tell
|
||||
`)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ScreenshotAPI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function captureScreenToBase64(args: string[]): Promise<{ base64: string; width: number; height: number }> {
|
||||
const tmpFile = join(tmpdir(), `cu-screenshot-${Date.now()}.png`)
|
||||
const proc = Bun.spawn(['screencapture', ...args, tmpFile], {
|
||||
stdout: 'pipe', stderr: 'pipe',
|
||||
})
|
||||
await proc.exited
|
||||
try {
|
||||
const buf = readFileSync(tmpFile)
|
||||
const base64 = buf.toString('base64')
|
||||
const width = buf.readUInt32BE(16)
|
||||
const height = buf.readUInt32BE(20)
|
||||
return { base64, width, height }
|
||||
} finally {
|
||||
try { unlinkSync(tmpFile) } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export const screenshot: ScreenshotAPI = {
|
||||
async captureExcluding(_allowedBundleIds, _quality, _targetW, _targetH, displayId) {
|
||||
const args = ['-x']
|
||||
if (displayId !== undefined) args.push('-D', String(displayId))
|
||||
return captureScreenToBase64(args)
|
||||
},
|
||||
|
||||
async captureRegion(_allowedBundleIds, x, y, w, h, _outW, _outH, _quality, displayId) {
|
||||
const args = ['-x', '-R', `${x},${y},${w},${h}`]
|
||||
if (displayId !== undefined) args.push('-D', String(displayId))
|
||||
return captureScreenToBase64(args)
|
||||
},
|
||||
}
|
||||
278
packages/@ant/computer-use-swift/src/backends/linux.ts
Normal file
278
packages/@ant/computer-use-swift/src/backends/linux.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Linux backend for computer-use-swift
|
||||
*
|
||||
* Uses xrandr for display info, scrot for screenshots,
|
||||
* wmctrl/xdotool for window management, and xdg-open for launching apps.
|
||||
*
|
||||
* Requires: xrandr, scrot, xdotool, wmctrl (optional)
|
||||
*/
|
||||
|
||||
import type {
|
||||
AppInfo, AppsAPI, DisplayAPI, DisplayGeometry, InstalledApp,
|
||||
PrepareDisplayResult, RunningApp, ScreenshotAPI, ScreenshotResult,
|
||||
SwiftBackend, WindowDisplayInfo,
|
||||
} from '../types.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shell helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function run(cmd: string[]): string {
|
||||
const result = Bun.spawnSync({
|
||||
cmd,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
return new TextDecoder().decode(result.stdout).trim()
|
||||
}
|
||||
|
||||
async function runAsync(cmd: string[]): Promise<string> {
|
||||
const proc = Bun.spawn(cmd, { stdout: 'pipe', stderr: 'pipe' })
|
||||
const out = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
return out.trim()
|
||||
}
|
||||
|
||||
function commandExists(name: string): boolean {
|
||||
const result = Bun.spawnSync({ cmd: ['which', name], stdout: 'pipe', stderr: 'pipe' })
|
||||
return result.exitCode === 0
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DisplayAPI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const display: DisplayAPI = {
|
||||
getSize(displayId?: number): DisplayGeometry {
|
||||
const all = this.listAll()
|
||||
if (displayId !== undefined) {
|
||||
const found = all.find(d => d.displayId === displayId)
|
||||
if (found) return found
|
||||
}
|
||||
return all[0] ?? { width: 1920, height: 1080, scaleFactor: 1, displayId: 0 }
|
||||
},
|
||||
|
||||
listAll(): DisplayGeometry[] {
|
||||
try {
|
||||
const raw = run(['xrandr', '--query'])
|
||||
const displays: DisplayGeometry[] = []
|
||||
let idx = 0
|
||||
|
||||
// Match lines like: "HDMI-1 connected 1920x1080+0+0" or "eDP-1 connected primary 2560x1440+0+0"
|
||||
const regex = /^\S+\s+connected\s+(?:primary\s+)?(\d+)x(\d+)\+\d+\+\d+/gm
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = regex.exec(raw)) !== null) {
|
||||
displays.push({
|
||||
width: Number(match[1]),
|
||||
height: Number(match[2]),
|
||||
scaleFactor: 1,
|
||||
displayId: idx++,
|
||||
})
|
||||
}
|
||||
|
||||
if (displays.length === 0) {
|
||||
return [{ width: 1920, height: 1080, scaleFactor: 1, displayId: 0 }]
|
||||
}
|
||||
return displays
|
||||
} catch {
|
||||
return [{ width: 1920, height: 1080, scaleFactor: 1, displayId: 0 }]
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AppsAPI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const apps: AppsAPI = {
|
||||
async prepareDisplay(_allowlistBundleIds, _surrogateHost, _displayId): Promise<PrepareDisplayResult> {
|
||||
return { activated: '', hidden: [] }
|
||||
},
|
||||
|
||||
async previewHideSet(_bundleIds, _displayId): Promise<AppInfo[]> {
|
||||
return []
|
||||
},
|
||||
|
||||
async findWindowDisplays(bundleIds): Promise<WindowDisplayInfo[]> {
|
||||
return bundleIds.map(bundleId => ({ bundleId, displayIds: [0] }))
|
||||
},
|
||||
|
||||
async appUnderPoint(x, y): Promise<AppInfo | null> {
|
||||
try {
|
||||
// Move mouse to point, get window under cursor
|
||||
const out = run(['xdotool', 'mousemove', '--sync', String(x), String(y), 'getmouselocation', '--shell'])
|
||||
const windowMatch = out.match(/WINDOW=(\d+)/)
|
||||
if (!windowMatch) return null
|
||||
|
||||
const windowId = windowMatch[1]
|
||||
const pidStr = run(['xdotool', 'getwindowpid', windowId!])
|
||||
if (!pidStr) return null
|
||||
|
||||
let exePath = ''
|
||||
try { exePath = run(['readlink', '-f', `/proc/${pidStr}/exe`]) } catch { /* ignore */ }
|
||||
|
||||
let appName = ''
|
||||
try { appName = run(['cat', `/proc/${pidStr}/comm`]) } catch { /* ignore */ }
|
||||
|
||||
if (!exePath && !appName) return null
|
||||
return { bundleId: exePath || pidStr!, displayName: appName || 'unknown' }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
async listInstalled(): Promise<InstalledApp[]> {
|
||||
try {
|
||||
// Read .desktop files from standard locations
|
||||
const dirs = ['/usr/share/applications', '/usr/local/share/applications', `${process.env.HOME}/.local/share/applications`]
|
||||
const apps: InstalledApp[] = []
|
||||
|
||||
for (const dir of dirs) {
|
||||
let files: string
|
||||
try {
|
||||
files = run(['find', dir, '-name', '*.desktop', '-maxdepth', '1'])
|
||||
} catch { continue }
|
||||
|
||||
for (const filepath of files.split('\n').filter(Boolean)) {
|
||||
try {
|
||||
const content = run(['cat', filepath])
|
||||
const nameMatch = content.match(/^Name=(.+)$/m)
|
||||
const execMatch = content.match(/^Exec=(.+)$/m)
|
||||
const noDisplay = content.match(/^NoDisplay=true$/m)
|
||||
if (noDisplay) continue
|
||||
|
||||
const name = nameMatch?.[1] ?? ''
|
||||
const exec = execMatch?.[1] ?? ''
|
||||
if (!name) continue
|
||||
|
||||
apps.push({
|
||||
bundleId: filepath.split('/').pop()?.replace('.desktop', '') ?? '',
|
||||
displayName: name,
|
||||
path: exec.split(/\s+/)[0] ?? '',
|
||||
})
|
||||
} catch { /* skip unreadable files */ }
|
||||
}
|
||||
}
|
||||
|
||||
return apps.slice(0, 200)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
},
|
||||
|
||||
iconDataUrl(_path): string | null {
|
||||
return null
|
||||
},
|
||||
|
||||
listRunning(): RunningApp[] {
|
||||
try {
|
||||
// Try wmctrl first
|
||||
if (commandExists('wmctrl')) {
|
||||
const raw = run(['wmctrl', '-l', '-p'])
|
||||
const apps: RunningApp[] = []
|
||||
for (const line of raw.split('\n').filter(Boolean)) {
|
||||
// wmctrl format: "0x04000003 0 12345 hostname Window Title"
|
||||
const parts = line.split(/\s+/)
|
||||
const pid = parts[2]
|
||||
if (!pid || pid === '0') continue
|
||||
|
||||
let exePath = ''
|
||||
try { exePath = run(['readlink', '-f', `/proc/${pid}/exe`]) } catch { /* ignore */ }
|
||||
let appName = ''
|
||||
try { appName = run(['cat', `/proc/${pid}/comm`]) } catch { /* ignore */ }
|
||||
|
||||
if (appName) {
|
||||
apps.push({ bundleId: exePath || pid, displayName: appName })
|
||||
}
|
||||
}
|
||||
// Deduplicate by bundleId
|
||||
const seen = new Set<string>()
|
||||
return apps.filter(a => {
|
||||
if (seen.has(a.bundleId)) return false
|
||||
seen.add(a.bundleId)
|
||||
return true
|
||||
}).slice(0, 50)
|
||||
}
|
||||
|
||||
// Fallback: ps with visible processes
|
||||
const raw = run(['ps', '-eo', 'pid,comm', '--no-headers'])
|
||||
const apps: RunningApp[] = []
|
||||
for (const line of raw.split('\n').filter(Boolean).slice(0, 50)) {
|
||||
const match = line.trim().match(/^(\d+)\s+(.+)$/)
|
||||
if (match) {
|
||||
apps.push({ bundleId: match[1]!, displayName: match[2]! })
|
||||
}
|
||||
}
|
||||
return apps
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
},
|
||||
|
||||
async open(name): Promise<void> {
|
||||
// Try gtk-launch first (for .desktop file names), fall back to xdg-open
|
||||
try {
|
||||
const desktopName = name.endsWith('.desktop') ? name : `${name}.desktop`
|
||||
if (commandExists('gtk-launch')) {
|
||||
await runAsync(['gtk-launch', desktopName])
|
||||
return
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
|
||||
await runAsync(['xdg-open', name])
|
||||
},
|
||||
|
||||
async unhide(bundleIds): Promise<void> {
|
||||
for (const id of bundleIds) {
|
||||
try {
|
||||
if (commandExists('wmctrl') && id.startsWith('0x')) {
|
||||
// Window ID — use wmctrl
|
||||
await runAsync(['wmctrl', '-i', '-R', id])
|
||||
} else {
|
||||
// Try xdotool windowactivate with search by name
|
||||
await runAsync(['xdotool', 'search', '--name', id, 'windowactivate'])
|
||||
}
|
||||
} catch { /* ignore failures for individual windows */ }
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ScreenshotAPI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SCREENSHOT_PATH = '/tmp/cu-screenshot.png'
|
||||
|
||||
export const screenshot: ScreenshotAPI = {
|
||||
async captureExcluding(_allowedBundleIds, _quality, _targetW, _targetH, _displayId): Promise<ScreenshotResult> {
|
||||
try {
|
||||
await runAsync(['scrot', '-o', SCREENSHOT_PATH])
|
||||
|
||||
// Read the file as base64
|
||||
const file = Bun.file(SCREENSHOT_PATH)
|
||||
const buffer = await file.arrayBuffer()
|
||||
const base64 = Buffer.from(buffer).toString('base64')
|
||||
|
||||
// Get dimensions from display info
|
||||
const size = display.getSize(_displayId)
|
||||
return { base64, width: size.width, height: size.height }
|
||||
} catch {
|
||||
return { base64: '', width: 0, height: 0 }
|
||||
}
|
||||
},
|
||||
|
||||
async captureRegion(_allowedBundleIds, x, y, w, h, _outW, _outH, _quality, _displayId): Promise<ScreenshotResult> {
|
||||
try {
|
||||
// scrot -a x,y,w,h captures a specific region
|
||||
await runAsync(['scrot', '-a', `${x},${y},${w},${h}`, '-o', SCREENSHOT_PATH])
|
||||
|
||||
const file = Bun.file(SCREENSHOT_PATH)
|
||||
const buffer = await file.arrayBuffer()
|
||||
const base64 = Buffer.from(buffer).toString('base64')
|
||||
|
||||
return { base64, width: w, height: h }
|
||||
} catch {
|
||||
return { base64: '', width: 0, height: 0 }
|
||||
}
|
||||
},
|
||||
}
|
||||
263
packages/@ant/computer-use-swift/src/backends/win32.ts
Normal file
263
packages/@ant/computer-use-swift/src/backends/win32.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Windows backend for computer-use-swift
|
||||
*
|
||||
* Uses PowerShell with .NET System.Drawing / System.Windows.Forms for
|
||||
* screenshots and Win32 P/Invoke for window/process management.
|
||||
*/
|
||||
|
||||
import type {
|
||||
AppInfo, AppsAPI, DisplayAPI, DisplayGeometry, InstalledApp,
|
||||
PrepareDisplayResult, RunningApp, ScreenshotAPI, ScreenshotResult,
|
||||
SwiftBackend, WindowDisplayInfo,
|
||||
} from '../types.js'
|
||||
|
||||
import { listWindows } from 'src/utils/computerUse/win32/windowEnum.js'
|
||||
import { captureWindow, captureWindowByHwnd } from 'src/utils/computerUse/win32/windowCapture.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PowerShell helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ps(script: string): string {
|
||||
const result = Bun.spawnSync({
|
||||
cmd: ['powershell', '-NoProfile', '-NonInteractive', '-Command', script],
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
return new TextDecoder().decode(result.stdout).trim()
|
||||
}
|
||||
|
||||
async function psAsync(script: string): Promise<string> {
|
||||
const proc = Bun.spawn(
|
||||
['powershell', '-NoProfile', '-NonInteractive', '-Command', script],
|
||||
{ stdout: 'pipe', stderr: 'pipe' },
|
||||
)
|
||||
const out = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
return out.trim()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DisplayAPI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const display: DisplayAPI = {
|
||||
getSize(displayId?: number): DisplayGeometry {
|
||||
const all = this.listAll()
|
||||
if (displayId !== undefined) {
|
||||
const found = all.find(d => d.displayId === displayId)
|
||||
if (found) return found
|
||||
}
|
||||
return all[0] ?? { width: 1920, height: 1080, scaleFactor: 1, displayId: 0 }
|
||||
},
|
||||
|
||||
listAll(): DisplayGeometry[] {
|
||||
try {
|
||||
const raw = ps(`
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
$result = @()
|
||||
$idx = 0
|
||||
foreach ($s in [System.Windows.Forms.Screen]::AllScreens) {
|
||||
$result += "$($s.Bounds.Width),$($s.Bounds.Height),$idx,$($s.Primary)"
|
||||
$idx++
|
||||
}
|
||||
$result -join "|"
|
||||
`)
|
||||
return raw.split('|').filter(Boolean).map(entry => {
|
||||
const [w, h, id, primary] = entry.split(',')
|
||||
return {
|
||||
width: Number(w),
|
||||
height: Number(h),
|
||||
scaleFactor: 1, // Windows DPI scaling handled at system level
|
||||
displayId: Number(id),
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return [{ width: 1920, height: 1080, scaleFactor: 1, displayId: 0 }]
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AppsAPI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const apps: AppsAPI = {
|
||||
async prepareDisplay(_allowlistBundleIds, _surrogateHost, _displayId) {
|
||||
return { activated: '', hidden: [] }
|
||||
},
|
||||
|
||||
async previewHideSet(_bundleIds, _displayId) {
|
||||
return []
|
||||
},
|
||||
|
||||
async findWindowDisplays(bundleIds) {
|
||||
return bundleIds.map(bundleId => ({ bundleId, displayIds: [0] }))
|
||||
},
|
||||
|
||||
async appUnderPoint(_x, _y) {
|
||||
try {
|
||||
const out = ps(`
|
||||
Add-Type @'
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
public class WinPt {
|
||||
[StructLayout(LayoutKind.Sequential)] public struct POINT { public int X; public int Y; }
|
||||
[DllImport("user32.dll")] public static extern IntPtr WindowFromPoint(POINT p);
|
||||
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);
|
||||
}
|
||||
'@
|
||||
$pt = New-Object WinPt+POINT
|
||||
$pt.X = ${_x}; $pt.Y = ${_y}
|
||||
$hwnd = [WinPt]::WindowFromPoint($pt)
|
||||
$pid = [uint32]0
|
||||
[WinPt]::GetWindowThreadProcessId($hwnd, [ref]$pid) | Out-Null
|
||||
$proc = Get-Process -Id $pid -ErrorAction SilentlyContinue
|
||||
"$($proc.MainModule.FileName)|$($proc.ProcessName)"
|
||||
`)
|
||||
if (!out || !out.includes('|')) return null
|
||||
const [exePath, name] = out.split('|', 2)
|
||||
return { bundleId: exePath!, displayName: name! }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
async listInstalled() {
|
||||
try {
|
||||
const raw = await psAsync(`
|
||||
$apps = @()
|
||||
$paths = @(
|
||||
'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*',
|
||||
'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*',
|
||||
'HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'
|
||||
)
|
||||
foreach ($p in $paths) {
|
||||
Get-ItemProperty $p -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName } | ForEach-Object {
|
||||
$apps += "$($_.DisplayName)|$($_.InstallLocation)|$($_.PSChildName)"
|
||||
}
|
||||
}
|
||||
$apps | Select-Object -Unique | Select-Object -First 200
|
||||
`)
|
||||
return raw.split('\n').filter(Boolean).map(line => {
|
||||
const [name, path, id] = line.split('|', 3)
|
||||
return {
|
||||
bundleId: id ?? name ?? '',
|
||||
displayName: name ?? '',
|
||||
path: path ?? '',
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
},
|
||||
|
||||
iconDataUrl(_path) {
|
||||
return null
|
||||
},
|
||||
|
||||
listRunning() {
|
||||
try {
|
||||
const windows = listWindows()
|
||||
return windows.map(w => ({
|
||||
bundleId: String(w.hwnd),
|
||||
displayName: w.title,
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
},
|
||||
|
||||
async open(name) {
|
||||
// On Windows, name is the exe path (bundleId) or process name.
|
||||
// Try exe path first, fall back to process name lookup.
|
||||
const escaped = name.replace(/'/g, "''")
|
||||
await psAsync(`
|
||||
if (Test-Path '${escaped}') {
|
||||
Start-Process '${escaped}'
|
||||
} else {
|
||||
Start-Process -FilePath '${escaped}' -ErrorAction SilentlyContinue
|
||||
}`)
|
||||
},
|
||||
|
||||
async unhide(bundleIds) {
|
||||
// Windows: bring window to foreground
|
||||
for (const name of bundleIds) {
|
||||
await psAsync(`
|
||||
Add-Type @'
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
public class WinShow {
|
||||
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmd);
|
||||
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||
}
|
||||
'@
|
||||
$proc = Get-Process -Name "${name}" -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($proc) { [WinShow]::ShowWindow($proc.MainWindowHandle, 9) | Out-Null; [WinShow]::SetForegroundWindow($proc.MainWindowHandle) | Out-Null }
|
||||
`)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ScreenshotAPI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const screenshot: ScreenshotAPI = {
|
||||
async captureExcluding(_allowedBundleIds, _quality, _targetW, _targetH, displayId) {
|
||||
const raw = await psAsync(`
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
$screen = if (${displayId ?? -1} -ge 0) { [System.Windows.Forms.Screen]::AllScreens[${displayId ?? 0}] } else { [System.Windows.Forms.Screen]::PrimaryScreen }
|
||||
$bounds = $screen.Bounds
|
||||
$bmp = New-Object System.Drawing.Bitmap($bounds.Width, $bounds.Height)
|
||||
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
||||
$g.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size)
|
||||
$g.Dispose()
|
||||
$ms = New-Object System.IO.MemoryStream
|
||||
$bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
$bmp.Dispose()
|
||||
$bytes = $ms.ToArray()
|
||||
$ms.Dispose()
|
||||
"$($bounds.Width),$($bounds.Height)," + [Convert]::ToBase64String($bytes)
|
||||
`)
|
||||
const firstComma = raw.indexOf(',')
|
||||
const secondComma = raw.indexOf(',', firstComma + 1)
|
||||
const width = Number(raw.slice(0, firstComma))
|
||||
const height = Number(raw.slice(firstComma + 1, secondComma))
|
||||
const base64 = raw.slice(secondComma + 1)
|
||||
return { base64, width, height }
|
||||
},
|
||||
|
||||
async captureRegion(_allowedBundleIds, x, y, w, h, _outW, _outH, _quality, _displayId) {
|
||||
const raw = await psAsync(`
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
$bmp = New-Object System.Drawing.Bitmap(${w}, ${h})
|
||||
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
||||
$g.CopyFromScreen(${x}, ${y}, 0, 0, (New-Object System.Drawing.Size(${w}, ${h})))
|
||||
$g.Dispose()
|
||||
$ms = New-Object System.IO.MemoryStream
|
||||
$bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
$bmp.Dispose()
|
||||
$bytes = $ms.ToArray()
|
||||
$ms.Dispose()
|
||||
"${w},${h}," + [Convert]::ToBase64String($bytes)
|
||||
`)
|
||||
const firstComma = raw.indexOf(',')
|
||||
const secondComma = raw.indexOf(',', firstComma + 1)
|
||||
const base64 = raw.slice(secondComma + 1)
|
||||
return { base64, width: w, height: h }
|
||||
},
|
||||
|
||||
/**
|
||||
* Capture a specific window by title or HWND using PrintWindow.
|
||||
* Works even for occluded or background windows.
|
||||
*/
|
||||
captureWindowTarget(titleOrHwnd: string | number): ScreenshotResult | null {
|
||||
if (typeof titleOrHwnd === 'number') {
|
||||
return captureWindowByHwnd(titleOrHwnd)
|
||||
}
|
||||
return captureWindow(titleOrHwnd)
|
||||
},
|
||||
}
|
||||
@@ -1,377 +1,62 @@
|
||||
/**
|
||||
* @ant/computer-use-swift — macOS 实现
|
||||
* @ant/computer-use-swift — macOS display, apps, and screenshot (Swift native)
|
||||
*
|
||||
* 用 AppleScript/JXA/screencapture 替代原始 Swift 原生模块。
|
||||
* 提供显示器信息、应用管理、截图等功能。
|
||||
*
|
||||
* 仅 macOS 支持。
|
||||
* This package wraps the macOS-only Swift .node native module.
|
||||
* For Windows/Linux, use src/utils/computerUse/platforms/ instead.
|
||||
*/
|
||||
|
||||
import { readFileSync, unlinkSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
export type {
|
||||
DisplayGeometry,
|
||||
PrepareDisplayResult,
|
||||
AppInfo,
|
||||
InstalledApp,
|
||||
RunningApp,
|
||||
ScreenshotResult,
|
||||
ResolvePrepareCaptureResult,
|
||||
WindowDisplayInfo,
|
||||
} from './backends/darwin.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types (exported for callers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DisplayGeometry {
|
||||
width: number
|
||||
height: number
|
||||
scaleFactor: number
|
||||
displayId: number
|
||||
}
|
||||
|
||||
export interface PrepareDisplayResult {
|
||||
activated: string
|
||||
hidden: string[]
|
||||
}
|
||||
|
||||
export interface AppInfo {
|
||||
bundleId: string
|
||||
displayName: string
|
||||
}
|
||||
|
||||
export interface InstalledApp {
|
||||
bundleId: string
|
||||
displayName: string
|
||||
path: string
|
||||
iconDataUrl?: string
|
||||
}
|
||||
|
||||
export interface RunningApp {
|
||||
bundleId: string
|
||||
displayName: string
|
||||
}
|
||||
|
||||
export interface ScreenshotResult {
|
||||
base64: string
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface ResolvePrepareCaptureResult {
|
||||
base64: string
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface WindowDisplayInfo {
|
||||
bundleId: string
|
||||
displayIds: number[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function jxaSync(script: string): string {
|
||||
const result = Bun.spawnSync({
|
||||
cmd: ['osascript', '-l', 'JavaScript', '-e', script],
|
||||
stdout: 'pipe', stderr: 'pipe',
|
||||
})
|
||||
return new TextDecoder().decode(result.stdout).trim()
|
||||
}
|
||||
|
||||
function osascriptSync(script: string): string {
|
||||
const result = Bun.spawnSync({
|
||||
cmd: ['osascript', '-e', script],
|
||||
stdout: 'pipe', stderr: 'pipe',
|
||||
})
|
||||
return new TextDecoder().decode(result.stdout).trim()
|
||||
}
|
||||
|
||||
async function osascript(script: string): Promise<string> {
|
||||
const proc = Bun.spawn(['osascript', '-e', script], {
|
||||
stdout: 'pipe', stderr: 'pipe',
|
||||
})
|
||||
const text = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
return text.trim()
|
||||
}
|
||||
|
||||
async function jxa(script: string): Promise<string> {
|
||||
const proc = Bun.spawn(['osascript', '-l', 'JavaScript', '-e', script], {
|
||||
stdout: 'pipe', stderr: 'pipe',
|
||||
})
|
||||
const text = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
return text.trim()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DisplayAPI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DisplayAPI {
|
||||
getSize(displayId?: number): DisplayGeometry
|
||||
listAll(): DisplayGeometry[]
|
||||
}
|
||||
|
||||
const displayAPI: DisplayAPI = {
|
||||
getSize(displayId?: number): DisplayGeometry {
|
||||
const all = this.listAll()
|
||||
if (displayId !== undefined) {
|
||||
const found = all.find(d => d.displayId === displayId)
|
||||
if (found) return found
|
||||
}
|
||||
return all[0] ?? { width: 1920, height: 1080, scaleFactor: 2, displayId: 1 }
|
||||
},
|
||||
|
||||
listAll(): DisplayGeometry[] {
|
||||
try {
|
||||
const raw = jxaSync(`
|
||||
ObjC.import("CoreGraphics");
|
||||
var displays = $.CGDisplayCopyAllDisplayModes ? [] : [];
|
||||
var active = $.CGGetActiveDisplayList(10, null, Ref());
|
||||
var countRef = Ref();
|
||||
$.CGGetActiveDisplayList(0, null, countRef);
|
||||
var count = countRef[0];
|
||||
var idBuf = Ref();
|
||||
$.CGGetActiveDisplayList(count, idBuf, countRef);
|
||||
var result = [];
|
||||
for (var i = 0; i < count; i++) {
|
||||
var did = idBuf[i];
|
||||
var w = $.CGDisplayPixelsWide(did);
|
||||
var h = $.CGDisplayPixelsHigh(did);
|
||||
var mode = $.CGDisplayCopyDisplayMode(did);
|
||||
var pw = $.CGDisplayModeGetPixelWidth(mode);
|
||||
var sf = pw > 0 && w > 0 ? pw / w : 2;
|
||||
result.push({width: w, height: h, scaleFactor: sf, displayId: did});
|
||||
}
|
||||
JSON.stringify(result);
|
||||
`)
|
||||
return (JSON.parse(raw) as DisplayGeometry[]).map(d => ({
|
||||
width: Number(d.width), height: Number(d.height),
|
||||
scaleFactor: Number(d.scaleFactor), displayId: Number(d.displayId),
|
||||
}))
|
||||
} catch {
|
||||
// Fallback: use NSScreen via JXA
|
||||
try {
|
||||
const raw = jxaSync(`
|
||||
ObjC.import("AppKit");
|
||||
var screens = $.NSScreen.screens;
|
||||
var result = [];
|
||||
for (var i = 0; i < screens.count; i++) {
|
||||
var s = screens.objectAtIndex(i);
|
||||
var frame = s.frame;
|
||||
var desc = s.deviceDescription;
|
||||
var screenNumber = desc.objectForKey($("NSScreenNumber")).intValue;
|
||||
var backingFactor = s.backingScaleFactor;
|
||||
result.push({
|
||||
width: Math.round(frame.size.width),
|
||||
height: Math.round(frame.size.height),
|
||||
scaleFactor: backingFactor,
|
||||
displayId: screenNumber
|
||||
});
|
||||
}
|
||||
JSON.stringify(result);
|
||||
`)
|
||||
return (JSON.parse(raw) as DisplayGeometry[]).map(d => ({
|
||||
width: Number(d.width),
|
||||
height: Number(d.height),
|
||||
scaleFactor: Number(d.scaleFactor),
|
||||
displayId: Number(d.displayId),
|
||||
}))
|
||||
} catch {
|
||||
return [{ width: 1920, height: 1080, scaleFactor: 2, displayId: 1 }]
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AppsAPI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AppsAPI {
|
||||
prepareDisplay(allowlistBundleIds: string[], surrogateHost: string, displayId?: number): Promise<PrepareDisplayResult>
|
||||
previewHideSet(bundleIds: string[], displayId?: number): Promise<AppInfo[]>
|
||||
findWindowDisplays(bundleIds: string[]): Promise<WindowDisplayInfo[]>
|
||||
appUnderPoint(x: number, y: number): Promise<AppInfo | null>
|
||||
listInstalled(): Promise<InstalledApp[]>
|
||||
iconDataUrl(path: string): string | null
|
||||
listRunning(): RunningApp[]
|
||||
open(bundleId: string): Promise<void>
|
||||
unhide(bundleIds: string[]): Promise<void>
|
||||
}
|
||||
|
||||
const appsAPI: AppsAPI = {
|
||||
async prepareDisplay(
|
||||
_allowlistBundleIds: string[],
|
||||
_surrogateHost: string,
|
||||
_displayId?: number,
|
||||
): Promise<PrepareDisplayResult> {
|
||||
return { activated: '', hidden: [] }
|
||||
},
|
||||
|
||||
async previewHideSet(
|
||||
_bundleIds: string[],
|
||||
_displayId?: number,
|
||||
): Promise<AppInfo[]> {
|
||||
return []
|
||||
},
|
||||
|
||||
async findWindowDisplays(bundleIds: string[]): Promise<WindowDisplayInfo[]> {
|
||||
// Each running app is assumed to be on display 1
|
||||
return bundleIds.map(bundleId => ({ bundleId, displayIds: [1] }))
|
||||
},
|
||||
|
||||
async appUnderPoint(_x: number, _y: number): Promise<AppInfo | null> {
|
||||
// Use JXA to find app at mouse position via accessibility
|
||||
try {
|
||||
const result = await jxa(`
|
||||
ObjC.import("CoreGraphics");
|
||||
ObjC.import("AppKit");
|
||||
var pt = $.CGPointMake(${_x}, ${_y});
|
||||
// Get frontmost app as a fallback
|
||||
var app = $.NSWorkspace.sharedWorkspace.frontmostApplication;
|
||||
JSON.stringify({bundleId: app.bundleIdentifier.js, displayName: app.localizedName.js});
|
||||
`)
|
||||
return JSON.parse(result)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
async listInstalled(): Promise<InstalledApp[]> {
|
||||
try {
|
||||
const result = await osascript(`
|
||||
tell application "System Events"
|
||||
set appList to ""
|
||||
repeat with appFile in (every file of folder "Applications" of startup disk whose name ends with ".app")
|
||||
set appPath to POSIX path of (appFile as alias)
|
||||
set appName to name of appFile
|
||||
set appList to appList & appPath & "|" & appName & "\\n"
|
||||
end repeat
|
||||
return appList
|
||||
end tell
|
||||
`)
|
||||
return result.split('\n').filter(Boolean).map(line => {
|
||||
const [path, name] = line.split('|', 2)
|
||||
// Derive bundleId from Info.plist would be ideal, but use path-based fallback
|
||||
const displayName = (name ?? '').replace(/\.app$/, '')
|
||||
return {
|
||||
bundleId: `com.app.${displayName.toLowerCase().replace(/\s+/g, '-')}`,
|
||||
displayName,
|
||||
path: path ?? '',
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
},
|
||||
|
||||
iconDataUrl(_path: string): string | null {
|
||||
return null
|
||||
},
|
||||
|
||||
listRunning(): RunningApp[] {
|
||||
try {
|
||||
const raw = jxaSync(`
|
||||
var apps = Application("System Events").applicationProcesses.whose({backgroundOnly: false});
|
||||
var result = [];
|
||||
for (var i = 0; i < apps.length; i++) {
|
||||
try {
|
||||
var a = apps[i];
|
||||
result.push({bundleId: a.bundleIdentifier(), displayName: a.name()});
|
||||
} catch(e) {}
|
||||
}
|
||||
JSON.stringify(result);
|
||||
`)
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
},
|
||||
|
||||
async open(bundleId: string): Promise<void> {
|
||||
await osascript(`tell application id "${bundleId}" to activate`)
|
||||
},
|
||||
|
||||
async unhide(bundleIds: string[]): Promise<void> {
|
||||
for (const bundleId of bundleIds) {
|
||||
await osascript(`
|
||||
tell application "System Events"
|
||||
set visible of application process (name of application process whose bundle identifier is "${bundleId}") to true
|
||||
end tell
|
||||
`)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ScreenshotAPI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ScreenshotAPI {
|
||||
captureExcluding(
|
||||
allowedBundleIds: string[], quality: number,
|
||||
targetW: number, targetH: number, displayId?: number,
|
||||
): Promise<ScreenshotResult>
|
||||
captureRegion(
|
||||
allowedBundleIds: string[],
|
||||
x: number, y: number, w: number, h: number,
|
||||
outW: number, outH: number, quality: number, displayId?: number,
|
||||
): Promise<ScreenshotResult>
|
||||
}
|
||||
|
||||
async function captureScreenToBase64(args: string[]): Promise<{ base64: string; width: number; height: number }> {
|
||||
const tmpFile = join(tmpdir(), `cu-screenshot-${Date.now()}.png`)
|
||||
const proc = Bun.spawn(['screencapture', ...args, tmpFile], {
|
||||
stdout: 'pipe', stderr: 'pipe',
|
||||
})
|
||||
await proc.exited
|
||||
import type { ResolvePrepareCaptureResult } from './backends/darwin.js'
|
||||
|
||||
function loadBackend() {
|
||||
try {
|
||||
const buf = readFileSync(tmpFile)
|
||||
const base64 = buf.toString('base64')
|
||||
// Parse PNG header for dimensions (bytes 16-23)
|
||||
const width = buf.readUInt32BE(16)
|
||||
const height = buf.readUInt32BE(20)
|
||||
return { base64, width, height }
|
||||
} finally {
|
||||
try { unlinkSync(tmpFile) } catch {}
|
||||
if (process.platform === 'darwin') {
|
||||
return require('./backends/darwin.js')
|
||||
} else if (process.platform === 'win32') {
|
||||
return require('./backends/win32.js')
|
||||
} else if (process.platform === 'linux') {
|
||||
return require('./backends/linux.js')
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const screenshotAPI: ScreenshotAPI = {
|
||||
async captureExcluding(
|
||||
_allowedBundleIds: string[],
|
||||
_quality: number,
|
||||
_targetW: number,
|
||||
_targetH: number,
|
||||
displayId?: number,
|
||||
): Promise<ScreenshotResult> {
|
||||
const args = ['-x'] // silent
|
||||
if (displayId !== undefined) {
|
||||
args.push('-D', String(displayId))
|
||||
}
|
||||
return captureScreenToBase64(args)
|
||||
},
|
||||
|
||||
async captureRegion(
|
||||
_allowedBundleIds: string[],
|
||||
x: number, y: number, w: number, h: number,
|
||||
_outW: number, _outH: number, _quality: number,
|
||||
displayId?: number,
|
||||
): Promise<ScreenshotResult> {
|
||||
const args = ['-x', '-R', `${x},${y},${w},${h}`]
|
||||
if (displayId !== undefined) {
|
||||
args.push('-D', String(displayId))
|
||||
}
|
||||
return captureScreenToBase64(args)
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ComputerUseAPI — Main export
|
||||
// ---------------------------------------------------------------------------
|
||||
const backend = loadBackend()
|
||||
|
||||
export class ComputerUseAPI {
|
||||
apps: AppsAPI = appsAPI
|
||||
display: DisplayAPI = displayAPI
|
||||
screenshot: ScreenshotAPI = screenshotAPI
|
||||
apps = backend?.apps ?? {
|
||||
async prepareDisplay() { return { activated: '', hidden: [] } },
|
||||
async previewHideSet() { return [] },
|
||||
async findWindowDisplays(ids: string[]) { return ids.map((b: string) => ({ bundleId: b, displayIds: [] as number[] })) },
|
||||
async appUnderPoint() { return null },
|
||||
async listInstalled() { return [] },
|
||||
iconDataUrl() { return null },
|
||||
listRunning() { return [] },
|
||||
async open() { throw new Error('@ant/computer-use-swift: macOS only') },
|
||||
async unhide() {},
|
||||
}
|
||||
|
||||
display = backend?.display ?? {
|
||||
getSize() { throw new Error('@ant/computer-use-swift: macOS only') },
|
||||
listAll() { throw new Error('@ant/computer-use-swift: macOS only') },
|
||||
}
|
||||
|
||||
screenshot = backend?.screenshot ?? {
|
||||
async captureExcluding() { throw new Error('@ant/computer-use-swift: macOS only') },
|
||||
async captureRegion() { throw new Error('@ant/computer-use-swift: macOS only') },
|
||||
}
|
||||
|
||||
async resolvePrepareCapture(
|
||||
allowedBundleIds: string[],
|
||||
@@ -380,8 +65,6 @@ export class ComputerUseAPI {
|
||||
targetW: number,
|
||||
targetH: number,
|
||||
displayId?: number,
|
||||
_autoResolve?: boolean,
|
||||
_doHide?: boolean,
|
||||
): Promise<ResolvePrepareCaptureResult> {
|
||||
return this.screenshot.captureExcluding(allowedBundleIds, quality, targetW, targetH, displayId)
|
||||
}
|
||||
|
||||
80
packages/@ant/computer-use-swift/src/types.ts
Normal file
80
packages/@ant/computer-use-swift/src/types.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
export interface DisplayGeometry {
|
||||
width: number
|
||||
height: number
|
||||
scaleFactor: number
|
||||
displayId: number
|
||||
}
|
||||
|
||||
export interface PrepareDisplayResult {
|
||||
activated: string
|
||||
hidden: string[]
|
||||
}
|
||||
|
||||
export interface AppInfo {
|
||||
bundleId: string
|
||||
displayName: string
|
||||
}
|
||||
|
||||
export interface InstalledApp {
|
||||
bundleId: string
|
||||
displayName: string
|
||||
path: string
|
||||
iconDataUrl?: string
|
||||
}
|
||||
|
||||
export interface RunningApp {
|
||||
bundleId: string
|
||||
displayName: string
|
||||
}
|
||||
|
||||
export interface ScreenshotResult {
|
||||
base64: string
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface ResolvePrepareCaptureResult {
|
||||
base64: string
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface WindowDisplayInfo {
|
||||
bundleId: string
|
||||
displayIds: number[]
|
||||
}
|
||||
|
||||
export interface DisplayAPI {
|
||||
getSize(displayId?: number): DisplayGeometry
|
||||
listAll(): DisplayGeometry[]
|
||||
}
|
||||
|
||||
export interface AppsAPI {
|
||||
prepareDisplay(allowlistBundleIds: string[], surrogateHost: string, displayId?: number): Promise<PrepareDisplayResult>
|
||||
previewHideSet(bundleIds: string[], displayId?: number): Promise<AppInfo[]>
|
||||
findWindowDisplays(bundleIds: string[]): Promise<WindowDisplayInfo[]>
|
||||
appUnderPoint(x: number, y: number): Promise<AppInfo | null>
|
||||
listInstalled(): Promise<InstalledApp[]>
|
||||
iconDataUrl(path: string): string | null
|
||||
listRunning(): RunningApp[]
|
||||
open(bundleId: string): Promise<void>
|
||||
unhide(bundleIds: string[]): Promise<void>
|
||||
}
|
||||
|
||||
export interface ScreenshotAPI {
|
||||
captureExcluding(
|
||||
allowedBundleIds: string[], quality: number,
|
||||
targetW: number, targetH: number, displayId?: number,
|
||||
): Promise<ScreenshotResult>
|
||||
captureRegion(
|
||||
allowedBundleIds: string[],
|
||||
x: number, y: number, w: number, h: number,
|
||||
outW: number, outH: number, quality: number, displayId?: number,
|
||||
): Promise<ScreenshotResult>
|
||||
}
|
||||
|
||||
export interface SwiftBackend {
|
||||
display: DisplayAPI
|
||||
apps: AppsAPI
|
||||
screenshot: ScreenshotAPI
|
||||
}
|
||||
30
packages/@ant/ink/package.json
Normal file
30
packages/@ant/ink/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@anthropic/ink",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"auto-bind": "^5.0.1",
|
||||
"bidi-js": "^1.0.3",
|
||||
"chalk": "^5.6.2",
|
||||
"cli-boxes": "^4.0.1",
|
||||
"emoji-regex": "^10.6.0",
|
||||
"figures": "^6.1.0",
|
||||
"get-east-asian-width": "^1.5.0",
|
||||
"indent-string": "^5.0.0",
|
||||
"lodash-es": "^4.17.23",
|
||||
"react": "^19.2.4",
|
||||
"react-reconciler": "^0.33.0",
|
||||
"signal-exit": "^4.1.0",
|
||||
"strip-ansi": "^7.2.0",
|
||||
"supports-hyperlinks": "^4.4.0",
|
||||
"type-fest": "^5.5.0",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"wrap-ansi": "^10.0.0"
|
||||
}
|
||||
}
|
||||
87
packages/@ant/ink/src/components/AlternateScreen.tsx
Normal file
87
packages/@ant/ink/src/components/AlternateScreen.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React, {
|
||||
type PropsWithChildren,
|
||||
useContext,
|
||||
useInsertionEffect,
|
||||
} from 'react'
|
||||
import instances from '../core/instances.js'
|
||||
import {
|
||||
DISABLE_MOUSE_TRACKING,
|
||||
ENABLE_MOUSE_TRACKING,
|
||||
ENTER_ALT_SCREEN,
|
||||
EXIT_ALT_SCREEN,
|
||||
} from '../core/termio/dec.js'
|
||||
import { TerminalWriteContext } from '../hooks/useTerminalNotification.js'
|
||||
import Box from './Box.js'
|
||||
import { TerminalSizeContext } from './TerminalSizeContext.js'
|
||||
|
||||
type Props = PropsWithChildren<{
|
||||
/** Enable SGR mouse tracking (wheel + click/drag). Default true. */
|
||||
mouseTracking?: boolean
|
||||
}>
|
||||
|
||||
/**
|
||||
* Run children in the terminal's alternate screen buffer, constrained to
|
||||
* the viewport height. While mounted:
|
||||
*
|
||||
* - Enters the alt screen (DEC 1049), clears it, homes the cursor
|
||||
* - Constrains its own height to the terminal row count, so overflow must
|
||||
* be handled via `overflow: scroll` / flexbox (no native scrollback)
|
||||
* - Optionally enables SGR mouse tracking (wheel + click/drag) — events
|
||||
* surface as `ParsedKey` (wheel) and update the Ink instance's
|
||||
* selection state (click/drag)
|
||||
*
|
||||
* On unmount, disables mouse tracking and exits the alt screen, restoring
|
||||
* the main screen's content. Safe for use in ctrl-o transcript overlays
|
||||
* and similar temporary fullscreen views — the main screen is preserved.
|
||||
*
|
||||
* Notifies the Ink instance via `setAltScreenActive()` so the renderer
|
||||
* keeps the cursor inside the viewport (preventing the cursor-restore LF
|
||||
* from scrolling content) and so signal-exit cleanup can exit the alt
|
||||
* screen if the component's own unmount doesn't run.
|
||||
*/
|
||||
export function AlternateScreen({
|
||||
children,
|
||||
mouseTracking = true,
|
||||
}: Props): React.ReactNode {
|
||||
const size = useContext(TerminalSizeContext)
|
||||
const writeRaw = useContext(TerminalWriteContext)
|
||||
|
||||
// useInsertionEffect (not useLayoutEffect): react-reconciler calls
|
||||
// resetAfterCommit between the mutation and layout commit phases, and
|
||||
// Ink's resetAfterCommit triggers onRender. With useLayoutEffect, that
|
||||
// first onRender fires BEFORE this effect — writing a full frame to the
|
||||
// main screen with altScreen=false. That frame is preserved when we
|
||||
// enter alt screen and revealed on exit as a broken view. Insertion
|
||||
// effects fire during the mutation phase, before resetAfterCommit, so
|
||||
// ENTER_ALT_SCREEN reaches the terminal before the first frame does.
|
||||
// Cleanup timing is unchanged: both insertion and layout effect cleanup
|
||||
// run in the mutation phase on unmount, before resetAfterCommit.
|
||||
useInsertionEffect(() => {
|
||||
const ink = instances.get(process.stdout)
|
||||
if (!writeRaw) return
|
||||
|
||||
writeRaw(
|
||||
ENTER_ALT_SCREEN +
|
||||
'\x1b[2J\x1b[H' +
|
||||
(mouseTracking ? ENABLE_MOUSE_TRACKING : ''),
|
||||
)
|
||||
ink?.setAltScreenActive(true, mouseTracking)
|
||||
|
||||
return () => {
|
||||
ink?.setAltScreenActive(false)
|
||||
ink?.clearTextSelection()
|
||||
writeRaw((mouseTracking ? DISABLE_MOUSE_TRACKING : '') + EXIT_ALT_SCREEN)
|
||||
}
|
||||
}, [writeRaw, mouseTracking])
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
height={size?.rows ?? 24}
|
||||
width="100%"
|
||||
flexShrink={0}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
118
packages/@ant/ink/src/components/Box.tsx
Normal file
118
packages/@ant/ink/src/components/Box.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import React, { type PropsWithChildren, type Ref } from 'react'
|
||||
import type { Except } from 'type-fest'
|
||||
import type { DOMElement } from '../core/dom.js'
|
||||
import type { ClickEvent } from '../core/events/click-event.js'
|
||||
import type { FocusEvent } from '../core/events/focus-event.js'
|
||||
import type { KeyboardEvent } from '../core/events/keyboard-event.js'
|
||||
import type { Styles } from '../core/styles.js'
|
||||
import * as warn from '../core/warn.js'
|
||||
|
||||
export type Props = Except<Styles, 'textWrap'> & {
|
||||
ref?: Ref<DOMElement>
|
||||
/**
|
||||
* Tab order index. Nodes with `tabIndex >= 0` participate in
|
||||
* Tab/Shift+Tab cycling; `-1` means programmatically focusable only.
|
||||
*/
|
||||
tabIndex?: number
|
||||
/**
|
||||
* Focus this element when it mounts. Like the HTML `autofocus`
|
||||
* attribute — the FocusManager calls `focus(node)` during the
|
||||
* reconciler's `commitMount` phase.
|
||||
*/
|
||||
autoFocus?: boolean
|
||||
/**
|
||||
* Fired on left-button click (press + release without drag). Only works
|
||||
* inside `<AlternateScreen>` where mouse tracking is enabled — no-op
|
||||
* otherwise. The event bubbles from the deepest hit Box up through
|
||||
* ancestors; call `event.stopImmediatePropagation()` to stop bubbling.
|
||||
*/
|
||||
onClick?: (event: ClickEvent) => void
|
||||
onFocus?: (event: FocusEvent) => void
|
||||
onFocusCapture?: (event: FocusEvent) => void
|
||||
onBlur?: (event: FocusEvent) => void
|
||||
onBlurCapture?: (event: FocusEvent) => void
|
||||
onKeyDown?: (event: KeyboardEvent) => void
|
||||
onKeyDownCapture?: (event: KeyboardEvent) => void
|
||||
/**
|
||||
* Fired when the mouse moves into this Box's rendered rect. Like DOM
|
||||
* `mouseenter`, does NOT bubble — moving between children does not
|
||||
* re-fire on the parent. Only works inside `<AlternateScreen>` where
|
||||
* mode-1003 mouse tracking is enabled.
|
||||
*/
|
||||
onMouseEnter?: () => void
|
||||
/** Fired when the mouse moves out of this Box's rendered rect. */
|
||||
onMouseLeave?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* `<Box>` is an essential Ink component to build your layout. It's like `<div style="display: flex">` in the browser.
|
||||
*/
|
||||
function Box({
|
||||
children,
|
||||
flexWrap = 'nowrap',
|
||||
flexDirection = 'row',
|
||||
flexGrow = 0,
|
||||
flexShrink = 1,
|
||||
ref,
|
||||
tabIndex,
|
||||
autoFocus,
|
||||
onClick,
|
||||
onFocus,
|
||||
onFocusCapture,
|
||||
onBlur,
|
||||
onBlurCapture,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
onKeyDown,
|
||||
onKeyDownCapture,
|
||||
...style
|
||||
}: PropsWithChildren<Props>): React.ReactNode {
|
||||
// Warn if spacing values are not integers to prevent fractional layout dimensions
|
||||
warn.ifNotInteger(style.margin, 'margin')
|
||||
warn.ifNotInteger(style.marginX, 'marginX')
|
||||
warn.ifNotInteger(style.marginY, 'marginY')
|
||||
warn.ifNotInteger(style.marginTop, 'marginTop')
|
||||
warn.ifNotInteger(style.marginBottom, 'marginBottom')
|
||||
warn.ifNotInteger(style.marginLeft, 'marginLeft')
|
||||
warn.ifNotInteger(style.marginRight, 'marginRight')
|
||||
warn.ifNotInteger(style.padding, 'padding')
|
||||
warn.ifNotInteger(style.paddingX, 'paddingX')
|
||||
warn.ifNotInteger(style.paddingY, 'paddingY')
|
||||
warn.ifNotInteger(style.paddingTop, 'paddingTop')
|
||||
warn.ifNotInteger(style.paddingBottom, 'paddingBottom')
|
||||
warn.ifNotInteger(style.paddingLeft, 'paddingLeft')
|
||||
warn.ifNotInteger(style.paddingRight, 'paddingRight')
|
||||
warn.ifNotInteger(style.gap, 'gap')
|
||||
warn.ifNotInteger(style.columnGap, 'columnGap')
|
||||
warn.ifNotInteger(style.rowGap, 'rowGap')
|
||||
|
||||
return (
|
||||
<ink-box
|
||||
ref={ref}
|
||||
tabIndex={tabIndex}
|
||||
autoFocus={autoFocus}
|
||||
onClick={onClick}
|
||||
onFocus={onFocus}
|
||||
onFocusCapture={onFocusCapture}
|
||||
onBlur={onBlur}
|
||||
onBlurCapture={onBlurCapture}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyDownCapture={onKeyDownCapture}
|
||||
style={{
|
||||
flexWrap,
|
||||
flexDirection,
|
||||
flexGrow,
|
||||
flexShrink,
|
||||
...style,
|
||||
overflowX: style.overflowX ?? style.overflow ?? 'visible',
|
||||
overflowY: style.overflowY ?? style.overflow ?? 'visible',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ink-box>
|
||||
)
|
||||
}
|
||||
|
||||
export default Box
|
||||
122
packages/@ant/ink/src/components/Button.tsx
Normal file
122
packages/@ant/ink/src/components/Button.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import React, {
|
||||
type Ref,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import type { Except } from 'type-fest'
|
||||
import type { DOMElement } from '../core/dom.js'
|
||||
import type { ClickEvent } from '../core/events/click-event.js'
|
||||
import type { FocusEvent } from '../core/events/focus-event.js'
|
||||
import type { KeyboardEvent } from '../core/events/keyboard-event.js'
|
||||
import type { Styles } from '../core/styles.js'
|
||||
import Box from './Box.js'
|
||||
|
||||
type ButtonState = {
|
||||
focused: boolean
|
||||
hovered: boolean
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export type Props = Except<Styles, 'textWrap'> & {
|
||||
ref?: Ref<DOMElement>
|
||||
/**
|
||||
* Called when the button is activated via Enter, Space, or click.
|
||||
*/
|
||||
onAction: () => void
|
||||
/**
|
||||
* Tab order index. Defaults to 0 (in tab order).
|
||||
* Set to -1 for programmatically focusable only.
|
||||
*/
|
||||
tabIndex?: number
|
||||
/**
|
||||
* Focus this button when it mounts.
|
||||
*/
|
||||
autoFocus?: boolean
|
||||
/**
|
||||
* Render prop receiving the interactive state. Use this to
|
||||
* style children based on focus/hover/active — Button itself
|
||||
* is intentionally unstyled.
|
||||
*
|
||||
* If not provided, children render as-is (no state-dependent styling).
|
||||
*/
|
||||
children: ((state: ButtonState) => React.ReactNode) | React.ReactNode
|
||||
}
|
||||
|
||||
function Button({
|
||||
onAction,
|
||||
tabIndex = 0,
|
||||
autoFocus,
|
||||
children,
|
||||
ref,
|
||||
...style
|
||||
}: Props): React.ReactNode {
|
||||
const [isFocused, setIsFocused] = useState(false)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [isActive, setIsActive] = useState(false)
|
||||
|
||||
const activeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (activeTimer.current) clearTimeout(activeTimer.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === 'return' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setIsActive(true)
|
||||
onAction()
|
||||
if (activeTimer.current) clearTimeout(activeTimer.current)
|
||||
activeTimer.current = setTimeout(
|
||||
setter => setter(false),
|
||||
100,
|
||||
setIsActive,
|
||||
)
|
||||
}
|
||||
},
|
||||
[onAction],
|
||||
)
|
||||
|
||||
const handleClick = useCallback(
|
||||
(_e: ClickEvent) => {
|
||||
onAction()
|
||||
},
|
||||
[onAction],
|
||||
)
|
||||
|
||||
const handleFocus = useCallback((_e: FocusEvent) => setIsFocused(true), [])
|
||||
const handleBlur = useCallback((_e: FocusEvent) => setIsFocused(false), [])
|
||||
const handleMouseEnter = useCallback(() => setIsHovered(true), [])
|
||||
const handleMouseLeave = useCallback(() => setIsHovered(false), [])
|
||||
|
||||
const state: ButtonState = {
|
||||
focused: isFocused,
|
||||
hovered: isHovered,
|
||||
active: isActive,
|
||||
}
|
||||
const content = typeof children === 'function' ? children(state) : children
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
tabIndex={tabIndex}
|
||||
autoFocus={autoFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={handleClick}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
{...style}
|
||||
>
|
||||
{content}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default Button
|
||||
export type { ButtonState }
|
||||
99
packages/@ant/ink/src/components/ClockContext.tsx
Normal file
99
packages/@ant/ink/src/components/ClockContext.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React, { createContext, useEffect, useState } from 'react'
|
||||
import { FRAME_INTERVAL_MS } from '../core/constants.js'
|
||||
import { useTerminalFocus } from '../hooks/use-terminal-focus.js'
|
||||
|
||||
export type Clock = {
|
||||
subscribe: (onChange: () => void, keepAlive: boolean) => () => void
|
||||
now: () => number
|
||||
setTickInterval: (ms: number) => void
|
||||
}
|
||||
|
||||
export function createClock(tickIntervalMs: number): Clock {
|
||||
const subscribers = new Map<() => void, boolean>()
|
||||
let interval: ReturnType<typeof setInterval> | null = null
|
||||
let currentTickIntervalMs = tickIntervalMs
|
||||
let startTime = 0
|
||||
// Snapshot of the current tick's time, ensuring all subscribers in the same
|
||||
// tick see the same value (keeps animations synchronized)
|
||||
let tickTime = 0
|
||||
|
||||
function tick(): void {
|
||||
tickTime = Date.now() - startTime
|
||||
for (const onChange of subscribers.keys()) {
|
||||
onChange()
|
||||
}
|
||||
}
|
||||
|
||||
function updateInterval(): void {
|
||||
const anyKeepAlive = [...subscribers.values()].some(Boolean)
|
||||
|
||||
if (anyKeepAlive) {
|
||||
if (interval) {
|
||||
clearInterval(interval)
|
||||
interval = null
|
||||
}
|
||||
if (startTime === 0) {
|
||||
startTime = Date.now()
|
||||
}
|
||||
interval = setInterval(tick, currentTickIntervalMs)
|
||||
} else if (interval) {
|
||||
clearInterval(interval)
|
||||
interval = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe(onChange, keepAlive) {
|
||||
subscribers.set(onChange, keepAlive)
|
||||
updateInterval()
|
||||
return () => {
|
||||
subscribers.delete(onChange)
|
||||
updateInterval()
|
||||
}
|
||||
},
|
||||
|
||||
now() {
|
||||
if (startTime === 0) {
|
||||
startTime = Date.now()
|
||||
}
|
||||
// When the clock interval is running, return the synchronized tickTime
|
||||
// so all subscribers in the same tick see the same value.
|
||||
// When paused (no keepAlive subscribers), return real-time to avoid
|
||||
// returning a stale tickTime from the last tick before the pause.
|
||||
if (interval && tickTime) {
|
||||
return tickTime
|
||||
}
|
||||
return Date.now() - startTime
|
||||
},
|
||||
|
||||
setTickInterval(ms) {
|
||||
if (ms === currentTickIntervalMs) return
|
||||
currentTickIntervalMs = ms
|
||||
updateInterval()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const ClockContext = createContext<Clock | null>(null)
|
||||
|
||||
const BLURRED_TICK_INTERVAL_MS = FRAME_INTERVAL_MS * 2
|
||||
|
||||
// Own component so App.tsx doesn't re-render when the clock is created.
|
||||
// The clock value is stable (created once via useState), so the provider
|
||||
// never causes consumer re-renders on its own.
|
||||
export function ClockProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}): React.ReactNode {
|
||||
const [clock] = useState(() => createClock(FRAME_INTERVAL_MS))
|
||||
const focused = useTerminalFocus()
|
||||
|
||||
useEffect(() => {
|
||||
clock.setTickInterval(
|
||||
focused ? FRAME_INTERVAL_MS : BLURRED_TICK_INTERVAL_MS,
|
||||
)
|
||||
}, [clock, focused])
|
||||
|
||||
return <ClockContext.Provider value={clock}>{children}</ClockContext.Provider>
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createContext } from 'react'
|
||||
import type { DOMElement } from '../dom.js'
|
||||
import type { DOMElement } from '../core/dom.js'
|
||||
|
||||
export type CursorDeclaration = {
|
||||
/** Display column (terminal cell width) within the declared node */
|
||||
134
packages/@ant/ink/src/components/ErrorOverview.tsx
Normal file
134
packages/@ant/ink/src/components/ErrorOverview.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import codeExcerpt, { type CodeExcerpt } from 'code-excerpt'
|
||||
import { readFileSync } from 'fs'
|
||||
import React from 'react'
|
||||
import StackUtils from 'stack-utils'
|
||||
import Box from './Box.js'
|
||||
import Text from './Text.js'
|
||||
|
||||
/* eslint-disable custom-rules/no-process-cwd -- stack trace file:// paths are relative to the real OS cwd, not the virtual cwd */
|
||||
|
||||
// Error's source file is reported as file:///home/user/file.js
|
||||
// This function removes the file://[cwd] part
|
||||
const cleanupPath = (path: string | undefined): string | undefined => {
|
||||
return path?.replace(`file://${process.cwd()}/`, '')
|
||||
}
|
||||
|
||||
let stackUtils: StackUtils | undefined
|
||||
function getStackUtils(): StackUtils {
|
||||
return (stackUtils ??= new StackUtils({
|
||||
cwd: process.cwd(),
|
||||
internals: StackUtils.nodeInternals(),
|
||||
}))
|
||||
}
|
||||
|
||||
/* eslint-enable custom-rules/no-process-cwd */
|
||||
|
||||
type Props = {
|
||||
readonly error: Error
|
||||
}
|
||||
|
||||
export default function ErrorOverview({ error }: Props) {
|
||||
const stack = error.stack ? error.stack.split('\n').slice(1) : undefined
|
||||
const origin = stack ? getStackUtils().parseLine(stack[0]!) : undefined
|
||||
const filePath = cleanupPath(origin?.file)
|
||||
let excerpt: CodeExcerpt[] | undefined
|
||||
let lineWidth = 0
|
||||
|
||||
if (filePath && origin?.line) {
|
||||
try {
|
||||
// eslint-disable-next-line custom-rules/no-sync-fs -- sync render path; error overlay can't go async without suspense restructuring
|
||||
const sourceCode = readFileSync(filePath, 'utf8')
|
||||
excerpt = codeExcerpt(sourceCode, origin.line)
|
||||
|
||||
if (excerpt) {
|
||||
for (const { line } of excerpt) {
|
||||
lineWidth = Math.max(lineWidth, String(line).length)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// file not readable — skip source context
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Box>
|
||||
<Text backgroundColor="ansi:red" color="ansi:white">
|
||||
{' '}
|
||||
ERROR{' '}
|
||||
</Text>
|
||||
|
||||
<Text> {error.message}</Text>
|
||||
</Box>
|
||||
|
||||
{origin && filePath && (
|
||||
<Box marginTop={1}>
|
||||
<Text dim>
|
||||
{filePath}:{origin.line}:{origin.column}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{origin && excerpt && (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
{excerpt.map(({ line, value }) => (
|
||||
<Box key={line}>
|
||||
<Box width={lineWidth + 1}>
|
||||
<Text
|
||||
dim={line !== origin.line}
|
||||
backgroundColor={
|
||||
line === origin.line ? 'ansi:red' : undefined
|
||||
}
|
||||
color={line === origin.line ? 'ansi:white' : undefined}
|
||||
>
|
||||
{String(line).padStart(lineWidth, ' ')}:
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Text
|
||||
key={line}
|
||||
backgroundColor={line === origin.line ? 'ansi:red' : undefined}
|
||||
color={line === origin.line ? 'ansi:white' : undefined}
|
||||
>
|
||||
{' ' + value}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error.stack && (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
{error.stack
|
||||
.split('\n')
|
||||
.slice(1)
|
||||
.map(line => {
|
||||
const parsedLine = getStackUtils().parseLine(line)
|
||||
|
||||
// If the line from the stack cannot be parsed, we print out the unparsed line.
|
||||
if (!parsedLine) {
|
||||
return (
|
||||
<Box key={line}>
|
||||
<Text dim>- </Text>
|
||||
<Text bold>{line}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box key={line}>
|
||||
<Text dim>- </Text>
|
||||
<Text bold>{parsedLine.function}</Text>
|
||||
<Text dim>
|
||||
{' '}
|
||||
({cleanupPath(parsedLine.file) ?? ''}:{parsedLine.line}:
|
||||
{parsedLine.column})
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
31
packages/@ant/ink/src/components/Link.tsx
Normal file
31
packages/@ant/ink/src/components/Link.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import React from 'react'
|
||||
import { supportsHyperlinks } from '../core/supports-hyperlinks.js'
|
||||
import Text from './Text.js'
|
||||
|
||||
export type Props = {
|
||||
readonly children?: ReactNode
|
||||
readonly url: string
|
||||
readonly fallback?: ReactNode
|
||||
}
|
||||
|
||||
export default function Link({
|
||||
children,
|
||||
url,
|
||||
fallback,
|
||||
}: Props): React.ReactNode {
|
||||
// Use children if provided, otherwise display the URL
|
||||
const content = children ?? url
|
||||
|
||||
if (supportsHyperlinks()) {
|
||||
// Wrap in Text to ensure we're in a text context
|
||||
// (ink-link is a text element like ink-text)
|
||||
return (
|
||||
<Text>
|
||||
<ink-link href={url}>{content}</ink-link>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
return <Text>{fallback ?? content}</Text>
|
||||
}
|
||||
17
packages/@ant/ink/src/components/Newline.tsx
Normal file
17
packages/@ant/ink/src/components/Newline.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react'
|
||||
|
||||
export type Props = {
|
||||
/**
|
||||
* Number of newlines to insert.
|
||||
*
|
||||
* @default 1
|
||||
*/
|
||||
readonly count?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds one or more newline (\n) characters. Must be used within <Text> components.
|
||||
*/
|
||||
export default function Newline({ count = 1 }: Props) {
|
||||
return <ink-text>{'\n'.repeat(count)}</ink-text>
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import React, { type PropsWithChildren } from 'react';
|
||||
import Box, { type Props as BoxProps } from './Box.js';
|
||||
import React, { type PropsWithChildren } from 'react'
|
||||
import Box, { type Props as BoxProps } from './Box.js'
|
||||
|
||||
type Props = Omit<BoxProps, 'noSelect'> & {
|
||||
/**
|
||||
* Extend the exclusion zone from column 0 to this box's right edge,
|
||||
@@ -11,8 +11,8 @@ type Props = Omit<BoxProps, 'noSelect'> & {
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
fromLeftEdge?: boolean;
|
||||
};
|
||||
fromLeftEdge?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks its contents as non-selectable in fullscreen text selection.
|
||||
@@ -32,36 +32,14 @@ type Props = Omit<BoxProps, 'noSelect'> & {
|
||||
* tracking). No-op in the main-screen scrollback render where the
|
||||
* terminal's native selection is used instead.
|
||||
*/
|
||||
export function NoSelect(t0) {
|
||||
const $ = _c(8);
|
||||
let boxProps;
|
||||
let children;
|
||||
let fromLeftEdge;
|
||||
if ($[0] !== t0) {
|
||||
({
|
||||
children,
|
||||
fromLeftEdge,
|
||||
...boxProps
|
||||
} = t0);
|
||||
$[0] = t0;
|
||||
$[1] = boxProps;
|
||||
$[2] = children;
|
||||
$[3] = fromLeftEdge;
|
||||
} else {
|
||||
boxProps = $[1];
|
||||
children = $[2];
|
||||
fromLeftEdge = $[3];
|
||||
}
|
||||
const t1 = fromLeftEdge ? "from-left-edge" : true;
|
||||
let t2;
|
||||
if ($[4] !== boxProps || $[5] !== children || $[6] !== t1) {
|
||||
t2 = <Box {...boxProps} noSelect={t1}>{children}</Box>;
|
||||
$[4] = boxProps;
|
||||
$[5] = children;
|
||||
$[6] = t1;
|
||||
$[7] = t2;
|
||||
} else {
|
||||
t2 = $[7];
|
||||
}
|
||||
return t2;
|
||||
export function NoSelect({
|
||||
children,
|
||||
fromLeftEdge,
|
||||
...boxProps
|
||||
}: PropsWithChildren<Props>): React.ReactNode {
|
||||
return (
|
||||
<Box {...boxProps} noSelect={fromLeftEdge ? 'from-left-edge' : true}>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import React from 'react';
|
||||
import React from 'react'
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* Pre-rendered ANSI lines. Each element must be exactly one terminal row
|
||||
* (already wrapped to `width` by the producer) with ANSI escape codes inline.
|
||||
*/
|
||||
lines: string[];
|
||||
lines: string[]
|
||||
/** Column width the producer wrapped to. Sent to Yoga as the fixed leaf width. */
|
||||
width: number;
|
||||
};
|
||||
width: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Bypass the <Ansi> → React tree → Yoga → squash → re-serialize roundtrip for
|
||||
@@ -25,32 +25,15 @@ type Props = {
|
||||
* (width × lines.length) and hands the joined string straight to output.write(),
|
||||
* which already splits on '\n' and parses ANSI into the screen buffer.
|
||||
*/
|
||||
export function RawAnsi(t0) {
|
||||
const $ = _c(6);
|
||||
const {
|
||||
lines,
|
||||
width
|
||||
} = t0;
|
||||
export function RawAnsi({ lines, width }: Props): React.ReactNode {
|
||||
if (lines.length === 0) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
let t1;
|
||||
if ($[0] !== lines) {
|
||||
t1 = lines.join("\n");
|
||||
$[0] = lines;
|
||||
$[1] = t1;
|
||||
} else {
|
||||
t1 = $[1];
|
||||
}
|
||||
let t2;
|
||||
if ($[2] !== lines.length || $[3] !== t1 || $[4] !== width) {
|
||||
t2 = <ink-raw-ansi rawText={t1} rawWidth={width} rawHeight={lines.length} />;
|
||||
$[2] = lines.length;
|
||||
$[3] = t1;
|
||||
$[4] = width;
|
||||
$[5] = t2;
|
||||
} else {
|
||||
t2 = $[5];
|
||||
}
|
||||
return t2;
|
||||
return (
|
||||
<ink-raw-ansi
|
||||
rawText={lines.join('\n')}
|
||||
rawWidth={width}
|
||||
rawHeight={lines.length}
|
||||
/>
|
||||
)
|
||||
}
|
||||
257
packages/@ant/ink/src/components/ScrollBox.tsx
Normal file
257
packages/@ant/ink/src/components/ScrollBox.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import React, {
|
||||
type PropsWithChildren,
|
||||
type Ref,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import type { Except } from 'type-fest'
|
||||
import type { DOMElement } from '../core/dom.js'
|
||||
import { markDirty, scheduleRenderFrom } from '../core/dom.js'
|
||||
import { markCommitStart } from '../core/reconciler.js'
|
||||
import type { Styles } from '../core/styles.js'
|
||||
import Box from './Box.js'
|
||||
|
||||
export type ScrollBoxHandle = {
|
||||
scrollTo: (y: number) => void
|
||||
scrollBy: (dy: number) => void
|
||||
/**
|
||||
* Scroll so `el`'s top is at the viewport top (plus `offset`). Unlike
|
||||
* scrollTo which bakes a number that's stale by the time the throttled
|
||||
* render fires, this defers the position read to render time —
|
||||
* render-node-to-output reads `el.yogaNode.getComputedTop()` in the
|
||||
* SAME Yoga pass that computes scrollHeight. Deterministic. One-shot.
|
||||
*/
|
||||
scrollToElement: (el: DOMElement, offset?: number) => void
|
||||
scrollToBottom: () => void
|
||||
getScrollTop: () => number
|
||||
getPendingDelta: () => number
|
||||
getScrollHeight: () => number
|
||||
/**
|
||||
* Like getScrollHeight, but reads Yoga directly instead of the cached
|
||||
* value written by render-node-to-output (throttled, up to 16ms stale).
|
||||
* Use when you need a fresh value in useLayoutEffect after a React commit
|
||||
* that grew content. Slightly more expensive (native Yoga call).
|
||||
*/
|
||||
getFreshScrollHeight: () => number
|
||||
getViewportHeight: () => number
|
||||
/**
|
||||
* Absolute screen-buffer row of the first visible content line (inside
|
||||
* padding). Used for drag-to-scroll edge detection.
|
||||
*/
|
||||
getViewportTop: () => number
|
||||
/**
|
||||
* True when scroll is pinned to the bottom. Set by scrollToBottom, the
|
||||
* initial stickyScroll attribute, and by the renderer when positional
|
||||
* follow fires (scrollTop at prevMax, content grows). Cleared by
|
||||
* scrollTo/scrollBy. Stable signal for "at bottom" that doesn't depend on
|
||||
* layout values (unlike scrollTop+viewportH >= scrollHeight).
|
||||
*/
|
||||
isSticky: () => boolean
|
||||
/**
|
||||
* Subscribe to imperative scroll changes (scrollTo/scrollBy/scrollToBottom).
|
||||
* Does NOT fire for stickyScroll updates done by the Ink renderer — those
|
||||
* happen during Ink's render phase after React has committed. Callers that
|
||||
* care about the sticky case should treat "at bottom" as a fallback.
|
||||
*/
|
||||
subscribe: (listener: () => void) => () => void
|
||||
/**
|
||||
* Set the render-time scrollTop clamp to the currently-mounted children's
|
||||
* coverage span. Called by useVirtualScroll after computing its range;
|
||||
* render-node-to-output clamps scrollTop to [min, max] so burst scrollTo
|
||||
* calls that race past React's async re-render show the edge of mounted
|
||||
* content instead of blank spacer. Pass undefined to disable (sticky,
|
||||
* cold start).
|
||||
*/
|
||||
setClampBounds: (min: number | undefined, max: number | undefined) => void
|
||||
}
|
||||
|
||||
export type ScrollBoxProps = Except<
|
||||
Styles,
|
||||
'textWrap' | 'overflow' | 'overflowX' | 'overflowY'
|
||||
> & {
|
||||
ref?: Ref<ScrollBoxHandle>
|
||||
/**
|
||||
* When true, automatically pins scroll position to the bottom when content
|
||||
* grows. Unset manually via scrollTo/scrollBy to break the stickiness.
|
||||
*/
|
||||
stickyScroll?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A Box with `overflow: scroll` and an imperative scroll API.
|
||||
*
|
||||
* Children are laid out at their full Yoga-computed height inside a
|
||||
* constrained container. At render time, only children intersecting the
|
||||
* visible window (scrollTop..scrollTop+height) are rendered (viewport
|
||||
* culling). Content is translated by -scrollTop and clipped to the box bounds.
|
||||
*
|
||||
* Works best inside a fullscreen (constrained-height root) Ink tree.
|
||||
*/
|
||||
function ScrollBox({
|
||||
children,
|
||||
ref,
|
||||
stickyScroll,
|
||||
...style
|
||||
}: PropsWithChildren<ScrollBoxProps>): React.ReactNode {
|
||||
const domRef = useRef<DOMElement>(null)
|
||||
// scrollTo/scrollBy bypass React: they mutate scrollTop on the DOM node,
|
||||
// mark it dirty, and call the root's throttled scheduleRender directly.
|
||||
// The Ink renderer reads scrollTop from the node — no React state needed,
|
||||
// no reconciler overhead per wheel event. The microtask defer coalesces
|
||||
// multiple scrollBy calls in one input batch (discreteUpdates) into one
|
||||
// render — otherwise scheduleRender's leading edge fires on the FIRST
|
||||
// event before subsequent events mutate scrollTop. scrollToBottom still
|
||||
// forces a React render: sticky is attribute-observed, no DOM-only path.
|
||||
const [, forceRender] = useState(0)
|
||||
const listenersRef = useRef(new Set<() => void>())
|
||||
const renderQueuedRef = useRef(false)
|
||||
|
||||
const notify = () => {
|
||||
for (const l of listenersRef.current) l()
|
||||
}
|
||||
|
||||
function scrollMutated(el: DOMElement): void {
|
||||
// Signal background intervals (IDE poll, LSP poll, GCS fetch, orphan
|
||||
// check) to skip their next tick — they compete for the event loop and
|
||||
// contributed to 1402ms max frame gaps during scroll drain.
|
||||
// noop — injected by business layer via onScrollActivity callback
|
||||
markDirty(el)
|
||||
markCommitStart()
|
||||
notify()
|
||||
if (renderQueuedRef.current) return
|
||||
renderQueuedRef.current = true
|
||||
queueMicrotask(() => {
|
||||
renderQueuedRef.current = false
|
||||
scheduleRenderFrom(el)
|
||||
})
|
||||
}
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
(): ScrollBoxHandle => ({
|
||||
scrollTo(y: number) {
|
||||
const el = domRef.current
|
||||
if (!el) return
|
||||
// Explicit false overrides the DOM attribute so manual scroll
|
||||
// breaks stickiness. Render code checks ?? precedence.
|
||||
el.stickyScroll = false
|
||||
el.pendingScrollDelta = undefined
|
||||
el.scrollAnchor = undefined
|
||||
el.scrollTop = Math.max(0, Math.floor(y))
|
||||
scrollMutated(el)
|
||||
},
|
||||
scrollToElement(el: DOMElement, offset = 0) {
|
||||
const box = domRef.current
|
||||
if (!box) return
|
||||
box.stickyScroll = false
|
||||
box.pendingScrollDelta = undefined
|
||||
box.scrollAnchor = { el, offset }
|
||||
scrollMutated(box)
|
||||
},
|
||||
scrollBy(dy: number) {
|
||||
const el = domRef.current
|
||||
if (!el) return
|
||||
el.stickyScroll = false
|
||||
// Wheel input cancels any in-flight anchor seek — user override.
|
||||
el.scrollAnchor = undefined
|
||||
// Accumulate in pendingScrollDelta; renderer drains it at a capped
|
||||
// rate so fast flicks show intermediate frames. Pure accumulator:
|
||||
// scroll-up followed by scroll-down naturally cancels.
|
||||
el.pendingScrollDelta = (el.pendingScrollDelta ?? 0) + Math.floor(dy)
|
||||
scrollMutated(el)
|
||||
},
|
||||
scrollToBottom() {
|
||||
const el = domRef.current
|
||||
if (!el) return
|
||||
el.pendingScrollDelta = undefined
|
||||
el.stickyScroll = true
|
||||
markDirty(el)
|
||||
notify()
|
||||
forceRender(n => n + 1)
|
||||
},
|
||||
getScrollTop() {
|
||||
return domRef.current?.scrollTop ?? 0
|
||||
},
|
||||
getPendingDelta() {
|
||||
// Accumulated-but-not-yet-drained delta. useVirtualScroll needs
|
||||
// this to mount the union [committed, committed+pending] range —
|
||||
// otherwise intermediate drain frames find no children (blank).
|
||||
return domRef.current?.pendingScrollDelta ?? 0
|
||||
},
|
||||
getScrollHeight() {
|
||||
return domRef.current?.scrollHeight ?? 0
|
||||
},
|
||||
getFreshScrollHeight() {
|
||||
const content = domRef.current?.childNodes[0] as DOMElement | undefined
|
||||
return (
|
||||
content?.yogaNode?.getComputedHeight() ??
|
||||
domRef.current?.scrollHeight ??
|
||||
0
|
||||
)
|
||||
},
|
||||
getViewportHeight() {
|
||||
return domRef.current?.scrollViewportHeight ?? 0
|
||||
},
|
||||
getViewportTop() {
|
||||
return domRef.current?.scrollViewportTop ?? 0
|
||||
},
|
||||
isSticky() {
|
||||
const el = domRef.current
|
||||
if (!el) return false
|
||||
return el.stickyScroll ?? Boolean(el.attributes['stickyScroll'])
|
||||
},
|
||||
subscribe(listener: () => void) {
|
||||
listenersRef.current.add(listener)
|
||||
return () => listenersRef.current.delete(listener)
|
||||
},
|
||||
setClampBounds(min, max) {
|
||||
const el = domRef.current
|
||||
if (!el) return
|
||||
el.scrollClampMin = min
|
||||
el.scrollClampMax = max
|
||||
},
|
||||
}),
|
||||
// notify/scrollMutated are inline (no useCallback) but only close over
|
||||
// refs + imports — stable. Empty deps avoids rebuilding the handle on
|
||||
// every render (which re-registers the ref = churn).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
)
|
||||
|
||||
// Structure: outer viewport (overflow:scroll, constrained height) >
|
||||
// inner content (flexGrow:1, flexShrink:0 — fills at least the viewport
|
||||
// but grows beyond it for tall content). flexGrow:1 lets children use
|
||||
// spacers to pin elements to the bottom of the scroll area. Yoga's
|
||||
// Overflow.Scroll prevents the viewport from growing to fit the content.
|
||||
// The renderer computes scrollHeight from the content box and culls
|
||||
// content's children based on scrollTop.
|
||||
//
|
||||
// stickyScroll is passed as a DOM attribute (via ink-box directly) so it's
|
||||
// available on the first render — ref callbacks fire after the initial
|
||||
// commit, which is too late for the first frame.
|
||||
return (
|
||||
<ink-box
|
||||
ref={el => {
|
||||
domRef.current = el
|
||||
if (el) el.scrollTop ??= 0
|
||||
}}
|
||||
style={{
|
||||
flexWrap: 'nowrap',
|
||||
flexDirection: style.flexDirection ?? 'row',
|
||||
flexGrow: style.flexGrow ?? 0,
|
||||
flexShrink: style.flexShrink ?? 1,
|
||||
...style,
|
||||
overflowX: 'scroll',
|
||||
overflowY: 'scroll',
|
||||
}}
|
||||
{...(stickyScroll ? { stickyScroll: true } : {})}
|
||||
>
|
||||
<Box flexDirection="column" flexGrow={1} flexShrink={0} width="100%">
|
||||
{children}
|
||||
</Box>
|
||||
</ink-box>
|
||||
)
|
||||
}
|
||||
|
||||
export default ScrollBox
|
||||
10
packages/@ant/ink/src/components/Spacer.tsx
Normal file
10
packages/@ant/ink/src/components/Spacer.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import Box from './Box.js'
|
||||
|
||||
/**
|
||||
* A flexible space that expands along the major axis of its containing layout.
|
||||
* It's useful as a shortcut for filling all the available spaces between elements.
|
||||
*/
|
||||
export default function Spacer() {
|
||||
return <Box flexGrow={1} />
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createContext } from 'react'
|
||||
import { EventEmitter } from '../events/emitter.js'
|
||||
import type { TerminalQuerier } from '../terminal-querier.js'
|
||||
import { EventEmitter } from '../core/events/emitter.js'
|
||||
import type { TerminalQuerier } from '../core/terminal-querier.js'
|
||||
|
||||
export type Props = {
|
||||
/**
|
||||
53
packages/@ant/ink/src/components/TerminalFocusContext.tsx
Normal file
53
packages/@ant/ink/src/components/TerminalFocusContext.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React, { createContext, useMemo, useSyncExternalStore } from 'react'
|
||||
import {
|
||||
getTerminalFocused,
|
||||
getTerminalFocusState,
|
||||
subscribeTerminalFocus,
|
||||
type TerminalFocusState,
|
||||
} from '../core/terminal-focus-state.js'
|
||||
|
||||
export type { TerminalFocusState }
|
||||
|
||||
export type TerminalFocusContextProps = {
|
||||
readonly isTerminalFocused: boolean
|
||||
readonly terminalFocusState: TerminalFocusState
|
||||
}
|
||||
|
||||
const TerminalFocusContext = createContext<TerminalFocusContextProps>({
|
||||
isTerminalFocused: true,
|
||||
terminalFocusState: 'unknown',
|
||||
})
|
||||
|
||||
// eslint-disable-next-line custom-rules/no-top-level-side-effects
|
||||
TerminalFocusContext.displayName = 'TerminalFocusContext'
|
||||
|
||||
// Separate component so App.tsx doesn't re-render on focus changes.
|
||||
// Children are a stable prop reference, so they don't re-render either —
|
||||
// only components that consume the context will re-render.
|
||||
export function TerminalFocusProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}): React.ReactNode {
|
||||
const isTerminalFocused = useSyncExternalStore(
|
||||
subscribeTerminalFocus,
|
||||
getTerminalFocused,
|
||||
)
|
||||
const terminalFocusState = useSyncExternalStore(
|
||||
subscribeTerminalFocus,
|
||||
getTerminalFocusState,
|
||||
)
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ isTerminalFocused, terminalFocusState }),
|
||||
[isTerminalFocused, terminalFocusState],
|
||||
)
|
||||
|
||||
return (
|
||||
<TerminalFocusContext.Provider value={value}>
|
||||
{children}
|
||||
</TerminalFocusContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default TerminalFocusContext
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createContext } from 'react';
|
||||
import { createContext } from 'react'
|
||||
|
||||
export type TerminalSize = {
|
||||
columns: number;
|
||||
rows: number;
|
||||
};
|
||||
export const TerminalSizeContext = createContext<TerminalSize | null>(null);
|
||||
columns: number
|
||||
rows: number
|
||||
}
|
||||
|
||||
export const TerminalSizeContext = createContext<TerminalSize | null>(null)
|
||||
144
packages/@ant/ink/src/components/Text.tsx
Normal file
144
packages/@ant/ink/src/components/Text.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import React from 'react'
|
||||
import type { Color, Styles, TextStyles } from '../core/styles.js'
|
||||
|
||||
type BaseProps = {
|
||||
/**
|
||||
* Change text color. Accepts a raw color value (rgb, hex, ansi).
|
||||
*/
|
||||
readonly color?: Color
|
||||
|
||||
/**
|
||||
* Same as `color`, but for background.
|
||||
*/
|
||||
readonly backgroundColor?: Color
|
||||
|
||||
/**
|
||||
* Make the text italic.
|
||||
*/
|
||||
readonly italic?: boolean
|
||||
|
||||
/**
|
||||
* Make the text underlined.
|
||||
*/
|
||||
readonly underline?: boolean
|
||||
|
||||
/**
|
||||
* Make the text crossed with a line.
|
||||
*/
|
||||
readonly strikethrough?: boolean
|
||||
|
||||
/**
|
||||
* Inverse background and foreground colors.
|
||||
*/
|
||||
readonly inverse?: boolean
|
||||
|
||||
/**
|
||||
* This property tells Ink to wrap or truncate text if its width is larger than container.
|
||||
* If `wrap` is passed (by default), Ink will wrap text and split it into multiple lines.
|
||||
* If `truncate-*` is passed, Ink will truncate text instead, which will result in one line of text with the rest cut off.
|
||||
*/
|
||||
readonly wrap?: Styles['textWrap']
|
||||
|
||||
readonly children?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Bold and dim are mutually exclusive in terminals.
|
||||
* This type ensures you can use one or the other, but not both.
|
||||
*/
|
||||
type WeightProps =
|
||||
| { bold?: never; dim?: never }
|
||||
| { bold: boolean; dim?: never }
|
||||
| { dim: boolean; bold?: never }
|
||||
|
||||
export type Props = BaseProps & WeightProps
|
||||
|
||||
const memoizedStylesForWrap: Record<NonNullable<Styles['textWrap']>, Styles> = {
|
||||
wrap: {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
flexDirection: 'row',
|
||||
textWrap: 'wrap',
|
||||
},
|
||||
'wrap-trim': {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
flexDirection: 'row',
|
||||
textWrap: 'wrap-trim',
|
||||
},
|
||||
end: {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
flexDirection: 'row',
|
||||
textWrap: 'end',
|
||||
},
|
||||
middle: {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
flexDirection: 'row',
|
||||
textWrap: 'middle',
|
||||
},
|
||||
'truncate-end': {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
flexDirection: 'row',
|
||||
textWrap: 'truncate-end',
|
||||
},
|
||||
truncate: {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
flexDirection: 'row',
|
||||
textWrap: 'truncate',
|
||||
},
|
||||
'truncate-middle': {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
flexDirection: 'row',
|
||||
textWrap: 'truncate-middle',
|
||||
},
|
||||
'truncate-start': {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
flexDirection: 'row',
|
||||
textWrap: 'truncate-start',
|
||||
},
|
||||
} as const
|
||||
|
||||
/**
|
||||
* This component can display text, and change its style to make it colorful, bold, underline, italic or strikethrough.
|
||||
*/
|
||||
export default function Text({
|
||||
color,
|
||||
backgroundColor,
|
||||
bold,
|
||||
dim,
|
||||
italic = false,
|
||||
underline = false,
|
||||
strikethrough = false,
|
||||
inverse = false,
|
||||
wrap = 'wrap',
|
||||
children,
|
||||
}: Props): React.ReactNode {
|
||||
if (children === undefined || children === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Build textStyles object with only the properties that are set
|
||||
const textStyles: TextStyles = {
|
||||
...(color && { color }),
|
||||
...(backgroundColor && { backgroundColor }),
|
||||
...(dim && { dim }),
|
||||
...(bold && { bold }),
|
||||
...(italic && { italic }),
|
||||
...(underline && { underline }),
|
||||
...(strikethrough && { strikethrough }),
|
||||
...(inverse && { inverse }),
|
||||
}
|
||||
|
||||
return (
|
||||
<ink-text style={memoizedStylesForWrap[wrap]} textStyles={textStyles}>
|
||||
{children}
|
||||
</ink-text>
|
||||
)
|
||||
}
|
||||
307
packages/@ant/ink/src/core/Ansi.tsx
Normal file
307
packages/@ant/ink/src/core/Ansi.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
import React from 'react'
|
||||
import Link from '../components/Link.js'
|
||||
import Text from '../components/Text.js'
|
||||
import type { Color } from './styles.js'
|
||||
import {
|
||||
type NamedColor,
|
||||
Parser,
|
||||
type Color as TermioColor,
|
||||
type TextStyle,
|
||||
} from './termio.js'
|
||||
|
||||
type Props = {
|
||||
children: string
|
||||
/** When true, force all text to be rendered with dim styling */
|
||||
dimColor?: boolean
|
||||
}
|
||||
|
||||
type SpanProps = {
|
||||
color?: Color
|
||||
backgroundColor?: Color
|
||||
dim?: boolean
|
||||
bold?: boolean
|
||||
italic?: boolean
|
||||
underline?: boolean
|
||||
strikethrough?: boolean
|
||||
inverse?: boolean
|
||||
hyperlink?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that parses ANSI escape codes and renders them using Text components.
|
||||
*
|
||||
* Use this as an escape hatch when you have pre-formatted ANSI strings from
|
||||
* external tools (like cli-highlight) that need to be rendered in Ink.
|
||||
*
|
||||
* Memoized to prevent re-renders when parent changes but children string is the same.
|
||||
*/
|
||||
export const Ansi = React.memo(function Ansi({
|
||||
children,
|
||||
dimColor,
|
||||
}: Props): React.ReactNode {
|
||||
if (typeof children !== 'string') {
|
||||
return dimColor ? (
|
||||
<Text dim>{String(children)}</Text>
|
||||
) : (
|
||||
<Text>{String(children)}</Text>
|
||||
)
|
||||
}
|
||||
|
||||
if (children === '') {
|
||||
return null
|
||||
}
|
||||
|
||||
const spans = parseToSpans(children)
|
||||
|
||||
if (spans.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (spans.length === 1 && !hasAnyProps(spans[0]!.props)) {
|
||||
return dimColor ? (
|
||||
<Text dim>{spans[0]!.text}</Text>
|
||||
) : (
|
||||
<Text>{spans[0]!.text}</Text>
|
||||
)
|
||||
}
|
||||
|
||||
const content = spans.map((span, i) => {
|
||||
const hyperlink = span.props.hyperlink
|
||||
// When dimColor is forced, override the span's dim prop
|
||||
if (dimColor) {
|
||||
span.props.dim = true
|
||||
}
|
||||
const hasTextProps = hasAnyTextProps(span.props)
|
||||
|
||||
if (hyperlink) {
|
||||
return hasTextProps ? (
|
||||
<Link key={i} url={hyperlink}>
|
||||
<StyledText
|
||||
color={span.props.color}
|
||||
backgroundColor={span.props.backgroundColor}
|
||||
dim={span.props.dim}
|
||||
bold={span.props.bold}
|
||||
italic={span.props.italic}
|
||||
underline={span.props.underline}
|
||||
strikethrough={span.props.strikethrough}
|
||||
inverse={span.props.inverse}
|
||||
>
|
||||
{span.text}
|
||||
</StyledText>
|
||||
</Link>
|
||||
) : (
|
||||
<Link key={i} url={hyperlink}>
|
||||
{span.text}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return hasTextProps ? (
|
||||
<StyledText
|
||||
key={i}
|
||||
color={span.props.color}
|
||||
backgroundColor={span.props.backgroundColor}
|
||||
dim={span.props.dim}
|
||||
bold={span.props.bold}
|
||||
italic={span.props.italic}
|
||||
underline={span.props.underline}
|
||||
strikethrough={span.props.strikethrough}
|
||||
inverse={span.props.inverse}
|
||||
>
|
||||
{span.text}
|
||||
</StyledText>
|
||||
) : (
|
||||
span.text
|
||||
)
|
||||
})
|
||||
|
||||
return dimColor ? <Text dim>{content}</Text> : <Text>{content}</Text>
|
||||
})
|
||||
|
||||
type Span = {
|
||||
text: string
|
||||
props: SpanProps
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an ANSI string into spans using the termio parser.
|
||||
*/
|
||||
function parseToSpans(input: string): Span[] {
|
||||
const parser = new Parser()
|
||||
const actions = parser.feed(input)
|
||||
const spans: Span[] = []
|
||||
|
||||
let currentHyperlink: string | undefined
|
||||
|
||||
for (const action of actions) {
|
||||
if (action.type === 'link') {
|
||||
if (action.action.type === 'start') {
|
||||
currentHyperlink = action.action.url
|
||||
} else {
|
||||
currentHyperlink = undefined
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (action.type === 'text') {
|
||||
const text = action.graphemes.map(g => g.value).join('')
|
||||
if (!text) continue
|
||||
|
||||
const props = textStyleToSpanProps(action.style)
|
||||
if (currentHyperlink) {
|
||||
props.hyperlink = currentHyperlink
|
||||
}
|
||||
|
||||
// Try to merge with previous span if props match
|
||||
const lastSpan = spans[spans.length - 1]
|
||||
if (lastSpan && propsEqual(lastSpan.props, props)) {
|
||||
lastSpan.text += text
|
||||
} else {
|
||||
spans.push({ text, props })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return spans
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert termio's TextStyle to SpanProps.
|
||||
*/
|
||||
function textStyleToSpanProps(style: TextStyle): SpanProps {
|
||||
const props: SpanProps = {}
|
||||
|
||||
if (style.bold) props.bold = true
|
||||
if (style.dim) props.dim = true
|
||||
if (style.italic) props.italic = true
|
||||
if (style.underline !== 'none') props.underline = true
|
||||
if (style.strikethrough) props.strikethrough = true
|
||||
if (style.inverse) props.inverse = true
|
||||
|
||||
const fgColor = colorToString(style.fg)
|
||||
if (fgColor) props.color = fgColor
|
||||
|
||||
const bgColor = colorToString(style.bg)
|
||||
if (bgColor) props.backgroundColor = bgColor
|
||||
|
||||
return props
|
||||
}
|
||||
|
||||
// Map termio named colors to the ansi: format
|
||||
const NAMED_COLOR_MAP: Record<NamedColor, string> = {
|
||||
black: 'ansi:black',
|
||||
red: 'ansi:red',
|
||||
green: 'ansi:green',
|
||||
yellow: 'ansi:yellow',
|
||||
blue: 'ansi:blue',
|
||||
magenta: 'ansi:magenta',
|
||||
cyan: 'ansi:cyan',
|
||||
white: 'ansi:white',
|
||||
brightBlack: 'ansi:blackBright',
|
||||
brightRed: 'ansi:redBright',
|
||||
brightGreen: 'ansi:greenBright',
|
||||
brightYellow: 'ansi:yellowBright',
|
||||
brightBlue: 'ansi:blueBright',
|
||||
brightMagenta: 'ansi:magentaBright',
|
||||
brightCyan: 'ansi:cyanBright',
|
||||
brightWhite: 'ansi:whiteBright',
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert termio's Color to the string format used by Ink.
|
||||
*/
|
||||
function colorToString(color: TermioColor): Color | undefined {
|
||||
switch (color.type) {
|
||||
case 'named':
|
||||
return NAMED_COLOR_MAP[color.name] as Color
|
||||
case 'indexed':
|
||||
return `ansi256(${color.index})` as Color
|
||||
case 'rgb':
|
||||
return `rgb(${color.r},${color.g},${color.b})` as Color
|
||||
case 'default':
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two SpanProps are equal for merging.
|
||||
*/
|
||||
function propsEqual(a: SpanProps, b: SpanProps): boolean {
|
||||
return (
|
||||
a.color === b.color &&
|
||||
a.backgroundColor === b.backgroundColor &&
|
||||
a.bold === b.bold &&
|
||||
a.dim === b.dim &&
|
||||
a.italic === b.italic &&
|
||||
a.underline === b.underline &&
|
||||
a.strikethrough === b.strikethrough &&
|
||||
a.inverse === b.inverse &&
|
||||
a.hyperlink === b.hyperlink
|
||||
)
|
||||
}
|
||||
|
||||
function hasAnyProps(props: SpanProps): boolean {
|
||||
return (
|
||||
props.color !== undefined ||
|
||||
props.backgroundColor !== undefined ||
|
||||
props.dim === true ||
|
||||
props.bold === true ||
|
||||
props.italic === true ||
|
||||
props.underline === true ||
|
||||
props.strikethrough === true ||
|
||||
props.inverse === true ||
|
||||
props.hyperlink !== undefined
|
||||
)
|
||||
}
|
||||
|
||||
function hasAnyTextProps(props: SpanProps): boolean {
|
||||
return (
|
||||
props.color !== undefined ||
|
||||
props.backgroundColor !== undefined ||
|
||||
props.dim === true ||
|
||||
props.bold === true ||
|
||||
props.italic === true ||
|
||||
props.underline === true ||
|
||||
props.strikethrough === true ||
|
||||
props.inverse === true
|
||||
)
|
||||
}
|
||||
|
||||
// Text style props without weight (bold/dim) - these are handled separately
|
||||
type BaseTextStyleProps = {
|
||||
color?: Color
|
||||
backgroundColor?: Color
|
||||
italic?: boolean
|
||||
underline?: boolean
|
||||
strikethrough?: boolean
|
||||
inverse?: boolean
|
||||
}
|
||||
|
||||
// Wrapper component that handles bold/dim mutual exclusivity for Text
|
||||
function StyledText({
|
||||
bold,
|
||||
dim,
|
||||
children,
|
||||
...rest
|
||||
}: BaseTextStyleProps & {
|
||||
bold?: boolean
|
||||
dim?: boolean
|
||||
children: string
|
||||
}): React.ReactNode {
|
||||
// dim takes precedence over bold when both are set (terminals treat them as mutually exclusive)
|
||||
if (dim) {
|
||||
return (
|
||||
<Text {...rest} dim>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
if (bold) {
|
||||
return (
|
||||
<Text {...rest} bold>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
return <Text {...rest}>{children}</Text>
|
||||
}
|
||||
@@ -4,10 +4,14 @@ import {
|
||||
DiscreteEventPriority,
|
||||
NoEventPriority,
|
||||
} from 'react-reconciler/constants.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
import { HANDLER_FOR_EVENT } from './event-handlers.js'
|
||||
import type { EventTarget, TerminalEvent } from './terminal-event.js'
|
||||
|
||||
// logError stub — replaced from business dependency; use injected logger in production
|
||||
const logError = (error: unknown): void => {
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
// --
|
||||
|
||||
type DispatchListener = {
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user