mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
Merge branch 'main' into feature/pokemon/battle
This commit is contained in:
@@ -41,7 +41,8 @@ All teach-me data is stored under `.claude/skills/teach-me/records/`:
|
||||
.claude/skills/teach-me/records/
|
||||
├── learner-profile.md # Cross-topic notes (created on first session)
|
||||
└── {topic-slug}/
|
||||
└── session.md # Learning state: concepts, status, notes
|
||||
├── session.md # Learning state: concepts, status, notes
|
||||
└── {topic-slug}-notes.md # Learner-facing summary notes (generated at session end)
|
||||
```
|
||||
|
||||
**Slug**: Topic in kebab-case, 2-5 words. Example: "Python decorators" → `python-decorators`
|
||||
@@ -275,7 +276,8 @@ Update `session.md` after each round:
|
||||
When all concepts mastered or user ends session:
|
||||
|
||||
1. Update `session.md` with final state.
|
||||
2. Update `.claude/skills/teach-me/records/learner-profile.md` (keep under 30 lines):
|
||||
2. **Generate learner-facing notes** — write `{topic-slug}-notes.md` in the topic directory. This is a standalone reference document the learner can review later. See "Notes Generation" below for format.
|
||||
3. Update `.claude/skills/teach-me/records/learner-profile.md` (keep under 30 lines):
|
||||
|
||||
```markdown
|
||||
# Learner Profile
|
||||
@@ -293,7 +295,48 @@ Updated: {timestamp}
|
||||
- Python decorators (8/10 concepts, 2025-01-15)
|
||||
```
|
||||
|
||||
3. Give a brief text summary of what was covered, key insights, and areas for further study.
|
||||
4. Give a brief text summary of what was covered, key insights, and areas for further study.
|
||||
|
||||
## Notes Generation
|
||||
|
||||
At session end, generate a learner-facing notes file at `{topic-slug}/{topic-slug}-notes.md`. This file is **written for the learner to review later**, not for the tutor. It should be self-contained and organized as a quick-reference.
|
||||
|
||||
### Notes Structure
|
||||
|
||||
```markdown
|
||||
# {Topic} 核心笔记
|
||||
|
||||
## 1. {Section Name}
|
||||
{Key concept, mechanism, or principle}
|
||||
* **One-line summary**: {what it does / why it matters}
|
||||
* **Detail**: {brief explanation, 2-4 sentences max}
|
||||
* **Example** (if applicable): {code snippet, command, or concrete scenario}
|
||||
|
||||
---
|
||||
|
||||
## 2. {Section Name}
|
||||
...
|
||||
|
||||
---
|
||||
|
||||
## n. 实战参数 / Cheat Sheet (if applicable)
|
||||
{Practical commands, config, or quick-reference table}
|
||||
|
||||
| Parameter / Concept | What it does | Tuning tip |
|
||||
|---------------------|-------------|------------|
|
||||
| ... | ... | ... |
|
||||
```
|
||||
|
||||
### Notes Writing Rules
|
||||
|
||||
1. **Start with "what & why"** before "how". Each section should answer: what is this, why does it exist, what problem does it solve.
|
||||
2. **Use analogies sparingly but effectively**. Only include an analogy if it clarifies a non-obvious mechanism (e.g., "PagedAttention is like OS virtual memory paging").
|
||||
3. **Include trade-offs**. Every optimization or design choice has a cost. Always state it (e.g., "TP improves throughput but increases communication latency").
|
||||
4. **Code / command examples should be minimal**. Under 10 lines, self-contained, with comments explaining the key flags.
|
||||
5. **Organize by concept dependency**, not by chronological teaching order. Foundation concepts first, advanced ones last.
|
||||
6. **No quiz questions, no misconceptions, no tutor-side notes**. This is a clean reference document.
|
||||
7. **Language matches the session**. If the session was in Chinese, notes are in Chinese (technical terms can stay in English).
|
||||
8. **Keep it under 150 lines**. If it gets too long, the learner won't review it. Be ruthless about cutting fluff.
|
||||
|
||||
## Resuming Sessions
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -43,3 +43,4 @@ data
|
||||
.codex/skills/.system/**
|
||||
!.codex/prompts/
|
||||
!.codex/prompts/**
|
||||
teach-me
|
||||
|
||||
128
AGENTS.md
128
AGENTS.md
@@ -1,10 +1,10 @@
|
||||
# AGENTS.md
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
|
||||
This file provides guidance to Claude Code (claude.ai/code) and other AI coding agents when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is a **reverse-engineered / decompiled** version of Anthropic's official Codex CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced — **`bunx tsc --noEmit` must pass with zero errors**.
|
||||
This is a **reverse-engineered / decompiled** version of Anthropic's official Claude Code CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced — **`bunx tsc --noEmit` must pass with zero errors**.
|
||||
|
||||
## Git Commit Message Convention
|
||||
|
||||
@@ -39,8 +39,11 @@ echo "say hello" | bun run src/entrypoints/cli.tsx -p
|
||||
# Build (code splitting, outputs dist/cli.js + chunk files)
|
||||
bun run build
|
||||
|
||||
# Build with Vite (alternative build pipeline)
|
||||
bun run build:vite
|
||||
|
||||
# Test
|
||||
bun test # run all tests (2453 tests / 137 files / 0 fail)
|
||||
bun test # run all tests
|
||||
bun test src/utils/__tests__/hash.test.ts # run single file
|
||||
bun test --coverage # with coverage report
|
||||
|
||||
@@ -55,6 +58,10 @@ bun run health
|
||||
# Check unused exports
|
||||
bun run check:unused
|
||||
|
||||
# Full check (typecheck + lint + test) — run after completing any task
|
||||
bun run test:all
|
||||
bun run typecheck
|
||||
|
||||
# Remote Control Server
|
||||
bun run rcs
|
||||
|
||||
@@ -72,17 +79,17 @@ bun run docs:dev
|
||||
- **Build**: `build.ts` 执行 `Bun.build()` with `splitting: true`,入口 `src/entrypoints/cli.tsx`,输出 `dist/cli.js` + chunk files。Build 默认启用 19 个 feature(见下方 Feature Flag 段)。构建后自动替换 `import.meta.require` 为 Node.js 兼容版本(产物 bun/node 都可运行)。
|
||||
- **Dev mode**: `scripts/dev.ts` 通过 Bun `-d` flag 注入 `MACRO.*` defines,运行 `src/entrypoints/cli.tsx`。默认启用全部 feature。
|
||||
- **Module system**: ESM (`"type": "module"`), TSX with `react-jsx` transform.
|
||||
- **Monorepo**: Bun workspaces — 14 个 internal packages in `packages/` resolved via `workspace:*`。
|
||||
- **Monorepo**: Bun workspaces — 15 个 workspace packages + 若干辅助目录 in `packages/` resolved via `workspace:*`。
|
||||
- **Lint/Format**: Biome (`biome.json`)。`bun run lint` / `bun run lint:fix` / `bun run format`。
|
||||
- **Defines**: 集中管理在 `scripts/defines.ts`。当前版本 `2.1.888`。
|
||||
- **CI**: GitHub Actions — `ci.yml`(构建+测试)、`release-rcs.yml`(RCS 发布)、`update-contributors.yml`(自动更新贡献者)。
|
||||
|
||||
### Entry & Bootstrap
|
||||
|
||||
1. **`src/entrypoints/cli.tsx`** (323 行) — True entrypoint。`main()` 函数按优先级处理多条快速路径:
|
||||
1. **`src/entrypoints/cli.tsx`** — True entrypoint。`main()` 函数按优先级处理多条快速路径:
|
||||
- `--version` / `-v` — 零模块加载
|
||||
- `--dump-system-prompt` — feature-gated (DUMP_SYSTEM_PROMPT)
|
||||
- `--Codex-in-chrome-mcp` / `--chrome-native-host`
|
||||
- `--claude-in-chrome-mcp` / `--chrome-native-host`
|
||||
- `--computer-use-mcp` — 独立 MCP server 模式
|
||||
- `--daemon-worker=<kind>` — feature-gated (DAEMON)
|
||||
- `remote-control` / `rc` / `remote` / `sync` / `bridge` — feature-gated (BRIDGE_MODE)
|
||||
@@ -92,26 +99,26 @@ bun run docs:dev
|
||||
- `environment-runner` / `self-hosted-runner` — BYOC runner
|
||||
- `--tmux` + `--worktree` 组合
|
||||
- 默认路径:加载 `main.tsx` 启动完整 CLI
|
||||
2. **`src/main.tsx`** (~6970 行) — Commander.js CLI definition。注册大量 subcommands:`mcp` (serve/add/remove/list...)、`server`、`ssh`、`open`、`auth`、`plugin`、`agents`、`auto-mode`、`doctor`、`update` 等。主 `.action()` 处理器负责权限、MCP、会话恢复、REPL/Headless 模式分发。
|
||||
2. **`src/main.tsx`** (~6981 行) — Commander.js CLI definition。注册大量 subcommands:`mcp` (serve/add/remove/list...)、`server`、`ssh`、`open`、`auth`、`plugin`、`agents`、`auto-mode`、`doctor`、`update` 等。主 `.action()` 处理器负责权限、MCP、会话恢复、REPL/Headless 模式分发。
|
||||
3. **`src/entrypoints/init.ts`** — One-time initialization (telemetry, config, trust dialog)。
|
||||
|
||||
### Core Loop
|
||||
|
||||
- **`src/query.ts`** — The main API query function. Sends messages to Codex API, handles streaming responses, processes tool calls, and manages the conversation turn loop.
|
||||
- **`src/query.ts`** — The main API query function. Sends messages to Claude API, handles streaming responses, processes tool calls, and manages the conversation turn loop.
|
||||
- **`src/QueryEngine.ts`** — Higher-level orchestrator wrapping `query()`. Manages conversation state, compaction, file history snapshots, attribution, and turn-level bookkeeping. Used by the REPL screen.
|
||||
- **`src/screens/REPL.tsx`** — The interactive REPL screen (React/Ink component). Handles user input, message display, tool permission prompts, and keyboard shortcuts.
|
||||
|
||||
### API Layer
|
||||
|
||||
- **`src/services/api/Codex.ts`** — Core API client. Builds request params (system prompt, messages, tools, betas), calls the Anthropic SDK streaming endpoint, and processes `BetaRawMessageStreamEvent` events.
|
||||
- **`src/services/api/claude.ts`** — Core API client. Builds request params (system prompt, messages, tools, betas), calls the Anthropic SDK streaming endpoint, and processes `BetaRawMessageStreamEvent` events.
|
||||
- **7 providers**: `firstParty` (Anthropic direct), `bedrock` (AWS), `vertex` (Google Cloud), `foundry`, `openai`, `gemini`, `grok` (xAI)。
|
||||
- Provider selection in `src/utils/model/providers.ts`。优先级:modelType 参数 > 环境变量 > 默认 firstParty。
|
||||
|
||||
### Tool System
|
||||
|
||||
- **`src/Tool.ts`** — Tool interface definition (`Tool` type) and utilities (`findToolByName`, `toolMatchesName`).
|
||||
- **`src/tools.ts`** (387 行) — Tool registry. Assembles the tool list; some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`.
|
||||
- **`src/tools/<ToolName>/`** — 55 个 tool 目录。主要分类:
|
||||
- **`src/tools.ts`** — Tool registry. Assembles the tool list; tools are imported from `@claude-code-best/builtin-tools` package. Some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`.
|
||||
- **`packages/builtin-tools/src/tools/`** — 59 个子目录(含 shared/testing 等工具目录),通过 `@claude-code-best/builtin-tools` 包导出。主要分类:
|
||||
- **文件操作**: FileEditTool, FileReadTool, FileWriteTool, GlobTool, GrepTool
|
||||
- **Shell/执行**: BashTool, PowerShellTool, REPLTool
|
||||
- **Agent 系统**: AgentTool, TaskCreateTool, TaskUpdateTool, TaskListTool, TaskGetTool
|
||||
@@ -119,7 +126,7 @@ bun run docs:dev
|
||||
- **Web/MCP**: WebFetchTool, WebSearchTool, MCPTool, McpAuthTool
|
||||
- **调度**: CronCreateTool, CronDeleteTool, CronListTool
|
||||
- **其他**: LSPTool, ConfigTool, SkillTool, EnterWorktreeTool, ExitWorktreeTool 等
|
||||
- **`src/tools/shared/`** — Tool 共享工具函数。
|
||||
- **`src/tools/shared/`** / **`packages/builtin-tools/src/tools/shared/`** — Tool 共享工具函数。
|
||||
|
||||
### UI Layer (Ink)
|
||||
|
||||
@@ -149,10 +156,18 @@ bun run docs:dev
|
||||
| `packages/@ant/computer-use-mcp/` | Computer Use MCP server(截图/键鼠/剪贴板/应用管理) |
|
||||
| `packages/@ant/computer-use-input/` | 键鼠模拟(dispatcher + darwin/win32/linux backend) |
|
||||
| `packages/@ant/computer-use-swift/` | 截图 + 应用管理(dispatcher + per-platform backend) |
|
||||
| `packages/@ant/Codex-for-chrome-mcp/` | Chrome 浏览器控制(通过 `--chrome` 启用) |
|
||||
| `packages/remote-control-server/` | 自托管 Remote Control Server(Docker 部署,含 Web UI) |
|
||||
| `packages/swarm/` | Swarm 解耦模块 |
|
||||
| `packages/shell/` | Shell 抽象 |
|
||||
| `packages/@ant/claude-for-chrome-mcp/` | Chrome 浏览器控制(通过 `--chrome` 启用) |
|
||||
| `packages/@ant/model-provider/` | Model provider 抽象层 |
|
||||
| `packages/builtin-tools/` | 内置工具集(60 个 tool 实现,通过 `@claude-code-best/builtin-tools` 导出) |
|
||||
| `packages/agent-tools/` | Agent 工具集 |
|
||||
| `packages/acp-link/` | ACP 代理服务器(WebSocket → ACP agent 桥接) |
|
||||
| `packages/cc-knowledge/` | Claude Code 知识库(非 workspace 包) |
|
||||
| `packages/langfuse-dashboard/` | Langfuse 可观测性面板(非 workspace 包) |
|
||||
| `packages/mcp-client/` | MCP 客户端库 |
|
||||
| `packages/mcp-server/` | MCP 服务端库(非 workspace 包) |
|
||||
| `packages/remote-control-server/` | 自托管 Remote Control Server(Docker 部署,含 Web UI)— Web UI 已重构为 React + Vite + Radix UI,支持 ACP agent 接入 |
|
||||
| `packages/swarm/` | Swarm 解耦模块(非 workspace 包) |
|
||||
| `packages/shell/` | Shell 抽象(非 workspace 包) |
|
||||
| `packages/audio-capture-napi/` | 原生音频捕获(已恢复) |
|
||||
| `packages/color-diff-napi/` | 颜色差异计算(完整实现,11 tests) |
|
||||
| `packages/image-processor-napi/` | 图像处理(已恢复) |
|
||||
@@ -161,19 +176,26 @@ bun run docs:dev
|
||||
|
||||
### Bridge / Remote Control
|
||||
|
||||
- **`src/bridge/`** (~37 files) — Remote Control / Bridge 模式。feature-gated by `BRIDGE_MODE`。包含 bridge API、会话管理、JWT 认证、消息传输、权限回调等。Entry: `bridgeMain.ts`。
|
||||
- **`packages/remote-control-server/`** — 自托管 RCS,支持 Docker 部署,含 Web UI 控制面板。通过 `bun run rcs` 启动。
|
||||
- CLI 快速路径: `Codex remote-control` / `Codex rc` / `Codex bridge`。
|
||||
- **`src/bridge/`** — Remote Control / Bridge 模式。feature-gated by `BRIDGE_MODE`。包含 bridge API、会话管理、JWT 认证、消息传输、权限回调等。Entry: `bridgeMain.ts`。
|
||||
- **`packages/remote-control-server/`** — 自托管 RCS,支持 Docker 部署,含 Web UI 控制面板(React 19 + Vite + Radix UI)。支持 ACP agent 通过 acp-link 接入(ACP WebSocket handler、relay handler、SSE event stream)。通过 `bun run rcs` 启动。
|
||||
- CLI 快速路径: `claude remote-control` / `claude rc` / `claude bridge`。
|
||||
- 详见 `docs/features/remote-control-self-hosting.md`。
|
||||
|
||||
### ACP Protocol (Agent Client Protocol)
|
||||
|
||||
- **`src/services/acp/`** — ACP agent 实现,包含 `agent.ts`(AcpAgent 类)、`bridge.ts`(Claude Code ↔ ACP 桥接)、`permissions.ts`(权限处理)、`entry.ts`(入口)。
|
||||
- **`packages/acp-link/`** — ACP 代理服务器,将 WebSocket 客户端桥接到 ACP agent。提供 `acp-link` CLI 命令,支持自定义端口/HTTPS/认证/会话管理、RCS 集成(REST 注册 + WS identify 两步流程)、权限模式透传(fallback: 客户端传值 > config > `ACP_PERMISSION_MODE` 环境变量)。
|
||||
- ACP 权限管道改进:`createAcpCanUseTool` 统一权限流水线,`applySessionMode` 模式同步,`bypassPermissions` 可用性检测(非 root/sandbox 环境)。
|
||||
- ACP Plan 可视化已支持 `session/update plan` 类型的消息展示(PlanView 组件,含进度条/状态图标/优先级标签)。
|
||||
|
||||
### Daemon Mode
|
||||
|
||||
- **`src/daemon/`** — Daemon 模式(长驻 supervisor)。feature-gated by `DAEMON`。包含 `main.ts`(entry)和 `workerRegistry.ts`(worker 管理)。
|
||||
|
||||
### Context & System Prompt
|
||||
|
||||
- **`src/context.ts`** — Builds system/user context for the API call (git status, date, AGENTS.md contents, memory files).
|
||||
- **`src/utils/claudemd.ts`** — Discovers and loads AGENTS.md files from project hierarchy.
|
||||
- **`src/context.ts`** — Builds system/user context for the API call (git status, date, CLAUDE.md contents, memory files).
|
||||
- **`src/utils/claudemd.ts`** — Discovers and loads CLAUDE.md files from project hierarchy.
|
||||
|
||||
### Feature Flag System
|
||||
|
||||
@@ -196,7 +218,7 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
|
||||
|
||||
### Multi-API 兼容层
|
||||
|
||||
所有兼容层均采用流适配器模式:将第三方 API 格式转为 Anthropic 内部格式,下游代码完全不改。
|
||||
所有兼容层均采用流适配器模式:将第三方 API 格式转为 Anthropic 内部格式,下游代码完全不改。通过 `/login` 命令配置。
|
||||
|
||||
#### OpenAI 兼容层
|
||||
|
||||
@@ -221,6 +243,12 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
|
||||
|
||||
详见各兼容层的 docs 文档。
|
||||
|
||||
### 穷鬼模式(Budget Mode)
|
||||
|
||||
- 通过 `/poor` 命令切换,持久化到 `settings.json`。
|
||||
- 启用后跳过 `extract_memories`、`prompt_suggestion` 和 `verification_agent`,显著减少 token 消耗。
|
||||
- 实现在 `src/commands/poor/poorMode.ts`。
|
||||
|
||||
### Stubbed/Deleted Modules
|
||||
|
||||
| Module | Status |
|
||||
@@ -245,20 +273,40 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
|
||||
## Testing
|
||||
|
||||
- **框架**: `bun:test`(内置断言 + mock)
|
||||
- **当前状态**: 2472 tests / 138 files / 0 fail
|
||||
- **单元测试**: 就近放置于 `src/**/__tests__/`,文件名 `<module>.test.ts`
|
||||
- **集成测试**: `tests/integration/` — 4 个文件(cli-arguments, context-build, message-pipeline, tool-chain)
|
||||
- **共享 mock/fixture**: `tests/mocks/`(api-responses, file-system, fixtures/)
|
||||
- **命名**: `describe("functionName")` + `test("behavior description")`,英文
|
||||
- **Mock 模式**: 对重依赖模块使用 `mock.module()` + `await import()` 解锁(必须内联在测试文件中,不能从共享 helper 导入)
|
||||
- **包测试**: `packages/` 下各包也有独立测试(如 `color-diff-napi` 11 tests)
|
||||
|
||||
### Mock 使用规范
|
||||
|
||||
**只 mock 有副作用的依赖链,不 mock 纯函数/纯数据模块。**
|
||||
|
||||
被迫 mock 的根源:`log.ts` / `debug.ts` → `bootstrap/state.ts`(模块级 `realpathSync` / `randomUUID` 副作用)。必须 mock 的模块:`log.ts`、`debug.ts`、`bun:bundle`、`settings/settings.js`、`config.ts`、`auth.ts`、第三方网络库。
|
||||
|
||||
**`log.ts` 和 `debug.ts` 使用共享 mock**(`tests/mocks/log.ts` / `tests/mocks/debug.ts`),不要在测试文件中内联 mock 定义。使用方式:
|
||||
|
||||
```ts
|
||||
import { logMock } from "../../../tests/mocks/log";
|
||||
mock.module("src/utils/log.ts", logMock);
|
||||
|
||||
import { debugMock } from "../../../../tests/mocks/debug";
|
||||
mock.module("src/utils/debug.ts", debugMock);
|
||||
```
|
||||
|
||||
源文件导出变更时只需更新 `tests/mocks/` 下的对应文件,不需要逐个修改测试。
|
||||
|
||||
不要 mock:纯函数模块(`errors.ts`、`stringUtils.js`)、mock 值与真实实现相同的模块、mock 路径与实际 import 不匹配的模块。
|
||||
|
||||
路径规则:统一用 `.ts` 扩展名 + `src/*` 别名路径,禁止双重 mock 同一模块。
|
||||
|
||||
### 类型检查
|
||||
|
||||
项目使用 TypeScript strict 模式,**tsc 必须零错误**。每次修改后运行:
|
||||
|
||||
```bash
|
||||
bunx tsc --noEmit
|
||||
bun run typecheck
|
||||
```
|
||||
|
||||
**类型规范**:
|
||||
@@ -271,7 +319,7 @@ bunx tsc --noEmit
|
||||
|
||||
## Working with This Codebase
|
||||
|
||||
- **tsc must pass** — `bunx tsc --noEmit` 必须零错误,任何修改都不能引入新的类型错误。
|
||||
- **tsc must pass** — `bun run typecheck` 必须零错误,任何修改都不能引入新的类型错误。
|
||||
- **Feature flags** — 默认全部关闭(`feature()` 返回 `false`)。Dev/build 各有自己的默认启用列表。不要在 `cli.tsx` 中重定义 `feature` 函数。
|
||||
- **React Compiler output** — Components have decompiled memoization boilerplate (`const $ = _c(N)`). This is normal.
|
||||
- **`bun:bundle` import** — `import { feature } from 'bun:bundle'` 是 Bun 内置模块,由运行时/构建器解析。不要用自定义函数替代它。**`feature()` 只能直接用在 `if` 语句或三元表达式的条件位置**(Bun 编译器限制),不能赋值给变量、不能放在箭头函数体里、不能作为 `&&` 链的一部分。正确:`if (feature('X')) {}` 或 `feature('X') ? a : b`。
|
||||
@@ -281,3 +329,29 @@ bunx tsc --noEmit
|
||||
- **Biome 配置** — 大量 lint 规则被关闭(decompiled 代码不适合严格 lint)。`.tsx` 文件用 120 行宽 + 强制分号;其他文件 80 行宽 + 按需分号。
|
||||
- **Ink 框架在 `packages/@ant/ink/`** — 不是 `src/ink/`(该目录不存在)。Ink 相关的组件、hooks、keybindings 都在 packages 中。
|
||||
- **Provider 优先级** — `modelType` 参数 > 环境变量 > 默认 `firstParty`。新增 provider 需在 `src/utils/model/providers.ts` 注册。
|
||||
|
||||
## Design Context
|
||||
|
||||
Impeccable 设计上下文保存在 `.impeccable.md` 中。设计 Web UI(RCS 控制面板、文档站、着陆页)时必须参考该文件。
|
||||
|
||||
### 核心设计原则
|
||||
|
||||
1. **Considered over clever** — 每个设计选择都应感觉有意为之,而非追逐潮流
|
||||
2. **Warmth through subtlety** — 通过橙色色调的中性色、留白布局、有温度的文案来传达温暖
|
||||
3. **Density with clarity** — 技术用户需要信息密度,但不能混乱
|
||||
4. **Community voice** — 设计应感觉是由使用者创造的,而非遥远的设计团队
|
||||
5. **Anthropic's shadow** — 遵循 Anthropic 的设计直觉:干净的布局、充足的间距、温暖的色温
|
||||
|
||||
### 品牌色
|
||||
|
||||
- 主色:Claude Orange `#D77757`(terra cotta)
|
||||
- 辅色:Claude Blue `#5769F7`
|
||||
- 暗色模式使用温暖的深色表面(非冷蓝黑色)
|
||||
|
||||
### 目标用户
|
||||
|
||||
技术团队/企业,在专业工作流中使用 AI 辅助编程。友好的开源社区氛围,非企业 SaaS 风格。
|
||||
|
||||
### 视觉参考
|
||||
|
||||
Anthropic 公司的设计风格 — 干净、考究、温暖的底色。大量留白,以排版为核心。避免 AI 产品常见的设计套路(渐变文字、玻璃态、霓虹色)。
|
||||
|
||||
42
CLAUDE.md
42
CLAUDE.md
@@ -1,10 +1,10 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
This file provides guidance to Claude Code (claude.ai/code) and other AI coding agents when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is a **reverse-engineered / decompiled** version of Anthropic's official Claude Code CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced(见 Working with This Codebase 段的 tsc 要求)。
|
||||
This is a **reverse-engineered / decompiled** version of Anthropic's official Claude Code CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced — **`bunx tsc --noEmit` must pass with zero errors**.
|
||||
|
||||
## Git Commit Message Convention
|
||||
|
||||
@@ -43,7 +43,7 @@ bun run build
|
||||
bun run build:vite
|
||||
|
||||
# Test
|
||||
bun test # run all tests (3175 tests / 207 files / 0 fail)
|
||||
bun test # run all tests
|
||||
bun test src/utils/__tests__/hash.test.ts # run single file
|
||||
bun test --coverage # with coverage report
|
||||
|
||||
@@ -60,7 +60,6 @@ bun run check:unused
|
||||
|
||||
# Full check (typecheck + lint + test) — run after completing any task
|
||||
bun run test:all
|
||||
|
||||
bun run typecheck
|
||||
|
||||
# Remote Control Server
|
||||
@@ -87,7 +86,7 @@ bun run docs:dev
|
||||
|
||||
### Entry & Bootstrap
|
||||
|
||||
1. **`src/entrypoints/cli.tsx`** (373 行) — True entrypoint。`main()` 函数按优先级处理多条快速路径:
|
||||
1. **`src/entrypoints/cli.tsx`** — True entrypoint。`main()` 函数按优先级处理多条快速路径:
|
||||
- `--version` / `-v` — 零模块加载
|
||||
- `--dump-system-prompt` — feature-gated (DUMP_SYSTEM_PROMPT)
|
||||
- `--claude-in-chrome-mcp` / `--chrome-native-host`
|
||||
@@ -118,7 +117,7 @@ bun run docs:dev
|
||||
### Tool System
|
||||
|
||||
- **`src/Tool.ts`** — Tool interface definition (`Tool` type) and utilities (`findToolByName`, `toolMatchesName`).
|
||||
- **`src/tools.ts`** (392 行) — Tool registry. Assembles the tool list; tools are imported from `@claude-code-best/builtin-tools` package. Some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`.
|
||||
- **`src/tools.ts`** — Tool registry. Assembles the tool list; tools are imported from `@claude-code-best/builtin-tools` package. Some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`.
|
||||
- **`packages/builtin-tools/src/tools/`** — 59 个子目录(含 shared/testing 等工具目录),通过 `@claude-code-best/builtin-tools` 包导出。主要分类:
|
||||
- **文件操作**: FileEditTool, FileReadTool, FileWriteTool, GlobTool, GrepTool
|
||||
- **Shell/执行**: BashTool, PowerShellTool, REPLTool
|
||||
@@ -127,6 +126,7 @@ bun run docs:dev
|
||||
- **Web/MCP**: WebFetchTool, WebSearchTool, MCPTool, McpAuthTool
|
||||
- **调度**: CronCreateTool, CronDeleteTool, CronListTool
|
||||
- **其他**: LSPTool, ConfigTool, SkillTool, EnterWorktreeTool, ExitWorktreeTool 等
|
||||
- **`src/tools/shared/`** / **`packages/builtin-tools/src/tools/shared/`** — Tool 共享工具函数。
|
||||
|
||||
### UI Layer (Ink)
|
||||
|
||||
@@ -176,7 +176,7 @@ bun run docs:dev
|
||||
|
||||
### Bridge / Remote Control
|
||||
|
||||
- **`src/bridge/`** (~38 files) — Remote Control / Bridge 模式。feature-gated by `BRIDGE_MODE`。包含 bridge API、会话管理、JWT 认证、消息传输、权限回调等。Entry: `bridgeMain.ts`。
|
||||
- **`src/bridge/`** — Remote Control / Bridge 模式。feature-gated by `BRIDGE_MODE`。包含 bridge API、会话管理、JWT 认证、消息传输、权限回调等。Entry: `bridgeMain.ts`。
|
||||
- **`packages/remote-control-server/`** — 自托管 RCS,支持 Docker 部署,含 Web UI 控制面板(React 19 + Vite + Radix UI)。支持 ACP agent 通过 acp-link 接入(ACP WebSocket handler、relay handler、SSE event stream)。通过 `bun run rcs` 启动。
|
||||
- CLI 快速路径: `claude remote-control` / `claude rc` / `claude bridge`。
|
||||
- 详见 `docs/features/remote-control-self-hosting.md`。
|
||||
@@ -218,7 +218,30 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
|
||||
|
||||
### Multi-API 兼容层
|
||||
|
||||
支持 OpenAI、Gemini、Grok 三种第三方 API,通过 `/login` 命令配置,均采用流适配器模式转为 Anthropic 内部格式。详见各兼容层的 docs 文档。
|
||||
所有兼容层均采用流适配器模式:将第三方 API 格式转为 Anthropic 内部格式,下游代码完全不改。通过 `/login` 命令配置。
|
||||
|
||||
#### OpenAI 兼容层
|
||||
|
||||
通过 `CLAUDE_CODE_USE_OPENAI=1` 启用,支持 Ollama/DeepSeek/vLLM 等任意 OpenAI Chat Completions 协议端点。含 DeepSeek thinking mode 支持。
|
||||
|
||||
- **`src/services/api/openai/`** — client、消息/工具转换、流适配、模型映射
|
||||
- 关键环境变量:`CLAUDE_CODE_USE_OPENAI`、`OPENAI_API_KEY`、`OPENAI_BASE_URL`、`OPENAI_MODEL`
|
||||
|
||||
#### Gemini 兼容层
|
||||
|
||||
通过 `CLAUDE_CODE_USE_GEMINI=1` 启用。独立环境变量体系。
|
||||
|
||||
- **`src/services/api/gemini/`** — client、模型映射、类型定义
|
||||
- 关键环境变量:`GEMINI_API_KEY`(必填)、`GEMINI_MODEL`(直接指定)、`GEMINI_DEFAULT_SONNET_MODEL`/`GEMINI_DEFAULT_OPUS_MODEL`(按能力映射)
|
||||
- 模型映射优先级:`GEMINI_MODEL` > `GEMINI_DEFAULT_*_MODEL` > `ANTHROPIC_DEFAULT_*_MODEL`(已废弃) > 原样返回
|
||||
|
||||
#### Grok 兼容层
|
||||
|
||||
通过 `CLAUDE_CODE_USE_GROK=1` 启用。自定义模型映射支持 xAI Grok API。
|
||||
|
||||
- **`src/services/api/grok/`** — client、模型映射
|
||||
|
||||
详见各兼容层的 docs 文档。
|
||||
|
||||
### 穷鬼模式(Budget Mode)
|
||||
|
||||
@@ -250,7 +273,6 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
|
||||
## Testing
|
||||
|
||||
- **框架**: `bun:test`(内置断言 + mock)
|
||||
- **当前状态**: 3175 tests / 207 files / 0 fail
|
||||
- **单元测试**: 就近放置于 `src/**/__tests__/`,文件名 `<module>.test.ts`
|
||||
- **集成测试**: `tests/integration/` — 4 个文件(cli-arguments, context-build, message-pipeline, tool-chain)
|
||||
- **共享 mock/fixture**: `tests/mocks/`(api-responses, file-system, fixtures/)
|
||||
@@ -284,7 +306,7 @@ mock.module("src/utils/debug.ts", debugMock);
|
||||
项目使用 TypeScript strict 模式,**tsc 必须零错误**。每次修改后运行:
|
||||
|
||||
```bash
|
||||
bun run typecheck # equivalent to bun run typecheck
|
||||
bun run typecheck
|
||||
```
|
||||
|
||||
**类型规范**:
|
||||
|
||||
57
README.md
57
README.md
@@ -12,6 +12,8 @@
|
||||
|
||||
牢 A (Anthropic) 官方 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI 工具的源码反编译/逆向还原项目。目标是将 Claude Code 大部分功能及工程化能力复现 (问就是老佛爷已经付过钱了)。虽然很难绷, 但是它叫做 CCB(踩踩背)... 而且, 我们实现了企业版或者需要登陆 Claude 账号才能使用的特性, 实现技术普惠
|
||||
|
||||
> 我们将会在五一期间进行整个代码仓库的 lint 规范化, 这个期间提交的 PR 可能会有非常多的冲突, 所以大的功能请尽量在这之前提交哈
|
||||
|
||||
[文档在这里, 支持投稿 PR](https://ccb.agent-aura.top/) | [留影文档在这里](./Friends.md) | [Discord 群组](https://discord.gg/uApuzJWGKX)
|
||||
|
||||
|
||||
@@ -60,11 +62,66 @@ CLAUDE_BRIDGE_BASE_URL=https://remote-control.claude-code-best.win/ CLAUDE_BRIDG
|
||||
一定要最新版本的 bun 啊, 不然一堆奇奇怪怪的 BUG!!! bun upgrade!!!
|
||||
|
||||
- 📦 [Bun](https://bun.sh/) >= 1.3.11
|
||||
|
||||
**安装 Bun:**
|
||||
|
||||
```bash
|
||||
# Linux 和 macOS
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
|
||||
# Windows (PowerShell)
|
||||
powershell -c "irm bun.sh/install.ps1 | iex"
|
||||
```
|
||||
|
||||
**安装后的操作:**
|
||||
|
||||
1. **让当前终端识别 `bun` 命令**
|
||||
|
||||
安装脚本会把 `~/.bun/bin` 写入对应的 shell 配置文件。macOS 默认 zsh 环境通常会看到:
|
||||
|
||||
```text
|
||||
Added "~/.bun/bin" to $PATH in "~/.zshrc"
|
||||
```
|
||||
|
||||
可以按安装脚本提示重启当前 shell:
|
||||
|
||||
```bash
|
||||
exec /bin/zsh
|
||||
```
|
||||
|
||||
如果你使用 bash,重新加载 bash 配置:
|
||||
|
||||
```bash
|
||||
source ~/.bashrc
|
||||
```
|
||||
|
||||
Windows PowerShell 用户关闭并重新打开 PowerShell 即可。
|
||||
|
||||
2. **验证 Bun 是否可用**
|
||||
|
||||
```bash
|
||||
bun --help
|
||||
bun --version
|
||||
```
|
||||
|
||||
3. **如果已经安装过 Bun,更新到最新版本**
|
||||
|
||||
```bash
|
||||
bun upgrade
|
||||
```
|
||||
|
||||
- ⚙️ 常规的配置 CC 的方式, 各大提供商都有自己的配置方式
|
||||
|
||||
### 📍 命令执行位置
|
||||
|
||||
- 安装或检查 Bun 的命令可以在任意目录执行:
|
||||
`curl -fsSL https://bun.sh/install | bash`、`bun --help`、`bun --version`、`bun upgrade`
|
||||
- 安装本项目依赖、启动开发模式、构建项目时,必须先进入本仓库根目录,也就是包含 `package.json` 的目录。
|
||||
|
||||
### 📥 安装
|
||||
|
||||
```bash
|
||||
cd /path/to/claude-code
|
||||
bun install
|
||||
```
|
||||
|
||||
|
||||
53
README_EN.md
53
README_EN.md
@@ -48,11 +48,64 @@ Sponsor placeholder.
|
||||
Make sure you're on the latest version of Bun, otherwise you'll run into all sorts of weird bugs. Run `bun upgrade`!
|
||||
|
||||
- [Bun](https://bun.sh/) >= 1.3.11
|
||||
|
||||
**Install Bun:**
|
||||
|
||||
```bash
|
||||
# Linux and macOS
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
|
||||
# Windows (PowerShell)
|
||||
powershell -c "irm bun.sh/install.ps1 | iex"
|
||||
```
|
||||
|
||||
**Post-installation steps:**
|
||||
|
||||
1. **Make `bun` available in the current terminal**
|
||||
|
||||
The installer adds `~/.bun/bin` to the matching shell configuration file. On macOS with the default zsh shell, you may see:
|
||||
|
||||
```text
|
||||
Added "~/.bun/bin" to $PATH in "~/.zshrc"
|
||||
```
|
||||
|
||||
Restart the current shell as the installer suggests:
|
||||
|
||||
```bash
|
||||
exec /bin/zsh
|
||||
```
|
||||
|
||||
If you use bash, reload the bash configuration:
|
||||
|
||||
```bash
|
||||
source ~/.bashrc
|
||||
```
|
||||
|
||||
Windows PowerShell users can close and reopen PowerShell.
|
||||
|
||||
2. **Verify that Bun is available:**
|
||||
```bash
|
||||
bun --help
|
||||
bun --version
|
||||
```
|
||||
|
||||
3. **Update to latest version (if already installed):**
|
||||
```bash
|
||||
bun upgrade
|
||||
```
|
||||
|
||||
- Standard Claude Code configuration — each provider has its own setup method
|
||||
|
||||
### Command Execution Location
|
||||
|
||||
- Bun installation and checking commands can be run from any directory:
|
||||
`curl -fsSL https://bun.sh/install | bash`, `bun --help`, `bun --version`, `bun upgrade`
|
||||
- Project dependency installation, development mode, and builds must be run from this repository root, the directory containing `package.json`.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
cd /path/to/claude-code
|
||||
bun install
|
||||
```
|
||||
|
||||
|
||||
426
docs/features/ssh-remote.md
Normal file
426
docs/features/ssh-remote.md
Normal file
@@ -0,0 +1,426 @@
|
||||
# SSH Remote — 远程主机运行 Claude Code
|
||||
|
||||
## 概述
|
||||
|
||||
SSH Remote 提供两种方式在远程 Linux 主机上运行 Claude Code:
|
||||
|
||||
1. **SSH Remote 模块**(`ccb ssh <host>`)— 本地 REPL + 远程工具执行,自动部署二进制 + 认证隧道
|
||||
2. **直接 SSH 运行**(`ssh <host> -t ccb`)— 远程已安装 ccb,直接启动交互式会话
|
||||
|
||||
## 架构
|
||||
|
||||
### 方式一:SSH Remote 模块(完整模式)
|
||||
|
||||
适用场景:远端没有 API 凭据或没有安装 ccb。
|
||||
|
||||
```
|
||||
┌──────────────── 本地 Windows/Mac/Linux ───────────┐
|
||||
│ │
|
||||
│ ccb ssh <host> [dir] │
|
||||
│ │ │
|
||||
│ ├── 1. SSHProbe: 探测远端平台/架构/已有二进制 │
|
||||
│ ├── 2. SSHDeploy: 部署 dist/ 到远端 │
|
||||
│ ├── 3. SSHAuthProxy: 启动本地认证代理 │
|
||||
│ │ ├─ Unix Socket (Linux/Mac) │
|
||||
│ │ └─ TCP 127.0.0.1:<port> (Windows) │
|
||||
│ │ │
|
||||
│ └── 4. SSH -R 反向隧道 + 启动远端 CLI │
|
||||
│ ssh -R <remote>:<local> <host> \ │
|
||||
│ ANTHROPIC_BASE_URL=... \ │
|
||||
│ ANTHROPIC_AUTH_NONCE=... \ │
|
||||
│ ccb --output-format stream-json │
|
||||
│ │
|
||||
│ ┌─────── 本地 REPL (Ink TUI) ───────┐ │
|
||||
│ │ 用户输入 → NDJSON → SSH stdin │ │
|
||||
│ │ SSH stdout → NDJSON → 渲染消息 │ │
|
||||
│ │ 工具权限请求 → 本地审批 → 回传 │ │
|
||||
│ └────────────────────────────────────┘ │
|
||||
└────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ SSH 连接 (加密通道)
|
||||
│
|
||||
┌───────────────── 远端 Linux ──────────────────────┐
|
||||
│ │
|
||||
│ ccb (自动部署或已存在) │
|
||||
│ ├── --output-format stream-json │
|
||||
│ ├── --input-format stream-json │
|
||||
│ ├── --verbose -p │
|
||||
│ │ │
|
||||
│ ├── API 请求 → ANTHROPIC_BASE_URL │
|
||||
│ │ → SSH 反向隧道 → 本地 AuthProxy │
|
||||
│ │ → 注入真实凭据 → api.anthropic.com │
|
||||
│ │ │
|
||||
│ └── 工具执行 (Bash/Read/Write/...) │
|
||||
│ 直接在远端文件系统上操作 │
|
||||
└────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 方式二:直接 SSH 运行(简单模式)
|
||||
|
||||
适用场景:远端已安装 ccb 且已有 API 凭据(订阅或 API Key)。
|
||||
|
||||
```
|
||||
┌─────── 本地终端 ───────┐ ┌──────── 远端 Linux ────────┐
|
||||
│ │ SSH │ │
|
||||
│ ssh <host> -t ccb │ ──────→ │ ccb (全局安装) │
|
||||
│ │ │ ├── 使用远端自身凭据 │
|
||||
│ 终端直接显示远端 TUI │ ←────── │ ├── 远端文件系统操作 │
|
||||
│ │ TTY │ └── API 直连 Anthropic │
|
||||
└─────────────────────────┘ └─────────────────────────────┘
|
||||
```
|
||||
|
||||
### 适用场景对比
|
||||
|
||||
| | SSH Remote 模块 | 直接 SSH 运行 |
|
||||
|---|---|---|
|
||||
| 远端需要安装 ccb | 不需要(自动部署) | 需要 |
|
||||
| 远端需要 API 凭据 | 不需要(本地隧道) | 需要 |
|
||||
| 本地需要安装 ccb | 需要 | 不需要(任何终端) |
|
||||
| 斜杠命令 | 本地处理 | 远端处理 |
|
||||
| 网络延迟敏感 | 高(NDJSON 双向) | 低(仅 TTY) |
|
||||
| 推荐场景 | 远端无凭据/无安装 | 远端已配置完整 |
|
||||
|
||||
---
|
||||
|
||||
## 前置准备:SSH 密钥配置
|
||||
|
||||
两种方式都依赖 SSH 免密连接。以下是完整的密钥配置步骤。
|
||||
|
||||
### 1. 生成 SSH 密钥对(本地)
|
||||
|
||||
```bash
|
||||
# 生成 Ed25519 密钥(推荐)
|
||||
ssh-keygen -t ed25519 -C "your-email@example.com" -f ~/.ssh/id_remote
|
||||
|
||||
# 或 RSA 4096 位
|
||||
ssh-keygen -t rsa -b 4096 -C "your-email@example.com" -f ~/.ssh/id_remote
|
||||
```
|
||||
|
||||
生成两个文件:
|
||||
- `~/.ssh/id_remote` — 私钥(不可泄露)
|
||||
- `~/.ssh/id_remote.pub` — 公钥(部署到远端)
|
||||
|
||||
### 2. 将公钥部署到远端
|
||||
|
||||
```bash
|
||||
# 方式 A:ssh-copy-id(推荐)
|
||||
ssh-copy-id -i ~/.ssh/id_remote.pub user@remote-host
|
||||
|
||||
# 方式 B:手动复制
|
||||
cat ~/.ssh/id_remote.pub | ssh user@remote-host "mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
|
||||
```
|
||||
|
||||
### 3. 配置 SSH Config(本地)
|
||||
|
||||
编辑 `~/.ssh/config`(不存在则创建):
|
||||
|
||||
```
|
||||
Host my-server
|
||||
HostName 192.168.1.100 # 远端 IP 或域名
|
||||
User root # 远端用户名
|
||||
IdentityFile ~/.ssh/id_remote # 私钥路径
|
||||
ServerAliveInterval 60 # 防止连接超时断开
|
||||
ServerAliveCountMax 3
|
||||
```
|
||||
|
||||
配置后可直接用别名连接:
|
||||
|
||||
```bash
|
||||
ssh my-server # 等同于 ssh -i ~/.ssh/id_remote root@192.168.1.100
|
||||
```
|
||||
|
||||
### 4. 文件权限设置
|
||||
|
||||
#### Linux / macOS
|
||||
|
||||
```bash
|
||||
chmod 700 ~/.ssh
|
||||
chmod 600 ~/.ssh/config
|
||||
chmod 600 ~/.ssh/id_remote
|
||||
chmod 644 ~/.ssh/id_remote.pub
|
||||
```
|
||||
|
||||
#### Windows(OpenSSH 强制 ACL 检查)
|
||||
|
||||
```powershell
|
||||
# 重置 .ssh 目录权限:仅允许当前用户 + SYSTEM
|
||||
icacls "$env:USERPROFILE\.ssh" /inheritance:r /grant:r "$($env:USERNAME):(OI)(CI)F" /grant "SYSTEM:(OI)(CI)F"
|
||||
|
||||
# 修复 config 文件权限
|
||||
icacls "$env:USERPROFILE\.ssh\config" /inheritance:r /grant:r "$($env:USERNAME):F" /grant "SYSTEM:F"
|
||||
|
||||
# 修复私钥权限
|
||||
icacls "$env:USERPROFILE\.ssh\id_remote" /inheritance:r /grant:r "$($env:USERNAME):F" /grant "SYSTEM:F"
|
||||
```
|
||||
|
||||
> **Windows 常见错误**:如果 `icacls` 显示 `UNKNOWN\UNKNOWN` ACL 条目,需要先移除再重新授权。权限错误会导致 SSH 拒绝使用密钥。
|
||||
|
||||
### 5. 验证免密连接
|
||||
|
||||
```bash
|
||||
ssh my-server "echo 'SSH connection OK'"
|
||||
# 应直接输出 "SSH connection OK",不要求输入密码
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 使用方式
|
||||
|
||||
### 方式一:SSH Remote 模块
|
||||
|
||||
```bash
|
||||
# 基本用法 — 自动探测、部署、启动
|
||||
ccb ssh user@remote-host
|
||||
|
||||
# 使用 SSH Config 别名
|
||||
ccb ssh my-server
|
||||
|
||||
# 指定远端工作目录
|
||||
ccb ssh my-server /home/user/project
|
||||
|
||||
# 使用自定义远端二进制(跳过探测/部署)
|
||||
ccb ssh my-server --remote-bin "bun /opt/ccb/dist/cli.js"
|
||||
|
||||
# 权限控制
|
||||
ccb ssh my-server --permission-mode auto
|
||||
ccb ssh my-server --dangerously-skip-permissions
|
||||
|
||||
# 恢复远端会话
|
||||
ccb ssh my-server --continue
|
||||
ccb ssh my-server --resume <session-uuid>
|
||||
|
||||
# 选择模型
|
||||
ccb ssh my-server --model claude-sonnet-4-6-20250514
|
||||
|
||||
# 本地测试模式(不连接远端,测试 auth proxy 管道)
|
||||
ccb ssh localhost --local
|
||||
```
|
||||
|
||||
### 方式二:直接 SSH 运行
|
||||
|
||||
```bash
|
||||
# 启动交互式会话
|
||||
ssh my-server -t ccb
|
||||
|
||||
# 指定工作目录
|
||||
ssh my-server -t "ccb --cwd /home/user/project"
|
||||
|
||||
# 使用特定模型
|
||||
ssh my-server -t "ccb --model claude-sonnet-4-6-20250514"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 构建与部署
|
||||
|
||||
### 构建产物
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
bun install
|
||||
|
||||
# 构建(输出到 dist/)
|
||||
bun run build
|
||||
```
|
||||
|
||||
产物说明:
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `dist/cli.js` | Bun 入口(`#!/usr/bin/env bun`) |
|
||||
| `dist/cli-node.js` | Node.js 入口(`#!/usr/bin/env node` → `import ./cli.js`) |
|
||||
| `dist/cli-bun.js` | Bun 专用入口 |
|
||||
| `dist/chunk-*.js` | 代码分割 chunk 文件(约 668 个) |
|
||||
|
||||
### 运行方式
|
||||
|
||||
```bash
|
||||
# 方式 A:通过 bun 直接运行(开发/调试)
|
||||
bun run dev
|
||||
|
||||
# 方式 B:运行构建产物(bun 运行时)
|
||||
bun dist/cli.js
|
||||
|
||||
# 方式 C:运行构建产物(node 运行时)
|
||||
node dist/cli-node.js
|
||||
|
||||
# 方式 D:全局安装后使用命令名
|
||||
ccb
|
||||
```
|
||||
|
||||
### 全局安装
|
||||
|
||||
在项目根目录执行:
|
||||
|
||||
```bash
|
||||
# bun 全局安装(推荐)
|
||||
bun install -g .
|
||||
|
||||
# 创建的命令:
|
||||
# ccb → dist/cli-node.js
|
||||
# ccb-bun → dist/cli-bun.js
|
||||
# claude-code-best → dist/cli-node.js
|
||||
|
||||
# 安装位置:~/.bun/bin/ccb
|
||||
```
|
||||
|
||||
或使用 npm:
|
||||
|
||||
```bash
|
||||
npm install -g .
|
||||
```
|
||||
|
||||
验证:
|
||||
|
||||
```bash
|
||||
ccb --version
|
||||
# → x.x.x (Claude Code)
|
||||
```
|
||||
|
||||
### 远端部署(全流程)
|
||||
|
||||
```bash
|
||||
# 1. 登录远端
|
||||
ssh my-server
|
||||
|
||||
# 2. 克隆或同步项目代码
|
||||
git clone <repo-url> ~/ccb-project
|
||||
cd ~/ccb-project
|
||||
|
||||
# 3. 安装运行时(如果没有 bun)
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
source ~/.bashrc
|
||||
|
||||
# 4. 安装依赖 + 构建
|
||||
bun install
|
||||
bun run build
|
||||
|
||||
# 5. 全局安装
|
||||
bun install -g .
|
||||
|
||||
# 6. 确保非交互式 SSH 可访问 ccb 命令
|
||||
# bun install -g 安装到 ~/.bun/bin/,但非交互式 SSH 不加载 .bashrc,
|
||||
# 所以 PATH 中不包含 ~/.bun/bin/
|
||||
# 解决方式(任选其一):
|
||||
|
||||
# 方式 A:符号链接到系统 PATH(推荐)
|
||||
ln -sf ~/.bun/bin/ccb /usr/local/bin/ccb
|
||||
|
||||
# 方式 B:添加到 /etc/profile.d/(所有用户生效)
|
||||
echo 'export PATH="$HOME/.bun/bin:$PATH"' > /etc/profile.d/bun-path.sh
|
||||
|
||||
# 方式 C:添加到 ~/.bash_profile(当前用户,ssh -t 时生效)
|
||||
echo 'export PATH="$HOME/.bun/bin:$PATH"' >> ~/.bash_profile
|
||||
|
||||
# 7. 验证
|
||||
ccb --version
|
||||
|
||||
# 8. 从本地测试
|
||||
# (在本地终端)
|
||||
ssh my-server -t ccb
|
||||
```
|
||||
|
||||
### SSH Remote 自动部署
|
||||
|
||||
使用 `ccb ssh <host>` 时,模块自动处理:
|
||||
|
||||
1. **SSHProbe** 探测远端 `~/.local/bin/claude` 或 `command -v claude`
|
||||
2. 若二进制不存在或版本不匹配,**SSHDeploy** 通过 `scp` 传输 `dist/` 目录
|
||||
3. 在远端创建 wrapper 脚本(`~/.local/bin/claude`)
|
||||
4. 无需手动安装
|
||||
|
||||
---
|
||||
|
||||
## 模块结构
|
||||
|
||||
```
|
||||
src/ssh/
|
||||
├── createSSHSession.ts — 会话工厂:编排 probe → deploy → proxy → spawn
|
||||
├── SSHSessionManager.ts — 双向 NDJSON 通信管理 + 权限转发 + 重连
|
||||
├── SSHAuthProxy.ts — 本地认证代理(API 凭据隧道)
|
||||
├── SSHProbe.ts — 远端主机探测(平台/架构/已有二进制)
|
||||
├── SSHDeploy.ts — 远端二进制部署(scp + wrapper 脚本)
|
||||
└── __tests__/
|
||||
└── SSHSessionManager.test.ts — 17 个单元测试
|
||||
```
|
||||
|
||||
## 关键技术细节
|
||||
|
||||
### 认证隧道
|
||||
|
||||
- **AuthProxy** 在本地监听(Unix socket 或 TCP),接收远端 CLI 的 API 请求
|
||||
- 通过 SSH `-R` 反向端口转发隧道到远端
|
||||
- AuthProxy 注入本地真实凭据(API key 或 OAuth token),转发到 `api.anthropic.com`
|
||||
- `ANTHROPIC_AUTH_NONCE` header 防止未授权访问(nonce 通过环境变量传递给远端 CLI,远端 CLI 在每个 API 请求中携带此 header)
|
||||
|
||||
### waitForInit vs 存活检查
|
||||
|
||||
- **标准模式**:`waitForInit` 等待远端 CLI 发送 `{type:'system', subtype:'init'}` JSON 消息
|
||||
- **`--remote-bin` 模式**:跳过 `waitForInit`(print+stream-json 模式下 init 只在首次查询后发送),改用 3 秒进程存活检查
|
||||
|
||||
### 重连机制
|
||||
|
||||
- `SSHSessionManager` 检测 SSH 连接断开后自动重连
|
||||
- 重连时在远端 CLI 命令中追加 `--continue` 恢复会话
|
||||
- 指数退避重试(最多 5 次,间隔 1s → 2s → 4s → 8s → 16s)
|
||||
|
||||
## Feature Flag
|
||||
|
||||
SSH Remote 功能受 `SSH_REMOTE` feature flag 控制:
|
||||
|
||||
- **Dev 模式**:默认启用
|
||||
- **Build 模式**:需在 `build.ts` 的 `DEFAULT_BUILD_FEATURES` 中添加 `'SSH_REMOTE'`
|
||||
- **运行时**:`FEATURE_SSH_REMOTE=1` 环境变量
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### `ccb: command not found`(SSH 远程执行时)
|
||||
|
||||
非交互式 SSH 不加载 `.bashrc`,`~/.bun/bin` 不在 PATH 中。
|
||||
|
||||
```bash
|
||||
# 解决:创建符号链接
|
||||
ln -sf ~/.bun/bin/ccb /usr/local/bin/ccb
|
||||
```
|
||||
|
||||
### SSH 密钥被拒绝
|
||||
|
||||
```
|
||||
Permission denied (publickey)
|
||||
```
|
||||
|
||||
1. 确认公钥已添加到远端 `~/.ssh/authorized_keys`
|
||||
2. 确认本地私钥文件权限正确(`chmod 600`)
|
||||
3. 确认 `~/.ssh/config` 中 `IdentityFile` 路径正确
|
||||
4. Windows 用户检查 ACL 权限(见上方 Windows 权限设置)
|
||||
|
||||
### SSH 连接超时
|
||||
|
||||
```
|
||||
ssh: connect to host x.x.x.x port 22: Connection timed out
|
||||
```
|
||||
|
||||
1. 确认远端 SSH 服务正在运行:`systemctl status sshd`
|
||||
2. 确认防火墙允许 22 端口
|
||||
3. 确认 IP 地址/域名正确
|
||||
4. 在 `~/.ssh/config` 中添加 `ConnectTimeout 10`
|
||||
|
||||
### 403 Forbidden(SSH Remote 模块)
|
||||
|
||||
AuthProxy 的 nonce 验证失败。确认:
|
||||
1. 远端 CLI 版本包含 nonce header 注入修复
|
||||
2. `ANTHROPIC_AUTH_NONCE` 环境变量正确传递到远端
|
||||
3. `src/services/api/client.ts` 中 `x-auth-nonce` header 已启用
|
||||
|
||||
### 远端 CLI 启动后立即退出
|
||||
|
||||
```
|
||||
Remote process exited immediately (code 1)
|
||||
```
|
||||
|
||||
1. 确认远端 `bun` / `node` 运行时可用
|
||||
2. 手动在远端执行 `ccb --version` 验证安装
|
||||
3. 检查 `--remote-bin` 路径是否正确
|
||||
4. 查看 stderr 输出获取详细错误信息
|
||||
@@ -286,6 +286,15 @@ export default class App extends PureComponent<Props, State> {
|
||||
// ignore calling setRawMode on an handle stdin it cannot be called
|
||||
if (this.isRawModeSupported()) {
|
||||
this.handleSetRawMode(false)
|
||||
} else {
|
||||
// Even when raw mode was never enabled (e.g. non-TTY stdin on
|
||||
// Windows Node.js), ensure stdin is unref'd so the process can
|
||||
// exit. earlyInput may have called ref() before Ink mounted.
|
||||
try {
|
||||
this.props.stdin.unref()
|
||||
} catch {
|
||||
// stdin may already be destroyed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ describe('anthropicMessagesToOpenAI', () => {
|
||||
])
|
||||
})
|
||||
|
||||
test('strips thinking blocks', () => {
|
||||
test('preserves thinking blocks as reasoning_content', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[
|
||||
makeAssistantMsg([
|
||||
@@ -131,7 +131,7 @@ describe('anthropicMessagesToOpenAI', () => {
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
expect(result).toEqual([{ role: 'assistant', content: 'visible response' }])
|
||||
expect(result).toEqual([{ role: 'assistant', content: 'visible response', reasoning_content: 'internal thoughts...' }] as any)
|
||||
})
|
||||
|
||||
test('handles full conversation with tools', () => {
|
||||
@@ -299,7 +299,7 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
|
||||
expect(assistant.reasoning_content).toBe('Let me reason about this...')
|
||||
})
|
||||
|
||||
test('drops thinking block when enableThinking is false (default)', () => {
|
||||
test('preserves thinking block as reasoning_content even without enableThinking', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[
|
||||
makeAssistantMsg([
|
||||
@@ -311,7 +311,7 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
|
||||
)
|
||||
const assistant = result[0] as any
|
||||
expect(assistant.content).toBe('visible response')
|
||||
expect(assistant.reasoning_content).toBeUndefined()
|
||||
expect(assistant.reasoning_content).toBe('internal thoughts...')
|
||||
})
|
||||
|
||||
test('preserves reasoning_content with tool_calls in same turn', () => {
|
||||
@@ -352,7 +352,7 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
|
||||
expect(assistant.tool_calls[0].function.name).toBe('get_weather')
|
||||
})
|
||||
|
||||
test('strips reasoning_content from previous turns', () => {
|
||||
test('always preserves reasoning_content from all turns', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[
|
||||
// Turn 1: user → assistant (with thinking)
|
||||
@@ -361,7 +361,8 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
|
||||
{ type: 'thinking' as const, thinking: 'Turn 1 reasoning...' },
|
||||
{ type: 'text', text: 'Turn 1 answer' },
|
||||
]),
|
||||
// Turn 2: new user message → previous reasoning should be stripped
|
||||
// Turn 2: new user message → reasoning should still be preserved
|
||||
// (DeepSeek requires reasoning_content to be passed back when tool calls are involved)
|
||||
makeUserMsg('question 2'),
|
||||
makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'Turn 2 reasoning...' },
|
||||
@@ -373,10 +374,9 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
|
||||
)
|
||||
|
||||
const assistants = result.filter(m => m.role === 'assistant')
|
||||
// Turn 1 assistant: reasoning should be stripped (previous turn)
|
||||
expect((assistants[0] as any).reasoning_content).toBeUndefined()
|
||||
// Both turns preserve reasoning_content (DeepSeek API requires it for tool calls)
|
||||
expect((assistants[0] as any).reasoning_content).toBe('Turn 1 reasoning...')
|
||||
expect((assistants[0] as any).content).toBe('Turn 1 answer')
|
||||
// Turn 2 assistant: reasoning should be preserved (current turn)
|
||||
expect((assistants[1] as any).reasoning_content).toBe('Turn 2 reasoning...')
|
||||
expect((assistants[1] as any).content).toBe('Turn 2 answer')
|
||||
})
|
||||
|
||||
@@ -26,16 +26,16 @@ export interface ConvertMessagesOptions {
|
||||
* - system prompt → role: "system" message prepended
|
||||
* - tool_use blocks → tool_calls[] on assistant message
|
||||
* - tool_result blocks → role: "tool" messages
|
||||
* - thinking blocks → silently dropped (or preserved as reasoning_content when enableThinking=true)
|
||||
* - thinking blocks → preserved as reasoning_content (DeepSeek requires passing it back)
|
||||
* - cache_control → stripped
|
||||
*/
|
||||
export function anthropicMessagesToOpenAI(
|
||||
messages: (UserMessage | AssistantMessage)[],
|
||||
systemPrompt: SystemPrompt,
|
||||
options?: ConvertMessagesOptions,
|
||||
// options retained for API compatibility; thinking blocks are now always preserved
|
||||
_options?: ConvertMessagesOptions,
|
||||
): ChatCompletionMessageParam[] {
|
||||
const result: ChatCompletionMessageParam[] = []
|
||||
const enableThinking = options?.enableThinking ?? false
|
||||
|
||||
// Prepend system prompt as system message
|
||||
const systemText = systemPromptToText(systemPrompt)
|
||||
@@ -46,53 +46,13 @@ export function anthropicMessagesToOpenAI(
|
||||
} satisfies ChatCompletionSystemMessageParam)
|
||||
}
|
||||
|
||||
// When thinking mode is on, detect turn boundaries so that reasoning_content
|
||||
// from *previous* user turns is stripped (saves bandwidth; DeepSeek ignores it).
|
||||
// A "new turn" starts when a user text message appears after at least one assistant response.
|
||||
const turnBoundaries = new Set<number>()
|
||||
if (enableThinking) {
|
||||
let hasSeenAssistant = false
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i]
|
||||
if (msg.type === 'assistant') {
|
||||
hasSeenAssistant = true
|
||||
}
|
||||
if (msg.type === 'user' && hasSeenAssistant) {
|
||||
const content = msg.message.content
|
||||
// A user message starts a new turn if it contains any non-tool_result content
|
||||
// (text, image, or other media). Tool results alone do NOT start a new turn
|
||||
// because they are continuations of the previous assistant tool call.
|
||||
const startsNewUserTurn =
|
||||
typeof content === 'string'
|
||||
? content.length > 0
|
||||
: Array.isArray(content) &&
|
||||
content.some(
|
||||
(b: any) =>
|
||||
typeof b === 'string' ||
|
||||
(b &&
|
||||
typeof b === 'object' &&
|
||||
'type' in b &&
|
||||
b.type !== 'tool_result'),
|
||||
)
|
||||
if (startsNewUserTurn) {
|
||||
turnBoundaries.add(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i]
|
||||
for (const msg of messages) {
|
||||
switch (msg.type) {
|
||||
case 'user':
|
||||
result.push(...convertInternalUserMessage(msg))
|
||||
break
|
||||
case 'assistant':
|
||||
// Preserve reasoning_content unless we're before a turn boundary
|
||||
// (i.e., from a previous user Q&A round)
|
||||
const preserveReasoning =
|
||||
enableThinking && !isBeforeAnyTurnBoundary(i, turnBoundaries)
|
||||
result.push(...convertInternalAssistantMessage(msg, preserveReasoning))
|
||||
result.push(...convertInternalAssistantMessage(msg))
|
||||
break
|
||||
default:
|
||||
break
|
||||
@@ -107,17 +67,6 @@ function systemPromptToText(systemPrompt: SystemPrompt): string {
|
||||
return systemPrompt.filter(Boolean).join('\n\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if index `i` falls before any turn boundary (i.e. it belongs to a previous turn).
|
||||
* A message at index i is "before" a boundary if there exists a boundary j where i < j.
|
||||
*/
|
||||
function isBeforeAnyTurnBoundary(i: number, boundaries: Set<number>): boolean {
|
||||
for (const b of boundaries) {
|
||||
if (i < b) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function convertInternalUserMessage(
|
||||
msg: UserMessage,
|
||||
): ChatCompletionMessageParam[] {
|
||||
@@ -213,7 +162,6 @@ function convertToolResult(
|
||||
|
||||
function convertInternalAssistantMessage(
|
||||
msg: AssistantMessage,
|
||||
preserveReasoning = false,
|
||||
): ChatCompletionMessageParam[] {
|
||||
const content = msg.message.content
|
||||
|
||||
@@ -257,8 +205,10 @@ function convertInternalAssistantMessage(
|
||||
typeof tu.input === 'string' ? tu.input : JSON.stringify(tu.input),
|
||||
},
|
||||
})
|
||||
} else if (block.type === 'thinking' && preserveReasoning) {
|
||||
// DeepSeek thinking mode: preserve reasoning_content for tool call iterations
|
||||
} else if (block.type === 'thinking') {
|
||||
// DeepSeek thinking mode: always preserve reasoning_content.
|
||||
// DeepSeek requires reasoning_content to be passed back in subsequent requests,
|
||||
// especially when tool calls are involved (returns 400 if missing).
|
||||
const thinkingText = (block as unknown as Record<string, unknown>)
|
||||
.thinking
|
||||
if (typeof thinkingText === 'string' && thinkingText) {
|
||||
|
||||
@@ -394,6 +394,7 @@ export const getAgentDefinitionsWithOverrides = memoize(
|
||||
|
||||
export function clearAgentDefinitionsCache(): void {
|
||||
getAgentDefinitionsWithOverrides.cache.clear?.()
|
||||
loadMarkdownFilesForSubdir.cache?.clear?.()
|
||||
clearPluginAgentCache()
|
||||
}
|
||||
|
||||
|
||||
@@ -273,18 +273,6 @@ export const FileEditTool = buildTool({
|
||||
}
|
||||
|
||||
const readTimestamp = toolUseContext.readFileState.get(fullFilePath)
|
||||
if (!readTimestamp || readTimestamp.isPartialView) {
|
||||
return {
|
||||
result: false,
|
||||
behavior: 'ask',
|
||||
message:
|
||||
'File has not been read yet. Read it first before writing to it.',
|
||||
meta: {
|
||||
isFilePathAbsolute: String(isAbsolute(file_path)),
|
||||
},
|
||||
errorCode: 6,
|
||||
}
|
||||
}
|
||||
|
||||
// Check if file exists and get its last modified time
|
||||
if (readTimestamp) {
|
||||
|
||||
@@ -186,14 +186,6 @@ export function renderToolUseErrorMessage(
|
||||
extractTag(result, 'tool_use_error')
|
||||
) {
|
||||
const errorMessage = extractTag(result, 'tool_use_error')
|
||||
// Show a less scary message for intended behavior
|
||||
if (errorMessage?.includes('File has not been read yet')) {
|
||||
return (
|
||||
<MessageResponse>
|
||||
<Text dimColor>File must be read first</Text>
|
||||
</MessageResponse>
|
||||
)
|
||||
}
|
||||
if (errorMessage?.includes(FILE_NOT_FOUND_CWD_NOTE)) {
|
||||
return (
|
||||
<MessageResponse>
|
||||
|
||||
@@ -196,18 +196,10 @@ export const FileWriteTool = buildTool({
|
||||
}
|
||||
|
||||
const readTimestamp = toolUseContext.readFileState.get(fullFilePath)
|
||||
if (!readTimestamp || readTimestamp.isPartialView) {
|
||||
return {
|
||||
result: false,
|
||||
message:
|
||||
'File has not been read yet. Read it first before writing to it.',
|
||||
errorCode: 2,
|
||||
}
|
||||
}
|
||||
|
||||
// Reuse mtime from the stat above — avoids a redundant statSync via
|
||||
// getFileModificationTime. The readTimestamp guard above ensures this
|
||||
// block is always reached when the file exists.
|
||||
// getFileModificationTime.
|
||||
if (readTimestamp) {
|
||||
const lastWriteTime = Math.floor(fileMtimeMs)
|
||||
if (lastWriteTime > readTimestamp.timestamp) {
|
||||
return {
|
||||
@@ -217,6 +209,7 @@ export const FileWriteTool = buildTool({
|
||||
errorCode: 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { result: true }
|
||||
},
|
||||
|
||||
@@ -66,10 +66,13 @@ export const DEFAULT_BUILD_FEATURES = [
|
||||
'COMMIT_ATTRIBUTION', // Git 提交归属追踪(记录 AI 辅助贡献)
|
||||
// Server mode (claude server / claude open)
|
||||
'DIRECT_CONNECT', // 直连模式(claude server / claude open)
|
||||
// Skill search
|
||||
// Skill search & learning
|
||||
'EXPERIMENTAL_SKILL_SEARCH', // 实验性技能搜索(DiscoverSkills)
|
||||
'SKILL_LEARNING', // 技能学习系统,从对话中自动生成/演化技能
|
||||
// P3: poor mode
|
||||
'POOR', // 穷鬼模式,跳过 extract_memories/prompt_suggestion 减少消耗
|
||||
// Team Memory
|
||||
'TEAMMEM', // 团队记忆,代理队友间共享记忆文件
|
||||
// SSH Remote
|
||||
'SSH_REMOTE', // SSH 远程连接,本地 REPL + 远端工具执行
|
||||
]as const;
|
||||
|
||||
@@ -235,11 +235,6 @@ type State = {
|
||||
// microcompact is first enabled, keep sending the header so mid-session
|
||||
// GrowthBook/settings toggles don't bust the prompt cache.
|
||||
cacheEditingHeaderLatched: boolean | null
|
||||
// Sticky-on latch for clearing thinking from prior tool loops. Triggered
|
||||
// when >1h since last API call (confirmed cache miss — no cache-hit
|
||||
// benefit to keeping thinking). Once latched, stays on so the newly-warmed
|
||||
// thinking-cleared cache isn't busted by flipping back to keep:'all'.
|
||||
thinkingClearLatched: boolean | null
|
||||
// Current prompt ID (UUID) correlating a user prompt with subsequent OTel events
|
||||
promptId: string | null
|
||||
// Last API requestId for the main conversation chain (not subagents).
|
||||
@@ -414,7 +409,6 @@ function getInitialState(): State {
|
||||
afkModeHeaderLatched: null,
|
||||
fastModeHeaderLatched: null,
|
||||
cacheEditingHeaderLatched: null,
|
||||
thinkingClearLatched: null,
|
||||
// Current prompt ID
|
||||
promptId: null,
|
||||
lastMainRequestId: undefined,
|
||||
@@ -1729,14 +1723,6 @@ export function setCacheEditingHeaderLatched(v: boolean): void {
|
||||
STATE.cacheEditingHeaderLatched = v
|
||||
}
|
||||
|
||||
export function getThinkingClearLatched(): boolean | null {
|
||||
return STATE.thinkingClearLatched
|
||||
}
|
||||
|
||||
export function setThinkingClearLatched(v: boolean): void {
|
||||
STATE.thinkingClearLatched = v
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset beta header latches to null. Called on /clear and /compact so a
|
||||
* fresh conversation gets fresh header evaluation.
|
||||
@@ -1745,7 +1731,6 @@ export function clearBetaHeaderLatches(): void {
|
||||
STATE.afkModeHeaderLatched = null
|
||||
STATE.fastModeHeaderLatched = null
|
||||
STATE.cacheEditingHeaderLatched = null
|
||||
STATE.thinkingClearLatched = null
|
||||
}
|
||||
|
||||
export function getPromptId(): string | null {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useSetAppState } from 'src/state/AppState.js'
|
||||
import type { Tools } from '../../../../Tool.js'
|
||||
import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'
|
||||
import { getActiveAgentsFromList } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'
|
||||
import { clearAgentDefinitionsCache } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'
|
||||
import { editFileInEditor } from '../../../../utils/promptEditor.js'
|
||||
import { useWizard } from '../../../wizard/index.js'
|
||||
import { getNewAgentFilePath, saveAgentToFile } from '../../agentFileUtils.js'
|
||||
@@ -62,6 +63,8 @@ export function ConfirmStepWrapper({
|
||||
}
|
||||
})
|
||||
|
||||
clearAgentDefinitionsCache()
|
||||
|
||||
if (openInEditor) {
|
||||
const filePath = getNewAgentFilePath({
|
||||
source: wizardData.location!,
|
||||
|
||||
@@ -614,17 +614,6 @@ ${CYBER_RISK_INSTRUCTION}`,
|
||||
'summarize_tool_results',
|
||||
() => SUMMARIZE_TOOL_RESULTS_SECTION,
|
||||
),
|
||||
// Numeric length anchors — research shows ~1.2% output token reduction vs
|
||||
// qualitative "be concise". Ant-only to measure quality impact first.
|
||||
...(process.env.USER_TYPE === 'ant'
|
||||
? [
|
||||
systemPromptSection(
|
||||
'numeric_length_anchors',
|
||||
() =>
|
||||
'Length limits: keep text between tool calls to \u226425 words. Keep final responses to \u2264100 words unless the task requires more detail.',
|
||||
),
|
||||
]
|
||||
: []),
|
||||
...(feature('TOKEN_BUDGET')
|
||||
? [
|
||||
// Cached unconditionally — the "When the user specifies..." phrasing
|
||||
|
||||
19
src/main.tsx
19
src/main.tsx
@@ -869,6 +869,7 @@ type PendingSSH = {
|
||||
local: boolean;
|
||||
/** Extra CLI args to forward to the remote CLI on initial spawn (--resume, -c). */
|
||||
extraCliArgs: string[];
|
||||
remoteBin: string | undefined;
|
||||
};
|
||||
const _pendingSSH: PendingSSH | undefined = feature("SSH_REMOTE")
|
||||
? {
|
||||
@@ -878,6 +879,7 @@ const _pendingSSH: PendingSSH | undefined = feature("SSH_REMOTE")
|
||||
dangerouslySkipPermissions: false,
|
||||
local: false,
|
||||
extraCliArgs: [],
|
||||
remoteBin: undefined,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
@@ -1084,6 +1086,17 @@ export async function main() {
|
||||
rawCliArgs.splice(eqI, 1);
|
||||
}
|
||||
};
|
||||
const rbIdx = rawCliArgs.indexOf('--remote-bin');
|
||||
if (rbIdx !== -1 && rawCliArgs[rbIdx + 1] && !rawCliArgs[rbIdx + 1]!.startsWith('-')) {
|
||||
_pendingSSH.remoteBin = rawCliArgs[rbIdx + 1];
|
||||
rawCliArgs.splice(rbIdx, 2);
|
||||
}
|
||||
const rbEqIdx = rawCliArgs.findIndex(a => a.startsWith('--remote-bin='));
|
||||
if (rbEqIdx !== -1) {
|
||||
_pendingSSH.remoteBin = rawCliArgs[rbEqIdx]!.split('=').slice(1).join('=');
|
||||
rawCliArgs.splice(rbEqIdx, 1);
|
||||
}
|
||||
|
||||
extractFlag("-c", { as: "--continue" });
|
||||
extractFlag("--continue");
|
||||
extractFlag("--resume", { hasValue: true });
|
||||
@@ -4643,6 +4656,7 @@ async function run(): Promise<CommanderCommand> {
|
||||
dangerouslySkipPermissions:
|
||||
_pendingSSH.dangerouslySkipPermissions,
|
||||
extraCliArgs: _pendingSSH.extraCliArgs,
|
||||
remoteBin: _pendingSSH.remoteBin,
|
||||
},
|
||||
isTTY
|
||||
? {
|
||||
@@ -5980,6 +5994,11 @@ async function run(): Promise<CommanderCommand> {
|
||||
"--dangerously-skip-permissions",
|
||||
"Skip all permission prompts on the remote (dangerous)",
|
||||
)
|
||||
.option(
|
||||
"--remote-bin <command>",
|
||||
"Custom remote binary command (skips probe/deploy). " +
|
||||
"Example: --remote-bin 'bun /path/to/project/dist/cli.js'",
|
||||
)
|
||||
.option(
|
||||
"--local",
|
||||
"e2e test mode — spawn the child CLI locally (skip ssh/deploy). " +
|
||||
|
||||
@@ -124,14 +124,12 @@ import {
|
||||
getPromptCache1hAllowlist,
|
||||
getPromptCache1hEligible,
|
||||
getSessionId,
|
||||
getThinkingClearLatched,
|
||||
setAfkModeHeaderLatched,
|
||||
setCacheEditingHeaderLatched,
|
||||
setFastModeHeaderLatched,
|
||||
setLastMainRequestId,
|
||||
setPromptCache1hAllowlist,
|
||||
setPromptCache1hEligible,
|
||||
setThinkingClearLatched,
|
||||
} from 'src/bootstrap/state.js'
|
||||
import {
|
||||
AFK_MODE_BETA_HEADER,
|
||||
@@ -1492,20 +1490,6 @@ async function* queryModel(
|
||||
}
|
||||
}
|
||||
|
||||
// Only latch from agentic queries so a classifier call doesn't flip the
|
||||
// main thread's context_management mid-turn.
|
||||
let thinkingClearLatched = getThinkingClearLatched() === true
|
||||
if (!thinkingClearLatched && isAgenticQuery) {
|
||||
const lastCompletion = getLastApiCompletionTimestamp()
|
||||
if (
|
||||
lastCompletion !== null &&
|
||||
Date.now() - lastCompletion > CACHE_TTL_1HOUR_MS
|
||||
) {
|
||||
thinkingClearLatched = true
|
||||
setThinkingClearLatched(true)
|
||||
}
|
||||
}
|
||||
|
||||
const effort = resolveAppliedEffort(options.model, options.effortValue)
|
||||
|
||||
if (feature('PROMPT_CACHE_BREAK_DETECTION')) {
|
||||
@@ -1684,7 +1668,7 @@ async function* queryModel(
|
||||
const contextManagement = getAPIContextManagement({
|
||||
hasThinking,
|
||||
isRedactThinkingActive: betasParams.includes(REDACT_THINKING_BETA_HEADER),
|
||||
clearAllThinking: thinkingClearLatched,
|
||||
clearAllThinking: false,
|
||||
})
|
||||
|
||||
const enablePromptCaching =
|
||||
|
||||
@@ -109,6 +109,10 @@ export async function getAnthropicClient({
|
||||
: {}),
|
||||
// SDK consumers can identify their app/library for backend analytics
|
||||
...(clientApp ? { 'x-client-app': clientApp } : {}),
|
||||
// SSH auth proxy nonce — tunneled API requests must carry this header
|
||||
...(process.env.ANTHROPIC_AUTH_NONCE
|
||||
? { 'x-auth-nonce': process.env.ANTHROPIC_AUTH_NONCE }
|
||||
: {}),
|
||||
}
|
||||
|
||||
// Log API client configuration for HFI debugging
|
||||
|
||||
@@ -100,16 +100,28 @@ describe('isOpenAIThinkingEnabled', () => {
|
||||
expect(isOpenAIThinkingEnabled('TokenService/deepseek-v3.2')).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false when model name is "deepseek-chat"', () => {
|
||||
expect(isOpenAIThinkingEnabled('deepseek-chat')).toBe(false)
|
||||
test('returns true when model name is "deepseek-chat"', () => {
|
||||
expect(isOpenAIThinkingEnabled('deepseek-chat')).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false when model name is "deepseek-v3"', () => {
|
||||
expect(isOpenAIThinkingEnabled('deepseek-v3')).toBe(false)
|
||||
test('returns true when model name is "deepseek-v3"', () => {
|
||||
expect(isOpenAIThinkingEnabled('deepseek-v3')).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false when model name contains "deepseek" but not "reasoner" or "v3.2"', () => {
|
||||
expect(isOpenAIThinkingEnabled('deepseek-coder')).toBe(false)
|
||||
test('returns true when model name is "deepseek-v4"', () => {
|
||||
expect(isOpenAIThinkingEnabled('deepseek-v4')).toBe(true)
|
||||
})
|
||||
|
||||
test('returns true when model name is "deepseek-v4-pro"', () => {
|
||||
expect(isOpenAIThinkingEnabled('deepseek-v4-pro')).toBe(true)
|
||||
})
|
||||
|
||||
test('returns true when model name is "deepseek-r1"', () => {
|
||||
expect(isOpenAIThinkingEnabled('deepseek-r1')).toBe(true)
|
||||
})
|
||||
|
||||
test('returns true when model name contains "deepseek"', () => {
|
||||
expect(isOpenAIThinkingEnabled('deepseek-coder')).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false when model name is "gpt-4o"', () => {
|
||||
@@ -126,6 +138,7 @@ describe('isOpenAIThinkingEnabled', () => {
|
||||
process.env.OPENAI_ENABLE_THINKING = '1'
|
||||
expect(isOpenAIThinkingEnabled('gpt-4o')).toBe(true)
|
||||
expect(isOpenAIThinkingEnabled('deepseek-v3')).toBe(true)
|
||||
expect(isOpenAIThinkingEnabled('qwen-3')).toBe(true)
|
||||
})
|
||||
|
||||
test('OPENAI_ENABLE_THINKING=false disables thinking even for deepseek-reasoner', () => {
|
||||
|
||||
@@ -25,9 +25,9 @@ export function isOpenAIThinkingEnabled(model: string): boolean {
|
||||
if (isEnvDefinedFalsy(process.env.OPENAI_ENABLE_THINKING)) return false
|
||||
// Explicit enable
|
||||
if (isEnvTruthy(process.env.OPENAI_ENABLE_THINKING)) return true
|
||||
// Auto-detect from model name (deepseek-reasoner and DeepSeek-V3.2 support thinking mode)
|
||||
// Auto-detect from model name (all DeepSeek models support thinking mode)
|
||||
const modelLower = model.toLowerCase()
|
||||
return modelLower.includes('deepseek-reasoner') || modelLower.includes('deepseek-v3.2')
|
||||
return modelLower.includes('deepseek')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,14 +6,12 @@ export type getFastModeHeaderLatched = any;
|
||||
export type getLastApiCompletionTimestamp = any;
|
||||
export type getPromptCache1hAllowlist = any;
|
||||
export type getPromptCache1hEligible = any;
|
||||
export type getThinkingClearLatched = any;
|
||||
export type setAfkModeHeaderLatched = any;
|
||||
export type setCacheEditingHeaderLatched = any;
|
||||
export type setFastModeHeaderLatched = any;
|
||||
export type setLastMainRequestId = any;
|
||||
export type setPromptCache1hAllowlist = any;
|
||||
export type setPromptCache1hEligible = any;
|
||||
export type setThinkingClearLatched = any;
|
||||
export type addToTotalDurationState = any;
|
||||
export type consumePostCompaction = any;
|
||||
export type getIsNonInteractiveSession = any;
|
||||
|
||||
165
src/ssh/SSHAuthProxy.ts
Normal file
165
src/ssh/SSHAuthProxy.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { unlinkSync } from 'fs'
|
||||
import { getClaudeAIOAuthTokens } from 'src/utils/auth.js'
|
||||
import { getOauthConfig } from 'src/constants/oauth.js'
|
||||
import { logForDebugging } from 'src/utils/debug.js'
|
||||
|
||||
export interface SSHAuthProxy {
|
||||
stop(): void
|
||||
}
|
||||
|
||||
export interface AuthProxyInfo {
|
||||
proxy: SSHAuthProxy
|
||||
/** Unix socket path or 127.0.0.1:<port> */
|
||||
localAddress: string
|
||||
/** Environment variables to inject into the remote/child CLI process */
|
||||
authEnv: Record<string, string>
|
||||
}
|
||||
|
||||
const isWindows = process.platform === 'win32'
|
||||
|
||||
function resolveAuthHeaders(): Record<string, string> {
|
||||
const apiKey = process.env.ANTHROPIC_API_KEY
|
||||
if (apiKey) {
|
||||
return { 'x-api-key': apiKey }
|
||||
}
|
||||
|
||||
const oauthTokens = getClaudeAIOAuthTokens()
|
||||
if (oauthTokens?.accessToken) {
|
||||
return { Authorization: `Bearer ${oauthTokens.accessToken}` }
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
function resolveUpstreamBaseUrl(): string {
|
||||
return process.env.ANTHROPIC_BASE_URL || getOauthConfig().BASE_API_URL
|
||||
}
|
||||
|
||||
async function proxyFetch(
|
||||
req: Request,
|
||||
nonce: string | null,
|
||||
): Promise<Response> {
|
||||
if (nonce && req.headers.get('x-auth-nonce') !== nonce) {
|
||||
return new Response('Forbidden', { status: 403 })
|
||||
}
|
||||
|
||||
const upstreamBase = resolveUpstreamBaseUrl()
|
||||
const url = new URL(req.url)
|
||||
const upstreamUrl = `${upstreamBase}${url.pathname}${url.search}`
|
||||
|
||||
const authHeaders = resolveAuthHeaders()
|
||||
if (Object.keys(authHeaders).length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'No API credentials available on local machine',
|
||||
}),
|
||||
{ status: 401, headers: { 'content-type': 'application/json' } },
|
||||
)
|
||||
}
|
||||
|
||||
const forwardHeaders = new Headers(req.headers)
|
||||
for (const [k, v] of Object.entries(authHeaders)) {
|
||||
forwardHeaders.set(k, v)
|
||||
}
|
||||
forwardHeaders.delete('host')
|
||||
forwardHeaders.delete('x-auth-nonce')
|
||||
|
||||
logForDebugging(
|
||||
`[SSHAuthProxy] ${req.method} ${url.pathname} -> ${upstreamUrl}`,
|
||||
)
|
||||
|
||||
try {
|
||||
const upstreamRes = await fetch(upstreamUrl, {
|
||||
method: req.method,
|
||||
headers: forwardHeaders,
|
||||
body: req.body,
|
||||
// @ts-expect-error Bun supports duplex for streaming request bodies
|
||||
duplex: 'half',
|
||||
})
|
||||
|
||||
const responseHeaders = new Headers(upstreamRes.headers)
|
||||
responseHeaders.delete('content-encoding')
|
||||
responseHeaders.delete('content-length')
|
||||
|
||||
return new Response(upstreamRes.body, {
|
||||
status: upstreamRes.status,
|
||||
statusText: upstreamRes.statusText,
|
||||
headers: responseHeaders,
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logForDebugging(`[SSHAuthProxy] upstream error: ${message}`)
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Proxy upstream error: ${message}` }),
|
||||
{ status: 502, headers: { 'content-type': 'application/json' } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAuthProxy(): Promise<AuthProxyInfo> {
|
||||
const id = randomUUID()
|
||||
|
||||
if (isWindows) {
|
||||
return createTcpAuthProxy(id)
|
||||
}
|
||||
return createUnixSocketAuthProxy(id)
|
||||
}
|
||||
|
||||
async function createUnixSocketAuthProxy(id: string): Promise<AuthProxyInfo> {
|
||||
const socketPath = `/tmp/claude-ssh-auth-${id}.sock`
|
||||
|
||||
const server = Bun.serve({
|
||||
unix: socketPath,
|
||||
fetch: req => proxyFetch(req, null),
|
||||
})
|
||||
|
||||
logForDebugging(`[SSHAuthProxy] listening on unix:${socketPath}`)
|
||||
|
||||
const proxy: SSHAuthProxy = {
|
||||
stop() {
|
||||
server.stop(true)
|
||||
try {
|
||||
unlinkSync(socketPath)
|
||||
} catch {
|
||||
// Socket file may already be cleaned up
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
proxy,
|
||||
localAddress: socketPath,
|
||||
authEnv: { ANTHROPIC_AUTH_SOCKET: socketPath },
|
||||
}
|
||||
}
|
||||
|
||||
async function createTcpAuthProxy(id: string): Promise<AuthProxyInfo> {
|
||||
const nonce = randomUUID()
|
||||
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
fetch: req => proxyFetch(req, nonce),
|
||||
})
|
||||
|
||||
const port = server.port
|
||||
logForDebugging(
|
||||
`[SSHAuthProxy] listening on TCP 127.0.0.1:${port} (nonce-protected)`,
|
||||
)
|
||||
|
||||
const proxy: SSHAuthProxy = {
|
||||
stop() {
|
||||
server.stop(true)
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
proxy,
|
||||
localAddress: `127.0.0.1:${port}`,
|
||||
authEnv: {
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`,
|
||||
ANTHROPIC_AUTH_NONCE: nonce,
|
||||
},
|
||||
}
|
||||
}
|
||||
123
src/ssh/SSHDeploy.ts
Normal file
123
src/ssh/SSHDeploy.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { existsSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
import { logForDebugging } from 'src/utils/debug.js'
|
||||
|
||||
const SSH_TIMEOUT_MS = 60_000
|
||||
const REMOTE_BIN_DIR = '~/.local/bin'
|
||||
const REMOTE_CLI_FILE = 'claude-code-cli.js'
|
||||
const REMOTE_WRAPPER = 'claude'
|
||||
|
||||
export interface DeployOptions {
|
||||
host: string
|
||||
remotePlatform: string
|
||||
remoteArch: string
|
||||
localVersion: string
|
||||
onProgress?: (msg: string) => void
|
||||
}
|
||||
|
||||
async function runSshCommand(
|
||||
host: string,
|
||||
command: string,
|
||||
timeoutMs = SSH_TIMEOUT_MS,
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
const proc = Bun.spawn(['ssh', '-o', 'ConnectTimeout=10', host, command], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
|
||||
const timer = setTimeout(() => proc.kill(), timeoutMs)
|
||||
|
||||
try {
|
||||
const [stdout, stderr] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
])
|
||||
const exitCode = await proc.exited
|
||||
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
function findLocalBinary(): string {
|
||||
const projectRoot = resolve(import.meta.dir, '../..')
|
||||
const distPath = resolve(projectRoot, 'dist/cli.js')
|
||||
if (existsSync(distPath)) return distPath
|
||||
|
||||
const devPath = resolve(projectRoot, 'src/entrypoints/cli.tsx')
|
||||
if (existsSync(devPath)) return devPath
|
||||
|
||||
throw new Error(
|
||||
'Cannot find local CLI binary to deploy. Run `bun run build` first.',
|
||||
)
|
||||
}
|
||||
|
||||
export async function deployBinary(options: DeployOptions): Promise<string> {
|
||||
const { host, remotePlatform, remoteArch, localVersion, onProgress } = options
|
||||
|
||||
if (remotePlatform !== 'linux' && remotePlatform !== 'darwin') {
|
||||
throw new Error(
|
||||
`Remote platform "${remotePlatform}" is not supported. Only linux and darwin are supported.`,
|
||||
)
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`[SSHDeploy] deploying to ${host} (${remotePlatform}/${remoteArch}, v${localVersion})`,
|
||||
)
|
||||
|
||||
const localBinary = findLocalBinary()
|
||||
logForDebugging(`[SSHDeploy] local binary: ${localBinary}`)
|
||||
|
||||
onProgress?.('Creating remote directory...')
|
||||
const mkdirResult = await runSshCommand(host, `mkdir -p ${REMOTE_BIN_DIR}`)
|
||||
if (mkdirResult.exitCode !== 0) {
|
||||
throw new Error(`Failed to create remote directory: ${mkdirResult.stderr}`)
|
||||
}
|
||||
|
||||
onProgress?.('Uploading binary...')
|
||||
const remotePath = `${REMOTE_BIN_DIR}/${REMOTE_CLI_FILE}`
|
||||
const scpProc = Bun.spawn(
|
||||
['scp', '-o', 'ConnectTimeout=10', localBinary, `${host}:${remotePath}`],
|
||||
{ stdout: 'pipe', stderr: 'pipe' },
|
||||
)
|
||||
const scpTimer = setTimeout(() => scpProc.kill(), SSH_TIMEOUT_MS)
|
||||
const scpStderr = await new Response(scpProc.stderr).text()
|
||||
const scpExit = await scpProc.exited
|
||||
clearTimeout(scpTimer)
|
||||
|
||||
if (scpExit !== 0) {
|
||||
throw new Error(`SCP upload failed (exit ${scpExit}): ${scpStderr.trim()}`)
|
||||
}
|
||||
|
||||
onProgress?.('Installing wrapper script...')
|
||||
const wrapperScript = [
|
||||
`cat > ${REMOTE_BIN_DIR}/${REMOTE_WRAPPER} << 'WRAPPER'`,
|
||||
'#!/bin/sh',
|
||||
`exec bun ${REMOTE_BIN_DIR}/${REMOTE_CLI_FILE} "$@"`,
|
||||
'WRAPPER',
|
||||
`chmod +x ${REMOTE_BIN_DIR}/${REMOTE_WRAPPER}`,
|
||||
].join('\n')
|
||||
|
||||
const wrapperResult = await runSshCommand(host, wrapperScript)
|
||||
if (wrapperResult.exitCode !== 0) {
|
||||
throw new Error(`Failed to install wrapper script: ${wrapperResult.stderr}`)
|
||||
}
|
||||
|
||||
onProgress?.('Verifying installation...')
|
||||
const verifyResult = await runSshCommand(
|
||||
host,
|
||||
`${REMOTE_BIN_DIR}/${REMOTE_WRAPPER} --version`,
|
||||
)
|
||||
if (verifyResult.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`Binary deployed but verification failed (exit ${verifyResult.exitCode}): ${verifyResult.stderr}`,
|
||||
)
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`[SSHDeploy] deployed successfully, remote version: ${verifyResult.stdout}`,
|
||||
)
|
||||
onProgress?.(`Deployed v${verifyResult.stdout}`)
|
||||
|
||||
return `${REMOTE_BIN_DIR}/${REMOTE_WRAPPER}`
|
||||
}
|
||||
99
src/ssh/SSHProbe.ts
Normal file
99
src/ssh/SSHProbe.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { logForDebugging } from 'src/utils/debug.js'
|
||||
|
||||
const PROBE_TIMEOUT_MS = 15_000
|
||||
|
||||
export interface ProbeResult {
|
||||
hasBinary: boolean
|
||||
remoteVersion: string | null
|
||||
remotePlatform: 'linux' | 'darwin'
|
||||
remoteArch: 'x64' | 'arm64'
|
||||
defaultCwd: string
|
||||
binaryPath: string | null
|
||||
}
|
||||
|
||||
export class SSHProbeError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'SSHProbeError'
|
||||
}
|
||||
}
|
||||
|
||||
export async function probeRemote(
|
||||
host: string,
|
||||
onProgress?: (msg: string) => void,
|
||||
): Promise<ProbeResult> {
|
||||
onProgress?.('Probing remote host…')
|
||||
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
'ssh',
|
||||
'-o',
|
||||
'BatchMode=yes',
|
||||
'-o',
|
||||
'ConnectTimeout=10',
|
||||
host,
|
||||
'CLAUDE_BIN=$(test -x "$HOME/.local/bin/claude" && echo "$HOME/.local/bin/claude" || command -v claude 2>/dev/null); echo "$CLAUDE_BIN"; $CLAUDE_BIN --version 2>/dev/null; uname -sm; pwd',
|
||||
],
|
||||
{ stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
|
||||
)
|
||||
|
||||
const result = await Promise.race([
|
||||
proc.exited,
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new SSHProbeError(
|
||||
`SSH probe timed out after ${PROBE_TIMEOUT_MS / 1000}s`,
|
||||
),
|
||||
),
|
||||
PROBE_TIMEOUT_MS,
|
||||
),
|
||||
),
|
||||
])
|
||||
|
||||
const stdout = await new Response(proc.stdout).text()
|
||||
const stderr = await new Response(proc.stderr).text()
|
||||
|
||||
if (result !== 0) {
|
||||
const detail = stderr.trim() || `exit code ${result}`
|
||||
throw new SSHProbeError(`SSH probe failed: ${detail}`)
|
||||
}
|
||||
|
||||
const lines = stdout
|
||||
.split('\n')
|
||||
.map(l => l.trim())
|
||||
.filter(Boolean)
|
||||
logForDebugging(`[SSHProbe] raw lines: ${JSON.stringify(lines)}`)
|
||||
|
||||
const unameIdx = lines.findIndex(l => /^(Linux|Darwin)\s/.test(l))
|
||||
if (unameIdx === -1) {
|
||||
throw new SSHProbeError(
|
||||
'Could not detect remote platform (uname output missing)',
|
||||
)
|
||||
}
|
||||
|
||||
const binaryPath = unameIdx >= 2 ? lines[unameIdx - 2] || null : null
|
||||
const versionLine = unameIdx >= 1 ? lines[unameIdx - 1] || null : null
|
||||
const remoteVersion =
|
||||
versionLine && /^\d+\.\d+/.test(versionLine) ? versionLine : null
|
||||
const hasBinary = binaryPath !== null && binaryPath.startsWith('/')
|
||||
const defaultCwd = lines[unameIdx + 1] || '/'
|
||||
|
||||
const [osName, arch] = lines[unameIdx]!.split(/\s+/)
|
||||
|
||||
const remotePlatform = osName === 'Darwin' ? 'darwin' : 'linux'
|
||||
const remoteArch: 'x64' | 'arm64' =
|
||||
arch === 'aarch64' || arch === 'arm64' ? 'arm64' : 'x64'
|
||||
|
||||
onProgress?.(`Detected ${remotePlatform}/${remoteArch}`)
|
||||
|
||||
return {
|
||||
hasBinary: hasBinary && remoteVersion !== null,
|
||||
remoteVersion,
|
||||
remotePlatform,
|
||||
remoteArch,
|
||||
defaultCwd,
|
||||
binaryPath: hasBinary ? binaryPath : null,
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,26 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
import type { SDKMessage } from '../entrypoints/sdk/coreTypes.js'
|
||||
import type { Subprocess } from 'bun'
|
||||
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
|
||||
import type {
|
||||
SDKControlPermissionRequest,
|
||||
StdoutMessage,
|
||||
} from '../entrypoints/sdk/controlTypes.js'
|
||||
import type { PermissionUpdate } from '../types/permissions.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { jsonParse, jsonStringify } from '../utils/slowOperations.js'
|
||||
import type { RemoteMessageContent } from '../utils/teleport/api.js'
|
||||
|
||||
export interface SSHSessionManagerOptions {
|
||||
onMessage: (sdkMessage: SDKMessage) => void
|
||||
onPermissionRequest: (request: SSHPermissionRequest, requestId: string) => void
|
||||
onPermissionRequest: (
|
||||
request: SSHPermissionRequest,
|
||||
requestId: string,
|
||||
) => void
|
||||
onConnected: () => void
|
||||
onReconnecting: (attempt: number, max: number) => void
|
||||
onDisconnected: () => void
|
||||
onError: (error: Error) => void
|
||||
reconnect?: () => Promise<Subprocess>
|
||||
maxReconnectAttempts?: number
|
||||
}
|
||||
|
||||
export interface SSHPermissionRequest {
|
||||
@@ -26,5 +37,317 @@ export interface SSHSessionManager {
|
||||
disconnect(): void
|
||||
sendMessage(content: RemoteMessageContent): Promise<boolean>
|
||||
sendInterrupt(): void
|
||||
respondToPermissionRequest(requestId: string, response: { behavior: string; message?: string; updatedInput?: unknown }): void
|
||||
respondToPermissionRequest(
|
||||
requestId: string,
|
||||
response: { behavior: string; message?: string; updatedInput?: unknown },
|
||||
): void
|
||||
}
|
||||
|
||||
function isStdoutMessage(value: unknown): value is StdoutMessage {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'type' in value &&
|
||||
typeof (value as Record<string, unknown>).type === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
const BASE_RECONNECT_DELAY_MS = 2_000
|
||||
const MAX_RECONNECT_DELAY_MS = 15_000
|
||||
const DEFAULT_MAX_RECONNECT_ATTEMPTS = 3
|
||||
|
||||
export class SSHSessionManagerImpl implements SSHSessionManager {
|
||||
private proc: Subprocess
|
||||
private options: SSHSessionManagerOptions
|
||||
private connected = false
|
||||
private disconnected = false
|
||||
private readLoopAbort: AbortController | null = null
|
||||
private reconnectAttempt = 0
|
||||
private readonly maxReconnectAttempts: number
|
||||
private userInitiatedDisconnect = false
|
||||
private reconnecting = false
|
||||
|
||||
constructor(proc: Subprocess, options: SSHSessionManagerOptions) {
|
||||
this.proc = proc
|
||||
this.options = options
|
||||
this.maxReconnectAttempts =
|
||||
options.maxReconnectAttempts ?? DEFAULT_MAX_RECONNECT_ATTEMPTS
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
if (this.connected) return
|
||||
|
||||
this.readLoopAbort = new AbortController()
|
||||
this.startReadLoop()
|
||||
this.monitorExit()
|
||||
|
||||
this.connected = true
|
||||
this.options.onConnected()
|
||||
}
|
||||
|
||||
private async startReadLoop(): Promise<void> {
|
||||
const stdout = this.proc.stdout
|
||||
if (!stdout) {
|
||||
this.options.onError(new Error('SSH process stdout is not available'))
|
||||
return
|
||||
}
|
||||
|
||||
const reader = (stdout as ReadableStream<Uint8Array>).getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let lineBuffer = ''
|
||||
|
||||
try {
|
||||
while (!this.disconnected) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
lineBuffer += decoder.decode(value, { stream: true })
|
||||
const lines = lineBuffer.split('\n')
|
||||
lineBuffer = lines.pop() ?? ''
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
this.processLine(trimmed)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!this.disconnected) {
|
||||
this.options.onError(
|
||||
err instanceof Error ? err : new Error(String(err)),
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
if (!this.disconnected && !this.userInitiatedDisconnect) {
|
||||
void this.handleProcessExit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private monitorExit(): void {
|
||||
if (this.proc.exitCode !== null) {
|
||||
if (!this.userInitiatedDisconnect) {
|
||||
void this.handleProcessExit()
|
||||
}
|
||||
return
|
||||
}
|
||||
this.proc.exited
|
||||
.then(() => {
|
||||
if (!this.disconnected && !this.userInitiatedDisconnect) {
|
||||
void this.handleProcessExit()
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!this.disconnected && !this.userInitiatedDisconnect) {
|
||||
void this.handleProcessExit()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async handleProcessExit(): Promise<void> {
|
||||
if (this.disconnected || this.reconnecting) return
|
||||
this.connected = false
|
||||
|
||||
if (!this.options.reconnect) {
|
||||
this.disconnected = true
|
||||
this.options.onDisconnected()
|
||||
return
|
||||
}
|
||||
|
||||
if (this.reconnectAttempt >= this.maxReconnectAttempts) {
|
||||
this.disconnected = true
|
||||
this.options.onDisconnected()
|
||||
return
|
||||
}
|
||||
|
||||
this.reconnecting = true
|
||||
try {
|
||||
await this.attemptReconnect()
|
||||
} finally {
|
||||
this.reconnecting = false
|
||||
}
|
||||
}
|
||||
|
||||
private async attemptReconnect(): Promise<void> {
|
||||
const reconnect = this.options.reconnect!
|
||||
|
||||
while (this.reconnectAttempt < this.maxReconnectAttempts) {
|
||||
this.reconnectAttempt++
|
||||
this.options.onReconnecting(
|
||||
this.reconnectAttempt,
|
||||
this.maxReconnectAttempts,
|
||||
)
|
||||
|
||||
const delay = Math.min(
|
||||
BASE_RECONNECT_DELAY_MS * 2 ** (this.reconnectAttempt - 1),
|
||||
MAX_RECONNECT_DELAY_MS,
|
||||
)
|
||||
await new Promise<void>(r => setTimeout(r, delay))
|
||||
|
||||
if (this.userInitiatedDisconnect) return
|
||||
|
||||
try {
|
||||
const newProc = await reconnect()
|
||||
this.proc = newProc
|
||||
this.reconnectAttempt = 0
|
||||
this.connected = true
|
||||
this.startReadLoop()
|
||||
this.monitorExit()
|
||||
this.options.onConnected()
|
||||
return
|
||||
} catch (err) {
|
||||
logForDebugging(
|
||||
`[SSH] reconnect attempt ${this.reconnectAttempt} failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
this.disconnected = true
|
||||
this.options.onDisconnected()
|
||||
}
|
||||
|
||||
private processLine(line: string): void {
|
||||
let raw: unknown
|
||||
try {
|
||||
raw = jsonParse(line)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isStdoutMessage(raw)) return
|
||||
const parsed = raw
|
||||
|
||||
if (parsed.type === 'control_request') {
|
||||
const request = parsed as unknown as {
|
||||
request_id: string
|
||||
request: SDKControlPermissionRequest & { subtype: string }
|
||||
}
|
||||
if (request.request.subtype === 'can_use_tool') {
|
||||
this.options.onPermissionRequest(
|
||||
request.request as unknown as SSHPermissionRequest,
|
||||
request.request_id,
|
||||
)
|
||||
} else {
|
||||
logForDebugging(
|
||||
`[SSH] Unsupported control request subtype: ${request.request.subtype}`,
|
||||
)
|
||||
this.sendErrorResponse(
|
||||
request.request_id,
|
||||
`Unsupported control request subtype: ${request.request.subtype}`,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
parsed.type !== 'control_response' &&
|
||||
parsed.type !== 'keep_alive' &&
|
||||
parsed.type !== 'control_cancel_request' &&
|
||||
parsed.type !== 'streamlined_text' &&
|
||||
parsed.type !== 'streamlined_tool_use_summary' &&
|
||||
!(
|
||||
parsed.type === 'system' &&
|
||||
(parsed as Record<string, unknown>).subtype === 'post_turn_summary'
|
||||
)
|
||||
) {
|
||||
this.options.onMessage(parsed as SDKMessage)
|
||||
}
|
||||
}
|
||||
|
||||
private writeToStdin(data: string): boolean {
|
||||
try {
|
||||
const stdin = this.proc.stdin
|
||||
if (!stdin || typeof stdin === 'number' || this.disconnected) return false
|
||||
const encoded = new TextEncoder().encode(data + '\n')
|
||||
;(stdin as unknown as { write(d: Uint8Array): number }).write(encoded)
|
||||
;(stdin as unknown as { flush?(): void }).flush?.()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage(content: RemoteMessageContent): Promise<boolean> {
|
||||
const message = jsonStringify({
|
||||
type: 'user',
|
||||
message: {
|
||||
role: 'user',
|
||||
content,
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
session_id: '',
|
||||
})
|
||||
return this.writeToStdin(message)
|
||||
}
|
||||
|
||||
sendInterrupt(): void {
|
||||
const request = jsonStringify({
|
||||
type: 'control_request',
|
||||
request_id: crypto.randomUUID(),
|
||||
request: {
|
||||
subtype: 'interrupt',
|
||||
},
|
||||
})
|
||||
this.writeToStdin(request)
|
||||
}
|
||||
|
||||
respondToPermissionRequest(
|
||||
requestId: string,
|
||||
response: { behavior: string; message?: string; updatedInput?: unknown },
|
||||
): void {
|
||||
const msg = jsonStringify({
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: requestId,
|
||||
response: {
|
||||
behavior: response.behavior,
|
||||
...(response.behavior === 'allow'
|
||||
? { updatedInput: response.updatedInput }
|
||||
: { message: response.message }),
|
||||
},
|
||||
},
|
||||
})
|
||||
this.writeToStdin(msg)
|
||||
}
|
||||
|
||||
private sendErrorResponse(requestId: string, error: string): void {
|
||||
const response = jsonStringify({
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'error',
|
||||
request_id: requestId,
|
||||
error,
|
||||
},
|
||||
})
|
||||
this.writeToStdin(response)
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this.disconnected) return
|
||||
this.userInitiatedDisconnect = true
|
||||
this.disconnected = true
|
||||
this.connected = false
|
||||
this.readLoopAbort?.abort()
|
||||
|
||||
try {
|
||||
const stdin = this.proc.stdin
|
||||
if (stdin && typeof stdin !== 'number') {
|
||||
;(stdin as unknown as { end?(): void }).end?.()
|
||||
}
|
||||
} catch {
|
||||
// stdin may already be closed
|
||||
}
|
||||
|
||||
try {
|
||||
this.proc.kill()
|
||||
} catch {
|
||||
// process may already be dead
|
||||
}
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.connected && !this.disconnected
|
||||
}
|
||||
}
|
||||
|
||||
413
src/ssh/__tests__/SSHSessionManager.test.ts
Normal file
413
src/ssh/__tests__/SSHSessionManager.test.ts
Normal file
@@ -0,0 +1,413 @@
|
||||
import { describe, test, expect, mock, beforeEach } from 'bun:test'
|
||||
import { debugMock } from '../../../tests/mocks/debug'
|
||||
|
||||
mock.module('src/utils/debug.ts', debugMock)
|
||||
|
||||
import { SSHSessionManagerImpl } from '../SSHSessionManager'
|
||||
import type { SSHSessionManagerOptions } from '../SSHSessionManager'
|
||||
import type { Subprocess } from 'bun'
|
||||
|
||||
function createMockSubprocess(options?: {
|
||||
exitCode?: number | null
|
||||
stdoutLines?: string[]
|
||||
}): {
|
||||
proc: Subprocess
|
||||
writeToStdout: (data: string) => void
|
||||
simulateExit: (code?: number) => void
|
||||
} {
|
||||
let stdoutController: ReadableStreamDefaultController<Uint8Array>
|
||||
const exitResolvers: Array<(code: number) => void> = []
|
||||
let exitCode: number | null = options?.exitCode ?? null
|
||||
|
||||
const stdout = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
stdoutController = controller
|
||||
if (options?.stdoutLines) {
|
||||
const encoder = new TextEncoder()
|
||||
for (const line of options.stdoutLines) {
|
||||
controller.enqueue(encoder.encode(line + '\n'))
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const stdinChunks: Uint8Array[] = []
|
||||
const stdin = {
|
||||
write(d: Uint8Array) {
|
||||
stdinChunks.push(d)
|
||||
return d.length
|
||||
},
|
||||
flush() {},
|
||||
end() {},
|
||||
}
|
||||
|
||||
const exited = new Promise<number>(resolve => {
|
||||
exitResolvers.push(resolve)
|
||||
if (exitCode !== null) resolve(exitCode)
|
||||
})
|
||||
|
||||
const proc = {
|
||||
stdout,
|
||||
stdin,
|
||||
stderr: null,
|
||||
get exitCode() {
|
||||
return exitCode
|
||||
},
|
||||
exited,
|
||||
kill: mock(() => {}),
|
||||
pid: 12345,
|
||||
killed: false,
|
||||
signalCode: null,
|
||||
ref: () => {},
|
||||
unref: () => {},
|
||||
} as unknown as Subprocess
|
||||
|
||||
return {
|
||||
proc,
|
||||
writeToStdout(data: string) {
|
||||
const encoder = new TextEncoder()
|
||||
stdoutController.enqueue(encoder.encode(data + '\n'))
|
||||
},
|
||||
simulateExit(code = 0) {
|
||||
exitCode = code
|
||||
try {
|
||||
stdoutController.close()
|
||||
} catch {
|
||||
// may already be closed
|
||||
}
|
||||
for (const resolve of exitResolvers) resolve(code)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
interface MockState {
|
||||
messages: unknown[]
|
||||
permissionRequests: Array<{ request: unknown; requestId: string }>
|
||||
reconnectingCalls: Array<{ attempt: number; max: number }>
|
||||
connectedCount: number
|
||||
disconnectedCount: number
|
||||
errors: Error[]
|
||||
}
|
||||
|
||||
function createMockOptions(
|
||||
overrides?: Partial<SSHSessionManagerOptions>,
|
||||
): SSHSessionManagerOptions & { state: MockState } {
|
||||
const state: MockState = {
|
||||
messages: [],
|
||||
permissionRequests: [],
|
||||
reconnectingCalls: [],
|
||||
connectedCount: 0,
|
||||
disconnectedCount: 0,
|
||||
errors: [],
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
onMessage: msg => {
|
||||
state.messages.push(msg)
|
||||
},
|
||||
onPermissionRequest: (request, requestId) => {
|
||||
state.permissionRequests.push({ request, requestId })
|
||||
},
|
||||
onConnected: () => {
|
||||
state.connectedCount++
|
||||
},
|
||||
onReconnecting: (attempt, max) => {
|
||||
state.reconnectingCalls.push({ attempt, max })
|
||||
},
|
||||
onDisconnected: () => {
|
||||
state.disconnectedCount++
|
||||
},
|
||||
onError: err => {
|
||||
state.errors.push(err)
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('SSHSessionManagerImpl', () => {
|
||||
test('connect() sets connected state and calls onConnected', () => {
|
||||
const { proc } = createMockSubprocess()
|
||||
const opts = createMockOptions()
|
||||
const manager = new SSHSessionManagerImpl(proc, opts)
|
||||
|
||||
manager.connect()
|
||||
|
||||
expect(manager.isConnected()).toBe(true)
|
||||
expect(opts.state.connectedCount).toBe(1)
|
||||
})
|
||||
|
||||
test('connect() is idempotent', () => {
|
||||
const { proc } = createMockSubprocess()
|
||||
const opts = createMockOptions()
|
||||
const manager = new SSHSessionManagerImpl(proc, opts)
|
||||
|
||||
manager.connect()
|
||||
manager.connect()
|
||||
|
||||
expect(opts.state.connectedCount).toBe(1)
|
||||
})
|
||||
|
||||
test('disconnect() sets disconnected state and kills process', () => {
|
||||
const { proc } = createMockSubprocess()
|
||||
const opts = createMockOptions()
|
||||
const manager = new SSHSessionManagerImpl(proc, opts)
|
||||
|
||||
manager.connect()
|
||||
manager.disconnect()
|
||||
|
||||
expect(manager.isConnected()).toBe(false)
|
||||
expect((proc.kill as ReturnType<typeof mock>).mock.calls.length).toBe(1)
|
||||
})
|
||||
|
||||
test('disconnect() is idempotent', () => {
|
||||
const { proc } = createMockSubprocess()
|
||||
const opts = createMockOptions()
|
||||
const manager = new SSHSessionManagerImpl(proc, opts)
|
||||
|
||||
manager.connect()
|
||||
manager.disconnect()
|
||||
manager.disconnect()
|
||||
|
||||
expect((proc.kill as ReturnType<typeof mock>).mock.calls.length).toBe(1)
|
||||
})
|
||||
|
||||
test('processLine routes SDK messages to onMessage', async () => {
|
||||
const sdkMessage = JSON.stringify({
|
||||
type: 'assistant',
|
||||
message: { role: 'assistant', content: 'hello' },
|
||||
})
|
||||
|
||||
const { proc, writeToStdout, simulateExit } = createMockSubprocess()
|
||||
const opts = createMockOptions()
|
||||
const manager = new SSHSessionManagerImpl(proc, opts)
|
||||
|
||||
manager.connect()
|
||||
writeToStdout(sdkMessage)
|
||||
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
simulateExit(0)
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
|
||||
expect(opts.state.messages.length).toBe(1)
|
||||
expect((opts.state.messages[0] as Record<string, unknown>).type).toBe(
|
||||
'assistant',
|
||||
)
|
||||
})
|
||||
|
||||
test('processLine filters noise types', async () => {
|
||||
const noiseTypes = [
|
||||
'control_response',
|
||||
'keep_alive',
|
||||
'control_cancel_request',
|
||||
'streamlined_text',
|
||||
'streamlined_tool_use_summary',
|
||||
]
|
||||
|
||||
const { proc, writeToStdout, simulateExit } = createMockSubprocess()
|
||||
const opts = createMockOptions()
|
||||
const manager = new SSHSessionManagerImpl(proc, opts)
|
||||
|
||||
manager.connect()
|
||||
|
||||
for (const type of noiseTypes) {
|
||||
writeToStdout(JSON.stringify({ type }))
|
||||
}
|
||||
writeToStdout(
|
||||
JSON.stringify({ type: 'system', subtype: 'post_turn_summary' }),
|
||||
)
|
||||
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
simulateExit(0)
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
|
||||
expect(opts.state.messages.length).toBe(0)
|
||||
})
|
||||
|
||||
test('processLine routes control_request to onPermissionRequest', async () => {
|
||||
const controlRequest = JSON.stringify({
|
||||
type: 'control_request',
|
||||
request_id: 'req-123',
|
||||
request: {
|
||||
subtype: 'can_use_tool',
|
||||
tool_name: 'Bash',
|
||||
tool_use_id: 'tool-456',
|
||||
input: { command: 'ls' },
|
||||
},
|
||||
})
|
||||
|
||||
const { proc, writeToStdout, simulateExit } = createMockSubprocess()
|
||||
const opts = createMockOptions()
|
||||
const manager = new SSHSessionManagerImpl(proc, opts)
|
||||
|
||||
manager.connect()
|
||||
writeToStdout(controlRequest)
|
||||
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
simulateExit(0)
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
|
||||
expect(opts.state.permissionRequests.length).toBe(1)
|
||||
expect(opts.state.permissionRequests[0]!.requestId).toBe('req-123')
|
||||
})
|
||||
|
||||
test('sendMessage writes NDJSON to stdin', async () => {
|
||||
const { proc } = createMockSubprocess()
|
||||
const opts = createMockOptions()
|
||||
const manager = new SSHSessionManagerImpl(proc, opts)
|
||||
|
||||
manager.connect()
|
||||
const result = await manager.sendMessage('hello world')
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test('sendInterrupt writes interrupt control request', () => {
|
||||
const { proc } = createMockSubprocess()
|
||||
const opts = createMockOptions()
|
||||
const manager = new SSHSessionManagerImpl(proc, opts)
|
||||
|
||||
manager.connect()
|
||||
manager.sendInterrupt()
|
||||
|
||||
const stdin = proc.stdin as unknown as { write: ReturnType<typeof mock> }
|
||||
expect(stdin.write).toBeDefined()
|
||||
})
|
||||
|
||||
test('respondToPermissionRequest sends allow response', () => {
|
||||
const { proc } = createMockSubprocess()
|
||||
const opts = createMockOptions()
|
||||
const manager = new SSHSessionManagerImpl(proc, opts)
|
||||
|
||||
manager.connect()
|
||||
manager.respondToPermissionRequest('req-123', {
|
||||
behavior: 'allow',
|
||||
updatedInput: { command: 'ls -la' },
|
||||
})
|
||||
})
|
||||
|
||||
test('respondToPermissionRequest sends deny response', () => {
|
||||
const { proc } = createMockSubprocess()
|
||||
const opts = createMockOptions()
|
||||
const manager = new SSHSessionManagerImpl(proc, opts)
|
||||
|
||||
manager.connect()
|
||||
manager.respondToPermissionRequest('req-123', {
|
||||
behavior: 'deny',
|
||||
message: 'User denied',
|
||||
})
|
||||
})
|
||||
|
||||
test('process exit without reconnect calls onDisconnected', async () => {
|
||||
const { proc, simulateExit } = createMockSubprocess()
|
||||
const opts = createMockOptions()
|
||||
const manager = new SSHSessionManagerImpl(proc, opts)
|
||||
|
||||
manager.connect()
|
||||
simulateExit(1)
|
||||
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
|
||||
expect(opts.state.disconnectedCount).toBe(1)
|
||||
expect(manager.isConnected()).toBe(false)
|
||||
})
|
||||
|
||||
test('user disconnect does not trigger reconnect', async () => {
|
||||
let reconnectCalled = false
|
||||
const { proc } = createMockSubprocess()
|
||||
const opts = createMockOptions({
|
||||
reconnect: async () => {
|
||||
reconnectCalled = true
|
||||
return createMockSubprocess().proc
|
||||
},
|
||||
maxReconnectAttempts: 3,
|
||||
})
|
||||
const manager = new SSHSessionManagerImpl(proc, opts)
|
||||
|
||||
manager.connect()
|
||||
manager.disconnect()
|
||||
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
|
||||
expect(reconnectCalled).toBe(false)
|
||||
expect(opts.state.reconnectingCalls.length).toBe(0)
|
||||
})
|
||||
|
||||
test('invalid JSON lines are silently skipped', async () => {
|
||||
const { proc, writeToStdout, simulateExit } = createMockSubprocess()
|
||||
const opts = createMockOptions()
|
||||
const manager = new SSHSessionManagerImpl(proc, opts)
|
||||
|
||||
manager.connect()
|
||||
writeToStdout('not valid json')
|
||||
writeToStdout('{also: broken')
|
||||
writeToStdout(
|
||||
JSON.stringify({ type: 'assistant', message: { role: 'assistant' } }),
|
||||
)
|
||||
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
simulateExit(0)
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
|
||||
expect(opts.state.messages.length).toBe(1)
|
||||
expect(opts.state.errors.length).toBe(0)
|
||||
})
|
||||
|
||||
test('non-StdoutMessage objects are skipped', async () => {
|
||||
const { proc, writeToStdout, simulateExit } = createMockSubprocess()
|
||||
const opts = createMockOptions()
|
||||
const manager = new SSHSessionManagerImpl(proc, opts)
|
||||
|
||||
manager.connect()
|
||||
writeToStdout(JSON.stringify({ noTypeField: true }))
|
||||
writeToStdout(JSON.stringify([1, 2, 3]))
|
||||
writeToStdout(JSON.stringify('string'))
|
||||
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
simulateExit(0)
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
|
||||
expect(opts.state.messages.length).toBe(0)
|
||||
})
|
||||
|
||||
test('process exit with reconnect factory attempts reconnection', async () => {
|
||||
const { proc: proc1, simulateExit } = createMockSubprocess()
|
||||
const { proc: proc2 } = createMockSubprocess()
|
||||
|
||||
const opts = createMockOptions({
|
||||
reconnect: mock(async () => proc2),
|
||||
maxReconnectAttempts: 3,
|
||||
})
|
||||
const manager = new SSHSessionManagerImpl(proc1, opts)
|
||||
|
||||
manager.connect()
|
||||
simulateExit(1)
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
expect(opts.state.reconnectingCalls.length).toBeGreaterThanOrEqual(1)
|
||||
expect(opts.state.reconnectingCalls[0]!.attempt).toBe(1)
|
||||
expect(opts.state.reconnectingCalls[0]!.max).toBe(3)
|
||||
})
|
||||
|
||||
test('reconnect failure exhausts attempts then disconnects', async () => {
|
||||
const { proc, simulateExit } = createMockSubprocess()
|
||||
|
||||
const opts = createMockOptions({
|
||||
reconnect: mock(async () => {
|
||||
throw new Error('SSH connection refused')
|
||||
}),
|
||||
maxReconnectAttempts: 2,
|
||||
})
|
||||
const manager = new SSHSessionManagerImpl(proc, opts)
|
||||
|
||||
manager.connect()
|
||||
simulateExit(1)
|
||||
|
||||
await new Promise(r => setTimeout(r, 12000))
|
||||
|
||||
expect(opts.state.reconnectingCalls.length).toBe(2)
|
||||
expect(opts.state.disconnectedCount).toBe(1)
|
||||
expect(manager.isConnected()).toBe(false)
|
||||
}, 15000)
|
||||
})
|
||||
@@ -1,10 +1,21 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
import type { Subprocess } from 'bun'
|
||||
import type { SSHSessionManager, SSHSessionManagerOptions } from './SSHSessionManager.js'
|
||||
import { SSHSessionManagerImpl } from './SSHSessionManager.js'
|
||||
import type {
|
||||
SSHSessionManager,
|
||||
SSHSessionManagerOptions,
|
||||
} from './SSHSessionManager.js'
|
||||
import { createAuthProxy } from './SSHAuthProxy.js'
|
||||
export type { SSHAuthProxy } from './SSHAuthProxy.js'
|
||||
import type { SSHAuthProxy } from './SSHAuthProxy.js'
|
||||
import { probeRemote } from './SSHProbe.js'
|
||||
import { deployBinary } from './SSHDeploy.js'
|
||||
import { buildCliLaunch } from '../utils/cliLaunch.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { jsonParse } from '../utils/slowOperations.js'
|
||||
import { randomUUID } from 'crypto'
|
||||
|
||||
export interface SSHAuthProxy {
|
||||
stop(): void
|
||||
}
|
||||
const INIT_TIMEOUT_MS = 30_000
|
||||
const STDERR_TAIL_LINES = 20
|
||||
|
||||
export interface SSHSession {
|
||||
remoteCwd: string
|
||||
@@ -21,9 +32,419 @@ export class SSHSessionError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export const createSSHSession: (...args: unknown[]) => Promise<SSHSession> = (async () => {
|
||||
throw new SSHSessionError('SSH sessions are not supported in this build')
|
||||
});
|
||||
export const createLocalSSHSession: (...args: unknown[]) => Promise<SSHSession> = (async () => {
|
||||
throw new SSHSessionError('Local SSH sessions are not supported in this build')
|
||||
});
|
||||
export async function createSSHSession(
|
||||
config: {
|
||||
host: string
|
||||
cwd?: string
|
||||
localVersion: string
|
||||
permissionMode?: string
|
||||
dangerouslySkipPermissions?: boolean
|
||||
extraCliArgs: string[]
|
||||
remoteBin?: string
|
||||
},
|
||||
callbacks?: {
|
||||
onProgress?: (msg: string) => void
|
||||
},
|
||||
): Promise<SSHSession> {
|
||||
const { host, localVersion, extraCliArgs, remoteBin } = config
|
||||
const onProgress = callbacks?.onProgress
|
||||
|
||||
let remoteBinaryPath: string
|
||||
let defaultCwd = '/'
|
||||
|
||||
if (remoteBin) {
|
||||
onProgress?.('Using custom remote binary, skipping probe/deploy…')
|
||||
remoteBinaryPath = remoteBin
|
||||
logForDebugging(`[SSH] custom remoteBin: ${remoteBin}`)
|
||||
// Quick SSH to get remote home directory for default CWD
|
||||
try {
|
||||
const pwdProc = Bun.spawn(
|
||||
['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', host, 'pwd'],
|
||||
{
|
||||
stdin: 'ignore',
|
||||
stdout: 'pipe',
|
||||
stderr: 'ignore',
|
||||
},
|
||||
)
|
||||
await pwdProc.exited
|
||||
const pwd = (await new Response(pwdProc.stdout).text()).trim()
|
||||
if (pwd.startsWith('/')) defaultCwd = pwd
|
||||
} catch {
|
||||
/* use fallback */
|
||||
}
|
||||
} else {
|
||||
// 1. Probe remote host
|
||||
const probe = await probeRemote(host, onProgress)
|
||||
logForDebugging(`[SSH] probe result: ${JSON.stringify(probe)}`)
|
||||
defaultCwd = probe.defaultCwd
|
||||
|
||||
// 2. Deploy if binary missing or version mismatch
|
||||
remoteBinaryPath = probe.binaryPath ?? '~/.local/bin/claude'
|
||||
if (!probe.hasBinary || probe.remoteVersion !== localVersion) {
|
||||
onProgress?.(
|
||||
probe.hasBinary
|
||||
? `Updating remote binary (${probe.remoteVersion} → ${localVersion})…`
|
||||
: 'Deploying binary to remote…',
|
||||
)
|
||||
remoteBinaryPath = await deployBinary({
|
||||
host,
|
||||
remotePlatform: probe.remotePlatform,
|
||||
remoteArch: probe.remoteArch,
|
||||
localVersion,
|
||||
onProgress,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Start local auth proxy
|
||||
const { proxy, localAddress, authEnv } = await createAuthProxy()
|
||||
logForDebugging(`[SSH] auth proxy listening on ${localAddress}`)
|
||||
|
||||
// 4. Build SSH command with -R reverse forward and remote CLI
|
||||
const remoteSocketId = randomUUID().slice(0, 8)
|
||||
const isWindows = process.platform === 'win32'
|
||||
|
||||
const remoteCli: string[] = []
|
||||
for (const [k, v] of Object.entries(authEnv)) {
|
||||
remoteCli.push(`${k}=${v}`)
|
||||
}
|
||||
remoteCli.push(
|
||||
remoteBinaryPath,
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--input-format',
|
||||
'stream-json',
|
||||
'--verbose',
|
||||
'-p',
|
||||
)
|
||||
if (config.cwd) remoteCli.push('--cwd', config.cwd)
|
||||
if (config.permissionMode)
|
||||
remoteCli.push('--permission-mode', config.permissionMode)
|
||||
if (config.dangerouslySkipPermissions)
|
||||
remoteCli.push('--dangerously-skip-permissions')
|
||||
remoteCli.push(...extraCliArgs)
|
||||
|
||||
const sshArgs = ['ssh']
|
||||
|
||||
if (!isWindows) {
|
||||
const remoteSocket = `/tmp/claude-ssh-auth-${remoteSocketId}.sock`
|
||||
sshArgs.push('-R', `${remoteSocket}:${localAddress}`)
|
||||
sshArgs.push('-o', 'StreamLocalBindUnlink=yes')
|
||||
// Override auth env to use the remote socket path
|
||||
const idx = remoteCli.indexOf(
|
||||
`ANTHROPIC_AUTH_SOCKET=${authEnv.ANTHROPIC_AUTH_SOCKET}`,
|
||||
)
|
||||
if (idx !== -1) {
|
||||
remoteCli[idx] = `ANTHROPIC_AUTH_SOCKET=${remoteSocket}`
|
||||
}
|
||||
} else {
|
||||
// Windows: TCP reverse forward
|
||||
const localPort = localAddress.split(':')[1]
|
||||
const remotePort = 10000 + Math.floor(Math.random() * 50000)
|
||||
sshArgs.push('-R', `${remotePort}:127.0.0.1:${localPort}`)
|
||||
// Override auth env to use remote TCP address
|
||||
const baseIdx = remoteCli.findIndex(s =>
|
||||
s.startsWith('ANTHROPIC_BASE_URL='),
|
||||
)
|
||||
if (baseIdx !== -1) {
|
||||
remoteCli[baseIdx] = `ANTHROPIC_BASE_URL=http://127.0.0.1:${remotePort}`
|
||||
}
|
||||
}
|
||||
|
||||
sshArgs.push(host, remoteCli.join(' '))
|
||||
|
||||
onProgress?.('Starting remote session…')
|
||||
logForDebugging(`[SSH] spawning: ${sshArgs.join(' ')}`)
|
||||
|
||||
let proc: Subprocess
|
||||
try {
|
||||
proc = Bun.spawn(sshArgs, {
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
} catch (err) {
|
||||
proxy.stop()
|
||||
throw new SSHSessionError(
|
||||
`Failed to spawn SSH process: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
}
|
||||
|
||||
const stderrChunks: string[] = []
|
||||
collectStderr(proc, stderrChunks)
|
||||
|
||||
let remoteCwd: string
|
||||
if (remoteBin) {
|
||||
// Custom binary mode: the remote CLI in print+stream-json mode emits
|
||||
// init only after receiving the first user message (QueryEngine yield).
|
||||
// Waiting for init here would deadlock. Instead, verify the process
|
||||
// is alive and use the configured or probed CWD.
|
||||
const earlyExit = await Promise.race([
|
||||
proc.exited.then(code => code),
|
||||
new Promise<null>(r => setTimeout(() => r(null), 3_000)),
|
||||
])
|
||||
if (earlyExit !== null) {
|
||||
proxy.stop()
|
||||
const tail = stderrChunks.join('').trim()
|
||||
throw new SSHSessionError(
|
||||
`Remote process exited immediately (code ${earlyExit})${tail ? `: ${tail}` : ''}`,
|
||||
)
|
||||
}
|
||||
remoteCwd = config.cwd || defaultCwd || '/'
|
||||
} else {
|
||||
try {
|
||||
remoteCwd = await waitForInit(proc, config.cwd || defaultCwd)
|
||||
} catch (err) {
|
||||
proxy.stop()
|
||||
proc.kill()
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
logForDebugging(`[SSH] remote session initialized, remoteCwd=${remoteCwd}`)
|
||||
|
||||
let currentProc = proc
|
||||
|
||||
const reconnect = async (): Promise<Subprocess> => {
|
||||
logForDebugging('[SSH] reconnect: re-spawning SSH process with --continue')
|
||||
const reconnectArgs = [...sshArgs]
|
||||
const cmdIdx = reconnectArgs.length - 1
|
||||
const existingCmd = reconnectArgs[cmdIdx]!
|
||||
if (!existingCmd.includes('--continue')) {
|
||||
reconnectArgs[cmdIdx] = existingCmd.replace(
|
||||
/ -p(?:\s|$)/,
|
||||
' -p --continue ',
|
||||
)
|
||||
}
|
||||
|
||||
const newProc = Bun.spawn(reconnectArgs, {
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
|
||||
const newStderrChunks: string[] = []
|
||||
collectStderr(newProc, newStderrChunks)
|
||||
|
||||
await waitForInit(newProc, remoteCwd)
|
||||
currentProc = newProc
|
||||
stderrChunks.length = 0
|
||||
stderrChunks.push(...newStderrChunks)
|
||||
|
||||
return newProc
|
||||
}
|
||||
|
||||
return {
|
||||
remoteCwd,
|
||||
get proc() {
|
||||
return currentProc
|
||||
},
|
||||
proxy,
|
||||
createManager(options: SSHSessionManagerOptions): SSHSessionManager {
|
||||
return new SSHSessionManagerImpl(currentProc, {
|
||||
...options,
|
||||
reconnect,
|
||||
})
|
||||
},
|
||||
getStderrTail(): string {
|
||||
return stderrChunks.slice(-STDERR_TAIL_LINES).join('')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function createLocalSSHSession(config: {
|
||||
cwd?: string
|
||||
permissionMode?: string
|
||||
dangerouslySkipPermissions?: boolean
|
||||
}): Promise<SSHSession> {
|
||||
const { proxy, authEnv } = await createAuthProxy()
|
||||
|
||||
const cliArgs: string[] = [
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--input-format',
|
||||
'stream-json',
|
||||
'-p',
|
||||
]
|
||||
if (config.cwd) {
|
||||
cliArgs.push('--cwd', config.cwd)
|
||||
}
|
||||
if (config.permissionMode) {
|
||||
cliArgs.push('--permission-mode', config.permissionMode)
|
||||
}
|
||||
if (config.dangerouslySkipPermissions) {
|
||||
cliArgs.push('--dangerously-skip-permissions')
|
||||
}
|
||||
|
||||
const spec = buildCliLaunch(cliArgs)
|
||||
|
||||
let proc: Subprocess
|
||||
try {
|
||||
proc = Bun.spawn([spec.execPath, ...spec.args], {
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: { ...spec.env, ...authEnv },
|
||||
})
|
||||
} catch (err) {
|
||||
proxy.stop()
|
||||
throw new SSHSessionError(
|
||||
`Failed to spawn local CLI process: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
}
|
||||
|
||||
logForDebugging('[SSH] local session spawned, waiting for init message...')
|
||||
|
||||
const stderrChunks: string[] = []
|
||||
collectStderr(proc, stderrChunks)
|
||||
|
||||
let remoteCwd: string
|
||||
try {
|
||||
remoteCwd = await waitForInit(proc, config.cwd)
|
||||
} catch (err) {
|
||||
proxy.stop()
|
||||
proc.kill()
|
||||
throw err
|
||||
}
|
||||
|
||||
logForDebugging(`[SSH] local session initialized, remoteCwd=${remoteCwd}`)
|
||||
|
||||
let currentProc = proc
|
||||
|
||||
const reconnect = async (): Promise<Subprocess> => {
|
||||
logForDebugging('[SSH] local reconnect: re-spawning CLI with --continue')
|
||||
const reconnectCliArgs = [...cliArgs]
|
||||
if (!reconnectCliArgs.includes('--continue')) {
|
||||
reconnectCliArgs.push('--continue')
|
||||
}
|
||||
|
||||
const reconnectSpec = buildCliLaunch(reconnectCliArgs)
|
||||
const newProc = Bun.spawn([reconnectSpec.execPath, ...reconnectSpec.args], {
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: { ...reconnectSpec.env, ...authEnv },
|
||||
})
|
||||
|
||||
const newStderrChunks: string[] = []
|
||||
collectStderr(newProc, newStderrChunks)
|
||||
|
||||
await waitForInit(newProc, remoteCwd)
|
||||
currentProc = newProc
|
||||
stderrChunks.length = 0
|
||||
stderrChunks.push(...newStderrChunks)
|
||||
|
||||
return newProc
|
||||
}
|
||||
|
||||
return {
|
||||
remoteCwd,
|
||||
get proc() {
|
||||
return currentProc
|
||||
},
|
||||
proxy,
|
||||
createManager(options: SSHSessionManagerOptions): SSHSessionManager {
|
||||
return new SSHSessionManagerImpl(currentProc, {
|
||||
...options,
|
||||
reconnect,
|
||||
})
|
||||
},
|
||||
getStderrTail(): string {
|
||||
return stderrChunks.slice(-STDERR_TAIL_LINES).join('')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForInit(
|
||||
proc: Subprocess,
|
||||
fallbackCwd?: string,
|
||||
): Promise<string> {
|
||||
const stdout = proc.stdout
|
||||
if (!stdout) {
|
||||
throw new SSHSessionError('Child process stdout is not readable')
|
||||
}
|
||||
|
||||
const reader = (stdout as ReadableStream<Uint8Array>).getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
const deadline = Date.now() + INIT_TIMEOUT_MS
|
||||
|
||||
try {
|
||||
while (Date.now() < deadline) {
|
||||
const remaining = deadline - Date.now()
|
||||
const result = await Promise.race([
|
||||
reader.read(),
|
||||
new Promise<{ done: true; value: undefined }>((_, reject) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new SSHSessionError(
|
||||
'Remote CLI did not initialize within 30 seconds. Check SSH connectivity and remote binary.',
|
||||
),
|
||||
),
|
||||
remaining,
|
||||
),
|
||||
),
|
||||
])
|
||||
|
||||
if (result.done) {
|
||||
throw new SSHSessionError(
|
||||
'Child process exited before sending init message',
|
||||
)
|
||||
}
|
||||
|
||||
buffer += decoder.decode(result.value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
try {
|
||||
const msg = jsonParse(trimmed) as Record<string, unknown>
|
||||
if (msg.type === 'system' && msg.subtype === 'init') {
|
||||
reader.releaseLock()
|
||||
return (msg.cwd as string) || fallbackCwd || process.cwd()
|
||||
}
|
||||
} catch {
|
||||
// not valid JSON — skip
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
reader.releaseLock()
|
||||
throw err instanceof SSHSessionError
|
||||
? err
|
||||
: new SSHSessionError(
|
||||
`Error reading init message: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
}
|
||||
|
||||
reader.releaseLock()
|
||||
throw new SSHSessionError(
|
||||
'Remote CLI did not initialize within 30 seconds. Check SSH connectivity and remote binary.',
|
||||
)
|
||||
}
|
||||
|
||||
function collectStderr(proc: Subprocess, chunks: string[]): void {
|
||||
const stderr = proc.stderr
|
||||
if (!stderr) return
|
||||
|
||||
const reader = (stderr as ReadableStream<Uint8Array>).getReader()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
chunks.push(decoder.decode(value, { stream: true }))
|
||||
if (chunks.length > STDERR_TAIL_LINES * 2) {
|
||||
chunks.splice(0, chunks.length - STDERR_TAIL_LINES)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// stderr closed — expected on process exit
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ let earlyInputBuffer = ''
|
||||
let isCapturing = false
|
||||
// Reference to the readable handler so we can remove it later
|
||||
let readableHandler: (() => void) | null = null
|
||||
// Safety valve: auto-cleanup after timeout so stdin.ref() never leaks
|
||||
let safetyTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/**
|
||||
* Start capturing stdin data early, before the REPL is initialized.
|
||||
@@ -60,6 +62,20 @@ export function startCapturingEarlyInput(): void {
|
||||
}
|
||||
|
||||
process.stdin.on('readable', readableHandler)
|
||||
|
||||
// Safety valve: if Ink never takes over within 10s (e.g. setup dialog
|
||||
// stalls, or an error prevents Ink mount on Windows), unref stdin so
|
||||
// the process doesn't hang forever. The REPL's Ink App normally calls
|
||||
// consumeEarlyInput() → stopCapturingEarlyInput() long before this.
|
||||
safetyTimer = setTimeout(() => {
|
||||
if (isCapturing) {
|
||||
stopCapturingEarlyInput()
|
||||
}
|
||||
}, 10_000)
|
||||
// Don't let the timer itself keep the event loop alive
|
||||
if (safetyTimer && typeof safetyTimer === 'object' && 'unref' in safetyTimer) {
|
||||
safetyTimer.unref()
|
||||
}
|
||||
} catch {
|
||||
// If we can't set raw mode, just silently continue without early capture
|
||||
isCapturing = false
|
||||
@@ -172,14 +188,34 @@ export function stopCapturingEarlyInput(): void {
|
||||
|
||||
isCapturing = false
|
||||
|
||||
// Clear safety timer
|
||||
if (safetyTimer) {
|
||||
clearTimeout(safetyTimer)
|
||||
safetyTimer = null
|
||||
}
|
||||
|
||||
if (readableHandler) {
|
||||
process.stdin.removeListener('readable', readableHandler)
|
||||
readableHandler = null
|
||||
}
|
||||
|
||||
// Don't reset stdin state - the REPL's Ink App will manage stdin state.
|
||||
// If we call setRawMode(false) here, it can interfere with the REPL's
|
||||
// own stdin setup which happens around the same time.
|
||||
// Undo the ref() from startCapturingEarlyInput so the event loop isn't
|
||||
// kept alive if Ink never takes over (e.g. raw mode unsupported on
|
||||
// Windows Node.js, or an error during setup). Ink's own
|
||||
// handleSetRawMode(true) calls stdin.ref() again, and its
|
||||
// handleSetRawMode(false) / unmount path calls stdin.unref(), so this
|
||||
// unref is safe even when Ink does take over — the two ref/unref calls
|
||||
// balance out.
|
||||
try {
|
||||
process.stdin.unref()
|
||||
} catch {
|
||||
// stdin may already be destroyed
|
||||
}
|
||||
|
||||
// Don't reset setRawMode here — Ink's App.handleSetRawMode(true)
|
||||
// calls stopCapturingEarlyInput() synchronously and then immediately
|
||||
// calls setRawMode(true) + ref() on the same stdin, so toggling it
|
||||
// off here would add a visible flicker on Windows.
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -348,13 +348,13 @@ export function getDefaultEffortForModel(
|
||||
model.toLowerCase().includes('opus-4-6')
|
||||
) {
|
||||
if (isProSubscriber()) {
|
||||
return 'medium'
|
||||
return 'high'
|
||||
}
|
||||
if (
|
||||
getOpusDefaultEffortConfig().enabled &&
|
||||
(isMaxSubscriber() || isTeamSubscriber())
|
||||
) {
|
||||
return 'medium'
|
||||
return 'high'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
14
teach-me/learner-profile.md
Normal file
14
teach-me/learner-profile.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Learner Profile
|
||||
Updated: 2026-04-24
|
||||
|
||||
## Style
|
||||
- Learns best with: Analogies (Memory page <-> Capsule hotel), Concrete trade-offs (Latency vs Throughput)
|
||||
- Strength: Strong logical intuition regarding memory constraints.
|
||||
- Pace: Fast. Grasped PagedAttention/TP concepts quickly from first principles.
|
||||
|
||||
## Patterns
|
||||
- Tends to focus on memory usage ("is it OOM?"), which is a good instinct for inference tuning.
|
||||
- Needs precision on API flags (e.g., `--tensor-parallel-size`).
|
||||
|
||||
## Topics
|
||||
- vLLM Inference Optimization (10/10 concepts mastered, 2026-04-24)
|
||||
25
teach-me/vllm/session.md
Normal file
25
teach-me/vllm/session.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Session: vLLM Inference Optimization
|
||||
- Level: Beginner (Target: Inference Optimization)
|
||||
- Started: 2026-04-24
|
||||
- Status: Mastered
|
||||
|
||||
## Concepts
|
||||
1. ✅ LLM 推理的两个阶段 (Prefill vs Decode)
|
||||
2. ✅ KV Cache
|
||||
3. ✅ 显存瓶颈与碎片化
|
||||
4. ✅ PagedAttention
|
||||
5. ✅ vLLM 架构 (Scheduler, Worker)
|
||||
6. ✅ 实战部署 (--dtype, openai api)
|
||||
7. ✅ 量化 (AWQ/GPTQ vs 暴力 dtype)
|
||||
8. ✅ Tensor Parallel (TP, NCCL)
|
||||
9. ✅ 性能参数 (--gpu-memory-utilization)
|
||||
10. ✅ Chunked Prefill
|
||||
|
||||
## Misconceptions
|
||||
- [Chunked Prefill]: 原以为主要目的是降低显存。
|
||||
- 纠正:确实降低了**峰值激活显存**,但核心目的是降低**Latency (卡顿感)**。
|
||||
|
||||
## Log
|
||||
- Diagnosed: Beginner
|
||||
- Mastery: Intuitive understanding of memory constraints and fragmentation is strong.
|
||||
- Final Quiz: 3/3 correct (with minor clarification needed on TP params).
|
||||
81
teach-me/vllm/vllm-notes.md
Normal file
81
teach-me/vllm/vllm-notes.md
Normal file
@@ -0,0 +1,81 @@
|
||||
|
||||
# vLLM 核心原理与性能调优笔记
|
||||
|
||||
## 1. vLLM 是什么?
|
||||
一个**高吞吐量、低延迟**的大语言模型推理服务框架。
|
||||
* **核心目标**:榨干 GPU 性能,让同一个显卡能同时服务更多并发请求(Throughput),并减少卡顿(Latency)。
|
||||
* **一句话理解**:LLM 推理版的"显存管理大师与调度大师"。
|
||||
|
||||
---
|
||||
|
||||
## 2. 为什么 vLLM 快?(核心原理)
|
||||
|
||||
### 2.1 显存的痛点:KV Cache 与 显存碎片化
|
||||
LLM 推理分为两个阶段:
|
||||
1. **Prefill (预填)**:处理 Prompt,生成第一个 token。
|
||||
2. **Decode (解码)**:基于之前的 token,一个接一个地生成下一个 token。
|
||||
* **KV Cache**:为了避免每次 Decode 都重新计算一遍之前所有 token 的 Attention,必须把这些中间结果 (KV) 存在 GPU 显存里。
|
||||
* **传统框架痛点**:一次申请固定长度的连续显存。如果一个请求用了 50% 的空间就结束,剩下的显存因为"不连续"而无法分给其他请求,导致显存利用率只有 20% 左右(**显存碎片化**)。
|
||||
|
||||
### 2.2 PagedAttention (分页存储技术 —— vLLM 的大杀器)
|
||||
借鉴了操作系统**虚拟内存分页**的设计。
|
||||
* **做法**:不再一次性分配一大块显存,而是把 KV Cache 切分成固定大小的 **Block**。每个 Block 存在显存的任意位置,通过 **Block Table** 映射。
|
||||
* **效果**:空闲的 Block 随时分配给新请求。显存利用率从 20% 提升到 90%+。
|
||||
* **好处**:彻底解决了碎片化问题,使 Concurrent Batching 成为可能。
|
||||
|
||||
### 2.3 Continuous Batching (连续批处理)
|
||||
* **Static Batching (传统)**:一个 Batch 里的请求必须一起跑。哪怕 9 个请求生成了 10 个 token 就结束了,必须等第 10 个请求生成完(比如 500 个 token)才能结束。这导致 GPU 在后期大量空转。
|
||||
* **Continuous Batching**:一个请求一旦结束,立刻从 Batch 中剔除,并从队列里拉一个新请求塞进去。GPU 始终在满负荷工作,**吞吐量呈指数级提升**。
|
||||
|
||||
---
|
||||
|
||||
## 3. 性能与显存进阶优化
|
||||
|
||||
### 3.1 量化 (Quantization)
|
||||
把高精度的权重(如 FP16)压缩成低精度的版本(如 INT8, INT4, FP8)。
|
||||
* **作用**:**减少显存占用(装下更大的模型);提高推理速度(低精度计算更快)**。
|
||||
* **AWQ / GPTQ vs 暴力降低精度**:
|
||||
* 模型中有极少数关键权重 (**Outliers/异常值**)。如果暴力降低精度,这部分信息丢失,模型性能(IQ)会暴跌。
|
||||
* **AWQ 等算法**会先探测哪些权重敏感,针对这些权重特殊保护(保留更高精度),其余部分暴力压缩。类似于“好钢用在刀刃上”。
|
||||
|
||||
### 3.2 多卡并行 (Tensor Parallelism - TP)
|
||||
当模型太大,单张显卡(如 A100 80G)装不下(比如 70B FP16 需要 140G 显存):
|
||||
* **做法**:把模型的每一层权重矩阵切分成 N 份(N = GPU 数),分配给多张卡。每执行一步,各卡算好自己那份,再通过 **NCCL 协议** 在 GPU 之间交换中间结果并合并。
|
||||
* **代价**:**通信带宽瓶颈**。如果模型不大,切分后的通信延迟会抵消计算带来的速度提升。
|
||||
|
||||
### 3.4 分块预填 (Chunked Prefill)
|
||||
* **背景**:在 Continuous Batching 中,如果一个巨大 Prompt (100k) 进来,它的 Prefill 计算量极其庞大,可能会导致其他小请求被阻塞(卡顿)。
|
||||
* **做法**:把大 Prompt 的 Prefill 阶段切成小块,穿插在小请求的 Decode 阶段之间执行。
|
||||
* **效果**:大幅降低**Latency(卡顿感)**,并降低 Prefill 的**峰值显存占用**,允许调度更多并发请求。
|
||||
|
||||
### 3.5 其他关键优化
|
||||
* **Prefix Caching (前缀缓存)**:如果应用有大量重复的 System Prompt(比如 500 tokens 的角色设定),可以直接复用之前的 KV Cache,不用重新计算。
|
||||
* **Stream Processing (流式处理)**:不用等全部生成完才返回,算出几个 token 就返回给用户,降低“首字延迟” (TTFT)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 实战参数大全 (Cheat Sheet)
|
||||
|
||||
```bash
|
||||
# 1. 加载 70B 模型,4 张 A100-80G
|
||||
# 使用 4 卡切分 (TP=4),自动选择精度 (通常是 FP16)
|
||||
# 最大支持 8k 上下文
|
||||
# 使用 PagedAttention 优化显存 (默认开启)
|
||||
vllm serve Qwen/Qwen2.5-70B-Instruct \
|
||||
--tensor-parallel-size 4 \
|
||||
--dtype auto \
|
||||
--max-model-len 8192 \
|
||||
--gpu-memory-utilization 0.95
|
||||
|
||||
# 2. 量化加载 (如果只有一张卡,想用 INT4 加载 70B)
|
||||
# (需要模型支持 AWQ 格式文件)
|
||||
vllm serve Qwen/Qwen2.5-70B-Instruct-AWQ \
|
||||
--quantization awq
|
||||
```
|
||||
|
||||
| 参数 | 作用 | 调优建议 |
|
||||
|------|------|----------|
|
||||
| `--tensor-parallel-size N` | 多卡切分 (TP) | 大模型 (30B+) 才用。卡越多,通信越慢,单请求延迟越高,但吞吐量越高。 |
|
||||
| `--max-model-len N` | 最大上下文长度 | **越小越好**。显存省得越多,并发请求量 (Batch Size) 越大。按需设置 (如 4096)。 |
|
||||
| `--gpu-memory-utilization` | 显存利用率阈值 | 建议 `0.90` 或 `0.95`。留一些余量给 Activation (激活值) 避免 OOM 崩溃。 |
|
||||
| `--enable-prefix-caching` | 开启前缀缓存 | Agentic 场景 / Long context 场景推荐开启。大幅降低重复 Prompt 的计算时间。 |
|
||||
Reference in New Issue
Block a user