diff --git a/.claude/skills/teach-me/SKILL.md b/.claude/skills/teach-me/SKILL.md index 1900181a1..88c589825 100644 --- a/.claude/skills/teach-me/SKILL.md +++ b/.claude/skills/teach-me/SKILL.md @@ -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 diff --git a/.gitignore b/.gitignore index bf422f8e9..f84c208ba 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ data .codex/skills/.system/** !.codex/prompts/ !.codex/prompts/** +teach-me diff --git a/AGENTS.md b/AGENTS.md index d1404eee6..e856b7770 100644 --- a/AGENTS.md +++ b/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,10 +39,13 @@ 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 +bun test --coverage # with coverage report # Lint & Format (Biome) bun run lint # check only @@ -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=` — 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//`** — 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__/`,文件名 `.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 产品常见的设计套路(渐变文字、玻璃态、霓虹色)。 diff --git a/CLAUDE.md b/CLAUDE.md index a31381bc4..edb3510d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,10 +1,10 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +This file provides guidance to Claude Code (claude.ai/code) and other AI coding agents when working with code in this repository. ## Project Overview -This is a **reverse-engineered / decompiled** version of Anthropic's official Claude Code CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced(见 Working with This Codebase 段的 tsc 要求)。 +This is a **reverse-engineered / decompiled** version of Anthropic's official Claude Code CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced — **`bunx tsc --noEmit` must pass with zero errors**. ## Git Commit Message Convention @@ -43,9 +43,9 @@ bun run build bun run build:vite # Test -bun test # run all tests (3175 tests / 207 files / 0 fail) +bun test # run all tests bun test src/utils/__tests__/hash.test.ts # run single file -bun test --coverage # with coverage report +bun test --coverage # with coverage report # Lint & Format (Biome) bun run lint # check only @@ -60,7 +60,6 @@ bun run check:unused # Full check (typecheck + lint + test) — run after completing any task bun run test:all - bun run typecheck # Remote Control Server @@ -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__/`,文件名 `.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 ``` **类型规范**: diff --git a/README.md b/README.md index e1d74d821..4d4130289 100644 --- a/README.md +++ b/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 ``` diff --git a/README_EN.md b/README_EN.md index 5e3255b3e..68e1dfe4f 100644 --- a/README_EN.md +++ b/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 ``` diff --git a/docs/features/ssh-remote.md b/docs/features/ssh-remote.md new file mode 100644 index 000000000..981dbbb3d --- /dev/null +++ b/docs/features/ssh-remote.md @@ -0,0 +1,426 @@ +# SSH Remote — 远程主机运行 Claude Code + +## 概述 + +SSH Remote 提供两种方式在远程 Linux 主机上运行 Claude Code: + +1. **SSH Remote 模块**(`ccb ssh `)— 本地 REPL + 远程工具执行,自动部署二进制 + 认证隧道 +2. **直接 SSH 运行**(`ssh -t ccb`)— 远程已安装 ccb,直接启动交互式会话 + +## 架构 + +### 方式一:SSH Remote 模块(完整模式) + +适用场景:远端没有 API 凭据或没有安装 ccb。 + +``` +┌──────────────── 本地 Windows/Mac/Linux ───────────┐ +│ │ +│ ccb ssh [dir] │ +│ │ │ +│ ├── 1. SSHProbe: 探测远端平台/架构/已有二进制 │ +│ ├── 2. SSHDeploy: 部署 dist/ 到远端 │ +│ ├── 3. SSHAuthProxy: 启动本地认证代理 │ +│ │ ├─ Unix Socket (Linux/Mac) │ +│ │ └─ TCP 127.0.0.1: (Windows) │ +│ │ │ +│ └── 4. SSH -R 反向隧道 + 启动远端 CLI │ +│ ssh -R : \ │ +│ 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 -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 + +# 选择模型 +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 ~/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 ` 时,模块自动处理: + +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 输出获取详细错误信息 diff --git a/packages/@ant/ink/src/components/App.tsx b/packages/@ant/ink/src/components/App.tsx index 8b7f5bdaa..543cd359b 100644 --- a/packages/@ant/ink/src/components/App.tsx +++ b/packages/@ant/ink/src/components/App.tsx @@ -286,6 +286,15 @@ export default class App extends PureComponent { // 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 + } } } diff --git a/packages/@ant/model-provider/src/shared/__tests__/openaiConvertMessages.test.ts b/packages/@ant/model-provider/src/shared/__tests__/openaiConvertMessages.test.ts index 27c792a5d..974849af9 100644 --- a/packages/@ant/model-provider/src/shared/__tests__/openaiConvertMessages.test.ts +++ b/packages/@ant/model-provider/src/shared/__tests__/openaiConvertMessages.test.ts @@ -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') }) diff --git a/packages/@ant/model-provider/src/shared/openaiConvertMessages.ts b/packages/@ant/model-provider/src/shared/openaiConvertMessages.ts index 2d7cf62ba..286ad55d7 100644 --- a/packages/@ant/model-provider/src/shared/openaiConvertMessages.ts +++ b/packages/@ant/model-provider/src/shared/openaiConvertMessages.ts @@ -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() - 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): 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) .thinking if (typeof thinkingText === 'string' && thinkingText) { diff --git a/packages/builtin-tools/src/tools/AgentTool/loadAgentsDir.ts b/packages/builtin-tools/src/tools/AgentTool/loadAgentsDir.ts index 30cf8bb91..1551dec20 100644 --- a/packages/builtin-tools/src/tools/AgentTool/loadAgentsDir.ts +++ b/packages/builtin-tools/src/tools/AgentTool/loadAgentsDir.ts @@ -394,6 +394,7 @@ export const getAgentDefinitionsWithOverrides = memoize( export function clearAgentDefinitionsCache(): void { getAgentDefinitionsWithOverrides.cache.clear?.() + loadMarkdownFilesForSubdir.cache?.clear?.() clearPluginAgentCache() } diff --git a/packages/builtin-tools/src/tools/FileEditTool/FileEditTool.ts b/packages/builtin-tools/src/tools/FileEditTool/FileEditTool.ts index 42b00676b..0cad34958 100644 --- a/packages/builtin-tools/src/tools/FileEditTool/FileEditTool.ts +++ b/packages/builtin-tools/src/tools/FileEditTool/FileEditTool.ts @@ -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) { diff --git a/packages/builtin-tools/src/tools/FileEditTool/UI.tsx b/packages/builtin-tools/src/tools/FileEditTool/UI.tsx index 3fbd9a34b..417ffa3f5 100644 --- a/packages/builtin-tools/src/tools/FileEditTool/UI.tsx +++ b/packages/builtin-tools/src/tools/FileEditTool/UI.tsx @@ -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 ( - - File must be read first - - ) - } if (errorMessage?.includes(FILE_NOT_FOUND_CWD_NOTE)) { return ( diff --git a/packages/builtin-tools/src/tools/FileWriteTool/FileWriteTool.ts b/packages/builtin-tools/src/tools/FileWriteTool/FileWriteTool.ts index 399bab62e..009207472 100644 --- a/packages/builtin-tools/src/tools/FileWriteTool/FileWriteTool.ts +++ b/packages/builtin-tools/src/tools/FileWriteTool/FileWriteTool.ts @@ -196,25 +196,18 @@ export const FileWriteTool = buildTool({ } const readTimestamp = toolUseContext.readFileState.get(fullFilePath) - if (!readTimestamp || readTimestamp.isPartialView) { - return { - result: false, - message: - 'File has not been read yet. Read it first before writing to it.', - errorCode: 2, - } - } // Reuse mtime from the stat above — avoids a redundant statSync via - // getFileModificationTime. The readTimestamp guard above ensures this - // block is always reached when the file exists. - const lastWriteTime = Math.floor(fileMtimeMs) - if (lastWriteTime > readTimestamp.timestamp) { - return { - result: false, - message: - 'File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.', - errorCode: 3, + // getFileModificationTime. + if (readTimestamp) { + const lastWriteTime = Math.floor(fileMtimeMs) + if (lastWriteTime > readTimestamp.timestamp) { + return { + result: false, + message: + 'File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.', + errorCode: 3, + } } } diff --git a/scripts/defines.ts b/scripts/defines.ts index 804935419..480251603 100644 --- a/scripts/defines.ts +++ b/scripts/defines.ts @@ -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; diff --git a/src/bootstrap/state.ts b/src/bootstrap/state.ts index 66702cadf..90d613b61 100644 --- a/src/bootstrap/state.ts +++ b/src/bootstrap/state.ts @@ -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 { diff --git a/src/components/agents/new-agent-creation/wizard-steps/ConfirmStepWrapper.tsx b/src/components/agents/new-agent-creation/wizard-steps/ConfirmStepWrapper.tsx index b1e391e7f..b5bd87c68 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/ConfirmStepWrapper.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/ConfirmStepWrapper.tsx @@ -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!, diff --git a/src/constants/prompts.ts b/src/constants/prompts.ts index ea8a5dc02..02b68f94f 100644 --- a/src/constants/prompts.ts +++ b/src/constants/prompts.ts @@ -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 diff --git a/src/main.tsx b/src/main.tsx index c4588b1b2..0b13c182e 100644 --- a/src/main.tsx +++ b/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 { dangerouslySkipPermissions: _pendingSSH.dangerouslySkipPermissions, extraCliArgs: _pendingSSH.extraCliArgs, + remoteBin: _pendingSSH.remoteBin, }, isTTY ? { @@ -5980,6 +5994,11 @@ async function run(): Promise { "--dangerously-skip-permissions", "Skip all permission prompts on the remote (dangerous)", ) + .option( + "--remote-bin ", + "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). " + diff --git a/src/services/api/claude.ts b/src/services/api/claude.ts index ddc814809..ec4dfaeab 100644 --- a/src/services/api/claude.ts +++ b/src/services/api/claude.ts @@ -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 = diff --git a/src/services/api/client.ts b/src/services/api/client.ts index b01efc2d9..f433fe013 100644 --- a/src/services/api/client.ts +++ b/src/services/api/client.ts @@ -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 diff --git a/src/services/api/openai/__tests__/thinking.test.ts b/src/services/api/openai/__tests__/thinking.test.ts index 9b8433282..5a51451a5 100644 --- a/src/services/api/openai/__tests__/thinking.test.ts +++ b/src/services/api/openai/__tests__/thinking.test.ts @@ -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', () => { diff --git a/src/services/api/openai/requestBody.ts b/src/services/api/openai/requestBody.ts index e8f93ecfa..09163c834 100644 --- a/src/services/api/openai/requestBody.ts +++ b/src/services/api/openai/requestBody.ts @@ -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') } /** diff --git a/src/services/api/src/bootstrap/state.ts b/src/services/api/src/bootstrap/state.ts index 24331fe0d..ec9794128 100644 --- a/src/services/api/src/bootstrap/state.ts +++ b/src/services/api/src/bootstrap/state.ts @@ -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; diff --git a/src/ssh/SSHAuthProxy.ts b/src/ssh/SSHAuthProxy.ts new file mode 100644 index 000000000..4b16f3c6b --- /dev/null +++ b/src/ssh/SSHAuthProxy.ts @@ -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: */ + localAddress: string + /** Environment variables to inject into the remote/child CLI process */ + authEnv: Record +} + +const isWindows = process.platform === 'win32' + +function resolveAuthHeaders(): Record { + 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 { + 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 { + const id = randomUUID() + + if (isWindows) { + return createTcpAuthProxy(id) + } + return createUnixSocketAuthProxy(id) +} + +async function createUnixSocketAuthProxy(id: string): Promise { + 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 { + 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, + }, + } +} diff --git a/src/ssh/SSHDeploy.ts b/src/ssh/SSHDeploy.ts new file mode 100644 index 000000000..fbddb4b18 --- /dev/null +++ b/src/ssh/SSHDeploy.ts @@ -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 { + 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}` +} diff --git a/src/ssh/SSHProbe.ts b/src/ssh/SSHProbe.ts new file mode 100644 index 000000000..adb074ff1 --- /dev/null +++ b/src/ssh/SSHProbe.ts @@ -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 { + 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((_, 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, + } +} diff --git a/src/ssh/SSHSessionManager.ts b/src/ssh/SSHSessionManager.ts index 6a2faaefa..47741345c 100644 --- a/src/ssh/SSHSessionManager.ts +++ b/src/ssh/SSHSessionManager.ts @@ -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 + maxReconnectAttempts?: number } export interface SSHPermissionRequest { @@ -26,5 +37,317 @@ export interface SSHSessionManager { disconnect(): void sendMessage(content: RemoteMessageContent): Promise 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).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 { + const stdout = this.proc.stdout + if (!stdout) { + this.options.onError(new Error('SSH process stdout is not available')) + return + } + + const reader = (stdout as ReadableStream).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 { + 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 { + 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(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).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 { + 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 + } } diff --git a/src/ssh/__tests__/SSHSessionManager.test.ts b/src/ssh/__tests__/SSHSessionManager.test.ts new file mode 100644 index 000000000..1f169abc5 --- /dev/null +++ b/src/ssh/__tests__/SSHSessionManager.test.ts @@ -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 + const exitResolvers: Array<(code: number) => void> = [] + let exitCode: number | null = options?.exitCode ?? null + + const stdout = new ReadableStream({ + 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(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 & { 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).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).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).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 } + 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) +}) diff --git a/src/ssh/createSSHSession.ts b/src/ssh/createSSHSession.ts index 1db14a1f3..fa10844dd 100644 --- a/src/ssh/createSSHSession.ts +++ b/src/ssh/createSSHSession.ts @@ -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 = (async () => { - throw new SSHSessionError('SSH sessions are not supported in this build') -}); -export const createLocalSSHSession: (...args: unknown[]) => Promise = (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 { + 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(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 => { + 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 { + 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 => { + 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 { + const stdout = proc.stdout + if (!stdout) { + throw new SSHSessionError('Child process stdout is not readable') + } + + const reader = (stdout as ReadableStream).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 + 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).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 + } + })() +} diff --git a/src/utils/earlyInput.ts b/src/utils/earlyInput.ts index a5d58db5e..3d8d03554 100644 --- a/src/utils/earlyInput.ts +++ b/src/utils/earlyInput.ts @@ -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 | 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. } /** diff --git a/src/utils/effort.ts b/src/utils/effort.ts index bb920b38c..4cf530995 100644 --- a/src/utils/effort.ts +++ b/src/utils/effort.ts @@ -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' } } diff --git a/teach-me/learner-profile.md b/teach-me/learner-profile.md new file mode 100644 index 000000000..69272d71d --- /dev/null +++ b/teach-me/learner-profile.md @@ -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) diff --git a/teach-me/vllm/session.md b/teach-me/vllm/session.md new file mode 100644 index 000000000..d8b9d7773 --- /dev/null +++ b/teach-me/vllm/session.md @@ -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). diff --git a/teach-me/vllm/vllm-notes.md b/teach-me/vllm/vllm-notes.md new file mode 100644 index 000000000..fabc678a1 --- /dev/null +++ b/teach-me/vllm/vllm-notes.md @@ -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 的计算时间。 |