Compare commits
29 Commits
cleanup-in
...
fix/386-wi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1137b9a037 | ||
|
|
54e6df58eb | ||
|
|
7f432fe87d | ||
|
|
7350a798cb | ||
|
|
5ef79546e9 | ||
|
|
daa3aa27a0 | ||
|
|
5ff34283c4 | ||
|
|
d1a2df2f69 | ||
|
|
ba1b9913aa | ||
|
|
0d27ca596a | ||
|
|
8fc40ee8c4 | ||
|
|
2f162af60c | ||
|
|
9e84d2fddc | ||
|
|
75d2543854 | ||
|
|
01acc4c10e | ||
|
|
e4cf810e14 | ||
|
|
0951c8bc59 | ||
|
|
4c3118e071 | ||
|
|
80a2f1414c | ||
|
|
462a985d7e | ||
|
|
ef881b247f | ||
|
|
a0bdab24c0 | ||
|
|
cdc92d16e4 | ||
|
|
fbf3385395 | ||
|
|
ea335aeddc | ||
|
|
280c9732f5 | ||
|
|
08be5181ab | ||
|
|
b4725c19e0 | ||
|
|
3c2e80a1ae |
7
.github/workflows/pr-checks.yml
vendored
7
.github/workflows/pr-checks.yml
vendored
@@ -16,6 +16,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
@@ -33,6 +35,11 @@ jobs:
|
|||||||
- name: Smoke check
|
- name: Smoke check
|
||||||
run: bun run smoke
|
run: bun run smoke
|
||||||
|
|
||||||
|
- name: Full unit test suite
|
||||||
|
run: bun test --max-concurrency=1
|
||||||
|
|
||||||
|
- name: Suspicious PR intent scan
|
||||||
|
run: bun run security:pr-scan -- --base ${{ github.event.pull_request.base.sha || 'origin/main' }}
|
||||||
- name: Provider tests
|
- name: Provider tests
|
||||||
run: bun run test:provider
|
run: bun run test:provider
|
||||||
|
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,3 +6,4 @@ dist/
|
|||||||
!.env.example
|
!.env.example
|
||||||
.openclaude-profile.json
|
.openclaude-profile.json
|
||||||
reports/
|
reports/
|
||||||
|
coverage/
|
||||||
|
|||||||
180
README.md
180
README.md
@@ -1,33 +1,24 @@
|
|||||||
# OpenClaude
|
# OpenClaude
|
||||||
|
|
||||||
OpenClaude is an open-source coding-agent CLI that works with more than one model provider.
|
OpenClaude is an open-source coding-agent CLI for cloud and local model providers.
|
||||||
|
|
||||||
Use OpenAI-compatible APIs, Gemini, GitHub Models, Codex, Ollama, Atomic Chat, and other supported backends while keeping the same terminal-first workflow: prompts, tools, agents, MCP, slash commands, and streaming output.
|
Use OpenAI-compatible APIs, Gemini, GitHub Models, Codex, Ollama, Atomic Chat, and other supported backends while keeping one terminal-first workflow: prompts, tools, agents, MCP, slash commands, and streaming output.
|
||||||
|
|
||||||
|
[](https://github.com/Gitlawb/openclaude/actions/workflows/pr-checks.yml)
|
||||||
|
[](https://github.com/Gitlawb/openclaude/tags)
|
||||||
|
[](https://github.com/Gitlawb/openclaude/discussions)
|
||||||
|
[](SECURITY.md)
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
|
[Quick Start](#quick-start) | [Setup Guides](#setup-guides) | [Providers](#supported-providers) | [Source Build](#source-build-and-local-development) | [VS Code Extension](#vs-code-extension) | [Community](#community)
|
||||||
|
|
||||||
## Why OpenClaude
|
## Why OpenClaude
|
||||||
|
|
||||||
- Use one CLI across cloud and local model providers
|
- Use one CLI across cloud APIs and local model backends
|
||||||
- Save provider profiles inside the app with `/provider`
|
- Save provider profiles inside the app with `/provider`
|
||||||
- Run locally with Ollama or Atomic Chat
|
- Run with OpenAI-compatible services, Gemini, GitHub Models, Codex, Ollama, Atomic Chat, and other supported providers
|
||||||
- Keep core coding-agent workflows: bash, file tools, grep, glob, agents, tasks, MCP, and web tools
|
- Keep coding-agent workflows in one place: bash, file tools, grep, glob, agents, tasks, MCP, and web tools
|
||||||
|
- Use the bundled VS Code extension for launch integration and theme support
|
||||||
## Provenance & Legal Notice
|
|
||||||
|
|
||||||
OpenClaude is derived from Anthropic's Claude Code CLI source code, which was
|
|
||||||
inadvertently exposed in March 2026 through a packaging error in npm. The
|
|
||||||
original Claude Code source is proprietary software owned by Anthropic PBC.
|
|
||||||
|
|
||||||
This project adds multi-provider support, strips telemetry, and adapts the
|
|
||||||
codebase for open use. It is not an authorized fork or open-source release
|
|
||||||
by Anthropic.
|
|
||||||
|
|
||||||
**"Claude" and "Claude Code" are trademarks of Anthropic PBC.**
|
|
||||||
|
|
||||||
Contributors should be aware that the legal status of distributing code
|
|
||||||
derived from Anthropic's proprietary source is unresolved. See the LICENSE
|
|
||||||
file for details.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -37,7 +28,7 @@ file for details.
|
|||||||
npm install -g @gitlawb/openclaude
|
npm install -g @gitlawb/openclaude
|
||||||
```
|
```
|
||||||
|
|
||||||
If the npm install path later reports `ripgrep not found`, install ripgrep system-wide and confirm `rg --version` works in the same terminal before starting OpenClaude.
|
If the install later reports `ripgrep not found`, install ripgrep system-wide and confirm `rg --version` works in the same terminal before starting OpenClaude.
|
||||||
|
|
||||||
### Start
|
### Start
|
||||||
|
|
||||||
@@ -47,8 +38,8 @@ openclaude
|
|||||||
|
|
||||||
Inside OpenClaude:
|
Inside OpenClaude:
|
||||||
|
|
||||||
- run `/provider` for guided setup of OpenAI-compatible, Gemini, Ollama, or Codex profiles
|
- run `/provider` for guided provider setup and saved profiles
|
||||||
- run `/onboard-github` for GitHub Models setup
|
- run `/onboard-github` for GitHub Models onboarding
|
||||||
|
|
||||||
### Fastest OpenAI setup
|
### Fastest OpenAI setup
|
||||||
|
|
||||||
@@ -94,8 +85,6 @@ $env:OPENAI_MODEL="qwen2.5-coder:7b"
|
|||||||
openclaude
|
openclaude
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Setup Guides
|
## Setup Guides
|
||||||
|
|
||||||
Beginner-friendly guides:
|
Beginner-friendly guides:
|
||||||
@@ -109,38 +98,26 @@ Advanced and source-build guides:
|
|||||||
- [Advanced Setup](docs/advanced-setup.md)
|
- [Advanced Setup](docs/advanced-setup.md)
|
||||||
- [Android Install](ANDROID_INSTALL.md)
|
- [Android Install](ANDROID_INSTALL.md)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Supported Providers
|
## Supported Providers
|
||||||
|
|
||||||
| Provider | Setup Path | Notes |
|
| Provider | Setup Path | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| OpenAI-compatible | `/provider` or env vars | Works with OpenAI, OpenRouter, DeepSeek, Groq, Mistral, LM Studio, and compatible local `/v1` servers |
|
| OpenAI-compatible | `/provider` or env vars | Works with OpenAI, OpenRouter, DeepSeek, Groq, Mistral, LM Studio, and other compatible `/v1` servers |
|
||||||
| Gemini | `/provider` or env vars | Google Gemini support through the runtime provider layer |
|
| Gemini | `/provider` or env vars | Supports API key, access token, or local ADC workflow on current `main` |
|
||||||
| GitHub Models | `/onboard-github` | Interactive onboarding with saved credentials |
|
| GitHub Models | `/onboard-github` | Interactive onboarding with saved credentials |
|
||||||
| Codex | `/provider` | Uses existing Codex credentials when available |
|
| Codex | `/provider` | Uses existing Codex credentials when available |
|
||||||
| Ollama | `/provider` or env vars | Local inference with no API key |
|
| Ollama | `/provider` or env vars | Local inference with no API key |
|
||||||
| Atomic Chat | advanced setup | Local Apple Silicon backend |
|
| Atomic Chat | advanced setup | Local Apple Silicon backend |
|
||||||
| Bedrock / Vertex / Foundry | env vars | Additional provider integrations for supported environments |
|
| Bedrock / Vertex / Foundry | env vars | Additional provider integrations for supported environments |
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What Works
|
## What Works
|
||||||
|
|
||||||
- Tool-driven coding workflows
|
- **Tool-driven coding workflows**: Bash, file read/write/edit, grep, glob, agents, tasks, MCP, and slash commands
|
||||||
Bash, file read/write/edit, grep, glob, agents, tasks, MCP, and slash commands
|
- **Streaming responses**: Real-time token output and tool progress
|
||||||
- Streaming responses
|
- **Tool calling**: Multi-step tool loops with model calls, tool execution, and follow-up responses
|
||||||
Real-time token output and tool progress
|
- **Images**: URL and base64 image inputs for providers that support vision
|
||||||
- Tool calling
|
- **Provider profiles**: Guided setup plus saved `.openclaude-profile.json` support
|
||||||
Multi-step tool loops with model calls, tool execution, and follow-up responses
|
- **Local and remote model backends**: Cloud APIs, local servers, and Apple Silicon local inference
|
||||||
- Images
|
|
||||||
URL and base64 image inputs for providers that support vision
|
|
||||||
- Provider profiles
|
|
||||||
Guided setup plus saved `.openclaude-profile.json` support
|
|
||||||
- Local and remote model backends
|
|
||||||
Cloud APIs, local servers, and Apple Silicon local inference
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Provider Notes
|
## Provider Notes
|
||||||
|
|
||||||
@@ -153,13 +130,9 @@ OpenClaude supports multiple providers, but behavior is not identical across all
|
|||||||
|
|
||||||
For best results, use models with strong tool/function calling support.
|
For best results, use models with strong tool/function calling support.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Agent Routing
|
## Agent Routing
|
||||||
|
|
||||||
Route different agents to different AI providers within the same session. Useful for cost optimization (cheap model for code review, powerful model for complex coding) or leveraging model strengths.
|
OpenClaude can route different agents to different models through settings-based routing. This is useful for cost optimization or splitting work by model strength.
|
||||||
|
|
||||||
### Configuration
|
|
||||||
|
|
||||||
Add to `~/.claude/settings.json`:
|
Add to `~/.claude/settings.json`:
|
||||||
|
|
||||||
@@ -185,29 +158,19 @@ Add to `~/.claude/settings.json`:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### How It Works
|
When no routing match is found, the global provider remains the fallback.
|
||||||
|
|
||||||
- **agentModels**: Maps model names to OpenAI-compatible API endpoints
|
|
||||||
- **agentRouting**: Maps agent types or team member names to model names
|
|
||||||
- **Priority**: `name` > `subagent_type` > `"default"` > global provider
|
|
||||||
- **Matching**: Case-insensitive, hyphen/underscore equivalent (`general-purpose` = `general_purpose`)
|
|
||||||
- **Teams**: Team members are routed by their `name` — no extra config needed
|
|
||||||
|
|
||||||
When no routing match is found, the global provider (env vars) is used as fallback.
|
|
||||||
|
|
||||||
> **Note:** `api_key` values in `settings.json` are stored in plaintext. Keep this file private and do not commit it to version control.
|
> **Note:** `api_key` values in `settings.json` are stored in plaintext. Keep this file private and do not commit it to version control.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Web Search and Fetch
|
## Web Search and Fetch
|
||||||
|
|
||||||
By default, `WebSearch` now works on non-Anthropic models using DuckDuckGo. This gives GPT-4o, DeepSeek, Gemini, Ollama, and other OpenAI-compatible providers a free web search path out of the box.
|
By default, `WebSearch` works on non-Anthropic models using DuckDuckGo. This gives GPT-4o, DeepSeek, Gemini, Ollama, and other OpenAI-compatible providers a free web search path out of the box.
|
||||||
|
|
||||||
>**Note:** DuckDuckGo fallback works by scraping search results and may be rate-limited, blocked, or subject to DuckDuckGo's Terms of Service. If you want a more reliable supported option, configure Firecrawl.
|
> **Note:** DuckDuckGo fallback works by scraping search results and may be rate-limited, blocked, or subject to DuckDuckGo's Terms of Service. If you want a more reliable supported option, configure Firecrawl.
|
||||||
|
|
||||||
For Anthropic-native backends (Anthropic/Vertex/Foundry) and Codex responses, OpenClaude keeps the native provider web search behavior.
|
For Anthropic-native backends and Codex responses, OpenClaude keeps the native provider web search behavior.
|
||||||
|
|
||||||
`WebFetch` works but uses basic HTTP plus HTML-to-markdown conversion. That fails on JavaScript-rendered pages (React, Next.js, Vue SPAs) and sites that block plain HTTP requests.
|
`WebFetch` works, but its basic HTTP plus HTML-to-markdown path can still fail on JavaScript-rendered sites or sites that block plain HTTP requests.
|
||||||
|
|
||||||
Set a [Firecrawl](https://firecrawl.dev) API key if you want Firecrawl-powered search/fetch behavior:
|
Set a [Firecrawl](https://firecrawl.dev) API key if you want Firecrawl-powered search/fetch behavior:
|
||||||
|
|
||||||
@@ -217,14 +180,12 @@ export FIRECRAWL_API_KEY=your-key-here
|
|||||||
|
|
||||||
With Firecrawl enabled:
|
With Firecrawl enabled:
|
||||||
|
|
||||||
- `WebSearch` can use Firecrawl's search API (while DuckDuckGo remains the default free path for non-Claude models)
|
- `WebSearch` can use Firecrawl's search API while DuckDuckGo remains the default free path for non-Claude models
|
||||||
- `WebFetch` uses Firecrawl's scrape endpoint instead of raw HTTP, handling JS-rendered pages correctly
|
- `WebFetch` uses Firecrawl's scrape endpoint instead of raw HTTP, handling JS-rendered pages correctly
|
||||||
|
|
||||||
Free tier at [firecrawl.dev](https://firecrawl.dev) includes 500 credits. The key is optional.
|
Free tier at [firecrawl.dev](https://firecrawl.dev) includes 500 credits. The key is optional.
|
||||||
|
|
||||||
---
|
## Source Build And Local Development
|
||||||
|
|
||||||
## Source Build
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun install
|
bun install
|
||||||
@@ -235,22 +196,78 @@ node dist/cli.mjs
|
|||||||
Helpful commands:
|
Helpful commands:
|
||||||
|
|
||||||
- `bun run dev`
|
- `bun run dev`
|
||||||
|
- `bun test`
|
||||||
|
- `bun run test:coverage`
|
||||||
|
- `bun run security:pr-scan -- --base origin/main`
|
||||||
- `bun run smoke`
|
- `bun run smoke`
|
||||||
- `bun run doctor:runtime`
|
- `bun run doctor:runtime`
|
||||||
|
- `bun run verify:privacy`
|
||||||
|
- focused `bun test ...` runs for the areas you touch
|
||||||
|
|
||||||
---
|
## Testing And Coverage
|
||||||
|
|
||||||
|
OpenClaude uses Bun's built-in test runner for unit tests.
|
||||||
|
|
||||||
|
Run the full unit suite:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun test
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate unit test coverage:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run test:coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
Open the visual coverage report:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
open coverage/index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
If you already have `coverage/lcov.info` and only want to rebuild the UI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run test:coverage:ui
|
||||||
|
```
|
||||||
|
|
||||||
|
Use focused test runs when you only touch one area:
|
||||||
|
|
||||||
|
- `bun run test:provider`
|
||||||
|
- `bun run test:provider-recommendation`
|
||||||
|
- `bun test path/to/file.test.ts`
|
||||||
|
|
||||||
|
Recommended contributor validation before opening a PR:
|
||||||
|
|
||||||
|
- `bun run build`
|
||||||
|
- `bun run smoke`
|
||||||
|
- `bun run test:coverage` for broader unit coverage when your change affects shared runtime or provider logic
|
||||||
|
- focused `bun test ...` runs for the files and flows you changed
|
||||||
|
|
||||||
|
Coverage output is written to `coverage/lcov.info`, and OpenClaude also generates a git-activity-style heatmap at `coverage/index.html`.
|
||||||
|
## Repository Structure
|
||||||
|
|
||||||
|
- `src/` - core CLI/runtime
|
||||||
|
- `scripts/` - build, verification, and maintenance scripts
|
||||||
|
- `docs/` - setup, contributor, and project documentation
|
||||||
|
- `python/` - standalone Python helpers and their tests
|
||||||
|
- `vscode-extension/openclaude-vscode/` - VS Code extension
|
||||||
|
- `.github/` - repo automation, templates, and CI configuration
|
||||||
|
- `bin/` - CLI launcher entrypoints
|
||||||
|
|
||||||
## VS Code Extension
|
## VS Code Extension
|
||||||
|
|
||||||
The repo includes a VS Code extension in [`vscode-extension/openclaude-vscode`](vscode-extension/openclaude-vscode) for OpenClaude launch integration and theme support.
|
The repo includes a VS Code extension in [`vscode-extension/openclaude-vscode`](vscode-extension/openclaude-vscode) for OpenClaude launch integration, provider-aware control-center UI, and theme support.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
If you believe you found a security issue, see [SECURITY.md](SECURITY.md).
|
If you believe you found a security issue, see [SECURITY.md](SECURITY.md).
|
||||||
|
|
||||||
---
|
## Community
|
||||||
|
|
||||||
|
- Use [GitHub Discussions](https://github.com/Gitlawb/openclaude/discussions) for Q&A, ideas, and community conversation
|
||||||
|
- Use [GitHub Issues](https://github.com/Gitlawb/openclaude/issues) for confirmed bugs and actionable feature work
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
@@ -259,19 +276,16 @@ Contributions are welcome.
|
|||||||
For larger changes, open an issue first so the scope is clear before implementation. Helpful validation commands include:
|
For larger changes, open an issue first so the scope is clear before implementation. Helpful validation commands include:
|
||||||
|
|
||||||
- `bun run build`
|
- `bun run build`
|
||||||
|
- `bun run test:coverage`
|
||||||
- `bun run smoke`
|
- `bun run smoke`
|
||||||
- focused `bun test ...` runs for touched areas
|
- focused `bun test ...` runs for touched areas
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
OpenClaude is an independent community project and is not affiliated with, endorsed by, or sponsored by Anthropic.
|
OpenClaude is an independent community project and is not affiliated with, endorsed by, or sponsored by Anthropic.
|
||||||
|
|
||||||
"Claude" and "Claude Code" are trademarks of Anthropic.
|
OpenClaude originated from the Claude Code codebase and has since been substantially modified to support multiple providers and open use. "Claude" and "Claude Code" are trademarks of Anthropic PBC. See [LICENSE](LICENSE) for details.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
See [LICENSE](LICENSE).
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { join } from 'path'
|
import { join, win32 } from 'path'
|
||||||
import { pathToFileURL } from 'url'
|
import { pathToFileURL } from 'url'
|
||||||
|
|
||||||
export function getDistImportSpecifier(baseDir) {
|
export function getDistImportSpecifier(baseDir) {
|
||||||
const distPath = join(baseDir, '..', 'dist', 'cli.mjs')
|
if (/^[A-Za-z]:\\/.test(baseDir)) {
|
||||||
|
const distPath = win32.join(baseDir, '..', 'dist', 'cli.mjs')
|
||||||
|
return `file:///${distPath.replace(/\\/g, '/')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const joinImpl = join
|
||||||
|
const distPath = joinImpl(baseDir, '..', 'dist', 'cli.mjs')
|
||||||
return pathToFileURL(distPath).href
|
return pathToFileURL(distPath).href
|
||||||
}
|
}
|
||||||
|
|||||||
1
bun.lock
1
bun.lock
@@ -36,6 +36,7 @@
|
|||||||
"cli-highlight": "2.1.11",
|
"cli-highlight": "2.1.11",
|
||||||
"code-excerpt": "4.0.0",
|
"code-excerpt": "4.0.0",
|
||||||
"commander": "12.1.0",
|
"commander": "12.1.0",
|
||||||
|
"cross-spawn": "7.0.6",
|
||||||
"diff": "8.0.3",
|
"diff": "8.0.3",
|
||||||
"duck-duck-scrape": "^2.2.7",
|
"duck-duck-scrape": "^2.2.7",
|
||||||
"emoji-regex": "10.6.0",
|
"emoji-regex": "10.6.0",
|
||||||
|
|||||||
@@ -31,6 +31,10 @@
|
|||||||
"dev:fast": "bun run profile:fast && bun run dev:ollama:fast",
|
"dev:fast": "bun run profile:fast && bun run dev:ollama:fast",
|
||||||
"dev:code": "bun run profile:code && bun run dev:profile",
|
"dev:code": "bun run profile:code && bun run dev:profile",
|
||||||
"start": "node dist/cli.mjs",
|
"start": "node dist/cli.mjs",
|
||||||
|
"test": "bun test",
|
||||||
|
"test:coverage": "bun test --coverage --coverage-reporter=lcov --coverage-dir=coverage --max-concurrency=1 && bun run scripts/render-coverage-heatmap.ts",
|
||||||
|
"test:coverage:ui": "bun run scripts/render-coverage-heatmap.ts",
|
||||||
|
"security:pr-scan": "bun run scripts/pr-intent-scan.ts",
|
||||||
"test:provider-recommendation": "bun test src/utils/providerRecommendation.test.ts src/utils/providerProfile.test.ts",
|
"test:provider-recommendation": "bun test src/utils/providerRecommendation.test.ts src/utils/providerProfile.test.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"smoke": "bun run build && node dist/cli.mjs --version",
|
"smoke": "bun run build && node dist/cli.mjs --version",
|
||||||
@@ -76,6 +80,7 @@
|
|||||||
"cli-highlight": "2.1.11",
|
"cli-highlight": "2.1.11",
|
||||||
"code-excerpt": "4.0.0",
|
"code-excerpt": "4.0.0",
|
||||||
"commander": "12.1.0",
|
"commander": "12.1.0",
|
||||||
|
"cross-spawn": "7.0.6",
|
||||||
"diff": "8.0.3",
|
"diff": "8.0.3",
|
||||||
"duck-duck-scrape": "^2.2.7",
|
"duck-duck-scrape": "^2.2.7",
|
||||||
"emoji-regex": "10.6.0",
|
"emoji-regex": "10.6.0",
|
||||||
|
|||||||
1
python/__init__.py
Normal file
1
python/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Python helper package for standalone provider-side utilities.
|
||||||
1
python/tests/__init__.py
Normal file
1
python/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Pytest package marker for the Python helper test suite.
|
||||||
5
python/tests/conftest.py
Normal file
5
python/tests/conftest.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Make the sibling `python/` helper modules importable from this test package.
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
test_atomic_chat_provider.py
|
test_atomic_chat_provider.py
|
||||||
Run: pytest test_atomic_chat_provider.py -v
|
Run: pytest python/tests/test_atomic_chat_provider.py -v
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
test_ollama_provider.py
|
test_ollama_provider.py
|
||||||
Run: pytest test_ollama_provider.py -v
|
Run: pytest python/tests/test_ollama_provider.py -v
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -13,25 +13,31 @@ from ollama_provider import (
|
|||||||
check_ollama_running,
|
check_ollama_running,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_strips_prefix():
|
def test_normalize_strips_prefix():
|
||||||
assert normalize_ollama_model("ollama/llama3:8b") == "llama3:8b"
|
assert normalize_ollama_model("ollama/llama3:8b") == "llama3:8b"
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_no_prefix():
|
def test_normalize_no_prefix():
|
||||||
assert normalize_ollama_model("codellama:34b") == "codellama:34b"
|
assert normalize_ollama_model("codellama:34b") == "codellama:34b"
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_empty():
|
def test_normalize_empty():
|
||||||
assert normalize_ollama_model("") == ""
|
assert normalize_ollama_model("") == ""
|
||||||
|
|
||||||
|
|
||||||
def test_converts_string_content():
|
def test_converts_string_content():
|
||||||
messages = [{"role": "user", "content": "Hello!"}]
|
messages = [{"role": "user", "content": "Hello!"}]
|
||||||
result = anthropic_to_ollama_messages(messages)
|
result = anthropic_to_ollama_messages(messages)
|
||||||
assert result == [{"role": "user", "content": "Hello!"}]
|
assert result == [{"role": "user", "content": "Hello!"}]
|
||||||
|
|
||||||
|
|
||||||
def test_converts_text_block_list():
|
def test_converts_text_block_list():
|
||||||
messages = [{"role": "user", "content": [{"type": "text", "text": "What is Python?"}]}]
|
messages = [{"role": "user", "content": [{"type": "text", "text": "What is Python?"}]}]
|
||||||
result = anthropic_to_ollama_messages(messages)
|
result = anthropic_to_ollama_messages(messages)
|
||||||
assert result[0]["content"] == "What is Python?"
|
assert result[0]["content"] == "What is Python?"
|
||||||
|
|
||||||
|
|
||||||
def test_converts_image_block_to_placeholder():
|
def test_converts_image_block_to_placeholder():
|
||||||
messages = [{"role": "user", "content": [{"type": "image", "source": {}}, {"type": "text", "text": "Describe this"}]}]
|
messages = [{"role": "user", "content": [{"type": "image", "source": {}}, {"type": "text", "text": "Describe this"}]}]
|
||||||
result = anthropic_to_ollama_messages(messages)
|
result = anthropic_to_ollama_messages(messages)
|
||||||
@@ -68,6 +74,7 @@ def test_converts_multi_turn():
|
|||||||
assert len(result) == 3
|
assert len(result) == 3
|
||||||
assert result[1]["role"] == "assistant"
|
assert result[1]["role"] == "assistant"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_ollama_running_true():
|
async def test_ollama_running_true():
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
@@ -77,6 +84,7 @@ async def test_ollama_running_true():
|
|||||||
result = await check_ollama_running()
|
result = await check_ollama_running()
|
||||||
assert result is True
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_ollama_running_false_on_exception():
|
async def test_ollama_running_false_on_exception():
|
||||||
with patch("ollama_provider.httpx.AsyncClient") as MockClient:
|
with patch("ollama_provider.httpx.AsyncClient") as MockClient:
|
||||||
@@ -84,6 +92,7 @@ async def test_ollama_running_false_on_exception():
|
|||||||
result = await check_ollama_running()
|
result = await check_ollama_running()
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_list_models_returns_names():
|
async def test_list_models_returns_names():
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
@@ -95,6 +104,7 @@ async def test_list_models_returns_names():
|
|||||||
models = await list_ollama_models()
|
models = await list_ollama_models()
|
||||||
assert "llama3:8b" in models
|
assert "llama3:8b" in models
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_ollama_chat_returns_anthropic_format():
|
async def test_ollama_chat_returns_anthropic_format():
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
@@ -115,9 +125,11 @@ async def test_ollama_chat_returns_anthropic_format():
|
|||||||
assert result["role"] == "assistant"
|
assert result["role"] == "assistant"
|
||||||
assert "42" in result["content"][0]["text"]
|
assert "42" in result["content"][0]["text"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_ollama_chat_prepends_system():
|
async def test_ollama_chat_prepends_system():
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|
||||||
async def mock_post(url, json=None, **kwargs):
|
async def mock_post(url, json=None, **kwargs):
|
||||||
captured.update(json or {})
|
captured.update(json or {})
|
||||||
m = MagicMock()
|
m = MagicMock()
|
||||||
@@ -134,7 +146,7 @@ async def test_ollama_chat_prepends_system():
|
|||||||
await ollama_chat(
|
await ollama_chat(
|
||||||
model="llama3:8b",
|
model="llama3:8b",
|
||||||
messages=[{"role": "user", "content": "Hi"}],
|
messages=[{"role": "user", "content": "Hi"}],
|
||||||
system="Be helpful."
|
system="Be helpful.",
|
||||||
)
|
)
|
||||||
assert captured["messages"][0]["role"] == "system"
|
assert captured["messages"][0]["role"] == "system"
|
||||||
assert "helpful" in captured["messages"][0]["content"]
|
assert "helpful" in captured["messages"][0]["content"]
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
test_smart_router.py
|
test_smart_router.py
|
||||||
--------------------
|
--------------------
|
||||||
Tests for the SmartRouter.
|
Tests for the SmartRouter.
|
||||||
Run: pytest test_smart_router.py -v
|
Run: pytest python/tests/test_smart_router.py -v
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -18,6 +18,7 @@ from smart_router import SmartRouter, Provider
|
|||||||
def fake_api_key(monkeypatch):
|
def fake_api_key(monkeypatch):
|
||||||
monkeypatch.setenv("FAKE_KEY", "test-key")
|
monkeypatch.setenv("FAKE_KEY", "test-key")
|
||||||
|
|
||||||
|
|
||||||
def make_provider(name, healthy=True, configured=True,
|
def make_provider(name, healthy=True, configured=True,
|
||||||
latency=100.0, cost=0.002, errors=0, requests=0):
|
latency=100.0, cost=0.002, errors=0, requests=0):
|
||||||
p = Provider(
|
p = Provider(
|
||||||
@@ -33,7 +34,7 @@ def make_provider(name, healthy=True, configured=True,
|
|||||||
p.error_count = errors
|
p.error_count = errors
|
||||||
p.request_count = requests
|
p.request_count = requests
|
||||||
if not configured:
|
if not configured:
|
||||||
p.api_key_env = "" # makes is_configured False for non-ollama
|
p.api_key_env = "" # makes is_configured False for non-local providers
|
||||||
return p
|
return p
|
||||||
|
|
||||||
|
|
||||||
108
scripts/build.ts
108
scripts/build.ts
@@ -3,7 +3,7 @@
|
|||||||
* distributable JS file using Bun's bundler.
|
* distributable JS file using Bun's bundler.
|
||||||
*
|
*
|
||||||
* Handles:
|
* Handles:
|
||||||
* - bun:bundle feature() flags → all false (disables internal-only features)
|
* - bun:bundle feature() flags for the open build
|
||||||
* - MACRO.* globals → inlined version/build-time constants
|
* - MACRO.* globals → inlined version/build-time constants
|
||||||
* - src/ path aliases
|
* - src/ path aliases
|
||||||
*/
|
*/
|
||||||
@@ -14,8 +14,9 @@ import { noTelemetryPlugin } from './no-telemetry-plugin'
|
|||||||
const pkg = JSON.parse(readFileSync('./package.json', 'utf-8'))
|
const pkg = JSON.parse(readFileSync('./package.json', 'utf-8'))
|
||||||
const version = pkg.version
|
const version = pkg.version
|
||||||
|
|
||||||
// Feature flags — all disabled for the open build.
|
// Feature flags for the open build.
|
||||||
// These gate Anthropic-internal features (voice, proactive, kairos, etc.)
|
// Most Anthropic-internal features stay off; open-build features can be
|
||||||
|
// selectively enabled here when their full source exists in the mirror.
|
||||||
const featureFlags: Record<string, boolean> = {
|
const featureFlags: Record<string, boolean> = {
|
||||||
VOICE_MODE: false,
|
VOICE_MODE: false,
|
||||||
PROACTIVE: false,
|
PROACTIVE: false,
|
||||||
@@ -37,7 +38,7 @@ const featureFlags: Record<string, boolean> = {
|
|||||||
TRANSCRIPT_CLASSIFIER: false,
|
TRANSCRIPT_CLASSIFIER: false,
|
||||||
WEB_BROWSER_TOOL: false,
|
WEB_BROWSER_TOOL: false,
|
||||||
MESSAGE_ACTIONS: false,
|
MESSAGE_ACTIONS: false,
|
||||||
BUDDY: false,
|
BUDDY: true,
|
||||||
CHICAGO_MCP: false,
|
CHICAGO_MCP: false,
|
||||||
COWORKER_TYPE_TELEMETRY: false,
|
COWORKER_TYPE_TELEMETRY: false,
|
||||||
}
|
}
|
||||||
@@ -110,7 +111,7 @@ export async function handleBgFlag() { throw new Error("Background sessions are
|
|||||||
build.onLoad(
|
build.onLoad(
|
||||||
{ filter: /.*/, namespace: 'bun-bundle-shim' },
|
{ filter: /.*/, namespace: 'bun-bundle-shim' },
|
||||||
() => ({
|
() => ({
|
||||||
contents: `export function feature(name) { return false; }`,
|
contents: `const featureFlags = ${JSON.stringify(featureFlags)};\nexport function feature(name) { return featureFlags[name] ?? false; }`,
|
||||||
loader: 'js',
|
loader: 'js',
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -250,6 +251,103 @@ export const SeverityNumber = {};
|
|||||||
loader: 'js',
|
loader: 'js',
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Pre-scan: find all missing modules that need stubbing
|
||||||
|
// (Bun's onResolve corrupts module graph even when returning null,
|
||||||
|
// so we use exact-match resolvers instead of catch-all patterns)
|
||||||
|
const fs = require('fs')
|
||||||
|
const pathMod = require('path')
|
||||||
|
const srcDir = pathMod.resolve(__dirname, '..', 'src')
|
||||||
|
const missingModules = new Set<string>()
|
||||||
|
const missingModuleExports = new Map<string, Set<string>>()
|
||||||
|
|
||||||
|
// Known missing external packages
|
||||||
|
for (const pkg of [
|
||||||
|
'@ant/computer-use-mcp',
|
||||||
|
'@ant/computer-use-mcp/sentinelApps',
|
||||||
|
'@ant/computer-use-mcp/types',
|
||||||
|
'@ant/computer-use-swift',
|
||||||
|
'@ant/computer-use-input',
|
||||||
|
]) {
|
||||||
|
missingModules.add(pkg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan source to find imports that can't resolve
|
||||||
|
function scanForMissingImports() {
|
||||||
|
function walk(dir: string) {
|
||||||
|
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = pathMod.join(dir, ent.name)
|
||||||
|
if (ent.isDirectory()) { walk(full); continue }
|
||||||
|
if (!/\.(ts|tsx)$/.test(ent.name)) continue
|
||||||
|
const code: string = fs.readFileSync(full, 'utf-8')
|
||||||
|
// Collect all imports
|
||||||
|
for (const m of code.matchAll(/import\s+(?:\{([^}]*)\}|(\w+))?\s*(?:,\s*\{([^}]*)\})?\s*from\s+['"](.*?)['"]/g)) {
|
||||||
|
const specifier = m[4]
|
||||||
|
const namedPart = m[1] || m[3] || ''
|
||||||
|
const names = namedPart.split(',')
|
||||||
|
.map((s: string) => s.trim().replace(/^type\s+/, ''))
|
||||||
|
.filter((s: string) => s && !s.startsWith('type '))
|
||||||
|
|
||||||
|
// Check src/tasks/ non-relative imports
|
||||||
|
if (specifier.startsWith('src/tasks/')) {
|
||||||
|
const resolved = pathMod.resolve(__dirname, '..', specifier)
|
||||||
|
const candidates = [
|
||||||
|
resolved,
|
||||||
|
`${resolved}.ts`, `${resolved}.tsx`,
|
||||||
|
resolved.replace(/\.js$/, '.ts'), resolved.replace(/\.js$/, '.tsx'),
|
||||||
|
pathMod.join(resolved, 'index.ts'), pathMod.join(resolved, 'index.tsx'),
|
||||||
|
]
|
||||||
|
if (!candidates.some((c: string) => fs.existsSync(c))) {
|
||||||
|
missingModules.add(specifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check relative .js imports
|
||||||
|
else if (specifier.endsWith('.js') && (specifier.startsWith('./') || specifier.startsWith('../'))) {
|
||||||
|
const dir2 = pathMod.dirname(full)
|
||||||
|
const resolved = pathMod.resolve(dir2, specifier)
|
||||||
|
const tsVariant = resolved.replace(/\.js$/, '.ts')
|
||||||
|
const tsxVariant = resolved.replace(/\.js$/, '.tsx')
|
||||||
|
if (!fs.existsSync(resolved) && !fs.existsSync(tsVariant) && !fs.existsSync(tsxVariant)) {
|
||||||
|
missingModules.add(specifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track named exports for missing modules
|
||||||
|
if (names.length > 0) {
|
||||||
|
if (!missingModuleExports.has(specifier)) missingModuleExports.set(specifier, new Set())
|
||||||
|
for (const n of names) missingModuleExports.get(specifier)!.add(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(srcDir)
|
||||||
|
}
|
||||||
|
scanForMissingImports()
|
||||||
|
|
||||||
|
// Register exact-match resolvers for each missing module
|
||||||
|
for (const mod of missingModules) {
|
||||||
|
const escaped = mod.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
build.onResolve({ filter: new RegExp(`^${escaped}$`) }, () => ({
|
||||||
|
path: mod,
|
||||||
|
namespace: 'missing-module-stub',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
build.onLoad(
|
||||||
|
{ filter: /.*/, namespace: 'missing-module-stub' },
|
||||||
|
(args) => {
|
||||||
|
const names = missingModuleExports.get(args.path) ?? new Set()
|
||||||
|
const exports = [...names].map(n => `export const ${n} = noop;`).join('\n')
|
||||||
|
return {
|
||||||
|
contents: `
|
||||||
|
const noop = () => null;
|
||||||
|
export default noop;
|
||||||
|
${exports}
|
||||||
|
`,
|
||||||
|
loader: 'js',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -203,6 +203,60 @@ export async function submitTranscriptShare() { return { success: false }; }
|
|||||||
'services/internalLogging': `
|
'services/internalLogging': `
|
||||||
export async function logPermissionContextForAnts() {}
|
export async function logPermissionContextForAnts() {}
|
||||||
export const getContainerId = async () => null;
|
export const getContainerId = async () => null;
|
||||||
|
`,
|
||||||
|
|
||||||
|
// ─── Deleted Anthropic-internal modules ───────────────────────────────
|
||||||
|
|
||||||
|
'services/api/dumpPrompts': `
|
||||||
|
export function createDumpPromptsFetch() { return undefined; }
|
||||||
|
export function getDumpPromptsPath() { return ''; }
|
||||||
|
export function getLastApiRequests() { return []; }
|
||||||
|
export function clearApiRequestCache() {}
|
||||||
|
export function clearDumpState() {}
|
||||||
|
export function clearAllDumpState() {}
|
||||||
|
export function addApiRequestToCache() {}
|
||||||
|
`,
|
||||||
|
|
||||||
|
'utils/undercover': `
|
||||||
|
export function isUndercover() { return false; }
|
||||||
|
export function getUndercoverInstructions() { return ''; }
|
||||||
|
export function shouldShowUndercoverAutoNotice() { return false; }
|
||||||
|
`,
|
||||||
|
|
||||||
|
'types/generated/events_mono/claude_code/v1/claude_code_internal_event': `
|
||||||
|
export const ClaudeCodeInternalEvent = {
|
||||||
|
fromJSON: value => value,
|
||||||
|
toJSON: value => value,
|
||||||
|
create: value => value ?? {},
|
||||||
|
fromPartial: value => value ?? {},
|
||||||
|
};
|
||||||
|
`,
|
||||||
|
|
||||||
|
'types/generated/events_mono/growthbook/v1/growthbook_experiment_event': `
|
||||||
|
export const GrowthbookExperimentEvent = {
|
||||||
|
fromJSON: value => value,
|
||||||
|
toJSON: value => value,
|
||||||
|
create: value => value ?? {},
|
||||||
|
fromPartial: value => value ?? {},
|
||||||
|
};
|
||||||
|
`,
|
||||||
|
|
||||||
|
'types/generated/events_mono/common/v1/auth': `
|
||||||
|
export const PublicApiAuth = {
|
||||||
|
fromJSON: value => value,
|
||||||
|
toJSON: value => value,
|
||||||
|
create: value => value ?? {},
|
||||||
|
fromPartial: value => value ?? {},
|
||||||
|
};
|
||||||
|
`,
|
||||||
|
|
||||||
|
'types/generated/google/protobuf/timestamp': `
|
||||||
|
export const Timestamp = {
|
||||||
|
fromJSON: value => value,
|
||||||
|
toJSON: value => value,
|
||||||
|
create: value => value ?? {},
|
||||||
|
fromPartial: value => value ?? {},
|
||||||
|
};
|
||||||
`,
|
`,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
136
scripts/pr-intent-scan.test.ts
Normal file
136
scripts/pr-intent-scan.test.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import { scanAddedLines, type DiffLine } from './pr-intent-scan.ts'
|
||||||
|
|
||||||
|
function line(content: string, overrides: Partial<DiffLine> = {}): DiffLine {
|
||||||
|
return {
|
||||||
|
file: 'README.md',
|
||||||
|
line: 10,
|
||||||
|
content,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('scanAddedLines', () => {
|
||||||
|
test('flags suspicious file-hosting links', () => {
|
||||||
|
const findings = scanAddedLines([
|
||||||
|
line('Please install the tool from https://dropbox.com/s/abc123/tool.zip?dl=1'),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(findings.some(finding => finding.code === 'suspicious-download-link')).toBe(
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
expect(findings.some(finding => finding.code === 'executable-download-link')).toBe(
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
expect(findings.some(finding => finding.severity === 'high')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('flags shortened URLs', () => {
|
||||||
|
const findings = scanAddedLines([
|
||||||
|
line('See details at https://bit.ly/some-short-link'),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(findings.some(finding => finding.code === 'shortened-url')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('flags remote download and execute chains', () => {
|
||||||
|
const findings = scanAddedLines([
|
||||||
|
line('curl -fsSL https://example.com/install.sh | bash'),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(findings.some(finding => finding.code === 'shell-eval-remote')).toBe(true)
|
||||||
|
expect(findings.some(finding => finding.severity === 'high')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('flags encoded powershell payloads', () => {
|
||||||
|
const findings = scanAddedLines([
|
||||||
|
line('powershell.exe -enc SQBtAHAAcgBvAHYAZQBkAA=='),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(findings.some(finding => finding.code === 'powershell-encoded')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('flags long encoded blobs', () => {
|
||||||
|
const findings = scanAddedLines([
|
||||||
|
line(`const payload = "${'A'.repeat(96)}"`),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(findings.some(finding => finding.code === 'long-encoded-payload')).toBe(
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('flags long encoded blobs on repeated scans', () => {
|
||||||
|
const lines = [line(`const payload = "${'A'.repeat(96)}"`)]
|
||||||
|
|
||||||
|
const first = scanAddedLines(lines)
|
||||||
|
const second = scanAddedLines(lines)
|
||||||
|
|
||||||
|
expect(first.some(finding => finding.code === 'long-encoded-payload')).toBe(true)
|
||||||
|
expect(second.some(finding => finding.code === 'long-encoded-payload')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('flags executable download links', () => {
|
||||||
|
const findings = scanAddedLines([
|
||||||
|
line('Get it from https://example.com/releases/latest/tool.pkg'),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(findings.some(finding => finding.code === 'executable-download-link')).toBe(
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
expect(findings.some(finding => finding.severity === 'high')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('flags suspicious additions in workflow files', () => {
|
||||||
|
const findings = scanAddedLines([
|
||||||
|
line('run: curl -fsSL https://example.com/install.sh | bash', {
|
||||||
|
file: '.github/workflows/release.yml',
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(findings.some(finding => finding.code === 'sensitive-automation-change')).toBe(
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
expect(findings.some(finding => finding.code === 'download-command')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('flags markdown reference links to suspicious downloads', () => {
|
||||||
|
const findings = scanAddedLines([
|
||||||
|
line('[installer]: https://dropbox.com/s/abc123/tool.zip?dl=1'),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(findings.some(finding => finding.code === 'suspicious-download-link')).toBe(
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ignores the scanner implementation and tests themselves', () => {
|
||||||
|
const findings = scanAddedLines([
|
||||||
|
line('curl -fsSL https://example.com/install.sh | bash', {
|
||||||
|
file: 'scripts/pr-intent-scan.test.ts',
|
||||||
|
}),
|
||||||
|
line('const pattern = /https:\\/\\/dropbox\\.com\\//', {
|
||||||
|
file: 'scripts/pr-intent-scan.ts',
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(findings).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not flag ordinary docs links', () => {
|
||||||
|
const findings = scanAddedLines([
|
||||||
|
line('Read more at https://docs.github.com/en/actions'),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(findings).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not flag bare curl examples in README without a URL', () => {
|
||||||
|
const findings = scanAddedLines([
|
||||||
|
line('Use curl with your preferred flags for local testing.'),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(findings.some(finding => finding.code === 'download-command')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
453
scripts/pr-intent-scan.ts
Normal file
453
scripts/pr-intent-scan.ts
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
|
||||||
|
export type FindingSeverity = 'high' | 'medium'
|
||||||
|
|
||||||
|
export type DiffLine = {
|
||||||
|
file: string
|
||||||
|
line: number
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Finding = {
|
||||||
|
severity: FindingSeverity
|
||||||
|
code: string
|
||||||
|
file: string
|
||||||
|
line: number
|
||||||
|
detail: string
|
||||||
|
excerpt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type CliOptions = {
|
||||||
|
baseRef: string
|
||||||
|
json: boolean
|
||||||
|
failOn: FindingSeverity
|
||||||
|
}
|
||||||
|
|
||||||
|
const SELF_EXCLUDED_FILES = new Set([
|
||||||
|
'scripts/pr-intent-scan.ts',
|
||||||
|
'scripts/pr-intent-scan.test.ts',
|
||||||
|
])
|
||||||
|
|
||||||
|
const SHORTENER_DOMAINS = [
|
||||||
|
'bit.ly',
|
||||||
|
'tinyurl.com',
|
||||||
|
'goo.gl',
|
||||||
|
't.co',
|
||||||
|
'is.gd',
|
||||||
|
'rb.gy',
|
||||||
|
'cutt.ly',
|
||||||
|
]
|
||||||
|
|
||||||
|
const SUSPICIOUS_DOWNLOAD_DOMAINS = [
|
||||||
|
'dropbox.com',
|
||||||
|
'dl.dropboxusercontent.com',
|
||||||
|
'drive.google.com',
|
||||||
|
'docs.google.com',
|
||||||
|
'mega.nz',
|
||||||
|
'mediafire.com',
|
||||||
|
'transfer.sh',
|
||||||
|
'anonfiles.com',
|
||||||
|
'catbox.moe',
|
||||||
|
]
|
||||||
|
|
||||||
|
const URL_REGEX = /\bhttps?:\/\/[^\s)>"']+/gi
|
||||||
|
const LONG_BASE64_REGEX = /\b(?:[A-Za-z0-9+/]{80,}={0,2}|[A-Za-z0-9_-]{80,})\b/
|
||||||
|
const EXECUTABLE_PATH_REGEX =
|
||||||
|
/\.(?:sh|bash|zsh|ps1|exe|msi|pkg|deb|rpm|zip|tar|tgz|gz|xz|dmg|appimage)(?:$|[?#])/i
|
||||||
|
const SENSITIVE_PATH_REGEX =
|
||||||
|
/^(?:\.github\/workflows\/|scripts\/|bin\/|install(?:\/|\.|$)|.*(?:Dockerfile|docker-compose|compose\.ya?ml)$)/i
|
||||||
|
|
||||||
|
function parseOptions(argv: string[]): CliOptions {
|
||||||
|
const options: CliOptions = {
|
||||||
|
baseRef: 'origin/main',
|
||||||
|
json: false,
|
||||||
|
failOn: 'high',
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < argv.length; index++) {
|
||||||
|
const arg = argv[index]
|
||||||
|
if (arg === '--json') {
|
||||||
|
options.json = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (arg === '--base') {
|
||||||
|
const next = argv[index + 1]
|
||||||
|
if (next && !next.startsWith('--')) {
|
||||||
|
options.baseRef = next
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (arg === '--fail-on') {
|
||||||
|
const next = argv[index + 1]
|
||||||
|
if (next === 'high' || next === 'medium') {
|
||||||
|
options.failOn = next
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimExcerpt(content: string): string {
|
||||||
|
const compact = content.trim().replace(/\s+/g, ' ')
|
||||||
|
return compact.length > 140 ? `${compact.slice(0, 137)}...` : compact
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueFindings(findings: Finding[]): Finding[] {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
return findings.filter(finding => {
|
||||||
|
const key = `${finding.code}:${finding.file}:${finding.line}:${finding.detail}`
|
||||||
|
if (seen.has(key)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seen.add(key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAddedLines(diffText: string): DiffLine[] {
|
||||||
|
const lines = diffText.split('\n')
|
||||||
|
const added: DiffLine[] = []
|
||||||
|
let currentFile: string | null = null
|
||||||
|
let currentLine = 0
|
||||||
|
|
||||||
|
for (const rawLine of lines) {
|
||||||
|
if (rawLine.startsWith('+++ b/')) {
|
||||||
|
currentFile = rawLine.slice('+++ b/'.length)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawLine.startsWith('@@')) {
|
||||||
|
const match = /\+(\d+)(?:,(\d+))?/.exec(rawLine)
|
||||||
|
if (match) {
|
||||||
|
currentLine = Number(match[1])
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentFile) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawLine.startsWith('+') && !rawLine.startsWith('+++')) {
|
||||||
|
added.push({
|
||||||
|
file: currentFile,
|
||||||
|
line: currentLine,
|
||||||
|
content: rawLine.slice(1),
|
||||||
|
})
|
||||||
|
currentLine += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawLine.startsWith('-') && !rawLine.startsWith('---')) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rawLine.startsWith('\\')) {
|
||||||
|
currentLine += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return added
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryParseUrl(value: string): URL | null {
|
||||||
|
try {
|
||||||
|
return new URL(value)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostMatches(hostname: string, domain: string): boolean {
|
||||||
|
return hostname === domain || hostname.endsWith(`.${domain}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSuspiciousDownloadIndicators(url: URL): boolean {
|
||||||
|
const combined = `${url.pathname}${url.search}`.toLowerCase()
|
||||||
|
return (
|
||||||
|
combined.includes('dl=1') ||
|
||||||
|
combined.includes('raw=1') ||
|
||||||
|
combined.includes('export=download') ||
|
||||||
|
combined.includes('/download') ||
|
||||||
|
combined.includes('/uc?export=download')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function findUrlFindings(line: DiffLine): Finding[] {
|
||||||
|
const findings: Finding[] = []
|
||||||
|
const matches = line.content.match(URL_REGEX) ?? []
|
||||||
|
|
||||||
|
for (const match of matches) {
|
||||||
|
const parsed = tryParseUrl(match)
|
||||||
|
if (!parsed) continue
|
||||||
|
|
||||||
|
const hostname = parsed.hostname.toLowerCase()
|
||||||
|
|
||||||
|
for (const domain of SHORTENER_DOMAINS) {
|
||||||
|
if (hostMatches(hostname, domain)) {
|
||||||
|
findings.push({
|
||||||
|
severity: 'medium',
|
||||||
|
code: 'shortened-url',
|
||||||
|
file: line.file,
|
||||||
|
line: line.line,
|
||||||
|
detail: `Added shortened URL: ${hostname}`,
|
||||||
|
excerpt: trimExcerpt(line.content),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSuspiciousHost = SUSPICIOUS_DOWNLOAD_DOMAINS.some(domain =>
|
||||||
|
hostMatches(hostname, domain),
|
||||||
|
)
|
||||||
|
const isExecutableDownload = EXECUTABLE_PATH_REGEX.test(
|
||||||
|
`${parsed.pathname}${parsed.search}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isSuspiciousHost) {
|
||||||
|
findings.push({
|
||||||
|
severity:
|
||||||
|
hasSuspiciousDownloadIndicators(parsed) || isExecutableDownload
|
||||||
|
? 'high'
|
||||||
|
: 'medium',
|
||||||
|
code: 'suspicious-download-link',
|
||||||
|
file: line.file,
|
||||||
|
line: line.line,
|
||||||
|
detail: `Added external file-hosting link: ${hostname}`,
|
||||||
|
excerpt: trimExcerpt(line.content),
|
||||||
|
})
|
||||||
|
} else if (isExecutableDownload) {
|
||||||
|
findings.push({
|
||||||
|
severity: 'high',
|
||||||
|
code: 'executable-download-link',
|
||||||
|
file: line.file,
|
||||||
|
line: line.line,
|
||||||
|
detail: `Added direct link to executable or archive payload: ${hostname}`,
|
||||||
|
excerpt: trimExcerpt(line.content),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return findings
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSensitivePathFindings(line: DiffLine): Finding[] {
|
||||||
|
if (!SENSITIVE_PATH_REGEX.test(line.file)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const lower = line.content.toLowerCase()
|
||||||
|
|
||||||
|
if (
|
||||||
|
/\b(curl|wget|invoke-webrequest|iwr|powershell|bash|sh|chmod\s+\+x)\b/i.test(
|
||||||
|
line.content,
|
||||||
|
) ||
|
||||||
|
URL_REGEX.test(line.content) ||
|
||||||
|
lower.includes('download')
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
severity: 'medium',
|
||||||
|
code: 'sensitive-automation-change',
|
||||||
|
file: line.file,
|
||||||
|
line: line.line,
|
||||||
|
detail:
|
||||||
|
'Added network, execution, or download-related content in a sensitive automation file',
|
||||||
|
excerpt: trimExcerpt(line.content),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCommandFindings(line: DiffLine): Finding[] {
|
||||||
|
const findings: Finding[] = []
|
||||||
|
const lower = line.content.toLowerCase()
|
||||||
|
|
||||||
|
const highPatterns: Array<[string, RegExp, string]> = [
|
||||||
|
[
|
||||||
|
'download-exec-chain',
|
||||||
|
/\b(curl|wget|invoke-webrequest|iwr)\b.*(\|\s*(sh|bash|zsh)|;\s*chmod\s+\+x|&&\s*\.\.?\/|>\s*\/tmp\/)/i,
|
||||||
|
'Added remote download followed by execution or staging',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'powershell-encoded',
|
||||||
|
/\bpowershell(?:\.exe)?\b.*(?:-enc|-encodedcommand)\b/i,
|
||||||
|
'Added encoded PowerShell invocation',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'shell-eval-remote',
|
||||||
|
/\b(curl|wget)\b.*\|\s*(sh|bash|zsh)\b/i,
|
||||||
|
'Added shell pipe from remote content into interpreter',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'binary-lolbin',
|
||||||
|
/\b(mshta|rundll32|regsvr32|certutil)\b/i,
|
||||||
|
'Added living-off-the-land binary often used for payload staging',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'invoke-expression',
|
||||||
|
/\b(iex|invoke-expression)\b/i,
|
||||||
|
'Added PowerShell expression execution',
|
||||||
|
],
|
||||||
|
]
|
||||||
|
|
||||||
|
const mediumPatterns: Array<[string, RegExp, string]> = [
|
||||||
|
[
|
||||||
|
'download-command',
|
||||||
|
/\b(curl|wget|invoke-webrequest|iwr)\b.*https?:\/\//i,
|
||||||
|
'Added command that downloads remote content',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'archive-extract-exec',
|
||||||
|
/\b(unzip|tar|7z)\b.*(&&|;).*\b(chmod|node|python|bash|sh)\b/i,
|
||||||
|
'Added archive extraction followed by execution',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'base64-decode',
|
||||||
|
/\b(base64\s+-d|openssl\s+base64\s+-d|python .*b64decode)\b/i,
|
||||||
|
'Added explicit payload decode step',
|
||||||
|
],
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const [code, pattern, detail] of highPatterns) {
|
||||||
|
if (pattern.test(line.content)) {
|
||||||
|
findings.push({
|
||||||
|
severity: 'high',
|
||||||
|
code,
|
||||||
|
file: line.file,
|
||||||
|
line: line.line,
|
||||||
|
detail,
|
||||||
|
excerpt: trimExcerpt(line.content),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [code, pattern, detail] of mediumPatterns) {
|
||||||
|
if (code === 'download-command' && !SENSITIVE_PATH_REGEX.test(line.file)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (pattern.test(line.content)) {
|
||||||
|
findings.push({
|
||||||
|
severity: 'medium',
|
||||||
|
code,
|
||||||
|
file: line.file,
|
||||||
|
line: line.line,
|
||||||
|
detail,
|
||||||
|
excerpt: trimExcerpt(line.content),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (LONG_BASE64_REGEX.test(line.content) && !lower.includes('sha256') && !lower.includes('sha512')) {
|
||||||
|
findings.push({
|
||||||
|
severity: 'medium',
|
||||||
|
code: 'long-encoded-payload',
|
||||||
|
file: line.file,
|
||||||
|
line: line.line,
|
||||||
|
detail: 'Added long encoded blob or token-like payload',
|
||||||
|
excerpt: trimExcerpt(line.content),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return findings
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scanAddedLines(lines: DiffLine[]): Finding[] {
|
||||||
|
const findings = lines
|
||||||
|
.filter(line => !SELF_EXCLUDED_FILES.has(line.file))
|
||||||
|
.flatMap(line => [
|
||||||
|
...findUrlFindings(line),
|
||||||
|
...findCommandFindings(line),
|
||||||
|
...findSensitivePathFindings(line),
|
||||||
|
])
|
||||||
|
return uniqueFindings(findings)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGitDiff(baseRef: string): string {
|
||||||
|
const mergeBase = spawnSync('git', ['merge-base', baseRef, 'HEAD'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (mergeBase.status !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Could not determine merge-base with ${baseRef}: ${mergeBase.stderr.trim() || mergeBase.stdout.trim()}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = mergeBase.stdout.trim()
|
||||||
|
const diff = spawnSync(
|
||||||
|
'git',
|
||||||
|
['diff', '--unified=0', '--no-ext-diff', `${base}...HEAD`],
|
||||||
|
{ encoding: 'utf8' },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (diff.status !== 0) {
|
||||||
|
throw new Error(`git diff failed: ${diff.stderr.trim() || diff.stdout.trim()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return diff.stdout
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldFail(findings: Finding[], failOn: FindingSeverity): boolean {
|
||||||
|
if (failOn === 'medium') {
|
||||||
|
return findings.length > 0
|
||||||
|
}
|
||||||
|
return findings.some(finding => finding.severity === 'high')
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderText(findings: Finding[]): string {
|
||||||
|
if (findings.length === 0) {
|
||||||
|
return 'PR intent scan: no suspicious additions found.'
|
||||||
|
}
|
||||||
|
|
||||||
|
const high = findings.filter(f => f.severity === 'high')
|
||||||
|
const medium = findings.filter(f => f.severity === 'medium')
|
||||||
|
const lines = [
|
||||||
|
`PR intent scan: ${findings.length} finding(s)`,
|
||||||
|
`- high: ${high.length}`,
|
||||||
|
`- medium: ${medium.length}`,
|
||||||
|
'',
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const finding of findings) {
|
||||||
|
lines.push(
|
||||||
|
`[${finding.severity.toUpperCase()}] ${finding.file}:${finding.line} ${finding.detail}`,
|
||||||
|
)
|
||||||
|
lines.push(` ${finding.excerpt}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function run(options: CliOptions): number {
|
||||||
|
const diff = getGitDiff(options.baseRef)
|
||||||
|
const addedLines = parseAddedLines(diff)
|
||||||
|
const findings = scanAddedLines(addedLines)
|
||||||
|
|
||||||
|
if (options.json) {
|
||||||
|
process.stdout.write(
|
||||||
|
`${JSON.stringify(
|
||||||
|
{
|
||||||
|
baseRef: options.baseRef,
|
||||||
|
addedLines: addedLines.length,
|
||||||
|
findings,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}\n`,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
process.stdout.write(`${renderText(findings)}\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return shouldFail(findings, options.failOn) ? 1 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.main) {
|
||||||
|
const options = parseOptions(process.argv.slice(2))
|
||||||
|
process.exitCode = run(options)
|
||||||
|
}
|
||||||
393
scripts/render-coverage-heatmap.ts
Normal file
393
scripts/render-coverage-heatmap.ts
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
import { mkdir, readFile, writeFile } from 'fs/promises'
|
||||||
|
import { dirname, resolve } from 'path'
|
||||||
|
|
||||||
|
type FileCoverage = {
|
||||||
|
path: string
|
||||||
|
found: number
|
||||||
|
hit: number
|
||||||
|
chunks: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type DirectoryCoverage = {
|
||||||
|
path: string
|
||||||
|
found: number
|
||||||
|
hit: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const LCOV_PATH = resolve(process.cwd(), 'coverage/lcov.info')
|
||||||
|
const HTML_PATH = resolve(process.cwd(), 'coverage/index.html')
|
||||||
|
const CHUNK_COUNT = 20
|
||||||
|
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
function bucketColor(ratio: number): string {
|
||||||
|
if (ratio >= 0.9) return '#166534'
|
||||||
|
if (ratio >= 0.75) return '#15803d'
|
||||||
|
if (ratio >= 0.5) return '#65a30d'
|
||||||
|
if (ratio > 0) return '#a3a3a3'
|
||||||
|
return '#262626'
|
||||||
|
}
|
||||||
|
|
||||||
|
function coverageLabel(ratio: number): string {
|
||||||
|
return `${Math.round(ratio * 100)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
function coverageRatio(found: number, hit: number): number {
|
||||||
|
return found === 0 ? 0 : hit / found
|
||||||
|
}
|
||||||
|
|
||||||
|
function bucketGlyph(ratio: number): string {
|
||||||
|
if (ratio >= 0.9) return '█'
|
||||||
|
if (ratio >= 0.75) return '▓'
|
||||||
|
if (ratio >= 0.5) return '▒'
|
||||||
|
if (ratio > 0) return '░'
|
||||||
|
return '·'
|
||||||
|
}
|
||||||
|
|
||||||
|
function terminalBar(chunks: number[]): string {
|
||||||
|
return chunks.map(bucketGlyph).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeDirectories(files: FileCoverage[]): DirectoryCoverage[] {
|
||||||
|
const dirs = new Map<string, DirectoryCoverage>()
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const dir =
|
||||||
|
file.path.includes('/') ? file.path.slice(0, file.path.lastIndexOf('/')) : '.'
|
||||||
|
const current = dirs.get(dir) ?? { path: dir, found: 0, hit: 0 }
|
||||||
|
current.found += file.found
|
||||||
|
current.hit += file.hit
|
||||||
|
dirs.set(dir, current)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...dirs.values()].sort((a, b) => {
|
||||||
|
const left = coverageRatio(a.found, a.hit)
|
||||||
|
const right = coverageRatio(b.found, b.hit)
|
||||||
|
if (right !== left) return right - left
|
||||||
|
return b.found - a.found
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTerminalReport(files: FileCoverage[]): string {
|
||||||
|
const totalFound = files.reduce((sum, file) => sum + file.found, 0)
|
||||||
|
const totalHit = files.reduce((sum, file) => sum + file.hit, 0)
|
||||||
|
const totalRatio = coverageRatio(totalFound, totalHit)
|
||||||
|
const overallChunks = new Array(CHUNK_COUNT).fill(totalRatio)
|
||||||
|
const topDirectories = summarizeDirectories(files)
|
||||||
|
.filter(dir => dir.found > 0)
|
||||||
|
.slice(0, 8)
|
||||||
|
const lowestFiles = [...files]
|
||||||
|
.filter(file => file.found >= 20)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const left = coverageRatio(a.found, a.hit)
|
||||||
|
const right = coverageRatio(b.found, b.hit)
|
||||||
|
if (left !== right) return left - right
|
||||||
|
return b.found - a.found
|
||||||
|
})
|
||||||
|
.slice(0, 10)
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
'',
|
||||||
|
'Coverage Activity',
|
||||||
|
`${terminalBar(overallChunks)} ${coverageLabel(totalRatio)} ${totalHit}/${totalFound} lines ${files.length} files`,
|
||||||
|
'',
|
||||||
|
'Top Directories',
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const dir of topDirectories) {
|
||||||
|
const ratio = coverageRatio(dir.found, dir.hit)
|
||||||
|
lines.push(
|
||||||
|
`${terminalBar(new Array(12).fill(ratio))} ${coverageLabel(ratio).padStart(4)} ${String(dir.hit).padStart(5)}/${String(dir.found).padEnd(5)} ${dir.path}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('', 'Lowest Coverage Files')
|
||||||
|
|
||||||
|
for (const file of lowestFiles) {
|
||||||
|
const ratio = coverageRatio(file.found, file.hit)
|
||||||
|
lines.push(
|
||||||
|
`${terminalBar(file.chunks).padEnd(CHUNK_COUNT)} ${coverageLabel(ratio).padStart(4)} ${String(file.hit).padStart(5)}/${String(file.found).padEnd(5)} ${file.path}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('', `HTML report: ${HTML_PATH}`)
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLcov(content: string): FileCoverage[] {
|
||||||
|
const files: FileCoverage[] = []
|
||||||
|
const sections = content.split('end_of_record')
|
||||||
|
|
||||||
|
for (const rawSection of sections) {
|
||||||
|
const section = rawSection.trim()
|
||||||
|
if (!section) continue
|
||||||
|
|
||||||
|
const lines = section.split('\n')
|
||||||
|
let filePath = ''
|
||||||
|
const lineHits = new Map<number, number>()
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('SF:')) {
|
||||||
|
filePath = line.slice(3).trim()
|
||||||
|
} else if (line.startsWith('DA:')) {
|
||||||
|
const [lineNumberText, hitText] = line.slice(3).split(',')
|
||||||
|
const lineNumber = Number(lineNumberText)
|
||||||
|
const hits = Number(hitText)
|
||||||
|
if (Number.isFinite(lineNumber) && Number.isFinite(hits)) {
|
||||||
|
lineHits.set(lineNumber, hits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filePath || lineHits.size === 0) continue
|
||||||
|
|
||||||
|
const ordered = [...lineHits.entries()].sort((a, b) => a[0] - b[0])
|
||||||
|
const found = ordered.length
|
||||||
|
const hit = ordered.filter(([, hits]) => hits > 0).length
|
||||||
|
const chunkSize = Math.max(1, Math.ceil(found / CHUNK_COUNT))
|
||||||
|
const chunks: number[] = []
|
||||||
|
|
||||||
|
for (let index = 0; index < found; index += chunkSize) {
|
||||||
|
const slice = ordered.slice(index, index + chunkSize)
|
||||||
|
const covered = slice.filter(([, hits]) => hits > 0).length
|
||||||
|
chunks.push(slice.length === 0 ? 0 : covered / slice.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
while (chunks.length < CHUNK_COUNT) {
|
||||||
|
chunks.push(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
files.push({
|
||||||
|
path: filePath,
|
||||||
|
found,
|
||||||
|
hit,
|
||||||
|
chunks: chunks.slice(0, CHUNK_COUNT),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return files.sort((a, b) => {
|
||||||
|
const left = a.found === 0 ? 0 : a.hit / a.found
|
||||||
|
const right = b.found === 0 ? 0 : b.hit / b.found
|
||||||
|
if (right !== left) return right - left
|
||||||
|
return a.path.localeCompare(b.path)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHtml(files: FileCoverage[]): string {
|
||||||
|
const totalFound = files.reduce((sum, file) => sum + file.found, 0)
|
||||||
|
const totalHit = files.reduce((sum, file) => sum + file.hit, 0)
|
||||||
|
const totalRatio = totalFound === 0 ? 0 : totalHit / totalFound
|
||||||
|
|
||||||
|
const cards = [
|
||||||
|
['Files', String(files.length)],
|
||||||
|
['Covered Lines', `${totalHit}/${totalFound}`],
|
||||||
|
['Line Coverage', coverageLabel(totalRatio)],
|
||||||
|
]
|
||||||
|
|
||||||
|
const rows = files
|
||||||
|
.map(file => {
|
||||||
|
const ratio = file.found === 0 ? 0 : file.hit / file.found
|
||||||
|
const squares = file.chunks
|
||||||
|
.map(
|
||||||
|
(chunk, index) =>
|
||||||
|
`<span class="cell" title="Chunk ${index + 1}: ${coverageLabel(chunk)}" style="background:${bucketColor(chunk)}"></span>`,
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td class="file">${escapeHtml(file.path)}</td>
|
||||||
|
<td class="percent">${coverageLabel(ratio)}</td>
|
||||||
|
<td class="lines">${file.hit}/${file.found}</td>
|
||||||
|
<td class="heatmap">${squares}</td>
|
||||||
|
</tr>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
const summary = cards
|
||||||
|
.map(
|
||||||
|
([label, value]) => `
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-label">${escapeHtml(label)}</div>
|
||||||
|
<div class="card-value">${escapeHtml(value)}</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>OpenClaude Coverage</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #09090b;
|
||||||
|
--panel: #111113;
|
||||||
|
--panel-2: #18181b;
|
||||||
|
--border: #27272a;
|
||||||
|
--text: #fafafa;
|
||||||
|
--muted: #a1a1aa;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: linear-gradient(180deg, #09090b 0%, #0f0f12 100%);
|
||||||
|
color: var(--text);
|
||||||
|
font: 14px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
max-width: 1440px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 24px 48px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 32px;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin: 24px 0;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: rgba(24, 24, 27, 0.92);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
}
|
||||||
|
.card-label {
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.card-value {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.table-wrap {
|
||||||
|
background: rgba(17, 17, 19, 0.94);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 18px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
thead th {
|
||||||
|
text-align: left;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 500;
|
||||||
|
background: rgba(24, 24, 27, 0.95);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
tbody tr + tr td {
|
||||||
|
border-top: 1px solid rgba(39, 39, 42, 0.65);
|
||||||
|
}
|
||||||
|
.file {
|
||||||
|
width: 48%;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.percent, .lines {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.heatmap {
|
||||||
|
width: 32%;
|
||||||
|
min-width: 280px;
|
||||||
|
}
|
||||||
|
.cell {
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
margin-right: 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.05);
|
||||||
|
}
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 16px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.legend-scale {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.summary {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.heatmap {
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Coverage Activity</h1>
|
||||||
|
<p>Git-style heatmap generated from coverage/lcov.info</p>
|
||||||
|
<section class="summary">${summary}</section>
|
||||||
|
<section class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>File</th>
|
||||||
|
<th>Coverage</th>
|
||||||
|
<th>Lines</th>
|
||||||
|
<th>Activity</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
<div class="legend">
|
||||||
|
<span>Less</span>
|
||||||
|
<div class="legend-scale">
|
||||||
|
<span class="cell" style="background:#262626"></span>
|
||||||
|
<span class="cell" style="background:#a3a3a3"></span>
|
||||||
|
<span class="cell" style="background:#65a30d"></span>
|
||||||
|
<span class="cell" style="background:#15803d"></span>
|
||||||
|
<span class="cell" style="background:#166534"></span>
|
||||||
|
</div>
|
||||||
|
<span>More</span>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const content = await readFile(LCOV_PATH, 'utf8')
|
||||||
|
const files = parseLcov(content)
|
||||||
|
const html = buildHtml(files)
|
||||||
|
await mkdir(dirname(HTML_PATH), { recursive: true })
|
||||||
|
await writeFile(HTML_PATH, html, 'utf8')
|
||||||
|
console.log(buildTerminalReport(files))
|
||||||
|
console.log(`coverage heatmap written to ${HTML_PATH}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await main()
|
||||||
@@ -19,6 +19,10 @@ BANNED=(
|
|||||||
"/var/run/secrets/kubernetes"
|
"/var/run/secrets/kubernetes"
|
||||||
"/proc/self/mountinfo"
|
"/proc/self/mountinfo"
|
||||||
"tengu_internal_record_permission_context"
|
"tengu_internal_record_permission_context"
|
||||||
|
"anthropic-serve"
|
||||||
|
"infra.ant.dev"
|
||||||
|
"claude-code-feedback"
|
||||||
|
"C07VBSHV7EV"
|
||||||
)
|
)
|
||||||
|
|
||||||
echo "Checking $DIST for banned patterns..."
|
echo "Checking $DIST for banned patterns..."
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ const BANNED_PATTERNS = [
|
|||||||
'/var/run/secrets/kubernetes',
|
'/var/run/secrets/kubernetes',
|
||||||
'/proc/self/mountinfo',
|
'/proc/self/mountinfo',
|
||||||
'tengu_internal_record_permission_context',
|
'tengu_internal_record_permission_context',
|
||||||
|
'anthropic-serve',
|
||||||
|
'infra.ant.dev',
|
||||||
|
'claude-code-feedback',
|
||||||
|
'C07VBSHV7EV',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
if (!existsSync(DIST)) {
|
if (!existsSync(DIST)) {
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ type State = {
|
|||||||
agentColorIndex: number
|
agentColorIndex: number
|
||||||
// Last API request for bug reports
|
// Last API request for bug reports
|
||||||
lastAPIRequest: Omit<BetaMessageStreamParams, 'messages'> | null
|
lastAPIRequest: Omit<BetaMessageStreamParams, 'messages'> | null
|
||||||
// Messages from the last API request (ant-only; reference, not clone).
|
// Messages from the last API request (internal-only; reference, not clone).
|
||||||
// Captures the exact post-compaction, CLAUDE.md-injected message set sent
|
// Captures the exact post-compaction, CLAUDE.md-injected message set sent
|
||||||
// to the API so /share's serialized_conversation.json reflects reality.
|
// to the API so /share's serialized_conversation.json reflects reality.
|
||||||
lastAPIRequestMessages: BetaMessageStreamParams['messages'] | null
|
lastAPIRequestMessages: BetaMessageStreamParams['messages'] | null
|
||||||
@@ -185,7 +185,7 @@ type State = {
|
|||||||
agentId: string | null
|
agentId: string | null
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
// Track slow operations for dev bar display (ant-only)
|
// Track slow operations for dev bar display (internal-only)
|
||||||
slowOperations: Array<{
|
slowOperations: Array<{
|
||||||
operation: string
|
operation: string
|
||||||
durationMs: number
|
durationMs: number
|
||||||
@@ -1756,3 +1756,12 @@ export function setPromptId(id: string | null): void {
|
|||||||
STATE.promptId = id
|
STATE.promptId = id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stub for feature-gated REPL bridge (not available in open build)
|
||||||
|
export function isReplBridgeActive(): boolean {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReplBridgeHandle(): null {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Shared bridge auth/URL resolution. Consolidates the ant-only
|
* Shared bridge auth/URL resolution. Consolidates the internal-only
|
||||||
* CLAUDE_BRIDGE_* dev overrides that were previously copy-pasted across
|
* CLAUDE_BRIDGE_* dev overrides that were previously copy-pasted across
|
||||||
* a dozen files — inboundAttachments, BriefTool/upload, bridgeMain,
|
* a dozen files — inboundAttachments, BriefTool/upload, bridgeMain,
|
||||||
* initReplBridge, remoteBridgeCore, daemon workers, /rename,
|
* initReplBridge, remoteBridgeCore, daemon workers, /rename,
|
||||||
* /remote-control.
|
* /remote-control.
|
||||||
*
|
*
|
||||||
* Two layers: *Override() returns the ant-only env var (or undefined);
|
* Two layers: *Override() returns the internal-only env var (or undefined);
|
||||||
* the non-Override versions fall through to the real OAuth store/config.
|
* the non-Override versions fall through to the real OAuth store/config.
|
||||||
* Callers that compose with a different auth source (e.g. daemon workers
|
* Callers that compose with a different auth source (e.g. daemon workers
|
||||||
* using IPC auth) use the Override getters directly.
|
* using IPC auth) use the Override getters directly.
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ export function checkBridgeMinVersion(): string | null {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Default for remoteControlAtStartup when the user hasn't explicitly set it.
|
* Default for remoteControlAtStartup when the user hasn't explicitly set it.
|
||||||
* When the CCR_AUTO_CONNECT build flag is present (ant-only) and the
|
* When the CCR_AUTO_CONNECT build flag is present (internal-only) and the
|
||||||
* tengu_cobalt_harbor GrowthBook gate is on, all sessions connect to CCR by
|
* tengu_cobalt_harbor GrowthBook gate is on, all sessions connect to CCR by
|
||||||
* default — the user can still opt out by setting remoteControlAtStartup=false
|
* default — the user can still opt out by setting remoteControlAtStartup=false
|
||||||
* in config (explicit settings always win over this default).
|
* in config (explicit settings always win over this default).
|
||||||
|
|||||||
@@ -1520,7 +1520,7 @@ export async function runBridgeLoop(
|
|||||||
// Skip when the loop exited fatally (env expired, auth failed, give-up) —
|
// Skip when the loop exited fatally (env expired, auth failed, give-up) —
|
||||||
// resume is impossible in those cases and the message would contradict the
|
// resume is impossible in those cases and the message would contradict the
|
||||||
// error already printed.
|
// error already printed.
|
||||||
// feature('KAIROS') gate: --session-id is ant-only; without the gate,
|
// feature('KAIROS') gate: --session-id is internal-only; without the gate,
|
||||||
// revert to the pre-PR behavior (archive + deregister on every shutdown).
|
// revert to the pre-PR behavior (archive + deregister on every shutdown).
|
||||||
if (
|
if (
|
||||||
feature('KAIROS') &&
|
feature('KAIROS') &&
|
||||||
@@ -1888,7 +1888,7 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|||||||
|
|
||||||
async function printHelp(): Promise<void> {
|
async function printHelp(): Promise<void> {
|
||||||
// Use EXTERNAL_PERMISSION_MODES for help text — internal modes (bubble)
|
// Use EXTERNAL_PERMISSION_MODES for help text — internal modes (bubble)
|
||||||
// are ant-only and auto is feature-gated; they're still accepted by validation.
|
// are internal-only and auto is feature-gated; they're still accepted by validation.
|
||||||
const { EXTERNAL_PERMISSION_MODES } = await import('../types/permissions.js')
|
const { EXTERNAL_PERMISSION_MODES } = await import('../types/permissions.js')
|
||||||
const modes = EXTERNAL_PERMISSION_MODES.join(', ')
|
const modes = EXTERNAL_PERMISSION_MODES.join(', ')
|
||||||
const showServer = await isMultiSessionSpawnEnabled()
|
const showServer = await isMultiSessionSpawnEnabled()
|
||||||
@@ -2356,7 +2356,7 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
|||||||
// environment_id and reuse that for registration (idempotent on the
|
// environment_id and reuse that for registration (idempotent on the
|
||||||
// backend). Left undefined otherwise — the backend rejects
|
// backend). Left undefined otherwise — the backend rejects
|
||||||
// client-generated UUIDs and will allocate a fresh environment.
|
// client-generated UUIDs and will allocate a fresh environment.
|
||||||
// feature('KAIROS') gate: --session-id is ant-only; parseArgs already
|
// feature('KAIROS') gate: --session-id is internal-only; parseArgs already
|
||||||
// rejects the flag when the gate is off, so resumeSessionId is always
|
// rejects the flag when the gate is off, so resumeSessionId is always
|
||||||
// undefined here in external builds — this guard is for tree-shaking.
|
// undefined here in external builds — this guard is for tree-shaking.
|
||||||
let reuseEnvironmentId: string | undefined
|
let reuseEnvironmentId: string | undefined
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ export function createBridgeLogger(options: {
|
|||||||
|
|
||||||
if (process.env.USER_TYPE === 'ant' && debugLogPath) {
|
if (process.env.USER_TYPE === 'ant' && debugLogPath) {
|
||||||
writeStatus(
|
writeStatus(
|
||||||
`${chalk.yellow('[ANT-ONLY] Logs:')} ${chalk.dim(debugLogPath)}\n`,
|
`${chalk.yellow('[internal] Logs:')} ${chalk.dim(debugLogPath)}\n`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
writeStatus(`${indicatorColor(indicator)} ${stateText}${suffix}\n`)
|
writeStatus(`${indicatorColor(indicator)} ${stateText}${suffix}\n`)
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ export async function initReplBridge(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// When CLAUDE_BRIDGE_OAUTH_TOKEN is set (ant-only local dev), the bridge
|
// When CLAUDE_BRIDGE_OAUTH_TOKEN is set (internal-only local dev), the bridge
|
||||||
// uses that token directly via getBridgeAccessToken() — keychain state is
|
// uses that token directly via getBridgeAccessToken() — keychain state is
|
||||||
// irrelevant. Skip 2b/2c to preserve that decoupling: an expired keychain
|
// irrelevant. Skip 2b/2c to preserve that decoupling: an expired keychain
|
||||||
// token shouldn't block a bridge connection that doesn't use it.
|
// token shouldn't block a bridge connection that doesn't use it.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
|
// biome-ignore-all assist/source/organizeImports: internal-only import markers must not be reordered
|
||||||
/**
|
/**
|
||||||
* Env-less Remote Control bridge core.
|
* Env-less Remote Control bridge core.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
|
// biome-ignore-all assist/source/organizeImports: internal-only import markers must not be reordered
|
||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
import {
|
import {
|
||||||
createBridgeApiClient,
|
createBridgeApiClient,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { jsonStringify } from '../utils/slowOperations.js'
|
|||||||
*
|
*
|
||||||
* Bridge sessions have SecurityTier=ELEVATED on the server (CCR v2).
|
* Bridge sessions have SecurityTier=ELEVATED on the server (CCR v2).
|
||||||
* The server gates ConnectBridgeWorker on its own flag
|
* The server gates ConnectBridgeWorker on its own flag
|
||||||
* (sessions_elevated_auth_enforcement in Anthropic Main); this CLI-side
|
* (sessions_elevated_auth_enforcement in the server-side main deployment); this CLI-side
|
||||||
* flag controls whether the CLI sends X-Trusted-Device-Token at all.
|
* flag controls whether the CLI sends X-Trusted-Device-Token at all.
|
||||||
* Two flags so rollout can be staged: flip CLI-side first (headers
|
* Two flags so rollout can be staged: flip CLI-side first (headers
|
||||||
* start flowing, server still no-ops), then flip server-side.
|
* start flowing, server still no-ops), then flip server-side.
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { c as _c } from "react-compiler-runtime";
|
import { c as _c } from "react-compiler-runtime";
|
||||||
import { feature } from 'bun:bundle';
|
|
||||||
import figures from 'figures';
|
import figures from 'figures';
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||||
@@ -11,6 +10,7 @@ import { getGlobalConfig } from '../utils/config.js';
|
|||||||
import { isFullscreenActive } from '../utils/fullscreen.js';
|
import { isFullscreenActive } from '../utils/fullscreen.js';
|
||||||
import type { Theme } from '../utils/theme.js';
|
import type { Theme } from '../utils/theme.js';
|
||||||
import { getCompanion } from './companion.js';
|
import { getCompanion } from './companion.js';
|
||||||
|
import { isBuddyEnabled } from './feature.js';
|
||||||
import { renderFace, renderSprite, spriteFrameCount } from './sprites.js';
|
import { renderFace, renderSprite, spriteFrameCount } from './sprites.js';
|
||||||
import { RARITY_COLORS } from './types.js';
|
import { RARITY_COLORS } from './types.js';
|
||||||
const TICK_MS = 500;
|
const TICK_MS = 500;
|
||||||
@@ -165,7 +165,7 @@ function spriteColWidth(nameWidth: number): number {
|
|||||||
// Narrow terminals: 0 — REPL.tsx stacks the one-liner on its own row
|
// Narrow terminals: 0 — REPL.tsx stacks the one-liner on its own row
|
||||||
// (above input in fullscreen, below in scrollback), so no reservation.
|
// (above input in fullscreen, below in scrollback), so no reservation.
|
||||||
export function companionReservedColumns(terminalColumns: number, speaking: boolean): number {
|
export function companionReservedColumns(terminalColumns: number, speaking: boolean): number {
|
||||||
if (!feature('BUDDY')) return 0;
|
if (!isBuddyEnabled()) return 0;
|
||||||
const companion = getCompanion();
|
const companion = getCompanion();
|
||||||
if (!companion || getGlobalConfig().companionMuted) return 0;
|
if (!companion || getGlobalConfig().companionMuted) return 0;
|
||||||
if (terminalColumns < MIN_COLS_FOR_FULL_SPRITE) return 0;
|
if (terminalColumns < MIN_COLS_FOR_FULL_SPRITE) return 0;
|
||||||
@@ -212,7 +212,7 @@ export function CompanionSprite(): React.ReactNode {
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- tick intentionally captured at reaction-change, not tracked
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- tick intentionally captured at reaction-change, not tracked
|
||||||
}, [reaction, setAppState]);
|
}, [reaction, setAppState]);
|
||||||
if (!feature('BUDDY')) return null;
|
if (!isBuddyEnabled()) return null;
|
||||||
const companion = getCompanion();
|
const companion = getCompanion();
|
||||||
if (!companion || getGlobalConfig().companionMuted) return null;
|
if (!companion || getGlobalConfig().companionMuted) return null;
|
||||||
const color = RARITY_COLORS[companion.rarity];
|
const color = RARITY_COLORS[companion.rarity];
|
||||||
@@ -337,7 +337,7 @@ export function CompanionFloatingBubble() {
|
|||||||
t3 = $[4];
|
t3 = $[4];
|
||||||
}
|
}
|
||||||
useEffect(t2, t3);
|
useEffect(t2, t3);
|
||||||
if (!feature("BUDDY") || !reaction) {
|
if (!isBuddyEnabled() || !reaction) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const companion = getCompanion();
|
const companion = getCompanion();
|
||||||
|
|||||||
3
src/buddy/feature.ts
Normal file
3
src/buddy/feature.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function isBuddyEnabled(): boolean {
|
||||||
|
return true
|
||||||
|
}
|
||||||
65
src/buddy/observer.ts
Normal file
65
src/buddy/observer.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import type { Message } from '../types/message.js'
|
||||||
|
import { getGlobalConfig } from '../utils/config.js'
|
||||||
|
import { getUserMessageText } from '../utils/messages.js'
|
||||||
|
import { getCompanion } from './companion.js'
|
||||||
|
|
||||||
|
const DIRECT_REPLIES = [
|
||||||
|
'I am observing.',
|
||||||
|
'I am helping from the corner.',
|
||||||
|
'I saw that.',
|
||||||
|
'Still here.',
|
||||||
|
'Watching closely.',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const PET_REPLIES = [
|
||||||
|
'happy chirp',
|
||||||
|
'tiny victory dance',
|
||||||
|
'quietly approves',
|
||||||
|
'wiggles with joy',
|
||||||
|
'looks pleased',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
function hashString(s: string): number {
|
||||||
|
let h = 2166136261
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
h ^= s.charCodeAt(i)
|
||||||
|
h = Math.imul(h, 16777619)
|
||||||
|
}
|
||||||
|
return h >>> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickDeterministic<T>(items: readonly T[], seed: string): T {
|
||||||
|
return items[hashString(seed) % items.length]!
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fireCompanionObserver(
|
||||||
|
messages: Message[],
|
||||||
|
onReaction: (reaction: string | undefined) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
const companion = getCompanion()
|
||||||
|
if (!companion || getGlobalConfig().companionMuted) return
|
||||||
|
|
||||||
|
const lastUser = [...messages].reverse().find(msg => msg.type === 'user')
|
||||||
|
if (!lastUser) return
|
||||||
|
|
||||||
|
const text = getUserMessageText(lastUser)?.trim()
|
||||||
|
if (!text) return
|
||||||
|
|
||||||
|
const lower = text.toLowerCase()
|
||||||
|
const companionName = companion.name.toLowerCase()
|
||||||
|
|
||||||
|
if (lower.includes('/buddy')) {
|
||||||
|
onReaction(pickDeterministic(PET_REPLIES, text + companion.name))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
lower.includes(companionName) ||
|
||||||
|
lower.includes('buddy') ||
|
||||||
|
lower.includes('companion')
|
||||||
|
) {
|
||||||
|
onReaction(
|
||||||
|
`${companion.name}: ${pickDeterministic(DIRECT_REPLIES, text + companion.personality)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { feature } from 'bun:bundle'
|
|
||||||
import type { Message } from '../types/message.js'
|
import type { Message } from '../types/message.js'
|
||||||
import type { Attachment } from '../utils/attachments.js'
|
import type { Attachment } from '../utils/attachments.js'
|
||||||
import { getGlobalConfig } from '../utils/config.js'
|
import { getGlobalConfig } from '../utils/config.js'
|
||||||
import { getCompanion } from './companion.js'
|
import { getCompanion } from './companion.js'
|
||||||
|
import { isBuddyEnabled } from './feature.js'
|
||||||
|
|
||||||
export function companionIntroText(name: string, species: string): string {
|
export function companionIntroText(name: string, species: string): string {
|
||||||
return `# Companion
|
return `# Companion
|
||||||
@@ -15,7 +15,7 @@ When the user addresses ${name} directly (by name), its bubble will answer. Your
|
|||||||
export function getCompanionIntroAttachment(
|
export function getCompanionIntroAttachment(
|
||||||
messages: Message[] | undefined,
|
messages: Message[] | undefined,
|
||||||
): Attachment[] {
|
): Attachment[] {
|
||||||
if (!feature('BUDDY')) return []
|
if (!isBuddyEnabled()) return []
|
||||||
const companion = getCompanion()
|
const companion = getCompanion()
|
||||||
if (!companion || getGlobalConfig().companionMuted) return []
|
if (!companion || getGlobalConfig().companionMuted) return []
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { c as _c } from "react-compiler-runtime";
|
import { c as _c } from "react-compiler-runtime";
|
||||||
import { feature } from 'bun:bundle';
|
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { useNotifications } from '../context/notifications.js';
|
import { useNotifications } from '../context/notifications.js';
|
||||||
import { Text } from '../ink.js';
|
import { Text } from '../ink.js';
|
||||||
import { getGlobalConfig } from '../utils/config.js';
|
import { getGlobalConfig } from '../utils/config.js';
|
||||||
import { getRainbowColor } from '../utils/thinking.js';
|
import { getRainbowColor } from '../utils/thinking.js';
|
||||||
|
import { isBuddyEnabled } from './feature.js';
|
||||||
|
|
||||||
// Local date, not UTC — 24h rolling wave across timezones. Sustained Twitter
|
// Local date, not UTC — 24h rolling wave across timezones. Sustained Twitter
|
||||||
// buzz instead of a single UTC-midnight spike, gentler on soul-gen load.
|
// buzz instead of a single UTC-midnight spike, gentler on soul-gen load.
|
||||||
@@ -50,7 +50,7 @@ export function useBuddyNotification() {
|
|||||||
let t1;
|
let t1;
|
||||||
if ($[0] !== addNotification || $[1] !== removeNotification) {
|
if ($[0] !== addNotification || $[1] !== removeNotification) {
|
||||||
t0 = () => {
|
t0 = () => {
|
||||||
if (!feature("BUDDY")) {
|
if (!isBuddyEnabled()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const config = getGlobalConfig();
|
const config = getGlobalConfig();
|
||||||
@@ -80,7 +80,7 @@ export function findBuddyTriggerPositions(text: string): Array<{
|
|||||||
start: number;
|
start: number;
|
||||||
end: number;
|
end: number;
|
||||||
}> {
|
}> {
|
||||||
if (!feature('BUDDY')) return [];
|
if (!isBuddyEnabled()) return [];
|
||||||
const triggers: Array<{
|
const triggers: Array<{
|
||||||
start: number;
|
start: number;
|
||||||
end: number;
|
end: number;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
|
// biome-ignore-all assist/source/organizeImports: internal-only import markers must not be reordered
|
||||||
import { feature } from 'bun:bundle'
|
import { feature } from 'bun:bundle'
|
||||||
import { readFile, stat } from 'fs/promises'
|
import { readFile, stat } from 'fs/promises'
|
||||||
import { dirname } from 'path'
|
import { dirname } from 'path'
|
||||||
@@ -2829,7 +2829,7 @@ function runHeadlessStreaming(
|
|||||||
|
|
||||||
if (message.type === 'control_request') {
|
if (message.type === 'control_request') {
|
||||||
if (message.request.subtype === 'interrupt') {
|
if (message.request.subtype === 'interrupt') {
|
||||||
// Track escapes for attribution (ant-only feature)
|
// Track escapes for attribution (internal-only feature)
|
||||||
if (feature('COMMIT_ATTRIBUTION')) {
|
if (feature('COMMIT_ATTRIBUTION')) {
|
||||||
setAppState(prev => ({
|
setAppState(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -3765,7 +3765,7 @@ function runHeadlessStreaming(
|
|||||||
...getSettingsWithSources(),
|
...getSettingsWithSources(),
|
||||||
applied: {
|
applied: {
|
||||||
model,
|
model,
|
||||||
// Numeric effort (ant-only) → null; SDK schema is string-level only.
|
// Numeric effort (internal-only) → null; SDK schema is string-level only.
|
||||||
effort: typeof effort === 'string' ? effort : null,
|
effort: typeof effort === 'string' ? effort : null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -5025,7 +5025,7 @@ async function loadInitialMessages(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle resume in print mode (accepts session ID or URL)
|
// Handle resume in print mode (accepts session ID or URL)
|
||||||
// URLs are [ANT-ONLY]
|
// URLs are [internal-only]
|
||||||
if (options.resume) {
|
if (options.resume) {
|
||||||
try {
|
try {
|
||||||
logEvent('tengu_resume_print', {})
|
logEvent('tengu_resume_print', {})
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import { getInitialSettings } from 'src/utils/settings/settings.js'
|
|||||||
|
|
||||||
export async function update() {
|
export async function update() {
|
||||||
// Block updates for third-party providers. The update mechanism downloads
|
// Block updates for third-party providers. The update mechanism downloads
|
||||||
// from Anthropic's distribution bucket, which would silently replace the
|
// from the first-party distribution bucket, which would silently replace the
|
||||||
// OpenClaude build (with the OpenAI shim) with the upstream Claude Code
|
// OpenClaude build (with the OpenAI shim) with the upstream Claude Code
|
||||||
// binary (without it).
|
// binary (without it).
|
||||||
if (getAPIProvider() !== 'firstParty') {
|
if (getAPIProvider() !== 'firstParty') {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
|
// biome-ignore-all assist/source/organizeImports: internal-only import markers must not be reordered
|
||||||
import addDir from './commands/add-dir/index.js'
|
import addDir from './commands/add-dir/index.js'
|
||||||
import autofixPr from './commands/autofix-pr/index.js'
|
import autofixPr from './commands/autofix-pr/index.js'
|
||||||
import backfillSessions from './commands/backfill-sessions/index.js'
|
import backfillSessions from './commands/backfill-sessions/index.js'
|
||||||
@@ -59,6 +59,7 @@ import usage from './commands/usage/index.js'
|
|||||||
import theme from './commands/theme/index.js'
|
import theme from './commands/theme/index.js'
|
||||||
import vim from './commands/vim/index.js'
|
import vim from './commands/vim/index.js'
|
||||||
import { feature } from 'bun:bundle'
|
import { feature } from 'bun:bundle'
|
||||||
|
import { isBuddyEnabled } from './buddy/feature.js'
|
||||||
// Dead code elimination: conditional imports
|
// Dead code elimination: conditional imports
|
||||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||||
const proactive =
|
const proactive =
|
||||||
@@ -117,7 +118,7 @@ const forkCmd = feature('FORK_SUBAGENT')
|
|||||||
require('./commands/fork/index.js') as typeof import('./commands/fork/index.js')
|
require('./commands/fork/index.js') as typeof import('./commands/fork/index.js')
|
||||||
).default
|
).default
|
||||||
: null
|
: null
|
||||||
const buddy = feature('BUDDY')
|
const buddy = isBuddyEnabled()
|
||||||
? (
|
? (
|
||||||
require('./commands/buddy/index.js') as typeof import('./commands/buddy/index.js')
|
require('./commands/buddy/index.js') as typeof import('./commands/buddy/index.js')
|
||||||
).default
|
).default
|
||||||
|
|||||||
185
src/commands/buddy/buddy.tsx
Normal file
185
src/commands/buddy/buddy.tsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js'
|
||||||
|
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
|
||||||
|
import { companionUserId, getCompanion, rollWithSeed } from '../../buddy/companion.js'
|
||||||
|
import type { StoredCompanion } from '../../buddy/types.js'
|
||||||
|
import { COMMON_HELP_ARGS, COMMON_INFO_ARGS } from '../../constants/xml.js'
|
||||||
|
|
||||||
|
const NAME_PREFIXES = [
|
||||||
|
'Byte',
|
||||||
|
'Echo',
|
||||||
|
'Glint',
|
||||||
|
'Miso',
|
||||||
|
'Nova',
|
||||||
|
'Pixel',
|
||||||
|
'Rune',
|
||||||
|
'Static',
|
||||||
|
'Vector',
|
||||||
|
'Whisk',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const NAME_SUFFIXES = [
|
||||||
|
'bean',
|
||||||
|
'bit',
|
||||||
|
'bud',
|
||||||
|
'dot',
|
||||||
|
'ling',
|
||||||
|
'loop',
|
||||||
|
'moss',
|
||||||
|
'patch',
|
||||||
|
'puff',
|
||||||
|
'spark',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const PERSONALITIES = [
|
||||||
|
'Curious and quietly encouraging',
|
||||||
|
'A patient little watcher with strong debugging instincts',
|
||||||
|
'Playful, observant, and suspicious of flaky tests',
|
||||||
|
'Calm under pressure and fond of clean diffs',
|
||||||
|
'A tiny terminal gremlin who likes successful builds',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const PET_REACTIONS = [
|
||||||
|
'leans into the headpat',
|
||||||
|
'does a proud little bounce',
|
||||||
|
'emits a content beep',
|
||||||
|
'looks delighted',
|
||||||
|
'wiggles happily',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
function hashString(s: string): number {
|
||||||
|
let h = 2166136261
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
h ^= s.charCodeAt(i)
|
||||||
|
h = Math.imul(h, 16777619)
|
||||||
|
}
|
||||||
|
return h >>> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickDeterministic<T>(items: readonly T[], seed: string): T {
|
||||||
|
return items[hashString(seed) % items.length]!
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleCase(s: string): string {
|
||||||
|
return s.charAt(0).toUpperCase() + s.slice(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStoredCompanion(): StoredCompanion {
|
||||||
|
const userId = companionUserId()
|
||||||
|
const { bones } = rollWithSeed(`${userId}:buddy`)
|
||||||
|
const prefix = pickDeterministic(NAME_PREFIXES, `${userId}:prefix`)
|
||||||
|
const suffix = pickDeterministic(NAME_SUFFIXES, `${userId}:suffix`)
|
||||||
|
const personality = pickDeterministic(PERSONALITIES, `${userId}:personality`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: `${prefix}${suffix}`,
|
||||||
|
personality: `${personality}.`,
|
||||||
|
hatchedAt: Date.now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCompanionReaction(
|
||||||
|
context: LocalJSXCommandContext,
|
||||||
|
reaction: string | undefined,
|
||||||
|
pet = false,
|
||||||
|
): void {
|
||||||
|
context.setAppState(prev => ({
|
||||||
|
...prev,
|
||||||
|
companionReaction: reaction,
|
||||||
|
companionPetAt: pet ? Date.now() : prev.companionPetAt,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function showHelp(onDone: LocalJSXCommandOnDone): void {
|
||||||
|
onDone(
|
||||||
|
'Usage: /buddy [status|mute|unmute]\n\nRun /buddy with no args to hatch your companion the first time, then pet it on later runs.',
|
||||||
|
{ display: 'system' },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function call(
|
||||||
|
onDone: LocalJSXCommandOnDone,
|
||||||
|
context: LocalJSXCommandContext,
|
||||||
|
args?: string,
|
||||||
|
): Promise<null> {
|
||||||
|
const arg = args?.trim().toLowerCase() ?? ''
|
||||||
|
|
||||||
|
if (COMMON_HELP_ARGS.includes(arg) || arg === '') {
|
||||||
|
const existing = getCompanion()
|
||||||
|
if (arg !== '' || existing) {
|
||||||
|
if (arg !== '') {
|
||||||
|
showHelp(onDone)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (COMMON_HELP_ARGS.includes(arg)) {
|
||||||
|
showHelp(onDone)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (COMMON_INFO_ARGS.includes(arg) || arg === 'status') {
|
||||||
|
const companion = getCompanion()
|
||||||
|
if (!companion) {
|
||||||
|
onDone('No buddy hatched yet. Run /buddy to hatch one.', {
|
||||||
|
display: 'system',
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
onDone(
|
||||||
|
`${companion.name} is your ${titleCase(companion.rarity)} ${companion.species}. ${companion.personality}`,
|
||||||
|
{ display: 'system' },
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === 'mute' || arg === 'unmute') {
|
||||||
|
const muted = arg === 'mute'
|
||||||
|
saveGlobalConfig(current => ({
|
||||||
|
...current,
|
||||||
|
companionMuted: muted,
|
||||||
|
}))
|
||||||
|
if (muted) {
|
||||||
|
setCompanionReaction(context, undefined)
|
||||||
|
}
|
||||||
|
onDone(`Buddy ${muted ? 'muted' : 'unmuted'}.`, { display: 'system' })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg !== '') {
|
||||||
|
showHelp(onDone)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let companion = getCompanion()
|
||||||
|
if (!companion) {
|
||||||
|
const stored = createStoredCompanion()
|
||||||
|
saveGlobalConfig(current => ({
|
||||||
|
...current,
|
||||||
|
companion: stored,
|
||||||
|
companionMuted: false,
|
||||||
|
}))
|
||||||
|
companion = {
|
||||||
|
...rollWithSeed(`${companionUserId()}:buddy`).bones,
|
||||||
|
...stored,
|
||||||
|
}
|
||||||
|
setCompanionReaction(
|
||||||
|
context,
|
||||||
|
`${companion.name} the ${companion.species} has hatched.`,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
onDone(
|
||||||
|
`${companion.name} the ${companion.species} is now your buddy. Run /buddy again to pet them.`,
|
||||||
|
{ display: 'system' },
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const reaction = `${companion.name} ${pickDeterministic(
|
||||||
|
PET_REACTIONS,
|
||||||
|
`${Date.now()}:${companion.name}`,
|
||||||
|
)}`
|
||||||
|
setCompanionReaction(context, reaction, true)
|
||||||
|
onDone(undefined, { display: 'skip' })
|
||||||
|
return null
|
||||||
|
}
|
||||||
12
src/commands/buddy/index.ts
Normal file
12
src/commands/buddy/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Command } from '../../commands.js'
|
||||||
|
|
||||||
|
const buddy = {
|
||||||
|
type: 'local-jsx',
|
||||||
|
name: 'buddy',
|
||||||
|
description: 'Hatch, pet, and manage your Open Claude companion',
|
||||||
|
immediate: true,
|
||||||
|
argumentHint: '[status|mute|unmute|help]',
|
||||||
|
load: () => import('./buddy.js'),
|
||||||
|
} satisfies Command
|
||||||
|
|
||||||
|
export default buddy
|
||||||
@@ -199,13 +199,13 @@ function formatContextAsMarkdownTable(data: ContextData): string {
|
|||||||
output += `\n`
|
output += `\n`
|
||||||
}
|
}
|
||||||
|
|
||||||
// System tools (ant-only)
|
// System tools (internal-only)
|
||||||
if (
|
if (
|
||||||
systemTools &&
|
systemTools &&
|
||||||
systemTools.length > 0 &&
|
systemTools.length > 0 &&
|
||||||
process.env.USER_TYPE === 'ant'
|
process.env.USER_TYPE === 'ant'
|
||||||
) {
|
) {
|
||||||
output += `### [ANT-ONLY] System Tools\n\n`
|
output += `### [internal] System Tools\n\n`
|
||||||
output += `| Tool | Tokens |\n`
|
output += `| Tool | Tokens |\n`
|
||||||
output += `|------|--------|\n`
|
output += `|------|--------|\n`
|
||||||
for (const tool of systemTools) {
|
for (const tool of systemTools) {
|
||||||
@@ -214,13 +214,13 @@ function formatContextAsMarkdownTable(data: ContextData): string {
|
|||||||
output += `\n`
|
output += `\n`
|
||||||
}
|
}
|
||||||
|
|
||||||
// System prompt sections (ant-only)
|
// System prompt sections (internal-only)
|
||||||
if (
|
if (
|
||||||
systemPromptSections &&
|
systemPromptSections &&
|
||||||
systemPromptSections.length > 0 &&
|
systemPromptSections.length > 0 &&
|
||||||
process.env.USER_TYPE === 'ant'
|
process.env.USER_TYPE === 'ant'
|
||||||
) {
|
) {
|
||||||
output += `### [ANT-ONLY] System Prompt Sections\n\n`
|
output += `### [internal] System Prompt Sections\n\n`
|
||||||
output += `| Section | Tokens |\n`
|
output += `| Section | Tokens |\n`
|
||||||
output += `|---------|--------|\n`
|
output += `|---------|--------|\n`
|
||||||
for (const section of systemPromptSections) {
|
for (const section of systemPromptSections) {
|
||||||
@@ -288,9 +288,9 @@ function formatContextAsMarkdownTable(data: ContextData): string {
|
|||||||
output += `\n`
|
output += `\n`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Message breakdown (ant-only)
|
// Message breakdown (internal-only)
|
||||||
if (messageBreakdown && process.env.USER_TYPE === 'ant') {
|
if (messageBreakdown && process.env.USER_TYPE === 'ant') {
|
||||||
output += `### [ANT-ONLY] Message Breakdown\n\n`
|
output += `### [internal] Message Breakdown\n\n`
|
||||||
output += `| Category | Tokens |\n`
|
output += `| Category | Tokens |\n`
|
||||||
output += `|----------|--------|\n`
|
output += `|----------|--------|\n`
|
||||||
output += `| Tool calls | ${formatTokens(messageBreakdown.toolCallTokens)} |\n`
|
output += `| Tool calls | ${formatTokens(messageBreakdown.toolCallTokens)} |\n`
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const call: LocalCommandCall = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (process.env.USER_TYPE === 'ant') {
|
if (process.env.USER_TYPE === 'ant') {
|
||||||
value += `\n\n[ANT-ONLY] Showing cost anyway:\n ${formatTotalCost()}`
|
value += `\n\n[internal-only] Showing cost anyway:\n ${formatTotalCost()}`
|
||||||
}
|
}
|
||||||
return { type: 'text', value }
|
return { type: 'text', value }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ If the user chose personal CLAUDE.local.md or both: ask about them, not the code
|
|||||||
- Only if Phase 2 found multiple git worktrees: ask whether their worktrees are nested inside the main repo (e.g., \`.claude/worktrees/<name>/\`) or siblings/external (e.g., \`../myrepo-feature/\`). If nested, the upward file walk finds the main repo's CLAUDE.local.md automatically — no special handling needed. If sibling/external, the personal content should live in a home-directory file (e.g., \`~/.claude/<project-name>-instructions.md\`) and each worktree gets a one-line CLAUDE.local.md stub that imports it: \`@~/.claude/<project-name>-instructions.md\`. Never put this import in the project CLAUDE.md — that would check a personal reference into the team-shared file.
|
- Only if Phase 2 found multiple git worktrees: ask whether their worktrees are nested inside the main repo (e.g., \`.claude/worktrees/<name>/\`) or siblings/external (e.g., \`../myrepo-feature/\`). If nested, the upward file walk finds the main repo's CLAUDE.local.md automatically — no special handling needed. If sibling/external, the personal content should live in a home-directory file (e.g., \`~/.claude/<project-name>-instructions.md\`) and each worktree gets a one-line CLAUDE.local.md stub that imports it: \`@~/.claude/<project-name>-instructions.md\`. Never put this import in the project CLAUDE.md — that would check a personal reference into the team-shared file.
|
||||||
- Any communication preferences? (e.g., "be terse", "always explain tradeoffs", "don't summarize at the end")
|
- Any communication preferences? (e.g., "be terse", "always explain tradeoffs", "don't summarize at the end")
|
||||||
|
|
||||||
**Synthesize a proposal from Phase 2 findings** — e.g., format-on-edit if a formatter exists, a \`/verify\` skill if tests exist, a CLAUDE.md note for anything from the gap-fill answers that's a guideline rather than a workflow. For each, pick the artifact type that fits, **constrained by the Phase 1 skills+hooks choice**:
|
**Synthesize a proposal from Phase 2 findings** — e.g., format-on-edit if a formatter exists, a project verification workflow if tests exist, a CLAUDE.md note for anything from the gap-fill answers that's a guideline rather than a workflow. For each, pick the artifact type that fits, **constrained by the Phase 1 skills+hooks choice**:
|
||||||
|
|
||||||
- **Hook** (stricter) — deterministic shell command on a tool event; Claude can't skip it. Fits mechanical, fast, per-edit steps: formatting, linting, running a quick test on the changed file.
|
- **Hook** (stricter) — deterministic shell command on a tool event; Claude can't skip it. Fits mechanical, fast, per-edit steps: formatting, linting, running a quick test on the changed file.
|
||||||
- **Skill** (on-demand) — you or Claude invoke \`/skill-name\` when you want it. Fits workflows that don't belong on every edit: deep verification, session reports, deploys.
|
- **Skill** (on-demand) — you or Claude invoke \`/skill-name\` when you want it. Fits workflows that don't belong on every edit: deep verification, session reports, deploys.
|
||||||
@@ -85,7 +85,7 @@ If the user chose personal CLAUDE.local.md or both: ask about them, not the code
|
|||||||
- **Keep previews compact — the preview box truncates with no scrolling.** One line per item, no blank lines between items, no header. Example preview content:
|
- **Keep previews compact — the preview box truncates with no scrolling.** One line per item, no blank lines between items, no header. Example preview content:
|
||||||
|
|
||||||
• **Format-on-edit hook** (automatic) — \`ruff format <file>\` via PostToolUse
|
• **Format-on-edit hook** (automatic) — \`ruff format <file>\` via PostToolUse
|
||||||
• **/verify skill** (on-demand) — \`make lint && make typecheck && make test\`
|
• **Verification workflow** (on-demand) — \`make lint && make typecheck && make test\`
|
||||||
• **CLAUDE.md note** (guideline) — "run lint/typecheck/test before marking done"
|
• **CLAUDE.md note** (guideline) — "run lint/typecheck/test before marking done"
|
||||||
|
|
||||||
- Option labels stay short ("Looks good", "Drop the hook", "Drop the skill") — the tool auto-adds an "Other" free-text option, so don't add your own catch-all.
|
- Option labels stay short ("Looks good", "Drop the hook", "Drop the skill") — the tool auto-adds an "Other" free-text option, so don't add your own catch-all.
|
||||||
@@ -157,7 +157,7 @@ Skills add capabilities Claude can use on demand without bloating every session.
|
|||||||
|
|
||||||
**First, consume \`skill\` entries from the Phase 3 preference queue.** Each queued skill preference becomes a SKILL.md tailored to what the user described. For each:
|
**First, consume \`skill\` entries from the Phase 3 preference queue.** Each queued skill preference becomes a SKILL.md tailored to what the user described. For each:
|
||||||
- Name it from the preference (e.g., "verify-deep", "session-report", "deploy-sandbox")
|
- Name it from the preference (e.g., "verify-deep", "session-report", "deploy-sandbox")
|
||||||
- Write the body using the user's own words from the interview plus whatever Phase 2 found (test commands, report format, deploy target). If the preference maps to an existing bundled skill (e.g., \`/verify\`), write a project skill that adds the user's specific constraints on top — tell the user the bundled one still exists and theirs is additive.
|
- Write the body using the user's own words from the interview plus whatever Phase 2 found (test commands, report format, deploy target). If the preference maps to an existing project workflow, write a project skill that captures the user's specific constraints on top.
|
||||||
- Ask a quick follow-up if the preference is underspecified (e.g., "which test command should verify-deep run?")
|
- Ask a quick follow-up if the preference is underspecified (e.g., "which test command should verify-deep run?")
|
||||||
|
|
||||||
**Then suggest additional skills** beyond the queue when you find:
|
**Then suggest additional skills** beyond the queue when you find:
|
||||||
|
|||||||
@@ -2187,7 +2187,7 @@ function generateHtmlReport(
|
|||||||
`
|
`
|
||||||
: ''
|
: ''
|
||||||
|
|
||||||
// Build Team Feedback section (collapsible, ant-only)
|
// Build Team Feedback section (collapsible, internal-only)
|
||||||
const ccImprovements =
|
const ccImprovements =
|
||||||
process.env.USER_TYPE === 'ant'
|
process.env.USER_TYPE === 'ant'
|
||||||
? insights.cc_team_improvements?.improvements || []
|
? insights.cc_team_improvements?.improvements || []
|
||||||
@@ -2804,7 +2804,7 @@ export async function generateUsageReport(options?: {
|
|||||||
}> {
|
}> {
|
||||||
let remoteStats: { hosts: RemoteHostInfo[]; totalCopied: number } | undefined
|
let remoteStats: { hosts: RemoteHostInfo[]; totalCopied: number } | undefined
|
||||||
|
|
||||||
// Optionally collect data from remote hosts first (ant-only)
|
// Optionally collect data from remote hosts first (internal-only)
|
||||||
if (process.env.USER_TYPE === 'ant' && options?.collectRemote) {
|
if (process.env.USER_TYPE === 'ant' && options?.collectRemote) {
|
||||||
const destDir = join(getClaudeConfigHomeDir(), 'projects')
|
const destDir = join(getClaudeConfigHomeDir(), 'projects')
|
||||||
const { hosts, totalCopied } = await collectAllRemoteHostData(destDir)
|
const { hosts, totalCopied } = await collectAllRemoteHostData(destDir)
|
||||||
@@ -3072,33 +3072,6 @@ const usageReport: Command = {
|
|||||||
let reportUrl = `file://${htmlPath}`
|
let reportUrl = `file://${htmlPath}`
|
||||||
let uploadHint = ''
|
let uploadHint = ''
|
||||||
|
|
||||||
if (process.env.USER_TYPE === 'ant') {
|
|
||||||
// Try to upload to S3
|
|
||||||
const timestamp = new Date()
|
|
||||||
.toISOString()
|
|
||||||
.replace(/[-:]/g, '')
|
|
||||||
.replace('T', '_')
|
|
||||||
.slice(0, 15)
|
|
||||||
const username = process.env.SAFEUSER || process.env.USER || 'unknown'
|
|
||||||
const filename = `${username}_insights_${timestamp}.html`
|
|
||||||
const s3Path = `s3://anthropic-serve/atamkin/cc-user-reports/${filename}`
|
|
||||||
const s3Url = `https://s3-frontend.infra.ant.dev/anthropic-serve/atamkin/cc-user-reports/${filename}`
|
|
||||||
|
|
||||||
reportUrl = s3Url
|
|
||||||
try {
|
|
||||||
execFileSync('ff', ['cp', htmlPath, s3Path], {
|
|
||||||
timeout: 60000,
|
|
||||||
stdio: 'pipe', // Suppress output
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
// Upload failed - fall back to local file and show upload command
|
|
||||||
reportUrl = `file://${htmlPath}`
|
|
||||||
uploadHint = `\nAutomatic upload failed. Are you on the boron namespace? Try \`use-bo\` and ensure you've run \`sso\`.
|
|
||||||
To share, run: ff cp ${htmlPath} ${s3Path}
|
|
||||||
Then access at: ${s3Url}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build header with stats
|
// Build header with stats
|
||||||
const sessionLabel =
|
const sessionLabel =
|
||||||
data.total_sessions_scanned &&
|
data.total_sessions_scanned &&
|
||||||
@@ -3112,7 +3085,7 @@ Then access at: ${s3Url}`
|
|||||||
`${data.git_commits} commits`,
|
`${data.git_commits} commits`,
|
||||||
].join(' · ')
|
].join(' · ')
|
||||||
|
|
||||||
// Build remote host info (ant-only)
|
// Build remote host info (internal-only)
|
||||||
let remoteInfo = ''
|
let remoteInfo = ''
|
||||||
if (process.env.USER_TYPE === 'ant') {
|
if (process.env.USER_TYPE === 'ant') {
|
||||||
if (remoteStats && remoteStats.totalCopied > 0) {
|
if (remoteStats && remoteStats.totalCopied > 0) {
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import { isBilledAsExtraUsage } from '../../utils/extraUsage.js';
|
|||||||
import { clearFastModeCooldown, isFastModeAvailable, isFastModeEnabled, isFastModeSupportedByModel } from '../../utils/fastMode.js';
|
import { clearFastModeCooldown, isFastModeAvailable, isFastModeEnabled, isFastModeSupportedByModel } from '../../utils/fastMode.js';
|
||||||
import { MODEL_ALIASES } from '../../utils/model/aliases.js';
|
import { MODEL_ALIASES } from '../../utils/model/aliases.js';
|
||||||
import { checkOpus1mAccess, checkSonnet1mAccess } from '../../utils/model/check1mAccess.js';
|
import { checkOpus1mAccess, checkSonnet1mAccess } from '../../utils/model/check1mAccess.js';
|
||||||
|
import type { ModelOption } from '../../utils/model/modelOptions.js';
|
||||||
|
import { discoverOpenAICompatibleModelOptions } from '../../utils/model/openaiModelDiscovery.js';
|
||||||
|
import { getAPIProvider } from '../../utils/model/providers.js';
|
||||||
|
import { getActiveOpenAIModelOptionsCache, setActiveOpenAIModelOptionsCache } from '../../utils/providerProfiles.js';
|
||||||
import { getDefaultMainLoopModelSetting, isOpus1mMergeEnabled, renderDefaultModelSetting } from '../../utils/model/model.js';
|
import { getDefaultMainLoopModelSetting, isOpus1mMergeEnabled, renderDefaultModelSetting } from '../../utils/model/model.js';
|
||||||
import { isModelAllowed } from '../../utils/model/modelAllowlist.js';
|
import { isModelAllowed } from '../../utils/model/modelAllowlist.js';
|
||||||
import { validateModel } from '../../utils/model/validateModel.js';
|
import { validateModel } from '../../utils/model/validateModel.js';
|
||||||
@@ -268,6 +272,33 @@ function _temp8(s_0) {
|
|||||||
function _temp7(s) {
|
function _temp7(s) {
|
||||||
return s.mainLoopModel;
|
return s.mainLoopModel;
|
||||||
}
|
}
|
||||||
|
function haveSameModelOptions(left: ModelOption[], right: ModelOption[]): boolean {
|
||||||
|
if (left.length !== right.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return left.every((option, index) => {
|
||||||
|
const other = right[index];
|
||||||
|
return other !== undefined && option.value === other.value && option.label === other.label && option.description === other.description && option.descriptionForModel === other.descriptionForModel;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function refreshOpenAIModelOptionsCache(): Promise<void> {
|
||||||
|
if (getAPIProvider() !== 'openai') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const discoveredOptions = await discoverOpenAICompatibleModelOptions();
|
||||||
|
if (discoveredOptions.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentOptions = getActiveOpenAIModelOptionsCache();
|
||||||
|
if (haveSameModelOptions(currentOptions, discoveredOptions)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActiveOpenAIModelOptionsCache(discoveredOptions);
|
||||||
|
} catch {
|
||||||
|
// Keep /model usable even if endpoint discovery fails.
|
||||||
|
}
|
||||||
|
}
|
||||||
export const call: LocalJSXCommandCall = async (onDone, _context, args) => {
|
export const call: LocalJSXCommandCall = async (onDone, _context, args) => {
|
||||||
args = args?.trim() || '';
|
args = args?.trim() || '';
|
||||||
if (COMMON_INFO_ARGS.includes(args)) {
|
if (COMMON_INFO_ARGS.includes(args)) {
|
||||||
@@ -288,6 +319,7 @@ export const call: LocalJSXCommandCall = async (onDone, _context, args) => {
|
|||||||
});
|
});
|
||||||
return <SetModelAndClose args={args} onDone={onDone} />;
|
return <SetModelAndClose args={args} onDone={onDone} />;
|
||||||
}
|
}
|
||||||
|
await refreshOpenAIModelOptionsCache();
|
||||||
return <ModelPickerWrapper onDone={onDone} />;
|
return <ModelPickerWrapper onDone={onDone} />;
|
||||||
};
|
};
|
||||||
function renderModelLabel(model: string | null): string {
|
function renderModelLabel(model: string | null): string {
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import type { Command } from '../../commands.js'
|
import type { Command } from '../../commands.js'
|
||||||
import { shouldInferenceConfigCommandBeImmediate } from '../../utils/immediateCommand.js'
|
|
||||||
|
|
||||||
export default {
|
const provider = {
|
||||||
type: 'local-jsx',
|
type: 'local-jsx',
|
||||||
name: 'provider',
|
name: 'provider',
|
||||||
description: 'Set up and save a third-party provider profile for OpenClaude',
|
description: 'Manage API provider profiles',
|
||||||
get immediate() {
|
|
||||||
return shouldInferenceConfigCommandBeImmediate()
|
|
||||||
},
|
|
||||||
load: () => import('./provider.js'),
|
load: () => import('./provider.js'),
|
||||||
} satisfies Command
|
} satisfies Command
|
||||||
|
|
||||||
|
export default provider
|
||||||
|
|||||||
@@ -197,6 +197,23 @@ test('buildProfileSaveMessage maps provider fields without echoing secrets', ()
|
|||||||
expect(message).not.toContain('sk-secret-12345678')
|
expect(message).not.toContain('sk-secret-12345678')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('buildProfileSaveMessage describes Gemini access token / ADC mode clearly', () => {
|
||||||
|
const message = buildProfileSaveMessage(
|
||||||
|
'gemini',
|
||||||
|
{
|
||||||
|
GEMINI_AUTH_MODE: 'access-token',
|
||||||
|
GEMINI_MODEL: 'gemini-2.5-flash',
|
||||||
|
GEMINI_BASE_URL: 'https://generativelanguage.googleapis.com/v1beta/openai',
|
||||||
|
},
|
||||||
|
'D:/codings/Opensource/openclaude/.openclaude-profile.json',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(message).toContain('Saved Google Gemini profile.')
|
||||||
|
expect(message).toContain('Model: gemini-2.5-flash')
|
||||||
|
expect(message).toContain('Credentials: access token (stored securely)')
|
||||||
|
expect(message).not.toContain('AIza')
|
||||||
|
})
|
||||||
|
|
||||||
test('buildCurrentProviderSummary redacts poisoned model and endpoint values', () => {
|
test('buildCurrentProviderSummary redacts poisoned model and endpoint values', () => {
|
||||||
const summary = buildCurrentProviderSummary({
|
const summary = buildCurrentProviderSummary({
|
||||||
processEnv: {
|
processEnv: {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import * as React from 'react'
|
|||||||
|
|
||||||
import type { LocalJSXCommandCall, LocalJSXCommandOnDone } from '../../types/command.js'
|
import type { LocalJSXCommandCall, LocalJSXCommandOnDone } from '../../types/command.js'
|
||||||
import { COMMON_HELP_ARGS, COMMON_INFO_ARGS } from '../../constants/xml.js'
|
import { COMMON_HELP_ARGS, COMMON_INFO_ARGS } from '../../constants/xml.js'
|
||||||
|
import { ProviderManager } from '../../components/ProviderManager.js'
|
||||||
import TextInput from '../../components/TextInput.js'
|
import TextInput from '../../components/TextInput.js'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -36,6 +37,14 @@ import {
|
|||||||
type ProfileFile,
|
type ProfileFile,
|
||||||
type ProviderProfile,
|
type ProviderProfile,
|
||||||
} from '../../utils/providerProfile.js'
|
} from '../../utils/providerProfile.js'
|
||||||
|
import {
|
||||||
|
getGeminiProjectIdHint,
|
||||||
|
mayHaveGeminiAdcCredentials,
|
||||||
|
} from '../../utils/geminiAuth.js'
|
||||||
|
import {
|
||||||
|
readGeminiAccessToken,
|
||||||
|
saveGeminiAccessToken,
|
||||||
|
} from '../../utils/geminiCredentials.js'
|
||||||
import {
|
import {
|
||||||
getGoalDefaultOpenAIModel,
|
getGoalDefaultOpenAIModel,
|
||||||
normalizeRecommendationGoal,
|
normalizeRecommendationGoal,
|
||||||
@@ -60,8 +69,14 @@ type Step =
|
|||||||
baseUrl: string | null
|
baseUrl: string | null
|
||||||
defaultModel: string
|
defaultModel: string
|
||||||
}
|
}
|
||||||
|
| { name: 'gemini-auth-method' }
|
||||||
| { name: 'gemini-key' }
|
| { name: 'gemini-key' }
|
||||||
| { name: 'gemini-model'; apiKey: string }
|
| { name: 'gemini-access-token' }
|
||||||
|
| {
|
||||||
|
name: 'gemini-model'
|
||||||
|
apiKey?: string
|
||||||
|
authMode: 'api-key' | 'access-token' | 'adc'
|
||||||
|
}
|
||||||
| { name: 'codex-check' }
|
| { name: 'codex-check' }
|
||||||
|
|
||||||
type CurrentProviderSummary = {
|
type CurrentProviderSummary = {
|
||||||
@@ -216,9 +231,13 @@ function buildSavedProfileSummary(
|
|||||||
env,
|
env,
|
||||||
),
|
),
|
||||||
credentialLabel:
|
credentialLabel:
|
||||||
maskSecretForDisplay(env.GEMINI_API_KEY) !== undefined
|
env.GEMINI_AUTH_MODE === 'access-token'
|
||||||
? 'configured'
|
? 'access token (stored securely)'
|
||||||
: undefined,
|
: env.GEMINI_AUTH_MODE === 'adc'
|
||||||
|
? 'local ADC'
|
||||||
|
: maskSecretForDisplay(env.GEMINI_API_KEY) !== undefined
|
||||||
|
? 'configured'
|
||||||
|
: undefined,
|
||||||
}
|
}
|
||||||
case 'codex':
|
case 'codex':
|
||||||
return {
|
return {
|
||||||
@@ -427,7 +446,7 @@ function ProviderChooser({
|
|||||||
{
|
{
|
||||||
label: 'Gemini',
|
label: 'Gemini',
|
||||||
value: 'gemini',
|
value: 'gemini',
|
||||||
description: 'Use a Google Gemini API key',
|
description: 'Use Google Gemini with API key, access token, or local ADC',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Codex',
|
label: 'Codex',
|
||||||
@@ -926,7 +945,7 @@ export function ProviderWizard({
|
|||||||
defaultModel: defaults.openAIModel,
|
defaultModel: defaults.openAIModel,
|
||||||
})
|
})
|
||||||
} else if (value === 'gemini') {
|
} else if (value === 'gemini') {
|
||||||
setStep({ name: 'gemini-key' })
|
setStep({ name: 'gemini-auth-method' })
|
||||||
} else if (value === 'clear') {
|
} else if (value === 'clear') {
|
||||||
const filePath = deleteProfileFile()
|
const filePath = deleteProfileFile()
|
||||||
onDone(`Removed saved provider profile at ${filePath}. Restart OpenClaude to go back to normal startup.`, {
|
onDone(`Removed saved provider profile at ${filePath}. Restart OpenClaude to go back to normal startup.`, {
|
||||||
@@ -1066,12 +1085,76 @@ export function ProviderWizard({
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
case 'gemini-auth-method': {
|
||||||
|
const hasShellGeminiKey = Boolean(
|
||||||
|
process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY,
|
||||||
|
)
|
||||||
|
const hasShellGeminiAccessToken = Boolean(process.env.GEMINI_ACCESS_TOKEN)
|
||||||
|
const hasStoredGeminiAccessToken = Boolean(readGeminiAccessToken())
|
||||||
|
const hasAdc = mayHaveGeminiAdcCredentials(process.env)
|
||||||
|
const projectHint = getGeminiProjectIdHint(process.env)
|
||||||
|
|
||||||
|
const options: OptionWithDescription[] = [
|
||||||
|
{
|
||||||
|
label: 'API key',
|
||||||
|
value: 'api-key',
|
||||||
|
description: hasShellGeminiKey
|
||||||
|
? 'Use the current Gemini API key from this shell, or enter a new one'
|
||||||
|
: 'Use a Google Gemini API key',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Access token',
|
||||||
|
value: 'access-token',
|
||||||
|
description: hasShellGeminiAccessToken || hasStoredGeminiAccessToken
|
||||||
|
? `Use ${
|
||||||
|
hasShellGeminiAccessToken
|
||||||
|
? 'the current GEMINI_ACCESS_TOKEN'
|
||||||
|
: 'the securely stored Gemini access token'
|
||||||
|
}`
|
||||||
|
: 'Enter a Gemini access token and store it securely',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Local ADC',
|
||||||
|
value: 'adc',
|
||||||
|
description: hasAdc
|
||||||
|
? `Use local Google ADC credentials${projectHint ? ` (project: ${projectHint})` : ''}`
|
||||||
|
: 'Use local Google ADC credentials after running gcloud auth application-default login',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog title="Gemini setup" onCancel={() => onDone()}>
|
||||||
|
<Box flexDirection="column" gap={1}>
|
||||||
|
<Text>Choose how this Gemini profile should authenticate.</Text>
|
||||||
|
<Select
|
||||||
|
options={options}
|
||||||
|
inlineDescriptions
|
||||||
|
visibleOptionCount={options.length}
|
||||||
|
onChange={value => {
|
||||||
|
if (value === 'api-key') {
|
||||||
|
setStep({ name: 'gemini-key' })
|
||||||
|
} else if (value === 'access-token') {
|
||||||
|
setStep({ name: 'gemini-access-token' })
|
||||||
|
} else {
|
||||||
|
setStep({
|
||||||
|
name: 'gemini-model',
|
||||||
|
authMode: 'adc',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onCancel={() => setStep({ name: 'choose' })}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
case 'gemini-key':
|
case 'gemini-key':
|
||||||
return (
|
return (
|
||||||
<TextEntryDialog
|
<TextEntryDialog
|
||||||
resetStateKey={step.name}
|
resetStateKey={step.name}
|
||||||
title="Gemini setup"
|
title="Gemini setup"
|
||||||
subtitle="Step 1 of 2"
|
subtitle="Step 1 of 3"
|
||||||
description={
|
description={
|
||||||
process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY
|
process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY
|
||||||
? 'Enter a Gemini API key, or leave this blank to reuse the current GEMINI_API_KEY/GOOGLE_API_KEY from this session.'
|
? 'Enter a Gemini API key, or leave this blank to reuse the current GEMINI_API_KEY/GOOGLE_API_KEY from this session.'
|
||||||
@@ -1089,25 +1172,95 @@ export function ProviderWizard({
|
|||||||
process.env.GEMINI_API_KEY ||
|
process.env.GEMINI_API_KEY ||
|
||||||
process.env.GOOGLE_API_KEY ||
|
process.env.GOOGLE_API_KEY ||
|
||||||
''
|
''
|
||||||
setStep({ name: 'gemini-model', apiKey })
|
setStep({ name: 'gemini-model', apiKey, authMode: 'api-key' })
|
||||||
}}
|
}}
|
||||||
onCancel={() => setStep({ name: 'choose' })}
|
onCancel={() => setStep({ name: 'gemini-auth-method' })}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
case 'gemini-access-token': {
|
||||||
|
const currentToken =
|
||||||
|
process.env.GEMINI_ACCESS_TOKEN || readGeminiAccessToken() || ''
|
||||||
|
return (
|
||||||
|
<TextEntryDialog
|
||||||
|
resetStateKey={step.name}
|
||||||
|
title="Gemini setup"
|
||||||
|
subtitle="Step 2 of 3"
|
||||||
|
description={
|
||||||
|
currentToken
|
||||||
|
? 'Enter a Gemini access token, or leave this blank to reuse the current token from this session or secure storage.'
|
||||||
|
: 'Enter a Gemini access token. It will be stored securely for this profile.'
|
||||||
|
}
|
||||||
|
initialValue=""
|
||||||
|
placeholder="ya29...."
|
||||||
|
mask="*"
|
||||||
|
allowEmpty={Boolean(currentToken)}
|
||||||
|
validate={value => {
|
||||||
|
const token = value.trim() || currentToken
|
||||||
|
return token ? null : 'Enter a Gemini access token or go back and choose Local ADC.'
|
||||||
|
}}
|
||||||
|
onSubmit={value => {
|
||||||
|
const token = value.trim() || currentToken
|
||||||
|
const saved = saveGeminiAccessToken(token)
|
||||||
|
if (!saved.success) {
|
||||||
|
onDone(
|
||||||
|
`Failed to save Gemini access token: ${saved.warning ?? 'unknown error'}`,
|
||||||
|
{
|
||||||
|
display: 'system',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setStep({
|
||||||
|
name: 'gemini-model',
|
||||||
|
authMode: 'access-token',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onCancel={() => setStep({ name: 'gemini-auth-method' })}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
case 'gemini-model':
|
case 'gemini-model':
|
||||||
return (
|
return (
|
||||||
<TextEntryDialog
|
<TextEntryDialog
|
||||||
resetStateKey={step.name}
|
resetStateKey={step.name}
|
||||||
title="Gemini setup"
|
title="Gemini setup"
|
||||||
subtitle="Step 2 of 2"
|
subtitle={
|
||||||
description={`Enter a Gemini model name. Leave blank for ${DEFAULT_GEMINI_MODEL}.`}
|
step.authMode === 'api-key'
|
||||||
|
? 'Step 3 of 3'
|
||||||
|
: step.authMode === 'access-token'
|
||||||
|
? 'Step 3 of 3'
|
||||||
|
: 'Step 2 of 2'
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
step.authMode === 'api-key'
|
||||||
|
? `Enter a Gemini model name. Leave blank for ${DEFAULT_GEMINI_MODEL}.`
|
||||||
|
: step.authMode === 'access-token'
|
||||||
|
? `Enter a Gemini model name. Leave blank for ${DEFAULT_GEMINI_MODEL}. This profile will use the stored Gemini access token at runtime.`
|
||||||
|
: `Enter a Gemini model name. Leave blank for ${DEFAULT_GEMINI_MODEL}. This profile will use local Google ADC credentials at runtime.`
|
||||||
|
}
|
||||||
initialValue={defaults.geminiModel}
|
initialValue={defaults.geminiModel}
|
||||||
placeholder={DEFAULT_GEMINI_MODEL}
|
placeholder={DEFAULT_GEMINI_MODEL}
|
||||||
allowEmpty
|
allowEmpty
|
||||||
onSubmit={value => {
|
onSubmit={value => {
|
||||||
|
if (
|
||||||
|
step.authMode === 'adc' &&
|
||||||
|
!mayHaveGeminiAdcCredentials(process.env)
|
||||||
|
) {
|
||||||
|
onDone(
|
||||||
|
'Local ADC credentials were not detected. Run `gcloud auth application-default login` first, then save the Gemini ADC profile again.',
|
||||||
|
{
|
||||||
|
display: 'system',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const env = buildGeminiProfileEnv({
|
const env = buildGeminiProfileEnv({
|
||||||
apiKey: step.apiKey,
|
apiKey: step.apiKey,
|
||||||
|
authMode: step.authMode,
|
||||||
model: value.trim() || DEFAULT_GEMINI_MODEL,
|
model: value.trim() || DEFAULT_GEMINI_MODEL,
|
||||||
processEnv: {},
|
processEnv: {},
|
||||||
})
|
})
|
||||||
@@ -1115,7 +1268,13 @@ export function ProviderWizard({
|
|||||||
finishProfileSave(onDone, 'gemini', env)
|
finishProfileSave(onDone, 'gemini', env)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onCancel={() => setStep({ name: 'gemini-key' })}
|
onCancel={() =>
|
||||||
|
step.authMode === 'api-key'
|
||||||
|
? setStep({ name: 'gemini-key' })
|
||||||
|
: step.authMode === 'access-token'
|
||||||
|
? setStep({ name: 'gemini-access-token' })
|
||||||
|
: setStep({ name: 'gemini-auth-method' })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1131,22 +1290,34 @@ export function ProviderWizard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const call: LocalJSXCommandCall = async (onDone, _context, args) => {
|
export const call: LocalJSXCommandCall = async (onDone, _context, args) => {
|
||||||
const normalizedArgs = args?.trim().toLowerCase() || ''
|
const trimmedArgs = args?.trim().toLowerCase() ?? ''
|
||||||
|
|
||||||
if (COMMON_INFO_ARGS.includes(normalizedArgs)) {
|
if (
|
||||||
onDone(buildUsageText(), { display: 'system' })
|
COMMON_HELP_ARGS.includes(trimmedArgs) ||
|
||||||
return null
|
COMMON_INFO_ARGS.includes(trimmedArgs) ||
|
||||||
|
trimmedArgs === 'help' ||
|
||||||
|
trimmedArgs === '--help' ||
|
||||||
|
trimmedArgs === '-h'
|
||||||
|
) {
|
||||||
|
onDone(
|
||||||
|
'Run /provider to add, edit, delete, or activate provider profiles. The active provider controls base URL, model, and API key.',
|
||||||
|
{ display: 'system' },
|
||||||
|
)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (COMMON_HELP_ARGS.includes(normalizedArgs)) {
|
return (
|
||||||
onDone(buildUsageText(), { display: 'system' })
|
<ProviderManager
|
||||||
return null
|
mode="manage"
|
||||||
}
|
onDone={result => {
|
||||||
|
const message =
|
||||||
|
result?.message ??
|
||||||
|
(result?.action === 'saved'
|
||||||
|
? 'Provider profile updated'
|
||||||
|
: 'Provider manager closed')
|
||||||
|
|
||||||
if (normalizedArgs) {
|
onDone(message, { display: 'system' })
|
||||||
onDone('Usage: /provider', { display: 'system' })
|
}}
|
||||||
return null
|
/>
|
||||||
}
|
)
|
||||||
|
|
||||||
return <ProviderWizard onDone={onDone} />
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export async function setupTerminal(theme: ThemeName): Promise<string> {
|
|||||||
});
|
});
|
||||||
maybeMarkProjectOnboardingComplete();
|
maybeMarkProjectOnboardingComplete();
|
||||||
|
|
||||||
// Install shell completions (ant-only, since the completion command is ant-only)
|
// Install shell completions (internal-only, since the completion command is internal-only)
|
||||||
if ("external" === 'ant') {
|
if ("external" === 'ant') {
|
||||||
result += await setupShellCompletion(theme);
|
result += await setupShellCompletion(theme);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ const DEFAULT_INSTRUCTIONS: string = (typeof _rawPrompt === 'string' ? _rawPromp
|
|||||||
// so the override path is DCE'd from external builds).
|
// so the override path is DCE'd from external builds).
|
||||||
// Shell-set env only, so top-level process.env read is fine
|
// Shell-set env only, so top-level process.env read is fine
|
||||||
// — settings.env never injects this.
|
// — settings.env never injects this.
|
||||||
/* eslint-disable custom-rules/no-process-env-top-level, custom-rules/no-sync-fs -- ant-only dev override; eager top-level read is the point (crash at startup, not silently inside the slash-command try/catch) */
|
/* eslint-disable custom-rules/no-process-env-top-level, custom-rules/no-sync-fs -- internal-only dev override; eager top-level read is the point (crash at startup, not silently inside the slash-command try/catch) */
|
||||||
const ULTRAPLAN_INSTRUCTIONS: string = "external" === 'ant' && process.env.ULTRAPLAN_PROMPT_FILE ? readFileSync(process.env.ULTRAPLAN_PROMPT_FILE, 'utf8').trimEnd() : DEFAULT_INSTRUCTIONS;
|
const ULTRAPLAN_INSTRUCTIONS: string = "external" === 'ant' && process.env.ULTRAPLAN_PROMPT_FILE ? readFileSync(process.env.ULTRAPLAN_PROMPT_FILE, 'utf8').trimEnd() : DEFAULT_INSTRUCTIONS;
|
||||||
/* eslint-enable custom-rules/no-process-env-top-level, custom-rules/no-sync-fs */
|
/* eslint-enable custom-rules/no-process-env-top-level, custom-rules/no-sync-fs */
|
||||||
|
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ export function ContextVisualization(t0) {
|
|||||||
} else {
|
} else {
|
||||||
t4 = $[53];
|
t4 = $[53];
|
||||||
}
|
}
|
||||||
t5 = (systemTools && systemTools.length > 0 || hasDeferredBuiltinTools) && false && <Box flexDirection="column" marginTop={1}><Box><Text bold={true}>[ANT-ONLY] System tools</Text>{hasDeferredBuiltinTools && <Text dimColor={true}> (some loaded on-demand)</Text>}</Box><Box flexDirection="column" marginTop={1}><Text dimColor={true}>Loaded</Text>{systemTools?.map(_temp14)}{deferredBuiltinTools.filter(_temp15).map(_temp16)}</Box>{hasDeferredBuiltinTools && deferredBuiltinTools.some(_temp17) && <Box flexDirection="column" marginTop={1}><Text dimColor={true}>Available</Text>{deferredBuiltinTools.filter(_temp18).map(_temp19)}</Box>}</Box>;
|
t5 = (systemTools && systemTools.length > 0 || hasDeferredBuiltinTools) && false && <Box flexDirection="column" marginTop={1}><Box><Text bold={true}>[internal] System tools</Text>{hasDeferredBuiltinTools && <Text dimColor={true}> (some loaded on-demand)</Text>}</Box><Box flexDirection="column" marginTop={1}><Text dimColor={true}>Loaded</Text>{systemTools?.map(_temp14)}{deferredBuiltinTools.filter(_temp15).map(_temp16)}</Box>{hasDeferredBuiltinTools && deferredBuiltinTools.some(_temp17) && <Box flexDirection="column" marginTop={1}><Text dimColor={true}>Available</Text>{deferredBuiltinTools.filter(_temp18).map(_temp19)}</Box>}</Box>;
|
||||||
$[0] = categories;
|
$[0] = categories;
|
||||||
$[1] = gridRows;
|
$[1] = gridRows;
|
||||||
$[2] = mcpTools;
|
$[2] = mcpTools;
|
||||||
@@ -304,7 +304,7 @@ export function ContextVisualization(t0) {
|
|||||||
}
|
}
|
||||||
let t10;
|
let t10;
|
||||||
if ($[54] !== systemPromptSections) {
|
if ($[54] !== systemPromptSections) {
|
||||||
t10 = systemPromptSections && systemPromptSections.length > 0 && false && <Box flexDirection="column" marginTop={1}><Text bold={true}>[ANT-ONLY] System prompt sections</Text>{systemPromptSections.map(_temp20)}</Box>;
|
t10 = systemPromptSections && systemPromptSections.length > 0 && false && <Box flexDirection="column" marginTop={1}><Text bold={true}>[internal] System prompt sections</Text>{systemPromptSections.map(_temp20)}</Box>;
|
||||||
$[54] = systemPromptSections;
|
$[54] = systemPromptSections;
|
||||||
$[55] = t10;
|
$[55] = t10;
|
||||||
} else {
|
} else {
|
||||||
@@ -336,7 +336,7 @@ export function ContextVisualization(t0) {
|
|||||||
}
|
}
|
||||||
let t14;
|
let t14;
|
||||||
if ($[62] !== messageBreakdown) {
|
if ($[62] !== messageBreakdown) {
|
||||||
t14 = messageBreakdown && false && <Box flexDirection="column" marginTop={1}><Text bold={true}>[ANT-ONLY] Message breakdown</Text><Box flexDirection="column" marginLeft={1}><Box><Text>Tool calls: </Text><Text dimColor={true}>{formatTokens(messageBreakdown.toolCallTokens)} tokens</Text></Box><Box><Text>Tool results: </Text><Text dimColor={true}>{formatTokens(messageBreakdown.toolResultTokens)} tokens</Text></Box><Box><Text>Attachments: </Text><Text dimColor={true}>{formatTokens(messageBreakdown.attachmentTokens)} tokens</Text></Box><Box><Text>Assistant messages (non-tool): </Text><Text dimColor={true}>{formatTokens(messageBreakdown.assistantMessageTokens)} tokens</Text></Box><Box><Text>User messages (non-tool-result): </Text><Text dimColor={true}>{formatTokens(messageBreakdown.userMessageTokens)} tokens</Text></Box></Box>{messageBreakdown.toolCallsByType.length > 0 && <Box flexDirection="column" marginTop={1}><Text bold={true}>[ANT-ONLY] Top tools</Text>{messageBreakdown.toolCallsByType.slice(0, 5).map(_temp26)}</Box>}{messageBreakdown.attachmentsByType.length > 0 && <Box flexDirection="column" marginTop={1}><Text bold={true}>[ANT-ONLY] Top attachments</Text>{messageBreakdown.attachmentsByType.slice(0, 5).map(_temp27)}</Box>}</Box>;
|
t14 = messageBreakdown && false && <Box flexDirection="column" marginTop={1}><Text bold={true}>[internal] Message breakdown</Text><Box flexDirection="column" marginLeft={1}><Box><Text>Tool calls: </Text><Text dimColor={true}>{formatTokens(messageBreakdown.toolCallTokens)} tokens</Text></Box><Box><Text>Tool results: </Text><Text dimColor={true}>{formatTokens(messageBreakdown.toolResultTokens)} tokens</Text></Box><Box><Text>Attachments: </Text><Text dimColor={true}>{formatTokens(messageBreakdown.attachmentTokens)} tokens</Text></Box><Box><Text>Assistant messages (non-tool): </Text><Text dimColor={true}>{formatTokens(messageBreakdown.assistantMessageTokens)} tokens</Text></Box><Box><Text>User messages (non-tool-result): </Text><Text dimColor={true}>{formatTokens(messageBreakdown.userMessageTokens)} tokens</Text></Box></Box>{messageBreakdown.toolCallsByType.length > 0 && <Box flexDirection="column" marginTop={1}><Text bold={true}>[internal] Top tools</Text>{messageBreakdown.toolCallsByType.slice(0, 5).map(_temp26)}</Box>}{messageBreakdown.attachmentsByType.length > 0 && <Box flexDirection="column" marginTop={1}><Text bold={true}>[internal] Top attachments</Text>{messageBreakdown.attachmentsByType.slice(0, 5).map(_temp27)}</Box>}</Box>;
|
||||||
$[62] = messageBreakdown;
|
$[62] = messageBreakdown;
|
||||||
$[63] = t14;
|
$[63] = t14;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function DevBar() {
|
|||||||
const recentOps = t1;
|
const recentOps = t1;
|
||||||
let t2;
|
let t2;
|
||||||
if ($[3] !== recentOps) {
|
if ($[3] !== recentOps) {
|
||||||
t2 = <Text wrap="truncate-end" color="warning">[ANT-ONLY] slow sync: {recentOps}</Text>;
|
t2 = <Text wrap="truncate-end" color="warning">[internal] slow sync: {recentOps}</Text>;
|
||||||
$[3] = recentOps;
|
$[3] = recentOps;
|
||||||
$[4] = t2;
|
$[4] = t2;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export function HelpV2(t0) {
|
|||||||
if (false && antOnlyCommands.length > 0) {
|
if (false && antOnlyCommands.length > 0) {
|
||||||
let t7;
|
let t7;
|
||||||
if ($[26] !== antOnlyCommands || $[27] !== close || $[28] !== columns || $[29] !== maxHeight) {
|
if ($[26] !== antOnlyCommands || $[27] !== close || $[28] !== columns || $[29] !== maxHeight) {
|
||||||
t7 = <Tab key="ant-only" title="[ant-only]"><Commands commands={antOnlyCommands} maxHeight={maxHeight} columns={columns} title="Browse ant-only commands:" onCancel={close} /></Tab>;
|
t7 = <Tab key="internal-only" title="[internal-only]"><Commands commands={antOnlyCommands} maxHeight={maxHeight} columns={columns} title="Browse internal-only commands:" onCancel={close} /></Tab>;
|
||||||
$[26] = antOnlyCommands;
|
$[26] = antOnlyCommands;
|
||||||
$[27] = close;
|
$[27] = close;
|
||||||
$[28] = columns;
|
$[28] = columns;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export function InterruptedByUser() {
|
|||||||
const $ = _c(1);
|
const $ = _c(1);
|
||||||
let t0;
|
let t0;
|
||||||
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
|
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
|
||||||
t0 = <><Text dimColor={true}>Interrupted </Text>{false ? <Text dimColor={true}>· [ANT-ONLY] /issue to report a model issue</Text> : <Text dimColor={true}>· What should Claude do instead?</Text>}</>;
|
t0 = <><Text dimColor={true}>Interrupted </Text>{false ? <Text dimColor={true}>· [internal] /issue to report a model issue</Text> : <Text dimColor={true}>· What should Claude do instead?</Text>}</>;
|
||||||
$[0] = t0;
|
$[0] = t0;
|
||||||
} else {
|
} else {
|
||||||
t0 = $[0];
|
t0 = $[0];
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { c as _c } from "react-compiler-runtime";
|
import { c as _c } from "react-compiler-runtime";
|
||||||
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
|
// biome-ignore-all assist/source/organizeImports: internal-only import markers must not be reordered
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Box, Text, color } from '../../ink.js';
|
import { Box, Text, color } from '../../ink.js';
|
||||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||||
@@ -225,7 +225,7 @@ export function LogoV2() {
|
|||||||
let t22;
|
let t22;
|
||||||
if ($[25] === Symbol.for("react.memo_cache_sentinel")) {
|
if ($[25] === Symbol.for("react.memo_cache_sentinel")) {
|
||||||
t19 = false && !process.env.DEMO_VERSION && <Box paddingLeft={2} flexDirection="column"><Text dimColor={true}>Use /issue to report model behavior issues</Text></Box>;
|
t19 = false && !process.env.DEMO_VERSION && <Box paddingLeft={2} flexDirection="column"><Text dimColor={true}>Use /issue to report model behavior issues</Text></Box>;
|
||||||
t20 = false && !process.env.DEMO_VERSION && <Box paddingLeft={2} flexDirection="column"><Text color="warning">[ANT-ONLY] Logs:</Text><Text dimColor={true}>API calls: {getDisplayPath(getDumpPromptsPath())}</Text><Text dimColor={true}>Debug logs: {getDisplayPath(getDebugLogPath())}</Text>{isDetailedProfilingEnabled() && <Text dimColor={true}>Startup Perf: {getDisplayPath(getStartupPerfLogPath())}</Text>}</Box>;
|
t20 = false && !process.env.DEMO_VERSION && <Box paddingLeft={2} flexDirection="column"><Text color="warning">[internal] Logs:</Text><Text dimColor={true}>API calls: {getDisplayPath(getDumpPromptsPath())}</Text><Text dimColor={true}>Debug logs: {getDisplayPath(getDebugLogPath())}</Text>{isDetailedProfilingEnabled() && <Text dimColor={true}>Startup Perf: {getDisplayPath(getStartupPerfLogPath())}</Text>}</Box>;
|
||||||
t21 = false && <GateOverridesWarning />;
|
t21 = false && <GateOverridesWarning />;
|
||||||
t22 = false && <ExperimentEnrollmentNotice />;
|
t22 = false && <ExperimentEnrollmentNotice />;
|
||||||
$[25] = t19;
|
$[25] = t19;
|
||||||
@@ -502,7 +502,7 @@ export function LogoV2() {
|
|||||||
let t40;
|
let t40;
|
||||||
if ($[86] === Symbol.for("react.memo_cache_sentinel")) {
|
if ($[86] === Symbol.for("react.memo_cache_sentinel")) {
|
||||||
t37 = false && !process.env.DEMO_VERSION && <Box paddingLeft={2} flexDirection="column"><Text dimColor={true}>Use /issue to report model behavior issues</Text></Box>;
|
t37 = false && !process.env.DEMO_VERSION && <Box paddingLeft={2} flexDirection="column"><Text dimColor={true}>Use /issue to report model behavior issues</Text></Box>;
|
||||||
t38 = false && !process.env.DEMO_VERSION && <Box paddingLeft={2} flexDirection="column"><Text color="warning">[ANT-ONLY] Logs:</Text><Text dimColor={true}>API calls: {getDisplayPath(getDumpPromptsPath())}</Text><Text dimColor={true}>Debug logs: {getDisplayPath(getDebugLogPath())}</Text>{isDetailedProfilingEnabled() && <Text dimColor={true}>Startup Perf: {getDisplayPath(getStartupPerfLogPath())}</Text>}</Box>;
|
t38 = false && !process.env.DEMO_VERSION && <Box paddingLeft={2} flexDirection="column"><Text color="warning">[internal] Logs:</Text><Text dimColor={true}>API calls: {getDisplayPath(getDumpPromptsPath())}</Text><Text dimColor={true}>Debug logs: {getDisplayPath(getDebugLogPath())}</Text>{isDetailedProfilingEnabled() && <Text dimColor={true}>Startup Perf: {getDisplayPath(getStartupPerfLogPath())}</Text>}</Box>;
|
||||||
t39 = false && <GateOverridesWarning />;
|
t39 = false && <GateOverridesWarning />;
|
||||||
t40 = false && <ExperimentEnrollmentNotice />;
|
t40 = false && <ExperimentEnrollmentNotice />;
|
||||||
$[86] = t37;
|
$[86] = t37;
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function createWhatsNewFeed(releaseNotes: string[]): FeedConfig {
|
|||||||
});
|
});
|
||||||
const emptyMessage = "external" === 'ant' ? 'Unable to fetch latest claude-cli-internal commits' : 'Check /release-notes for recent updates';
|
const emptyMessage = "external" === 'ant' ? 'Unable to fetch latest claude-cli-internal commits' : 'Check /release-notes for recent updates';
|
||||||
return {
|
return {
|
||||||
title: "external" === 'ant' ? "Open Claude Updates [ANT-ONLY: Latest CC commits]" : "Open Claude Updates",
|
title: "external" === 'ant' ? "Open Claude Updates [internal-only: Latest CC commits]" : "Open Claude Updates",
|
||||||
lines,
|
lines,
|
||||||
footer: lines.length > 0 ? '/release-notes for more' : undefined,
|
footer: lines.length > 0 ? '/release-notes for more' : undefined,
|
||||||
emptyMessage
|
emptyMessage
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* ANT-ONLY: Banner shown in the transcript that prompts users to report
|
* internal-only: Banner shown in the transcript that prompts users to report
|
||||||
* issues via /issue. Appears when friction is detected in the conversation.
|
* issues via /issue. Appears when friction is detected in the conversation.
|
||||||
*/
|
*/
|
||||||
export function IssueFlagBanner() {
|
export function IssueFlagBanner() {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { getCwd } from 'src/utils/cwd.js';
|
|||||||
import { isQueuedCommandEditable, popAllEditable } from 'src/utils/messageQueueManager.js';
|
import { isQueuedCommandEditable, popAllEditable } from 'src/utils/messageQueueManager.js';
|
||||||
import stripAnsi from 'strip-ansi';
|
import stripAnsi from 'strip-ansi';
|
||||||
import { companionReservedColumns } from '../../buddy/CompanionSprite.js';
|
import { companionReservedColumns } from '../../buddy/CompanionSprite.js';
|
||||||
|
import { isBuddyEnabled } from '../../buddy/feature.js';
|
||||||
import { findBuddyTriggerPositions, useBuddyNotification } from '../../buddy/useBuddyNotification.js';
|
import { findBuddyTriggerPositions, useBuddyNotification } from '../../buddy/useBuddyNotification.js';
|
||||||
import { FastModePicker } from '../../commands/fast/fast.js';
|
import { FastModePicker } from '../../commands/fast/fast.js';
|
||||||
import { isUltrareviewEnabled } from '../../commands/review/ultrareviewEnabled.js';
|
import { isUltrareviewEnabled } from '../../commands/review/ultrareviewEnabled.js';
|
||||||
@@ -293,7 +294,7 @@ function PromptInput({
|
|||||||
// the pill returns null for implicit-and-not-reconnecting, so nav must too,
|
// the pill returns null for implicit-and-not-reconnecting, so nav must too,
|
||||||
// otherwise bridge becomes an invisible selection stop.
|
// otherwise bridge becomes an invisible selection stop.
|
||||||
const bridgeFooterVisible = replBridgeConnected && (replBridgeExplicit || replBridgeReconnecting);
|
const bridgeFooterVisible = replBridgeConnected && (replBridgeExplicit || replBridgeReconnecting);
|
||||||
// Tmux pill (ant-only) — visible when there's an active tungsten session
|
// Tmux pill (internal-only) — visible when there's an active tungsten session
|
||||||
const hasTungstenSession = useAppState(s => "external" === 'ant' && s.tungstenActiveSession !== undefined);
|
const hasTungstenSession = useAppState(s => "external" === 'ant' && s.tungstenActiveSession !== undefined);
|
||||||
const tmuxFooterVisible = "external" === 'ant' && hasTungstenSession;
|
const tmuxFooterVisible = "external" === 'ant' && hasTungstenSession;
|
||||||
// WebBrowser pill — visible when a browser is open
|
// WebBrowser pill — visible when a browser is open
|
||||||
@@ -309,7 +310,7 @@ function PromptInput({
|
|||||||
const {
|
const {
|
||||||
companion: _companion,
|
companion: _companion,
|
||||||
companionMuted
|
companionMuted
|
||||||
} = feature('BUDDY') ? getGlobalConfig() : {
|
} = isBuddyEnabled() ? getGlobalConfig() : {
|
||||||
companion: undefined,
|
companion: undefined,
|
||||||
companionMuted: undefined
|
companionMuted: undefined
|
||||||
};
|
};
|
||||||
@@ -1786,7 +1787,7 @@ function PromptInput({
|
|||||||
}
|
}
|
||||||
switch (footerItemSelected) {
|
switch (footerItemSelected) {
|
||||||
case 'companion':
|
case 'companion':
|
||||||
if (feature('BUDDY')) {
|
if (isBuddyEnabled()) {
|
||||||
selectFooterItem(null);
|
selectFooterItem(null);
|
||||||
void onSubmit('/buddy');
|
void onSubmit('/buddy');
|
||||||
}
|
}
|
||||||
@@ -1981,8 +1982,7 @@ function PromptInput({
|
|||||||
});
|
});
|
||||||
}, [effortNotificationText, addNotification, removeNotification]);
|
}, [effortNotificationText, addNotification, removeNotification]);
|
||||||
useBuddyNotification();
|
useBuddyNotification();
|
||||||
const companionSpeaking = feature('BUDDY') ?
|
const companionSpeaking = isBuddyEnabled() ?
|
||||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
|
||||||
useAppState(s => s.companionReaction !== undefined) : false;
|
useAppState(s => s.companionReaction !== undefined) : false;
|
||||||
const {
|
const {
|
||||||
columns,
|
columns,
|
||||||
@@ -2153,7 +2153,7 @@ function PromptInput({
|
|||||||
}} onCancel={() => setShowHistoryPicker(false)} />;
|
}} onCancel={() => setShowHistoryPicker(false)} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show loop mode menu when requested (ant-only, eliminated from external builds)
|
// Show loop mode menu when requested (internal-only, eliminated from external builds)
|
||||||
if (modelPickerElement) {
|
if (modelPickerElement) {
|
||||||
return modelPickerElement;
|
return modelPickerElement;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { c as _c } from "react-compiler-runtime";
|
import { c as _c } from "react-compiler-runtime";
|
||||||
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
|
// biome-ignore-all assist/source/organizeImports: internal-only import markers must not be reordered
|
||||||
import { feature } from 'bun:bundle';
|
import { feature } from 'bun:bundle';
|
||||||
// Dead code elimination: conditional import for COORDINATOR_MODE
|
// Dead code elimination: conditional import for COORDINATOR_MODE
|
||||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||||
@@ -364,7 +364,7 @@ function ModeIndicator({
|
|||||||
// BackgroundTaskStatus is NOT in parts — it renders as a Box sibling so
|
// BackgroundTaskStatus is NOT in parts — it renders as a Box sibling so
|
||||||
// its click-target Box isn't nested inside the <Text wrap="truncate">
|
// its click-target Box isn't nested inside the <Text wrap="truncate">
|
||||||
// wrapper (reconciler throws on Box-in-Text).
|
// wrapper (reconciler throws on Box-in-Text).
|
||||||
// Tmux pill (ant-only) — appears right after tasks in nav order
|
// Tmux pill (internal-only) — appears right after tasks in nav order
|
||||||
...("external" === 'ant' && hasTmuxSession ? [<TungstenPill key="tmux" selected={tmuxSelected} />] : []), ...(isAgentSwarmsEnabled() && hasTeams ? [<TeamStatus key="teams" teamsSelected={teamsSelected} showHint={showHint && !hasBackgroundTasks} />] : []), ...(shouldShowPrStatus ? [<PrBadge key="pr-status" number={prStatus.number!} url={prStatus.url!} reviewState={prStatus.reviewState!} />] : [])];
|
...("external" === 'ant' && hasTmuxSession ? [<TungstenPill key="tmux" selected={tmuxSelected} />] : []), ...(isAgentSwarmsEnabled() && hasTeams ? [<TeamStatus key="teams" teamsSelected={teamsSelected} showHint={showHint && !hasBackgroundTasks} />] : []), ...(shouldShowPrStatus ? [<PrBadge key="pr-status" number={prStatus.number!} url={prStatus.url!} reviewState={prStatus.reviewState!} />] : [])];
|
||||||
|
|
||||||
// Check if any in-process teammates exist (for hint text cycling)
|
// Check if any in-process teammates exist (for hint text cycling)
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
|
||||||
|
import { renderToString } from '../../utils/staticRender.js'
|
||||||
|
|
||||||
|
describe('PromptInputQueuedCommands', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mock.module('../../hooks/useCommandQueue.js', () => ({
|
||||||
|
useCommandQueue: () => [
|
||||||
|
{
|
||||||
|
value: 'Use another library',
|
||||||
|
mode: 'prompt',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module('src/state/AppState.js', () => ({
|
||||||
|
useAppState: (
|
||||||
|
selector: (state: { viewingAgentTaskId?: string; isBriefOnly: boolean }) => unknown,
|
||||||
|
) => selector({ viewingAgentTaskId: undefined, isBriefOnly: false }),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mock.restore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a next-turn guidance banner for queued prompt messages', async () => {
|
||||||
|
const { PromptInputQueuedCommands } = await import('./PromptInputQueuedCommands.js')
|
||||||
|
|
||||||
|
const output = await renderToString(<PromptInputQueuedCommands />, 100)
|
||||||
|
|
||||||
|
expect(output).toContain('1 message queued for next turn')
|
||||||
|
expect(output).toContain('Use another library')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
import { feature } from 'bun:bundle';
|
import { feature } from 'bun:bundle';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Box } from 'src/ink.js';
|
import { Box, Text } from 'src/ink.js';
|
||||||
import { useAppState } from 'src/state/AppState.js';
|
import { useAppState } from 'src/state/AppState.js';
|
||||||
|
import type { AppState } from 'src/state/AppState.js';
|
||||||
import { STATUS_TAG, SUMMARY_TAG, TASK_NOTIFICATION_TAG } from '../../constants/xml.js';
|
import { STATUS_TAG, SUMMARY_TAG, TASK_NOTIFICATION_TAG } from '../../constants/xml.js';
|
||||||
import { QueuedMessageProvider } from '../../context/QueuedMessageContext.js';
|
import { QueuedMessageProvider } from '../../context/QueuedMessageContext.js';
|
||||||
import { useCommandQueue } from '../../hooks/useCommandQueue.js';
|
import { useCommandQueue } from '../../hooks/useCommandQueue.js';
|
||||||
import type { QueuedCommand } from '../../types/textInputTypes.js';
|
import type { QueuedCommand } from '../../types/textInputTypes.js';
|
||||||
import { isQueuedCommandVisible } from '../../utils/messageQueueManager.js';
|
import { isQueuedCommandEditable, isQueuedCommandVisible } from '../../utils/messageQueueManager.js';
|
||||||
import { createUserMessage, EMPTY_LOOKUPS, normalizeMessages } from '../../utils/messages.js';
|
import { createUserMessage, EMPTY_LOOKUPS, normalizeMessages } from '../../utils/messages.js';
|
||||||
import { jsonParse } from '../../utils/slowOperations.js';
|
import { jsonParse } from '../../utils/slowOperations.js';
|
||||||
import { Message } from '../Message.js';
|
import { Message } from '../Message.js';
|
||||||
@@ -70,17 +71,25 @@ function processQueuedCommands(queuedCommands: QueuedCommand[]): QueuedCommand[]
|
|||||||
}
|
}
|
||||||
function PromptInputQueuedCommandsImpl(): React.ReactNode {
|
function PromptInputQueuedCommandsImpl(): React.ReactNode {
|
||||||
const queuedCommands = useCommandQueue();
|
const queuedCommands = useCommandQueue();
|
||||||
const viewingAgent = useAppState(s => !!s.viewingAgentTaskId);
|
const viewingAgent = useAppState((s: AppState) => !!s.viewingAgentTaskId);
|
||||||
// Brief layout: dim queue items + skip the paddingX (brief messages
|
// Brief layout: dim queue items + skip the paddingX (brief messages
|
||||||
// already indent themselves). Gate mirrors the brief-spinner/message
|
// already indent themselves). Gate mirrors the brief-spinner/message
|
||||||
// check elsewhere — no teammate-view override needed since this
|
// check elsewhere — no teammate-view override needed since this
|
||||||
// component early-returns when viewing a teammate.
|
// component early-returns when viewing a teammate.
|
||||||
const useBriefLayout = feature('KAIROS') || feature('KAIROS_BRIEF') ?
|
const useBriefLayout = feature('KAIROS') || feature('KAIROS_BRIEF') ?
|
||||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||||
useAppState(s_0 => s_0.isBriefOnly) : false;
|
useAppState((s_0: AppState) => s_0.isBriefOnly) : false;
|
||||||
|
|
||||||
// createUserMessage mints a fresh UUID per call; without memoization, streaming
|
// createUserMessage mints a fresh UUID per call; without memoization, streaming
|
||||||
// re-renders defeat Message's areMessagePropsEqual (compares uuid) → flicker.
|
// re-renders defeat Message's areMessagePropsEqual (compares uuid) → flicker.
|
||||||
|
const queuedPromptCount = useMemo(
|
||||||
|
() =>
|
||||||
|
queuedCommands.filter(
|
||||||
|
cmd => isQueuedCommandEditable(cmd) && cmd.mode === 'prompt',
|
||||||
|
).length,
|
||||||
|
[queuedCommands],
|
||||||
|
);
|
||||||
|
|
||||||
const messages = useMemo(() => {
|
const messages = useMemo(() => {
|
||||||
if (queuedCommands.length === 0) return null;
|
if (queuedCommands.length === 0) return null;
|
||||||
// task-notification is shown via useInboxNotification; most isMeta commands
|
// task-notification is shown via useInboxNotification; most isMeta commands
|
||||||
@@ -108,6 +117,11 @@ function PromptInputQueuedCommandsImpl(): React.ReactNode {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return <Box marginTop={1} flexDirection="column">
|
return <Box marginTop={1} flexDirection="column">
|
||||||
|
{queuedPromptCount > 0 && <Box marginLeft={2} marginBottom={1}>
|
||||||
|
<Text dimColor>
|
||||||
|
{queuedPromptCount === 1 ? '1 message queued for next turn' : `${queuedPromptCount} messages queued for next turn`}
|
||||||
|
</Text>
|
||||||
|
</Box>}
|
||||||
{messages.map((message, i) => <QueuedMessageProvider key={i} isFirst={i === 0} useBriefLayout={useBriefLayout}>
|
{messages.map((message, i) => <QueuedMessageProvider key={i} isFirst={i === 0} useBriefLayout={useBriefLayout}>
|
||||||
<Message message={message} lookups={EMPTY_LOOKUPS} addMargin={false} tools={[]} commands={[]} verbose={false} inProgressToolUseIDs={EMPTY_SET} progressMessagesForMessage={[]} shouldAnimate={false} shouldShowDot={false} isTranscriptMode={false} isStatic={true} />
|
<Message message={message} lookups={EMPTY_LOOKUPS} addMargin={false} tools={[]} commands={[]} verbose={false} inProgressToolUseIDs={EMPTY_SET} progressMessagesForMessage={[]} shouldAnimate={false} shouldShowDot={false} isTranscriptMode={false} isStatic={true} />
|
||||||
</QueuedMessageProvider>)}
|
</QueuedMessageProvider>)}
|
||||||
|
|||||||
613
src/components/ProviderManager.tsx
Normal file
613
src/components/ProviderManager.tsx
Normal file
@@ -0,0 +1,613 @@
|
|||||||
|
import figures from 'figures'
|
||||||
|
import * as React from 'react'
|
||||||
|
import { Box, Text } from '../ink.js'
|
||||||
|
import { useKeybinding } from '../keybindings/useKeybinding.js'
|
||||||
|
import type { ProviderProfile } from '../utils/config.js'
|
||||||
|
import {
|
||||||
|
addProviderProfile,
|
||||||
|
deleteProviderProfile,
|
||||||
|
getActiveProviderProfile,
|
||||||
|
getProviderPresetDefaults,
|
||||||
|
getProviderProfiles,
|
||||||
|
setActiveProviderProfile,
|
||||||
|
type ProviderPreset,
|
||||||
|
type ProviderProfileInput,
|
||||||
|
updateProviderProfile,
|
||||||
|
} from '../utils/providerProfiles.js'
|
||||||
|
import { Select } from './CustomSelect/index.js'
|
||||||
|
import { Pane } from './design-system/Pane.js'
|
||||||
|
import TextInput from './TextInput.js'
|
||||||
|
|
||||||
|
export type ProviderManagerResult = {
|
||||||
|
action: 'saved' | 'cancelled'
|
||||||
|
activeProfileId?: string
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
mode: 'first-run' | 'manage'
|
||||||
|
onDone: (result?: ProviderManagerResult) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type Screen =
|
||||||
|
| 'menu'
|
||||||
|
| 'select-preset'
|
||||||
|
| 'form'
|
||||||
|
| 'select-active'
|
||||||
|
| 'select-edit'
|
||||||
|
| 'select-delete'
|
||||||
|
|
||||||
|
type DraftField = 'name' | 'baseUrl' | 'model' | 'apiKey'
|
||||||
|
|
||||||
|
type ProviderDraft = Record<DraftField, string>
|
||||||
|
|
||||||
|
const FORM_STEPS: Array<{
|
||||||
|
key: DraftField
|
||||||
|
label: string
|
||||||
|
placeholder: string
|
||||||
|
helpText: string
|
||||||
|
optional?: boolean
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
label: 'Provider name',
|
||||||
|
placeholder: 'e.g. Ollama Home, OpenAI Work',
|
||||||
|
helpText: 'A short label shown in /provider and startup setup.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'baseUrl',
|
||||||
|
label: 'Base URL',
|
||||||
|
placeholder: 'e.g. http://localhost:11434/v1',
|
||||||
|
helpText: 'API base URL used for this provider profile.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'model',
|
||||||
|
label: 'Default model',
|
||||||
|
placeholder: 'e.g. llama3.1:8b',
|
||||||
|
helpText: 'Model name to use when this provider is active.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'apiKey',
|
||||||
|
label: 'API key',
|
||||||
|
placeholder: 'Leave empty if your provider does not require one',
|
||||||
|
helpText: 'Optional. Press Enter with empty value to skip.',
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function toDraft(profile: ProviderProfile): ProviderDraft {
|
||||||
|
return {
|
||||||
|
name: profile.name,
|
||||||
|
baseUrl: profile.baseUrl,
|
||||||
|
model: profile.model,
|
||||||
|
apiKey: profile.apiKey ?? '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function presetToDraft(preset: ProviderPreset): ProviderDraft {
|
||||||
|
const defaults = getProviderPresetDefaults(preset)
|
||||||
|
return {
|
||||||
|
name: defaults.name,
|
||||||
|
baseUrl: defaults.baseUrl,
|
||||||
|
model: defaults.model,
|
||||||
|
apiKey: defaults.apiKey ?? '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileSummary(profile: ProviderProfile, isActive: boolean): string {
|
||||||
|
const activeSuffix = isActive ? ' (active)' : ''
|
||||||
|
const keyInfo = profile.apiKey ? 'key set' : 'no key'
|
||||||
|
const providerKind =
|
||||||
|
profile.provider === 'anthropic' ? 'anthropic' : 'openai-compatible'
|
||||||
|
return `${providerKind} · ${profile.baseUrl} · ${profile.model} · ${keyInfo}${activeSuffix}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||||
|
const [profiles, setProfiles] = React.useState(() => getProviderProfiles())
|
||||||
|
const [activeProfileId, setActiveProfileId] = React.useState(
|
||||||
|
() => getActiveProviderProfile()?.id,
|
||||||
|
)
|
||||||
|
const [screen, setScreen] = React.useState<Screen>(
|
||||||
|
mode === 'first-run' ? 'select-preset' : 'menu',
|
||||||
|
)
|
||||||
|
const [editingProfileId, setEditingProfileId] = React.useState<string | null>(null)
|
||||||
|
const [draftProvider, setDraftProvider] = React.useState<ProviderProfile['provider']>(
|
||||||
|
'openai',
|
||||||
|
)
|
||||||
|
const [draft, setDraft] = React.useState<ProviderDraft>(() =>
|
||||||
|
presetToDraft('ollama'),
|
||||||
|
)
|
||||||
|
const [formStepIndex, setFormStepIndex] = React.useState(0)
|
||||||
|
const [cursorOffset, setCursorOffset] = React.useState(0)
|
||||||
|
const [statusMessage, setStatusMessage] = React.useState<string | undefined>()
|
||||||
|
const [errorMessage, setErrorMessage] = React.useState<string | undefined>()
|
||||||
|
|
||||||
|
const currentStep = FORM_STEPS[formStepIndex] ?? FORM_STEPS[0]
|
||||||
|
const currentStepKey = currentStep.key
|
||||||
|
const currentValue = draft[currentStepKey]
|
||||||
|
|
||||||
|
function refreshProfiles(): void {
|
||||||
|
const nextProfiles = getProviderProfiles()
|
||||||
|
setProfiles(nextProfiles)
|
||||||
|
setActiveProfileId(getActiveProviderProfile()?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeWithCancelled(message: string): void {
|
||||||
|
onDone({ action: 'cancelled', message })
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCreateFromPreset(preset: ProviderPreset): void {
|
||||||
|
const defaults = getProviderPresetDefaults(preset)
|
||||||
|
const nextDraft = {
|
||||||
|
name: defaults.name,
|
||||||
|
baseUrl: defaults.baseUrl,
|
||||||
|
model: defaults.model,
|
||||||
|
apiKey: defaults.apiKey ?? '',
|
||||||
|
}
|
||||||
|
setEditingProfileId(null)
|
||||||
|
setDraftProvider(defaults.provider ?? 'openai')
|
||||||
|
setDraft(nextDraft)
|
||||||
|
setFormStepIndex(0)
|
||||||
|
setCursorOffset(nextDraft.name.length)
|
||||||
|
setErrorMessage(undefined)
|
||||||
|
setScreen('form')
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEditProfile(profileId: string): void {
|
||||||
|
const existing = profiles.find(profile => profile.id === profileId)
|
||||||
|
if (!existing) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextDraft = toDraft(existing)
|
||||||
|
setEditingProfileId(profileId)
|
||||||
|
setDraftProvider(existing.provider ?? 'openai')
|
||||||
|
setDraft(nextDraft)
|
||||||
|
setFormStepIndex(0)
|
||||||
|
setCursorOffset(nextDraft.name.length)
|
||||||
|
setErrorMessage(undefined)
|
||||||
|
setScreen('form')
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistDraft(): void {
|
||||||
|
const payload: ProviderProfileInput = {
|
||||||
|
provider: draftProvider,
|
||||||
|
name: draft.name,
|
||||||
|
baseUrl: draft.baseUrl,
|
||||||
|
model: draft.model,
|
||||||
|
apiKey: draft.apiKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
const saved = editingProfileId
|
||||||
|
? updateProviderProfile(editingProfileId, payload)
|
||||||
|
: addProviderProfile(payload, { makeActive: true })
|
||||||
|
|
||||||
|
if (!saved) {
|
||||||
|
setErrorMessage('Could not save provider. Fill all required fields.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshProfiles()
|
||||||
|
setStatusMessage(
|
||||||
|
editingProfileId
|
||||||
|
? `Updated provider: ${saved.name}`
|
||||||
|
: `Added provider: ${saved.name} (now active)`,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (mode === 'first-run') {
|
||||||
|
onDone({
|
||||||
|
action: 'saved',
|
||||||
|
activeProfileId: saved.id,
|
||||||
|
message: `Provider configured: ${saved.name}`,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditingProfileId(null)
|
||||||
|
setFormStepIndex(0)
|
||||||
|
setErrorMessage(undefined)
|
||||||
|
setScreen('menu')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFormSubmit(value: string): void {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
|
||||||
|
if (!currentStep.optional && trimmed.length === 0) {
|
||||||
|
setErrorMessage(`${currentStep.label} is required.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextDraft = {
|
||||||
|
...draft,
|
||||||
|
[currentStepKey]: trimmed,
|
||||||
|
}
|
||||||
|
|
||||||
|
setDraft(nextDraft)
|
||||||
|
setErrorMessage(undefined)
|
||||||
|
|
||||||
|
if (formStepIndex < FORM_STEPS.length - 1) {
|
||||||
|
const nextIndex = formStepIndex + 1
|
||||||
|
const nextKey = FORM_STEPS[nextIndex]?.key ?? 'name'
|
||||||
|
setFormStepIndex(nextIndex)
|
||||||
|
setCursorOffset(nextDraft[nextKey].length)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
persistDraft()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackFromForm(): void {
|
||||||
|
setErrorMessage(undefined)
|
||||||
|
|
||||||
|
if (formStepIndex > 0) {
|
||||||
|
const nextIndex = formStepIndex - 1
|
||||||
|
const nextKey = FORM_STEPS[nextIndex]?.key ?? 'name'
|
||||||
|
setFormStepIndex(nextIndex)
|
||||||
|
setCursorOffset(draft[nextKey].length)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'first-run') {
|
||||||
|
setScreen('select-preset')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setScreen('menu')
|
||||||
|
}
|
||||||
|
|
||||||
|
useKeybinding('confirm:no', handleBackFromForm, {
|
||||||
|
context: 'Settings',
|
||||||
|
isActive: screen === 'form',
|
||||||
|
})
|
||||||
|
|
||||||
|
function renderPresetSelection(): React.ReactNode {
|
||||||
|
const options = [
|
||||||
|
{
|
||||||
|
value: 'anthropic',
|
||||||
|
label: 'Anthropic',
|
||||||
|
description: 'Native Claude API (x-api-key auth)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'ollama',
|
||||||
|
label: 'Ollama',
|
||||||
|
description: 'Local or remote Ollama endpoint',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'openai',
|
||||||
|
label: 'OpenAI',
|
||||||
|
description: 'OpenAI API with API key',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'moonshotai',
|
||||||
|
label: 'Moonshot AI',
|
||||||
|
description: 'Kimi OpenAI-compatible endpoint',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'deepseek',
|
||||||
|
label: 'DeepSeek',
|
||||||
|
description: 'DeepSeek OpenAI-compatible endpoint',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'gemini',
|
||||||
|
label: 'Google Gemini',
|
||||||
|
description: 'Gemini OpenAI-compatible endpoint',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'together',
|
||||||
|
label: 'Together AI',
|
||||||
|
description: 'Together chat/completions endpoint',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'groq',
|
||||||
|
label: 'Groq',
|
||||||
|
description: 'Groq OpenAI-compatible endpoint',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'mistral',
|
||||||
|
label: 'Mistral',
|
||||||
|
description: 'Mistral OpenAI-compatible endpoint',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'azure-openai',
|
||||||
|
label: 'Azure OpenAI',
|
||||||
|
description: 'Azure OpenAI endpoint (model=deployment name)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'openrouter',
|
||||||
|
label: 'OpenRouter',
|
||||||
|
description: 'OpenRouter OpenAI-compatible endpoint',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'lmstudio',
|
||||||
|
label: 'LM Studio',
|
||||||
|
description: 'Local LM Studio endpoint',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'custom',
|
||||||
|
label: 'Custom',
|
||||||
|
description: 'Any OpenAI-compatible provider',
|
||||||
|
},
|
||||||
|
...(mode === 'first-run'
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
value: 'skip',
|
||||||
|
label: 'Skip for now',
|
||||||
|
description: 'Continue with current defaults',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" gap={1}>
|
||||||
|
<Text color="remember" bold>
|
||||||
|
{mode === 'first-run' ? 'Set up provider' : 'Choose provider preset'}
|
||||||
|
</Text>
|
||||||
|
<Text dimColor>
|
||||||
|
Pick a preset, then confirm base URL, model, and API key.
|
||||||
|
</Text>
|
||||||
|
<Select
|
||||||
|
options={options}
|
||||||
|
onChange={value => {
|
||||||
|
if (value === 'skip') {
|
||||||
|
closeWithCancelled('Provider setup skipped')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startCreateFromPreset(value as ProviderPreset)
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
if (mode === 'first-run') {
|
||||||
|
closeWithCancelled('Provider setup skipped')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setScreen('menu')
|
||||||
|
}}
|
||||||
|
visibleOptionCount={Math.min(12, options.length)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderForm(): React.ReactNode {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" gap={1}>
|
||||||
|
<Text color="remember" bold>
|
||||||
|
{editingProfileId ? 'Edit provider profile' : 'Create provider profile'}
|
||||||
|
</Text>
|
||||||
|
<Text dimColor>{currentStep.helpText}</Text>
|
||||||
|
<Text dimColor>
|
||||||
|
Provider type:{' '}
|
||||||
|
{draftProvider === 'anthropic'
|
||||||
|
? 'Anthropic native API'
|
||||||
|
: 'OpenAI-compatible API'}
|
||||||
|
</Text>
|
||||||
|
<Text dimColor>
|
||||||
|
Step {formStepIndex + 1} of {FORM_STEPS.length}: {currentStep.label}
|
||||||
|
</Text>
|
||||||
|
<Box flexDirection="row" gap={1}>
|
||||||
|
<Text>{figures.pointer}</Text>
|
||||||
|
<TextInput
|
||||||
|
value={currentValue}
|
||||||
|
onChange={value =>
|
||||||
|
setDraft(prev => ({
|
||||||
|
...prev,
|
||||||
|
[currentStepKey]: value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
onSubmit={handleFormSubmit}
|
||||||
|
focus={true}
|
||||||
|
showCursor={true}
|
||||||
|
placeholder={`${currentStep.placeholder}${figures.ellipsis}`}
|
||||||
|
columns={80}
|
||||||
|
cursorOffset={cursorOffset}
|
||||||
|
onChangeCursorOffset={setCursorOffset}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{errorMessage && <Text color="error">{errorMessage}</Text>}
|
||||||
|
<Text dimColor>
|
||||||
|
Press Enter to continue. Press Esc to go back.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMenu(): React.ReactNode {
|
||||||
|
const hasProfiles = profiles.length > 0
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{
|
||||||
|
value: 'add',
|
||||||
|
label: 'Add provider',
|
||||||
|
description: 'Create a new provider profile',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'activate',
|
||||||
|
label: 'Set active provider',
|
||||||
|
description: 'Switch the active provider profile',
|
||||||
|
disabled: !hasProfiles,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'edit',
|
||||||
|
label: 'Edit provider',
|
||||||
|
description: 'Update URL, model, or key',
|
||||||
|
disabled: !hasProfiles,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'delete',
|
||||||
|
label: 'Delete provider',
|
||||||
|
description: 'Remove a provider profile',
|
||||||
|
disabled: !hasProfiles,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'done',
|
||||||
|
label: 'Done',
|
||||||
|
description: 'Return to chat',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" gap={1}>
|
||||||
|
<Text color="remember" bold>
|
||||||
|
Provider manager
|
||||||
|
</Text>
|
||||||
|
<Text dimColor>
|
||||||
|
Active profile controls base URL, model, and API key used by this session.
|
||||||
|
</Text>
|
||||||
|
{statusMessage && <Text>{statusMessage}</Text>}
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{profiles.length === 0 ? (
|
||||||
|
<Text dimColor>No provider profiles configured yet.</Text>
|
||||||
|
) : (
|
||||||
|
profiles.map(profile => (
|
||||||
|
<Text key={profile.id} dimColor>
|
||||||
|
- {profile.name}: {profileSummary(profile, profile.id === activeProfileId)}
|
||||||
|
</Text>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Select
|
||||||
|
options={options}
|
||||||
|
onChange={value => {
|
||||||
|
setErrorMessage(undefined)
|
||||||
|
switch (value) {
|
||||||
|
case 'add':
|
||||||
|
setScreen('select-preset')
|
||||||
|
break
|
||||||
|
case 'activate':
|
||||||
|
if (profiles.length > 0) {
|
||||||
|
setScreen('select-active')
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'edit':
|
||||||
|
if (profiles.length > 0) {
|
||||||
|
setScreen('select-edit')
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'delete':
|
||||||
|
if (profiles.length > 0) {
|
||||||
|
setScreen('select-delete')
|
||||||
|
}
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
closeWithCancelled('Provider manager closed')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onCancel={() => closeWithCancelled('Provider manager closed')}
|
||||||
|
visibleOptionCount={options.length}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProfileSelection(
|
||||||
|
title: string,
|
||||||
|
emptyMessage: string,
|
||||||
|
onSelect: (profileId: string) => void,
|
||||||
|
): React.ReactNode {
|
||||||
|
if (profiles.length === 0) {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" gap={1}>
|
||||||
|
<Text color="remember" bold>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text dimColor>{emptyMessage}</Text>
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: 'back',
|
||||||
|
label: 'Back',
|
||||||
|
description: 'Return to provider manager',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onChange={() => setScreen('menu')}
|
||||||
|
onCancel={() => setScreen('menu')}
|
||||||
|
visibleOptionCount={1}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = profiles.map(profile => ({
|
||||||
|
value: profile.id,
|
||||||
|
label:
|
||||||
|
profile.id === activeProfileId
|
||||||
|
? `${profile.name} (active)`
|
||||||
|
: profile.name,
|
||||||
|
description: `${profile.provider === 'anthropic' ? 'anthropic' : 'openai-compatible'} · ${profile.baseUrl} · ${profile.model}`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" gap={1}>
|
||||||
|
<Text color="remember" bold>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Select
|
||||||
|
options={options}
|
||||||
|
onChange={onSelect}
|
||||||
|
onCancel={() => setScreen('menu')}
|
||||||
|
visibleOptionCount={Math.min(10, Math.max(2, options.length))}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let content: React.ReactNode
|
||||||
|
|
||||||
|
switch (screen) {
|
||||||
|
case 'select-preset':
|
||||||
|
content = renderPresetSelection()
|
||||||
|
break
|
||||||
|
case 'form':
|
||||||
|
content = renderForm()
|
||||||
|
break
|
||||||
|
case 'select-active':
|
||||||
|
content = renderProfileSelection(
|
||||||
|
'Set active provider',
|
||||||
|
'No providers available. Add one first.',
|
||||||
|
profileId => {
|
||||||
|
const active = setActiveProviderProfile(profileId)
|
||||||
|
if (!active) {
|
||||||
|
setErrorMessage('Could not change active provider.')
|
||||||
|
setScreen('menu')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refreshProfiles()
|
||||||
|
setStatusMessage(`Active provider: ${active.name}`)
|
||||||
|
setScreen('menu')
|
||||||
|
},
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case 'select-edit':
|
||||||
|
content = renderProfileSelection(
|
||||||
|
'Edit provider',
|
||||||
|
'No providers available. Add one first.',
|
||||||
|
profileId => {
|
||||||
|
startEditProfile(profileId)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case 'select-delete':
|
||||||
|
content = renderProfileSelection(
|
||||||
|
'Delete provider',
|
||||||
|
'No providers available. Add one first.',
|
||||||
|
profileId => {
|
||||||
|
const result = deleteProviderProfile(profileId)
|
||||||
|
if (!result.removed) {
|
||||||
|
setErrorMessage('Could not delete provider.')
|
||||||
|
} else {
|
||||||
|
refreshProfiles()
|
||||||
|
setStatusMessage('Provider deleted')
|
||||||
|
}
|
||||||
|
setScreen('menu')
|
||||||
|
},
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case 'menu':
|
||||||
|
default:
|
||||||
|
content = renderMenu()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Pane color="permission">{content}</Pane>
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { c as _c } from "react-compiler-runtime";
|
import { c as _c } from "react-compiler-runtime";
|
||||||
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
|
// biome-ignore-all assist/source/organizeImports: internal-only import markers must not be reordered
|
||||||
import { feature } from 'bun:bundle';
|
import { feature } from 'bun:bundle';
|
||||||
import { Box, Text, useTheme, useThemeSetting, useTerminalFocus } from '../../ink.js';
|
import { Box, Text, useTheme, useThemeSetting, useTerminalFocus } from '../../ink.js';
|
||||||
import type { KeyboardEvent } from '../../ink/events/keyboard-event.js';
|
import type { KeyboardEvent } from '../../ink/events/keyboard-event.js';
|
||||||
@@ -342,7 +342,7 @@ export function Config({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Fast mode toggle (ant-only, eliminated from external builds)
|
// Fast mode toggle (internal-only, eliminated from external builds)
|
||||||
...(isFastModeEnabled() && isFastModeAvailable() ? [{
|
...(isFastModeEnabled() && isFastModeAvailable() ? [{
|
||||||
id: 'fastMode',
|
id: 'fastMode',
|
||||||
label: `Fast mode (${FAST_MODE_MODEL_DISPLAY} only)`,
|
label: `Fast mode (${FAST_MODE_MODEL_DISPLAY} only)`,
|
||||||
@@ -391,7 +391,7 @@ export function Config({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}] : []),
|
}] : []),
|
||||||
// Speculation toggle (ant-only)
|
// Speculation toggle (internal-only)
|
||||||
...("external" === 'ant' ? [{
|
...("external" === 'ant' ? [{
|
||||||
id: 'speculationEnabled',
|
id: 'speculationEnabled',
|
||||||
label: 'Speculative execution',
|
label: 'Speculative execution',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { c as _c } from "react-compiler-runtime";
|
import { c as _c } from "react-compiler-runtime";
|
||||||
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
|
// biome-ignore-all assist/source/organizeImports: internal-only import markers must not be reordered
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Suspense, useState } from 'react';
|
import { Suspense, useState } from 'react';
|
||||||
import { useKeybinding } from '../../keybindings/useKeybinding.js';
|
import { useKeybinding } from '../../keybindings/useKeybinding.js';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { c as _c } from "react-compiler-runtime";
|
import { c as _c } from "react-compiler-runtime";
|
||||||
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
|
// biome-ignore-all assist/source/organizeImports: internal-only import markers must not be reordered
|
||||||
import { Box, Text } from '../ink.js';
|
import { Box, Text } from '../ink.js';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
@@ -258,7 +258,7 @@ function SpinnerWithVerbInner({
|
|||||||
const showBtwTip = tipsEnabled && elapsedSnapshot > 30_000 && !getGlobalConfig().btwUseCount;
|
const showBtwTip = tipsEnabled && elapsedSnapshot > 30_000 && !getGlobalConfig().btwUseCount;
|
||||||
const effectiveTip = contextTipsActive ? undefined : showClearTip && !nextTask ? 'Use /clear to start fresh when switching topics and free up context' : showBtwTip && !nextTask ? "Use /btw to ask a quick side question without interrupting Claude's current work" : spinnerTip;
|
const effectiveTip = contextTipsActive ? undefined : showClearTip && !nextTask ? 'Use /clear to start fresh when switching topics and free up context' : showBtwTip && !nextTask ? "Use /btw to ask a quick side question without interrupting Claude's current work" : spinnerTip;
|
||||||
|
|
||||||
// Budget text (ant-only) — shown above the tip line
|
// Budget text (internal-only) — shown above the tip line
|
||||||
let budgetText: string | null = null;
|
let budgetText: string | null = null;
|
||||||
if (feature('TOKEN_BUDGET')) {
|
if (feature('TOKEN_BUDGET')) {
|
||||||
const budget = getCurrentTurnTokenBudget();
|
const budget = getCurrentTurnTokenBudget();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { formatDuration, formatNumber } from '../../utils/format.js';
|
|||||||
import { toInkColor } from '../../utils/ink.js';
|
import { toInkColor } from '../../utils/ink.js';
|
||||||
import type { Theme } from '../../utils/theme.js';
|
import type { Theme } from '../../utils/theme.js';
|
||||||
import { Byline } from '../design-system/Byline.js';
|
import { Byline } from '../design-system/Byline.js';
|
||||||
|
import FullWidthRow from '../design-system/FullWidthRow.js';
|
||||||
import { GlimmerMessage } from './GlimmerMessage.js';
|
import { GlimmerMessage } from './GlimmerMessage.js';
|
||||||
import { SpinnerGlyph } from './SpinnerGlyph.js';
|
import { SpinnerGlyph } from './SpinnerGlyph.js';
|
||||||
import type { SpinnerMode } from './types.js';
|
import type { SpinnerMode } from './types.js';
|
||||||
@@ -223,11 +224,13 @@ export function SpinnerAnimationRow({
|
|||||||
<Byline>{parts}</Byline>
|
<Byline>{parts}</Byline>
|
||||||
<Text dimColor>)</Text>
|
<Text dimColor>)</Text>
|
||||||
</> : null;
|
</> : null;
|
||||||
return <Box ref={viewportRef} flexDirection="row" flexWrap="wrap" marginTop={1} width="100%">
|
return <FullWidthRow>
|
||||||
<SpinnerGlyph frame={frame} messageColor={messageColor} stalledIntensity={overrideColor ? 0 : stalledIntensity} reducedMotion={reducedMotion} time={time} />
|
<Box ref={viewportRef} flexDirection="row" flexWrap="wrap" marginTop={1}>
|
||||||
<GlimmerMessage message={message} mode={mode} messageColor={messageColor} glimmerIndex={glimmerIndex} flashOpacity={flashOpacity} shimmerColor={shimmerColor} stalledIntensity={overrideColor ? 0 : stalledIntensity} />
|
<SpinnerGlyph frame={frame} messageColor={messageColor} stalledIntensity={overrideColor ? 0 : stalledIntensity} reducedMotion={reducedMotion} time={time} />
|
||||||
{status}
|
<GlimmerMessage message={message} mode={mode} messageColor={messageColor} glimmerIndex={glimmerIndex} flashOpacity={flashOpacity} shimmerColor={shimmerColor} stalledIntensity={overrideColor ? 0 : stalledIntensity} />
|
||||||
</Box>;
|
{status}
|
||||||
|
</Box>
|
||||||
|
</FullWidthRow>;
|
||||||
}
|
}
|
||||||
function SpinnerModeGlyph(t0) {
|
function SpinnerModeGlyph(t0) {
|
||||||
const $ = _c(2);
|
const $ = _c(2);
|
||||||
|
|||||||
@@ -379,7 +379,7 @@ function OverviewTab({
|
|||||||
// Calculate range days based on selected date range
|
// Calculate range days based on selected date range
|
||||||
const rangeDays = dateRange === '7d' ? 7 : dateRange === '30d' ? 30 : stats.totalDays;
|
const rangeDays = dateRange === '7d' ? 7 : dateRange === '30d' ? 30 : stats.totalDays;
|
||||||
|
|
||||||
// Compute shot stats data (ant-only, gated by feature flag)
|
// Compute shot stats data (internal-only, gated by feature flag)
|
||||||
let shotStatsData: {
|
let shotStatsData: {
|
||||||
avgShots: string;
|
avgShots: string;
|
||||||
buckets: {
|
buckets: {
|
||||||
@@ -511,7 +511,7 @@ function OverviewTab({
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Speculation time saved (ant-only) */}
|
{/* Speculation time saved (internal-only) */}
|
||||||
{"external" === 'ant' && stats.totalSpeculationTimeSavedMs > 0 && <Box flexDirection="row" gap={4}>
|
{"external" === 'ant' && stats.totalSpeculationTimeSavedMs > 0 && <Box flexDirection="row" gap={4}>
|
||||||
<Box flexDirection="column" width={28}>
|
<Box flexDirection="column" width={28}>
|
||||||
<Text wrap="truncate">
|
<Text wrap="truncate">
|
||||||
@@ -523,7 +523,7 @@ function OverviewTab({
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>}
|
</Box>}
|
||||||
|
|
||||||
{/* Shot stats (ant-only) */}
|
{/* Shot stats (internal-only) */}
|
||||||
{shotStatsData && <>
|
{shotStatsData && <>
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text>Shot distribution</Text>
|
<Text>Shot distribution</Text>
|
||||||
@@ -1150,13 +1150,13 @@ function renderOverviewToAnsi(stats: ClaudeCodeStats): string[] {
|
|||||||
const peakHourVal = stats.peakActivityHour !== null ? `${stats.peakActivityHour}:00-${stats.peakActivityHour + 1}:00` : 'N/A';
|
const peakHourVal = stats.peakActivityHour !== null ? `${stats.peakActivityHour}:00-${stats.peakActivityHour + 1}:00` : 'N/A';
|
||||||
lines.push(row('Active days', activeDaysVal, 'Peak hour', peakHourVal));
|
lines.push(row('Active days', activeDaysVal, 'Peak hour', peakHourVal));
|
||||||
|
|
||||||
// Speculation time saved (ant-only)
|
// Speculation time saved (internal-only)
|
||||||
if ("external" === 'ant' && stats.totalSpeculationTimeSavedMs > 0) {
|
if ("external" === 'ant' && stats.totalSpeculationTimeSavedMs > 0) {
|
||||||
const label = 'Speculation saved:'.padEnd(COL1_LABEL_WIDTH);
|
const label = 'Speculation saved:'.padEnd(COL1_LABEL_WIDTH);
|
||||||
lines.push(label + h(formatDuration(stats.totalSpeculationTimeSavedMs)));
|
lines.push(label + h(formatDuration(stats.totalSpeculationTimeSavedMs)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shot stats (ant-only)
|
// Shot stats (internal-only)
|
||||||
if (feature('SHOT_STATS') && stats.shotDistribution) {
|
if (feature('SHOT_STATS') && stats.shotDistribution) {
|
||||||
const dist = stats.shotDistribution;
|
const dist = stats.shotDistribution;
|
||||||
const totalWithShots = Object.values(dist).reduce((s, n) => s + n, 0);
|
const totalWithShots = Object.values(dist).reduce((s, n) => s + n, 0);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { summarizeRecentActivities } from '../utils/collapseReadSearch.js';
|
|||||||
import { truncateToWidth } from '../utils/format.js';
|
import { truncateToWidth } from '../utils/format.js';
|
||||||
import { isTodoV2Enabled, type Task } from '../utils/tasks.js';
|
import { isTodoV2Enabled, type Task } from '../utils/tasks.js';
|
||||||
import type { Theme } from '../utils/theme.js';
|
import type { Theme } from '../utils/theme.js';
|
||||||
|
import FullWidthRow from './design-system/FullWidthRow.js';
|
||||||
import ThemedText from './design-system/ThemedText.js';
|
import ThemedText from './design-system/ThemedText.js';
|
||||||
type Props = {
|
type Props = {
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
@@ -186,11 +187,11 @@ export function TaskListV2({
|
|||||||
}
|
}
|
||||||
const content = <>
|
const content = <>
|
||||||
{visibleTasks.map(task_0 => <TaskItem key={task_0.id} task={task_0} ownerColor={task_0.owner ? teammateColors[task_0.owner] : undefined} openBlockers={task_0.blockedBy.filter(id_3 => unresolvedTaskIds.has(id_3))} activity={task_0.owner ? teammateActivity[task_0.owner] : undefined} ownerActive={task_0.owner ? activeTeammates.has(task_0.owner) : false} columns={columns} />)}
|
{visibleTasks.map(task_0 => <TaskItem key={task_0.id} task={task_0} ownerColor={task_0.owner ? teammateColors[task_0.owner] : undefined} openBlockers={task_0.blockedBy.filter(id_3 => unresolvedTaskIds.has(id_3))} activity={task_0.owner ? teammateActivity[task_0.owner] : undefined} ownerActive={task_0.owner ? activeTeammates.has(task_0.owner) : false} columns={columns} />)}
|
||||||
{maxDisplay > 0 && hiddenSummary && <Text dimColor>{hiddenSummary}</Text>}
|
{maxDisplay > 0 && hiddenSummary && <FullWidthRow><Text dimColor>{hiddenSummary}</Text></FullWidthRow>}
|
||||||
</>;
|
</>;
|
||||||
if (isStandalone) {
|
if (isStandalone) {
|
||||||
return <Box flexDirection="column" marginTop={1} marginLeft={2}>
|
return <Box flexDirection="column" marginTop={1} marginLeft={2} width="100%">
|
||||||
<Box>
|
<Box width="100%">
|
||||||
<Text dimColor>
|
<Text dimColor>
|
||||||
<Text bold>{tasks.length}</Text>
|
<Text bold>{tasks.length}</Text>
|
||||||
{' tasks ('}
|
{' tasks ('}
|
||||||
@@ -207,7 +208,7 @@ export function TaskListV2({
|
|||||||
{content}
|
{content}
|
||||||
</Box>;
|
</Box>;
|
||||||
}
|
}
|
||||||
return <Box flexDirection="column">{content}</Box>;
|
return <Box flexDirection="column" width="100%">{content}</Box>;
|
||||||
}
|
}
|
||||||
type TaskItemProps = {
|
type TaskItemProps = {
|
||||||
task: Task;
|
task: Task;
|
||||||
@@ -340,7 +341,7 @@ function TaskItem(t0) {
|
|||||||
}
|
}
|
||||||
let t10;
|
let t10;
|
||||||
if ($[26] !== t5 || $[27] !== t7 || $[28] !== t8 || $[29] !== t9) {
|
if ($[26] !== t5 || $[27] !== t7 || $[28] !== t8 || $[29] !== t9) {
|
||||||
t10 = <Box>{t5}{t7}{t8}{t9}</Box>;
|
t10 = <FullWidthRow>{t5}{t7}{t8}{t9}</FullWidthRow>;
|
||||||
$[26] = t5;
|
$[26] = t5;
|
||||||
$[27] = t7;
|
$[27] = t7;
|
||||||
$[28] = t8;
|
$[28] = t8;
|
||||||
@@ -351,7 +352,7 @@ function TaskItem(t0) {
|
|||||||
}
|
}
|
||||||
let t11;
|
let t11;
|
||||||
if ($[31] !== displayActivity || $[32] !== showActivity) {
|
if ($[31] !== displayActivity || $[32] !== showActivity) {
|
||||||
t11 = showActivity && displayActivity && <Box><Text dimColor={true}>{" "}{displayActivity}{figures.ellipsis}</Text></Box>;
|
t11 = showActivity && displayActivity && <FullWidthRow><Text dimColor={true}>{" "}{displayActivity}{figures.ellipsis}</Text></FullWidthRow>;
|
||||||
$[31] = displayActivity;
|
$[31] = displayActivity;
|
||||||
$[32] = showActivity;
|
$[32] = showActivity;
|
||||||
$[33] = t11;
|
$[33] = t11;
|
||||||
@@ -360,7 +361,7 @@ function TaskItem(t0) {
|
|||||||
}
|
}
|
||||||
let t12;
|
let t12;
|
||||||
if ($[34] !== t10 || $[35] !== t11) {
|
if ($[34] !== t10 || $[35] !== t11) {
|
||||||
t12 = <Box flexDirection="column">{t10}{t11}</Box>;
|
t12 = <Box flexDirection="column" width="100%">{t10}{t11}</Box>;
|
||||||
$[34] = t10;
|
$[34] = t10;
|
||||||
$[35] = t11;
|
$[35] = t11;
|
||||||
$[36] = t12;
|
$[36] = t12;
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function TextInput(props: Props): React.ReactNode {
|
|||||||
// Hoisted to mount-time — this component re-renders on every keystroke.
|
// Hoisted to mount-time — this component re-renders on every keystroke.
|
||||||
const accessibilityEnabled = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_ACCESSIBILITY), []);
|
const accessibilityEnabled = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_ACCESSIBILITY), []);
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const reducedMotion = settings.prefersReducedMotion ?? false;
|
const reducedMotion = settings?.prefersReducedMotion ?? false;
|
||||||
const voiceState = feature('VOICE_MODE') ?
|
const voiceState = feature('VOICE_MODE') ?
|
||||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||||
useVoiceState(s => s.voiceState) : 'idle' as const;
|
useVoiceState(s => s.voiceState) : 'idle' as const;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useKeybinding } from '../../keybindings/useKeybinding.js';
|
|||||||
import type { Theme } from '../../utils/theme.js';
|
import type { Theme } from '../../utils/theme.js';
|
||||||
import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js';
|
import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js';
|
||||||
import { Byline } from './Byline.js';
|
import { Byline } from './Byline.js';
|
||||||
|
import FullWidthRow from './FullWidthRow.js';
|
||||||
import { KeyboardShortcutHint } from './KeyboardShortcutHint.js';
|
import { KeyboardShortcutHint } from './KeyboardShortcutHint.js';
|
||||||
import { Pane } from './Pane.js';
|
import { Pane } from './Pane.js';
|
||||||
type DialogProps = {
|
type DialogProps = {
|
||||||
@@ -102,7 +103,7 @@ export function Dialog(t0) {
|
|||||||
}
|
}
|
||||||
let t9;
|
let t9;
|
||||||
if ($[16] !== defaultInputGuide || $[17] !== exitState || $[18] !== hideInputGuide || $[19] !== inputGuide) {
|
if ($[16] !== defaultInputGuide || $[17] !== exitState || $[18] !== hideInputGuide || $[19] !== inputGuide) {
|
||||||
t9 = !hideInputGuide && <Box marginTop={1}><Text dimColor={true} italic={true}>{inputGuide ? inputGuide(exitState) : defaultInputGuide}</Text></Box>;
|
t9 = !hideInputGuide && <Box marginTop={1}><FullWidthRow><Text dimColor={true} italic={true}>{inputGuide ? inputGuide(exitState) : defaultInputGuide}</Text></FullWidthRow></Box>;
|
||||||
$[16] = defaultInputGuide;
|
$[16] = defaultInputGuide;
|
||||||
$[17] = exitState;
|
$[17] = exitState;
|
||||||
$[18] = hideInputGuide;
|
$[18] = hideInputGuide;
|
||||||
|
|||||||
15
src/components/design-system/FullWidthRow.tsx
Normal file
15
src/components/design-system/FullWidthRow.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Box } from '../../ink.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FullWidthRow({
|
||||||
|
children
|
||||||
|
}: Props): React.ReactNode {
|
||||||
|
return <Box flexDirection="row" width="100%">
|
||||||
|
{children}
|
||||||
|
<Box flexGrow={1} />
|
||||||
|
</Box>;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { c as _c } from "react-compiler-runtime";
|
import { c as _c } from "react-compiler-runtime";
|
||||||
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
|
// biome-ignore-all assist/source/organizeImports: internal-only import markers must not be reordered
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { Ansi, Box, Text } from '../../ink.js';
|
import { Ansi, Box, Text } from '../../ink.js';
|
||||||
import type { Attachment } from 'src/utils/attachments.js';
|
import type { Attachment } from 'src/utils/attachments.js';
|
||||||
@@ -24,6 +24,7 @@ import { BLACK_CIRCLE } from '../../constants/figures.js';
|
|||||||
import { TeammateMessageContent } from './UserTeammateMessage.js';
|
import { TeammateMessageContent } from './UserTeammateMessage.js';
|
||||||
import { isShutdownApproved } from '../../utils/teammateMailbox.js';
|
import { isShutdownApproved } from '../../utils/teammateMailbox.js';
|
||||||
import { CtrlOToExpand } from '../CtrlOToExpand.js';
|
import { CtrlOToExpand } from '../CtrlOToExpand.js';
|
||||||
|
import FullWidthRow from '../design-system/FullWidthRow.js';
|
||||||
import { FilePathLink } from '../FilePathLink.js';
|
import { FilePathLink } from '../FilePathLink.js';
|
||||||
import { feature } from 'bun:bundle';
|
import { feature } from 'bun:bundle';
|
||||||
import { useSelectedMessageBg } from '../messageActions.js';
|
import { useSelectedMessageBg } from '../messageActions.js';
|
||||||
@@ -514,7 +515,7 @@ function Line(t0) {
|
|||||||
const bg = useSelectedMessageBg();
|
const bg = useSelectedMessageBg();
|
||||||
let t2;
|
let t2;
|
||||||
if ($[0] !== children || $[1] !== color || $[2] !== dimColor) {
|
if ($[0] !== children || $[1] !== color || $[2] !== dimColor) {
|
||||||
t2 = <MessageResponse><Text color={color} dimColor={dimColor} wrap="wrap">{children}</Text></MessageResponse>;
|
t2 = <MessageResponse><FullWidthRow><Text color={color} dimColor={dimColor} wrap="wrap">{children}</Text></FullWidthRow></MessageResponse>;
|
||||||
$[0] = children;
|
$[0] = children;
|
||||||
$[1] = color;
|
$[1] = color;
|
||||||
$[2] = dimColor;
|
$[2] = dimColor;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { c as _c } from "react-compiler-runtime";
|
import { c as _c } from "react-compiler-runtime";
|
||||||
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
|
// biome-ignore-all assist/source/organizeImports: internal-only import markers must not be reordered
|
||||||
import { Box, Text, type TextProps } from '../../ink.js';
|
import { Box, Text, type TextProps } from '../../ink.js';
|
||||||
import { feature } from 'bun:bundle';
|
import { feature } from 'bun:bundle';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { NO_CONTENT_MESSAGE } from '../../constants/messages.js';
|
|||||||
import { Box, Text } from '../../ink.js';
|
import { Box, Text } from '../../ink.js';
|
||||||
import { extractTag } from '../../utils/messages.js';
|
import { extractTag } from '../../utils/messages.js';
|
||||||
import { Markdown } from '../Markdown.js';
|
import { Markdown } from '../Markdown.js';
|
||||||
|
import FullWidthRow from '../design-system/FullWidthRow.js';
|
||||||
import { MessageResponse } from '../MessageResponse.js';
|
import { MessageResponse } from '../MessageResponse.js';
|
||||||
type Props = {
|
type Props = {
|
||||||
content: string;
|
content: string;
|
||||||
@@ -77,7 +78,7 @@ function IndentedContent(t0) {
|
|||||||
}
|
}
|
||||||
let t2;
|
let t2;
|
||||||
if ($[3] !== children) {
|
if ($[3] !== children) {
|
||||||
t2 = <Box flexDirection="row">{t1}<Box flexDirection="column" flexGrow={1}><Markdown>{children}</Markdown></Box></Box>;
|
t2 = <FullWidthRow>{t1}<Box flexDirection="column" flexGrow={1}><Markdown>{children}</Markdown></Box></FullWidthRow>;
|
||||||
$[3] = children;
|
$[3] = children;
|
||||||
$[4] = t2;
|
$[4] = t2;
|
||||||
} else {
|
} else {
|
||||||
@@ -147,7 +148,7 @@ function CloudLaunchContent(t0) {
|
|||||||
}
|
}
|
||||||
let t6;
|
let t6;
|
||||||
if ($[14] !== rest) {
|
if ($[14] !== rest) {
|
||||||
t6 = rest && <Box flexDirection="row"><Text dimColor={true}>{" \u23BF "}</Text><Text dimColor={true}>{rest}</Text></Box>;
|
t6 = rest && <FullWidthRow><Text dimColor={true}>{" \u23BF "}</Text><Text dimColor={true}>{rest}</Text></FullWidthRow>;
|
||||||
$[14] = rest;
|
$[14] = rest;
|
||||||
$[15] = t6;
|
$[15] = t6;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ function BashPermissionRequestInner({
|
|||||||
// Editable prefix — initialize synchronously with the best prefix we can
|
// Editable prefix — initialize synchronously with the best prefix we can
|
||||||
// extract without tree-sitter, then refine via tree-sitter for compound
|
// extract without tree-sitter, then refine via tree-sitter for compound
|
||||||
// commands. The sync path matters because TREE_SITTER_BASH is gated
|
// commands. The sync path matters because TREE_SITTER_BASH is gated
|
||||||
// ant-only: in external builds the async refinement below always resolves
|
// internal-only: in external builds the async refinement below always resolves
|
||||||
// to [] and this initial value is what the user sees.
|
// to [] and this initial value is what the user sees.
|
||||||
//
|
//
|
||||||
// Lazy initializer: this runs regex + split on every render if left in
|
// Lazy initializer: this runs regex + split on every render if left in
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function powershellToolUseOptions({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Note: No sandbox toggle for PowerShell - sandbox is not supported on Windows
|
// Note: No sandbox toggle for PowerShell - sandbox is not supported on Windows
|
||||||
// Note: No classifier-reviewed option for PowerShell (ANT-ONLY feature for Bash)
|
// Note: No classifier-reviewed option for PowerShell (internal-only feature for Bash)
|
||||||
|
|
||||||
// Only show "always allow" options when not restricted by allowManagedPermissionRulesOnly.
|
// Only show "always allow" options when not restricted by allowManagedPermissionRulesOnly.
|
||||||
// Prefer the editable prefix input (static extractor + user edits) over the
|
// Prefer the editable prefix input (static extractor + user edits) over the
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ export function usePermissionRequestLogging(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// [ANT-ONLY] Log bash tool calls, so we can categorize
|
// [internal-only] Log bash tool calls, so we can categorize
|
||||||
// & burn down calls that should have been allowed
|
// & burn down calls that should have been allowed
|
||||||
if (process.env.USER_TYPE === 'ant') {
|
if (process.env.USER_TYPE === 'ant') {
|
||||||
const parsedInput = BashTool.inputSchema.safeParse(toolUseConfirm.input)
|
const parsedInput = BashTool.inputSchema.safeParse(toolUseConfirm.input)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { getSettingSourceName, type SettingSource } from '../../utils/settings/c
|
|||||||
import { plural } from '../../utils/stringUtils.js';
|
import { plural } from '../../utils/stringUtils.js';
|
||||||
import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js';
|
import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js';
|
||||||
import { Dialog } from '../design-system/Dialog.js';
|
import { Dialog } from '../design-system/Dialog.js';
|
||||||
|
import FullWidthRow from '../design-system/FullWidthRow.js';
|
||||||
|
|
||||||
// Skills are always PromptCommands with CommandBase properties
|
// Skills are always PromptCommands with CommandBase properties
|
||||||
type SkillCommand = CommandBase & PromptCommand;
|
type SkillCommand = CommandBase & PromptCommand;
|
||||||
@@ -105,14 +106,14 @@ export function SkillsMenu(t0) {
|
|||||||
if (skills.length === 0) {
|
if (skills.length === 0) {
|
||||||
let t3;
|
let t3;
|
||||||
if ($[6] === Symbol.for("react.memo_cache_sentinel")) {
|
if ($[6] === Symbol.for("react.memo_cache_sentinel")) {
|
||||||
t3 = <Text dimColor={true}>Create skills in .claude/skills/ or ~/.claude/skills/</Text>;
|
t3 = <FullWidthRow><Text dimColor={true}>Create skills in .claude/skills/ or ~/.claude/skills/</Text></FullWidthRow>;
|
||||||
$[6] = t3;
|
$[6] = t3;
|
||||||
} else {
|
} else {
|
||||||
t3 = $[6];
|
t3 = $[6];
|
||||||
}
|
}
|
||||||
let t4;
|
let t4;
|
||||||
if ($[7] === Symbol.for("react.memo_cache_sentinel")) {
|
if ($[7] === Symbol.for("react.memo_cache_sentinel")) {
|
||||||
t4 = <Text dimColor={true} italic={true}><ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="close" /></Text>;
|
t4 = <FullWidthRow><Text dimColor={true} italic={true}><ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="close" /></Text></FullWidthRow>;
|
||||||
$[7] = t4;
|
$[7] = t4;
|
||||||
} else {
|
} else {
|
||||||
t4 = $[7];
|
t4 = $[7];
|
||||||
@@ -137,7 +138,7 @@ export function SkillsMenu(t0) {
|
|||||||
}
|
}
|
||||||
const title = getSourceTitle(source_0);
|
const title = getSourceTitle(source_0);
|
||||||
const subtitle = getSourceSubtitle(source_0, groupSkills);
|
const subtitle = getSourceSubtitle(source_0, groupSkills);
|
||||||
return <Box flexDirection="column" key={source_0}><Box><Text bold={true} dimColor={true}>{title}</Text>{subtitle && <Text dimColor={true}> ({subtitle})</Text>}</Box>{groupSkills.map(skill_1 => renderSkill(skill_1))}</Box>;
|
return <Box flexDirection="column" key={source_0}><FullWidthRow><Text bold={true} dimColor={true}>{title}</Text>{subtitle && <Text dimColor={true}> ({subtitle})</Text>}</FullWidthRow>{groupSkills.map(skill_1 => renderSkill(skill_1))}</Box>;
|
||||||
};
|
};
|
||||||
$[10] = skillsBySource;
|
$[10] = skillsBySource;
|
||||||
$[11] = t3;
|
$[11] = t3;
|
||||||
@@ -209,7 +210,7 @@ export function SkillsMenu(t0) {
|
|||||||
}
|
}
|
||||||
let t13;
|
let t13;
|
||||||
if ($[30] === Symbol.for("react.memo_cache_sentinel")) {
|
if ($[30] === Symbol.for("react.memo_cache_sentinel")) {
|
||||||
t13 = <Text dimColor={true} italic={true}><ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="close" /></Text>;
|
t13 = <FullWidthRow><Text dimColor={true} italic={true}><ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="close" /></Text></FullWidthRow>;
|
||||||
$[30] = t13;
|
$[30] = t13;
|
||||||
} else {
|
} else {
|
||||||
t13 = $[30];
|
t13 = $[30];
|
||||||
@@ -230,7 +231,7 @@ function _temp3(skill_0) {
|
|||||||
const estimatedTokens = estimateSkillFrontmatterTokens(skill_0);
|
const estimatedTokens = estimateSkillFrontmatterTokens(skill_0);
|
||||||
const tokenDisplay = `~${formatTokens(estimatedTokens)}`;
|
const tokenDisplay = `~${formatTokens(estimatedTokens)}`;
|
||||||
const pluginName = skill_0.source === "plugin" ? skill_0.pluginInfo?.pluginManifest.name : undefined;
|
const pluginName = skill_0.source === "plugin" ? skill_0.pluginInfo?.pluginManifest.name : undefined;
|
||||||
return <Box key={`${skill_0.name}-${skill_0.source}`}><Text>{getSkillListLabel(skill_0)}</Text><Text dimColor={true}>{pluginName ? ` · ${pluginName}` : ""} · {tokenDisplay} description tokens</Text></Box>;
|
return <FullWidthRow key={`${skill_0.name}-${skill_0.source}`}><Text>{getSkillListLabel(skill_0)}</Text><Text dimColor={true}>{pluginName ? ` · ${pluginName}` : ""} · {tokenDisplay} description tokens</Text></FullWidthRow>;
|
||||||
}
|
}
|
||||||
function _temp2(a, b) {
|
function _temp2(a, b) {
|
||||||
return a.name.localeCompare(b.name);
|
return a.name.localeCompare(b.name);
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ type ListItem = {
|
|||||||
status: 'running';
|
status: 'running';
|
||||||
};
|
};
|
||||||
|
|
||||||
// WORKFLOW_SCRIPTS is ant-only (build_flags.yaml). Static imports would leak
|
// WORKFLOW_SCRIPTS is internal-only (build_flags.yaml). Static imports would leak
|
||||||
// ~1.3K lines into external builds. Gate with feature() + require so the
|
// ~1.3K lines into external builds. Gate with feature() + require so the
|
||||||
// bundler can dead-code-eliminate the branch.
|
// bundler can dead-code-eliminate the branch.
|
||||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import memoize from 'lodash-es/memoize.js'
|
|||||||
|
|
||||||
// This ensures you get the LOCAL date in ISO format
|
// This ensures you get the LOCAL date in ISO format
|
||||||
export function getLocalISODate(): string {
|
export function getLocalISODate(): string {
|
||||||
// Check for ant-only date override
|
// Check for internal-only date override
|
||||||
if (process.env.CLAUDE_CODE_OVERRIDE_DATE) {
|
if (process.env.CLAUDE_CODE_OVERRIDE_DATE) {
|
||||||
return process.env.CLAUDE_CODE_OVERRIDE_DATE
|
return process.env.CLAUDE_CODE_OVERRIDE_DATE
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
|
// biome-ignore-all assist/source/organizeImports: internal-only import markers must not be reordered
|
||||||
import { type as osType, version as osVersion, release as osRelease } from 'os'
|
import { type as osType, version as osVersion, release as osRelease } from 'os'
|
||||||
import { env } from '../utils/env.js'
|
import { env } from '../utils/env.js'
|
||||||
import { getIsGit } from '../utils/git.js'
|
import { getIsGit } from '../utils/git.js'
|
||||||
@@ -242,7 +242,7 @@ function getSimpleDoingTasksSection(): string {
|
|||||||
: []),
|
: []),
|
||||||
...(process.env.USER_TYPE === 'ant'
|
...(process.env.USER_TYPE === 'ant'
|
||||||
? [
|
? [
|
||||||
`If the user reports a bug, slowness, or unexpected behavior with Claude Code itself (as opposed to asking you to fix their own code), recommend the appropriate slash command: /issue for model-related problems (odd outputs, wrong tool choices, hallucinations, refusals), or /share to upload the full session transcript for product bugs, crashes, slowness, or general issues. Only recommend these when the user is describing a problem with Claude Code. After /share produces a ccshare link, if you have a Slack MCP tool available, offer to post the link to #claude-code-feedback (channel ID C07VBSHV7EV) for the user.`,
|
`If the user reports a bug, slowness, or unexpected behavior with Claude Code itself (as opposed to asking you to fix their own code), recommend the appropriate slash command: /issue for model-related problems (odd outputs, wrong tool choices, hallucinations, refusals), or /share to upload the full session transcript for product bugs, crashes, slowness, or general issues. Only recommend these when the user is describing a problem with Claude Code.`,
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
`If the user asks for help or wants to give feedback inform them of the following:`,
|
`If the user asks for help or wants to give feedback inform them of the following:`,
|
||||||
@@ -389,7 +389,7 @@ function getSessionSpecificGuidanceSection(
|
|||||||
: null,
|
: null,
|
||||||
hasAgentTool &&
|
hasAgentTool &&
|
||||||
feature('VERIFICATION_AGENT') &&
|
feature('VERIFICATION_AGENT') &&
|
||||||
// 3P default: false — verification agent is ant-only A/B
|
// 3P default: false — verification agent is internal-only A/B
|
||||||
getFeatureValue_CACHED_MAY_BE_STALE('tengu_hive_evidence', false)
|
getFeatureValue_CACHED_MAY_BE_STALE('tengu_hive_evidence', false)
|
||||||
? `The contract: when non-trivial implementation happens on your turn, independent adversarial verification must happen before you report completion \u2014 regardless of who did the implementing (you directly, a fork you spawned, or a subagent). You are the one reporting to the user; you own the gate. Non-trivial means: 3+ file edits, backend/API changes, or infrastructure changes. Spawn the ${AGENT_TOOL_NAME} tool with subagent_type="${VERIFICATION_AGENT_TYPE}". Your own checks, caveats, and a fork's self-checks do NOT substitute \u2014 only the verifier assigns a verdict; you cannot self-assign PARTIAL. Pass the original user request, all files changed (by anyone), the approach, and the plan file path if applicable. Flag concerns if you have them but do NOT share test results or claim things work. On FAIL: fix, resume the verifier with its findings plus your fix, repeat until PASS. On PASS: spot-check it \u2014 re-run 2-3 commands from its report, confirm every PASS has a Command run block with output that matches your re-run. If any PASS lacks a command block or diverges, resume the verifier with the specifics. On PARTIAL (from the verifier): report what passed and what could not be verified.`
|
? `The contract: when non-trivial implementation happens on your turn, independent adversarial verification must happen before you report completion \u2014 regardless of who did the implementing (you directly, a fork you spawned, or a subagent). You are the one reporting to the user; you own the gate. Non-trivial means: 3+ file edits, backend/API changes, or infrastructure changes. Spawn the ${AGENT_TOOL_NAME} tool with subagent_type="${VERIFICATION_AGENT_TYPE}". Your own checks, caveats, and a fork's self-checks do NOT substitute \u2014 only the verifier assigns a verdict; you cannot self-assign PARTIAL. Pass the original user request, all files changed (by anyone), the approach, and the plan file path if applicable. Flag concerns if you have them but do NOT share test results or claim things work. On FAIL: fix, resume the verifier with its findings plus your fix, repeat until PASS. On PASS: spot-check it \u2014 re-run 2-3 commands from its report, confirm every PASS has a Command run block with output that matches your re-run. If any PASS lacks a command block or diverges, resume the verifier with the specifics. On PARTIAL (from the verifier): report what passed and what could not be verified.`
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
|
// biome-ignore-all assist/source/organizeImports: internal-only import markers must not be reordered
|
||||||
import { feature } from 'bun:bundle'
|
import { feature } from 'bun:bundle'
|
||||||
import { TASK_OUTPUT_TOOL_NAME } from '../tools/TaskOutputTool/constants.js'
|
import { TASK_OUTPUT_TOOL_NAME } from '../tools/TaskOutputTool/constants.js'
|
||||||
import { EXIT_PLAN_MODE_V2_TOOL_NAME } from '../tools/ExitPlanModeTool/constants.js'
|
import { EXIT_PLAN_MODE_V2_TOOL_NAME } from '../tools/ExitPlanModeTool/constants.js'
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { logError } from './utils/log.js'
|
|||||||
|
|
||||||
const MAX_STATUS_CHARS = 2000
|
const MAX_STATUS_CHARS = 2000
|
||||||
|
|
||||||
// System prompt injection for cache breaking (ant-only, ephemeral debugging state)
|
// System prompt injection for cache breaking (internal-only, ephemeral debugging state)
|
||||||
let systemPromptInjection: string | null = null
|
let systemPromptInjection: string | null = null
|
||||||
|
|
||||||
export function getSystemPromptInjection(): string | null {
|
export function getSystemPromptInjection(): string | null {
|
||||||
@@ -127,7 +127,7 @@ export const getSystemContext = memoize(
|
|||||||
? null
|
? null
|
||||||
: await getGitStatus()
|
: await getGitStatus()
|
||||||
|
|
||||||
// Include system prompt injection if set (for cache breaking, ant-only)
|
// Include system prompt injection if set (for cache breaking, internal-only)
|
||||||
const injection = feature('BREAK_CACHE_COMMAND')
|
const injection = feature('BREAK_CACHE_COMMAND')
|
||||||
? getSystemPromptInjection()
|
? getSystemPromptInjection()
|
||||||
: null
|
: null
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export function getCoordinatorUserContext(
|
|||||||
export function getCoordinatorSystemPrompt(): string {
|
export function getCoordinatorSystemPrompt(): string {
|
||||||
const workerCapabilities = isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)
|
const workerCapabilities = isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)
|
||||||
? 'Workers have access to Bash, Read, and Edit tools, plus MCP tools from configured MCP servers.'
|
? 'Workers have access to Bash, Read, and Edit tools, plus MCP tools from configured MCP servers.'
|
||||||
: 'Workers have access to standard tools, MCP tools from configured MCP servers, and project skills via the Skill tool. Delegate skill invocations (e.g. /commit, /verify) to workers.'
|
: 'Workers have access to standard tools, MCP tools from configured MCP servers, and project skills via the Skill tool. Delegate skill invocations (e.g. /commit or project workflow skills) to workers.'
|
||||||
|
|
||||||
return `You are Claude Code, an AI assistant that orchestrates software engineering tasks across multiple workers.
|
return `You are Claude Code, an AI assistant that orchestrates software engineering tasks across multiple workers.
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { feature } from 'bun:bundle';
|
import { feature } from 'bun:bundle';
|
||||||
import {
|
|
||||||
isLocalProviderUrl,
|
|
||||||
resolveCodexApiCredentials,
|
|
||||||
resolveProviderRequest,
|
|
||||||
} from '../services/api/providerConfig.js'
|
|
||||||
import {
|
import {
|
||||||
applyProfileEnvToProcessEnv,
|
applyProfileEnvToProcessEnv,
|
||||||
buildStartupEnvFromProfile,
|
buildStartupEnvFromProfile,
|
||||||
redactSecretValueForDisplay,
|
|
||||||
} from '../utils/providerProfile.js'
|
} from '../utils/providerProfile.js'
|
||||||
|
import {
|
||||||
|
getProviderValidationError,
|
||||||
|
validateProviderEnvOrExit,
|
||||||
|
} from '../utils/providerValidation.js'
|
||||||
|
|
||||||
// OpenClaude: disable experimental API betas by default.
|
// OpenClaude: disable experimental API betas by default.
|
||||||
// Tool search (defer_loading), global cache scope, and context management
|
// Tool search (defer_loading), global cache scope, and context management
|
||||||
@@ -42,82 +40,6 @@ if (feature('ABLATION_BASELINE') && process.env.CLAUDE_CODE_ABLATION_BASELINE) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isEnvTruthy(value: string | undefined): boolean {
|
|
||||||
if (!value) return false
|
|
||||||
const normalized = value.trim().toLowerCase()
|
|
||||||
return normalized !== '' && normalized !== '0' && normalized !== 'false' && normalized !== 'no'
|
|
||||||
}
|
|
||||||
|
|
||||||
function getProviderValidationError(
|
|
||||||
env: NodeJS.ProcessEnv = process.env,
|
|
||||||
): string | null {
|
|
||||||
const useOpenAI = isEnvTruthy(env.CLAUDE_CODE_USE_OPENAI)
|
|
||||||
const useGithub = isEnvTruthy(env.CLAUDE_CODE_USE_GITHUB)
|
|
||||||
|
|
||||||
if (isEnvTruthy(env.CLAUDE_CODE_USE_GEMINI)) {
|
|
||||||
if (!(env.GEMINI_API_KEY ?? env.GOOGLE_API_KEY)) {
|
|
||||||
return 'GEMINI_API_KEY is required when CLAUDE_CODE_USE_GEMINI=1.'
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (useGithub && !useOpenAI) {
|
|
||||||
const token = (env.GITHUB_TOKEN?.trim() || env.GH_TOKEN?.trim()) ?? ''
|
|
||||||
if (!token) {
|
|
||||||
return 'GITHUB_TOKEN or GH_TOKEN is required when CLAUDE_CODE_USE_GITHUB=1.'
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!useOpenAI) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const request = resolveProviderRequest({
|
|
||||||
model: env.OPENAI_MODEL,
|
|
||||||
baseUrl: env.OPENAI_BASE_URL,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (env.OPENAI_API_KEY === 'SUA_CHAVE') {
|
|
||||||
return 'Invalid OPENAI_API_KEY: placeholder value SUA_CHAVE detected. Set a real key or unset for local providers.'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.transport === 'codex_responses') {
|
|
||||||
const credentials = resolveCodexApiCredentials(env)
|
|
||||||
if (!credentials.apiKey) {
|
|
||||||
const authHint = credentials.authPath
|
|
||||||
? ` or put auth.json at ${credentials.authPath}`
|
|
||||||
: ''
|
|
||||||
const safeModel =
|
|
||||||
redactSecretValueForDisplay(request.requestedModel, env) ??
|
|
||||||
'the requested model'
|
|
||||||
return `Codex auth is required for ${safeModel}. Set CODEX_API_KEY${authHint}.`
|
|
||||||
}
|
|
||||||
if (!credentials.accountId) {
|
|
||||||
return 'Codex auth is missing chatgpt_account_id. Re-login with Codex or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.'
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!env.OPENAI_API_KEY && !isLocalProviderUrl(request.baseUrl)) {
|
|
||||||
const hasGithubToken = !!(env.GITHUB_TOKEN?.trim() || env.GH_TOKEN?.trim())
|
|
||||||
if (useGithub && hasGithubToken) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return 'OPENAI_API_KEY is required when CLAUDE_CODE_USE_OPENAI=1 and OPENAI_BASE_URL is not local.'
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateProviderEnvOrExit(): void {
|
|
||||||
const error = getProviderValidationError()
|
|
||||||
if (error) {
|
|
||||||
console.error(error)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bootstrap entrypoint - checks for special flags before loading the full CLI.
|
* Bootstrap entrypoint - checks for special flags before loading the full CLI.
|
||||||
* All imports are dynamic to minimize module evaluation for fast paths.
|
* All imports are dynamic to minimize module evaluation for fast paths.
|
||||||
@@ -151,6 +73,8 @@ async function main(): Promise<void> {
|
|||||||
enableConfigs()
|
enableConfigs()
|
||||||
const { applySafeConfigEnvironmentVariables } = await import('../utils/managedEnv.js')
|
const { applySafeConfigEnvironmentVariables } = await import('../utils/managedEnv.js')
|
||||||
applySafeConfigEnvironmentVariables()
|
applySafeConfigEnvironmentVariables()
|
||||||
|
const { hydrateGeminiAccessTokenFromSecureStorage } = await import('../utils/geminiCredentials.js')
|
||||||
|
hydrateGeminiAccessTokenFromSecureStorage()
|
||||||
const { hydrateGithubModelsTokenFromSecureStorage } = await import('../utils/githubModelsCredentials.js')
|
const { hydrateGithubModelsTokenFromSecureStorage } = await import('../utils/githubModelsCredentials.js')
|
||||||
hydrateGithubModelsTokenFromSecureStorage()
|
hydrateGithubModelsTokenFromSecureStorage()
|
||||||
}
|
}
|
||||||
@@ -159,7 +83,7 @@ async function main(): Promise<void> {
|
|||||||
processEnv: process.env,
|
processEnv: process.env,
|
||||||
})
|
})
|
||||||
if (startupEnv !== process.env) {
|
if (startupEnv !== process.env) {
|
||||||
const startupProfileError = getProviderValidationError(startupEnv)
|
const startupProfileError = await getProviderValidationError(startupEnv)
|
||||||
if (startupProfileError) {
|
if (startupProfileError) {
|
||||||
console.error(
|
console.error(
|
||||||
`Warning: ignoring saved provider profile. ${startupProfileError}`,
|
`Warning: ignoring saved provider profile. ${startupProfileError}`,
|
||||||
@@ -169,7 +93,7 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
validateProviderEnvOrExit()
|
await validateProviderEnvOrExit()
|
||||||
|
|
||||||
// Print the gradient startup screen before the Ink UI loads
|
// Print the gradient startup screen before the Ink UI loads
|
||||||
const { printStartupScreen } = await import('../components/StartupScreen.js')
|
const { printStartupScreen } = await import('../components/StartupScreen.js')
|
||||||
|
|||||||
@@ -505,7 +505,7 @@ export const SDKControlGetSettingsResponseSchema = lazySchema(() =>
|
|||||||
applied: z
|
applied: z
|
||||||
.object({
|
.object({
|
||||||
model: z.string(),
|
model: z.string(),
|
||||||
// String levels only — numeric effort is ant-only and the
|
// String levels only — numeric effort is internal-only and the
|
||||||
// Zod→proto generator can't emit enum∪number unions.
|
// Zod→proto generator can't emit enum∪number unions.
|
||||||
effort: z.enum(['low', 'medium', 'high', 'max']).nullable(),
|
effort: z.enum(['low', 'medium', 'high', 'max']).nullable(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
|
// biome-ignore-all assist/source/organizeImports: internal-only import markers must not be reordered
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import type { Tools, ToolPermissionContext } from '../Tool.js'
|
import type { Tools, ToolPermissionContext } from '../Tool.js'
|
||||||
import { assembleToolPool } from '../tools.js'
|
import { assembleToolPool } from '../tools.js'
|
||||||
|
|||||||
134
src/ink/termio/osc.test.ts
Normal file
134
src/ink/termio/osc.test.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
const originalEnv = { ...process.env }
|
||||||
|
const originalPlatform = process.platform
|
||||||
|
const mockedClipboardPath = join(process.cwd(), 'openclaude-clipboard.txt')
|
||||||
|
|
||||||
|
const generateTempFilePathMock = mock(() => mockedClipboardPath)
|
||||||
|
|
||||||
|
const execFileNoThrowMock = mock(
|
||||||
|
async () => ({ code: 0, stdout: '', stderr: '' }),
|
||||||
|
)
|
||||||
|
|
||||||
|
mock.module('../../utils/execFileNoThrow.js', () => ({
|
||||||
|
execFileNoThrow: execFileNoThrowMock,
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module('../../utils/tempfile.js', () => ({
|
||||||
|
generateTempFilePath: generateTempFilePathMock,
|
||||||
|
}))
|
||||||
|
|
||||||
|
async function importFreshOscModule() {
|
||||||
|
return import(`./osc.ts?ts=${Date.now()}-${Math.random()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushClipboardCopy(): Promise<void> {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Windows clipboard fallback', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
execFileNoThrowMock.mockClear()
|
||||||
|
generateTempFilePathMock.mockClear()
|
||||||
|
process.env = { ...originalEnv }
|
||||||
|
delete process.env['SSH_CONNECTION']
|
||||||
|
delete process.env['TMUX']
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = { ...originalEnv }
|
||||||
|
Object.defineProperty(process, 'platform', { value: originalPlatform })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('uses PowerShell instead of clip.exe for local Windows copy', async () => {
|
||||||
|
const { setClipboard } = await importFreshOscModule()
|
||||||
|
|
||||||
|
await setClipboard('Привет мир')
|
||||||
|
await flushClipboardCopy()
|
||||||
|
|
||||||
|
expect(execFileNoThrowMock.mock.calls.some(([cmd]) => cmd === 'clip')).toBe(
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
execFileNoThrowMock.mock.calls.some(([cmd]) => cmd === 'powershell'),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('passes Windows clipboard text through a UTF-8 temp file instead of stdin', async () => {
|
||||||
|
const { setClipboard } = await importFreshOscModule()
|
||||||
|
|
||||||
|
await setClipboard('Привет мир')
|
||||||
|
await flushClipboardCopy()
|
||||||
|
|
||||||
|
const windowsCall = execFileNoThrowMock.mock.calls.find(
|
||||||
|
([cmd]) => cmd === 'powershell',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(windowsCall?.[2]).toMatchObject({
|
||||||
|
stdin: 'ignore',
|
||||||
|
})
|
||||||
|
expect(windowsCall?.[2]).not.toMatchObject({ input: 'Привет мир' })
|
||||||
|
expect(windowsCall?.[2]).not.toMatchObject({
|
||||||
|
env: expect.objectContaining({
|
||||||
|
OPENCLAUDE_CLIPBOARD_TEXT_B64: expect.any(String),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
expect(windowsCall?.[1]).toContain(
|
||||||
|
`$text = [System.IO.File]::ReadAllText('${mockedClipboardPath.replace(/'/g, "''")}', [System.Text.Encoding]::UTF8); Set-Clipboard -Value $text`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('clipboard path behavior remains stable', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
execFileNoThrowMock.mockClear()
|
||||||
|
process.env = { ...originalEnv }
|
||||||
|
delete process.env['SSH_CONNECTION']
|
||||||
|
delete process.env['TMUX']
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = { ...originalEnv }
|
||||||
|
Object.defineProperty(process, 'platform', { value: originalPlatform })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getClipboardPath stays native on local macOS', async () => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'darwin' })
|
||||||
|
const { getClipboardPath } = await importFreshOscModule()
|
||||||
|
|
||||||
|
expect(getClipboardPath()).toBe('native')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getClipboardPath stays tmux-buffer when TMUX is set', async () => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'linux' })
|
||||||
|
process.env['TMUX'] = '/tmp/tmux-1000/default,123,0'
|
||||||
|
const { getClipboardPath } = await importFreshOscModule()
|
||||||
|
|
||||||
|
expect(getClipboardPath()).toBe('tmux-buffer')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Windows clipboard fallback is skipped over SSH', async () => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||||
|
process.env['SSH_CONNECTION'] = '1 2 3 4'
|
||||||
|
const { setClipboard } = await importFreshOscModule()
|
||||||
|
|
||||||
|
await setClipboard('Привет мир')
|
||||||
|
|
||||||
|
expect(execFileNoThrowMock.mock.calls.some(([cmd]) => cmd === 'powershell')).toBe(
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('local macOS clipboard fallback still uses pbcopy', async () => {
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'darwin' })
|
||||||
|
const { setClipboard } = await importFreshOscModule()
|
||||||
|
|
||||||
|
await setClipboard('hello')
|
||||||
|
|
||||||
|
expect(execFileNoThrowMock.mock.calls.some(([cmd]) => cmd === 'pbcopy')).toBe(
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3,8 +3,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Buffer } from 'buffer'
|
import { Buffer } from 'buffer'
|
||||||
|
import { unlink, writeFile } from 'node:fs/promises'
|
||||||
import { env } from '../../utils/env.js'
|
import { env } from '../../utils/env.js'
|
||||||
import { execFileNoThrow } from '../../utils/execFileNoThrow.js'
|
import { execFileNoThrow } from '../../utils/execFileNoThrow.js'
|
||||||
|
import { generateTempFilePath } from '../../utils/tempfile.js'
|
||||||
import { BEL, ESC, ESC_TYPE, SEP } from './ansi.js'
|
import { BEL, ESC, ESC_TYPE, SEP } from './ansi.js'
|
||||||
import type { Action, Color, TabStatusAction } from './types.js'
|
import type { Action, Color, TabStatusAction } from './types.js'
|
||||||
|
|
||||||
@@ -129,7 +131,7 @@ export async function tmuxLoadBuffer(text: string): Promise<boolean> {
|
|||||||
* Local (no SSH_CONNECTION): also shell out to a native clipboard utility.
|
* Local (no SSH_CONNECTION): also shell out to a native clipboard utility.
|
||||||
* OSC 52 and tmux -w both depend on terminal settings — iTerm2 disables
|
* OSC 52 and tmux -w both depend on terminal settings — iTerm2 disables
|
||||||
* OSC 52 by default, VS Code shows a permission prompt on first use. Native
|
* OSC 52 by default, VS Code shows a permission prompt on first use. Native
|
||||||
* utilities (pbcopy/wl-copy/xclip/xsel/clip.exe) always work locally. Over
|
* utilities (pbcopy/wl-copy/xclip/xsel/PowerShell Set-Clipboard) always work locally. Over
|
||||||
* SSH these would write to the remote clipboard — OSC 52 is the right path there.
|
* SSH these would write to the remote clipboard — OSC 52 is the right path there.
|
||||||
*
|
*
|
||||||
* Returns the sequence for the caller to write to stdout (raw OSC 52
|
* Returns the sequence for the caller to write to stdout (raw OSC 52
|
||||||
@@ -211,9 +213,32 @@ function copyNative(text: string): void {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
case 'win32':
|
case 'win32':
|
||||||
// clip.exe is always available on Windows. Unicode handling is
|
// Avoid piping non-ASCII text through the Windows stdin/codepage
|
||||||
// imperfect (system locale encoding) but good enough for a fallback.
|
// boundary. Write UTF-8 text to a temp file and let PowerShell read it
|
||||||
void execFileNoThrow('clip', [], opts)
|
// directly as UTF-8 before calling Set-Clipboard.
|
||||||
|
void (async () => {
|
||||||
|
const tempPath = generateTempFilePath('openclaude-clipboard', '.txt')
|
||||||
|
const escapedTempPath = tempPath.replace(/'/g, "''")
|
||||||
|
try {
|
||||||
|
await writeFile(tempPath, text, { encoding: 'utf8' })
|
||||||
|
await execFileNoThrow(
|
||||||
|
'powershell',
|
||||||
|
[
|
||||||
|
'-NoProfile',
|
||||||
|
'-NonInteractive',
|
||||||
|
'-Command',
|
||||||
|
`$text = [System.IO.File]::ReadAllText('${escapedTempPath}', [System.Text.Encoding]::UTF8); Set-Clipboard -Value $text`,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
useCwd: false,
|
||||||
|
timeout: opts.timeout,
|
||||||
|
stdin: 'ignore',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
await unlink(tempPath).catch(() => {})
|
||||||
|
}
|
||||||
|
})().catch(() => {})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -306,7 +306,7 @@ export const DEFAULT_BINDINGS: KeybindingBlock[] = [
|
|||||||
// Note: diff:back is handled by left arrow in detail mode
|
// Note: diff:back is handled by left arrow in detail mode
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Model picker effort cycling (ant-only)
|
// Model picker effort cycling (internal-only)
|
||||||
{
|
{
|
||||||
context: 'ModelPicker',
|
context: 'ModelPicker',
|
||||||
bindings: {
|
bindings: {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* for changes to reload them automatically.
|
* for changes to reload them automatically.
|
||||||
*
|
*
|
||||||
* NOTE: User keybinding customization is currently only available for
|
* NOTE: User keybinding customization is currently only available for
|
||||||
* Anthropic employees (USER_TYPE === 'ant'). External users always
|
* internal users (USER_TYPE === 'ant'). External users always
|
||||||
* use the default bindings.
|
* use the default bindings.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ function getDefaultParsedBindings(): ParsedBinding[] {
|
|||||||
* Returns merged default + user bindings along with validation warnings.
|
* Returns merged default + user bindings along with validation warnings.
|
||||||
*
|
*
|
||||||
* For external users, always returns default bindings only.
|
* For external users, always returns default bindings only.
|
||||||
* User customization is currently gated to Anthropic employees.
|
* User customization is currently gated to internal users.
|
||||||
*/
|
*/
|
||||||
export async function loadKeybindings(): Promise<KeybindingsLoadResult> {
|
export async function loadKeybindings(): Promise<KeybindingsLoadResult> {
|
||||||
const defaultBindings = getDefaultParsedBindings()
|
const defaultBindings = getDefaultParsedBindings()
|
||||||
@@ -254,7 +254,7 @@ export function loadKeybindingsSync(): ParsedBinding[] {
|
|||||||
* Uses cached values if available.
|
* Uses cached values if available.
|
||||||
*
|
*
|
||||||
* For external users, always returns default bindings only.
|
* For external users, always returns default bindings only.
|
||||||
* User customization is currently gated to Anthropic employees.
|
* User customization is currently gated to internal users.
|
||||||
*/
|
*/
|
||||||
export function loadKeybindingsSyncWithWarnings(): KeybindingsLoadResult {
|
export function loadKeybindingsSyncWithWarnings(): KeybindingsLoadResult {
|
||||||
if (cachedBindings) {
|
if (cachedBindings) {
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export const KEYBINDING_ACTIONS = [
|
|||||||
'diff:viewDetails',
|
'diff:viewDetails',
|
||||||
'diff:previousFile',
|
'diff:previousFile',
|
||||||
'diff:nextFile',
|
'diff:nextFile',
|
||||||
// Model picker actions (ant-only)
|
// Model picker actions (internal-only)
|
||||||
'modelPicker:decreaseEffort',
|
'modelPicker:decreaseEffort',
|
||||||
'modelPicker:increaseEffort',
|
'modelPicker:increaseEffort',
|
||||||
// Select component actions (distinct from confirm: to avoid collisions)
|
// Select component actions (distinct from confirm: to avoid collisions)
|
||||||
|
|||||||
44
src/main.tsx
44
src/main.tsx
@@ -35,6 +35,7 @@ import type { Root } from './ink.js';
|
|||||||
import { launchRepl } from './replLauncher.js';
|
import { launchRepl } from './replLauncher.js';
|
||||||
import { hasGrowthBookEnvOverride, initializeGrowthBook, refreshGrowthBookAfterAuthChange } from './services/analytics/growthbook.js';
|
import { hasGrowthBookEnvOverride, initializeGrowthBook, refreshGrowthBookAfterAuthChange } from './services/analytics/growthbook.js';
|
||||||
import { fetchBootstrapData } from './services/api/bootstrap.js';
|
import { fetchBootstrapData } from './services/api/bootstrap.js';
|
||||||
|
import { prefetchOllamaModels } from './utils/model/ollamaModels.js';
|
||||||
import { type DownloadResult, downloadSessionFiles, type FilesApiConfig, parseFileSpecs } from './services/api/filesApi.js';
|
import { type DownloadResult, downloadSessionFiles, type FilesApiConfig, parseFileSpecs } from './services/api/filesApi.js';
|
||||||
import { prefetchPassesEligibility } from './services/api/referral.js';
|
import { prefetchPassesEligibility } from './services/api/referral.js';
|
||||||
import { prefetchOfficialMcpUrls } from './services/mcp/officialRegistry.js';
|
import { prefetchOfficialMcpUrls } from './services/mcp/officialRegistry.js';
|
||||||
@@ -141,7 +142,6 @@ import { validateUuid } from './utils/uuid.js';
|
|||||||
import { registerMcpAddCommand } from 'src/commands/mcp/addCommand.js';
|
import { registerMcpAddCommand } from 'src/commands/mcp/addCommand.js';
|
||||||
import { registerMcpDoctorCommand } from 'src/commands/mcp/doctorCommand.js';
|
import { registerMcpDoctorCommand } from 'src/commands/mcp/doctorCommand.js';
|
||||||
import { registerMcpXaaIdpCommand } from 'src/commands/mcp/xaaIdpCommand.js';
|
import { registerMcpXaaIdpCommand } from 'src/commands/mcp/xaaIdpCommand.js';
|
||||||
import { logPermissionContextForAnts } from 'src/services/internalLogging.js';
|
|
||||||
import { fetchClaudeAIMcpConfigsIfEligible } from 'src/services/mcp/claudeai.js';
|
import { fetchClaudeAIMcpConfigsIfEligible } from 'src/services/mcp/claudeai.js';
|
||||||
import { clearServerCache } from 'src/services/mcp/client.js';
|
import { clearServerCache } from 'src/services/mcp/client.js';
|
||||||
import { areMcpConfigsAllowedWithEnterpriseMcpConfig, dedupClaudeAiMcpServers, doesEnterpriseMcpConfigExist, filterMcpServersByPolicy, getClaudeCodeMcpConfigs, getMcpServerSignature, parseMcpConfig, parseMcpConfigFromFilePath } from 'src/services/mcp/config.js';
|
import { areMcpConfigsAllowedWithEnterpriseMcpConfig, dedupClaudeAiMcpServers, doesEnterpriseMcpConfigExist, filterMcpServersByPolicy, getClaudeCodeMcpConfigs, getMcpServerSignature, parseMcpConfig, parseMcpConfigFromFilePath } from 'src/services/mcp/config.js';
|
||||||
@@ -1127,7 +1127,7 @@ async function run(): Promise<CommanderCommand> {
|
|||||||
// Extract disable slash commands flag
|
// Extract disable slash commands flag
|
||||||
const disableSlashCommands = options.disableSlashCommands || false;
|
const disableSlashCommands = options.disableSlashCommands || false;
|
||||||
|
|
||||||
// Extract tasks mode options (ant-only)
|
// Extract tasks mode options (internal-only)
|
||||||
const tasksOption = "external" === 'ant' && (options as {
|
const tasksOption = "external" === 'ant' && (options as {
|
||||||
tasks?: boolean | string;
|
tasks?: boolean | string;
|
||||||
}).tasks;
|
}).tasks;
|
||||||
@@ -2213,7 +2213,7 @@ async function run(): Promise<CommanderCommand> {
|
|||||||
const ctx = getRenderContext(false);
|
const ctx = getRenderContext(false);
|
||||||
getFpsMetrics = ctx.getFpsMetrics;
|
getFpsMetrics = ctx.getFpsMetrics;
|
||||||
stats = ctx.stats;
|
stats = ctx.stats;
|
||||||
// Install asciicast recorder before Ink mounts (ant-only, opt-in via CLAUDE_CODE_TERMINAL_RECORDING=1)
|
// Install asciicast recorder before Ink mounts (internal-only, opt-in via CLAUDE_CODE_TERMINAL_RECORDING=1)
|
||||||
if ("external" === 'ant') {
|
if ("external" === 'ant') {
|
||||||
installAsciicastRecorder();
|
installAsciicastRecorder();
|
||||||
}
|
}
|
||||||
@@ -2246,7 +2246,7 @@ async function run(): Promise<CommanderCommand> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for pending agent memory snapshot updates (only for --agent mode, ant-only)
|
// Check for pending agent memory snapshot updates (only for --agent mode, internal-only)
|
||||||
if (feature('AGENT_MEMORY_SNAPSHOT') && mainThreadAgentDefinition && isCustomAgent(mainThreadAgentDefinition) && mainThreadAgentDefinition.memory && mainThreadAgentDefinition.pendingSnapshotUpdate) {
|
if (feature('AGENT_MEMORY_SNAPSHOT') && mainThreadAgentDefinition && isCustomAgent(mainThreadAgentDefinition) && mainThreadAgentDefinition.memory && mainThreadAgentDefinition.pendingSnapshotUpdate) {
|
||||||
const agentDef = mainThreadAgentDefinition;
|
const agentDef = mainThreadAgentDefinition;
|
||||||
const choice = await launchSnapshotUpdateDialog(root, {
|
const choice = await launchSnapshotUpdateDialog(root, {
|
||||||
@@ -2333,6 +2333,9 @@ async function run(): Promise<CommanderCommand> {
|
|||||||
const bgRefreshThrottleMs = getFeatureValue_CACHED_MAY_BE_STALE('tengu_cicada_nap_ms', 0);
|
const bgRefreshThrottleMs = getFeatureValue_CACHED_MAY_BE_STALE('tengu_cicada_nap_ms', 0);
|
||||||
const lastPrefetched = getGlobalConfig().startupPrefetchedAt ?? 0;
|
const lastPrefetched = getGlobalConfig().startupPrefetchedAt ?? 0;
|
||||||
const skipStartupPrefetches = isBareMode() || bgRefreshThrottleMs > 0 && Date.now() - lastPrefetched < bgRefreshThrottleMs;
|
const skipStartupPrefetches = isBareMode() || bgRefreshThrottleMs > 0 && Date.now() - lastPrefetched < bgRefreshThrottleMs;
|
||||||
|
// Always prefetch Ollama models (not gated by throttle — local server, fast & cheap)
|
||||||
|
prefetchOllamaModels();
|
||||||
|
|
||||||
if (!skipStartupPrefetches) {
|
if (!skipStartupPrefetches) {
|
||||||
const lastPrefetchedInfo = lastPrefetched > 0 ? ` last ran ${Math.round((Date.now() - lastPrefetched) / 1000)}s ago` : '';
|
const lastPrefetchedInfo = lastPrefetched > 0 ? ` last ran ${Math.round((Date.now() - lastPrefetched) / 1000)}s ago` : '';
|
||||||
logForDebugging(`Starting background startup prefetches${lastPrefetchedInfo}`);
|
logForDebugging(`Starting background startup prefetches${lastPrefetchedInfo}`);
|
||||||
@@ -2507,7 +2510,6 @@ async function run(): Promise<CommanderCommand> {
|
|||||||
|
|
||||||
// Log context metrics once at initialization
|
// Log context metrics once at initialization
|
||||||
void logContextMetrics(regularMcpConfigs, toolPermissionContext);
|
void logContextMetrics(regularMcpConfigs, toolPermissionContext);
|
||||||
void logPermissionContextForAnts(null, 'initialization');
|
|
||||||
logManagedSettings();
|
logManagedSettings();
|
||||||
|
|
||||||
// Register PID file for concurrent-session detection (~/.claude/sessions/)
|
// Register PID file for concurrent-session detection (~/.claude/sessions/)
|
||||||
@@ -3039,7 +3041,7 @@ async function run(): Promise<CommanderCommand> {
|
|||||||
logSessionTelemetry();
|
logSessionTelemetry();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up per-turn session environment data uploader (ant-only build).
|
// Set up per-turn session environment data uploader (internal-only build).
|
||||||
// Default-enabled for all ant users when working in an Anthropic-owned
|
// Default-enabled for all ant users when working in an Anthropic-owned
|
||||||
// repo. Captures git/filesystem state (NOT transcripts) at each turn so
|
// repo. Captures git/filesystem state (NOT transcripts) at each turn so
|
||||||
// environments can be recreated at any user message index. Gating:
|
// environments can be recreated at any user message index. Gating:
|
||||||
@@ -3339,7 +3341,7 @@ async function run(): Promise<CommanderCommand> {
|
|||||||
}, renderAndRun);
|
}, renderAndRun);
|
||||||
return;
|
return;
|
||||||
} else if (options.resume || options.fromPr || teleport || remote !== null) {
|
} else if (options.resume || options.fromPr || teleport || remote !== null) {
|
||||||
// Handle resume flow - from file (ant-only), session ID, or interactive selector
|
// Handle resume flow - from file (internal-only), session ID, or interactive selector
|
||||||
|
|
||||||
// Clear stale caches before resuming to ensure fresh file/skill discovery
|
// Clear stale caches before resuming to ensure fresh file/skill discovery
|
||||||
const {
|
const {
|
||||||
@@ -3796,17 +3798,17 @@ async function run(): Promise<CommanderCommand> {
|
|||||||
program.addOption(new Option('--advisor <model>', 'Enable the server-side advisor tool with the specified model (alias or full ID).').hideHelp());
|
program.addOption(new Option('--advisor <model>', 'Enable the server-side advisor tool with the specified model (alias or full ID).').hideHelp());
|
||||||
}
|
}
|
||||||
if ("external" === 'ant') {
|
if ("external" === 'ant') {
|
||||||
program.addOption(new Option('--delegate-permissions', '[ANT-ONLY] Alias for --permission-mode auto.').implies({
|
program.addOption(new Option('--delegate-permissions', '[internal-only] Alias for --permission-mode auto.').implies({
|
||||||
permissionMode: 'auto'
|
permissionMode: 'auto'
|
||||||
}));
|
}));
|
||||||
program.addOption(new Option('--dangerously-skip-permissions-with-classifiers', '[ANT-ONLY] Deprecated alias for --permission-mode auto.').hideHelp().implies({
|
program.addOption(new Option('--dangerously-skip-permissions-with-classifiers', '[internal-only] Deprecated alias for --permission-mode auto.').hideHelp().implies({
|
||||||
permissionMode: 'auto'
|
permissionMode: 'auto'
|
||||||
}));
|
}));
|
||||||
program.addOption(new Option('--afk', '[ANT-ONLY] Deprecated alias for --permission-mode auto.').hideHelp().implies({
|
program.addOption(new Option('--afk', '[internal-only] Deprecated alias for --permission-mode auto.').hideHelp().implies({
|
||||||
permissionMode: 'auto'
|
permissionMode: 'auto'
|
||||||
}));
|
}));
|
||||||
program.addOption(new Option('--tasks [id]', '[ANT-ONLY] Tasks mode: watch for tasks and auto-process them. Optional id is used as both the task list ID and agent ID (defaults to "tasklist").').argParser(String).hideHelp());
|
program.addOption(new Option('--tasks [id]', '[internal-only] Tasks mode: watch for tasks and auto-process them. Optional id is used as both the task list ID and agent ID (defaults to "tasklist").').argParser(String).hideHelp());
|
||||||
program.option('--agent-teams', '[ANT-ONLY] Force Claude to use multi-agent mode for solving problems', () => true);
|
program.option('--agent-teams', '[internal-only] Force Claude to use multi-agent mode for solving problems', () => true);
|
||||||
}
|
}
|
||||||
if (feature('TRANSCRIPT_CLASSIFIER')) {
|
if (feature('TRANSCRIPT_CLASSIFIER')) {
|
||||||
program.addOption(new Option('--enable-auto-mode', 'Opt in to auto mode').hideHelp());
|
program.addOption(new Option('--enable-auto-mode', 'Opt in to auto mode').hideHelp());
|
||||||
@@ -4244,7 +4246,7 @@ async function run(): Promise<CommanderCommand> {
|
|||||||
} = await import('./cli/handlers/plugins.js');
|
} = await import('./cli/handlers/plugins.js');
|
||||||
await pluginUpdateHandler(plugin, options);
|
await pluginUpdateHandler(plugin, options);
|
||||||
});
|
});
|
||||||
// END ANT-ONLY
|
// END internal-only
|
||||||
|
|
||||||
// Setup token command
|
// Setup token command
|
||||||
program.command('setup-token').description('Set up a long-lived authentication token (requires Claude subscription)').action(async () => {
|
program.command('setup-token').description('Set up a long-lived authentication token (requires Claude subscription)').action(async () => {
|
||||||
@@ -4351,7 +4353,7 @@ async function run(): Promise<CommanderCommand> {
|
|||||||
|
|
||||||
// claude up — run the project's CLAUDE.md "# claude up" setup instructions.
|
// claude up — run the project's CLAUDE.md "# claude up" setup instructions.
|
||||||
if ("external" === 'ant') {
|
if ("external" === 'ant') {
|
||||||
program.command('up').description('[ANT-ONLY] Initialize or upgrade the local dev environment using the "# claude up" section of the nearest CLAUDE.md').action(async () => {
|
program.command('up').description('[internal-only] Initialize or upgrade the local dev environment using the "# claude up" section of the nearest CLAUDE.md').action(async () => {
|
||||||
const {
|
const {
|
||||||
up
|
up
|
||||||
} = await import('src/cli/up.js');
|
} = await import('src/cli/up.js');
|
||||||
@@ -4359,10 +4361,10 @@ async function run(): Promise<CommanderCommand> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// claude rollback (ant-only)
|
// claude rollback (internal-only)
|
||||||
// Rolls back to previous releases
|
// Rolls back to previous releases
|
||||||
if ("external" === 'ant') {
|
if ("external" === 'ant') {
|
||||||
program.command('rollback [target]').description('[ANT-ONLY] Roll back to a previous release\n\nExamples:\n claude rollback Go 1 version back from current\n claude rollback 3 Go 3 versions back from current\n claude rollback 2.0.73-dev.20251217.t190658 Roll back to a specific version').option('-l, --list', 'List recent published versions with ages').option('--dry-run', 'Show what would be installed without installing').option('--safe', 'Roll back to the server-pinned safe version (set by oncall during incidents)').action(async (target?: string, options?: {
|
program.command('rollback [target]').description('[internal-only] Roll back to a previous release\n\nExamples:\n claude rollback Go 1 version back from current\n claude rollback 3 Go 3 versions back from current\n claude rollback 2.0.73-dev.20251217.t190658 Roll back to a specific version').option('-l, --list', 'List recent published versions with ages').option('--dry-run', 'Show what would be installed without installing').option('--safe', 'Roll back to the server-pinned safe version (set by oncall during incidents)').action(async (target?: string, options?: {
|
||||||
list?: boolean;
|
list?: boolean;
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
safe?: boolean;
|
safe?: boolean;
|
||||||
@@ -4384,7 +4386,7 @@ async function run(): Promise<CommanderCommand> {
|
|||||||
await installHandler(target, options);
|
await installHandler(target, options);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ant-only commands
|
// internal-only commands
|
||||||
if ("external" === 'ant') {
|
if ("external" === 'ant') {
|
||||||
const validateLogId = (value: string) => {
|
const validateLogId = (value: string) => {
|
||||||
const maybeSessionId = validateUuid(value);
|
const maybeSessionId = validateUuid(value);
|
||||||
@@ -4392,7 +4394,7 @@ async function run(): Promise<CommanderCommand> {
|
|||||||
return Number(value);
|
return Number(value);
|
||||||
};
|
};
|
||||||
// claude log
|
// claude log
|
||||||
program.command('log').description('[ANT-ONLY] Manage conversation logs.').argument('[number|sessionId]', 'A number (0, 1, 2, etc.) to display a specific log, or the sesssion ID (uuid) of a log', validateLogId).action(async (logId: string | number | undefined) => {
|
program.command('log').description('[internal-only] Manage conversation logs.').argument('[number|sessionId]', 'A number (0, 1, 2, etc.) to display a specific log, or the sesssion ID (uuid) of a log', validateLogId).action(async (logId: string | number | undefined) => {
|
||||||
const {
|
const {
|
||||||
logHandler
|
logHandler
|
||||||
} = await import('./cli/handlers/ant.js');
|
} = await import('./cli/handlers/ant.js');
|
||||||
@@ -4400,7 +4402,7 @@ async function run(): Promise<CommanderCommand> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// claude error
|
// claude error
|
||||||
program.command('error').description('[ANT-ONLY] View error logs. Optionally provide a number (0, -1, -2, etc.) to display a specific log.').argument('[number]', 'A number (0, 1, 2, etc.) to display a specific log', parseInt).action(async (number: number | undefined) => {
|
program.command('error').description('[internal-only] View error logs. Optionally provide a number (0, -1, -2, etc.) to display a specific log.').argument('[number]', 'A number (0, 1, 2, etc.) to display a specific log', parseInt).action(async (number: number | undefined) => {
|
||||||
const {
|
const {
|
||||||
errorHandler
|
errorHandler
|
||||||
} = await import('./cli/handlers/ant.js');
|
} = await import('./cli/handlers/ant.js');
|
||||||
@@ -4408,7 +4410,7 @@ async function run(): Promise<CommanderCommand> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// claude export
|
// claude export
|
||||||
program.command('export').description('[ANT-ONLY] Export a conversation to a text file.').usage('<source> <outputFile>').argument('<source>', 'Session ID, log index (0, 1, 2...), or path to a .json/.jsonl log file').argument('<outputFile>', 'Output file path for the exported text').addHelpText('after', `
|
program.command('export').description('[internal-only] Export a conversation to a text file.').usage('<source> <outputFile>').argument('<source>', 'Session ID, log index (0, 1, 2...), or path to a .json/.jsonl log file').argument('<outputFile>', 'Output file path for the exported text').addHelpText('after', `
|
||||||
Examples:
|
Examples:
|
||||||
$ claude export 0 conversation.txt Export conversation at log index 0
|
$ claude export 0 conversation.txt Export conversation at log index 0
|
||||||
$ claude export <uuid> conversation.txt Export conversation by session ID
|
$ claude export <uuid> conversation.txt Export conversation by session ID
|
||||||
@@ -4420,7 +4422,7 @@ Examples:
|
|||||||
await exportHandler(source, outputFile);
|
await exportHandler(source, outputFile);
|
||||||
});
|
});
|
||||||
if ("external" === 'ant') {
|
if ("external" === 'ant') {
|
||||||
const taskCmd = program.command('task').description('[ANT-ONLY] Manage task list tasks');
|
const taskCmd = program.command('task').description('[internal-only] Manage task list tasks');
|
||||||
taskCmd.command('create <subject>').description('Create a new task').option('-d, --description <text>', 'Task description').option('-l, --list <id>', 'Task list ID (defaults to "tasklist")').action(async (subject: string, opts: {
|
taskCmd.command('create <subject>').description('Create a new task').option('-d, --description <text>', 'Task description').option('-l, --list <id>', 'Task list ID (defaults to "tasklist")').action(async (subject: string, opts: {
|
||||||
description?: string;
|
description?: string;
|
||||||
list?: string;
|
list?: string;
|
||||||
|
|||||||
15
src/query.ts
15
src/query.ts
@@ -1,4 +1,4 @@
|
|||||||
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
|
// biome-ignore-all assist/source/organizeImports: internal-only import markers must not be reordered
|
||||||
import type {
|
import type {
|
||||||
ToolResultBlockParam,
|
ToolResultBlockParam,
|
||||||
ToolUseBlock,
|
ToolUseBlock,
|
||||||
@@ -52,7 +52,6 @@ import {
|
|||||||
getMessagesAfterCompactBoundary,
|
getMessagesAfterCompactBoundary,
|
||||||
createToolUseSummaryMessage,
|
createToolUseSummaryMessage,
|
||||||
createMicrocompactBoundaryMessage,
|
createMicrocompactBoundaryMessage,
|
||||||
stripSignatureBlocks,
|
|
||||||
} from './utils/messages.js'
|
} from './utils/messages.js'
|
||||||
import { generateToolUseSummary } from './services/toolUseSummary/toolUseSummaryGenerator.js'
|
import { generateToolUseSummary } from './services/toolUseSummary/toolUseSummaryGenerator.js'
|
||||||
import { prependUserContext, appendSystemContext } from './utils/api.js'
|
import { prependUserContext, appendSystemContext } from './utils/api.js'
|
||||||
@@ -92,7 +91,6 @@ import { SLEEP_TOOL_NAME } from './tools/SleepTool/prompt.js'
|
|||||||
import { executePostSamplingHooks } from './utils/hooks/postSamplingHooks.js'
|
import { executePostSamplingHooks } from './utils/hooks/postSamplingHooks.js'
|
||||||
import { executeStopFailureHooks } from './utils/hooks.js'
|
import { executeStopFailureHooks } from './utils/hooks.js'
|
||||||
import type { QuerySource } from './constants/querySource.js'
|
import type { QuerySource } from './constants/querySource.js'
|
||||||
import { createDumpPromptsFetch } from './services/api/dumpPrompts.js'
|
|
||||||
import { StreamingToolExecutor } from './services/tools/StreamingToolExecutor.js'
|
import { StreamingToolExecutor } from './services/tools/StreamingToolExecutor.js'
|
||||||
import { queryCheckpoint } from './utils/queryProfiler.js'
|
import { queryCheckpoint } from './utils/queryProfiler.js'
|
||||||
import { runTools } from './services/tools/toolOrchestration.js'
|
import { runTools } from './services/tools/toolOrchestration.js'
|
||||||
@@ -587,13 +585,7 @@ async function* queryLoop(
|
|||||||
|
|
||||||
// Create fetch wrapper once per query session to avoid memory retention.
|
// Create fetch wrapper once per query session to avoid memory retention.
|
||||||
// Each call to createDumpPromptsFetch creates a closure that captures the request body.
|
// Each call to createDumpPromptsFetch creates a closure that captures the request body.
|
||||||
// Creating it once means only the latest request body is retained (~700KB),
|
const dumpPromptsFetch = undefined
|
||||||
// instead of all request bodies from the session (~500MB for long sessions).
|
|
||||||
// Note: agentId is effectively constant during a query() call - it only changes
|
|
||||||
// between queries (e.g., /clear command or session resume).
|
|
||||||
const dumpPromptsFetch = config.gates.isAnt
|
|
||||||
? createDumpPromptsFetch(toolUseContext.agentId ?? config.sessionId)
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
// Block if we've hit the hard blocking limit (only applies when auto-compact is OFF)
|
// Block if we've hit the hard blocking limit (only applies when auto-compact is OFF)
|
||||||
// This reserves space so users can still run /compact manually
|
// This reserves space so users can still run /compact manually
|
||||||
@@ -931,9 +923,6 @@ async function* queryLoop(
|
|||||||
// Thinking signatures are model-bound: replaying a protected-thinking
|
// Thinking signatures are model-bound: replaying a protected-thinking
|
||||||
// block (e.g. capybara) to an unprotected fallback (e.g. opus) 400s.
|
// block (e.g. capybara) to an unprotected fallback (e.g. opus) 400s.
|
||||||
// Strip before retry so the fallback model gets clean history.
|
// Strip before retry so the fallback model gets clean history.
|
||||||
if (process.env.USER_TYPE === 'ant') {
|
|
||||||
messagesForQuery = stripSignatureBlocks(messagesForQuery)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log the fallback event
|
// Log the fallback event
|
||||||
logEvent('tengu_model_fallback_triggered', {
|
logEvent('tengu_model_fallback_triggered', {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export function buildQueryConfig(): QueryConfig {
|
|||||||
emitToolUseSummaries: isEnvTruthy(
|
emitToolUseSummaries: isEnvTruthy(
|
||||||
process.env.CLAUDE_CODE_EMIT_TOOL_USE_SUMMARIES,
|
process.env.CLAUDE_CODE_EMIT_TOOL_USE_SUMMARIES,
|
||||||
),
|
),
|
||||||
isAnt: process.env.USER_TYPE === 'ant',
|
isAnt: false,
|
||||||
// Inlined from fastMode.ts to avoid pulling its heavy module graph
|
// Inlined from fastMode.ts to avoid pulling its heavy module graph
|
||||||
// (axios, settings, auth, model, oauth, config) into test shards that
|
// (axios, settings, auth, model, oauth, config) into test shards that
|
||||||
// didn't previously load it — changes init order and breaks unrelated tests.
|
// didn't previously load it — changes init order and breaks unrelated tests.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { c as _c } from "react-compiler-runtime";
|
import { c as _c } from "react-compiler-runtime";
|
||||||
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
|
// biome-ignore-all assist/source/organizeImports: internal-only import markers must not be reordered
|
||||||
import { feature } from 'bun:bundle';
|
import { feature } from 'bun:bundle';
|
||||||
import { spawnSync } from 'child_process';
|
import { spawnSync } from 'child_process';
|
||||||
import { snapshotOutputTokensForTurn, getCurrentTurnTokenBudget, getTurnOutputTokens, getBudgetContinuationCount, getTotalInputTokens } from '../bootstrap/state.js';
|
import { snapshotOutputTokensForTurn, getCurrentTurnTokenBudget, getTurnOutputTokens, getBudgetContinuationCount, getTotalInputTokens } from '../bootstrap/state.js';
|
||||||
@@ -101,7 +101,7 @@ const useVoiceIntegration: typeof import('../hooks/useVoiceIntegration.js').useV
|
|||||||
resetAnchor: () => { }
|
resetAnchor: () => { }
|
||||||
});
|
});
|
||||||
const VoiceKeybindingHandler: typeof import('../hooks/useVoiceIntegration.js').VoiceKeybindingHandler = feature('VOICE_MODE') ? require('../hooks/useVoiceIntegration.js').VoiceKeybindingHandler : () => null;
|
const VoiceKeybindingHandler: typeof import('../hooks/useVoiceIntegration.js').VoiceKeybindingHandler = feature('VOICE_MODE') ? require('../hooks/useVoiceIntegration.js').VoiceKeybindingHandler : () => null;
|
||||||
// Frustration detection is ant-only (dogfooding). Conditional require so external
|
// Frustration detection is internal-only (dogfooding). Conditional require so external
|
||||||
// builds eliminate the module entirely (including its two O(n) useMemos that run
|
// builds eliminate the module entirely (including its two O(n) useMemos that run
|
||||||
// on every messages change, plus the GrowthBook fetch).
|
// on every messages change, plus the GrowthBook fetch).
|
||||||
const useFrustrationDetection: typeof import('../components/FeedbackSurvey/useFrustrationDetection.js').useFrustrationDetection = "external" === 'ant' ? require('../components/FeedbackSurvey/useFrustrationDetection.js').useFrustrationDetection : () => ({
|
const useFrustrationDetection: typeof import('../components/FeedbackSurvey/useFrustrationDetection.js').useFrustrationDetection = "external" === 'ant' ? require('../components/FeedbackSurvey/useFrustrationDetection.js').useFrustrationDetection : () => ({
|
||||||
@@ -275,6 +275,8 @@ const WebBrowserPanelModule = feature('WEB_BROWSER_TOOL') ? require('../tools/We
|
|||||||
import { IssueFlagBanner } from '../components/PromptInput/IssueFlagBanner.js';
|
import { IssueFlagBanner } from '../components/PromptInput/IssueFlagBanner.js';
|
||||||
import { useIssueFlagBanner } from '../hooks/useIssueFlagBanner.js';
|
import { useIssueFlagBanner } from '../hooks/useIssueFlagBanner.js';
|
||||||
import { CompanionSprite, CompanionFloatingBubble, MIN_COLS_FOR_FULL_SPRITE } from '../buddy/CompanionSprite.js';
|
import { CompanionSprite, CompanionFloatingBubble, MIN_COLS_FOR_FULL_SPRITE } from '../buddy/CompanionSprite.js';
|
||||||
|
import { isBuddyEnabled } from '../buddy/feature.js';
|
||||||
|
import { fireCompanionObserver } from '../buddy/observer.js';
|
||||||
import { DevBar } from '../components/DevBar.js';
|
import { DevBar } from '../components/DevBar.js';
|
||||||
// Session manager removed - using AppState now
|
// Session manager removed - using AppState now
|
||||||
import type { RemoteSessionConfig } from '../remote/RemoteSessionManager.js';
|
import type { RemoteSessionConfig } from '../remote/RemoteSessionManager.js';
|
||||||
@@ -726,7 +728,7 @@ export function REPL({
|
|||||||
const [ideToInstallExtension, setIDEToInstallExtension] = useState<IdeType | null>(null);
|
const [ideToInstallExtension, setIDEToInstallExtension] = useState<IdeType | null>(null);
|
||||||
const [ideInstallationStatus, setIDEInstallationStatus] = useState<IDEExtensionInstallationStatus | null>(null);
|
const [ideInstallationStatus, setIDEInstallationStatus] = useState<IDEExtensionInstallationStatus | null>(null);
|
||||||
const [showIdeOnboarding, setShowIdeOnboarding] = useState(false);
|
const [showIdeOnboarding, setShowIdeOnboarding] = useState(false);
|
||||||
// Dead code elimination: model switch callout state (ant-only)
|
// Dead code elimination: model switch callout state (internal-only)
|
||||||
const [showModelSwitchCallout, setShowModelSwitchCallout] = useState(() => {
|
const [showModelSwitchCallout, setShowModelSwitchCallout] = useState(() => {
|
||||||
if ("external" === 'ant') {
|
if ("external" === 'ant') {
|
||||||
return shouldShowAntModelSwitch();
|
return shouldShowAntModelSwitch();
|
||||||
@@ -1161,7 +1163,7 @@ export function REPL({
|
|||||||
}
|
}
|
||||||
}, [sessionStatus, waitingFor]);
|
}, [sessionStatus, waitingFor]);
|
||||||
|
|
||||||
// 3P default: off — OSC 21337 is ant-only while the spec stabilizes.
|
// 3P default: off — OSC 21337 is internal-only while the spec stabilizes.
|
||||||
// Gated so we can roll back if the sidebar indicator conflicts with
|
// Gated so we can roll back if the sidebar indicator conflicts with
|
||||||
// the title spinner in terminals that render both. When the flag is
|
// the title spinner in terminals that render both. When the flag is
|
||||||
// on, the user-facing config setting controls whether it's active.
|
// on, the user-facing config setting controls whether it's active.
|
||||||
@@ -1302,7 +1304,7 @@ export function REPL({
|
|||||||
// Dismiss the companion bubble on scroll — it's absolute-positioned
|
// Dismiss the companion bubble on scroll — it's absolute-positioned
|
||||||
// at bottom-right and covers transcript content. Scrolling = user is
|
// at bottom-right and covers transcript content. Scrolling = user is
|
||||||
// trying to read something under it.
|
// trying to read something under it.
|
||||||
if (feature('BUDDY')) {
|
if (isBuddyEnabled()) {
|
||||||
setAppState(prev => prev.companionReaction === undefined ? prev : {
|
setAppState(prev => prev.companionReaction === undefined ? prev : {
|
||||||
...prev,
|
...prev,
|
||||||
companionReaction: undefined
|
companionReaction: undefined
|
||||||
@@ -1428,7 +1430,7 @@ export function REPL({
|
|||||||
// Ref instead of state to avoid triggering React re-renders on every
|
// Ref instead of state to avoid triggering React re-renders on every
|
||||||
// streaming text_delta. The spinner reads this via its animation timer.
|
// streaming text_delta. The spinner reads this via its animation timer.
|
||||||
const responseLengthRef = useRef(0);
|
const responseLengthRef = useRef(0);
|
||||||
// API performance metrics ref for ant-only spinner display (TTFT/OTPS).
|
// API performance metrics ref for internal-only spinner display (TTFT/OTPS).
|
||||||
// Accumulates metrics from all API requests in a turn for P50 aggregation.
|
// Accumulates metrics from all API requests in a turn for P50 aggregation.
|
||||||
const apiMetricsRef = useRef<Array<{
|
const apiMetricsRef = useRef<Array<{
|
||||||
ttftMs: number;
|
ttftMs: number;
|
||||||
@@ -2044,10 +2046,10 @@ export function REPL({
|
|||||||
// Onboarding dialogs (special conditions)
|
// Onboarding dialogs (special conditions)
|
||||||
if (allowDialogsWithAnimation && showIdeOnboarding) return 'ide-onboarding';
|
if (allowDialogsWithAnimation && showIdeOnboarding) return 'ide-onboarding';
|
||||||
|
|
||||||
// Model switch callout (ant-only, eliminated from external builds)
|
// Model switch callout (internal-only, eliminated from external builds)
|
||||||
if ("external" === 'ant' && allowDialogsWithAnimation && showModelSwitchCallout) return 'model-switch';
|
if ("external" === 'ant' && allowDialogsWithAnimation && showModelSwitchCallout) return 'model-switch';
|
||||||
|
|
||||||
// Undercover auto-enable explainer (ant-only, eliminated from external builds)
|
// Undercover auto-enable explainer (internal-only, eliminated from external builds)
|
||||||
if ("external" === 'ant' && allowDialogsWithAnimation && showUndercoverCallout) return 'undercover-callout';
|
if ("external" === 'ant' && allowDialogsWithAnimation && showUndercoverCallout) return 'undercover-callout';
|
||||||
|
|
||||||
// Effort callout (shown once for Opus 4.6 users when effort is enabled)
|
// Effort callout (shown once for Opus 4.6 users when effort is enabled)
|
||||||
@@ -2806,7 +2808,7 @@ export function REPL({
|
|||||||
})) {
|
})) {
|
||||||
onQueryEvent(event);
|
onQueryEvent(event);
|
||||||
}
|
}
|
||||||
if (feature('BUDDY')) {
|
if (isBuddyEnabled()) {
|
||||||
void fireCompanionObserver(messagesRef.current, reaction => setAppState(prev => prev.companionReaction === reaction ? prev : {
|
void fireCompanionObserver(messagesRef.current, reaction => setAppState(prev => prev.companionReaction === reaction ? prev : {
|
||||||
...prev,
|
...prev,
|
||||||
companionReaction: reaction
|
companionReaction: reaction
|
||||||
@@ -2814,7 +2816,7 @@ export function REPL({
|
|||||||
}
|
}
|
||||||
queryCheckpoint('query_end');
|
queryCheckpoint('query_end');
|
||||||
|
|
||||||
// Capture ant-only API metrics before resetLoadingState clears the ref.
|
// Capture internal-only API metrics before resetLoadingState clears the ref.
|
||||||
// For multi-request turns (tool use loops), compute P50 across all requests.
|
// For multi-request turns (tool use loops), compute P50 across all requests.
|
||||||
if ("external" === 'ant' && apiMetricsRef.current.length > 0) {
|
if ("external" === 'ant' && apiMetricsRef.current.length > 0) {
|
||||||
const entries = apiMetricsRef.current;
|
const entries = apiMetricsRef.current;
|
||||||
@@ -2938,7 +2940,7 @@ export function REPL({
|
|||||||
// can stop the spark animation and show post-turn UI.
|
// can stop the spark animation and show post-turn UI.
|
||||||
sendBridgeResultRef.current();
|
sendBridgeResultRef.current();
|
||||||
|
|
||||||
// Auto-hide tungsten panel content at turn end (ant-only), but keep
|
// Auto-hide tungsten panel content at turn end (internal-only), but keep
|
||||||
// tungstenActiveSession set so the pill stays in the footer and the user
|
// tungstenActiveSession set so the pill stays in the footer and the user
|
||||||
// can reopen the panel. Background tmux tasks (e.g. /hunter) run for
|
// can reopen the panel. Background tmux tasks (e.g. /hunter) run for
|
||||||
// minutes — wiping the session made the pill disappear entirely, forcing
|
// minutes — wiping the session made the pill disappear entirely, forcing
|
||||||
@@ -2955,7 +2957,7 @@ export function REPL({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture budget info before clearing (ant-only)
|
// Capture budget info before clearing (internal-only)
|
||||||
let budgetInfo: {
|
let budgetInfo: {
|
||||||
tokens: number;
|
tokens: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
@@ -4567,7 +4569,7 @@ export function REPL({
|
|||||||
{feature('MESSAGE_ACTIONS') && isFullscreenEnvEnabled() && !disableMessageActions ? <MessageActionsKeybindings handlers={messageActionHandlers} isActive={cursor !== null} /> : null}
|
{feature('MESSAGE_ACTIONS') && isFullscreenEnvEnabled() && !disableMessageActions ? <MessageActionsKeybindings handlers={messageActionHandlers} isActive={cursor !== null} /> : null}
|
||||||
<CancelRequestHandler {...cancelRequestProps} />
|
<CancelRequestHandler {...cancelRequestProps} />
|
||||||
<MCPConnectionManager key={remountKey} dynamicMcpConfig={dynamicMcpConfig} isStrictMcpConfig={strictMcpConfig}>
|
<MCPConnectionManager key={remountKey} dynamicMcpConfig={dynamicMcpConfig} isStrictMcpConfig={strictMcpConfig}>
|
||||||
<FullscreenLayout scrollRef={scrollRef} overlay={toolPermissionOverlay} bottomFloat={feature('BUDDY') && companionVisible && !companionNarrow ? <CompanionFloatingBubble /> : undefined} modal={centeredModal} modalScrollRef={modalScrollRef} dividerYRef={dividerYRef} hidePill={!!viewedAgentTask} hideSticky={!!viewedTeammateTask} newMessageCount={unseenDivider?.count ?? 0} onPillClick={() => {
|
<FullscreenLayout scrollRef={scrollRef} overlay={toolPermissionOverlay} bottomFloat={isBuddyEnabled() && companionVisible && !companionNarrow ? <CompanionFloatingBubble /> : undefined} modal={centeredModal} modalScrollRef={modalScrollRef} dividerYRef={dividerYRef} hidePill={!!viewedAgentTask} hideSticky={!!viewedTeammateTask} newMessageCount={unseenDivider?.count ?? 0} onPillClick={() => {
|
||||||
setCursor(null);
|
setCursor(null);
|
||||||
jumpToNew(scrollRef.current);
|
jumpToNew(scrollRef.current);
|
||||||
}} scrollable={<>
|
}} scrollable={<>
|
||||||
@@ -4592,8 +4594,8 @@ export function REPL({
|
|||||||
{showSpinner && <SpinnerWithVerb mode={streamMode} spinnerTip={spinnerTip} responseLengthRef={responseLengthRef} apiMetricsRef={apiMetricsRef} overrideMessage={spinnerMessage} spinnerSuffix={stopHookSpinnerSuffix} verbose={verbose} loadingStartTimeRef={loadingStartTimeRef} totalPausedMsRef={totalPausedMsRef} pauseStartTimeRef={pauseStartTimeRef} overrideColor={spinnerColor} overrideShimmerColor={spinnerShimmerColor} hasActiveTools={inProgressToolUseIDs.size > 0} leaderIsIdle={!isLoading} />}
|
{showSpinner && <SpinnerWithVerb mode={streamMode} spinnerTip={spinnerTip} responseLengthRef={responseLengthRef} apiMetricsRef={apiMetricsRef} overrideMessage={spinnerMessage} spinnerSuffix={stopHookSpinnerSuffix} verbose={verbose} loadingStartTimeRef={loadingStartTimeRef} totalPausedMsRef={totalPausedMsRef} pauseStartTimeRef={pauseStartTimeRef} overrideColor={spinnerColor} overrideShimmerColor={spinnerShimmerColor} hasActiveTools={inProgressToolUseIDs.size > 0} leaderIsIdle={!isLoading} />}
|
||||||
{!showSpinner && !isLoading && !userInputOnProcessing && !hasRunningTeammates && isBriefOnly && !viewedAgentTask && <BriefIdleStatus />}
|
{!showSpinner && !isLoading && !userInputOnProcessing && !hasRunningTeammates && isBriefOnly && !viewedAgentTask && <BriefIdleStatus />}
|
||||||
{isFullscreenEnvEnabled() && <PromptInputQueuedCommands />}
|
{isFullscreenEnvEnabled() && <PromptInputQueuedCommands />}
|
||||||
</>} bottom={<Box flexDirection={feature('BUDDY') && companionNarrow ? 'column' : 'row'} width="100%" alignItems={feature('BUDDY') && companionNarrow ? undefined : 'flex-end'}>
|
</>} bottom={<Box flexDirection={isBuddyEnabled() && companionNarrow ? 'column' : 'row'} width="100%" alignItems={isBuddyEnabled() && companionNarrow ? undefined : 'flex-end'}>
|
||||||
{feature('BUDDY') && companionNarrow && isFullscreenEnvEnabled() && companionVisible ? <CompanionSprite /> : null}
|
{isBuddyEnabled() && companionNarrow && isFullscreenEnvEnabled() && companionVisible ? <CompanionSprite /> : null}
|
||||||
<Box flexDirection="column" flexGrow={1}>
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
{permissionStickyFooter}
|
{permissionStickyFooter}
|
||||||
{/* Immediate local-jsx commands (/btw, /sandbox, /assistant,
|
{/* Immediate local-jsx commands (/btw, /sandbox, /assistant,
|
||||||
@@ -4901,7 +4903,7 @@ export function REPL({
|
|||||||
{postCompactSurvey.state !== 'closed' ? <FeedbackSurvey state={postCompactSurvey.state} lastResponse={postCompactSurvey.lastResponse} handleSelect={postCompactSurvey.handleSelect} inputValue={inputValue} setInputValue={setInputValue} onRequestFeedback={handleSurveyRequestFeedback} /> : memorySurvey.state !== 'closed' ? <FeedbackSurvey state={memorySurvey.state} lastResponse={memorySurvey.lastResponse} handleSelect={memorySurvey.handleSelect} handleTranscriptSelect={memorySurvey.handleTranscriptSelect} inputValue={inputValue} setInputValue={setInputValue} onRequestFeedback={handleSurveyRequestFeedback} message="How well did Claude use its memory? (optional)" /> : <FeedbackSurvey state={feedbackSurvey.state} lastResponse={feedbackSurvey.lastResponse} handleSelect={feedbackSurvey.handleSelect} handleTranscriptSelect={feedbackSurvey.handleTranscriptSelect} inputValue={inputValue} setInputValue={setInputValue} onRequestFeedback={didAutoRunIssueRef.current ? undefined : handleSurveyRequestFeedback} />}
|
{postCompactSurvey.state !== 'closed' ? <FeedbackSurvey state={postCompactSurvey.state} lastResponse={postCompactSurvey.lastResponse} handleSelect={postCompactSurvey.handleSelect} inputValue={inputValue} setInputValue={setInputValue} onRequestFeedback={handleSurveyRequestFeedback} /> : memorySurvey.state !== 'closed' ? <FeedbackSurvey state={memorySurvey.state} lastResponse={memorySurvey.lastResponse} handleSelect={memorySurvey.handleSelect} handleTranscriptSelect={memorySurvey.handleTranscriptSelect} inputValue={inputValue} setInputValue={setInputValue} onRequestFeedback={handleSurveyRequestFeedback} message="How well did Claude use its memory? (optional)" /> : <FeedbackSurvey state={feedbackSurvey.state} lastResponse={feedbackSurvey.lastResponse} handleSelect={feedbackSurvey.handleSelect} handleTranscriptSelect={feedbackSurvey.handleTranscriptSelect} inputValue={inputValue} setInputValue={setInputValue} onRequestFeedback={didAutoRunIssueRef.current ? undefined : handleSurveyRequestFeedback} />}
|
||||||
{/* Frustration-triggered transcript sharing prompt */}
|
{/* Frustration-triggered transcript sharing prompt */}
|
||||||
{frustrationDetection.state !== 'closed' && <FeedbackSurvey state={frustrationDetection.state} lastResponse={null} handleSelect={() => { }} handleTranscriptSelect={frustrationDetection.handleTranscriptSelect} inputValue={inputValue} setInputValue={setInputValue} />}
|
{frustrationDetection.state !== 'closed' && <FeedbackSurvey state={frustrationDetection.state} lastResponse={null} handleSelect={() => { }} handleTranscriptSelect={frustrationDetection.handleTranscriptSelect} inputValue={inputValue} setInputValue={setInputValue} />}
|
||||||
{/* Skill improvement survey - appears when improvements detected (ant-only) */}
|
{/* Skill improvement survey - appears when improvements detected (internal-only) */}
|
||||||
{"external" === 'ant' && skillImprovementSurvey.suggestion && <SkillImprovementSurvey isOpen={skillImprovementSurvey.isOpen} skillName={skillImprovementSurvey.suggestion.skillName} updates={skillImprovementSurvey.suggestion.updates} handleSelect={skillImprovementSurvey.handleSelect} inputValue={inputValue} setInputValue={setInputValue} />}
|
{"external" === 'ant' && skillImprovementSurvey.suggestion && <SkillImprovementSurvey isOpen={skillImprovementSurvey.isOpen} skillName={skillImprovementSurvey.suggestion.skillName} updates={skillImprovementSurvey.suggestion.updates} handleSelect={skillImprovementSurvey.handleSelect} inputValue={inputValue} setInputValue={setInputValue} />}
|
||||||
{showIssueFlagBanner && <IssueFlagBanner />}
|
{showIssueFlagBanner && <IssueFlagBanner />}
|
||||||
{ }
|
{ }
|
||||||
@@ -4997,7 +4999,7 @@ export function REPL({
|
|||||||
}} />}
|
}} />}
|
||||||
{"external" === 'ant' && <DevBar />}
|
{"external" === 'ant' && <DevBar />}
|
||||||
</Box>
|
</Box>
|
||||||
{feature('BUDDY') && !(companionNarrow && isFullscreenEnvEnabled()) && companionVisible ? <CompanionSprite /> : null}
|
{isBuddyEnabled() && !(companionNarrow && isFullscreenEnvEnabled()) && companionVisible ? <CompanionSprite /> : null}
|
||||||
</Box>} />
|
</Box>} />
|
||||||
</MCPConnectionManager>
|
</MCPConnectionManager>
|
||||||
</KeybindingSetup>;
|
</KeybindingSetup>;
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ function createSpeculationFeedbackMessage(
|
|||||||
: ''
|
: ''
|
||||||
|
|
||||||
return createSystemMessage(
|
return createSystemMessage(
|
||||||
`[ANT-ONLY] ${parts.join(' · ')} · ${savedText}${sessionSuffix}`,
|
`[internal-only] ${parts.join(' · ')} · ${savedText}${sessionSuffix}`,
|
||||||
'warning',
|
'warning',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ const extractSessionMemory = sequential(async function (
|
|||||||
|
|
||||||
// Check gate lazily when hook runs (cached, non-blocking)
|
// Check gate lazily when hook runs (cached, non-blocking)
|
||||||
if (!isSessionMemoryGateEnabled()) {
|
if (!isSessionMemoryGateEnabled()) {
|
||||||
// Log gate failure once per session (ant-only)
|
// Log gate failure once per session (internal-only)
|
||||||
if (process.env.USER_TYPE === 'ant' && !hasLoggedGateFailure) {
|
if (process.env.USER_TYPE === 'ant' && !hasLoggedGateFailure) {
|
||||||
hasLoggedGateFailure = true
|
hasLoggedGateFailure = true
|
||||||
logEvent('tengu_session_memory_gate_disabled', {})
|
logEvent('tengu_session_memory_gate_disabled', {})
|
||||||
@@ -359,7 +359,7 @@ export function initSessionMemory(): void {
|
|||||||
// Session memory is used for compaction, so respect auto-compact settings
|
// Session memory is used for compaction, so respect auto-compact settings
|
||||||
const autoCompactEnabled = isAutoCompactEnabled()
|
const autoCompactEnabled = isAutoCompactEnabled()
|
||||||
|
|
||||||
// Log initialization state (ant-only to avoid noise in external logs)
|
// Log initialization state (internal-only to avoid noise in external logs)
|
||||||
if (process.env.USER_TYPE === 'ant') {
|
if (process.env.USER_TYPE === 'ant') {
|
||||||
logEvent('tengu_session_memory_init', {
|
logEvent('tengu_session_memory_init', {
|
||||||
auto_compact_enabled: autoCompactEnabled,
|
auto_compact_enabled: autoCompactEnabled,
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ async function logEventTo1PAsync(
|
|||||||
// Debug logging when debug mode is enabled
|
// Debug logging when debug mode is enabled
|
||||||
if (process.env.USER_TYPE === 'ant') {
|
if (process.env.USER_TYPE === 'ant') {
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[ANT-ONLY] 1P event: ${eventName} ${jsonStringify(metadata, null, 0)}`,
|
`[internal-only] 1P event: ${eventName} ${jsonStringify(metadata, null, 0)}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,7 +287,7 @@ export function logGrowthBookExperimentTo1P(
|
|||||||
|
|
||||||
if (process.env.USER_TYPE === 'ant') {
|
if (process.env.USER_TYPE === 'ant') {
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[ANT-ONLY] 1P GrowthBook experiment: ${data.experimentId} variation=${data.variationId}`,
|
`[internal-only] 1P GrowthBook experiment: ${data.experimentId} variation=${data.variationId}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user