Update README.MD and add nano-claude-code v3.0 + original-source-code/src
- README.MD: add original-source-code and nano-claude-code sections, update overview table (4 subprojects), add v3.0 news entry, expand comparison table with memory/multi-agent/skills dimensions - nano-claude-code v3.0: multi-agent package (multi_agent/), memory package (memory/), skill package (skill/) with built-in /commit and /review skills, context compression (compaction.py), tool registry plugin system, diff view, 17 slash commands, 18 built-in tools, 101 tests (~5000 lines total) - original-source-code/src: add raw TypeScript source tree (1884 files) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
193
README.MD
193
README.MD
@@ -4,6 +4,7 @@
|
||||
> Source archive of Claude Code and a clean-room Python rewrite research repository
|
||||
|
||||
## 🔥🔥🔥 News (Pacific Time)
|
||||
- 12:20 PM · Apr 02, 2026: [Nano Claude Code v3.0: Multi-agent packages, memory package, skill package with built-in skills, argument substitution, fork/inline execution, AI memory search, git worktree isolation, agent type definitions (~5000 Lines)](https://github.com/SafeRL-Lab/nano-claude-code)
|
||||
- 7:40 AM · Apr 02, 2026: [Nano Claude Code v2.0: A Minimal Python Reimplementation (~3400 Lines), support open and closed source models, skill and memory](https://github.com/SafeRL-Lab/nano-claude-code)
|
||||
- 8:36 AM · Apr 01, 2026: [Nano Claude Code v1.0: A Minimal Python Reimplementation (~1300 Lines)](https://github.com/SafeRL-Lab/nano-claude-code)
|
||||
- 0:20 AM · Apr 01, 2026: [Analysis of Claude Code source code (Video: In Chinese)](https://www.youtube.com/watch?v=xsg6_Gvr2J0&t=10s)
|
||||
@@ -27,7 +28,8 @@
|
||||
|
||||
## Content
|
||||
|
||||
* [1. claude-code-source-code](#1-claude-code-source-code)
|
||||
* [1. original-source-code](#1-original-source-code)
|
||||
* [2. claude-code-source-code](#2-claude-code-source-code)
|
||||
+ [Overall Architecture](#overall-architecture)
|
||||
+ [Core Execution Flow](#core-execution-flow)
|
||||
+ [Tech Stack](#tech-stack)
|
||||
@@ -37,29 +39,57 @@
|
||||
- [Permission System](#permission-system)
|
||||
- [Context Management](#context-management)
|
||||
- [Analysis Documents (`docs/`)](#analysis-documents---docs---)
|
||||
* [2. claw-code](#2-claw-code)
|
||||
* [3. claw-code](#3-claw-code)
|
||||
+ [Overall Architecture](#overall-architecture-1)
|
||||
+ [Core Classes](#core-classes)
|
||||
+ [CLI Commands](#cli-commands)
|
||||
+ [Design Features](#design-features)
|
||||
* [Comparison of the Projects](#comparison-of-the-two-projects)
|
||||
* [4. nano-claude-code](#4-nano-claude-code)
|
||||
+ [Features](#features)
|
||||
+ [Supported Models](#supported-models)
|
||||
+ [Project Structure](#project-structure)
|
||||
* [Comparison of the Projects](#comparison-of-the-projects)
|
||||
* [License and Disclaimer](#license-and-disclaimer)
|
||||
|
||||
|
||||
|
||||
---
|
||||
This repository contains subprojects that study Claude Code (Anthropic’s official CLI tool: source claude code) from two different angles:
|
||||
This repository contains subprojects that study Claude Code (Anthropic’s official CLI tool) from multiple angles:
|
||||
|
||||
| Subproject | Language | Nature | File Count |
|
||||
| ----------------------------------------------------- | ---------- | ----------------------------------- | ----------- |
|
||||
| [claude-code-source-code](#1-claude-code-source-code) | TypeScript | Decompiled source archive (v2.1.88) | 1,884 files |
|
||||
| [claw-code](#2-claw-code) | Python | Clean-room architectural rewrite | 66 files |
|
||||
| Subproject | Language | Nature | File Count |
|
||||
| ------------------------------------------------------- | ---------- | ----------------------------------------- | ----------- |
|
||||
| [original-source-code](#1-original-source-code) | TypeScript | Raw leaked source archive | 1,884 files |
|
||||
| [claude-code-source-code](#2-claude-code-source-code) | TypeScript | Decompiled source archive (v2.1.88) + docs| 1,940 files |
|
||||
| [claw-code](#3-claw-code) | Python | Clean-room architectural rewrite | 109 files |
|
||||
| [nano-claude-code](#4-nano-claude-code) | Python | Minimal multi-provider reimplementation | ~30 files |
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## 1. claude-code-source-code
|
||||
## 1. original-source-code
|
||||
|
||||
The raw leaked TypeScript source of Claude Code, preserved as-is from the original exposure on March 31, 2026. Contains 1,884 TypeScript/TSX files (packaged as `src.zip`) spanning the full `src/` directory tree — the same files that triggered community discussion and downstream research.
|
||||
|
||||
```text
|
||||
original-source-code/
|
||||
├── src/ # Full TypeScript source tree (1,884 .ts/.tsx files)
|
||||
│ ├── main.tsx # CLI entry point
|
||||
│ ├── query.ts # Core agent loop
|
||||
│ ├── commands.ts # Slash command definitions
|
||||
│ ├── tools.ts # Tool registration
|
||||
│ └── ... # All other source directories (same layout as claude-code-source-code/src)
|
||||
├── src.zip # Compressed archive (~9.5 MB)
|
||||
└── readme.md
|
||||
```
|
||||
|
||||
This directory serves as the unmodified reference snapshot. No annotations, docs, or build tooling have been added — use `claude-code-source-code` for the researched and annotated version.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## 2. claude-code-source-code
|
||||
|
||||
A decompiled/unpacked source archive of Claude Code v2.1.88, reconstructed from the npm package `@anthropic-ai/claude-code@2.1.88`, containing approximately 163,318 lines of TypeScript code.
|
||||
|
||||
@@ -186,7 +216,7 @@ yield SDKMessage # Stream results back to the consumer
|
||||
|
||||
---
|
||||
|
||||
## 2. claw-code
|
||||
## 3. claw-code
|
||||
|
||||
A clean-room Python rewrite of Claude Code (without including original source copies), focused on architectural mirroring and research. Built by [@instructkr](https://github.com/instructkr) (Sigrid Jin), and became one of the fastest GitHub repositories in the world to reach 30K stars.
|
||||
|
||||
@@ -292,26 +322,143 @@ tool-pool # Tool pool assembly view
|
||||
* **Snapshot-driven**: command/tool metadata is loaded through JSON snapshots without requiring full logical implementations
|
||||
* **Clean-room rewrite**: does not include original TypeScript code; independently implemented
|
||||
* **Parity audit**: built-in `parity_audit.py` tracks gaps from the original implementation
|
||||
* **Lightweight architecture**: core framework implemented in 66 files, suitable for learning and extension
|
||||
* **Lightweight architecture**: core framework implemented in 109 files, suitable for learning and extension
|
||||
|
||||
---
|
||||
|
||||
## Comparison of the Two Projects
|
||||
## 4. nano-claude-code
|
||||
|
||||
| Dimension | claude-code-source-code | claw-code |
|
||||
| ----------------------- | ----------------------------------------- | ------------------------------------------------ |
|
||||
| Language | TypeScript | Python |
|
||||
| Code Size | ~163,000 lines | ~5,000 lines |
|
||||
| Nature | Decompiled source archive | Clean-room architectural rewrite |
|
||||
| Functional Completeness | Complete (100%) | Architectural framework (~20%) |
|
||||
| Core Loop | `query.ts` (785KB) | `QueryEnginePort` (~200 lines) |
|
||||
| Tool System | 40+ fully implemented tools | Snapshot metadata + execution framework |
|
||||
| Command System | ~87 fully implemented commands | Snapshot metadata + execution framework |
|
||||
| Main Use Case | Deep study of full implementation details | Architectural understanding and porting research |
|
||||
A minimal, fully-runnable Python reimplementation of Claude Code (~5,000 lines). Unlike `claw-code` (which focuses on architectural mapping), nano-claude-code is a real coding assistant that can be used immediately. It supports 20+ closed-source models and local open-source models, and has grown from a ~900-line prototype to a feature-rich v3.0 with multi-agent orchestration, persistent memory, and a skill system.
|
||||
|
||||
### Features
|
||||
|
||||
| Feature | Details |
|
||||
| ------- | ------- |
|
||||
| Multi-provider | Anthropic · OpenAI · Gemini · Kimi · Qwen · Zhipu · DeepSeek · Ollama · LM Studio · Custom endpoint |
|
||||
| Interactive REPL | readline history, Tab-complete slash commands |
|
||||
| Agent loop | Streaming API + automatic tool-use loop |
|
||||
| 18 built-in tools | Read · Write · Edit · Bash · Glob · Grep · WebFetch · WebSearch · MemorySave · MemoryDelete · MemorySearch · MemoryList · Agent · SendMessage · CheckAgentResult · ListAgentTasks · ListAgentTypes · Skill · SkillList |
|
||||
| Diff view | Git-style red/green diff display for Edit and Write |
|
||||
| Context compression | Auto-compact long conversations to stay within model limits |
|
||||
| Persistent memory | Dual-scope memory (user + project) with 4 types, AI search, staleness warnings |
|
||||
| Multi-agent | Spawn typed sub-agents (coder/reviewer/researcher/…), git worktree isolation, background mode |
|
||||
| Skills | Built-in `/commit` · `/review` + custom markdown skills with argument substitution and fork/inline execution |
|
||||
| Plugin tools | Register custom tools via `tool_registry.py` |
|
||||
| Permission system | `auto` / `accept-all` / `manual` modes |
|
||||
| 17 slash commands | `/model` · `/config` · `/save` · `/cost` · `/memory` · `/skills` · `/agents` · … |
|
||||
| Context injection | Auto-loads `CLAUDE.md`, git status, cwd, persistent memory |
|
||||
| Session persistence | Save / load conversations to `~/.nano_claude/sessions/` |
|
||||
| Extended Thinking | Toggle on/off (Claude models only) |
|
||||
| Cost tracking | Token usage + estimated USD cost |
|
||||
| Non-interactive mode | `--print` flag for scripting / CI |
|
||||
|
||||
### Supported Models
|
||||
|
||||
**Closed-source (API):** Claude (Anthropic), GPT / o-series (OpenAI), Gemini (Google), Kimi (Moonshot AI), Qwen (Alibaba DashScope), GLM (Zhipu), DeepSeek
|
||||
|
||||
**Open-source (local via Ollama):** llama3.3/3.2, qwen2.5-coder, deepseek-r1, phi4, mistral, mixtral, gemma3, codellama, and any model on `ollama list`
|
||||
|
||||
**Self-hosted:** vLLM, LM Studio, or any OpenAI-compatible endpoint via `CUSTOM_BASE_URL`
|
||||
|
||||
### Project Structure
|
||||
|
||||
```text
|
||||
nano-claude-code/
|
||||
├── nano_claude.py # Entry point: REPL + slash commands + rendering (~748 lines)
|
||||
├── agent.py # Agent loop: message format + tool dispatch (~174 lines)
|
||||
├── providers.py # Multi-provider adapters + message conversion (~507 lines)
|
||||
├── tools.py # Tool dispatch + auto-registration of all packages (~467 lines)
|
||||
├── tool_registry.py # Central tool registry + plugin entry point (~98 lines)
|
||||
├── context.py # System prompt builder: CLAUDE.md + git + memory (~135 lines)
|
||||
├── compaction.py # Context compression (auto-compact) (~196 lines)
|
||||
├── config.py # Config load/save/defaults (~72 lines)
|
||||
├── memory.py # Backward-compat shim → memory/
|
||||
├── skills.py # Backward-compat shim → skill/
|
||||
├── subagent.py # Backward-compat shim → multi_agent/
|
||||
│
|
||||
├── memory/ # Persistent memory package
|
||||
│ ├── store.py # Save/load/delete/search memory entries
|
||||
│ ├── scan.py # Index scanning, age/freshness helpers
|
||||
│ ├── context.py # System-prompt injection + AI-ranked search
|
||||
│ ├── types.py # MEMORY_TYPES definitions
|
||||
│ └── tools.py # MemorySave · MemoryDelete · MemorySearch · MemoryList
|
||||
│
|
||||
├── skill/ # Skill system package
|
||||
│ ├── loader.py # SkillDef, file parsing, argument substitution
|
||||
│ ├── builtin.py # Built-in skills: /commit, /review
|
||||
│ ├── executor.py # Inline + fork execution modes
|
||||
│ └── tools.py # Skill · SkillList
|
||||
│
|
||||
├── multi_agent/ # Multi-agent orchestration package
|
||||
│ ├── subagent.py # AgentDefinition, SubAgentTask, SubAgentManager, worktree helpers
|
||||
│ └── tools.py # Agent · SendMessage · CheckAgentResult · ListAgentTasks · ListAgentTypes
|
||||
│
|
||||
├── tests/ # 101 tests (monkeypatched, no real ~/.nano_claude touched)
|
||||
├── docs/ # Docs and demo assets
|
||||
└── requirements.txt
|
||||
```
|
||||
|
||||
**Quick start:**
|
||||
|
||||
```bash
|
||||
pip install anthropic openai httpx rich
|
||||
export ANTHROPIC_API_KEY=sk-ant-...
|
||||
python nano_claude.py
|
||||
|
||||
# Switch provider at startup
|
||||
python nano_claude.py --model gpt-4o
|
||||
python nano_claude.py --model ollama/qwen2.5-coder
|
||||
|
||||
# Non-interactive / CI
|
||||
python nano_claude.py --print "Write a Python fibonacci function" --accept-all
|
||||
```
|
||||
|
||||
**Memory** — persistent across sessions, dual-scope (user `~/.nano_claude/memory/` and project `.nano_claude/memory/`):
|
||||
|
||||
```
|
||||
/memory # list all memories with staleness info
|
||||
MemorySave(name="...", type="feedback", content="...", scope="user")
|
||||
MemorySearch(query="...", use_ai=True)
|
||||
```
|
||||
|
||||
**Skills** — reusable prompt templates, invoke from REPL:
|
||||
|
||||
```
|
||||
/commit # built-in: review staged changes and create a git commit
|
||||
/review 123 # built-in: review PR #123
|
||||
/skills # list all available skills with triggers and hints
|
||||
```
|
||||
|
||||
**Multi-agent** — spawn typed sub-agents with optional git worktree isolation:
|
||||
|
||||
```
|
||||
Agent(prompt="...", subagent_type="coder", isolation="worktree", wait=False)
|
||||
SendMessage(agent_name="my-agent", message="...")
|
||||
/agents # show all active and finished sub-agent tasks
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comparison of the Projects
|
||||
|
||||
| Dimension | original-source-code | claude-code-source-code | claw-code | nano-claude-code |
|
||||
| ----------------------- | ------------------------------ | ----------------------------------------- | ------------------------------------------------ | --------------------------------------------- |
|
||||
| Language | TypeScript | TypeScript | Python | Python |
|
||||
| Code Size | ~163,000 lines | ~163,000 lines + docs | ~5,000 lines | ~5,000 lines |
|
||||
| Nature | Raw leaked source archive | Decompiled source archive + analysis | Clean-room architectural rewrite | Minimal functional reimplementation |
|
||||
| Functional Completeness | Complete (100%) | Complete (100%) | Architectural framework (~20%) | Core loop + memory + multi-agent + skills |
|
||||
| Core Loop | `query.ts` (785KB) | `query.ts` (785KB) | `QueryEnginePort` (~200 lines) | `agent.py` (~174 lines) |
|
||||
| Tool System | 40+ fully implemented tools | 40+ fully implemented tools | Snapshot metadata + execution framework | 18 fully implemented tools + plugin registry |
|
||||
| Memory System | Yes (7-layer, complex) | Yes (7-layer, complex) | No | Yes (dual-scope, 4 types, AI search) |
|
||||
| Multi-agent | Yes (full coordinator) | Yes (full coordinator) | No | Yes (typed agents, worktree isolation) |
|
||||
| Skills | Yes (~87 commands) | Yes (~87 commands) | Snapshot metadata only | Yes (built-in + custom markdown skills) |
|
||||
| Multi-provider | No (Anthropic only) | No (Anthropic only) | No | Yes (10+ providers) |
|
||||
| Immediately Runnable | No | No | Limited (CLI metadata only) | Yes |
|
||||
| Main Use Case | Raw reference snapshot | Deep study of full implementation details | Architectural understanding and porting research | Lightweight full-featured coding assistant |
|
||||
|
||||
---
|
||||
|
||||
## License and Disclaimer
|
||||
|
||||
This repository is for academic research and educational purposes only. Both subprojects are built from publicly accessible information. Users are responsible for complying with applicable laws, regulations, and service terms.
|
||||
This repository is for academic research and educational purposes only. All subprojects are built from publicly accessible information. Users are responsible for complying with applicable laws, regulations, and service terms.
|
||||
|
||||
|
||||
2
nano-claude-code/.gitignore
vendored
Normal file
2
nano-claude-code/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -1,9 +1,10 @@
|
||||
|
||||
|
||||
<div align="center">
|
||||
<a href="https://github.com/SafeRL-Lab/nano-claude-code">
|
||||
<img src="https://github.com/SafeRL-Lab/nano-claude-code/blob/main/docs/demo.gif" alt="Logo" width="800">
|
||||
<a href="[https://github.com/SafeRL-Lab/Robust-Gymnasium](https://github.com/SafeRL-Lab/nano-claude-code)">
|
||||
<img src="https://github.com/SafeRL-Lab/nano-claude-code/blob/main/docs/logo-v1.png" alt="Logo" width="280">
|
||||
</a>
|
||||
|
||||
|
||||
<h1 align="center" style="font-size: 30px;"><strong><em>Nano Claude Code</em></strong>: A Minimal Python Reimplementation</h1>
|
||||
<p align="center">
|
||||
@@ -13,18 +14,27 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div align=center>
|
||||
<img src="https://github.com/SafeRL-Lab/nano-claude-code/blob/main/docs/demo.gif" width="850"/>
|
||||
</div>
|
||||
<div align=center>
|
||||
<center style="color:#000000;text-decoration:underline"> </center>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 🔥🔥🔥 News (Pacific Time)
|
||||
- 01:47 PM, Apr 01, 2026: Support VLLM inference (**~2000** lines of Python Code)
|
||||
- 11:30 AM, Apr 01, 2026: Support more **closed-source** models and **open-source models**: Claude, GPT, Gemini, Kimi, Qwen, Zhipu, DeepSeek, and local open-source models via Ollama or any OpenAI-compatible endpoint. (**~1700** lines of Python Code)
|
||||
- 09:50 AM, Apr 01, 2026: Support more **closed-source** models: Claude, GPT, Gemini. (**~1300** lines of Python Code)
|
||||
- 08:23 AM, Apr 01, 2026: Release the initial version of Nano Claude Code (**~900 lines** of Python Code)
|
||||
- 12:20 PM, Apr 02, 2026: **v3.0** — Multi-agent packages (`multi_agent/`), memory package (`memory/`), skill package (`skill/`) with built-in skills, argument substitution, fork/inline execution, AI memory search, git worktree isolation, agent type definitions (**~5000** lines of Python), see [update](https://github.com/SafeRL-Lab/nano-claude-code/blob/main/Update_README.MD).
|
||||
- 10:00 AM, Apr 02, 2026: **v2.0** — Context compression, memory, sub-agents, skills, diff view, tool plugin system (**~3400** lines of Python Code).
|
||||
- 01:47 PM, Apr 01, 2026: Support VLLM inference (**~2000** lines of Python Code).
|
||||
- 11:30 AM, Apr 01, 2026: Support more **closed-source** models and **open-source models**: Claude, GPT, Gemini, Kimi, Qwen, Zhipu, DeepSeek, and local open-source models via Ollama or any OpenAI-compatible endpoint. (**~1700** lines of Python Code).
|
||||
- 09:50 AM, Apr 01, 2026: Support more **closed-source** models: Claude, GPT, Gemini. (**~1300** lines of Python Code).
|
||||
- 08:23 AM, Apr 01, 2026: Release the initial version of Nano Claude Code (**~900 lines** of Python Code).
|
||||
|
||||
---
|
||||
|
||||
# Nano Claude Code
|
||||
|
||||

|
||||
|
||||
A minimal Python implementation of Claude Code in ~900 lines (Initial version), **supporting Claude, GPT, Gemini, Kimi, Qwen, Zhipu, DeepSeek, and local open-source models via Ollama or any OpenAI-compatible endpoint.**
|
||||
|
||||
---
|
||||
@@ -32,30 +42,20 @@ A minimal Python implementation of Claude Code in ~900 lines (Initial version),
|
||||
## Content
|
||||
* [Features](#features)
|
||||
* [Supported Models](#supported-models)
|
||||
+ [Closed-Source (API)](#closed-source--api-)
|
||||
+ [Open-Source (Local via Ollama)](#open-source--local-via-ollama-)
|
||||
* [Installation](#installation)
|
||||
* [Usage: Closed-Source API Models](#usage--closed-source-api-models)
|
||||
+ [Anthropic Claude](#anthropic-claude)
|
||||
+ [OpenAI GPT](#openai-gpt)
|
||||
+ [Google Gemini](#google-gemini)
|
||||
+ [Kimi (Moonshot AI)](#kimi--moonshot-ai-)
|
||||
+ [Qwen (Alibaba DashScope)](#qwen--alibaba-dashscope-)
|
||||
+ [Zhipu GLM](#zhipu-glm)
|
||||
+ [DeepSeek](#deepseek)
|
||||
* [Usage: Open-Source Models (Local)](#usage--open-source-models--local-)
|
||||
+ [Option A — Ollama (Recommended)](#option-a---ollama--recommended-)
|
||||
+ [Option B — LM Studio](#option-b---lm-studio)
|
||||
+ [Option C — vLLM / Self-Hosted OpenAI-Compatible Server](#option-c---vllm---self-hosted-openai-compatible-server)
|
||||
* [Model Name Format](#model-name-format)
|
||||
* [CLI Reference](#cli-reference)
|
||||
* [Slash Commands (REPL)](#slash-commands--repl-)
|
||||
* [Configuring API Keys](#configuring-api-keys)
|
||||
+ [Method 1: Environment Variables (recommended)](#method-1--environment-variables--recommended-)
|
||||
+ [Method 2: Set Inside the REPL (persisted)](#method-2--set-inside-the-repl--persisted-)
|
||||
+ [Method 3: Edit the Config File Directly](#method-3--edit-the-config-file-directly)
|
||||
* [Permission System](#permission-system)
|
||||
* [Built-in Tools](#built-in-tools)
|
||||
* [Memory](#memory)
|
||||
* [Skills](#skills)
|
||||
* [Sub-Agents](#sub-agents)
|
||||
* [Context Compression](#context-compression)
|
||||
* [Diff View](#diff-view)
|
||||
* [CLAUDE.md Support](#claudemd-support)
|
||||
* [Session Management](#session-management)
|
||||
* [Project Structure](#project-structure)
|
||||
@@ -71,10 +71,16 @@ A minimal Python implementation of Claude Code in ~900 lines (Initial version),
|
||||
| Multi-provider | Anthropic · OpenAI · Gemini · Kimi · Qwen · Zhipu · DeepSeek · Ollama · LM Studio · Custom endpoint |
|
||||
| Interactive REPL | readline history, Tab-complete slash commands |
|
||||
| Agent loop | Streaming API + automatic tool-use loop |
|
||||
| 8 built-in tools | Read · Write · Edit · Bash · Glob · Grep · WebFetch · WebSearch |
|
||||
| 18 built-in tools | Read · Write · Edit · Bash · Glob · Grep · WebFetch · WebSearch · MemorySave · MemoryDelete · MemorySearch · MemoryList · Agent · SendMessage · CheckAgentResult · ListAgentTasks · ListAgentTypes · Skill · SkillList |
|
||||
| Diff view | Git-style red/green diff display for Edit and Write |
|
||||
| Context compression | Auto-compact long conversations to stay within model limits |
|
||||
| Persistent memory | Dual-scope memory (user + project) with 4 types, AI search, staleness warnings |
|
||||
| Multi-agent | Spawn typed sub-agents (coder/reviewer/researcher/…), git worktree isolation, background mode |
|
||||
| Skills | Built-in `/commit` · `/review` + custom markdown skills with argument substitution and fork/inline execution |
|
||||
| Plugin tools | Register custom tools via `tool_registry.py` |
|
||||
| Permission system | `auto` / `accept-all` / `manual` modes |
|
||||
| 14 slash commands | `/model` · `/config` · `/save` · `/cost` · … |
|
||||
| Context injection | Auto-loads `CLAUDE.md`, git status, cwd |
|
||||
| 17 slash commands | `/model` · `/config` · `/save` · `/cost` · `/memory` · `/skills` · `/agents` · … |
|
||||
| Context injection | Auto-loads `CLAUDE.md`, git status, cwd, persistent memory |
|
||||
| Session persistence | Save / load conversations to `~/.nano_claude/sessions/` |
|
||||
| Extended Thinking | Toggle on/off (Claude models only) |
|
||||
| Cost tracking | Token usage + estimated USD cost |
|
||||
@@ -173,6 +179,7 @@ export OPENAI_API_KEY=sk-...
|
||||
|
||||
python nano_claude.py --model gpt-4o
|
||||
python nano_claude.py --model gpt-4o-mini
|
||||
python nano_claude.py --model gpt-4.1-mini
|
||||
python nano_claude.py --model o3-mini
|
||||
```
|
||||
|
||||
@@ -206,9 +213,9 @@ Get your API key at [dashscope.aliyun.com](https://dashscope.aliyun.com).
|
||||
```bash
|
||||
export DASHSCOPE_API_KEY=sk-...
|
||||
|
||||
python nano_claude.py --model qwen/qwen-max
|
||||
python nano_claude.py --model qwen/qwq-32b
|
||||
python nano_claude.py --model qwen/qwen2.5-coder-32b-instruct
|
||||
python nano_claude.py --model qwen/Qwen3.5-Plus
|
||||
python nano_claude.py --model qwen/Qwen3-MAX
|
||||
python nano_claude.py --model qwen/Qwen3.5-Flash
|
||||
```
|
||||
|
||||
### Zhipu GLM
|
||||
@@ -478,6 +485,10 @@ Type `/` and press **Tab** to autocomplete.
|
||||
| `/permissions <mode>` | Set permission mode: `auto` / `accept-all` / `manual` |
|
||||
| `/cwd` | Show current working directory |
|
||||
| `/cwd <path>` | Change working directory |
|
||||
| `/memory` | List all persistent memories |
|
||||
| `/memory <query>` | Search memories by keyword |
|
||||
| `/skills` | List available skills |
|
||||
| `/agents` | Show sub-agent task status |
|
||||
| `/exit` / `/quit` | Exit |
|
||||
|
||||
**Switching models inside a session:**
|
||||
@@ -573,17 +584,247 @@ Keys are saved to `~/.nano_claude/config.json` and loaded automatically on next
|
||||
|
||||
## Built-in Tools
|
||||
|
||||
### Core Tools
|
||||
|
||||
| Tool | Description | Key Parameters |
|
||||
|---|---|---|
|
||||
| `Read` | Read file with line numbers | `file_path`, `limit`, `offset` |
|
||||
| `Write` | Create or overwrite file | `file_path`, `content` |
|
||||
| `Edit` | Exact string replacement in file | `file_path`, `old_string`, `new_string`, `replace_all` |
|
||||
| `Write` | Create or overwrite file (shows diff) | `file_path`, `content` |
|
||||
| `Edit` | Exact string replacement (shows diff) | `file_path`, `old_string`, `new_string`, `replace_all` |
|
||||
| `Bash` | Execute shell command | `command`, `timeout` (default 30s) |
|
||||
| `Glob` | Find files by glob pattern | `pattern` (e.g. `**/*.py`), `path` |
|
||||
| `Grep` | Regex search in files (uses ripgrep if available) | `pattern`, `path`, `glob`, `output_mode` |
|
||||
| `WebFetch` | Fetch and extract text from URL | `url`, `prompt` |
|
||||
| `WebSearch` | Search the web via DuckDuckGo | `query` |
|
||||
|
||||
### Memory Tools
|
||||
|
||||
| Tool | Description | Key Parameters |
|
||||
|---|---|---|
|
||||
| `MemorySave` | Save or update a persistent memory | `name`, `type`, `description`, `content`, `scope` |
|
||||
| `MemoryDelete` | Delete a memory by name | `name`, `scope` |
|
||||
| `MemorySearch` | Search memories by keyword (or AI ranking) | `query`, `scope`, `use_ai`, `max_results` |
|
||||
| `MemoryList` | List all memories with age and metadata | `scope` |
|
||||
|
||||
### Sub-Agent Tools
|
||||
|
||||
| Tool | Description | Key Parameters |
|
||||
|---|---|---|
|
||||
| `Agent` | Spawn a sub-agent for a task | `prompt`, `subagent_type`, `isolation`, `name`, `model`, `wait` |
|
||||
| `SendMessage` | Send a message to a named background agent | `name`, `message` |
|
||||
| `CheckAgentResult` | Check status/result of a background agent | `task_id` |
|
||||
| `ListAgentTasks` | List all active and finished agent tasks | — |
|
||||
| `ListAgentTypes` | List available agent type definitions | — |
|
||||
|
||||
### Skill Tools
|
||||
|
||||
| Tool | Description | Key Parameters |
|
||||
|---|---|---|
|
||||
| `Skill` | Invoke a skill by name from within the conversation | `name`, `args` |
|
||||
| `SkillList` | List all available skills with triggers and metadata | — |
|
||||
|
||||
> **Adding custom tools:** See [Architecture Guide](docs/architecture.md#tool-registry) for how to register your own tools.
|
||||
|
||||
---
|
||||
|
||||
## Memory
|
||||
|
||||
The model can remember things across conversations using the built-in memory system.
|
||||
|
||||
**How it works:** Memories are stored as markdown files. There are two scopes:
|
||||
- **User scope** (`~/.nano_claude/memory/`) — follows you across all projects
|
||||
- **Project scope** (`.nano_claude/memory/` in cwd) — specific to the current repo
|
||||
|
||||
A `MEMORY.md` index (≤ 200 lines / 25 KB) is auto-rebuilt on every save or delete and injected into the system prompt so Claude always has an overview.
|
||||
|
||||
**Memory types:**
|
||||
|
||||
| Type | Use for |
|
||||
|---|---|
|
||||
| `user` | Your role, preferences, background |
|
||||
| `feedback` | How you want the model to behave |
|
||||
| `project` | Ongoing work, deadlines, decisions |
|
||||
| `reference` | Links to external resources |
|
||||
|
||||
**Memory file format** (`~/.nano_claude/memory/coding_style.md`):
|
||||
```markdown
|
||||
---
|
||||
name: coding style
|
||||
description: Python formatting preferences
|
||||
type: feedback
|
||||
created: 2026-04-02
|
||||
---
|
||||
Prefer 4-space indentation and full type hints in all Python code.
|
||||
**Why:** user explicitly stated this preference.
|
||||
**How to apply:** apply to every Python file written or edited.
|
||||
```
|
||||
|
||||
**Example interaction:**
|
||||
|
||||
```
|
||||
You: Remember that I prefer 4-space indentation and type hints in all Python code.
|
||||
AI: [calls MemorySave] Memory saved: coding_style [feedback/user]
|
||||
|
||||
You: /memory
|
||||
[feedback/user] coding_style (today): Python formatting preferences
|
||||
|
||||
You: /memory python
|
||||
[feedback/user] coding_style: Prefers 4-space indent and type hints in Python
|
||||
```
|
||||
|
||||
**Staleness warnings:** Memories older than 1 day get a freshness note in `/memory` output so you know when to review or update them.
|
||||
|
||||
**AI-ranked search:** `MemorySearch(query="...", use_ai=true)` uses the model to rank results by relevance rather than simple keyword matching.
|
||||
|
||||
---
|
||||
|
||||
## Skills
|
||||
|
||||
Skills are reusable prompt templates that give the model specialized capabilities. Two built-in skills ship out of the box — no setup required.
|
||||
|
||||
**Built-in skills:**
|
||||
|
||||
| Trigger | Description |
|
||||
|---|---|
|
||||
| `/commit` | Review staged changes and create a well-structured git commit |
|
||||
| `/review [PR]` | Review code or PR diff with structured feedback |
|
||||
|
||||
**Quick start — custom skill:**
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.nano_claude/skills
|
||||
```
|
||||
|
||||
Create `~/.nano_claude/skills/deploy.md`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: deploy
|
||||
description: Deploy to an environment
|
||||
triggers: [/deploy]
|
||||
allowed-tools: [Bash, Read]
|
||||
when_to_use: Use when the user wants to deploy a version to an environment.
|
||||
argument-hint: [env] [version]
|
||||
arguments: [env, version]
|
||||
context: inline
|
||||
---
|
||||
|
||||
Deploy $VERSION to the $ENV environment.
|
||||
Full args: $ARGUMENTS
|
||||
```
|
||||
|
||||
Now use it:
|
||||
|
||||
```
|
||||
You: /deploy staging 2.1.0
|
||||
AI: [deploys version 2.1.0 to staging]
|
||||
```
|
||||
|
||||
**Argument substitution:**
|
||||
- `$ARGUMENTS` — the full raw argument string
|
||||
- `$ARG_NAME` — positional substitution by named argument (first word → first name)
|
||||
- Missing args become empty strings
|
||||
|
||||
**Execution modes:**
|
||||
- `context: inline` (default) — runs inside current conversation history
|
||||
- `context: fork` — runs as an isolated sub-agent with fresh history; supports `model` override
|
||||
|
||||
**Priority** (highest wins): project-level > user-level > built-in
|
||||
|
||||
**List skills:** `/skills` — shows triggers, argument hint, source, and `when_to_use`
|
||||
|
||||
**Skill search paths:**
|
||||
|
||||
```
|
||||
./.nano_claude/skills/ # project-level (overrides user-level)
|
||||
~/.nano_claude/skills/ # user-level
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sub-Agents
|
||||
|
||||
The model can spawn independent sub-agents to handle tasks in parallel.
|
||||
|
||||
**Specialized agent types** — built-in:
|
||||
|
||||
| Type | Optimized for |
|
||||
|---|---|
|
||||
| `general-purpose` | Research, exploration, multi-step tasks |
|
||||
| `coder` | Writing, reading, and modifying code |
|
||||
| `reviewer` | Security, correctness, and code quality analysis |
|
||||
| `researcher` | Web search and documentation lookup |
|
||||
| `tester` | Writing and running tests |
|
||||
|
||||
**Basic usage:**
|
||||
```
|
||||
You: Search this codebase for all TODO comments and summarize them.
|
||||
AI: [calls Agent(prompt="...", subagent_type="researcher")]
|
||||
Sub-agent reads files, greps for TODOs...
|
||||
Result: Found 12 TODOs across 5 files...
|
||||
```
|
||||
|
||||
**Background mode** — spawn without waiting, collect result later:
|
||||
```
|
||||
AI: [calls Agent(prompt="run all tests", name="test-runner", wait=false)]
|
||||
AI: [continues other work...]
|
||||
AI: [calls CheckAgentResult / SendMessage to follow up]
|
||||
```
|
||||
|
||||
**Git worktree isolation** — agents work on an isolated branch with no conflicts:
|
||||
```
|
||||
Agent(prompt="refactor auth module", isolation="worktree")
|
||||
```
|
||||
The worktree is auto-cleaned up if no changes were made; otherwise the branch name is reported.
|
||||
|
||||
**Custom agent types** — create `~/.nano_claude/agents/myagent.md`:
|
||||
```markdown
|
||||
---
|
||||
name: myagent
|
||||
description: Specialized for X
|
||||
model: claude-haiku-4-5-20251001
|
||||
tools: [Read, Grep, Bash]
|
||||
---
|
||||
Extra system prompt for this agent type.
|
||||
```
|
||||
|
||||
**List running agents:** `/agents`
|
||||
|
||||
Sub-agents have independent conversation history, share the file system, and are limited to 3 levels of nesting.
|
||||
|
||||
---
|
||||
|
||||
## Context Compression
|
||||
|
||||
Long conversations are automatically compressed to stay within the model's context window.
|
||||
|
||||
**Two layers:**
|
||||
|
||||
1. **Snip** — Old tool outputs (file reads, bash results) are truncated after a few turns. Fast, no API cost.
|
||||
2. **Auto-compact** — When token usage exceeds 70% of the context limit, older messages are summarized by the model into a concise recap.
|
||||
|
||||
This happens transparently. You don't need to do anything.
|
||||
|
||||
---
|
||||
|
||||
## Diff View
|
||||
|
||||
When the model edits or overwrites a file, you see a git-style diff:
|
||||
|
||||
```diff
|
||||
Changes applied to config.py:
|
||||
|
||||
--- a/config.py
|
||||
+++ b/config.py
|
||||
@@ -12,7 +12,7 @@
|
||||
"model": "claude-opus-4-6",
|
||||
- "max_tokens": 8192,
|
||||
+ "max_tokens": 16384,
|
||||
"permission_mode": "auto",
|
||||
```
|
||||
|
||||
Green lines = added, red lines = removed. New file creations show a summary instead.
|
||||
|
||||
---
|
||||
|
||||
## CLAUDE.md Support
|
||||
@@ -637,19 +878,49 @@ Sessions are stored as JSON in `~/.nano_claude/sessions/`.
|
||||
|
||||
```
|
||||
nano_claude_code/
|
||||
├── nano_claude.py # Entry point: REPL + slash commands + output rendering (~580 lines)
|
||||
├── agent.py # Agent loop: neutral message format + tool dispatch (~160 lines)
|
||||
├── providers.py # Multi-provider: adapters + message format conversion (~480 lines)
|
||||
├── tools.py # 8 tool implementations + JSON schemas (~360 lines)
|
||||
├── context.py # System prompt builder: CLAUDE.md + git + cwd (~100 lines)
|
||||
├── config.py # Config load/save/defaults (~70 lines)
|
||||
├── demo.py # Demo script (requires API key)
|
||||
├── make_demo.py # Generates demo.gif and screenshot.png
|
||||
├── demo.gif # Animated demo
|
||||
├── screenshot.png # Static screenshot
|
||||
└── requirements.txt
|
||||
├── nano_claude.py # Entry point: REPL + slash commands + diff rendering
|
||||
├── agent.py # Agent loop: streaming, tool dispatch, compaction
|
||||
├── providers.py # Multi-provider: Anthropic, OpenAI-compat streaming
|
||||
├── tools.py # Core tools (Read/Write/Edit/Bash/Glob/Grep/Web) + registry wiring
|
||||
├── tool_registry.py # Tool plugin registry: register, lookup, execute
|
||||
├── compaction.py # Context compression: snip + auto-summarize
|
||||
├── context.py # System prompt builder: CLAUDE.md + git + memory
|
||||
├── config.py # Config load/save/defaults
|
||||
│
|
||||
├── multi_agent/ # Multi-agent package
|
||||
│ ├── __init__.py # Re-exports
|
||||
│ ├── subagent.py # AgentDefinition, SubAgentManager, worktree helpers
|
||||
│ └── tools.py # Agent, SendMessage, CheckAgentResult, ListAgentTasks, ListAgentTypes
|
||||
├── subagent.py # Backward-compat shim → multi_agent/
|
||||
│
|
||||
├── memory/ # Memory package
|
||||
│ ├── __init__.py # Re-exports
|
||||
│ ├── types.py # MEMORY_TYPES and format guidance
|
||||
│ ├── store.py # save/load/delete/search, MEMORY.md index rebuilding
|
||||
│ ├── scan.py # MemoryHeader, age/freshness helpers
|
||||
│ ├── context.py # get_memory_context(), truncation, AI search
|
||||
│ └── tools.py # MemorySave, MemoryDelete, MemorySearch, MemoryList
|
||||
├── memory.py # Backward-compat shim → memory/
|
||||
│
|
||||
├── skill/ # Skill package
|
||||
│ ├── __init__.py # Re-exports; imports builtin to register built-ins
|
||||
│ ├── loader.py # SkillDef, parse, load_skills, find_skill, substitute_arguments
|
||||
│ ├── builtin.py # Built-in skills: /commit, /review
|
||||
│ ├── executor.py # execute_skill(): inline or forked sub-agent
|
||||
│ └── tools.py # Skill, SkillList
|
||||
├── skills.py # Backward-compat shim → skill/
|
||||
│
|
||||
└── tests/ # 101 unit tests
|
||||
├── test_memory.py
|
||||
├── test_skills.py
|
||||
├── test_subagent.py
|
||||
├── test_tool_registry.py
|
||||
├── test_compaction.py
|
||||
└── test_diff_view.py
|
||||
```
|
||||
|
||||
> **For developers:** Each feature package (`multi_agent/`, `memory/`, `skill/`) is self-contained. Add custom tools by calling `register_tool(ToolDef(...))` from any module imported by `tools.py`.
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
||||
526
nano-claude-code/Update_README.MD
Normal file
526
nano-claude-code/Update_README.MD
Normal file
@@ -0,0 +1,526 @@
|
||||
# Nano Claude Code — Update Notes
|
||||
|
||||
This document describes three major feature additions to nano-claude-code:
|
||||
**Multi-Agent**, **Memory**, and **Skill**. Each feature is organized as a
|
||||
self-contained Python package, follows the same architectural pattern, and
|
||||
includes a backward-compatibility shim so existing code continues to work.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
All three packages follow the same pattern:
|
||||
|
||||
```
|
||||
feature/
|
||||
__init__.py — public re-exports
|
||||
<core>.py — data model, loading, business logic
|
||||
tools.py — registers tools into the central tool_registry
|
||||
...
|
||||
feature.py — backward-compat shim (re-exports from feature/)
|
||||
```
|
||||
|
||||
The **tool registry** (`tool_registry.py`) is the central hub. Each feature's
|
||||
`tools.py` calls `register_tool(ToolDef(...))` at import time. The top-level
|
||||
`tools.py` imports all three feature tool modules, triggering auto-registration.
|
||||
|
||||
The **agent loop** (`agent.py`) injects `_depth` and `_system_prompt` into the
|
||||
`config` dict on every call, so tool functions can read them via `config.get(...)`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Multi-Agent (`multi_agent/`)
|
||||
|
||||
### What it does
|
||||
|
||||
Allows Claude to spawn sub-agents — nested agent loops that run concurrently
|
||||
in background threads. Sub-agents can share the parent's context or run in an
|
||||
isolated git worktree. The user can send follow-up messages to named background
|
||||
agents and retrieve their results.
|
||||
|
||||
### Package structure
|
||||
|
||||
```
|
||||
multi_agent/
|
||||
__init__.py — re-exports AgentDefinition, SubAgentTask, SubAgentManager, etc.
|
||||
subagent.py — core: AgentDefinition, SubAgentTask, SubAgentManager, worktree helpers
|
||||
tools.py — registers: Agent, SendMessage, CheckAgentResult, ListAgentTasks, ListAgentTypes
|
||||
subagent.py — backward-compat shim
|
||||
```
|
||||
|
||||
### Key classes and functions
|
||||
|
||||
**`AgentDefinition`** (`multi_agent/subagent.py`)
|
||||
```python
|
||||
@dataclass
|
||||
class AgentDefinition:
|
||||
name: str
|
||||
description: str
|
||||
system_prompt: str # prepended to base prompt for this agent type
|
||||
model: str # "" = inherit from parent
|
||||
tools: list # [] = all tools
|
||||
source: str # "built-in" | "user" | "project"
|
||||
```
|
||||
|
||||
**Built-in agent types**: `general-purpose`, `coder`, `reviewer`, `researcher`, `tester`
|
||||
|
||||
**Custom agent definitions** — place a `.md` file with YAML frontmatter in:
|
||||
- `~/.nano_claude/agents/<name>.md` (user-level)
|
||||
- `.nano_claude/agents/<name>.md` (project-level, takes priority)
|
||||
|
||||
Frontmatter format:
|
||||
```markdown
|
||||
---
|
||||
name: my-agent
|
||||
description: What this agent does
|
||||
model: claude-opus-4-6
|
||||
tools: [Read, Glob, Grep]
|
||||
---
|
||||
Extra system prompt instructions for this agent.
|
||||
```
|
||||
|
||||
**`SubAgentManager`** (`multi_agent/subagent.py`)
|
||||
- `spawn(prompt, config, agent_def, isolation, name, wait)` — runs agent in thread pool
|
||||
- `send_message(task_id_or_name, message)` — enqueues message to a running background agent
|
||||
- `get_result(task_id)` — returns final text or status
|
||||
- `list_tasks()` — returns all SubAgentTask objects
|
||||
|
||||
**Git worktree isolation**:
|
||||
When `isolation="worktree"` is passed to `Agent`, a temporary git worktree is
|
||||
created on a fresh branch. The sub-agent works in isolation; if it makes no
|
||||
changes the worktree is cleaned up automatically.
|
||||
|
||||
### Tools registered
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `Agent` | Spawn a sub-agent (sync or background with `wait=false`) |
|
||||
| `SendMessage` | Send a follow-up message to a named background agent |
|
||||
| `CheckAgentResult` | Poll status / retrieve result of a background agent |
|
||||
| `ListAgentTasks` | List all active and finished sub-agent tasks |
|
||||
| `ListAgentTypes` | List all available agent type definitions |
|
||||
|
||||
### Agent tool parameters
|
||||
|
||||
```python
|
||||
Agent(
|
||||
prompt="...", # required — task description
|
||||
subagent_type="coder", # optional — use a specialized agent
|
||||
isolation="worktree", # optional — isolated git branch
|
||||
name="my-agent", # optional — name for SendMessage later
|
||||
wait=False, # optional — run in background
|
||||
model="...", # optional — model override
|
||||
)
|
||||
```
|
||||
|
||||
### How it was wired in
|
||||
|
||||
1. `multi_agent/subagent.py` uses **absolute imports** (`import agent as _agent_mod`)
|
||||
because the project root is in `sys.path` when running from that directory.
|
||||
2. `agent.py` was updated to inject `_system_prompt` into `config`:
|
||||
```python
|
||||
config = {**config, "_depth": depth, "_system_prompt": system_prompt}
|
||||
```
|
||||
3. `tools.py` (top-level) was updated to pass `config` through to the registry:
|
||||
```python
|
||||
return _registry_execute(name, inputs, cfg)
|
||||
```
|
||||
and at the bottom:
|
||||
```python
|
||||
import multi_agent.tools as _multiagent_tools
|
||||
```
|
||||
4. `context.py` system prompt template lists Agent, SendMessage, etc. under
|
||||
`## Multi-Agent`.
|
||||
5. `nano_claude.py` `/agents` command calls `get_agent_manager().list_tasks()`
|
||||
and prints status/worktree info. A `_print_background_notifications()` function
|
||||
checks for newly completed background agents before each user prompt.
|
||||
|
||||
### Files changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `multi_agent/__init__.py` | Created (re-exports) |
|
||||
| `multi_agent/subagent.py` | Created (moved + enhanced from `subagent.py`) |
|
||||
| `multi_agent/tools.py` | Created (tool registrations) |
|
||||
| `subagent.py` | Converted to backward-compat shim |
|
||||
| `agent.py` | Inject `_system_prompt` into config |
|
||||
| `tools.py` | Pass config to registry; import `multi_agent.tools` |
|
||||
| `context.py` | Add Multi-Agent section to system prompt |
|
||||
| `nano_claude.py` | `/agents` command; background notification; `_tool_desc()` |
|
||||
| `tests/test_subagent.py` | Update imports to `multi_agent.subagent` |
|
||||
|
||||
---
|
||||
|
||||
## 2. Memory (`memory/`)
|
||||
|
||||
### What it does
|
||||
|
||||
Provides persistent, file-based memory across sessions. Memories are stored as
|
||||
markdown files with YAML frontmatter. There are two scopes — **user** (global,
|
||||
`~/.nano_claude/memory/`) and **project** (per-repo, `.nano_claude/memory/`).
|
||||
A `MEMORY.md` index is auto-rebuilt after every save/delete and injected into
|
||||
the system prompt so Claude knows what memories exist.
|
||||
|
||||
### Package structure
|
||||
|
||||
```
|
||||
memory/
|
||||
__init__.py — re-exports all public symbols
|
||||
types.py — MEMORY_TYPES, type descriptions, format guidance
|
||||
store.py — MemoryEntry, save/load/delete/search, index rebuilding
|
||||
scan.py — MemoryHeader, scan_memory_dir, age/freshness helpers
|
||||
context.py — get_memory_context(), find_relevant_memories(), truncation
|
||||
tools.py — registers: MemorySave, MemoryDelete, MemorySearch, MemoryList
|
||||
memory.py — backward-compat shim
|
||||
```
|
||||
|
||||
### Memory types
|
||||
|
||||
Defined in `memory/types.py`, mirrors the four types from Claude Code:
|
||||
|
||||
| Type | Purpose |
|
||||
|------|---------|
|
||||
| `user` | User's role, goals, preferences |
|
||||
| `feedback` | Corrections and confirmed approaches |
|
||||
| `project` | Ongoing work, decisions, deadlines |
|
||||
| `reference` | Pointers to external resources |
|
||||
|
||||
### Storage layout
|
||||
|
||||
```
|
||||
~/.nano_claude/memory/
|
||||
MEMORY.md ← auto-generated index (<=200 lines, <=25 KB)
|
||||
my_note.md
|
||||
feedback_testing.md
|
||||
...
|
||||
|
||||
.nano_claude/memory/ ← project-local (relative to cwd)
|
||||
MEMORY.md
|
||||
...
|
||||
```
|
||||
|
||||
Each memory file format:
|
||||
```markdown
|
||||
---
|
||||
name: My Note
|
||||
description: one-line description for relevance decisions
|
||||
type: user
|
||||
created: 2026-04-02
|
||||
---
|
||||
|
||||
Memory content goes here.
|
||||
**Why:** ...
|
||||
**How to apply:** ...
|
||||
```
|
||||
|
||||
### Key API
|
||||
|
||||
**`memory/store.py`**
|
||||
```python
|
||||
save_memory(entry: MemoryEntry, scope="user") # save or update (same name = update)
|
||||
delete_memory(name: str, scope="user") # remove entry + rebuild index
|
||||
load_entries(scope="user") -> list[MemoryEntry] # load all entries for scope
|
||||
load_index(scope="all") -> list[MemoryEntry] # "all" merges user + project
|
||||
search_memory(query: str, scope="all") -> list # keyword search across content+name
|
||||
get_index_content(scope="all") -> str # raw MEMORY.md text
|
||||
```
|
||||
|
||||
**`memory/scan.py`**
|
||||
```python
|
||||
scan_memory_dir(mem_dir, scope) -> list[MemoryHeader] # newest-first, capped at 200
|
||||
scan_all_memories() -> list[MemoryHeader] # user + project merged
|
||||
memory_age_str(mtime_s) -> str # "today" | "yesterday" | "N days ago"
|
||||
memory_freshness_text(mtime_s) -> str # staleness warning for memories >1 day old
|
||||
format_memory_manifest(headers) -> str # formatted list for display
|
||||
```
|
||||
|
||||
**`memory/context.py`**
|
||||
```python
|
||||
get_memory_context() -> str # injected into system prompt
|
||||
truncate_index_content(raw) -> str # enforces <=200 lines / <=25 KB
|
||||
find_relevant_memories(query, max_results=5, use_ai=False, config=None)
|
||||
```
|
||||
|
||||
`find_relevant_memories` supports optional AI ranking: when `use_ai=True` it
|
||||
makes a small API call to rank candidates by relevance to the query.
|
||||
|
||||
### Tools registered
|
||||
|
||||
| Tool | Parameters | Description |
|
||||
|------|-----------|-------------|
|
||||
| `MemorySave` | `name, description, type, content, scope` | Save or update a memory |
|
||||
| `MemoryDelete` | `name, scope` | Delete a memory by name |
|
||||
| `MemorySearch` | `query, scope, use_ai, max_results` | Search by keyword (or AI) |
|
||||
| `MemoryList` | `scope` | List all memories with age and metadata |
|
||||
|
||||
### Index truncation
|
||||
|
||||
The `MEMORY.md` index is truncated before being injected into the system prompt:
|
||||
- Hard limit: **200 lines** (mirrors Claude Code's limit)
|
||||
- Byte limit: **25 000 bytes** (mirrors Claude Code's limit)
|
||||
- A `WARNING:` line is appended when either limit is hit
|
||||
|
||||
### How it was wired in
|
||||
|
||||
1. `memory/store.py` exports `USER_MEMORY_DIR` and `get_project_memory_dir` as
|
||||
module-level names so tests can monkeypatch them cleanly.
|
||||
2. `context.py` (system prompt builder) calls `get_memory_context()` at the end
|
||||
of `build_system_prompt()` and appends the result.
|
||||
3. `tools.py` (top-level) adds:
|
||||
```python
|
||||
import memory.tools as _memory_tools
|
||||
```
|
||||
4. `memory.py` (top-level) is now a shim:
|
||||
```python
|
||||
from memory.store import MemoryEntry, save_memory, ...
|
||||
from memory.context import get_memory_context
|
||||
```
|
||||
5. `nano_claude.py` `/memory` command uses `scan_all_memories()` to display a
|
||||
mtime-sorted list with freshness warnings.
|
||||
|
||||
### Files changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `memory/__init__.py` | Created (re-exports) |
|
||||
| `memory/types.py` | Created (MEMORY_TYPES, descriptions, format guidance) |
|
||||
| `memory/store.py` | Created (replaced top-level `memory.py` logic) |
|
||||
| `memory/scan.py` | Created (MemoryHeader, age/freshness, manifest) |
|
||||
| `memory/context.py` | Created (context injection, truncation, AI search) |
|
||||
| `memory/tools.py` | Created (MemorySave, MemoryDelete, MemorySearch, MemoryList) |
|
||||
| `memory.py` | Converted to backward-compat shim |
|
||||
| `tools.py` | Import `memory.tools` |
|
||||
| `context.py` | Call `get_memory_context()` in `build_system_prompt()` |
|
||||
| `nano_claude.py` | `/memory` command uses `scan_all_memories()` |
|
||||
| `tests/test_memory.py` | Completely rewritten (101 tests total) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Skill (`skill/`)
|
||||
|
||||
### What it does
|
||||
|
||||
Skills are reusable prompt templates stored as markdown files. A user types
|
||||
`/commit` or `/review pr-123` in the REPL and the skill's prompt (with
|
||||
arguments substituted) is injected into the conversation. Skills can run
|
||||
**inline** (current conversation context) or **forked** (isolated sub-agent).
|
||||
Two built-in skills (`/commit`, `/review`) are registered programmatically.
|
||||
|
||||
### Package structure
|
||||
|
||||
```
|
||||
skill/
|
||||
__init__.py — re-exports all public symbols; imports builtin to register them
|
||||
loader.py — SkillDef dataclass, file parsing, load_skills, find_skill, substitute_arguments
|
||||
builtin.py — built-in skills: /commit, /review
|
||||
executor.py — execute_skill() (inline or forked)
|
||||
tools.py — registers: Skill, SkillList
|
||||
skills.py — backward-compat shim
|
||||
```
|
||||
|
||||
### Skill file format
|
||||
|
||||
Place `.md` files in:
|
||||
- `~/.nano_claude/skills/<name>.md` (user-level)
|
||||
- `.nano_claude/skills/<name>.md` (project-level, takes priority)
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: deploy
|
||||
description: Deploy to an environment
|
||||
triggers: [/deploy]
|
||||
allowed-tools: [Bash, Read]
|
||||
when_to_use: Use when the user wants to deploy. Examples: '/deploy staging v1.2'
|
||||
argument-hint: [env] [version]
|
||||
arguments: [env, version]
|
||||
context: inline
|
||||
---
|
||||
|
||||
Deploy $VERSION to $ENV.
|
||||
|
||||
Full args provided: $ARGUMENTS
|
||||
```
|
||||
|
||||
### Frontmatter fields
|
||||
|
||||
| Field | Default | Description |
|
||||
|-------|---------|-------------|
|
||||
| `name` | required | Skill identifier |
|
||||
| `description` | `""` | One-line description shown in `/skills` |
|
||||
| `triggers` | `[/<name>]` | Slash commands or phrases that activate this skill |
|
||||
| `allowed-tools` / `tools` | `[]` | Tools the skill is allowed to use |
|
||||
| `when_to_use` | `""` | Guidance for when Claude should auto-invoke |
|
||||
| `argument-hint` | `""` | Hint shown in `/skills` list, e.g. `[branch] [desc]` |
|
||||
| `arguments` | `[]` | Named argument list for `$ARG_NAME` substitution |
|
||||
| `model` | `""` | Model override (fork context only) |
|
||||
| `user-invocable` | `true` | Show in `/skills` list |
|
||||
| `context` | `inline` | `inline` = current conversation, `fork` = isolated sub-agent |
|
||||
|
||||
### Argument substitution
|
||||
|
||||
`substitute_arguments(prompt, args, arg_names)` in `skill/loader.py`:
|
||||
|
||||
- `$ARGUMENTS` → the full raw args string
|
||||
- `$ARG_NAME` → positional substitution (first word → first arg name, etc.)
|
||||
- Missing args become empty strings
|
||||
|
||||
```
|
||||
prompt: "Deploy $VERSION to $ENV. Full: $ARGUMENTS"
|
||||
args: "1.0 staging"
|
||||
arg_names: ["env", "version"]
|
||||
|
||||
result: "Deploy staging to 1.0. Full: 1.0 staging"
|
||||
```
|
||||
|
||||
### Execution modes
|
||||
|
||||
**Inline** (`context: inline`, default):
|
||||
- Skill prompt is injected into the current `AgentState`
|
||||
- History is shared — the user can see and continue the conversation
|
||||
|
||||
**Fork** (`context: fork`):
|
||||
- A fresh `AgentState` is created (no shared history)
|
||||
- Optional `model` and `allowed-tools` overrides are applied
|
||||
- Good for self-contained tasks that don't need mid-process user input
|
||||
|
||||
### Built-in skills
|
||||
|
||||
Defined in `skill/builtin.py` and registered via `register_builtin_skill()`:
|
||||
|
||||
| Trigger | Name | Description |
|
||||
|---------|------|-------------|
|
||||
| `/commit` | commit | Review staged changes and create a well-structured git commit |
|
||||
| `/review`, `/review-pr` | review | Review code or PR diff with structured feedback |
|
||||
|
||||
Project-level skill files with the same name override built-ins.
|
||||
|
||||
### Tools registered
|
||||
|
||||
| Tool | Parameters | Description |
|
||||
|------|-----------|-------------|
|
||||
| `Skill` | `name, args` | Invoke a skill by name from inside a conversation |
|
||||
| `SkillList` | — | List all available skills with triggers and metadata |
|
||||
|
||||
### Priority order
|
||||
|
||||
When multiple skill sources define the same name, the highest priority wins:
|
||||
|
||||
```
|
||||
builtin < user (~/.nano_claude/skills/) < project (.nano_claude/skills/)
|
||||
```
|
||||
|
||||
### REPL usage
|
||||
|
||||
```
|
||||
/commit # run built-in commit skill
|
||||
/review 123 # review PR #123 (args = "123")
|
||||
/deploy staging 2.1.0 # custom skill with named args
|
||||
/skills # list all skills
|
||||
```
|
||||
|
||||
The `/skills` command output includes source label, triggers, argument hint,
|
||||
and the first 80 chars of `when_to_use` per skill.
|
||||
|
||||
### How it was wired in
|
||||
|
||||
1. `skill/__init__.py` imports `skill.builtin` which calls `register_builtin_skill()`
|
||||
for each built-in — just importing the package registers them.
|
||||
2. `tools.py` (top-level) adds:
|
||||
```python
|
||||
import skill.tools as _skill_tools
|
||||
```
|
||||
3. `skills.py` (top-level) becomes a shim re-exporting from `skill/`.
|
||||
4. `context.py` adds a `## Skills` section listing `Skill` and `SkillList`.
|
||||
5. `nano_claude.py`:
|
||||
- `cmd_skills` imports from `skill`, shows `when_to_use` and source label
|
||||
- `handle_slash` imports `find_skill` from `skill`; returns `(skill, args)` tuple
|
||||
- REPL loop calls `substitute_arguments` before building the injected message
|
||||
|
||||
### Files changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `skill/__init__.py` | Created (re-exports; imports builtin) |
|
||||
| `skill/loader.py` | Created (SkillDef, parse, load, find, substitute) |
|
||||
| `skill/builtin.py` | Created (/commit, /review built-ins) |
|
||||
| `skill/executor.py` | Created (inline + fork execution) |
|
||||
| `skill/tools.py` | Created (Skill, SkillList tool registration) |
|
||||
| `skills.py` | Converted to backward-compat shim |
|
||||
| `tools.py` | Import `skill.tools` |
|
||||
| `context.py` | Add Skills section to system prompt |
|
||||
| `nano_claude.py` | `cmd_skills`, `handle_slash`, REPL loop updated |
|
||||
| `tests/test_skills.py` | Rewritten (22 tests; patches `skill.loader`) |
|
||||
|
||||
---
|
||||
|
||||
## How to add custom agents, memories, and skills
|
||||
|
||||
### Custom agent type
|
||||
|
||||
Create `~/.nano_claude/agents/myagent.md`:
|
||||
```markdown
|
||||
---
|
||||
name: myagent
|
||||
description: Does specialized work
|
||||
model: claude-haiku-4-5-20251001
|
||||
tools: [Read, Grep, Bash]
|
||||
---
|
||||
You are specialized in X. Focus on Y. Never do Z.
|
||||
```
|
||||
|
||||
Then use: `Agent(prompt="...", subagent_type="myagent")`
|
||||
|
||||
### Custom memory
|
||||
|
||||
Use the REPL `MemorySave` tool or write a file directly to
|
||||
`~/.nano_claude/memory/my_note.md` with frontmatter:
|
||||
```markdown
|
||||
---
|
||||
name: my note
|
||||
description: short description
|
||||
type: feedback
|
||||
created: 2026-04-02
|
||||
---
|
||||
Memory content here.
|
||||
```
|
||||
|
||||
### Custom skill
|
||||
|
||||
Create `~/.nano_claude/skills/myskill.md` (user-level) or
|
||||
`.nano_claude/skills/myskill.md` (project-level):
|
||||
```markdown
|
||||
---
|
||||
name: myskill
|
||||
description: Does something useful
|
||||
triggers: [/myskill]
|
||||
arguments: [target]
|
||||
argument-hint: [target]
|
||||
when_to_use: Use when the user wants to do X with a target.
|
||||
---
|
||||
|
||||
Do something useful with $TARGET.
|
||||
|
||||
Full context: $ARGUMENTS
|
||||
```
|
||||
|
||||
Then invoke with `/myskill some-target`.
|
||||
|
||||
---
|
||||
|
||||
## Running tests
|
||||
|
||||
```bash
|
||||
cd nano-claude-code
|
||||
|
||||
# All tests
|
||||
python -m pytest tests/ -v
|
||||
|
||||
# Per-feature
|
||||
python -m pytest tests/test_subagent.py -v # multi-agent
|
||||
python -m pytest tests/test_memory.py -v # memory
|
||||
python -m pytest tests/test_skills.py -v # skills
|
||||
```
|
||||
|
||||
Total: **101 tests**, all passing. Each feature's tests use `monkeypatch` to
|
||||
redirect file system paths to `tmp_path` so no real `~/.nano_claude/`
|
||||
directories are touched during testing.
|
||||
@@ -5,8 +5,11 @@ import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Generator
|
||||
|
||||
from tools import TOOL_SCHEMAS, execute_tool
|
||||
from tool_registry import get_tool_schemas
|
||||
from tools import execute_tool
|
||||
import tools as _tools_init # ensure built-in tools are registered on import
|
||||
from providers import stream, AssistantTurn, TextChunk, ThinkingChunk, detect_provider
|
||||
from compaction import maybe_compact
|
||||
|
||||
# ── Re-export event types (used by nano_claude.py) ────────────────────────
|
||||
__all__ = [
|
||||
@@ -54,25 +57,39 @@ def run(
|
||||
state: AgentState,
|
||||
config: dict,
|
||||
system_prompt: str,
|
||||
depth: int = 0,
|
||||
cancel_check=None,
|
||||
) -> Generator:
|
||||
"""
|
||||
Multi-turn agent loop (generator).
|
||||
Yields: TextChunk | ThinkingChunk | ToolStart | ToolEnd |
|
||||
PermissionRequest | TurnDone
|
||||
|
||||
Args:
|
||||
depth: sub-agent nesting depth, 0 for top-level
|
||||
cancel_check: callable returning True to abort the loop early
|
||||
"""
|
||||
# Append user turn in neutral format
|
||||
state.messages.append({"role": "user", "content": user_message})
|
||||
|
||||
# Inject runtime metadata into config so tools (e.g. Agent) can access it
|
||||
config = {**config, "_depth": depth, "_system_prompt": system_prompt}
|
||||
|
||||
while True:
|
||||
if cancel_check and cancel_check():
|
||||
return
|
||||
state.turn_count += 1
|
||||
assistant_turn: AssistantTurn | None = None
|
||||
|
||||
# Compact context if approaching window limit
|
||||
maybe_compact(state, config)
|
||||
|
||||
# Stream from provider (auto-detected from model name)
|
||||
for event in stream(
|
||||
model=config["model"],
|
||||
system=system_prompt,
|
||||
messages=state.messages,
|
||||
tool_schemas=TOOL_SCHEMAS,
|
||||
tool_schemas=get_tool_schemas(),
|
||||
config=config,
|
||||
):
|
||||
if isinstance(event, (TextChunk, ThinkingChunk)):
|
||||
@@ -114,6 +131,7 @@ def run(
|
||||
result = execute_tool(
|
||||
tc["name"], tc["input"],
|
||||
permission_mode="accept-all", # already gate-checked above
|
||||
config=config,
|
||||
)
|
||||
|
||||
yield ToolEnd(tc["name"], result, permitted)
|
||||
|
||||
196
nano-claude-code/compaction.py
Normal file
196
nano-claude-code/compaction.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""Context window management: two-layer compression for long conversations."""
|
||||
from __future__ import annotations
|
||||
|
||||
import providers
|
||||
|
||||
|
||||
# ── Token estimation ──────────────────────────────────────────────────────
|
||||
|
||||
def estimate_tokens(messages: list) -> int:
|
||||
"""Estimate token count by summing content lengths / 3.5.
|
||||
|
||||
Args:
|
||||
messages: list of message dicts with "content" field (str or list of dicts)
|
||||
Returns:
|
||||
approximate token count, int
|
||||
"""
|
||||
total_chars = 0
|
||||
for m in messages:
|
||||
content = m.get("content", "")
|
||||
if isinstance(content, str):
|
||||
total_chars += len(content)
|
||||
elif isinstance(content, list):
|
||||
for block in content:
|
||||
if isinstance(block, dict):
|
||||
# Sum all string values in the block
|
||||
for v in block.values():
|
||||
if isinstance(v, str):
|
||||
total_chars += len(v)
|
||||
# Also count tool_calls if present
|
||||
for tc in m.get("tool_calls", []):
|
||||
if isinstance(tc, dict):
|
||||
for v in tc.values():
|
||||
if isinstance(v, str):
|
||||
total_chars += len(v)
|
||||
return int(total_chars / 3.5)
|
||||
|
||||
|
||||
def get_context_limit(model: str) -> int:
|
||||
"""Look up context window size for a model.
|
||||
|
||||
Args:
|
||||
model: model string (e.g. "claude-opus-4-6", "ollama/llama3.3")
|
||||
Returns:
|
||||
context limit in tokens
|
||||
"""
|
||||
provider_name = providers.detect_provider(model)
|
||||
prov = providers.PROVIDERS.get(provider_name, {})
|
||||
return prov.get("context_limit", 128000)
|
||||
|
||||
|
||||
# ── Layer 1: Snip old tool results ────────────────────────────────────────
|
||||
|
||||
def snip_old_tool_results(
|
||||
messages: list,
|
||||
max_chars: int = 2000,
|
||||
preserve_last_n_turns: int = 6,
|
||||
) -> list:
|
||||
"""Truncate tool-role messages older than preserve_last_n_turns from end.
|
||||
|
||||
For old tool messages whose content exceeds max_chars, keep the first half
|
||||
and last quarter, inserting '[... N chars snipped ...]' in between.
|
||||
Mutates in place and returns the same list.
|
||||
|
||||
Args:
|
||||
messages: list of message dicts (mutated in place)
|
||||
max_chars: maximum character length before truncation
|
||||
preserve_last_n_turns: number of messages from end to preserve
|
||||
Returns:
|
||||
the same messages list (mutated)
|
||||
"""
|
||||
cutoff = max(0, len(messages) - preserve_last_n_turns)
|
||||
for i in range(cutoff):
|
||||
m = messages[i]
|
||||
if m.get("role") != "tool":
|
||||
continue
|
||||
content = m.get("content", "")
|
||||
if not isinstance(content, str) or len(content) <= max_chars:
|
||||
continue
|
||||
first_half = content[: max_chars // 2]
|
||||
last_quarter = content[-(max_chars // 4):]
|
||||
snipped = len(content) - len(first_half) - len(last_quarter)
|
||||
m["content"] = f"{first_half}\n[... {snipped} chars snipped ...]\n{last_quarter}"
|
||||
return messages
|
||||
|
||||
|
||||
# ── Layer 2: Auto-compact ─────────────────────────────────────────────────
|
||||
|
||||
def find_split_point(messages: list, keep_ratio: float = 0.3) -> int:
|
||||
"""Find index that splits messages so ~keep_ratio of tokens are in the recent portion.
|
||||
|
||||
Walks backwards from end, accumulating token estimates, and returns the
|
||||
index where the recent portion reaches ~keep_ratio of total tokens.
|
||||
|
||||
Args:
|
||||
messages: list of message dicts
|
||||
keep_ratio: fraction of tokens to keep in the recent portion
|
||||
Returns:
|
||||
split index (messages[:idx] = old, messages[idx:] = recent)
|
||||
"""
|
||||
total = estimate_tokens(messages)
|
||||
target = int(total * keep_ratio)
|
||||
running = 0
|
||||
for i in range(len(messages) - 1, -1, -1):
|
||||
running += estimate_tokens([messages[i]])
|
||||
if running >= target:
|
||||
return i
|
||||
return 0
|
||||
|
||||
|
||||
def compact_messages(messages: list, config: dict) -> list:
|
||||
"""Compress old messages into a summary via LLM call.
|
||||
|
||||
Splits at find_split_point, summarizes old portion, returns
|
||||
[summary_msg, ack_msg, *recent_messages].
|
||||
|
||||
Args:
|
||||
messages: full message list
|
||||
config: agent config dict (must contain "model")
|
||||
Returns:
|
||||
new compacted message list
|
||||
"""
|
||||
split = find_split_point(messages)
|
||||
if split <= 0:
|
||||
return messages
|
||||
|
||||
old = messages[:split]
|
||||
recent = messages[split:]
|
||||
|
||||
# Build summary request
|
||||
old_text = ""
|
||||
for m in old:
|
||||
role = m.get("role", "?")
|
||||
content = m.get("content", "")
|
||||
if isinstance(content, str):
|
||||
old_text += f"[{role}]: {content[:500]}\n"
|
||||
elif isinstance(content, list):
|
||||
old_text += f"[{role}]: (structured content)\n"
|
||||
|
||||
summary_prompt = (
|
||||
"Summarize the following conversation history concisely. "
|
||||
"Preserve key decisions, file paths, tool results, and context "
|
||||
"needed to continue the conversation:\n\n" + old_text
|
||||
)
|
||||
|
||||
# Call LLM for summary
|
||||
summary_text = ""
|
||||
for event in providers.stream(
|
||||
model=config["model"],
|
||||
system="You are a concise summarizer.",
|
||||
messages=[{"role": "user", "content": summary_prompt}],
|
||||
tool_schemas=[],
|
||||
config=config,
|
||||
):
|
||||
if isinstance(event, providers.TextChunk):
|
||||
summary_text += event.text
|
||||
|
||||
summary_msg = {
|
||||
"role": "user",
|
||||
"content": f"[Previous conversation summary]\n{summary_text}",
|
||||
}
|
||||
ack_msg = {
|
||||
"role": "assistant",
|
||||
"content": "Understood. I have the context from the previous conversation. Let's continue.",
|
||||
}
|
||||
return [summary_msg, ack_msg, *recent]
|
||||
|
||||
|
||||
# ── Main entry ────────────────────────────────────────────────────────────
|
||||
|
||||
def maybe_compact(state, config: dict) -> bool:
|
||||
"""Check if context window is getting full and compress if needed.
|
||||
|
||||
Runs snip_old_tool_results first, then auto-compact if still over threshold.
|
||||
|
||||
Args:
|
||||
state: AgentState with .messages list
|
||||
config: agent config dict (must contain "model")
|
||||
Returns:
|
||||
True if compaction was performed
|
||||
"""
|
||||
model = config.get("model", "")
|
||||
limit = get_context_limit(model)
|
||||
threshold = limit * 0.7
|
||||
|
||||
if estimate_tokens(state.messages) <= threshold:
|
||||
return False
|
||||
|
||||
# Layer 1: snip old tool results
|
||||
snip_old_tool_results(state.messages)
|
||||
|
||||
if estimate_tokens(state.messages) <= threshold:
|
||||
return True
|
||||
|
||||
# Layer 2: auto-compact
|
||||
state.messages = compact_messages(state.messages, config)
|
||||
return True
|
||||
@@ -16,6 +16,9 @@ DEFAULTS = {
|
||||
"thinking": False,
|
||||
"thinking_budget": 10000,
|
||||
"custom_base_url": "", # for "custom" provider
|
||||
"max_tool_output": 32000,
|
||||
"max_agent_depth": 3,
|
||||
"max_concurrent_agents": 3,
|
||||
# Per-provider API keys (optional; env vars take priority)
|
||||
# "anthropic_api_key": "sk-ant-..."
|
||||
# "openai_api_key": "sk-..."
|
||||
|
||||
@@ -4,11 +4,15 @@ import subprocess
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
from memory import get_memory_context
|
||||
|
||||
SYSTEM_PROMPT_TEMPLATE = """\
|
||||
You are Nano Claude Code, Created by SAIL Lab (Safe AI and Robot Learning Lab), an AI coding assistant running in the terminal.
|
||||
You are Nano Claude Code, Created by SAIL Lab (Safe AI and Robot Learning Lab at UC Berkeley), an AI coding assistant running in the terminal.
|
||||
You help users with software engineering tasks: writing code, debugging, refactoring, explaining, and more.
|
||||
|
||||
# Available Tools
|
||||
|
||||
## File & Shell
|
||||
- **Read**: Read file contents with line numbers
|
||||
- **Write**: Create or overwrite files
|
||||
- **Edit**: Replace text in a file (exact string replacement)
|
||||
@@ -18,6 +22,27 @@ You help users with software engineering tasks: writing code, debugging, refacto
|
||||
- **WebFetch**: Fetch and extract content from a URL
|
||||
- **WebSearch**: Search the web via DuckDuckGo
|
||||
|
||||
## Multi-Agent
|
||||
- **Agent**: Spawn a sub-agent to handle a task autonomously. Supports:
|
||||
- `subagent_type`: specialized agent types (coder, reviewer, researcher, tester, general-purpose)
|
||||
- `isolation="worktree"`: isolated git branch/worktree for parallel coding
|
||||
- `name`: give the agent a name for later addressing
|
||||
- `wait=false`: run in background, then check result later
|
||||
- **SendMessage**: Send a follow-up message to a named background agent
|
||||
- **CheckAgentResult**: Check status/result of a background agent by task ID
|
||||
- **ListAgentTasks**: List all sub-agent tasks
|
||||
- **ListAgentTypes**: List all available agent types and their descriptions
|
||||
|
||||
## Memory
|
||||
- **MemorySave**: Save a persistent memory entry (user or project scope)
|
||||
- **MemoryDelete**: Delete a persistent memory entry by name
|
||||
- **MemorySearch**: Search memories by keyword (set use_ai=true for AI ranking)
|
||||
- **MemoryList**: List all memories with type, scope, age, and description
|
||||
|
||||
## Skills
|
||||
- **Skill**: Invoke a named skill (reusable prompt template) by name with optional args
|
||||
- **SkillList**: List all available skills with names, triggers, and descriptions
|
||||
|
||||
# Guidelines
|
||||
- Be concise and direct. Lead with the answer.
|
||||
- Prefer editing existing files over creating new ones.
|
||||
@@ -27,6 +52,12 @@ You help users with software engineering tasks: writing code, debugging, refacto
|
||||
- For multi-step tasks, work through them systematically.
|
||||
- If a task is unclear, ask for clarification before proceeding.
|
||||
|
||||
## Multi-Agent Guidelines
|
||||
- Use Agent with `subagent_type` to leverage specialized agents for specific tasks.
|
||||
- Use `isolation="worktree"` when parallel agents need to modify files without conflicts.
|
||||
- Use `wait=false` + `name=...` to run multiple agents in parallel, then collect results.
|
||||
- Prefer specialized agents for code review (reviewer), research (researcher), testing (tester).
|
||||
|
||||
# Environment
|
||||
- Current date: {date}
|
||||
- Working directory: {cwd}
|
||||
@@ -91,10 +122,14 @@ def get_claude_md() -> str:
|
||||
|
||||
def build_system_prompt() -> str:
|
||||
import platform
|
||||
return SYSTEM_PROMPT_TEMPLATE.format(
|
||||
prompt = SYSTEM_PROMPT_TEMPLATE.format(
|
||||
date=datetime.now().strftime("%Y-%m-%d %A"),
|
||||
cwd=str(Path.cwd()),
|
||||
platform=platform.system(),
|
||||
git_info=get_git_info(),
|
||||
claude_md=get_claude_md(),
|
||||
)
|
||||
memory_ctx = get_memory_context()
|
||||
if memory_ctx:
|
||||
prompt += f"\n\n# Memory\nYour persistent memories:\n{memory_ctx}\n"
|
||||
return prompt
|
||||
|
||||
374
nano-claude-code/docs/architecture.md
Normal file
374
nano-claude-code/docs/architecture.md
Normal file
@@ -0,0 +1,374 @@
|
||||
# Architecture Guide
|
||||
|
||||
This document is for developers who want to understand, modify, or extend nano-claude-code.
|
||||
For user-facing docs, see [README.md](../README.md).
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Nano-claude-code is a ~3.4K-line Python CLI that lets LLMs (GPT, Gemini, etc.) operate as
|
||||
coding agents with tool use, memory, sub-agents, and skills. The architecture is a flat
|
||||
module layout designed for readability and future migration to a package structure.
|
||||
|
||||
```
|
||||
User Input
|
||||
│
|
||||
▼
|
||||
nano_claude.py ── REPL, slash commands, rendering
|
||||
│
|
||||
├──► agent.py ── multi-turn loop, permission gates
|
||||
│ │
|
||||
│ ├──► providers.py ── API streaming (Anthropic / OpenAI-compat)
|
||||
│ ├──► tool_registry.py ──► tools.py ── 13 tools
|
||||
│ ├──► compaction.py ── context window management
|
||||
│ └──► subagent.py ── threaded sub-agent lifecycle
|
||||
│
|
||||
├──► context.py ── system prompt (git, CLAUDE.md, memory)
|
||||
│ └──► memory.py ── persistent file-based memory
|
||||
│
|
||||
├──► skills.py ── markdown skill loading + execution
|
||||
└──► config.py ── configuration persistence
|
||||
```
|
||||
|
||||
**Key invariant:** Dependencies flow downward. No circular imports at the module level
|
||||
(subagent.py uses lazy imports to call agent.py).
|
||||
|
||||
---
|
||||
|
||||
## Module Reference
|
||||
|
||||
### `tool_registry.py` — Tool Plugin System
|
||||
|
||||
The central registry that all tools register into. This is the foundation for extensibility.
|
||||
|
||||
**Data model:**
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class ToolDef:
|
||||
name: str # unique identifier (e.g. "Read", "MemorySave")
|
||||
schema: dict # JSON schema sent to the LLM API
|
||||
func: Callable # (params: dict, config: dict) -> str
|
||||
read_only: bool # True = auto-approve in 'auto' permission mode
|
||||
concurrent_safe: bool # True = safe to run in parallel (for sub-agents)
|
||||
```
|
||||
|
||||
**Public API:**
|
||||
|
||||
| Function | Description |
|
||||
|---|---|
|
||||
| `register_tool(tool_def)` | Add a tool to the registry (overwrites by name) |
|
||||
| `get_tool(name)` | Look up by name, returns `None` if not found |
|
||||
| `get_all_tools()` | List all registered tools |
|
||||
| `get_tool_schemas()` | Return schemas for API calls |
|
||||
| `execute_tool(name, params, config, max_output=32000)` | Execute with output truncation |
|
||||
| `clear_registry()` | Reset — for testing only |
|
||||
|
||||
**Output truncation:** If a tool returns more than `max_output` chars, the result is
|
||||
truncated to `first_half + [... N chars truncated ...] + last_quarter`. This prevents
|
||||
a single tool call (e.g. reading a huge file) from blowing up the context window.
|
||||
|
||||
**Registering a custom tool:**
|
||||
|
||||
```python
|
||||
from tool_registry import ToolDef, register_tool
|
||||
|
||||
def my_tool(params, config):
|
||||
return f"Hello, {params['name']}!"
|
||||
|
||||
register_tool(ToolDef(
|
||||
name="MyTool",
|
||||
schema={
|
||||
"name": "MyTool",
|
||||
"description": "A greeting tool",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}},
|
||||
"required": ["name"],
|
||||
},
|
||||
},
|
||||
func=my_tool,
|
||||
read_only=True,
|
||||
concurrent_safe=True,
|
||||
))
|
||||
```
|
||||
|
||||
### `tools.py` — Built-in Tool Implementations
|
||||
|
||||
Contains the 8 core tools (Read, Write, Edit, Bash, Glob, Grep, WebFetch, WebSearch)
|
||||
plus memory tools (MemorySave, MemoryDelete) and sub-agent tools (Agent, CheckAgentResult,
|
||||
ListAgentTasks). All register themselves via `tool_registry` at import time.
|
||||
|
||||
**Key internals:**
|
||||
|
||||
- `_is_safe_bash(cmd)` — whitelist of safe shell commands for auto-approval
|
||||
- `generate_unified_diff(old, new, filename)` — diff generation for Edit/Write
|
||||
- `maybe_truncate_diff(diff_text, max_lines=80)` — truncate large diffs for display
|
||||
- `_get_agent_manager()` — lazy singleton for SubAgentManager
|
||||
- Backward-compatible `execute_tool(name, inputs, permission_mode, ask_permission)` wrapper
|
||||
|
||||
### `agent.py` — Core Agent Loop
|
||||
|
||||
The heart of the system. `run()` is a generator that yields events as they happen.
|
||||
|
||||
```python
|
||||
def run(user_message, state, config, system_prompt,
|
||||
depth=0, cancel_check=None) -> Generator:
|
||||
```
|
||||
|
||||
**Loop logic:**
|
||||
|
||||
```
|
||||
1. Append user message
|
||||
2. Inject depth into config (for sub-agent depth tracking)
|
||||
3. While True:
|
||||
a. Check cancel_check() — cooperative cancellation for sub-agents
|
||||
b. maybe_compact(state, config) — compress if near context limit
|
||||
c. Stream from provider → yield TextChunk / ThinkingChunk
|
||||
d. Record assistant message
|
||||
e. If no tool_calls → break
|
||||
f. For each tool_call:
|
||||
- Permission check (_check_permission)
|
||||
- If denied → yield PermissionRequest → user decides
|
||||
- Execute tool → yield ToolStart / ToolEnd
|
||||
- Append tool result
|
||||
g. Loop (model sees tool results and responds)
|
||||
```
|
||||
|
||||
**Event types:**
|
||||
|
||||
| Event | Fields | When |
|
||||
|---|---|---|
|
||||
| `TextChunk` | `text` | Streaming text delta |
|
||||
| `ThinkingChunk` | `text` | Extended thinking block |
|
||||
| `ToolStart` | `name, inputs` | Before tool execution |
|
||||
| `ToolEnd` | `name, result, permitted` | After tool execution |
|
||||
| `PermissionRequest` | `description, granted` | Needs user approval |
|
||||
| `TurnDone` | `input_tokens, output_tokens` | End of one API turn |
|
||||
|
||||
### `compaction.py` — Context Window Management
|
||||
|
||||
Keeps conversations within model context limits using two layers.
|
||||
|
||||
**Layer 1: Snip** (`snip_old_tool_results`)
|
||||
- Rule-based, no API cost
|
||||
- Truncates tool-role messages older than `preserve_last_n_turns` (default 6)
|
||||
- Keeps first half + last quarter of the content
|
||||
|
||||
**Layer 2: Auto-Compact** (`compact_messages`)
|
||||
- Model-driven: calls the current model to summarize old messages
|
||||
- Splits messages into [old | recent] at ~70/30 ratio
|
||||
- Replaces old messages with a summary + acknowledgment
|
||||
|
||||
**Trigger:** `maybe_compact()` checks `estimate_tokens(messages) > context_limit * 0.7`.
|
||||
Runs snip first (cheap), then auto-compact if still over.
|
||||
|
||||
**Token estimation:** `len(content) / 3.5` — simple heuristic. Works for most models.
|
||||
`get_context_limit(model)` reads from the provider registry.
|
||||
|
||||
### `memory.py` — Persistent Memory
|
||||
|
||||
File-based memory system stored in `~/.nano_claude/memory/`.
|
||||
|
||||
**Storage format:**
|
||||
|
||||
```
|
||||
~/.nano_claude/memory/
|
||||
├── MEMORY.md # Index: one line per memory
|
||||
├── user_preferences.md # Individual memory file
|
||||
└── project_auth.md
|
||||
```
|
||||
|
||||
Each memory file uses markdown with YAML frontmatter:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: user preferences
|
||||
description: coding style preferences
|
||||
type: feedback
|
||||
created: 2026-04-02
|
||||
---
|
||||
|
||||
User prefers 4-space indentation and type hints.
|
||||
```
|
||||
|
||||
**How it integrates:**
|
||||
- `get_memory_context()` returns the MEMORY.md index text
|
||||
- `context.py` injects this into the system prompt
|
||||
- The model reads the index, then uses `Read` tool to access full memory content
|
||||
- The model uses `MemorySave` / `MemoryDelete` tools to manage memories
|
||||
|
||||
### `subagent.py` — Threaded Sub-Agents
|
||||
|
||||
Sub-agents run in background threads via `ThreadPoolExecutor`.
|
||||
|
||||
**Key design decisions:**
|
||||
|
||||
1. **Fresh context** — each sub-agent starts with empty message history + task prompt
|
||||
2. **Depth limiting** — `max_depth=3`, checked at spawn time. Model gets an error message
|
||||
(not silent tool removal) so it can adapt.
|
||||
3. **Cooperative cancellation** — `cancel_check` callable checked each loop iteration.
|
||||
Python threads can't be killed safely, so we set a flag.
|
||||
4. **Threading, not asyncio** — the entire codebase is synchronous generators. Threading
|
||||
via `concurrent.futures` keeps things simple. The SubAgentManager API is designed to
|
||||
be compatible with a future async migration.
|
||||
|
||||
**Lifecycle:**
|
||||
|
||||
```
|
||||
spawn(prompt, config, system_prompt, depth)
|
||||
→ Creates SubAgentTask
|
||||
→ Submits _run to ThreadPoolExecutor
|
||||
→ _run calls agent.run() with depth+1
|
||||
|
||||
wait(task_id, timeout) → blocks until complete
|
||||
cancel(task_id) → sets _cancel_flag
|
||||
get_result(task_id) → returns result string
|
||||
```
|
||||
|
||||
### `skills.py` — Reusable Prompt Templates
|
||||
|
||||
Skills are markdown files with frontmatter. They are **not code** — just structured prompts
|
||||
that get injected into the agent loop.
|
||||
|
||||
**Skill file format:**
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: commit
|
||||
description: Create a conventional commit
|
||||
triggers: ["/commit"]
|
||||
tools: [Bash, Read]
|
||||
---
|
||||
|
||||
Your prompt instructions here...
|
||||
```
|
||||
|
||||
**Execution:** `execute_skill()` wraps the skill prompt as a user message and calls
|
||||
`agent.run()`. The skill runs through the exact same agent loop as a normal query.
|
||||
|
||||
**Search order:** Project-level (`./.nano_claude/skills/`) overrides user-level
|
||||
(`~/.nano_claude/skills/`) when skill names collide.
|
||||
|
||||
### `providers.py` — Multi-Provider Abstraction
|
||||
|
||||
Two streaming adapters cover all providers:
|
||||
|
||||
| Adapter | Providers |
|
||||
|---|---|
|
||||
| `stream_anthropic()` | Anthropic (native SDK) |
|
||||
| `stream_openai_compat()` | OpenAI, Gemini, Kimi, Qwen, Zhipu, DeepSeek, Ollama, LM Studio, Custom |
|
||||
|
||||
**Neutral message format** (provider-independent):
|
||||
|
||||
```python
|
||||
{"role": "user", "content": "..."}
|
||||
{"role": "assistant", "content": "...", "tool_calls": [{"id": "...", "name": "...", "input": {...}}]}
|
||||
{"role": "tool", "tool_call_id": "...", "name": "...", "content": "..."}
|
||||
```
|
||||
|
||||
Conversion functions: `messages_to_anthropic()`, `messages_to_openai()`, `tools_to_openai()`.
|
||||
|
||||
**Provider-specific handling:**
|
||||
- Gemini 3 models require `thought_signature` in tool call responses — this is transparently
|
||||
captured and passed through via `extra_content` on tool_call dicts.
|
||||
|
||||
### `context.py` — System Prompt Builder
|
||||
|
||||
Assembles the system prompt from:
|
||||
1. Base template (role, date, cwd, platform)
|
||||
2. Git info (branch, status, recent commits)
|
||||
3. CLAUDE.md content (project-level + global)
|
||||
4. Memory index (from `memory.get_memory_context()`)
|
||||
|
||||
### `config.py` — Configuration
|
||||
|
||||
Defaults stored in `~/.nano_claude/config.json`. Key settings:
|
||||
|
||||
| Key | Default | Description |
|
||||
|---|---|---|
|
||||
| `model` | `claude-opus-4-6` | Active model |
|
||||
| `max_tokens` | `8192` | Max output tokens |
|
||||
| `permission_mode` | `auto` | Permission mode |
|
||||
| `max_tool_output` | `32000` | Tool output truncation limit |
|
||||
| `max_agent_depth` | `3` | Max sub-agent nesting |
|
||||
| `max_concurrent_agents` | `3` | Thread pool size |
|
||||
|
||||
---
|
||||
|
||||
## Data Flow Example
|
||||
|
||||
A user asks "Read config.py and change max_tokens to 16384":
|
||||
|
||||
```
|
||||
1. nano_claude.py captures input
|
||||
2. agent.run() appends user message, calls maybe_compact()
|
||||
3. providers.stream() sends to Gemini API with 13 tool schemas
|
||||
4. Model responds: text + tool_call[Read(config.py)]
|
||||
5. agent.py checks permission (Read = read_only → auto-approve)
|
||||
6. tool_registry.execute_tool("Read", ...) → file content (truncated if >32K)
|
||||
7. Tool result appended to messages, loop back to step 3
|
||||
8. Model responds: text + tool_call[Edit(config.py, "8192", "16384")]
|
||||
9. agent.py checks permission (Edit = not read_only → ask user)
|
||||
10. User approves → tools.py._edit() runs, generates diff
|
||||
11. nano_claude.py renders diff with ANSI colors (red/green)
|
||||
12. Tool result appended, loop back to step 3
|
||||
13. Model responds: "Done, max_tokens changed to 16384"
|
||||
14. No tool_calls → loop ends, TurnDone yielded
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run all 78 tests
|
||||
python -m pytest tests/ -v
|
||||
|
||||
# Run specific module tests
|
||||
python -m pytest tests/test_tool_registry.py -v
|
||||
python -m pytest tests/test_compaction.py -v
|
||||
python -m pytest tests/test_memory.py -v
|
||||
python -m pytest tests/test_subagent.py -v
|
||||
python -m pytest tests/test_skills.py -v
|
||||
python -m pytest tests/test_diff_view.py -v
|
||||
```
|
||||
|
||||
Tests use `monkeypatch` and `tmp_path` fixtures to avoid side effects.
|
||||
Sub-agent tests mock `_agent_run` to avoid real API calls.
|
||||
|
||||
---
|
||||
|
||||
## Future: Package Refactoring
|
||||
|
||||
When `tools.py` or `agent.py` grow too large, the flat layout can be migrated to:
|
||||
|
||||
```
|
||||
ncc/
|
||||
├── __init__.py
|
||||
├── repl.py # from nano_claude.py
|
||||
├── agent/
|
||||
│ ├── loop.py # from agent.py
|
||||
│ ├── subagent.py # from subagent.py
|
||||
│ └── compaction.py # from compaction.py
|
||||
├── providers/
|
||||
│ ├── base.py
|
||||
│ ├── openai_compat.py
|
||||
│ └── registry.py
|
||||
├── tools/
|
||||
│ ├── registry.py # from tool_registry.py
|
||||
│ ├── builtin.py # core 8 tools from tools.py
|
||||
│ ├── memory.py # MemorySave/MemoryDelete from tools.py
|
||||
│ └── subagent.py # Agent/Check/List from tools.py
|
||||
├── memory/
|
||||
│ └── store.py # from memory.py
|
||||
├── skills/
|
||||
│ └── loader.py # from skills.py
|
||||
└── config.py
|
||||
```
|
||||
|
||||
The current code is structured to make this migration straightforward:
|
||||
- Modules communicate via function parameters, not globals
|
||||
- Each module has a small public API surface
|
||||
- Dependencies are unidirectional
|
||||
BIN
nano-claude-code/docs/logo-v1.png
Normal file
BIN
nano-claude-code/docs/logo-v1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 191 KiB |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,643 @@
|
||||
# Open-CC: Nano Claude Code Enhancement Design
|
||||
|
||||
**Date:** 2026-04-02
|
||||
**Status:** Approved
|
||||
**Target:** GPT-5.4, Gemini 3/3.1 Pro (Claude not in scope)
|
||||
**Code budget:** ~10K lines total (currently ~2.2K)
|
||||
**Constraint:** PR-friendly, mergeable back to nano-claude-code upstream
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Evolve nano-claude-code from a minimal ~2.2K-line reference implementation into a capable AI coding CLI, approaching Claude Code's core functionality while staying lean. Five enhancement areas:
|
||||
|
||||
1. **Context Window Management** (`compaction.py`)
|
||||
2. **Tool System Enhancement** (`tool_registry.py` + `tools.py` refactor)
|
||||
3. **Sub-Agent** (`subagent.py`)
|
||||
4. **Memory System** (`memory.py`)
|
||||
5. **Skills System** (`skills.py`)
|
||||
|
||||
### Strategy
|
||||
|
||||
**Approach A: Layered Enhancement** -- add new modules alongside existing files, minimize changes to existing code. When agent.py grows too complex, refactor into Approach B (package structure under `ncc/`).
|
||||
|
||||
### Design Principles
|
||||
|
||||
- Modules communicate via function parameters / dataclasses, no globals
|
||||
- Each new module exposes 2-3 public functions, internals self-contained
|
||||
- New logic in agent.py grouped by clear `# --- section ---` comments
|
||||
- All code in English (comments, docstrings, commit messages)
|
||||
|
||||
---
|
||||
|
||||
## 2. File Structure
|
||||
|
||||
```
|
||||
nano-claude-code/
|
||||
├── nano_claude.py # REPL -- add /memory, /skill slash commands
|
||||
├── agent.py # Agent loop -- add compaction call + sub-agent dispatch
|
||||
├── providers.py # No changes (already solid)
|
||||
├── tools.py # Refactor: register built-in tools via registry
|
||||
├── context.py # Extend: inject memory context
|
||||
├── config.py # Add new config keys
|
||||
│
|
||||
├── compaction.py # NEW: Context window management
|
||||
├── subagent.py # NEW: Sub-agent lifecycle
|
||||
├── memory.py # NEW: File-based memory system
|
||||
├── skills.py # NEW: Skill loading and execution
|
||||
└── tool_registry.py # NEW: Tool plugin registry
|
||||
```
|
||||
|
||||
### Module Dependency Graph (unidirectional)
|
||||
|
||||
```
|
||||
nano_claude.py
|
||||
├-> agent.py
|
||||
│ ├-> providers.py
|
||||
│ ├-> tool_registry.py -> tools.py (built-in implementations)
|
||||
│ ├-> compaction.py -> providers.py (for summary model call)
|
||||
│ └-> subagent.py (calls agent.py:run recursively)
|
||||
├-> context.py -> memory.py
|
||||
├-> skills.py -> tool_registry.py
|
||||
└-> config.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Context Window Management (`compaction.py`)
|
||||
|
||||
Two-layer compression, inspired by Claude Code's three-layer strategy (Layer 3 contextCollapse is experimental, deferred).
|
||||
|
||||
### 3.1 Layer 1: Auto-Compact (model-driven summary)
|
||||
|
||||
Triggered when estimated token count exceeds 70% of model's context limit.
|
||||
|
||||
```python
|
||||
def compact_messages(messages: list[dict], config: dict) -> list[dict]:
|
||||
"""
|
||||
Split messages into [old | recent].
|
||||
Summarize old via model call.
|
||||
Return [summary_msg, ack_msg, *recent].
|
||||
"""
|
||||
split_point = find_split_point(messages, keep_ratio=0.3)
|
||||
old = messages[:split_point]
|
||||
recent = messages[split_point:]
|
||||
summary = call_model_for_summary(old, config)
|
||||
return [
|
||||
{"role": "user", "content": f"[Conversation summary]\n{summary}"},
|
||||
{"role": "assistant", "content": "Understood, I have the context."},
|
||||
*recent
|
||||
]
|
||||
```
|
||||
|
||||
### 3.2 Layer 2: Tool-Result Snipping (rule-based)
|
||||
|
||||
Truncate old tool outputs without model call. Fast and cheap.
|
||||
|
||||
```python
|
||||
def snip_old_tool_results(messages: list[dict], max_chars: int = 2000) -> list[dict]:
|
||||
"""
|
||||
For tool results older than N turns, truncate to max_chars.
|
||||
Preserve first/last lines, add [snipped N chars] marker.
|
||||
"""
|
||||
```
|
||||
|
||||
### 3.3 Token Estimation
|
||||
|
||||
```python
|
||||
def estimate_tokens(messages: list[dict]) -> int:
|
||||
"""Use tiktoken for GPT models, chars/3.5 fallback."""
|
||||
|
||||
def get_context_limit(model: str) -> int:
|
||||
"""Return context window size from provider registry."""
|
||||
```
|
||||
|
||||
### 3.4 Integration Point
|
||||
|
||||
```python
|
||||
# In agent.py run() loop, before each API call:
|
||||
def _maybe_compact(state: AgentState, config: dict) -> bool:
|
||||
token_count = estimate_tokens(state.messages)
|
||||
threshold = get_context_limit(config["model"]) * 0.7
|
||||
if token_count > threshold:
|
||||
state.messages = compact_messages(state.messages, config)
|
||||
return True
|
||||
return False
|
||||
```
|
||||
|
||||
### 3.5 Public API
|
||||
|
||||
```python
|
||||
maybe_compact(state: AgentState, config: dict) -> bool
|
||||
estimate_tokens(messages: list[dict]) -> int
|
||||
get_context_limit(model: str) -> int
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Tool System Enhancement (`tool_registry.py` + `tools.py`)
|
||||
|
||||
### 4.1 Tool Registry
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class ToolDef:
|
||||
name: str
|
||||
schema: dict # JSON schema for parameters
|
||||
func: Callable # (params: dict, config: dict) -> str
|
||||
read_only: bool # True = auto-approve in 'auto' mode
|
||||
concurrent_safe: bool # True = safe for parallel sub-agent use
|
||||
|
||||
_TOOLS: dict[str, ToolDef] = {}
|
||||
|
||||
def register_tool(tool_def: ToolDef) -> None
|
||||
def get_tool(name: str) -> ToolDef | None
|
||||
def get_all_tools() -> list[ToolDef]
|
||||
def get_tool_schemas() -> list[dict]
|
||||
def execute_tool(name: str, params: dict, config: dict) -> str
|
||||
```
|
||||
|
||||
### 4.2 Tool Output Truncation
|
||||
|
||||
Prevent oversized tool outputs (e.g., `cat` large file, `ls -R`) from blowing up context
|
||||
before compaction even gets a chance to run. Applied at the `execute_tool` boundary:
|
||||
|
||||
```python
|
||||
MAX_TOOL_OUTPUT = 32_000 # ~8K tokens, configurable per tool
|
||||
|
||||
def execute_tool(name, params, config):
|
||||
tool = get_tool(name)
|
||||
result = tool.func(params, config)
|
||||
|
||||
# Immediate truncation at source
|
||||
if len(result) > MAX_TOOL_OUTPUT:
|
||||
head = result[:MAX_TOOL_OUTPUT // 2]
|
||||
tail = result[-MAX_TOOL_OUTPUT // 4:]
|
||||
snipped = len(result) - len(head) - len(tail)
|
||||
result = f"{head}\n\n[... {snipped} chars truncated ...]\n\n{tail}"
|
||||
|
||||
return result
|
||||
```
|
||||
|
||||
Additionally, `Bash` tool caps `subprocess` stdout reads to prevent unbounded
|
||||
output (e.g., `cat /dev/urandom`).
|
||||
|
||||
This creates a two-layer defense:
|
||||
- **Layer 0 (here):** hard truncation at tool execution time — prevents oversized messages
|
||||
- **Layer 2 (compaction.py snip):** soft truncation of old tool results — reclaims context space
|
||||
|
||||
### 4.3 Built-in Tools Refactor
|
||||
|
||||
Existing tools.py implementations unchanged. Wrap each with `register_tool()` at module load:
|
||||
|
||||
```python
|
||||
register_tool(ToolDef(
|
||||
name="Read", schema=READ_SCHEMA, func=_read_file,
|
||||
read_only=True, concurrent_safe=True
|
||||
))
|
||||
```
|
||||
|
||||
### 4.3 Permission Logic (unified)
|
||||
|
||||
```python
|
||||
# agent.py
|
||||
def _check_permission(tool_name, params, config):
|
||||
tool = get_tool(tool_name)
|
||||
if config["permission_mode"] == "accept-all":
|
||||
return True
|
||||
if tool.read_only:
|
||||
return True
|
||||
if tool_name == "Bash" and _is_safe_command(params["command"]):
|
||||
return True
|
||||
return None # ask user
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Sub-Agent (`subagent.py`)
|
||||
|
||||
### 5.1 Data Model
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class SubAgentTask:
|
||||
id: str
|
||||
prompt: str
|
||||
status: str # "pending" | "running" | "completed" | "failed" | "cancelled"
|
||||
messages: list[dict] # independent message history
|
||||
result: str | None
|
||||
model: str | None # optional model override
|
||||
depth: int = 0 # recursion depth counter
|
||||
_cancel_flag: bool = False
|
||||
_future: Future | None = None
|
||||
|
||||
@dataclass
|
||||
class SubAgentManager:
|
||||
tasks: dict[str, SubAgentTask] = field(default_factory=dict)
|
||||
max_concurrent: int = 3
|
||||
max_depth: int = 3
|
||||
_pool: ThreadPoolExecutor = field(default_factory=
|
||||
lambda: ThreadPoolExecutor(max_workers=3))
|
||||
|
||||
def spawn(self, prompt, config, system_prompt, depth=0) -> SubAgentTask
|
||||
def get_result(self, task_id) -> str | None
|
||||
def list_tasks(self) -> list[SubAgentTask]
|
||||
def cancel(self, task_id) -> bool
|
||||
def wait(self, task_id, timeout=None) -> SubAgentTask
|
||||
```
|
||||
|
||||
### 5.2 Execution Model — Threading from Day 1
|
||||
|
||||
Sub-agents run in background threads via `ThreadPoolExecutor`. This enables:
|
||||
- Non-blocking spawn (main agent continues or waits by choice)
|
||||
- Cancellation via cooperative flag
|
||||
- Concurrent sub-agents (up to `max_concurrent`)
|
||||
|
||||
```python
|
||||
def spawn(self, prompt, config, system_prompt, depth=0):
|
||||
if depth >= self.max_depth:
|
||||
return SubAgentTask(status="failed",
|
||||
result="Error: max sub-agent depth reached.")
|
||||
|
||||
task = SubAgentTask(id=uuid4().hex[:8], prompt=prompt,
|
||||
status="running", depth=depth, ...)
|
||||
|
||||
def _run():
|
||||
sub_state = AgentState()
|
||||
try:
|
||||
for event in agent.run(
|
||||
prompt, sub_state, config, system_prompt,
|
||||
depth=depth + 1,
|
||||
cancel_check=lambda: task._cancel_flag
|
||||
):
|
||||
if isinstance(event, TurnDone):
|
||||
task.result = extract_final_text(sub_state.messages)
|
||||
task.status = "completed"
|
||||
except Exception as e:
|
||||
task.result = f"Error: {e}"
|
||||
task.status = "failed"
|
||||
|
||||
task._future = self._pool.submit(_run)
|
||||
self.tasks[task.id] = task
|
||||
return task
|
||||
```
|
||||
|
||||
### 5.3 Cooperative Cancellation
|
||||
|
||||
Python threads cannot be killed safely. Instead, `agent.run()` checks a
|
||||
`cancel_check` callable each loop iteration:
|
||||
|
||||
```python
|
||||
# agent.py run() — new parameter
|
||||
def run(user_message, state, config, system_prompt,
|
||||
depth=0, cancel_check=None):
|
||||
...
|
||||
while True:
|
||||
if cancel_check and cancel_check():
|
||||
return # clean exit
|
||||
for event in stream(...):
|
||||
yield event
|
||||
...
|
||||
```
|
||||
|
||||
### 5.4 Depth Limiting (No Tool Removal)
|
||||
|
||||
Sub-agents CAN call Agent tool (enabling A -> B -> C chains). Depth is
|
||||
passed through, and the Agent tool returns an error at `max_depth`:
|
||||
|
||||
```python
|
||||
def _agent_tool_func(params, config, depth=0):
|
||||
if depth >= manager.max_depth:
|
||||
return ("Error: max sub-agent depth reached. "
|
||||
"Complete this task directly without spawning sub-agents.")
|
||||
return manager.spawn(params["prompt"], config, system_prompt, depth)
|
||||
```
|
||||
|
||||
The model sees the error and adapts — no silent capability removal.
|
||||
|
||||
### 5.5 Context Strategy
|
||||
|
||||
Sub-agent gets **fresh context** (no parent message history):
|
||||
|
||||
```python
|
||||
sub_system_prompt = f"""You are a sub-agent. Your task:
|
||||
{prompt}
|
||||
|
||||
Working directory: {cwd}
|
||||
{memory_context}
|
||||
"""
|
||||
```
|
||||
|
||||
### 5.6 Tool Registration — 3 Tools
|
||||
|
||||
The sub-agent system registers three tools:
|
||||
|
||||
**Agent** — spawn a sub-agent:
|
||||
|
||||
```python
|
||||
AGENT_SCHEMA = {
|
||||
"name": "Agent",
|
||||
"description": "Launch a sub-agent to handle a task independently.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt": {"type": "string", "description": "Task description"},
|
||||
"model": {"type": "string", "description": "Optional model override"},
|
||||
"wait": {"type": "boolean", "default": True,
|
||||
"description": "True = block until done (default). "
|
||||
"False = return task_id immediately."}
|
||||
},
|
||||
"required": ["prompt"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `wait=True` (default): spawn + block + return result. Feels synchronous to model.
|
||||
- `wait=False`: spawn + return task_id immediately. Model must use CheckAgentResult later.
|
||||
|
||||
**CheckAgentResult** — poll a background sub-agent:
|
||||
|
||||
```python
|
||||
CHECK_AGENT_RESULT_SCHEMA = {
|
||||
"name": "CheckAgentResult",
|
||||
"description": "Check the result of a background sub-agent task.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": {"type": "string", "description": "Task ID from Agent tool"}
|
||||
},
|
||||
"required": ["task_id"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Returns: status + result (if completed), or status + "still running".
|
||||
|
||||
**ListAgentTasks** — overview of all sub-agents:
|
||||
|
||||
```python
|
||||
LIST_AGENT_TASKS_SCHEMA = {
|
||||
"name": "ListAgentTasks",
|
||||
"description": "List all sub-agent tasks and their status.",
|
||||
"input_schema": {"type": "object", "properties": {}}
|
||||
}
|
||||
```
|
||||
|
||||
Returns a table of `[id, status, prompt_preview]` for all tasks.
|
||||
|
||||
---
|
||||
|
||||
## 6. Memory System (`memory.py`)
|
||||
|
||||
### 6.1 Storage
|
||||
|
||||
```
|
||||
~/.nano_claude/memory/
|
||||
├── MEMORY.md # Index file (max 200 lines)
|
||||
├── user_role.md # Individual memory files
|
||||
├── feedback_testing.md
|
||||
└── ...
|
||||
```
|
||||
|
||||
Memory file format:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: user role
|
||||
description: user is a data scientist focused on logging
|
||||
type: user
|
||||
created: 2026-04-02
|
||||
---
|
||||
|
||||
User is a data scientist, currently investigating observability/logging.
|
||||
```
|
||||
|
||||
### 6.2 Public API
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class MemoryEntry:
|
||||
name: str
|
||||
description: str
|
||||
type: str # "user" | "feedback" | "project" | "reference"
|
||||
content: str
|
||||
file_path: str
|
||||
created: str
|
||||
|
||||
def load_index() -> list[MemoryEntry]
|
||||
def save_memory(entry: MemoryEntry) -> None
|
||||
def delete_memory(name: str) -> None
|
||||
def search_memory(query: str) -> list[MemoryEntry]
|
||||
def get_memory_context() -> str # for system prompt injection
|
||||
```
|
||||
|
||||
### 6.3 Tool Registration
|
||||
|
||||
Two tools for model-driven memory management:
|
||||
|
||||
- **MemorySave**: `{name, type, description, content}` -> write file + update index
|
||||
- **MemoryDelete**: `{name}` -> remove file + update index
|
||||
|
||||
### 6.4 Context Integration
|
||||
|
||||
`context.py:build_system_prompt()` appends `memory.get_memory_context()` (the MEMORY.md index). Model uses Read tool to access full memory file content when needed.
|
||||
|
||||
---
|
||||
|
||||
## 7. Skills System (`skills.py`)
|
||||
|
||||
### 7.1 Skill Definition
|
||||
|
||||
Markdown files with frontmatter:
|
||||
|
||||
```
|
||||
~/.nano_claude/skills/commit.md
|
||||
```
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: commit
|
||||
description: Create a git commit with conventional format
|
||||
triggers: ["/commit", "commit changes"]
|
||||
tools: [Bash, Read]
|
||||
---
|
||||
|
||||
# Commit Skill
|
||||
|
||||
Analyze staged changes and create a well-formatted commit message.
|
||||
...
|
||||
```
|
||||
|
||||
### 7.2 Search Path
|
||||
|
||||
```python
|
||||
SKILL_PATHS = [
|
||||
Path.cwd() / ".nano_claude" / "skills", # project-level (priority)
|
||||
Path.home() / ".nano_claude" / "skills", # user-level
|
||||
]
|
||||
```
|
||||
|
||||
### 7.3 Public API
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class SkillDef:
|
||||
name: str
|
||||
description: str
|
||||
triggers: list[str]
|
||||
tools: list[str]
|
||||
prompt: str
|
||||
file_path: str
|
||||
|
||||
def load_skills() -> list[SkillDef]
|
||||
def find_skill(query: str) -> SkillDef | None
|
||||
def execute_skill(skill, args, state, config) -> Generator
|
||||
```
|
||||
|
||||
### 7.4 Execution Model
|
||||
|
||||
Skills are just prompts injected into the normal agent loop:
|
||||
|
||||
```python
|
||||
def execute_skill(skill, args, state, config):
|
||||
prompt = f"[Skill: {skill.name}]\n\n{skill.prompt}"
|
||||
if args:
|
||||
prompt += f"\n\nUser context: {args}"
|
||||
system_prompt = build_system_prompt(config)
|
||||
for event in agent.run(prompt, state, config, system_prompt):
|
||||
yield event
|
||||
```
|
||||
|
||||
### 7.5 REPL Integration
|
||||
|
||||
In `nano_claude.py`, unmatched `/` commands fall through to skill lookup:
|
||||
|
||||
```python
|
||||
if user_input.startswith("/"):
|
||||
# Try built-in slash commands first
|
||||
# If no match -> find_skill(user_input)
|
||||
# If skill found -> execute_skill(...)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Diff View for File Modifications
|
||||
|
||||
Core UX improvement: show git-style red/green diff when Edit or Write modifies an existing file.
|
||||
|
||||
### 8.1 Diff Generation (in tools.py)
|
||||
|
||||
Edit and Write tool implementations capture before/after content and generate unified diff:
|
||||
|
||||
```python
|
||||
import difflib
|
||||
|
||||
def generate_unified_diff(old, new, filename, context_lines=3):
|
||||
"""
|
||||
Args:
|
||||
old: original file content, str
|
||||
new: modified file content, str
|
||||
filename: display name, str
|
||||
context_lines: lines of context around changes, int
|
||||
Returns:
|
||||
unified diff string
|
||||
"""
|
||||
old_lines = old.splitlines(keepends=True)
|
||||
new_lines = new.splitlines(keepends=True)
|
||||
diff = difflib.unified_diff(
|
||||
old_lines, new_lines,
|
||||
fromfile=f"a/{filename}", tofile=f"b/{filename}",
|
||||
n=context_lines
|
||||
)
|
||||
return "".join(diff)
|
||||
```
|
||||
|
||||
Tool return values change:
|
||||
- **Edit**: `"Changes applied to {filename}:\n\n{diff}"`
|
||||
- **Write** (existing file): `"File updated:\n\n{diff}"`
|
||||
- **Write** (new file): `"New file created: {filename} ({n} lines)"` (no diff)
|
||||
|
||||
### 8.2 REPL Rendering (in nano_claude.py)
|
||||
|
||||
Detect diff blocks in tool output and render with ANSI colors:
|
||||
|
||||
```python
|
||||
def render_diff(diff_text):
|
||||
for line in diff_text.splitlines():
|
||||
if line.startswith("+++") or line.startswith("---"):
|
||||
print(f"\033[1m{line}\033[0m") # bold
|
||||
elif line.startswith("+"):
|
||||
print(f"\033[32m{line}\033[0m") # green
|
||||
elif line.startswith("-"):
|
||||
print(f"\033[31m{line}\033[0m") # red
|
||||
elif line.startswith("@@"):
|
||||
print(f"\033[36m{line}\033[0m") # cyan
|
||||
else:
|
||||
print(line)
|
||||
```
|
||||
|
||||
### 8.3 Diff Truncation
|
||||
|
||||
For large diffs (e.g., Write replaces entire file), cap the diff display:
|
||||
|
||||
```python
|
||||
MAX_DIFF_LINES = 80
|
||||
|
||||
def maybe_truncate_diff(diff_text):
|
||||
lines = diff_text.splitlines()
|
||||
if len(lines) > MAX_DIFF_LINES:
|
||||
shown = lines[:MAX_DIFF_LINES]
|
||||
remaining = len(lines) - MAX_DIFF_LINES
|
||||
return "\n".join(shown) + f"\n\n[... {remaining} more lines ...]"
|
||||
return diff_text
|
||||
```
|
||||
|
||||
Note: truncation applies to the **display** in REPL only. The full diff is still
|
||||
returned to the model so it can verify the change.
|
||||
|
||||
---
|
||||
|
||||
## 9. Implementation Order
|
||||
|
||||
Each step is an independent PR:
|
||||
|
||||
| Phase | Module | Depends On | Estimated Lines |
|
||||
|-------|--------|-----------|-----------------|
|
||||
| 1 | `tool_registry.py` + `tools.py` refactor | None | ~600 |
|
||||
| 2 | Diff view in `tools.py` + `nano_claude.py` | Phase 1 | ~100 |
|
||||
| 3 | `compaction.py` + agent.py integration | Phase 1 | ~300 |
|
||||
| 4 | `memory.py` + context.py integration | Phase 1 | ~200 |
|
||||
| 5 | `subagent.py` + agent.py integration (threading) | Phase 1 | ~350 |
|
||||
| 6 | `skills.py` + nano_claude.py integration | Phase 1, 4 | ~200 |
|
||||
| 7 | Slash commands + config updates | All above | ~300 |
|
||||
|
||||
**Total new code: ~2050 lines. Grand total: ~4.2K lines.**
|
||||
|
||||
---
|
||||
|
||||
## 10. Key Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| Compression layers | 2 (autoCompact + snip) | Layer 3 is experimental in Claude Code |
|
||||
| Tool output truncation | Hard cap at execute_tool boundary | Prevents oversized outputs before compaction runs |
|
||||
| Sub-agent execution | Threading from day 1 | Sync blocks main agent, can't cancel, can't parallelize |
|
||||
| Sub-agent depth | Depth counter (max 3), no tool removal | Model sees error and adapts; sub-sub-agents allowed |
|
||||
| Sub-agent tools | Agent + CheckAgentResult + ListAgentTasks | Model needs feedback loop for async tasks |
|
||||
| Diff view | difflib unified diff + ANSI colors | Core UX, zero dependencies |
|
||||
| Memory search | Keyword match, no embeddings | Keep simple, model judges relevance |
|
||||
| Skills format | Markdown + frontmatter | Human-readable, git-friendly, no Python needed |
|
||||
| Tool registry | Global dict + register function | Simple, extensible, easy to migrate to package |
|
||||
| Target models | GPT-5.4, Gemini 3/3.1 Pro | User's primary use case |
|
||||
| No Claude support | Intentional | Official Claude Code exists |
|
||||
|
||||
---
|
||||
|
||||
## 11. Future Considerations (Not in Scope)
|
||||
|
||||
- MCP protocol support
|
||||
- Remote skill marketplace
|
||||
- Voice mode
|
||||
- Bridge to desktop apps
|
||||
- contextCollapse (Layer 3 compression)
|
||||
11
nano-claude-code/memory.py
Normal file
11
nano-claude-code/memory.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""Backward-compatibility shim — real implementation is in memory/ package."""
|
||||
from memory.store import ( # noqa: F401
|
||||
MemoryEntry,
|
||||
save_memory,
|
||||
delete_memory,
|
||||
load_index,
|
||||
search_memory,
|
||||
get_index_content,
|
||||
parse_frontmatter,
|
||||
)
|
||||
from memory.context import get_memory_context # noqa: F401
|
||||
86
nano-claude-code/memory/__init__.py
Normal file
86
nano-claude-code/memory/__init__.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Memory package for nano-claude-code.
|
||||
|
||||
Provides persistent, file-based memory across conversations.
|
||||
|
||||
Storage layout:
|
||||
user scope : ~/.nano_claude/memory/<slug>.md (shared across projects)
|
||||
project scope : .nano_claude/memory/<slug>.md (local to cwd)
|
||||
|
||||
The MEMORY.md index in each directory is auto-maintained and injected
|
||||
into the system prompt so Claude has an overview of available memories.
|
||||
|
||||
Public API (backward-compatible with the old memory.py module):
|
||||
MemoryEntry — dataclass for a single memory
|
||||
save_memory() — write/update a memory file
|
||||
delete_memory() — remove a memory file
|
||||
load_index() — load all entries from one or both scopes
|
||||
search_memory() — keyword search across entries
|
||||
get_memory_context() — MEMORY.md content for system prompt injection
|
||||
"""
|
||||
from .store import ( # noqa: F401
|
||||
MemoryEntry,
|
||||
save_memory,
|
||||
delete_memory,
|
||||
load_index,
|
||||
load_entries,
|
||||
search_memory,
|
||||
get_index_content,
|
||||
parse_frontmatter,
|
||||
USER_MEMORY_DIR,
|
||||
INDEX_FILENAME,
|
||||
MAX_INDEX_LINES,
|
||||
MAX_INDEX_BYTES,
|
||||
)
|
||||
from .scan import ( # noqa: F401
|
||||
MemoryHeader,
|
||||
scan_memory_dir,
|
||||
scan_all_memories,
|
||||
format_memory_manifest,
|
||||
memory_age_days,
|
||||
memory_age_str,
|
||||
memory_freshness_text,
|
||||
)
|
||||
from .context import ( # noqa: F401
|
||||
get_memory_context,
|
||||
find_relevant_memories,
|
||||
truncate_index_content,
|
||||
)
|
||||
from .types import ( # noqa: F401
|
||||
MEMORY_TYPES,
|
||||
MEMORY_TYPE_DESCRIPTIONS,
|
||||
MEMORY_SYSTEM_PROMPT,
|
||||
WHAT_NOT_TO_SAVE,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# store
|
||||
"MemoryEntry",
|
||||
"save_memory",
|
||||
"delete_memory",
|
||||
"load_index",
|
||||
"load_entries",
|
||||
"search_memory",
|
||||
"get_index_content",
|
||||
"parse_frontmatter",
|
||||
"USER_MEMORY_DIR",
|
||||
"INDEX_FILENAME",
|
||||
"MAX_INDEX_LINES",
|
||||
"MAX_INDEX_BYTES",
|
||||
# scan
|
||||
"MemoryHeader",
|
||||
"scan_memory_dir",
|
||||
"scan_all_memories",
|
||||
"format_memory_manifest",
|
||||
"memory_age_days",
|
||||
"memory_age_str",
|
||||
"memory_freshness_text",
|
||||
# context
|
||||
"get_memory_context",
|
||||
"find_relevant_memories",
|
||||
"truncate_index_content",
|
||||
# types
|
||||
"MEMORY_TYPES",
|
||||
"MEMORY_TYPE_DESCRIPTIONS",
|
||||
"MEMORY_SYSTEM_PROMPT",
|
||||
"WHAT_NOT_TO_SAVE",
|
||||
]
|
||||
221
nano-claude-code/memory/context.py
Normal file
221
nano-claude-code/memory/context.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""Memory context building for system prompt injection.
|
||||
|
||||
Provides:
|
||||
get_memory_context() — full context string for system prompt
|
||||
find_relevant_memories() — keyword (+ optional AI) relevance filtering
|
||||
truncate_index_content() — line + byte truncation with warning
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .store import (
|
||||
USER_MEMORY_DIR,
|
||||
INDEX_FILENAME,
|
||||
MAX_INDEX_LINES,
|
||||
MAX_INDEX_BYTES,
|
||||
get_memory_dir,
|
||||
get_index_content,
|
||||
load_entries,
|
||||
search_memory,
|
||||
)
|
||||
from .scan import scan_all_memories, format_memory_manifest, memory_freshness_text
|
||||
from .types import MEMORY_SYSTEM_PROMPT
|
||||
|
||||
|
||||
# ── Index truncation ───────────────────────────────────────────────────────
|
||||
|
||||
def truncate_index_content(raw: str) -> str:
|
||||
"""Truncate MEMORY.md content to line AND byte limits, appending a warning.
|
||||
|
||||
Matches Claude Code's truncateEntrypointContent:
|
||||
- Line-truncates first (natural boundary)
|
||||
- Then byte-truncates at the last newline before the cap
|
||||
- Appends which limit fired
|
||||
"""
|
||||
trimmed = raw.strip()
|
||||
content_lines = trimmed.split("\n")
|
||||
line_count = len(content_lines)
|
||||
byte_count = len(trimmed.encode())
|
||||
|
||||
was_line_truncated = line_count > MAX_INDEX_LINES
|
||||
was_byte_truncated = byte_count > MAX_INDEX_BYTES
|
||||
|
||||
if not was_line_truncated and not was_byte_truncated:
|
||||
return trimmed
|
||||
|
||||
truncated = "\n".join(content_lines[:MAX_INDEX_LINES]) if was_line_truncated else trimmed
|
||||
|
||||
if len(truncated.encode()) > MAX_INDEX_BYTES:
|
||||
# Cut at last newline before byte limit
|
||||
raw_bytes = truncated.encode()
|
||||
cut = raw_bytes[:MAX_INDEX_BYTES].rfind(b"\n")
|
||||
truncated = raw_bytes[: cut if cut > 0 else MAX_INDEX_BYTES].decode(errors="replace")
|
||||
|
||||
if was_byte_truncated and not was_line_truncated:
|
||||
reason = f"{byte_count:,} bytes (limit: {MAX_INDEX_BYTES:,}) — index entries are too long"
|
||||
elif was_line_truncated and not was_byte_truncated:
|
||||
reason = f"{line_count} lines (limit: {MAX_INDEX_LINES})"
|
||||
else:
|
||||
reason = f"{line_count} lines and {byte_count:,} bytes"
|
||||
|
||||
warning = (
|
||||
f"\n\n> WARNING: {INDEX_FILENAME} is {reason}. "
|
||||
"Only part of it was loaded. Keep index entries to one line under ~150 chars."
|
||||
)
|
||||
return truncated + warning
|
||||
|
||||
|
||||
# ── System prompt context ──────────────────────────────────────────────────
|
||||
|
||||
def get_memory_context(include_guidance: bool = False) -> str:
|
||||
"""Return memory context for injection into the system prompt.
|
||||
|
||||
Combines user-level and project-level MEMORY.md content (if present).
|
||||
Returns empty string when no memories exist.
|
||||
|
||||
Args:
|
||||
include_guidance: if True, prepend the full memory system guidance
|
||||
(MEMORY_SYSTEM_PROMPT). Normally False since the
|
||||
system prompt template already includes brief guidance.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
|
||||
# User-level index
|
||||
user_content = get_index_content("user")
|
||||
if user_content:
|
||||
truncated = truncate_index_content(user_content)
|
||||
parts.append(truncated)
|
||||
|
||||
# Project-level index (labelled separately)
|
||||
proj_content = get_index_content("project")
|
||||
if proj_content:
|
||||
truncated = truncate_index_content(proj_content)
|
||||
parts.append(f"[Project memories]\n{truncated}")
|
||||
|
||||
if not parts:
|
||||
return ""
|
||||
|
||||
body = "\n\n".join(parts)
|
||||
if include_guidance:
|
||||
return f"{MEMORY_SYSTEM_PROMPT}\n\n## MEMORY.md\n{body}"
|
||||
return body
|
||||
|
||||
|
||||
# ── Relevant memory finder ─────────────────────────────────────────────────
|
||||
|
||||
def find_relevant_memories(
|
||||
query: str,
|
||||
max_results: int = 5,
|
||||
use_ai: bool = False,
|
||||
config: dict | None = None,
|
||||
) -> list[dict]:
|
||||
"""Find memories relevant to a query.
|
||||
|
||||
Strategy:
|
||||
1. Always: keyword match on name + description + content
|
||||
2. If use_ai=True and config has a model: use a small AI call to rank
|
||||
|
||||
Returns:
|
||||
List of dicts with keys: name, description, type, scope, content,
|
||||
file_path, mtime_s, freshness_text
|
||||
"""
|
||||
# Step 1: Keyword filter
|
||||
keyword_results = search_memory(query)
|
||||
if not keyword_results:
|
||||
return []
|
||||
|
||||
if not use_ai or not config:
|
||||
# Return top max_results by recency (newest first)
|
||||
from .scan import scan_all_memories
|
||||
headers = scan_all_memories()
|
||||
path_to_mtime = {h.file_path: h.mtime_s for h in headers}
|
||||
|
||||
results = []
|
||||
for entry in keyword_results[:max_results]:
|
||||
mtime_s = path_to_mtime.get(entry.file_path, 0)
|
||||
results.append({
|
||||
"name": entry.name,
|
||||
"description": entry.description,
|
||||
"type": entry.type,
|
||||
"scope": entry.scope,
|
||||
"content": entry.content,
|
||||
"file_path": entry.file_path,
|
||||
"mtime_s": mtime_s,
|
||||
"freshness_text": memory_freshness_text(mtime_s),
|
||||
})
|
||||
results.sort(key=lambda r: r["mtime_s"], reverse=True)
|
||||
return results[:max_results]
|
||||
|
||||
# Step 2: AI-powered relevance selection (optional, lightweight)
|
||||
return _ai_select_memories(query, keyword_results, max_results, config)
|
||||
|
||||
|
||||
def _ai_select_memories(
|
||||
query: str,
|
||||
candidates: list,
|
||||
max_results: int,
|
||||
config: dict,
|
||||
) -> list[dict]:
|
||||
"""Use a fast AI call to select the most relevant memories from candidates.
|
||||
|
||||
Falls back to keyword results on any error.
|
||||
"""
|
||||
try:
|
||||
from providers import stream, AssistantTurn
|
||||
from .scan import scan_all_memories
|
||||
|
||||
headers = scan_all_memories()
|
||||
path_to_mtime = {h.file_path: h.mtime_s for h in headers}
|
||||
|
||||
# Build manifest of candidates only
|
||||
manifest_lines = []
|
||||
for i, e in enumerate(candidates):
|
||||
manifest_lines.append(f"{i}: [{e.type}] {e.name} — {e.description}")
|
||||
manifest = "\n".join(manifest_lines)
|
||||
|
||||
system = (
|
||||
"You select memories relevant to a query. "
|
||||
"Return a JSON object with key 'indices' containing a list of integer indices "
|
||||
f"(0-based) from the provided list. Select at most {max_results} entries. "
|
||||
"Only include indices clearly relevant to the query. Return {\"indices\": []} if none."
|
||||
)
|
||||
messages = [{"role": "user", "content": f"Query: {query}\n\nMemories:\n{manifest}"}]
|
||||
|
||||
result_text = ""
|
||||
for event in stream(
|
||||
model=config.get("model", "claude-haiku-4-5-20251001"),
|
||||
system=system,
|
||||
messages=messages,
|
||||
tool_schemas=[],
|
||||
config={**config, "max_tokens": 256, "no_tools": True},
|
||||
):
|
||||
if isinstance(event, AssistantTurn):
|
||||
result_text = event.text
|
||||
break
|
||||
|
||||
import json as _json
|
||||
parsed = _json.loads(result_text)
|
||||
selected_indices = [int(i) for i in parsed.get("indices", []) if isinstance(i, int)]
|
||||
|
||||
except Exception:
|
||||
# Fall back to keyword results
|
||||
selected_indices = list(range(min(max_results, len(candidates))))
|
||||
|
||||
results = []
|
||||
for i in selected_indices[:max_results]:
|
||||
if i < 0 or i >= len(candidates):
|
||||
continue
|
||||
entry = candidates[i]
|
||||
mtime_s = path_to_mtime.get(entry.file_path, 0) if "path_to_mtime" in dir() else 0
|
||||
results.append({
|
||||
"name": entry.name,
|
||||
"description": entry.description,
|
||||
"type": entry.type,
|
||||
"scope": entry.scope,
|
||||
"content": entry.content,
|
||||
"file_path": entry.file_path,
|
||||
"mtime_s": mtime_s,
|
||||
"freshness_text": memory_freshness_text(mtime_s),
|
||||
})
|
||||
return results
|
||||
144
nano-claude-code/memory/scan.py
Normal file
144
nano-claude-code/memory/scan.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""Memory file scanning with mtime tracking and freshness/age helpers.
|
||||
|
||||
Mirrors the key ideas from Claude Code's memoryScan.ts and memoryAge.ts:
|
||||
- Scan memory directories, sort newest-first
|
||||
- Format a manifest for display or AI relevance selection
|
||||
- Report memory age in human-readable form ("today", "3 days ago")
|
||||
- Emit a staleness caveat for memories older than 1 day
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .store import get_memory_dir, parse_frontmatter, INDEX_FILENAME
|
||||
|
||||
MAX_MEMORY_FILES = 200
|
||||
|
||||
|
||||
# ── Data model ─────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class MemoryHeader:
|
||||
"""Lightweight descriptor loaded from a memory file's frontmatter.
|
||||
|
||||
Attributes:
|
||||
filename: basename of the .md file
|
||||
file_path: absolute path
|
||||
mtime_s: modification time (seconds since epoch)
|
||||
description: value from frontmatter `description:` field
|
||||
type: value from frontmatter `type:` field
|
||||
scope: "user" or "project"
|
||||
"""
|
||||
filename: str
|
||||
file_path: str
|
||||
mtime_s: float
|
||||
description: str
|
||||
type: str
|
||||
scope: str
|
||||
|
||||
|
||||
# ── Scanning ───────────────────────────────────────────────────────────────
|
||||
|
||||
def scan_memory_dir(mem_dir: Path, scope: str) -> list[MemoryHeader]:
|
||||
"""Scan a single memory directory and return headers sorted newest-first.
|
||||
|
||||
Reads only the frontmatter (first ~30 lines) for efficiency.
|
||||
Silently skips unreadable files. Caps at MAX_MEMORY_FILES entries.
|
||||
"""
|
||||
if not mem_dir.is_dir():
|
||||
return []
|
||||
|
||||
headers: list[MemoryHeader] = []
|
||||
for fp in mem_dir.glob("*.md"):
|
||||
if fp.name == INDEX_FILENAME:
|
||||
continue
|
||||
try:
|
||||
stat = fp.stat()
|
||||
# Read only the first 30 lines for frontmatter
|
||||
lines = fp.read_text(errors="replace").splitlines()[:30]
|
||||
snippet = "\n".join(lines)
|
||||
meta, _ = parse_frontmatter(snippet)
|
||||
headers.append(MemoryHeader(
|
||||
filename=fp.name,
|
||||
file_path=str(fp),
|
||||
mtime_s=stat.st_mtime,
|
||||
description=meta.get("description", ""),
|
||||
type=meta.get("type", ""),
|
||||
scope=scope,
|
||||
))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
headers.sort(key=lambda h: h.mtime_s, reverse=True)
|
||||
return headers[:MAX_MEMORY_FILES]
|
||||
|
||||
|
||||
def scan_all_memories() -> list[MemoryHeader]:
|
||||
"""Scan both user and project memory directories, merged newest-first."""
|
||||
user_dir = get_memory_dir("user")
|
||||
proj_dir = get_memory_dir("project")
|
||||
|
||||
user_headers = scan_memory_dir(user_dir, "user")
|
||||
proj_headers = scan_memory_dir(proj_dir, "project")
|
||||
|
||||
combined = user_headers + proj_headers
|
||||
combined.sort(key=lambda h: h.mtime_s, reverse=True)
|
||||
return combined[:MAX_MEMORY_FILES]
|
||||
|
||||
|
||||
# ── Age / freshness ────────────────────────────────────────────────────────
|
||||
|
||||
def memory_age_days(mtime_s: float) -> int:
|
||||
"""Days since mtime_s (floor-rounded, clamped to 0 for future times)."""
|
||||
return max(0, math.floor((time.time() - mtime_s) / 86_400))
|
||||
|
||||
|
||||
def memory_age_str(mtime_s: float) -> str:
|
||||
"""Human-readable age: 'today', 'yesterday', or 'N days ago'."""
|
||||
d = memory_age_days(mtime_s)
|
||||
if d == 0:
|
||||
return "today"
|
||||
if d == 1:
|
||||
return "yesterday"
|
||||
return f"{d} days ago"
|
||||
|
||||
|
||||
def memory_freshness_text(mtime_s: float) -> str:
|
||||
"""Staleness caveat for memories older than 1 day (empty string if fresh).
|
||||
|
||||
Motivated by user reports of stale code-state memories (file:line
|
||||
citations to code that has since changed) being asserted as fact.
|
||||
"""
|
||||
d = memory_age_days(mtime_s)
|
||||
if d <= 1:
|
||||
return ""
|
||||
return (
|
||||
f"This memory is {d} days old. "
|
||||
"Memories are point-in-time observations, not live state — "
|
||||
"claims about code behavior or file:line citations may be outdated. "
|
||||
"Verify against current code before asserting as fact."
|
||||
)
|
||||
|
||||
|
||||
# ── Manifest formatting ────────────────────────────────────────────────────
|
||||
|
||||
def format_memory_manifest(headers: list[MemoryHeader]) -> str:
|
||||
"""Format a list of MemoryHeader as a text manifest.
|
||||
|
||||
Format per line: [type/scope] filename (age): description
|
||||
Example:
|
||||
[feedback/user] feedback_testing.md (3 days ago): Don't mock DB in tests
|
||||
[project/project] project_freeze.md (today): Merge freeze until 2026-04-10
|
||||
"""
|
||||
lines = []
|
||||
for h in headers:
|
||||
tag = f"[{h.type}/{h.scope}]" if h.type else f"[{h.scope}]"
|
||||
age = memory_age_str(h.mtime_s)
|
||||
if h.description:
|
||||
lines.append(f"- {tag} {h.filename} ({age}): {h.description}")
|
||||
else:
|
||||
lines.append(f"- {tag} {h.filename} ({age})")
|
||||
return "\n".join(lines)
|
||||
223
nano-claude-code/memory/store.py
Normal file
223
nano-claude-code/memory/store.py
Normal file
@@ -0,0 +1,223 @@
|
||||
"""File-based memory storage with user-level and project-level scopes.
|
||||
|
||||
Storage layout:
|
||||
user scope : ~/.nano_claude/memory/<slug>.md
|
||||
project scope : .nano_claude/memory/<slug>.md (relative to cwd)
|
||||
|
||||
MEMORY.md in each directory is the index file — rebuilt automatically after
|
||||
every save/delete. It is loaded into the system prompt to give Claude an
|
||||
overview of available memories.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ── Paths ──────────────────────────────────────────────────────────────────
|
||||
|
||||
USER_MEMORY_DIR = Path.home() / ".nano_claude" / "memory"
|
||||
INDEX_FILENAME = "MEMORY.md"
|
||||
|
||||
# Maximum lines/bytes for the index file (mirrors Claude Code limits)
|
||||
MAX_INDEX_LINES = 200
|
||||
MAX_INDEX_BYTES = 25_000
|
||||
|
||||
|
||||
def get_project_memory_dir() -> Path:
|
||||
"""Return the project-local memory directory (relative to cwd)."""
|
||||
return Path.cwd() / ".nano_claude" / "memory"
|
||||
|
||||
|
||||
def get_memory_dir(scope: str = "user") -> Path:
|
||||
"""Return the memory directory for the given scope.
|
||||
|
||||
Args:
|
||||
scope: "user" (global ~/.nano_claude/memory) or
|
||||
"project" (.nano_claude/memory relative to cwd)
|
||||
"""
|
||||
if scope == "project":
|
||||
return get_project_memory_dir()
|
||||
return USER_MEMORY_DIR
|
||||
|
||||
|
||||
# ── Data model ─────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class MemoryEntry:
|
||||
"""A single memory entry loaded from a .md file.
|
||||
|
||||
Attributes:
|
||||
name: human-readable name (also the display title in the index)
|
||||
description: short one-line description (used for relevance decisions)
|
||||
type: "user" | "feedback" | "project" | "reference"
|
||||
content: body text of the memory
|
||||
file_path: absolute path to the .md file on disk
|
||||
created: date string, e.g. "2026-04-02"
|
||||
scope: "user" | "project" — which directory this was loaded from
|
||||
"""
|
||||
name: str
|
||||
description: str
|
||||
type: str
|
||||
content: str
|
||||
file_path: str = ""
|
||||
created: str = ""
|
||||
scope: str = "user"
|
||||
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _slugify(name: str) -> str:
|
||||
"""Convert name to a filesystem-safe slug (max 60 chars)."""
|
||||
s = name.lower().strip().replace(" ", "_")
|
||||
s = re.sub(r"[^a-z0-9_]", "", s)
|
||||
return s[:60]
|
||||
|
||||
|
||||
def parse_frontmatter(text: str) -> tuple[dict, str]:
|
||||
"""Parse ---\\nkey: value\\n---\\nbody format.
|
||||
|
||||
Returns:
|
||||
(meta_dict, body_str)
|
||||
"""
|
||||
if not text.startswith("---"):
|
||||
return {}, text
|
||||
parts = text.split("---", 2)
|
||||
if len(parts) < 3:
|
||||
return {}, text
|
||||
meta: dict = {}
|
||||
for line in parts[1].strip().splitlines():
|
||||
if ":" in line:
|
||||
key, _, val = line.partition(":")
|
||||
meta[key.strip()] = val.strip()
|
||||
return meta, parts[2].strip()
|
||||
|
||||
|
||||
def _format_entry_md(entry: MemoryEntry) -> str:
|
||||
"""Render a MemoryEntry as a markdown file with YAML frontmatter."""
|
||||
return (
|
||||
f"---\n"
|
||||
f"name: {entry.name}\n"
|
||||
f"description: {entry.description}\n"
|
||||
f"type: {entry.type}\n"
|
||||
f"created: {entry.created}\n"
|
||||
f"---\n"
|
||||
f"{entry.content}\n"
|
||||
)
|
||||
|
||||
|
||||
# ── Core storage operations ────────────────────────────────────────────────
|
||||
|
||||
def save_memory(entry: MemoryEntry, scope: str = "user") -> None:
|
||||
"""Write/update a memory file and rebuild the index for that scope.
|
||||
|
||||
If a memory with the same name (slug) already exists, it is overwritten.
|
||||
|
||||
Args:
|
||||
entry: MemoryEntry to persist
|
||||
scope: "user" or "project"
|
||||
"""
|
||||
mem_dir = get_memory_dir(scope)
|
||||
mem_dir.mkdir(parents=True, exist_ok=True)
|
||||
slug = _slugify(entry.name)
|
||||
fp = mem_dir / f"{slug}.md"
|
||||
fp.write_text(_format_entry_md(entry))
|
||||
entry.file_path = str(fp)
|
||||
entry.scope = scope
|
||||
_rewrite_index(scope)
|
||||
|
||||
|
||||
def delete_memory(name: str, scope: str = "user") -> None:
|
||||
"""Remove the memory file matching name and rebuild the index.
|
||||
|
||||
No error if not found.
|
||||
"""
|
||||
mem_dir = get_memory_dir(scope)
|
||||
slug = _slugify(name)
|
||||
fp = mem_dir / f"{slug}.md"
|
||||
if fp.exists():
|
||||
fp.unlink()
|
||||
_rewrite_index(scope)
|
||||
|
||||
|
||||
def load_entries(scope: str = "user") -> list[MemoryEntry]:
|
||||
"""Scan all .md files (except MEMORY.md) in a scope and return entries.
|
||||
|
||||
Returns:
|
||||
List of MemoryEntry sorted alphabetically by name.
|
||||
"""
|
||||
mem_dir = get_memory_dir(scope)
|
||||
if not mem_dir.exists():
|
||||
return []
|
||||
entries: list[MemoryEntry] = []
|
||||
for fp in sorted(mem_dir.glob("*.md")):
|
||||
if fp.name == INDEX_FILENAME:
|
||||
continue
|
||||
try:
|
||||
text = fp.read_text()
|
||||
except Exception:
|
||||
continue
|
||||
meta, body = parse_frontmatter(text)
|
||||
entries.append(MemoryEntry(
|
||||
name=meta.get("name", fp.stem),
|
||||
description=meta.get("description", ""),
|
||||
type=meta.get("type", "user"),
|
||||
content=body,
|
||||
file_path=str(fp),
|
||||
created=meta.get("created", ""),
|
||||
scope=scope,
|
||||
))
|
||||
return entries
|
||||
|
||||
|
||||
def load_index(scope: str = "all") -> list[MemoryEntry]:
|
||||
"""Load memory entries from one or both scopes.
|
||||
|
||||
Args:
|
||||
scope: "user", "project", or "all" (both combined)
|
||||
|
||||
Returns:
|
||||
List of MemoryEntry (user entries first, then project).
|
||||
"""
|
||||
if scope == "all":
|
||||
return load_entries("user") + load_entries("project")
|
||||
return load_entries(scope)
|
||||
|
||||
|
||||
def search_memory(query: str, scope: str = "all") -> list[MemoryEntry]:
|
||||
"""Case-insensitive keyword match on name + description + content.
|
||||
|
||||
Returns:
|
||||
List of matching MemoryEntry objects.
|
||||
"""
|
||||
q = query.lower()
|
||||
results = []
|
||||
for entry in load_index(scope):
|
||||
haystack = f"{entry.name} {entry.description} {entry.content}".lower()
|
||||
if q in haystack:
|
||||
results.append(entry)
|
||||
return results
|
||||
|
||||
|
||||
def _rewrite_index(scope: str) -> None:
|
||||
"""Rebuild MEMORY.md for the given scope from all .md files in that dir."""
|
||||
mem_dir = get_memory_dir(scope)
|
||||
if not mem_dir.exists():
|
||||
return
|
||||
index_path = mem_dir / INDEX_FILENAME
|
||||
entries = load_entries(scope)
|
||||
lines = [
|
||||
f"- [{e.name}]({Path(e.file_path).name}) — {e.description}"
|
||||
for e in entries
|
||||
]
|
||||
index_path.write_text("\n".join(lines) + ("\n" if lines else ""))
|
||||
|
||||
|
||||
def get_index_content(scope: str = "user") -> str:
|
||||
"""Return raw MEMORY.md content for the given scope, or '' if absent."""
|
||||
mem_dir = get_memory_dir(scope)
|
||||
index_path = mem_dir / INDEX_FILENAME
|
||||
if not index_path.exists():
|
||||
return ""
|
||||
return index_path.read_text().strip()
|
||||
216
nano-claude-code/memory/tools.py
Normal file
216
nano-claude-code/memory/tools.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""Memory tool registrations: MemorySave, MemoryDelete, MemorySearch.
|
||||
|
||||
Importing this module registers the three tools into the central registry.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from tool_registry import ToolDef, register_tool
|
||||
from .store import MemoryEntry, save_memory, delete_memory, load_index
|
||||
from .context import find_relevant_memories
|
||||
from .scan import scan_all_memories, format_memory_manifest
|
||||
|
||||
|
||||
# ── Tool implementations ───────────────────────────────────────────────────
|
||||
|
||||
def _memory_save(params: dict, config: dict) -> str:
|
||||
"""Save or update a persistent memory entry."""
|
||||
entry = MemoryEntry(
|
||||
name=params["name"],
|
||||
description=params["description"],
|
||||
type=params["type"],
|
||||
content=params["content"],
|
||||
created=datetime.now().strftime("%Y-%m-%d"),
|
||||
)
|
||||
scope = params.get("scope", "user")
|
||||
save_memory(entry, scope=scope)
|
||||
|
||||
scope_label = "project" if scope == "project" else "user"
|
||||
return f"Memory saved: '{entry.name}' [{entry.type}/{scope_label}]"
|
||||
|
||||
|
||||
def _memory_delete(params: dict, config: dict) -> str:
|
||||
"""Delete a persistent memory entry by name."""
|
||||
name = params["name"]
|
||||
scope = params.get("scope", "user")
|
||||
delete_memory(name, scope=scope)
|
||||
return f"Memory deleted: '{name}' (scope: {scope})"
|
||||
|
||||
|
||||
def _memory_search(params: dict, config: dict) -> str:
|
||||
"""Search memories by keyword query with optional AI relevance filtering."""
|
||||
query = params["query"]
|
||||
use_ai = params.get("use_ai", False)
|
||||
max_results = params.get("max_results", 5)
|
||||
|
||||
results = find_relevant_memories(
|
||||
query, max_results=max_results, use_ai=use_ai, config=config
|
||||
)
|
||||
|
||||
if not results:
|
||||
return f"No memories found matching '{query}'."
|
||||
|
||||
lines = [f"Found {len(results)} relevant memory/memories for '{query}':", ""]
|
||||
for r in results:
|
||||
freshness = f" ⚠ {r['freshness_text']}" if r["freshness_text"] else ""
|
||||
lines.append(
|
||||
f"[{r['type']}/{r['scope']}] {r['name']}\n"
|
||||
f" {r['description']}\n"
|
||||
f" {r['content'][:200]}{'...' if len(r['content']) > 200 else ''}"
|
||||
f"{freshness}"
|
||||
)
|
||||
return "\n\n".join(lines)
|
||||
|
||||
|
||||
def _memory_list(params: dict, config: dict) -> str:
|
||||
"""List all memory entries with their manifest (type, scope, age, description)."""
|
||||
headers = scan_all_memories()
|
||||
if not headers:
|
||||
return "No memories stored."
|
||||
|
||||
scope_filter = params.get("scope", "all")
|
||||
if scope_filter != "all":
|
||||
headers = [h for h in headers if h.scope == scope_filter]
|
||||
if not headers:
|
||||
return f"No {scope_filter} memories stored."
|
||||
|
||||
manifest = format_memory_manifest(headers)
|
||||
return f"{len(headers)} memory/memories:\n\n{manifest}"
|
||||
|
||||
|
||||
# ── Tool registrations ─────────────────────────────────────────────────────
|
||||
|
||||
register_tool(ToolDef(
|
||||
name="MemorySave",
|
||||
schema={
|
||||
"name": "MemorySave",
|
||||
"description": (
|
||||
"Save a persistent memory entry as a markdown file with frontmatter. "
|
||||
"Use for information that should persist across conversations: "
|
||||
"user preferences, feedback/corrections, project context, or external references. "
|
||||
"Do NOT save: code patterns, architecture, git history, or task state.\n\n"
|
||||
"For feedback/project memories, structure content as: "
|
||||
"rule/fact, then **Why:** and **How to apply:** lines."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Human-readable name (becomes the filename slug)",
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["user", "feedback", "project", "reference"],
|
||||
"description": (
|
||||
"user=preferences/role, feedback=guidance on how to work, "
|
||||
"project=ongoing work/decisions, reference=external system pointers"
|
||||
),
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Short one-line description (used for relevance decisions — be specific)",
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Body text. For feedback/project: rule/fact + **Why:** + **How to apply:**",
|
||||
},
|
||||
"scope": {
|
||||
"type": "string",
|
||||
"enum": ["user", "project"],
|
||||
"description": (
|
||||
"'user' (default) = ~/.nano_claude/memory/ shared across projects; "
|
||||
"'project' = .nano_claude/memory/ local to this project"
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["name", "type", "description", "content"],
|
||||
},
|
||||
},
|
||||
func=_memory_save,
|
||||
read_only=False,
|
||||
concurrent_safe=False,
|
||||
))
|
||||
|
||||
register_tool(ToolDef(
|
||||
name="MemoryDelete",
|
||||
schema={
|
||||
"name": "MemoryDelete",
|
||||
"description": "Delete a persistent memory entry by name.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Name of the memory to delete"},
|
||||
"scope": {
|
||||
"type": "string",
|
||||
"enum": ["user", "project"],
|
||||
"description": "Scope to delete from (default: 'user')",
|
||||
},
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
},
|
||||
func=_memory_delete,
|
||||
read_only=False,
|
||||
concurrent_safe=False,
|
||||
))
|
||||
|
||||
register_tool(ToolDef(
|
||||
name="MemorySearch",
|
||||
schema={
|
||||
"name": "MemorySearch",
|
||||
"description": (
|
||||
"Search persistent memories by keyword. Returns matching entries with "
|
||||
"content preview and staleness warning for old memories. "
|
||||
"Set use_ai=true to use AI-powered relevance ranking (costs a small API call)."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Search query"},
|
||||
"max_results": {
|
||||
"type": "integer",
|
||||
"description": "Maximum results to return (default: 5)",
|
||||
},
|
||||
"use_ai": {
|
||||
"type": "boolean",
|
||||
"description": "Use AI relevance ranking (default: false = keyword only)",
|
||||
},
|
||||
"scope": {
|
||||
"type": "string",
|
||||
"enum": ["user", "project", "all"],
|
||||
"description": "Which scope to search (default: 'all')",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
func=_memory_search,
|
||||
read_only=True,
|
||||
concurrent_safe=True,
|
||||
))
|
||||
|
||||
register_tool(ToolDef(
|
||||
name="MemoryList",
|
||||
schema={
|
||||
"name": "MemoryList",
|
||||
"description": (
|
||||
"List all memory entries with type, scope, age, and description. "
|
||||
"Useful for reviewing what's been remembered before deciding to save or delete."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scope": {
|
||||
"type": "string",
|
||||
"enum": ["user", "project", "all"],
|
||||
"description": "Which scope to list (default: 'all')",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
func=_memory_list,
|
||||
read_only=True,
|
||||
concurrent_safe=True,
|
||||
))
|
||||
86
nano-claude-code/memory/types.py
Normal file
86
nano-claude-code/memory/types.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Memory type taxonomy and system-prompt guidance text.
|
||||
|
||||
Four types capture context NOT derivable from the current project state.
|
||||
Code patterns, architecture, git history, and file structure are derivable
|
||||
(via grep/git/CLAUDE.md) and should NOT be saved as memories.
|
||||
"""
|
||||
|
||||
MEMORY_TYPES = ["user", "feedback", "project", "reference"]
|
||||
|
||||
# Condensed per-type guidance (used in system prompt injection)
|
||||
MEMORY_TYPE_DESCRIPTIONS: dict[str, str] = {
|
||||
"user": (
|
||||
"Information about the user's role, goals, responsibilities, and knowledge. "
|
||||
"Helps tailor future behavior to the user's preferences."
|
||||
),
|
||||
"feedback": (
|
||||
"Guidance the user has given about how to approach work — both what to avoid "
|
||||
"and what to keep doing. Lead with the rule, then **Why:** and **How to apply:**."
|
||||
),
|
||||
"project": (
|
||||
"Ongoing work, goals, bugs, or incidents not derivable from code or git history. "
|
||||
"Lead with the fact/decision, then **Why:** and **How to apply:**. "
|
||||
"Always convert relative dates to absolute dates."
|
||||
),
|
||||
"reference": (
|
||||
"Pointers to external systems (issue trackers, dashboards, Slack channels, docs)."
|
||||
),
|
||||
}
|
||||
|
||||
# What NOT to save (mirrors Claude Code source)
|
||||
WHAT_NOT_TO_SAVE = """\
|
||||
## What NOT to save in memory
|
||||
- Code patterns, conventions, architecture, file paths, or project structure — derivable from the codebase.
|
||||
- Git history, recent changes, who-changed-what — use `git log` / `git blame`.
|
||||
- Debugging solutions or fix recipes — the fix is in the code; the commit message has context.
|
||||
- Anything already documented in CLAUDE.md files.
|
||||
- Ephemeral task details: in-progress work, temporary state, current conversation context.
|
||||
|
||||
These exclusions apply even when explicitly asked. If asked to save a PR list or activity summary,
|
||||
ask what was *surprising* or *non-obvious* — that is the part worth keeping."""
|
||||
|
||||
# Memory format example (frontmatter)
|
||||
MEMORY_FORMAT_EXAMPLE = """\
|
||||
```markdown
|
||||
---
|
||||
name: {{memory name}}
|
||||
description: {{one-line description — used to decide relevance, so be specific}}
|
||||
type: {{user | feedback | project | reference}}
|
||||
---
|
||||
|
||||
{{memory content — for feedback/project types: rule/fact, then **Why:** and **How to apply:** lines}}
|
||||
```"""
|
||||
|
||||
# Full guidance injected into the system prompt
|
||||
MEMORY_SYSTEM_PROMPT = """\
|
||||
## Memory system
|
||||
|
||||
You have a persistent, file-based memory system. Memories are stored as markdown files with
|
||||
YAML frontmatter. Build this up over time so future conversations have context about the user,
|
||||
their preferences, and the work you're doing together.
|
||||
|
||||
**Types** (save only what cannot be derived from the codebase):
|
||||
- **user** — role, goals, knowledge, preferences
|
||||
- **feedback** — guidance on how to work (corrections AND confirmations of non-obvious approaches)
|
||||
- **project** — ongoing work, decisions, deadlines not in git history
|
||||
- **reference** — pointers to external systems (Linear, Grafana, Slack, etc.)
|
||||
|
||||
**When to save**: If the user corrects you, confirms an approach, or shares context that should
|
||||
persist beyond this conversation. For feedback: save corrections AND quiet confirmations.
|
||||
|
||||
**Body structure for feedback/project**: Lead with the rule/fact, then:
|
||||
**Why:** (reason given) | **How to apply:** (when this guidance kicks in)
|
||||
|
||||
**Format**:
|
||||
{format_example}
|
||||
|
||||
**Saving is two steps**:
|
||||
1. Write the memory to its own file (e.g. `feedback_testing.md`) using MemorySave.
|
||||
2. The index (MEMORY.md) is updated automatically.
|
||||
|
||||
**What NOT to save**: code patterns, architecture, git history, debugging fixes,
|
||||
anything already in CLAUDE.md, or ephemeral task state.
|
||||
|
||||
**Before recommending from memory**: A memory naming a file, function, or flag may be stale.
|
||||
Verify it still exists before acting on it. For current state, prefer `git log` or reading code.
|
||||
""".format(format_example=MEMORY_FORMAT_EXAMPLE)
|
||||
23
nano-claude-code/multi_agent/__init__.py
Normal file
23
nano-claude-code/multi_agent/__init__.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""Multi-agent package for nano-claude-code.
|
||||
|
||||
Provides:
|
||||
- AgentDefinition — typed agent definition (name, system_prompt, model, tools)
|
||||
- SubAgentTask — lifecycle-tracked task
|
||||
- SubAgentManager — thread-pool manager for spawning agents
|
||||
- load_agent_definitions / get_agent_definition — agent registry
|
||||
"""
|
||||
from .subagent import (
|
||||
AgentDefinition,
|
||||
SubAgentTask,
|
||||
SubAgentManager,
|
||||
load_agent_definitions,
|
||||
get_agent_definition,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AgentDefinition",
|
||||
"SubAgentTask",
|
||||
"SubAgentManager",
|
||||
"load_agent_definitions",
|
||||
"get_agent_definition",
|
||||
]
|
||||
480
nano-claude-code/multi_agent/subagent.py
Normal file
480
nano-claude-code/multi_agent/subagent.py
Normal file
@@ -0,0 +1,480 @@
|
||||
"""Threaded sub-agent system for spawning nested agent loops."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
import queue
|
||||
import subprocess
|
||||
import tempfile
|
||||
from concurrent.futures import ThreadPoolExecutor, Future
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
|
||||
# ── Agent definition ───────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class AgentDefinition:
|
||||
"""Definition for a specialized agent type."""
|
||||
name: str
|
||||
description: str = ""
|
||||
system_prompt: str = "" # extra instructions prepended to the base system prompt
|
||||
model: str = "" # model override; "" = inherit from parent
|
||||
tools: list = field(default_factory=list) # empty list = all tools
|
||||
source: str = "user" # "built-in" | "user" | "project"
|
||||
|
||||
|
||||
# ── Built-in agent definitions ─────────────────────────────────────────────
|
||||
|
||||
_BUILTIN_AGENTS: Dict[str, AgentDefinition] = {
|
||||
"general-purpose": AgentDefinition(
|
||||
name="general-purpose",
|
||||
description=(
|
||||
"General-purpose agent for researching complex questions, "
|
||||
"searching for code, and executing multi-step tasks."
|
||||
),
|
||||
system_prompt="",
|
||||
source="built-in",
|
||||
),
|
||||
"coder": AgentDefinition(
|
||||
name="coder",
|
||||
description="Specialized coding agent for writing, reading, and modifying code.",
|
||||
system_prompt=(
|
||||
"You are a specialized coding assistant. Focus on:\n"
|
||||
"- Writing clean, idiomatic code\n"
|
||||
"- Reading and understanding existing code before modifying\n"
|
||||
"- Making minimal targeted changes\n"
|
||||
"- Never adding unnecessary features, comments, or error handling\n"
|
||||
),
|
||||
source="built-in",
|
||||
),
|
||||
"reviewer": AgentDefinition(
|
||||
name="reviewer",
|
||||
description="Code review agent analyzing quality, security, and correctness.",
|
||||
system_prompt=(
|
||||
"You are a code reviewer. Analyze code for:\n"
|
||||
"- Correctness and logic errors\n"
|
||||
"- Security vulnerabilities (injection, XSS, auth bypass, etc.)\n"
|
||||
"- Performance issues\n"
|
||||
"- Code quality and maintainability\n"
|
||||
"Be concise and specific. Categorize findings as: Critical | Warning | Suggestion.\n"
|
||||
),
|
||||
tools=["Read", "Glob", "Grep"],
|
||||
source="built-in",
|
||||
),
|
||||
"researcher": AgentDefinition(
|
||||
name="researcher",
|
||||
description="Research agent for exploring codebases and answering questions.",
|
||||
system_prompt=(
|
||||
"You are a research assistant focused on understanding codebases.\n"
|
||||
"- Read and analyze code thoroughly before answering\n"
|
||||
"- Provide factual, evidence-based answers\n"
|
||||
"- Cite specific file paths and line numbers\n"
|
||||
"- Be concise and focused\n"
|
||||
),
|
||||
tools=["Read", "Glob", "Grep", "WebFetch", "WebSearch"],
|
||||
source="built-in",
|
||||
),
|
||||
"tester": AgentDefinition(
|
||||
name="tester",
|
||||
description="Testing agent that writes and runs tests.",
|
||||
system_prompt=(
|
||||
"You are a testing specialist. Your job:\n"
|
||||
"- Write comprehensive tests for the given code\n"
|
||||
"- Run existing tests and diagnose failures\n"
|
||||
"- Focus on edge cases and error conditions\n"
|
||||
"- Keep tests simple, readable, and fast\n"
|
||||
),
|
||||
source="built-in",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ── Loading agent definitions from .md files ──────────────────────────────
|
||||
|
||||
def _parse_agent_md(path: Path, source: str = "user") -> AgentDefinition:
|
||||
"""Parse a .md file with optional YAML frontmatter into an AgentDefinition.
|
||||
|
||||
File format:
|
||||
---
|
||||
description: "Short description"
|
||||
model: claude-haiku-4-5-20251001
|
||||
tools: [Read, Write, Edit, Bash]
|
||||
---
|
||||
|
||||
System prompt body goes here...
|
||||
"""
|
||||
content = path.read_text()
|
||||
name = path.stem
|
||||
description = ""
|
||||
model = ""
|
||||
tools: list = []
|
||||
system_prompt_body = content
|
||||
|
||||
if content.startswith("---"):
|
||||
end = content.find("---", 3)
|
||||
if end != -1:
|
||||
fm_text = content[3:end].strip()
|
||||
system_prompt_body = content[end + 3:].strip()
|
||||
try:
|
||||
import yaml as _yaml
|
||||
fm = _yaml.safe_load(fm_text) or {}
|
||||
except ImportError:
|
||||
# Manual key: value parse (no yaml dependency required)
|
||||
fm: dict = {}
|
||||
for line in fm_text.splitlines():
|
||||
if ":" in line:
|
||||
k, _, v = line.partition(":")
|
||||
fm[k.strip()] = v.strip()
|
||||
description = str(fm.get("description", ""))
|
||||
model = str(fm.get("model", ""))
|
||||
raw_tools = fm.get("tools", [])
|
||||
if isinstance(raw_tools, list):
|
||||
tools = [str(t) for t in raw_tools]
|
||||
elif isinstance(raw_tools, str):
|
||||
# Handle "[Read, Write]" or "Read, Write" format
|
||||
s = raw_tools.strip("[]")
|
||||
tools = [t.strip() for t in s.split(",") if t.strip()]
|
||||
|
||||
return AgentDefinition(
|
||||
name=name,
|
||||
description=description,
|
||||
system_prompt=system_prompt_body,
|
||||
model=model,
|
||||
tools=tools,
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
def load_agent_definitions() -> Dict[str, AgentDefinition]:
|
||||
"""Load all agent definitions: built-ins → user-level → project-level.
|
||||
|
||||
Search paths:
|
||||
~/.nano-claude/agents/*.md (user-level)
|
||||
.nano-claude/agents/*.md (project-level, overrides user)
|
||||
"""
|
||||
defs: Dict[str, AgentDefinition] = dict(_BUILTIN_AGENTS)
|
||||
|
||||
# User-level
|
||||
user_dir = Path.home() / ".nano-claude" / "agents"
|
||||
if user_dir.is_dir():
|
||||
for p in sorted(user_dir.glob("*.md")):
|
||||
try:
|
||||
d = _parse_agent_md(p, source="user")
|
||||
defs[d.name] = d
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Project-level (overrides user)
|
||||
proj_dir = Path.cwd() / ".nano-claude" / "agents"
|
||||
if proj_dir.is_dir():
|
||||
for p in sorted(proj_dir.glob("*.md")):
|
||||
try:
|
||||
d = _parse_agent_md(p, source="project")
|
||||
defs[d.name] = d
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return defs
|
||||
|
||||
|
||||
def get_agent_definition(name: str) -> Optional[AgentDefinition]:
|
||||
"""Look up an agent definition by name. Returns None if not found."""
|
||||
return load_agent_definitions().get(name)
|
||||
|
||||
|
||||
# ── SubAgentTask ───────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class SubAgentTask:
|
||||
"""Represents a sub-agent task with lifecycle tracking."""
|
||||
id: str
|
||||
prompt: str
|
||||
status: str = "pending" # pending | running | completed | failed | cancelled
|
||||
result: Optional[str] = None
|
||||
depth: int = 0
|
||||
name: str = "" # optional human-readable name (addressable by SendMessage)
|
||||
worktree_path: str = "" # set if isolation="worktree"
|
||||
worktree_branch: str = "" # set if isolation="worktree"
|
||||
_cancel_flag: bool = False
|
||||
_future: Optional[Future] = field(default=None, repr=False)
|
||||
_inbox: Any = field(default_factory=queue.Queue, repr=False) # for send_message
|
||||
|
||||
|
||||
# ── Worktree helpers ───────────────────────────────────────────────────────
|
||||
|
||||
def _git_root(cwd: str) -> Optional[str]:
|
||||
"""Return the git root directory for cwd, or None if not in a git repo."""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
cwd=cwd, capture_output=True, text=True, check=True,
|
||||
)
|
||||
return r.stdout.strip()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _create_worktree(base_dir: str) -> tuple:
|
||||
"""Create a temporary git worktree.
|
||||
|
||||
Returns:
|
||||
(worktree_path, branch_name)
|
||||
Raises:
|
||||
subprocess.CalledProcessError or OSError on failure.
|
||||
"""
|
||||
branch = f"nano-agent-{uuid.uuid4().hex[:8]}"
|
||||
# mkdtemp gives us a path; remove the empty dir so git can create it
|
||||
wt_path = tempfile.mkdtemp(prefix="nano-agent-wt-")
|
||||
os.rmdir(wt_path)
|
||||
subprocess.run(
|
||||
["git", "worktree", "add", "-b", branch, wt_path],
|
||||
cwd=base_dir, check=True, capture_output=True, text=True,
|
||||
)
|
||||
return wt_path, branch
|
||||
|
||||
|
||||
def _remove_worktree(wt_path: str, branch: str, base_dir: str) -> None:
|
||||
"""Remove a git worktree and delete its branch (best-effort)."""
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "worktree", "remove", "--force", wt_path],
|
||||
cwd=base_dir, capture_output=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "branch", "-D", branch],
|
||||
cwd=base_dir, capture_output=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── Internal helpers ───────────────────────────────────────────────────────
|
||||
|
||||
def _agent_run(prompt, state, config, system_prompt, depth=0, cancel_check=None):
|
||||
"""Lazy-import wrapper to avoid circular dependency with agent module.
|
||||
|
||||
Uses absolute import so this works whether called from inside or outside
|
||||
the multi_agent package (sys.path includes the project root).
|
||||
"""
|
||||
import agent as _agent_mod
|
||||
return _agent_mod.run(prompt, state, config, system_prompt, depth=depth, cancel_check=cancel_check)
|
||||
|
||||
|
||||
def _extract_final_text(messages):
|
||||
"""Walk backwards through messages, return first assistant content string."""
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "assistant" and msg.get("content"):
|
||||
return msg["content"]
|
||||
return None
|
||||
|
||||
|
||||
# ── SubAgentManager ────────────────────────────────────────────────────────
|
||||
|
||||
class SubAgentManager:
|
||||
"""Manages concurrent sub-agent tasks using a thread pool."""
|
||||
|
||||
def __init__(self, max_concurrent: int = 5, max_depth: int = 5):
|
||||
self.tasks: Dict[str, SubAgentTask] = {}
|
||||
self._by_name: Dict[str, str] = {} # name → task_id
|
||||
self.max_concurrent = max_concurrent
|
||||
self.max_depth = max_depth
|
||||
self._pool = ThreadPoolExecutor(max_workers=max_concurrent)
|
||||
|
||||
def spawn(
|
||||
self,
|
||||
prompt: str,
|
||||
config: dict,
|
||||
system_prompt: str,
|
||||
depth: int = 0,
|
||||
agent_def: Optional[AgentDefinition] = None,
|
||||
isolation: str = "", # "" | "worktree"
|
||||
name: str = "",
|
||||
) -> SubAgentTask:
|
||||
"""Spawn a new sub-agent task.
|
||||
|
||||
Args:
|
||||
prompt: user message for the sub-agent
|
||||
config: agent configuration dict (copied before modification)
|
||||
system_prompt: base system prompt
|
||||
depth: current nesting depth (prevents infinite recursion)
|
||||
agent_def: optional AgentDefinition with model/system_prompt/tools overrides
|
||||
isolation: "" for normal, "worktree" for isolated git worktree
|
||||
name: optional human-readable name (addressable via SendMessage)
|
||||
|
||||
Returns:
|
||||
SubAgentTask tracking the spawned work.
|
||||
"""
|
||||
task_id = uuid.uuid4().hex[:12]
|
||||
short_name = name or task_id[:8]
|
||||
task = SubAgentTask(id=task_id, prompt=prompt, depth=depth, name=short_name)
|
||||
self.tasks[task_id] = task
|
||||
if name:
|
||||
self._by_name[name] = task_id
|
||||
|
||||
if depth >= self.max_depth:
|
||||
task.status = "failed"
|
||||
task.result = f"Max depth ({self.max_depth}) exceeded"
|
||||
return task
|
||||
|
||||
# Build effective config and system prompt for this sub-agent
|
||||
eff_config = dict(config)
|
||||
eff_system = system_prompt
|
||||
|
||||
if agent_def:
|
||||
if agent_def.model:
|
||||
eff_config["model"] = agent_def.model
|
||||
if agent_def.system_prompt:
|
||||
eff_system = agent_def.system_prompt.rstrip() + "\n\n" + system_prompt
|
||||
|
||||
# Handle worktree isolation
|
||||
worktree_path = ""
|
||||
worktree_branch = ""
|
||||
base_dir = os.getcwd()
|
||||
|
||||
if isolation == "worktree":
|
||||
git_root = _git_root(base_dir)
|
||||
if not git_root:
|
||||
task.status = "failed"
|
||||
task.result = "isolation='worktree' requires a git repository"
|
||||
return task
|
||||
try:
|
||||
worktree_path, worktree_branch = _create_worktree(git_root)
|
||||
task.worktree_path = worktree_path
|
||||
task.worktree_branch = worktree_branch
|
||||
notice = (
|
||||
f"\n\n[Note: You are working in an isolated git worktree at "
|
||||
f"{worktree_path} (branch: {worktree_branch}). "
|
||||
f"Your changes are isolated from the main workspace at {git_root}. "
|
||||
f"Commit your changes before finishing so they can be reviewed/merged.]"
|
||||
)
|
||||
prompt = prompt + notice
|
||||
except Exception as e:
|
||||
task.status = "failed"
|
||||
task.result = f"Failed to create worktree: {e}"
|
||||
return task
|
||||
|
||||
def _run():
|
||||
import agent as _agent_mod; AgentState = _agent_mod.AgentState
|
||||
task.status = "running"
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
if worktree_path:
|
||||
os.chdir(worktree_path)
|
||||
|
||||
state = AgentState()
|
||||
gen = _agent_run(
|
||||
prompt, state, eff_config, eff_system,
|
||||
depth=depth + 1,
|
||||
cancel_check=lambda: task._cancel_flag,
|
||||
)
|
||||
for _event in gen:
|
||||
if task._cancel_flag:
|
||||
break
|
||||
|
||||
if task._cancel_flag:
|
||||
task.status = "cancelled"
|
||||
task.result = None
|
||||
else:
|
||||
task.result = _extract_final_text(state.messages)
|
||||
task.status = "completed"
|
||||
|
||||
# Drain inbox: process any messages sent via SendMessage
|
||||
while not task._inbox.empty() and not task._cancel_flag:
|
||||
inbox_msg = task._inbox.get_nowait()
|
||||
task.status = "running"
|
||||
gen2 = _agent_run(
|
||||
inbox_msg, state, eff_config, eff_system,
|
||||
depth=depth + 1,
|
||||
cancel_check=lambda: task._cancel_flag,
|
||||
)
|
||||
for _ev in gen2:
|
||||
if task._cancel_flag:
|
||||
break
|
||||
if not task._cancel_flag:
|
||||
task.result = _extract_final_text(state.messages)
|
||||
task.status = "completed"
|
||||
|
||||
except Exception as e:
|
||||
task.status = "failed"
|
||||
task.result = f"Error: {e}"
|
||||
finally:
|
||||
if worktree_path:
|
||||
os.chdir(old_cwd)
|
||||
_remove_worktree(worktree_path, worktree_branch, old_cwd)
|
||||
|
||||
task._future = self._pool.submit(_run)
|
||||
return task
|
||||
|
||||
def wait(self, task_id: str, timeout: float = None) -> Optional[SubAgentTask]:
|
||||
"""Block until a task completes or timeout expires.
|
||||
|
||||
Returns:
|
||||
The task, or None if task_id is unknown.
|
||||
"""
|
||||
task = self.tasks.get(task_id)
|
||||
if task is None:
|
||||
return None
|
||||
if task._future is not None:
|
||||
try:
|
||||
task._future.result(timeout=timeout)
|
||||
except Exception:
|
||||
pass
|
||||
return task
|
||||
|
||||
def get_result(self, task_id: str) -> Optional[str]:
|
||||
"""Return the result string for a completed task, or None."""
|
||||
task = self.tasks.get(task_id)
|
||||
return task.result if task else None
|
||||
|
||||
def list_tasks(self) -> List[SubAgentTask]:
|
||||
"""Return all tracked tasks."""
|
||||
return list(self.tasks.values())
|
||||
|
||||
def send_message(self, task_id_or_name: str, message: str) -> bool:
|
||||
"""Send a message to a running background agent.
|
||||
|
||||
The message is queued and the agent will process it after completing
|
||||
its current work.
|
||||
|
||||
Args:
|
||||
task_id_or_name: task ID or the human-readable name passed to spawn()
|
||||
message: message text to send
|
||||
|
||||
Returns:
|
||||
True if the message was queued, False if task not found or already done.
|
||||
"""
|
||||
# Resolve name → task_id
|
||||
task_id = self._by_name.get(task_id_or_name, task_id_or_name)
|
||||
task = self.tasks.get(task_id)
|
||||
if task is None:
|
||||
return False
|
||||
if task.status not in ("running", "pending"):
|
||||
return False
|
||||
task._inbox.put(message)
|
||||
return True
|
||||
|
||||
def cancel(self, task_id: str) -> bool:
|
||||
"""Request cancellation of a running task.
|
||||
|
||||
Returns:
|
||||
True if the cancel flag was set, False if task not found or not running.
|
||||
"""
|
||||
task = self.tasks.get(task_id)
|
||||
if task is None:
|
||||
return False
|
||||
if task.status == "running":
|
||||
task._cancel_flag = True
|
||||
return True
|
||||
return False
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Cancel all running tasks and shut down the thread pool."""
|
||||
for task in self.tasks.values():
|
||||
if task.status == "running":
|
||||
task._cancel_flag = True
|
||||
self._pool.shutdown(wait=True)
|
||||
295
nano-claude-code/multi_agent/tools.py
Normal file
295
nano-claude-code/multi_agent/tools.py
Normal file
@@ -0,0 +1,295 @@
|
||||
"""Multi-agent tool registrations.
|
||||
|
||||
Registers the following tools into the central tool_registry:
|
||||
Agent — spawn a sub-agent (sync or background)
|
||||
SendMessage — send a message to a named background agent
|
||||
CheckAgentResult — check status/result of a background agent
|
||||
ListAgentTasks — list all active/finished agent tasks
|
||||
ListAgentTypes — list available agent type definitions
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from tool_registry import ToolDef, register_tool
|
||||
from .subagent import SubAgentManager, get_agent_definition, load_agent_definitions
|
||||
|
||||
|
||||
# ── Singleton manager ──────────────────────────────────────────────────────
|
||||
|
||||
_agent_manager: SubAgentManager | None = None
|
||||
|
||||
|
||||
def get_agent_manager() -> SubAgentManager:
|
||||
"""Return (and lazily create) the process-wide SubAgentManager."""
|
||||
global _agent_manager
|
||||
if _agent_manager is None:
|
||||
_agent_manager = SubAgentManager()
|
||||
return _agent_manager
|
||||
|
||||
|
||||
# ── Tool implementations ───────────────────────────────────────────────────
|
||||
|
||||
def _agent_tool(params: dict, config: dict) -> str:
|
||||
"""Spawn a sub-agent.
|
||||
|
||||
Reads from config:
|
||||
_system_prompt — injected by agent.py run(), used as base system prompt
|
||||
_depth — current nesting depth (prevents infinite recursion)
|
||||
"""
|
||||
mgr = get_agent_manager()
|
||||
|
||||
prompt = params["prompt"]
|
||||
wait = params.get("wait", True)
|
||||
isolation = params.get("isolation", "")
|
||||
name = params.get("name", "")
|
||||
model_override = params.get("model", "")
|
||||
subagent_type = params.get("subagent_type", "")
|
||||
|
||||
system_prompt = config.get("_system_prompt", "You are a helpful assistant.")
|
||||
depth = config.get("_depth", 0)
|
||||
|
||||
# Strip private keys before passing to sub-agent
|
||||
eff_config = {k: v for k, v in config.items() if not k.startswith("_")}
|
||||
if model_override:
|
||||
eff_config["model"] = model_override
|
||||
|
||||
# Resolve agent definition
|
||||
agent_def = None
|
||||
if subagent_type:
|
||||
agent_def = get_agent_definition(subagent_type)
|
||||
if agent_def is None:
|
||||
return (
|
||||
f"Error: unknown subagent_type '{subagent_type}'. "
|
||||
"Use ListAgentTypes to see available types."
|
||||
)
|
||||
|
||||
task = mgr.spawn(
|
||||
prompt, eff_config, system_prompt,
|
||||
depth=depth,
|
||||
agent_def=agent_def,
|
||||
isolation=isolation,
|
||||
name=name,
|
||||
)
|
||||
|
||||
if task.status == "failed":
|
||||
return f"Error spawning agent: {task.result}"
|
||||
|
||||
if wait:
|
||||
mgr.wait(task.id, timeout=300)
|
||||
result = task.result or f"(no output — status: {task.status})"
|
||||
header = f"[Agent: {task.name}"
|
||||
if subagent_type:
|
||||
header += f" ({subagent_type})"
|
||||
if task.worktree_branch:
|
||||
header += f", branch: {task.worktree_branch}"
|
||||
header += "]"
|
||||
return f"{header}\n\n{result}"
|
||||
else:
|
||||
info_parts = [f"Task ID: {task.id}", f"Name: {task.name}", f"Status: {task.status}"]
|
||||
if subagent_type:
|
||||
info_parts.append(f"Type: {subagent_type}")
|
||||
if task.worktree_branch:
|
||||
info_parts.append(f"Worktree branch: {task.worktree_branch}")
|
||||
info_parts.append("Use CheckAgentResult or SendMessage to interact with this agent.")
|
||||
return "\n".join(info_parts)
|
||||
|
||||
|
||||
def _send_message(params: dict, config: dict) -> str:
|
||||
mgr = get_agent_manager()
|
||||
target = params["to"]
|
||||
message = params["message"]
|
||||
ok = mgr.send_message(target, message)
|
||||
if ok:
|
||||
return f"Message queued for agent '{target}'. It will be processed after current work completes."
|
||||
task_id = mgr._by_name.get(target, target)
|
||||
task = mgr.tasks.get(task_id)
|
||||
if task is None:
|
||||
return f"Error: no agent found with id or name '{target}'"
|
||||
return f"Error: agent '{target}' is not running (status: {task.status}). Cannot send message."
|
||||
|
||||
|
||||
def _check_agent_result(params: dict, config: dict) -> str:
|
||||
mgr = get_agent_manager()
|
||||
task_id = params["task_id"]
|
||||
task = mgr.tasks.get(task_id)
|
||||
if task is None:
|
||||
return f"Error: no task with id '{task_id}'"
|
||||
lines = [f"Status: {task.status}", f"Name: {task.name}"]
|
||||
if task.worktree_branch:
|
||||
lines.append(f"Worktree branch: {task.worktree_branch}")
|
||||
if task.result:
|
||||
lines.append(f"\nResult:\n{task.result}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _list_agent_tasks(params: dict, config: dict) -> str:
|
||||
mgr = get_agent_manager()
|
||||
tasks = mgr.list_tasks()
|
||||
if not tasks:
|
||||
return "No sub-agent tasks."
|
||||
lines = ["ID | Name | Status | Worktree branch | Prompt"]
|
||||
lines.append("-------------|----------|-----------|-----------------|------")
|
||||
for t in tasks:
|
||||
prompt_short = t.prompt[:50] + ("..." if len(t.prompt) > 50 else "")
|
||||
wt = t.worktree_branch[:15] if t.worktree_branch else "-"
|
||||
lines.append(f"{t.id} | {t.name[:8]:8s} | {t.status:9s} | {wt:15s} | {prompt_short}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _list_agent_types(params: dict, config: dict) -> str:
|
||||
defs = load_agent_definitions()
|
||||
if not defs:
|
||||
return "No agent types available."
|
||||
lines = ["Available agent types:", ""]
|
||||
for aname, d in sorted(defs.items()):
|
||||
model_info = f" model: {d.model}" if d.model else ""
|
||||
tools_info = f" tools: {', '.join(d.tools)}" if d.tools else ""
|
||||
lines.append(f" {aname:20s} [{d.source:8s}] {d.description}")
|
||||
if model_info:
|
||||
lines.append(f" {model_info}")
|
||||
if tools_info:
|
||||
lines.append(f" {tools_info}")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Create custom agents: place .md files in ~/.nano-claude/agents/ or .nano-claude/agents/"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── Tool registrations ─────────────────────────────────────────────────────
|
||||
|
||||
register_tool(ToolDef(
|
||||
name="Agent",
|
||||
schema={
|
||||
"name": "Agent",
|
||||
"description": (
|
||||
"Spawn a sub-agent to handle a task autonomously. The sub-agent runs in a "
|
||||
"separate thread with its own conversation history. Supports specialized agent "
|
||||
"types (coder, reviewer, researcher, tester, or custom from .nano-claude/agents/), "
|
||||
"isolated git worktrees for parallel work, and background execution.\n\n"
|
||||
"When using isolation='worktree', the agent gets its own git branch and "
|
||||
"working copy — ideal for parallel coding tasks that shouldn't interfere."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "Task description for the sub-agent",
|
||||
},
|
||||
"subagent_type": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Specialized agent type: 'general-purpose', 'coder', 'reviewer', "
|
||||
"'researcher', 'tester', or any custom type. "
|
||||
"Use ListAgentTypes to see all available types."
|
||||
),
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Human-readable name for this agent instance. "
|
||||
"Makes it addressable via SendMessage while running in background."
|
||||
),
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Model override for this specific agent (optional)",
|
||||
},
|
||||
"wait": {
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
"Block until complete (default: true). "
|
||||
"Set false to run in background."
|
||||
),
|
||||
},
|
||||
"isolation": {
|
||||
"type": "string",
|
||||
"enum": ["worktree"],
|
||||
"description": (
|
||||
"'worktree' creates a temporary git worktree so the agent works "
|
||||
"on an isolated copy of the repo. Changes stay on a separate branch "
|
||||
"and can be reviewed/merged after completion."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["prompt"],
|
||||
},
|
||||
},
|
||||
func=_agent_tool,
|
||||
read_only=False,
|
||||
concurrent_safe=False,
|
||||
))
|
||||
|
||||
register_tool(ToolDef(
|
||||
name="SendMessage",
|
||||
schema={
|
||||
"name": "SendMessage",
|
||||
"description": (
|
||||
"Send a follow-up message to a running background agent. "
|
||||
"The message is queued and processed after the agent finishes its current work. "
|
||||
"Reference agents by the name set via Agent(name=...) or by task ID."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"to": {"type": "string", "description": "Agent name or task ID"},
|
||||
"message": {"type": "string", "description": "Message to send to the agent"},
|
||||
},
|
||||
"required": ["to", "message"],
|
||||
},
|
||||
},
|
||||
func=_send_message,
|
||||
read_only=False,
|
||||
concurrent_safe=True,
|
||||
))
|
||||
|
||||
register_tool(ToolDef(
|
||||
name="CheckAgentResult",
|
||||
schema={
|
||||
"name": "CheckAgentResult",
|
||||
"description": "Check the status and result of a spawned sub-agent task.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": {"type": "string", "description": "Task ID returned by Agent tool"},
|
||||
},
|
||||
"required": ["task_id"],
|
||||
},
|
||||
},
|
||||
func=_check_agent_result,
|
||||
read_only=True,
|
||||
concurrent_safe=True,
|
||||
))
|
||||
|
||||
register_tool(ToolDef(
|
||||
name="ListAgentTasks",
|
||||
schema={
|
||||
"name": "ListAgentTasks",
|
||||
"description": "List all sub-agent tasks and their statuses.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
},
|
||||
},
|
||||
func=_list_agent_tasks,
|
||||
read_only=True,
|
||||
concurrent_safe=True,
|
||||
))
|
||||
|
||||
register_tool(ToolDef(
|
||||
name="ListAgentTypes",
|
||||
schema={
|
||||
"name": "ListAgentTypes",
|
||||
"description": (
|
||||
"List all available agent types (built-in and custom). "
|
||||
"Use the type names as subagent_type when calling Agent."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
},
|
||||
},
|
||||
func=_list_agent_types,
|
||||
read_only=True,
|
||||
concurrent_safe=True,
|
||||
))
|
||||
186
nano-claude-code/nano_claude.py
Normal file → Executable file
186
nano-claude-code/nano_claude.py
Normal file → Executable file
@@ -26,6 +26,9 @@ Slash commands in REPL:
|
||||
/thinking Toggle extended thinking
|
||||
/permissions [mode] Set permission mode
|
||||
/cwd [path] Show or change working directory
|
||||
/memory [query] Show/search persistent memories
|
||||
/skills List available skills
|
||||
/agents Show sub-agent tasks
|
||||
/exit /quit Exit
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -33,13 +36,16 @@ from __future__ import annotations
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import readline
|
||||
try:
|
||||
import readline
|
||||
except ImportError:
|
||||
readline = None # Windows compatibility
|
||||
import atexit
|
||||
import argparse
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from typing import Optional, Union
|
||||
|
||||
# ── Optional rich for markdown rendering ──────────────────────────────────
|
||||
try:
|
||||
@@ -78,6 +84,25 @@ def warn(msg: str): print(clr(f"Warning: {msg}", "yellow"))
|
||||
def err(msg: str): print(clr(f"Error: {msg}", "red"), file=sys.stderr)
|
||||
|
||||
|
||||
def render_diff(text: str):
|
||||
"""Print diff text with ANSI colors: red for removals, green for additions."""
|
||||
for line in text.splitlines():
|
||||
if line.startswith("+++") or line.startswith("---"):
|
||||
print(C["bold"] + line + C["reset"])
|
||||
elif line.startswith("+"):
|
||||
print(C["green"] + line + C["reset"])
|
||||
elif line.startswith("-"):
|
||||
print(C["red"] + line + C["reset"])
|
||||
elif line.startswith("@@"):
|
||||
print(C["cyan"] + line + C["reset"])
|
||||
else:
|
||||
print(line)
|
||||
|
||||
def _has_diff(text: str) -> bool:
|
||||
"""Check if text contains a unified diff."""
|
||||
return "--- a/" in text and "+++ b/" in text
|
||||
|
||||
|
||||
# ── Conversation rendering ─────────────────────────────────────────────────
|
||||
|
||||
_accumulated_text: list[str] = [] # buffer text during streaming
|
||||
@@ -118,6 +143,12 @@ def print_tool_end(name: str, result: str, verbose: bool):
|
||||
summary = f"→ {lines} lines ({size} chars)"
|
||||
if not result.startswith("Error") and not result.startswith("Denied"):
|
||||
print(clr(f" ✓ {summary}", "dim", "green"), flush=True)
|
||||
# Render diff for Edit/Write results
|
||||
if name in ("Edit", "Write") and _has_diff(result):
|
||||
parts = result.split("\n\n", 1)
|
||||
if len(parts) == 2:
|
||||
print(clr(f" {parts[0]}", "dim"))
|
||||
render_diff(parts[1])
|
||||
else:
|
||||
print(clr(f" ✗ {result[:120]}", "dim", "red"), flush=True)
|
||||
if verbose and not result.startswith("Denied"):
|
||||
@@ -131,8 +162,26 @@ def _tool_desc(name: str, inputs: dict) -> str:
|
||||
if name == "Bash": return f"Bash({inputs.get('command','')[:80]})"
|
||||
if name == "Glob": return f"Glob({inputs.get('pattern','')})"
|
||||
if name == "Grep": return f"Grep({inputs.get('pattern','')})"
|
||||
if name == "WebFetch": return f"WebFetch({inputs.get('url','')[:60]})"
|
||||
if name == "WebSearch": return f"WebSearch({inputs.get('query','')})"
|
||||
if name == "WebFetch": return f"WebFetch({inputs.get('url','')[:60]})"
|
||||
if name == "WebSearch": return f"WebSearch({inputs.get('query','')})"
|
||||
if name == "Agent":
|
||||
atype = inputs.get("subagent_type", "")
|
||||
aname = inputs.get("name", "")
|
||||
iso = inputs.get("isolation", "")
|
||||
bg = not inputs.get("wait", True)
|
||||
parts = []
|
||||
if atype: parts.append(atype)
|
||||
if aname: parts.append(f"name={aname}")
|
||||
if iso: parts.append(f"isolation={iso}")
|
||||
if bg: parts.append("background")
|
||||
suffix = f"({', '.join(parts)})" if parts else ""
|
||||
prompt_short = inputs.get("prompt", "")[:60]
|
||||
return f"Agent{suffix}: {prompt_short}"
|
||||
if name == "SendMessage":
|
||||
return f"SendMessage(to={inputs.get('to','')}: {inputs.get('message','')[:50]})"
|
||||
if name == "CheckAgentResult": return f"CheckAgentResult({inputs.get('task_id','')})"
|
||||
if name == "ListAgentTasks": return "ListAgentTasks()"
|
||||
if name == "ListAgentTypes": return "ListAgentTypes()"
|
||||
return f"{name}({list(inputs.values())[:1]})"
|
||||
|
||||
|
||||
@@ -351,6 +400,101 @@ def cmd_exit(_args: str, _state, _config) -> bool:
|
||||
ok("Goodbye!")
|
||||
sys.exit(0)
|
||||
|
||||
def cmd_memory(args: str, _state, _config) -> bool:
|
||||
from memory import search_memory, load_index
|
||||
from memory.scan import scan_all_memories, format_memory_manifest, memory_freshness_text
|
||||
|
||||
if args.strip():
|
||||
results = search_memory(args.strip())
|
||||
if not results:
|
||||
info(f"No memories matching '{args.strip()}'")
|
||||
return True
|
||||
info(f" {len(results)} result(s) for '{args.strip()}':")
|
||||
for m in results:
|
||||
info(f" [{m.type:9s}|{m.scope:7s}] {m.name}: {m.description}")
|
||||
info(f" {m.content[:120]}{'...' if len(m.content) > 120 else ''}")
|
||||
return True
|
||||
|
||||
# Show manifest with age/freshness
|
||||
headers = scan_all_memories()
|
||||
if not headers:
|
||||
info("No memories stored. The model saves memories via MemorySave.")
|
||||
return True
|
||||
info(f" {len(headers)} memory/memories (newest first):")
|
||||
for h in headers:
|
||||
fresh_warn = " ⚠ stale" if memory_freshness_text(h.mtime_s) else ""
|
||||
tag = f"[{h.type or '?':9s}|{h.scope:7s}]"
|
||||
info(f" {tag} {h.filename}{fresh_warn}")
|
||||
if h.description:
|
||||
info(f" {h.description}")
|
||||
return True
|
||||
|
||||
def cmd_agents(_args: str, _state, _config) -> bool:
|
||||
try:
|
||||
from multi_agent.tools import get_agent_manager
|
||||
mgr = get_agent_manager()
|
||||
tasks = mgr.list_tasks()
|
||||
if not tasks:
|
||||
info("No sub-agent tasks.")
|
||||
return True
|
||||
info(f" {len(tasks)} sub-agent task(s):")
|
||||
for t in tasks:
|
||||
preview = t.prompt[:50] + ("..." if len(t.prompt) > 50 else "")
|
||||
wt_info = f" branch:{t.worktree_branch}" if t.worktree_branch else ""
|
||||
info(f" {t.id} [{t.status:9s}] name={t.name}{wt_info} {preview}")
|
||||
except Exception:
|
||||
info("Sub-agent system not initialized.")
|
||||
return True
|
||||
|
||||
|
||||
def _print_background_notifications():
|
||||
"""Print notifications for newly completed background agent tasks.
|
||||
|
||||
Called before each user prompt so the user sees results without polling.
|
||||
"""
|
||||
try:
|
||||
from multi_agent.tools import get_agent_manager
|
||||
mgr = get_agent_manager()
|
||||
except Exception:
|
||||
return
|
||||
|
||||
notified_key = "_notified"
|
||||
if not hasattr(_print_background_notifications, "_seen"):
|
||||
_print_background_notifications._seen = set()
|
||||
|
||||
for task in mgr.list_tasks():
|
||||
if task.id in _print_background_notifications._seen:
|
||||
continue
|
||||
if task.status in ("completed", "failed", "cancelled"):
|
||||
_print_background_notifications._seen.add(task.id)
|
||||
icon = "✓" if task.status == "completed" else "✗"
|
||||
color = "green" if task.status == "completed" else "red"
|
||||
branch_info = f" [branch: {task.worktree_branch}]" if task.worktree_branch else ""
|
||||
print(clr(
|
||||
f"\n {icon} Background agent '{task.name}' {task.status}{branch_info}",
|
||||
color, "bold"
|
||||
))
|
||||
if task.result:
|
||||
preview = task.result[:200] + ("..." if len(task.result) > 200 else "")
|
||||
print(clr(f" {preview}", "dim"))
|
||||
print()
|
||||
|
||||
def cmd_skills(_args: str, _state, _config) -> bool:
|
||||
from skill import load_skills
|
||||
skills = load_skills()
|
||||
if not skills:
|
||||
info("No skills found.")
|
||||
return True
|
||||
info(f"Available skills ({len(skills)}):")
|
||||
for s in skills:
|
||||
triggers = ", ".join(s.triggers)
|
||||
source_label = f"[{s.source}]" if s.source != "builtin" else ""
|
||||
hint = f" args: {s.argument_hint}" if s.argument_hint else ""
|
||||
print(f" {clr(s.name, 'cyan'):24s} {s.description} {clr(triggers, 'dim')}{hint} {clr(source_label, 'yellow')}")
|
||||
if s.when_to_use:
|
||||
print(f" {clr(s.when_to_use[:80], 'dim')}")
|
||||
return True
|
||||
|
||||
COMMANDS = {
|
||||
"help": cmd_help,
|
||||
"clear": cmd_clear,
|
||||
@@ -365,13 +509,16 @@ COMMANDS = {
|
||||
"thinking": cmd_thinking,
|
||||
"permissions": cmd_permissions,
|
||||
"cwd": cmd_cwd,
|
||||
"skills": cmd_skills,
|
||||
"memory": cmd_memory,
|
||||
"agents": cmd_agents,
|
||||
"exit": cmd_exit,
|
||||
"quit": cmd_exit,
|
||||
}
|
||||
|
||||
|
||||
def handle_slash(line: str, state, config) -> bool:
|
||||
"""Handle /command [args]. Returns True if handled."""
|
||||
def handle_slash(line: str, state, config) -> Union[bool, tuple]:
|
||||
"""Handle /command [args]. Returns True if handled, tuple (skill, args) for skill match."""
|
||||
if not line.startswith("/"):
|
||||
return False
|
||||
parts = line[1:].split(None, 1)
|
||||
@@ -383,6 +530,15 @@ def handle_slash(line: str, state, config) -> bool:
|
||||
if handler:
|
||||
handler(args, state, config)
|
||||
return True
|
||||
|
||||
# Fall through to skill lookup
|
||||
from skill import find_skill
|
||||
skill = find_skill(line)
|
||||
if skill:
|
||||
cmd_parts = line.strip().split(maxsplit=1)
|
||||
skill_args = cmd_parts[1] if len(cmd_parts) > 1 else ""
|
||||
return (skill, skill_args)
|
||||
|
||||
err(f"Unknown command: /{cmd} (type /help for commands)")
|
||||
return True
|
||||
|
||||
@@ -390,6 +546,8 @@ def handle_slash(line: str, state, config) -> bool:
|
||||
# ── Input history setup ────────────────────────────────────────────────────
|
||||
|
||||
def setup_readline(history_file: Path):
|
||||
if readline is None:
|
||||
return
|
||||
try:
|
||||
readline.read_history_file(str(history_file))
|
||||
except FileNotFoundError:
|
||||
@@ -487,6 +645,8 @@ def repl(config: dict, initial_prompt: str = None):
|
||||
return
|
||||
|
||||
while True:
|
||||
# Show notifications for background agents that finished
|
||||
_print_background_notifications()
|
||||
try:
|
||||
cwd_short = Path.cwd().name
|
||||
prompt = clr(f"\n[{cwd_short}] ", "dim") + clr("❯ ", "cyan", "bold")
|
||||
@@ -498,7 +658,19 @@ def repl(config: dict, initial_prompt: str = None):
|
||||
|
||||
if not user_input:
|
||||
continue
|
||||
if handle_slash(user_input, state, config):
|
||||
|
||||
result = handle_slash(user_input, state, config)
|
||||
if isinstance(result, tuple):
|
||||
skill, skill_args = result
|
||||
info(f"Running skill: {skill.name}" + (f" [{skill.context}]" if skill.context == "fork" else ""))
|
||||
try:
|
||||
from skill import substitute_arguments
|
||||
rendered = substitute_arguments(skill.prompt, skill_args, skill.arguments)
|
||||
run_query(f"[Skill: {skill.name}]\n\n{rendered}")
|
||||
except KeyboardInterrupt:
|
||||
print(clr("\n (interrupted)", "yellow"))
|
||||
continue
|
||||
if result:
|
||||
continue
|
||||
|
||||
try:
|
||||
|
||||
@@ -29,6 +29,7 @@ PROVIDERS: dict[str, dict] = {
|
||||
"anthropic": {
|
||||
"type": "anthropic",
|
||||
"api_key_env": "ANTHROPIC_API_KEY",
|
||||
"context_limit": 200000,
|
||||
"models": [
|
||||
"claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5-20251001",
|
||||
"claude-opus-4-5", "claude-sonnet-4-5",
|
||||
@@ -39,6 +40,7 @@ PROVIDERS: dict[str, dict] = {
|
||||
"type": "openai",
|
||||
"api_key_env": "OPENAI_API_KEY",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"context_limit": 128000,
|
||||
"models": [
|
||||
"gpt-4o", "gpt-4o-mini", "gpt-4-turbo",
|
||||
"o3-mini", "o1", "o1-mini",
|
||||
@@ -48,6 +50,7 @@ PROVIDERS: dict[str, dict] = {
|
||||
"type": "openai",
|
||||
"api_key_env": "GEMINI_API_KEY",
|
||||
"base_url": "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
"context_limit": 1000000,
|
||||
"models": [
|
||||
"gemini-2.5-pro-preview-03-25",
|
||||
"gemini-2.0-flash", "gemini-2.0-flash-lite",
|
||||
@@ -58,6 +61,7 @@ PROVIDERS: dict[str, dict] = {
|
||||
"type": "openai",
|
||||
"api_key_env": "MOONSHOT_API_KEY",
|
||||
"base_url": "https://api.moonshot.cn/v1",
|
||||
"context_limit": 128000,
|
||||
"models": [
|
||||
"moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k",
|
||||
"kimi-latest",
|
||||
@@ -67,6 +71,7 @@ PROVIDERS: dict[str, dict] = {
|
||||
"type": "openai",
|
||||
"api_key_env": "DASHSCOPE_API_KEY",
|
||||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"context_limit": 1000000,
|
||||
"models": [
|
||||
"qwen-max", "qwen-plus", "qwen-turbo", "qwen-long",
|
||||
"qwen2.5-72b-instruct", "qwen2.5-coder-32b-instruct",
|
||||
@@ -77,6 +82,7 @@ PROVIDERS: dict[str, dict] = {
|
||||
"type": "openai",
|
||||
"api_key_env": "ZHIPU_API_KEY",
|
||||
"base_url": "https://open.bigmodel.cn/api/paas/v4/",
|
||||
"context_limit": 128000,
|
||||
"models": [
|
||||
"glm-4-plus", "glm-4", "glm-4-flash", "glm-4-air",
|
||||
"glm-z1-flash",
|
||||
@@ -86,6 +92,7 @@ PROVIDERS: dict[str, dict] = {
|
||||
"type": "openai",
|
||||
"api_key_env": "DEEPSEEK_API_KEY",
|
||||
"base_url": "https://api.deepseek.com/v1",
|
||||
"context_limit": 64000,
|
||||
"models": [
|
||||
"deepseek-chat", "deepseek-coder", "deepseek-reasoner",
|
||||
],
|
||||
@@ -95,6 +102,7 @@ PROVIDERS: dict[str, dict] = {
|
||||
"api_key_env": None,
|
||||
"base_url": "http://localhost:11434/v1",
|
||||
"api_key": "ollama",
|
||||
"context_limit": 128000,
|
||||
"models": [
|
||||
"llama3.3", "llama3.2", "phi4", "mistral", "mixtral",
|
||||
"qwen2.5-coder", "deepseek-r1", "gemma3",
|
||||
@@ -105,12 +113,14 @@ PROVIDERS: dict[str, dict] = {
|
||||
"api_key_env": None,
|
||||
"base_url": "http://localhost:1234/v1",
|
||||
"api_key": "lm-studio",
|
||||
"context_limit": 128000,
|
||||
"models": [], # dynamic, depends on loaded model
|
||||
},
|
||||
"custom": {
|
||||
"type": "openai",
|
||||
"api_key_env": "CUSTOM_API_KEY",
|
||||
"base_url": None, # read from config["custom_base_url"]
|
||||
"context_limit": 128000,
|
||||
"models": [],
|
||||
},
|
||||
}
|
||||
@@ -277,8 +287,9 @@ def messages_to_openai(messages: list) -> list:
|
||||
msg: dict = {"role": "assistant", "content": m.get("content") or None}
|
||||
tcs = m.get("tool_calls", [])
|
||||
if tcs:
|
||||
msg["tool_calls"] = [
|
||||
{
|
||||
msg["tool_calls"] = []
|
||||
for tc in tcs:
|
||||
tc_msg = {
|
||||
"id": tc["id"],
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -286,8 +297,10 @@ def messages_to_openai(messages: list) -> list:
|
||||
"arguments": json.dumps(tc["input"], ensure_ascii=False),
|
||||
},
|
||||
}
|
||||
for tc in tcs
|
||||
]
|
||||
# Pass through provider-specific fields (e.g. Gemini thought_signature)
|
||||
if tc.get("extra_content"):
|
||||
tc_msg["extra_content"] = tc["extra_content"]
|
||||
msg["tool_calls"].append(tc_msg)
|
||||
result.append(msg)
|
||||
|
||||
elif role == "tool":
|
||||
@@ -425,7 +438,7 @@ def stream_openai_compat(
|
||||
for tc in delta.tool_calls:
|
||||
idx = tc.index
|
||||
if idx not in tool_buf:
|
||||
tool_buf[idx] = {"id": "", "name": "", "args": ""}
|
||||
tool_buf[idx] = {"id": "", "name": "", "args": "", "extra_content": None}
|
||||
if tc.id:
|
||||
tool_buf[idx]["id"] = tc.id
|
||||
if tc.function:
|
||||
@@ -433,6 +446,10 @@ def stream_openai_compat(
|
||||
tool_buf[idx]["name"] += tc.function.name
|
||||
if tc.function.arguments:
|
||||
tool_buf[idx]["args"] += tc.function.arguments
|
||||
# Capture extra_content (e.g. Gemini thought_signature)
|
||||
extra = getattr(tc, "extra_content", None)
|
||||
if extra:
|
||||
tool_buf[idx]["extra_content"] = extra
|
||||
|
||||
# Some providers include usage in the last chunk
|
||||
if hasattr(chunk, "usage") and chunk.usage:
|
||||
@@ -446,7 +463,10 @@ def stream_openai_compat(
|
||||
inp = json.loads(v["args"]) if v["args"] else {}
|
||||
except json.JSONDecodeError:
|
||||
inp = {"_raw": v["args"]}
|
||||
tool_calls.append({"id": v["id"] or f"call_{idx}", "name": v["name"], "input": inp})
|
||||
tc_entry = {"id": v["id"] or f"call_{idx}", "name": v["name"], "input": inp}
|
||||
if v.get("extra_content"):
|
||||
tc_entry["extra_content"] = v["extra_content"]
|
||||
tool_calls.append(tc_entry)
|
||||
|
||||
yield AssistantTurn(text, tool_calls, in_tok, out_tok)
|
||||
|
||||
|
||||
14
nano-claude-code/skill/__init__.py
Normal file
14
nano-claude-code/skill/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""skill package — reusable prompt templates (skills)."""
|
||||
from .loader import ( # noqa: F401
|
||||
SkillDef,
|
||||
load_skills,
|
||||
find_skill,
|
||||
substitute_arguments,
|
||||
register_builtin_skill,
|
||||
_parse_skill_file,
|
||||
_parse_list_field,
|
||||
)
|
||||
from .executor import execute_skill # noqa: F401
|
||||
|
||||
# Importing builtin registers the built-in skills
|
||||
from . import builtin as _builtin # noqa: F401
|
||||
100
nano-claude-code/skill/builtin.py
Normal file
100
nano-claude-code/skill/builtin.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Built-in skills that ship with nano-claude-code."""
|
||||
from __future__ import annotations
|
||||
|
||||
from .loader import SkillDef, register_builtin_skill
|
||||
|
||||
# ── /commit ────────────────────────────────────────────────────────────────
|
||||
|
||||
_COMMIT_PROMPT = """\
|
||||
Review the current git state and create a well-structured commit.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Run `git status` and `git diff --staged` to see what is staged.
|
||||
- If nothing is staged, run `git diff` to see unstaged changes, then stage relevant files.
|
||||
2. Analyze the changes:
|
||||
- Summarize the nature of the change (feature, bug fix, refactor, docs, etc.)
|
||||
- Write a concise commit title (≤72 chars) focusing on *why*, not just *what*.
|
||||
- If multiple logical changes exist, ask the user whether to split them.
|
||||
3. Create the commit:
|
||||
```
|
||||
git commit -m "<title>"
|
||||
```
|
||||
If additional context is needed, add a body separated by a blank line.
|
||||
4. Print the commit hash and summary when done.
|
||||
|
||||
**Rules:**
|
||||
- Never use `--no-verify`.
|
||||
- Never commit files that likely contain secrets (.env, credentials, keys).
|
||||
- Prefer imperative mood in the title: "Add X", "Fix Y", "Refactor Z".
|
||||
|
||||
User context: $ARGUMENTS
|
||||
"""
|
||||
|
||||
_REVIEW_PROMPT = """\
|
||||
Review the code or pull request and provide structured feedback.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Understand the scope:
|
||||
- If a PR number or URL is given in $ARGUMENTS, use `gh pr view $ARGUMENTS --patch` to get the diff.
|
||||
- Otherwise, use `git diff main...HEAD` (or `git diff HEAD~1`) for local changes.
|
||||
2. Analyze the diff:
|
||||
- Correctness: Are there bugs, edge cases, or logic errors?
|
||||
- Security: Injection, auth issues, exposed secrets, unsafe operations?
|
||||
- Performance: N+1 queries, unnecessary allocations, blocking calls?
|
||||
- Style: Does it follow existing conventions in the codebase?
|
||||
- Tests: Are new behaviors tested? Do existing tests cover the change?
|
||||
3. Write a structured review:
|
||||
```
|
||||
## Summary
|
||||
One-line overview of what the change does.
|
||||
|
||||
## Issues
|
||||
- [CRITICAL/MAJOR/MINOR] Description and location
|
||||
|
||||
## Suggestions
|
||||
- Nice-to-have improvements
|
||||
|
||||
## Verdict
|
||||
APPROVE / REQUEST CHANGES / COMMENT
|
||||
```
|
||||
4. If changes are needed, list specific file:line references.
|
||||
|
||||
User context: $ARGUMENTS
|
||||
"""
|
||||
|
||||
|
||||
def _register_builtins() -> None:
|
||||
register_builtin_skill(SkillDef(
|
||||
name="commit",
|
||||
description="Review staged changes and create a well-structured git commit",
|
||||
triggers=["/commit"],
|
||||
tools=["Bash", "Read"],
|
||||
prompt=_COMMIT_PROMPT,
|
||||
file_path="<builtin>",
|
||||
when_to_use="Use when the user wants to commit changes. Triggers: '/commit', 'commit changes', 'make a commit'.",
|
||||
argument_hint="[optional context]",
|
||||
arguments=[],
|
||||
user_invocable=True,
|
||||
context="inline",
|
||||
source="builtin",
|
||||
))
|
||||
|
||||
register_builtin_skill(SkillDef(
|
||||
name="review",
|
||||
description="Review code changes or a pull request and provide structured feedback",
|
||||
triggers=["/review", "/review-pr"],
|
||||
tools=["Bash", "Read", "Grep"],
|
||||
prompt=_REVIEW_PROMPT,
|
||||
file_path="<builtin>",
|
||||
when_to_use="Use when the user wants a code review. Triggers: '/review', '/review-pr', 'review this PR'.",
|
||||
argument_hint="[PR number or URL]",
|
||||
arguments=["pr"],
|
||||
user_invocable=True,
|
||||
context="inline",
|
||||
source="builtin",
|
||||
))
|
||||
|
||||
|
||||
_register_builtins()
|
||||
66
nano-claude-code/skill/executor.py
Normal file
66
nano-claude-code/skill/executor.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Skill execution: inline (current conversation) or forked (sub-agent)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Generator
|
||||
|
||||
from .loader import SkillDef, substitute_arguments
|
||||
|
||||
|
||||
def execute_skill(
|
||||
skill: SkillDef,
|
||||
args: str,
|
||||
state,
|
||||
config: dict,
|
||||
system_prompt: str,
|
||||
) -> Generator:
|
||||
"""Execute a skill.
|
||||
|
||||
If skill.context == "fork", runs as an isolated sub-agent and yields its events.
|
||||
Otherwise (inline), injects the rendered prompt into the current agent loop.
|
||||
|
||||
Args:
|
||||
skill: SkillDef to execute
|
||||
args: raw argument string from user (after the trigger word)
|
||||
state: AgentState
|
||||
config: config dict (may contain _depth, model, etc.)
|
||||
system_prompt: current system prompt string
|
||||
Yields:
|
||||
agent events (TextChunk, ToolStart, ToolEnd, TurnDone, …)
|
||||
"""
|
||||
rendered = substitute_arguments(skill.prompt, args, skill.arguments)
|
||||
message = f"[Skill: {skill.name}]\n\n{rendered}"
|
||||
|
||||
if skill.context == "fork":
|
||||
yield from _execute_forked(skill, message, config, system_prompt)
|
||||
else:
|
||||
yield from _execute_inline(message, state, config, system_prompt)
|
||||
|
||||
|
||||
def _execute_inline(message: str, state, config: dict, system_prompt: str) -> Generator:
|
||||
"""Run skill prompt inline in the current conversation."""
|
||||
import agent as _agent
|
||||
yield from _agent.run(message, state, config, system_prompt)
|
||||
|
||||
|
||||
def _execute_forked(
|
||||
skill: SkillDef,
|
||||
message: str,
|
||||
config: dict,
|
||||
system_prompt: str,
|
||||
) -> Generator:
|
||||
"""Run skill as an isolated sub-agent (separate conversation context)."""
|
||||
import agent as _agent
|
||||
|
||||
# Build a sub-agent config with depth tracking
|
||||
depth = config.get("_depth", 0) + 1
|
||||
sub_config = {**config, "_depth": depth, "_system_prompt": system_prompt}
|
||||
if skill.model:
|
||||
sub_config["model"] = skill.model
|
||||
|
||||
# Restrict tools if skill specifies allowed-tools
|
||||
if skill.tools:
|
||||
sub_config["_allowed_tools"] = skill.tools
|
||||
|
||||
# Run in fresh state (no shared history)
|
||||
sub_state = _agent.AgentState()
|
||||
yield from _agent.run(message, sub_state, sub_config, system_prompt)
|
||||
184
nano-claude-code/skill/loader.py
Normal file
184
nano-claude-code/skill/loader.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""Skill loading: parse markdown files with YAML frontmatter into SkillDef objects."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillDef:
|
||||
name: str
|
||||
description: str
|
||||
triggers: list[str] # ["/commit", "commit changes"]
|
||||
tools: list[str] # ["Bash", "Read"] (allowed-tools)
|
||||
prompt: str # full prompt body after frontmatter
|
||||
file_path: str
|
||||
# Enhanced fields
|
||||
when_to_use: str = "" # when Claude should auto-invoke this skill
|
||||
argument_hint: str = "" # e.g. "[branch] [description]"
|
||||
arguments: list[str] = field(default_factory=list) # named arg names
|
||||
model: str = "" # model override
|
||||
user_invocable: bool = True # appears in /skills list
|
||||
context: str = "inline" # "inline" or "fork" (fork = sub-agent)
|
||||
source: str = "user" # "user", "project", "builtin"
|
||||
|
||||
|
||||
# ── Directory paths ────────────────────────────────────────────────────────
|
||||
|
||||
def _get_skill_paths() -> list[Path]:
|
||||
return [
|
||||
Path.cwd() / ".nano_claude" / "skills", # project-level (priority)
|
||||
Path.home() / ".nano_claude" / "skills", # user-level
|
||||
]
|
||||
|
||||
|
||||
# ── List field parser ──────────────────────────────────────────────────────
|
||||
|
||||
def _parse_list_field(value: str) -> list[str]:
|
||||
"""Parse YAML-like list: ``[a, b, c]`` or ``"a, b, c"``."""
|
||||
value = value.strip()
|
||||
if value.startswith("[") and value.endswith("]"):
|
||||
value = value[1:-1]
|
||||
return [item.strip().strip('"').strip("'") for item in value.split(",") if item.strip()]
|
||||
|
||||
|
||||
# ── Single-file parser ─────────────────────────────────────────────────────
|
||||
|
||||
def _parse_skill_file(path: Path, source: str = "user") -> Optional[SkillDef]:
|
||||
"""Parse a markdown file with ``---`` frontmatter into a SkillDef.
|
||||
|
||||
Frontmatter fields:
|
||||
name, description, triggers, tools / allowed-tools,
|
||||
when_to_use, argument-hint, arguments, model,
|
||||
user-invocable, context
|
||||
"""
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if not text.startswith("---"):
|
||||
return None
|
||||
|
||||
parts = text.split("---", 2)
|
||||
if len(parts) < 3:
|
||||
return None
|
||||
|
||||
frontmatter_raw = parts[1].strip()
|
||||
prompt = parts[2].strip()
|
||||
|
||||
fields: dict[str, str] = {}
|
||||
for line in frontmatter_raw.splitlines():
|
||||
line = line.strip()
|
||||
if not line or ":" not in line:
|
||||
continue
|
||||
key, _, val = line.partition(":")
|
||||
fields[key.strip().lower()] = val.strip()
|
||||
|
||||
name = fields.get("name", "")
|
||||
if not name:
|
||||
return None
|
||||
|
||||
# allowed-tools wins over tools if present
|
||||
tools_raw = fields.get("allowed-tools", fields.get("tools", ""))
|
||||
tools = _parse_list_field(tools_raw) if tools_raw else []
|
||||
|
||||
triggers_raw = fields.get("triggers", "")
|
||||
triggers = _parse_list_field(triggers_raw) if triggers_raw else [f"/{name}"]
|
||||
|
||||
arguments_raw = fields.get("arguments", "")
|
||||
arguments = _parse_list_field(arguments_raw) if arguments_raw else []
|
||||
|
||||
user_invocable_raw = fields.get("user-invocable", "true")
|
||||
user_invocable = user_invocable_raw.lower() not in ("false", "0", "no")
|
||||
|
||||
context = fields.get("context", "inline").strip().lower()
|
||||
if context not in ("inline", "fork"):
|
||||
context = "inline"
|
||||
|
||||
return SkillDef(
|
||||
name=name,
|
||||
description=fields.get("description", ""),
|
||||
triggers=triggers,
|
||||
tools=tools,
|
||||
prompt=prompt,
|
||||
file_path=str(path),
|
||||
when_to_use=fields.get("when_to_use", ""),
|
||||
argument_hint=fields.get("argument-hint", ""),
|
||||
arguments=arguments,
|
||||
model=fields.get("model", ""),
|
||||
user_invocable=user_invocable,
|
||||
context=context,
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
# ── Registry of built-in skills (registered by builtin.py) ────────────────
|
||||
|
||||
_BUILTIN_SKILLS: list[SkillDef] = []
|
||||
|
||||
|
||||
def register_builtin_skill(skill: SkillDef) -> None:
|
||||
_BUILTIN_SKILLS.append(skill)
|
||||
|
||||
|
||||
# ── Load all skills ────────────────────────────────────────────────────────
|
||||
|
||||
def load_skills(include_builtins: bool = True) -> list[SkillDef]:
|
||||
"""Return skills from disk + builtins, deduplicated (project > user > builtin)."""
|
||||
seen: dict[str, SkillDef] = {}
|
||||
|
||||
# Builtins go in first (lowest priority)
|
||||
if include_builtins:
|
||||
for sk in _BUILTIN_SKILLS:
|
||||
seen[sk.name] = sk
|
||||
|
||||
# User-level next, project-level last (highest priority)
|
||||
skill_paths = _get_skill_paths()
|
||||
for i, skill_dir in enumerate(reversed(skill_paths)):
|
||||
src = "user" if i == 0 else "project"
|
||||
if not skill_dir.is_dir():
|
||||
continue
|
||||
for md_file in sorted(skill_dir.glob("*.md")):
|
||||
skill = _parse_skill_file(md_file, source=src)
|
||||
if skill:
|
||||
seen[skill.name] = skill
|
||||
|
||||
return list(seen.values())
|
||||
|
||||
|
||||
def find_skill(query: str) -> Optional[SkillDef]:
|
||||
"""Find a skill whose trigger matches the first word (or whole string) of query."""
|
||||
query = query.strip()
|
||||
if not query:
|
||||
return None
|
||||
|
||||
first_word = query.split()[0]
|
||||
for skill in load_skills():
|
||||
for trigger in skill.triggers:
|
||||
if first_word == trigger:
|
||||
return skill
|
||||
if trigger.startswith(first_word + " "):
|
||||
return skill
|
||||
return None
|
||||
|
||||
|
||||
# ── Argument substitution ─────────────────────────────────────────────────
|
||||
|
||||
def substitute_arguments(prompt: str, args: str, arg_names: list[str]) -> str:
|
||||
"""Replace $ARGUMENTS (whole args string) and $ARG_NAME placeholders.
|
||||
|
||||
Named args are positional: first word → first name, etc.
|
||||
"""
|
||||
# Always substitute $ARGUMENTS
|
||||
result = prompt.replace("$ARGUMENTS", args)
|
||||
|
||||
# Named args: split by whitespace
|
||||
arg_values = args.split()
|
||||
for i, arg_name in enumerate(arg_names):
|
||||
placeholder = f"${arg_name.upper()}"
|
||||
value = arg_values[i] if i < len(arg_values) else ""
|
||||
result = result.replace(placeholder, value)
|
||||
|
||||
return result
|
||||
110
nano-claude-code/skill/tools.py
Normal file
110
nano-claude-code/skill/tools.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Skill tool: lets the model invoke skills by name via tool call."""
|
||||
from __future__ import annotations
|
||||
|
||||
from tool_registry import ToolDef, register_tool
|
||||
from .loader import find_skill, load_skills, substitute_arguments
|
||||
|
||||
|
||||
_SKILL_SCHEMA = {
|
||||
"name": "Skill",
|
||||
"description": (
|
||||
"Invoke a named skill (reusable prompt template). "
|
||||
"Use SkillList to see available skills and their triggers."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Skill name (e.g. 'commit', 'review')",
|
||||
},
|
||||
"args": {
|
||||
"type": "string",
|
||||
"description": "Arguments to pass to the skill (replaces $ARGUMENTS)",
|
||||
"default": "",
|
||||
},
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
}
|
||||
|
||||
_SKILL_LIST_SCHEMA = {
|
||||
"name": "SkillList",
|
||||
"description": "List all available skills with their names, triggers, and descriptions.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _skill_tool(params: dict, config: dict) -> str:
|
||||
"""Execute a skill by name and return its output."""
|
||||
skill_name = params.get("name", "").strip()
|
||||
args = params.get("args", "")
|
||||
|
||||
# Look up by name first, then by trigger
|
||||
skill = None
|
||||
for s in load_skills():
|
||||
if s.name == skill_name:
|
||||
skill = s
|
||||
break
|
||||
if skill is None:
|
||||
skill = find_skill(skill_name)
|
||||
if skill is None:
|
||||
names = [s.name for s in load_skills()]
|
||||
return f"Error: skill '{skill_name}' not found. Available: {', '.join(names)}"
|
||||
|
||||
rendered = substitute_arguments(skill.prompt, args, skill.arguments)
|
||||
message = f"[Skill: {skill.name}]\n\n{rendered}"
|
||||
|
||||
# Run inline via agent and collect text output
|
||||
import agent as _agent
|
||||
system_prompt = config.get("_system_prompt", "")
|
||||
|
||||
# Collect output text
|
||||
output_parts: list[str] = []
|
||||
sub_state = _agent.AgentState()
|
||||
sub_config = {**config, "_depth": config.get("_depth", 0) + 1}
|
||||
try:
|
||||
for event in _agent.run(message, sub_state, sub_config, system_prompt):
|
||||
if hasattr(event, "text"):
|
||||
output_parts.append(event.text)
|
||||
except Exception as e:
|
||||
return f"Skill execution error: {e}"
|
||||
|
||||
return "".join(output_parts) or "(skill completed with no text output)"
|
||||
|
||||
|
||||
def _skill_list_tool(params: dict, config: dict) -> str:
|
||||
skills = load_skills()
|
||||
if not skills:
|
||||
return "No skills available."
|
||||
lines = ["Available skills:\n"]
|
||||
for s in skills:
|
||||
triggers = ", ".join(s.triggers)
|
||||
hint = f" args: {s.argument_hint}" if s.argument_hint else ""
|
||||
when = f"\n when: {s.when_to_use}" if s.when_to_use else ""
|
||||
lines.append(f"- **{s.name}** [{triggers}]{hint}\n {s.description}{when}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _register() -> None:
|
||||
register_tool(ToolDef(
|
||||
name="Skill",
|
||||
schema=_SKILL_SCHEMA,
|
||||
func=_skill_tool,
|
||||
read_only=False,
|
||||
concurrent_safe=False,
|
||||
))
|
||||
register_tool(ToolDef(
|
||||
name="SkillList",
|
||||
schema=_SKILL_LIST_SCHEMA,
|
||||
func=_skill_list_tool,
|
||||
read_only=True,
|
||||
concurrent_safe=True,
|
||||
))
|
||||
|
||||
|
||||
_register()
|
||||
14
nano-claude-code/skills.py
Normal file
14
nano-claude-code/skills.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Backward-compatibility shim — real implementation is in skill/ package."""
|
||||
from skill.loader import ( # noqa: F401
|
||||
SkillDef,
|
||||
load_skills,
|
||||
find_skill,
|
||||
substitute_arguments,
|
||||
_parse_skill_file,
|
||||
_parse_list_field,
|
||||
)
|
||||
from skill.executor import execute_skill # noqa: F401
|
||||
|
||||
# Legacy constant — kept for tests that patch it
|
||||
from skill.loader import _get_skill_paths as _gsp
|
||||
SKILL_PATHS = _gsp()
|
||||
11
nano-claude-code/subagent.py
Normal file
11
nano-claude-code/subagent.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""Backward-compatibility shim — real implementation is in multi_agent/subagent.py."""
|
||||
from multi_agent.subagent import ( # noqa: F401
|
||||
AgentDefinition,
|
||||
SubAgentTask,
|
||||
SubAgentManager,
|
||||
load_agent_definitions,
|
||||
get_agent_definition,
|
||||
_extract_final_text,
|
||||
_agent_run,
|
||||
_BUILTIN_AGENTS,
|
||||
)
|
||||
0
nano-claude-code/tests/__init__.py
Normal file
0
nano-claude-code/tests/__init__.py
Normal file
187
nano-claude-code/tests/test_compaction.py
Normal file
187
nano-claude-code/tests/test_compaction.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""Tests for compaction.py — token estimation, context limits, snipping, split point."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Ensure project root is on sys.path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from compaction import estimate_tokens, get_context_limit, snip_old_tool_results, find_split_point
|
||||
|
||||
|
||||
# ── estimate_tokens ───────────────────────────────────────────────────────
|
||||
|
||||
class TestEstimateTokens:
|
||||
def test_simple_messages(self):
|
||||
msgs = [
|
||||
{"role": "user", "content": "Hello world"}, # 11 chars
|
||||
{"role": "assistant", "content": "Hi there!"}, # 9 chars
|
||||
]
|
||||
result = estimate_tokens(msgs)
|
||||
# (11 + 9) / 3.5 = 5.71 -> 5
|
||||
assert result == int(20 / 3.5)
|
||||
|
||||
def test_empty_messages(self):
|
||||
assert estimate_tokens([]) == 0
|
||||
|
||||
def test_empty_content(self):
|
||||
msgs = [{"role": "user", "content": ""}]
|
||||
assert estimate_tokens(msgs) == 0
|
||||
|
||||
def test_tool_result_messages(self):
|
||||
msgs = [
|
||||
{"role": "tool", "tool_call_id": "abc", "name": "Read", "content": "x" * 350},
|
||||
]
|
||||
result = estimate_tokens(msgs)
|
||||
assert result == int(350 / 3.5)
|
||||
|
||||
def test_structured_content(self):
|
||||
"""Content that is a list of dicts (e.g. Anthropic tool_result blocks)."""
|
||||
msgs = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "tool_result", "tool_use_id": "id1", "content": "A" * 70},
|
||||
],
|
||||
},
|
||||
]
|
||||
result = estimate_tokens(msgs)
|
||||
# "tool_result" (11) + "id1" (3) + "A"*70 (70) = 84 -> 84/3.5 = 24
|
||||
assert result == int(84 / 3.5)
|
||||
|
||||
def test_with_tool_calls(self):
|
||||
msgs = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "ok",
|
||||
"tool_calls": [
|
||||
{"id": "c1", "name": "Bash", "input": {"command": "ls"}},
|
||||
],
|
||||
},
|
||||
]
|
||||
result = estimate_tokens(msgs)
|
||||
# content "ok" (2) + tool_calls string values: "c1" (2) + "Bash" (4) = 8
|
||||
assert result == int(8 / 3.5)
|
||||
|
||||
|
||||
# ── get_context_limit ─────────────────────────────────────────────────────
|
||||
|
||||
class TestGetContextLimit:
|
||||
def test_anthropic(self):
|
||||
assert get_context_limit("claude-opus-4-6") == 200000
|
||||
|
||||
def test_gemini(self):
|
||||
assert get_context_limit("gemini-2.0-flash") == 1000000
|
||||
|
||||
def test_deepseek(self):
|
||||
assert get_context_limit("deepseek-chat") == 64000
|
||||
|
||||
def test_openai(self):
|
||||
assert get_context_limit("gpt-4o") == 128000
|
||||
|
||||
def test_qwen(self):
|
||||
assert get_context_limit("qwen-max") == 1000000
|
||||
|
||||
def test_unknown_model_fallback(self):
|
||||
# Unknown models fall back to openai provider which has 128000
|
||||
assert get_context_limit("some-random-model-xyz") == 128000
|
||||
|
||||
def test_explicit_provider_prefix(self):
|
||||
assert get_context_limit("ollama/llama3.3") == 128000
|
||||
|
||||
|
||||
# ── snip_old_tool_results ─────────────────────────────────────────────────
|
||||
|
||||
class TestSnipOldToolResults:
|
||||
def test_old_tool_results_get_truncated(self):
|
||||
long_content = "A" * 5000
|
||||
msgs = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "let me check", "tool_calls": []},
|
||||
{"role": "tool", "tool_call_id": "t1", "name": "Read", "content": long_content},
|
||||
{"role": "user", "content": "thanks"},
|
||||
{"role": "assistant", "content": "you're welcome"},
|
||||
{"role": "user", "content": "bye"},
|
||||
{"role": "assistant", "content": "goodbye"},
|
||||
{"role": "user", "content": "wait"},
|
||||
{"role": "assistant", "content": "yes?"},
|
||||
{"role": "user", "content": "never mind"},
|
||||
]
|
||||
result = snip_old_tool_results(msgs, max_chars=2000, preserve_last_n_turns=6)
|
||||
assert result is msgs # mutated in place
|
||||
tool_msg = msgs[2]
|
||||
assert len(tool_msg["content"]) < 5000
|
||||
assert "snipped" in tool_msg["content"]
|
||||
|
||||
def test_recent_tool_results_preserved(self):
|
||||
long_content = "B" * 5000
|
||||
msgs = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "ok", "tool_calls": []},
|
||||
{"role": "tool", "tool_call_id": "t1", "name": "Read", "content": long_content},
|
||||
]
|
||||
# All 3 messages are within preserve_last_n_turns=6
|
||||
result = snip_old_tool_results(msgs, max_chars=2000, preserve_last_n_turns=6)
|
||||
assert msgs[2]["content"] == long_content # not truncated
|
||||
|
||||
def test_short_tool_results_not_touched(self):
|
||||
msgs = [
|
||||
{"role": "tool", "tool_call_id": "t1", "name": "Bash", "content": "short"},
|
||||
{"role": "user", "content": "a"},
|
||||
{"role": "user", "content": "b"},
|
||||
{"role": "user", "content": "c"},
|
||||
{"role": "user", "content": "d"},
|
||||
{"role": "user", "content": "e"},
|
||||
{"role": "user", "content": "f"},
|
||||
]
|
||||
snip_old_tool_results(msgs, max_chars=2000, preserve_last_n_turns=6)
|
||||
assert msgs[0]["content"] == "short"
|
||||
|
||||
def test_non_tool_messages_untouched(self):
|
||||
msgs = [
|
||||
{"role": "user", "content": "X" * 5000},
|
||||
{"role": "user", "content": "a"},
|
||||
{"role": "user", "content": "b"},
|
||||
{"role": "user", "content": "c"},
|
||||
{"role": "user", "content": "d"},
|
||||
{"role": "user", "content": "e"},
|
||||
{"role": "user", "content": "f"},
|
||||
]
|
||||
snip_old_tool_results(msgs, max_chars=2000, preserve_last_n_turns=6)
|
||||
assert msgs[0]["content"] == "X" * 5000
|
||||
|
||||
|
||||
# ── find_split_point ──────────────────────────────────────────────────────
|
||||
|
||||
class TestFindSplitPoint:
|
||||
def test_returns_reasonable_index(self):
|
||||
msgs = [
|
||||
{"role": "user", "content": "A" * 1000},
|
||||
{"role": "assistant", "content": "B" * 1000},
|
||||
{"role": "user", "content": "C" * 1000},
|
||||
{"role": "assistant", "content": "D" * 1000},
|
||||
{"role": "user", "content": "E" * 1000},
|
||||
]
|
||||
idx = find_split_point(msgs, keep_ratio=0.3)
|
||||
# With equal-size messages and keep_ratio=0.3, split should be around index 3-4
|
||||
assert 2 <= idx <= 4
|
||||
|
||||
def test_single_message(self):
|
||||
msgs = [{"role": "user", "content": "hello"}]
|
||||
idx = find_split_point(msgs, keep_ratio=0.3)
|
||||
assert idx == 0
|
||||
|
||||
def test_empty_messages(self):
|
||||
idx = find_split_point([], keep_ratio=0.3)
|
||||
assert idx == 0
|
||||
|
||||
def test_split_preserves_recent(self):
|
||||
# Recent portion should contain ~30% of tokens
|
||||
msgs = [{"role": "user", "content": "X" * 100} for _ in range(10)]
|
||||
idx = find_split_point(msgs, keep_ratio=0.3)
|
||||
total = estimate_tokens(msgs)
|
||||
recent = estimate_tokens(msgs[idx:])
|
||||
# Recent should be roughly 30% of total (allow some tolerance)
|
||||
assert recent >= total * 0.2
|
||||
assert recent <= total * 0.5
|
||||
50
nano-claude-code/tests/test_diff_view.py
Normal file
50
nano-claude-code/tests/test_diff_view.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import sys, os, tempfile
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
import pytest
|
||||
|
||||
def test_generate_unified_diff():
|
||||
from tools import generate_unified_diff
|
||||
old = "line1\nline2\nline3\n"
|
||||
new = "line1\nline2_modified\nline3\n"
|
||||
diff = generate_unified_diff(old, new, "test.py")
|
||||
assert "--- a/test.py" in diff
|
||||
assert "+++ b/test.py" in diff
|
||||
assert "-line2" in diff
|
||||
assert "+line2_modified" in diff
|
||||
|
||||
def test_generate_unified_diff_empty_old():
|
||||
from tools import generate_unified_diff
|
||||
diff = generate_unified_diff("", "new content\n", "test.py")
|
||||
assert "+new content" in diff
|
||||
|
||||
def test_edit_returns_diff(tmp_path):
|
||||
from tools import _edit
|
||||
f = tmp_path / "test.txt"
|
||||
f.write_text("hello world\n")
|
||||
result = _edit(str(f), "hello", "goodbye")
|
||||
assert "-hello world" in result
|
||||
assert "+goodbye world" in result
|
||||
|
||||
def test_write_existing_returns_diff(tmp_path):
|
||||
from tools import _write
|
||||
f = tmp_path / "test.txt"
|
||||
f.write_text("old content\n")
|
||||
result = _write(str(f), "new content\n")
|
||||
assert "-old content" in result
|
||||
assert "+new content" in result
|
||||
|
||||
def test_write_new_file_no_diff(tmp_path):
|
||||
from tools import _write
|
||||
f = tmp_path / "new.txt"
|
||||
result = _write(str(f), "content\n")
|
||||
assert "Created" in result
|
||||
assert "---" not in result
|
||||
|
||||
def test_diff_truncation():
|
||||
from tools import generate_unified_diff, maybe_truncate_diff
|
||||
old = "\n".join(f"line{i}" for i in range(200))
|
||||
new = "\n".join(f"CHANGED{i}" for i in range(200))
|
||||
diff = generate_unified_diff(old, new, "big.py")
|
||||
truncated = maybe_truncate_diff(diff, max_lines=50)
|
||||
assert "more lines" in truncated
|
||||
assert truncated.count("\n") < 60
|
||||
275
nano-claude-code/tests/test_memory.py
Normal file
275
nano-claude-code/tests/test_memory.py
Normal file
@@ -0,0 +1,275 @@
|
||||
"""Tests for the memory package (memory/)."""
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
import memory.store as _store
|
||||
from memory.store import (
|
||||
MemoryEntry,
|
||||
save_memory,
|
||||
load_index,
|
||||
load_entries,
|
||||
delete_memory,
|
||||
search_memory,
|
||||
_slugify,
|
||||
parse_frontmatter,
|
||||
get_index_content,
|
||||
)
|
||||
from memory.context import get_memory_context, truncate_index_content
|
||||
from memory.scan import (
|
||||
scan_memory_dir,
|
||||
format_memory_manifest,
|
||||
memory_age_days,
|
||||
memory_age_str,
|
||||
memory_freshness_text,
|
||||
MemoryHeader,
|
||||
)
|
||||
from memory.types import MEMORY_TYPES
|
||||
|
||||
|
||||
# ── Fixtures ─────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def redirect_memory_dirs(tmp_path, monkeypatch):
|
||||
"""Redirect user and project memory dirs to tmp_path for all tests."""
|
||||
user_mem = tmp_path / "user_memory"
|
||||
user_mem.mkdir()
|
||||
proj_mem = tmp_path / "project_memory"
|
||||
proj_mem.mkdir()
|
||||
|
||||
monkeypatch.setattr(_store, "USER_MEMORY_DIR", user_mem)
|
||||
|
||||
# Patch get_project_memory_dir to return our tmp project dir
|
||||
monkeypatch.setattr(_store, "get_project_memory_dir", lambda: proj_mem)
|
||||
|
||||
|
||||
def _make_entry(name="test note", description="a test", type_="user",
|
||||
content="hello world", scope="user"):
|
||||
return MemoryEntry(
|
||||
name=name, description=description, type=type_,
|
||||
content=content, created="2026-04-02", scope=scope,
|
||||
)
|
||||
|
||||
|
||||
# ── Save and Load ─────────────────────────────────────────────────────────
|
||||
|
||||
class TestSaveAndLoad:
|
||||
def test_roundtrip(self):
|
||||
entry = _make_entry()
|
||||
save_memory(entry, scope="user")
|
||||
loaded = load_entries("user")
|
||||
assert len(loaded) == 1
|
||||
assert loaded[0].name == "test note"
|
||||
assert loaded[0].description == "a test"
|
||||
assert loaded[0].type == "user"
|
||||
assert loaded[0].content == "hello world"
|
||||
|
||||
def test_creates_file_on_disk(self):
|
||||
entry = _make_entry()
|
||||
save_memory(entry, scope="user")
|
||||
assert Path(entry.file_path).exists()
|
||||
text = Path(entry.file_path).read_text()
|
||||
assert "hello world" in text
|
||||
|
||||
def test_update_existing(self):
|
||||
"""Save same name twice → only 1 entry with updated content."""
|
||||
save_memory(_make_entry(content="version 1"), scope="user")
|
||||
save_memory(_make_entry(content="version 2"), scope="user")
|
||||
loaded = load_entries("user")
|
||||
assert len(loaded) == 1
|
||||
assert loaded[0].content == "version 2"
|
||||
|
||||
def test_project_scope_stored_separately(self):
|
||||
save_memory(_make_entry(name="user note"), scope="user")
|
||||
save_memory(_make_entry(name="proj note"), scope="project")
|
||||
user_entries = load_entries("user")
|
||||
proj_entries = load_entries("project")
|
||||
assert len(user_entries) == 1
|
||||
assert len(proj_entries) == 1
|
||||
assert user_entries[0].name == "user note"
|
||||
assert proj_entries[0].name == "proj note"
|
||||
|
||||
def test_load_index_all_combines_scopes(self):
|
||||
save_memory(_make_entry(name="user note"), scope="user")
|
||||
save_memory(_make_entry(name="proj note"), scope="project")
|
||||
all_entries = load_index("all")
|
||||
names = {e.name for e in all_entries}
|
||||
assert "user note" in names
|
||||
assert "proj note" in names
|
||||
|
||||
|
||||
# ── Delete ────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestDelete:
|
||||
def test_delete_removes_file_and_index(self):
|
||||
entry = _make_entry()
|
||||
save_memory(entry, scope="user")
|
||||
delete_memory("test note", scope="user")
|
||||
assert load_entries("user") == []
|
||||
assert not Path(entry.file_path).exists()
|
||||
|
||||
def test_delete_nonexistent_no_error(self):
|
||||
delete_memory("nonexistent", scope="user")
|
||||
|
||||
def test_delete_from_project_scope(self):
|
||||
save_memory(_make_entry(name="proj note"), scope="project")
|
||||
delete_memory("proj note", scope="project")
|
||||
assert load_entries("project") == []
|
||||
|
||||
|
||||
# ── Search ────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestSearch:
|
||||
def test_search_by_keyword(self):
|
||||
save_memory(_make_entry(name="python tips", content="use list comprehension"), scope="user")
|
||||
save_memory(_make_entry(name="rust tips", content="use iterators"), scope="user")
|
||||
results = search_memory("python")
|
||||
assert len(results) == 1
|
||||
assert results[0].name == "python tips"
|
||||
|
||||
def test_search_case_insensitive(self):
|
||||
save_memory(_make_entry(name="Important Note", content="something"), scope="user")
|
||||
results = search_memory("important")
|
||||
assert len(results) == 1
|
||||
|
||||
def test_search_in_content(self):
|
||||
save_memory(_make_entry(name="misc", content="the quick brown fox"), scope="user")
|
||||
results = search_memory("brown fox")
|
||||
assert len(results) == 1
|
||||
|
||||
def test_search_across_scopes(self):
|
||||
save_memory(_make_entry(name="user note", content="alpha"), scope="user")
|
||||
save_memory(_make_entry(name="proj note", content="alpha"), scope="project")
|
||||
results = search_memory("alpha", scope="all")
|
||||
assert len(results) == 2
|
||||
|
||||
|
||||
# ── Memory context ────────────────────────────────────────────────────────
|
||||
|
||||
class TestGetMemoryContext:
|
||||
def test_returns_index_text(self):
|
||||
save_memory(_make_entry(name="my note", description="desc here"), scope="user")
|
||||
ctx = get_memory_context()
|
||||
assert "my note" in ctx
|
||||
assert "desc here" in ctx
|
||||
|
||||
def test_empty_when_no_memories(self):
|
||||
ctx = get_memory_context()
|
||||
assert ctx == ""
|
||||
|
||||
def test_project_memories_labelled(self):
|
||||
save_memory(_make_entry(name="proj note", description="project context"), scope="project")
|
||||
ctx = get_memory_context()
|
||||
assert "Project memories" in ctx
|
||||
assert "proj note" in ctx
|
||||
|
||||
|
||||
# ── Truncation ────────────────────────────────────────────────────────────
|
||||
|
||||
class TestTruncation:
|
||||
def test_no_truncation_within_limits(self):
|
||||
text = "- line\n" * 10
|
||||
result = truncate_index_content(text)
|
||||
assert "WARNING" not in result
|
||||
|
||||
def test_line_truncation(self):
|
||||
text = "\n".join(f"- line {i}" for i in range(300))
|
||||
result = truncate_index_content(text)
|
||||
assert "WARNING" in result
|
||||
assert "lines" in result
|
||||
|
||||
def test_byte_truncation(self):
|
||||
# 25001 bytes of content
|
||||
text = "x" * 25001
|
||||
result = truncate_index_content(text)
|
||||
assert "WARNING" in result
|
||||
|
||||
|
||||
# ── Slugify ───────────────────────────────────────────────────────────────
|
||||
|
||||
class TestSlugify:
|
||||
def test_basic(self):
|
||||
assert _slugify("Hello World") == "hello_world"
|
||||
|
||||
def test_special_chars(self):
|
||||
assert _slugify("foo@bar!baz") == "foobarbaz"
|
||||
|
||||
def test_max_length(self):
|
||||
assert len(_slugify("a" * 100)) == 60
|
||||
|
||||
|
||||
# ── parse_frontmatter ─────────────────────────────────────────────────────
|
||||
|
||||
class TestParseFrontmatter:
|
||||
def test_parse(self):
|
||||
text = "---\nname: foo\ntype: user\n---\nbody text"
|
||||
meta, body = parse_frontmatter(text)
|
||||
assert meta["name"] == "foo"
|
||||
assert meta["type"] == "user"
|
||||
assert body == "body text"
|
||||
|
||||
def test_no_frontmatter(self):
|
||||
meta, body = parse_frontmatter("just plain text")
|
||||
assert meta == {}
|
||||
assert body == "just plain text"
|
||||
|
||||
|
||||
# ── scan / age / freshness ────────────────────────────────────────────────
|
||||
|
||||
class TestScanAndAge:
|
||||
def test_scan_memory_dir(self):
|
||||
save_memory(_make_entry(name="note a"), scope="user")
|
||||
save_memory(_make_entry(name="note b"), scope="user")
|
||||
user_dir = _store.USER_MEMORY_DIR
|
||||
headers = scan_memory_dir(user_dir, "user")
|
||||
assert len(headers) == 2
|
||||
assert all(isinstance(h, MemoryHeader) for h in headers)
|
||||
|
||||
def test_format_manifest(self):
|
||||
import time
|
||||
headers = [
|
||||
MemoryHeader(
|
||||
filename="foo.md",
|
||||
file_path="/tmp/foo.md",
|
||||
mtime_s=time.time(),
|
||||
description="test desc",
|
||||
type="user",
|
||||
scope="user",
|
||||
)
|
||||
]
|
||||
manifest = format_memory_manifest(headers)
|
||||
assert "foo.md" in manifest
|
||||
assert "test desc" in manifest
|
||||
assert "today" in manifest
|
||||
|
||||
def test_memory_age_days_today(self):
|
||||
import time
|
||||
assert memory_age_days(time.time()) == 0
|
||||
|
||||
def test_memory_age_days_old(self):
|
||||
import time
|
||||
old = time.time() - 5 * 86400 # 5 days ago
|
||||
assert memory_age_days(old) == 5
|
||||
|
||||
def test_memory_age_str(self):
|
||||
import time
|
||||
assert memory_age_str(time.time()) == "today"
|
||||
assert memory_age_str(time.time() - 86400) == "yesterday"
|
||||
assert memory_age_str(time.time() - 3 * 86400) == "3 days ago"
|
||||
|
||||
def test_freshness_text_fresh(self):
|
||||
import time
|
||||
assert memory_freshness_text(time.time()) == ""
|
||||
|
||||
def test_freshness_text_stale(self):
|
||||
import time
|
||||
old = time.time() - 10 * 86400
|
||||
text = memory_freshness_text(old)
|
||||
assert "10 days old" in text
|
||||
assert "stale" in text.lower() or "outdated" in text.lower()
|
||||
|
||||
|
||||
# ── Memory types ──────────────────────────────────────────────────────────
|
||||
|
||||
class TestMemoryTypes:
|
||||
def test_types_list(self):
|
||||
assert set(MEMORY_TYPES) == {"user", "feedback", "project", "reference"}
|
||||
234
nano-claude-code/tests/test_skills.py
Normal file
234
nano-claude-code/tests/test_skills.py
Normal file
@@ -0,0 +1,234 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
import skill.loader as _loader
|
||||
from skill.loader import _parse_skill_file, _parse_list_field, find_skill, SkillDef
|
||||
from skill import load_skills, substitute_arguments
|
||||
|
||||
|
||||
COMMIT_MD = """\
|
||||
---
|
||||
name: commit
|
||||
description: Create a git commit
|
||||
triggers: [/commit, commit changes]
|
||||
tools: [Bash, Read]
|
||||
---
|
||||
Review staged changes and create a commit with a descriptive message.
|
||||
"""
|
||||
|
||||
REVIEW_MD = """\
|
||||
---
|
||||
name: review
|
||||
description: Review a pull request
|
||||
triggers: [/review, /review-pr]
|
||||
tools: [Bash, Read, Grep]
|
||||
---
|
||||
Analyze the PR diff and provide constructive feedback.
|
||||
"""
|
||||
|
||||
ARGS_MD = """\
|
||||
---
|
||||
name: deploy
|
||||
description: Deploy to an environment
|
||||
triggers: [/deploy]
|
||||
tools: [Bash]
|
||||
argument-hint: [env] [version]
|
||||
arguments: [env, version]
|
||||
---
|
||||
Deploy $VERSION to $ENV environment. Full args: $ARGUMENTS
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def skill_dir(tmp_path, monkeypatch):
|
||||
"""Create a temp skill directory with sample skills and patch _get_skill_paths."""
|
||||
skills_dir = tmp_path / "skills"
|
||||
skills_dir.mkdir()
|
||||
(skills_dir / "commit.md").write_text(COMMIT_MD, encoding="utf-8")
|
||||
(skills_dir / "review.md").write_text(REVIEW_MD, encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(_loader, "_get_skill_paths", lambda: [skills_dir])
|
||||
# Also patch the builtin list to be empty so tests are predictable
|
||||
monkeypatch.setattr(_loader, "_BUILTIN_SKILLS", [])
|
||||
return skills_dir
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# _parse_list_field
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_parse_list_field_bracket():
|
||||
assert _parse_list_field("[a, b, c]") == ["a", "b", "c"]
|
||||
|
||||
|
||||
def test_parse_list_field_plain():
|
||||
assert _parse_list_field("a, b, c") == ["a", "b", "c"]
|
||||
|
||||
|
||||
def test_parse_list_field_single():
|
||||
assert _parse_list_field("solo") == ["solo"]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# _parse_skill_file
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_parse_skill_file(skill_dir):
|
||||
path = skill_dir / "commit.md"
|
||||
skill = _parse_skill_file(path)
|
||||
assert skill is not None
|
||||
assert skill.name == "commit"
|
||||
assert skill.description == "Create a git commit"
|
||||
assert "/commit" in skill.triggers
|
||||
assert "commit changes" in skill.triggers
|
||||
assert "Bash" in skill.tools
|
||||
assert "Read" in skill.tools
|
||||
assert "commit" in skill.prompt.lower()
|
||||
assert skill.file_path == str(path)
|
||||
|
||||
|
||||
def test_parse_skill_file_review(skill_dir):
|
||||
path = skill_dir / "review.md"
|
||||
skill = _parse_skill_file(path)
|
||||
assert skill is not None
|
||||
assert skill.name == "review"
|
||||
assert "/review" in skill.triggers
|
||||
assert "/review-pr" in skill.triggers
|
||||
|
||||
|
||||
def test_parse_skill_file_invalid(tmp_path):
|
||||
bad = tmp_path / "bad.md"
|
||||
bad.write_text("no frontmatter here", encoding="utf-8")
|
||||
assert _parse_skill_file(bad) is None
|
||||
|
||||
|
||||
def test_parse_skill_file_no_name(tmp_path):
|
||||
no_name = tmp_path / "noname.md"
|
||||
no_name.write_text("---\ndescription: test\n---\nbody\n", encoding="utf-8")
|
||||
assert _parse_skill_file(no_name) is None
|
||||
|
||||
|
||||
def test_parse_skill_file_context_fork(tmp_path):
|
||||
fork_md = tmp_path / "fork.md"
|
||||
fork_md.write_text("---\nname: fork-task\ndescription: test\ncontext: fork\n---\nbody\n")
|
||||
skill = _parse_skill_file(fork_md)
|
||||
assert skill is not None
|
||||
assert skill.context == "fork"
|
||||
|
||||
|
||||
def test_parse_skill_file_allowed_tools(tmp_path):
|
||||
md = tmp_path / "t.md"
|
||||
md.write_text("---\nname: myskill\ndescription: d\nallowed-tools: [Bash, Read]\n---\nbody\n")
|
||||
skill = _parse_skill_file(md)
|
||||
assert skill is not None
|
||||
assert "Bash" in skill.tools
|
||||
assert "Read" in skill.tools
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# load_skills
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_load_skills(skill_dir):
|
||||
skills = load_skills()
|
||||
assert len(skills) == 2
|
||||
names = {s.name for s in skills}
|
||||
assert names == {"commit", "review"}
|
||||
|
||||
|
||||
def test_load_skills_empty_dir(tmp_path, monkeypatch):
|
||||
empty = tmp_path / "empty_skills"
|
||||
empty.mkdir()
|
||||
monkeypatch.setattr(_loader, "_get_skill_paths", lambda: [empty])
|
||||
monkeypatch.setattr(_loader, "_BUILTIN_SKILLS", [])
|
||||
assert load_skills() == []
|
||||
|
||||
|
||||
def test_load_skills_nonexistent_dir(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(_loader, "_get_skill_paths", lambda: [tmp_path / "does_not_exist"])
|
||||
monkeypatch.setattr(_loader, "_BUILTIN_SKILLS", [])
|
||||
assert load_skills() == []
|
||||
|
||||
|
||||
def test_load_skills_builtins_present(monkeypatch):
|
||||
"""Without patching, builtins (commit, review) should be present."""
|
||||
monkeypatch.setattr(_loader, "_get_skill_paths", lambda: [])
|
||||
skills = load_skills()
|
||||
names = {s.name for s in skills}
|
||||
assert "commit" in names
|
||||
assert "review" in names
|
||||
|
||||
|
||||
def test_load_skills_project_overrides_builtin(tmp_path, monkeypatch):
|
||||
"""A project skill with the same name overrides the builtin."""
|
||||
skills_dir = tmp_path / "skills"
|
||||
skills_dir.mkdir()
|
||||
# project-level "commit" with different description
|
||||
(skills_dir / "commit.md").write_text(
|
||||
"---\nname: commit\ndescription: OVERRIDDEN\n---\ncustom commit prompt\n"
|
||||
)
|
||||
monkeypatch.setattr(_loader, "_get_skill_paths", lambda: [skills_dir])
|
||||
skills = load_skills()
|
||||
commit = next(s for s in skills if s.name == "commit")
|
||||
assert commit.description == "OVERRIDDEN"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# find_skill
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_find_skill_commit(skill_dir):
|
||||
skill = find_skill("/commit")
|
||||
assert skill is not None
|
||||
assert skill.name == "commit"
|
||||
|
||||
|
||||
def test_find_skill_review(skill_dir):
|
||||
skill = find_skill("/review")
|
||||
assert skill is not None
|
||||
assert skill.name == "review"
|
||||
|
||||
|
||||
def test_find_skill_review_pr(skill_dir):
|
||||
skill = find_skill("/review-pr some-pr-url")
|
||||
assert skill is not None
|
||||
assert skill.name == "review"
|
||||
|
||||
|
||||
def test_find_skill_nonexistent(skill_dir):
|
||||
result = find_skill("/nonexistent")
|
||||
assert result is None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# substitute_arguments
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_substitute_arguments_placeholder():
|
||||
result = substitute_arguments("Deploy $ARGUMENTS please", "v1.2 prod", [])
|
||||
assert result == "Deploy v1.2 prod please"
|
||||
|
||||
|
||||
def test_substitute_named_args(tmp_path):
|
||||
result = substitute_arguments(
|
||||
"Deploy $VERSION to $ENV. Full args: $ARGUMENTS",
|
||||
"1.0 staging",
|
||||
["env", "version"],
|
||||
)
|
||||
# arg_names are positional: env=1.0, version=staging
|
||||
assert "$VERSION" not in result
|
||||
assert "$ENV" not in result
|
||||
assert "$ARGUMENTS" not in result
|
||||
|
||||
|
||||
def test_substitute_missing_arg():
|
||||
# If user provides fewer args than named slots, missing ones become ""
|
||||
result = substitute_arguments("Hello $NAME!", "", ["name"])
|
||||
assert result == "Hello !"
|
||||
|
||||
|
||||
def test_substitute_no_placeholders():
|
||||
result = substitute_arguments("just a plain prompt", "some args", [])
|
||||
assert result == "just a plain prompt"
|
||||
136
nano-claude-code/tests/test_subagent.py
Normal file
136
nano-claude-code/tests/test_subagent.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""Tests for the sub-agent system (subagent.py)."""
|
||||
import time
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from multi_agent.subagent import SubAgentManager, SubAgentTask, _extract_final_text
|
||||
|
||||
|
||||
# ── Mock for _agent_run ──────────────────────────────────────────────────
|
||||
|
||||
def _make_mock_agent_run(sleep_per_iter=0.05, iters=3):
|
||||
"""Return a mock _agent_run that simulates work and checks cancellation."""
|
||||
|
||||
def mock_agent_run(prompt, state, config, system_prompt, depth=0, cancel_check=None):
|
||||
for i in range(iters):
|
||||
if cancel_check and cancel_check():
|
||||
return
|
||||
time.sleep(sleep_per_iter)
|
||||
# Append an assistant message to state
|
||||
state.messages.append({
|
||||
"role": "assistant",
|
||||
"content": f"Result for: {prompt}",
|
||||
"tool_calls": [],
|
||||
})
|
||||
# Yield a TurnDone-like event (generator protocol)
|
||||
yield None
|
||||
|
||||
return mock_agent_run
|
||||
|
||||
|
||||
def _make_slow_mock(sleep_per_iter=0.2, iters=10):
|
||||
"""Return a slow mock for cancellation testing."""
|
||||
return _make_mock_agent_run(sleep_per_iter=sleep_per_iter, iters=iters)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager(monkeypatch):
|
||||
"""Create a SubAgentManager with mocked _agent_run."""
|
||||
mock = _make_mock_agent_run()
|
||||
monkeypatch.setattr("multi_agent.subagent._agent_run", mock)
|
||||
mgr = SubAgentManager(max_concurrent=3, max_depth=3)
|
||||
yield mgr
|
||||
mgr.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def slow_manager(monkeypatch):
|
||||
"""Create a SubAgentManager with a slow mock for cancel testing."""
|
||||
mock = _make_slow_mock()
|
||||
monkeypatch.setattr("multi_agent.subagent._agent_run", mock)
|
||||
mgr = SubAgentManager(max_concurrent=3, max_depth=3)
|
||||
yield mgr
|
||||
mgr.shutdown()
|
||||
|
||||
|
||||
# ── Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestSpawnAndWait:
|
||||
def test_spawn_and_wait_completes(self, manager):
|
||||
task = manager.spawn("hello", {}, "system")
|
||||
result_task = manager.wait(task.id, timeout=5)
|
||||
assert result_task is not None
|
||||
assert result_task.status == "completed"
|
||||
assert result_task.result == "Result for: hello"
|
||||
|
||||
def test_spawn_returns_immediately(self, manager):
|
||||
task = manager.spawn("hello", {}, "system")
|
||||
# Task should be pending or running, not yet completed
|
||||
assert task.status in ("pending", "running")
|
||||
|
||||
|
||||
class TestListTasks:
|
||||
def test_list_tasks(self, manager):
|
||||
t1 = manager.spawn("task1", {}, "system")
|
||||
t2 = manager.spawn("task2", {}, "system")
|
||||
tasks = manager.list_tasks()
|
||||
task_ids = [t.id for t in tasks]
|
||||
assert t1.id in task_ids
|
||||
assert t2.id in task_ids
|
||||
assert len(tasks) == 2
|
||||
|
||||
|
||||
class TestCancel:
|
||||
def test_cancel_running_task(self, slow_manager):
|
||||
task = slow_manager.spawn("slow task", {}, "system")
|
||||
# Wait briefly to ensure the task starts running
|
||||
time.sleep(0.1)
|
||||
assert task.status == "running"
|
||||
success = slow_manager.cancel(task.id)
|
||||
assert success is True
|
||||
# Wait for the task to actually finish
|
||||
slow_manager.wait(task.id, timeout=5)
|
||||
assert task.status == "cancelled"
|
||||
|
||||
|
||||
class TestDepthLimit:
|
||||
def test_spawn_at_max_depth_fails(self, manager):
|
||||
task = manager.spawn("deep", {}, "system", depth=3)
|
||||
assert task.status == "failed"
|
||||
assert "Max depth" in task.result
|
||||
|
||||
|
||||
class TestGetResult:
|
||||
def test_get_result_completed(self, manager):
|
||||
task = manager.spawn("hello", {}, "system")
|
||||
manager.wait(task.id, timeout=5)
|
||||
result = manager.get_result(task.id)
|
||||
assert result == "Result for: hello"
|
||||
|
||||
def test_get_result_unknown_id(self, manager):
|
||||
result = manager.get_result("nonexistent_id")
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestExtractFinalText:
|
||||
def test_extracts_last_assistant(self):
|
||||
messages = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "first"},
|
||||
{"role": "user", "content": "more"},
|
||||
{"role": "assistant", "content": "second"},
|
||||
]
|
||||
assert _extract_final_text(messages) == "second"
|
||||
|
||||
def test_returns_none_for_empty(self):
|
||||
assert _extract_final_text([]) is None
|
||||
|
||||
def test_returns_none_no_assistant(self):
|
||||
messages = [{"role": "user", "content": "hi"}]
|
||||
assert _extract_final_text(messages) is None
|
||||
|
||||
|
||||
class TestWaitUnknown:
|
||||
def test_wait_unknown_returns_none(self, manager):
|
||||
assert manager.wait("nonexistent") is None
|
||||
160
nano-claude-code/tests/test_tool_registry.py
Normal file
160
nano-claude-code/tests/test_tool_registry.py
Normal file
@@ -0,0 +1,160 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tool_registry import (
|
||||
ToolDef,
|
||||
clear_registry,
|
||||
execute_tool,
|
||||
get_all_tools,
|
||||
get_tool,
|
||||
get_tool_schemas,
|
||||
register_tool,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_registry():
|
||||
"""Reset registry before each test."""
|
||||
clear_registry()
|
||||
yield
|
||||
clear_registry()
|
||||
|
||||
|
||||
def _make_echo_tool(name: str = "echo", read_only: bool = False) -> ToolDef:
|
||||
"""Helper to build a simple echo tool."""
|
||||
schema = {
|
||||
"name": name,
|
||||
"description": f"Echo tool ({name})",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {"type": "string", "description": "text to echo"},
|
||||
},
|
||||
"required": ["text"],
|
||||
},
|
||||
}
|
||||
|
||||
def func(params: dict, config: dict) -> str:
|
||||
return params["text"]
|
||||
|
||||
return ToolDef(
|
||||
name=name,
|
||||
schema=schema,
|
||||
func=func,
|
||||
read_only=read_only,
|
||||
concurrent_safe=True,
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# register and get
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_register_and_get():
|
||||
tool = _make_echo_tool()
|
||||
register_tool(tool)
|
||||
result = get_tool("echo")
|
||||
assert result is not None
|
||||
assert result.name == "echo"
|
||||
|
||||
|
||||
def test_get_unknown_returns_none():
|
||||
assert get_tool("no_such_tool") is None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# get_all_tools
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_get_all_tools_empty():
|
||||
assert get_all_tools() == []
|
||||
|
||||
|
||||
def test_get_all_tools():
|
||||
register_tool(_make_echo_tool("a"))
|
||||
register_tool(_make_echo_tool("b"))
|
||||
names = [t.name for t in get_all_tools()]
|
||||
assert sorted(names) == ["a", "b"]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# get_tool_schemas
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_get_tool_schemas():
|
||||
register_tool(_make_echo_tool("echo"))
|
||||
schemas = get_tool_schemas()
|
||||
assert len(schemas) == 1
|
||||
assert schemas[0]["name"] == "echo"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# execute_tool
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_execute_tool():
|
||||
register_tool(_make_echo_tool())
|
||||
result = execute_tool("echo", {"text": "hello"}, config={})
|
||||
assert result == "hello"
|
||||
|
||||
|
||||
def test_execute_unknown_tool():
|
||||
result = execute_tool("missing", {}, config={})
|
||||
assert "unknown" in result.lower() or "not found" in result.lower()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# output truncation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_output_truncation():
|
||||
def big_func(params: dict, config: dict) -> str:
|
||||
return "x" * 100
|
||||
|
||||
tool = ToolDef(
|
||||
name="big",
|
||||
schema={"name": "big", "description": "big", "input_schema": {"type": "object", "properties": {}}},
|
||||
func=big_func,
|
||||
read_only=True,
|
||||
concurrent_safe=True,
|
||||
)
|
||||
register_tool(tool)
|
||||
|
||||
result = execute_tool("big", {}, config={}, max_output=40)
|
||||
# first half = 20 chars, last quarter = 10 chars, marker in between
|
||||
assert len(result) < 100
|
||||
assert "truncated" in result
|
||||
# The kept portion: first 20 + last 10 should be present
|
||||
assert result.startswith("x" * 20)
|
||||
assert result.endswith("x" * 10)
|
||||
|
||||
|
||||
def test_no_truncation_when_within_limit():
|
||||
register_tool(_make_echo_tool())
|
||||
result = execute_tool("echo", {"text": "short"}, config={})
|
||||
assert result == "short"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# duplicate register overwrites
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_duplicate_register_overwrites():
|
||||
register_tool(_make_echo_tool("dup"))
|
||||
|
||||
def new_func(params: dict, config: dict) -> str:
|
||||
return "new"
|
||||
|
||||
replacement = ToolDef(
|
||||
name="dup",
|
||||
schema={"name": "dup", "description": "new", "input_schema": {"type": "object", "properties": {}}},
|
||||
func=new_func,
|
||||
read_only=False,
|
||||
concurrent_safe=False,
|
||||
)
|
||||
register_tool(replacement)
|
||||
|
||||
assert len(get_all_tools()) == 1
|
||||
result = execute_tool("dup", {}, config={})
|
||||
assert result == "new"
|
||||
98
nano-claude-code/tool_registry.py
Normal file
98
nano-claude-code/tool_registry.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Tool plugin registry for nano-claude-code.
|
||||
|
||||
Provides a central registry for tool definitions, lookup, schema export,
|
||||
and dispatch with output truncation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolDef:
|
||||
"""Definition of a single tool plugin.
|
||||
|
||||
Attributes:
|
||||
name: unique tool identifier
|
||||
schema: JSON-schema dict sent to the API (name, description, input_schema)
|
||||
func: callable(params: dict, config: dict) -> str
|
||||
read_only: True if the tool never mutates state
|
||||
concurrent_safe: True if safe to run in parallel with other tools
|
||||
"""
|
||||
name: str
|
||||
schema: Dict[str, Any]
|
||||
func: Callable[[Dict[str, Any], Dict[str, Any]], str]
|
||||
read_only: bool = False
|
||||
concurrent_safe: bool = False
|
||||
|
||||
|
||||
# --------------- internal state ---------------
|
||||
|
||||
_registry: Dict[str, ToolDef] = {}
|
||||
|
||||
|
||||
# --------------- public API ---------------
|
||||
|
||||
def register_tool(tool_def: ToolDef) -> None:
|
||||
"""Register a tool, overwriting any existing tool with the same name."""
|
||||
_registry[tool_def.name] = tool_def
|
||||
|
||||
|
||||
def get_tool(name: str) -> Optional[ToolDef]:
|
||||
"""Look up a tool by name. Returns None if not found."""
|
||||
return _registry.get(name)
|
||||
|
||||
|
||||
def get_all_tools() -> List[ToolDef]:
|
||||
"""Return all registered tools (insertion order)."""
|
||||
return list(_registry.values())
|
||||
|
||||
|
||||
def get_tool_schemas() -> List[Dict[str, Any]]:
|
||||
"""Return the schemas of all registered tools (for API tool parameter)."""
|
||||
return [t.schema for t in _registry.values()]
|
||||
|
||||
|
||||
def execute_tool(
|
||||
name: str,
|
||||
params: Dict[str, Any],
|
||||
config: Dict[str, Any],
|
||||
max_output: int = 32000,
|
||||
) -> str:
|
||||
"""Dispatch a tool call by name.
|
||||
|
||||
Args:
|
||||
name: tool name
|
||||
params: tool input parameters dict
|
||||
config: runtime configuration dict
|
||||
max_output: maximum allowed output length in characters
|
||||
|
||||
Returns:
|
||||
Tool result string, possibly truncated.
|
||||
"""
|
||||
tool = get_tool(name)
|
||||
if tool is None:
|
||||
return f"Error: tool '{name}' not found."
|
||||
|
||||
try:
|
||||
result = tool.func(params, config)
|
||||
except Exception as e:
|
||||
return f"Error executing {name}: {e}"
|
||||
|
||||
if len(result) > max_output:
|
||||
first_half = max_output // 2
|
||||
last_quarter = max_output // 4
|
||||
truncated = len(result) - first_half - last_quarter
|
||||
result = (
|
||||
result[:first_half]
|
||||
+ f"\n[... {truncated} chars truncated ...]\n"
|
||||
+ result[-last_quarter:]
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def clear_registry() -> None:
|
||||
"""Remove all registered tools. Intended for testing."""
|
||||
_registry.clear()
|
||||
@@ -2,10 +2,14 @@
|
||||
import os
|
||||
import re
|
||||
import glob as _glob
|
||||
import difflib
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from tool_registry import ToolDef, register_tool
|
||||
from tool_registry import execute_tool as _registry_execute
|
||||
|
||||
# ── Tool JSON schemas (sent to Claude API) ─────────────────────────────────
|
||||
|
||||
TOOL_SCHEMAS = [
|
||||
@@ -142,6 +146,24 @@ def _is_safe_bash(cmd: str) -> bool:
|
||||
return any(c.startswith(p) for p in _SAFE_PREFIXES)
|
||||
|
||||
|
||||
# ── Diff helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
def generate_unified_diff(old, new, filename, context_lines=3):
|
||||
old_lines = old.splitlines(keepends=True)
|
||||
new_lines = new.splitlines(keepends=True)
|
||||
diff = difflib.unified_diff(old_lines, new_lines,
|
||||
fromfile=f"a/{filename}", tofile=f"b/{filename}", n=context_lines)
|
||||
return "".join(diff)
|
||||
|
||||
def maybe_truncate_diff(diff_text, max_lines=80):
|
||||
lines = diff_text.splitlines()
|
||||
if len(lines) <= max_lines:
|
||||
return diff_text
|
||||
shown = lines[:max_lines]
|
||||
remaining = len(lines) - max_lines
|
||||
return "\n".join(shown) + f"\n\n[... {remaining} more lines ...]"
|
||||
|
||||
|
||||
# ── Tool implementations ───────────────────────────────────────────────────
|
||||
|
||||
def _read(file_path: str, limit: int = None, offset: int = None) -> str:
|
||||
@@ -164,10 +186,19 @@ def _read(file_path: str, limit: int = None, offset: int = None) -> str:
|
||||
def _write(file_path: str, content: str) -> str:
|
||||
p = Path(file_path)
|
||||
try:
|
||||
is_new = not p.exists()
|
||||
old_content = "" if is_new else p.read_text(errors="replace")
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(content)
|
||||
lc = content.count("\n") + (1 if content and not content.endswith("\n") else 0)
|
||||
return f"Wrote {lc} lines to {file_path}"
|
||||
if is_new:
|
||||
lc = content.count("\n") + (1 if content and not content.endswith("\n") else 0)
|
||||
return f"Created {file_path} ({lc} lines)"
|
||||
filename = p.name
|
||||
diff = generate_unified_diff(old_content, content, filename)
|
||||
if not diff:
|
||||
return f"No changes in {file_path}"
|
||||
truncated = maybe_truncate_diff(diff)
|
||||
return f"File updated — {file_path}:\n\n{truncated}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@@ -184,10 +215,13 @@ def _edit(file_path: str, old_string: str, new_string: str, replace_all: bool =
|
||||
if count > 1 and not replace_all:
|
||||
return (f"Error: old_string appears {count} times. "
|
||||
"Provide more context to make it unique, or use replace_all=true.")
|
||||
old_content = content
|
||||
new_content = content.replace(old_string, new_string) if replace_all else \
|
||||
content.replace(old_string, new_string, 1)
|
||||
p.write_text(new_content)
|
||||
return f"Replaced {'all ' + str(count) if replace_all else '1'} occurrence(s) in {file_path}"
|
||||
filename = p.name
|
||||
diff = generate_unified_diff(old_content, new_content, filename)
|
||||
return f"Changes applied to {filename}:\n\n{diff}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@@ -299,15 +333,22 @@ def _websearch(query: str) -> str:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
# ── Dispatcher ─────────────────────────────────────────────────────────────
|
||||
# ── Dispatcher (backward-compatible wrapper) ──────────────────────────────
|
||||
|
||||
def execute_tool(
|
||||
name: str,
|
||||
inputs: dict,
|
||||
permission_mode: str = "auto",
|
||||
ask_permission: Optional[Callable[[str], bool]] = None,
|
||||
config: dict = None,
|
||||
) -> str:
|
||||
"""Dispatch tool execution; ask permission for write/destructive ops."""
|
||||
"""Dispatch tool execution; ask permission for write/destructive ops.
|
||||
|
||||
Permission checking is done here, then delegation goes to the registry.
|
||||
The config dict is forwarded to tool functions so they can access
|
||||
runtime context like _depth, _system_prompt, model, etc.
|
||||
"""
|
||||
cfg = config or {}
|
||||
|
||||
def _check(desc: str) -> bool:
|
||||
"""Return True if action is allowed."""
|
||||
@@ -317,43 +358,110 @@ def execute_tool(
|
||||
return ask_permission(desc)
|
||||
return True # headless: allow everything
|
||||
|
||||
if name == "Read":
|
||||
return _read(inputs["file_path"], inputs.get("limit"), inputs.get("offset"))
|
||||
|
||||
elif name == "Write":
|
||||
# --- permission gate ---
|
||||
if name == "Write":
|
||||
if not _check(f"Write to {inputs['file_path']}"):
|
||||
return "Denied: user rejected write operation"
|
||||
return _write(inputs["file_path"], inputs["content"])
|
||||
|
||||
elif name == "Edit":
|
||||
if not _check(f"Edit {inputs['file_path']}"):
|
||||
return "Denied: user rejected edit operation"
|
||||
return _edit(inputs["file_path"], inputs["old_string"],
|
||||
inputs["new_string"], inputs.get("replace_all", False))
|
||||
|
||||
elif name == "Bash":
|
||||
cmd = inputs["command"]
|
||||
if permission_mode != "accept-all" and not _is_safe_bash(cmd):
|
||||
if not _check(f"Bash: {cmd}"):
|
||||
return "Denied: user rejected bash command"
|
||||
return _bash(cmd, inputs.get("timeout", 30))
|
||||
|
||||
elif name == "Glob":
|
||||
return _glob(inputs["pattern"], inputs.get("path"))
|
||||
return _registry_execute(name, inputs, cfg)
|
||||
|
||||
elif name == "Grep":
|
||||
return _grep(
|
||||
inputs["pattern"], inputs.get("path"), inputs.get("glob"),
|
||||
inputs.get("output_mode", "files_with_matches"),
|
||||
inputs.get("case_insensitive", False),
|
||||
inputs.get("context", 0),
|
||||
)
|
||||
|
||||
elif name == "WebFetch":
|
||||
return _webfetch(inputs["url"], inputs.get("prompt"))
|
||||
# ── Register built-in tools with the plugin registry ─────────────────────
|
||||
|
||||
elif name == "WebSearch":
|
||||
return _websearch(inputs["query"])
|
||||
def _register_builtins() -> None:
|
||||
"""Register all 8 built-in tools into the central registry."""
|
||||
_tool_defs = [
|
||||
ToolDef(
|
||||
name="Read",
|
||||
schema=TOOL_SCHEMAS[0],
|
||||
func=lambda p, c: _read(**p),
|
||||
read_only=True,
|
||||
concurrent_safe=True,
|
||||
),
|
||||
ToolDef(
|
||||
name="Write",
|
||||
schema=TOOL_SCHEMAS[1],
|
||||
func=lambda p, c: _write(**p),
|
||||
read_only=False,
|
||||
concurrent_safe=False,
|
||||
),
|
||||
ToolDef(
|
||||
name="Edit",
|
||||
schema=TOOL_SCHEMAS[2],
|
||||
func=lambda p, c: _edit(**p),
|
||||
read_only=False,
|
||||
concurrent_safe=False,
|
||||
),
|
||||
ToolDef(
|
||||
name="Bash",
|
||||
schema=TOOL_SCHEMAS[3],
|
||||
func=lambda p, c: _bash(p["command"], p.get("timeout", 30)),
|
||||
read_only=False,
|
||||
concurrent_safe=False,
|
||||
),
|
||||
ToolDef(
|
||||
name="Glob",
|
||||
schema=TOOL_SCHEMAS[4],
|
||||
func=lambda p, c: _glob(p["pattern"], p.get("path")),
|
||||
read_only=True,
|
||||
concurrent_safe=True,
|
||||
),
|
||||
ToolDef(
|
||||
name="Grep",
|
||||
schema=TOOL_SCHEMAS[5],
|
||||
func=lambda p, c: _grep(
|
||||
p["pattern"], p.get("path"), p.get("glob"),
|
||||
p.get("output_mode", "files_with_matches"),
|
||||
p.get("case_insensitive", False),
|
||||
p.get("context", 0),
|
||||
),
|
||||
read_only=True,
|
||||
concurrent_safe=True,
|
||||
),
|
||||
ToolDef(
|
||||
name="WebFetch",
|
||||
schema=TOOL_SCHEMAS[6],
|
||||
func=lambda p, c: _webfetch(p["url"], p.get("prompt")),
|
||||
read_only=True,
|
||||
concurrent_safe=True,
|
||||
),
|
||||
ToolDef(
|
||||
name="WebSearch",
|
||||
schema=TOOL_SCHEMAS[7],
|
||||
func=lambda p, c: _websearch(p["query"]),
|
||||
read_only=True,
|
||||
concurrent_safe=True,
|
||||
),
|
||||
]
|
||||
for td in _tool_defs:
|
||||
register_tool(td)
|
||||
|
||||
else:
|
||||
return f"Unknown tool: {name}"
|
||||
|
||||
_register_builtins()
|
||||
|
||||
|
||||
# ── Memory tools (MemorySave, MemoryDelete, MemorySearch, MemoryList) ────────
|
||||
# Defined in memory/tools.py; importing registers them automatically.
|
||||
import memory.tools as _memory_tools # noqa: F401
|
||||
|
||||
|
||||
|
||||
# ── Multi-agent tools (Agent, SendMessage, CheckAgentResult, ListAgentTasks, ListAgentTypes) ──
|
||||
# Defined in multi_agent/tools.py; importing registers them automatically.
|
||||
import multi_agent.tools as _multiagent_tools # noqa: F401
|
||||
|
||||
# Expose get_agent_manager at module level for backward compatibility
|
||||
from multi_agent.tools import get_agent_manager as _get_agent_manager # noqa: F401
|
||||
|
||||
|
||||
# ── Skill tools (Skill, SkillList) ────────────────────────────────────────
|
||||
# Defined in skill/tools.py; importing registers them automatically.
|
||||
import skill.tools as _skill_tools # noqa: F401
|
||||
|
||||
1295
original-source-code/src/QueryEngine.ts
Normal file
1295
original-source-code/src/QueryEngine.ts
Normal file
File diff suppressed because it is too large
Load Diff
125
original-source-code/src/Task.ts
Normal file
125
original-source-code/src/Task.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { randomBytes } from 'crypto'
|
||||
import type { AppState } from './state/AppState.js'
|
||||
import type { AgentId } from './types/ids.js'
|
||||
import { getTaskOutputPath } from './utils/task/diskOutput.js'
|
||||
|
||||
export type TaskType =
|
||||
| 'local_bash'
|
||||
| 'local_agent'
|
||||
| 'remote_agent'
|
||||
| 'in_process_teammate'
|
||||
| 'local_workflow'
|
||||
| 'monitor_mcp'
|
||||
| 'dream'
|
||||
|
||||
export type TaskStatus =
|
||||
| 'pending'
|
||||
| 'running'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'killed'
|
||||
|
||||
/**
|
||||
* True when a task is in a terminal state and will not transition further.
|
||||
* Used to guard against injecting messages into dead teammates, evicting
|
||||
* finished tasks from AppState, and orphan-cleanup paths.
|
||||
*/
|
||||
export function isTerminalTaskStatus(status: TaskStatus): boolean {
|
||||
return status === 'completed' || status === 'failed' || status === 'killed'
|
||||
}
|
||||
|
||||
export type TaskHandle = {
|
||||
taskId: string
|
||||
cleanup?: () => void
|
||||
}
|
||||
|
||||
export type SetAppState = (f: (prev: AppState) => AppState) => void
|
||||
|
||||
export type TaskContext = {
|
||||
abortController: AbortController
|
||||
getAppState: () => AppState
|
||||
setAppState: SetAppState
|
||||
}
|
||||
|
||||
// Base fields shared by all task states
|
||||
export type TaskStateBase = {
|
||||
id: string
|
||||
type: TaskType
|
||||
status: TaskStatus
|
||||
description: string
|
||||
toolUseId?: string
|
||||
startTime: number
|
||||
endTime?: number
|
||||
totalPausedMs?: number
|
||||
outputFile: string
|
||||
outputOffset: number
|
||||
notified: boolean
|
||||
}
|
||||
|
||||
export type LocalShellSpawnInput = {
|
||||
command: string
|
||||
description: string
|
||||
timeout?: number
|
||||
toolUseId?: string
|
||||
agentId?: AgentId
|
||||
/** UI display variant: description-as-label, dialog title, status bar pill. */
|
||||
kind?: 'bash' | 'monitor'
|
||||
}
|
||||
|
||||
// What getTaskByType dispatches for: kill. spawn/render were never
|
||||
// called polymorphically (removed in #22546). All six kill implementations
|
||||
// use only setAppState — getAppState/abortController were dead weight.
|
||||
export type Task = {
|
||||
name: string
|
||||
type: TaskType
|
||||
kill(taskId: string, setAppState: SetAppState): Promise<void>
|
||||
}
|
||||
|
||||
// Task ID prefixes
|
||||
const TASK_ID_PREFIXES: Record<string, string> = {
|
||||
local_bash: 'b', // Keep as 'b' for backward compatibility
|
||||
local_agent: 'a',
|
||||
remote_agent: 'r',
|
||||
in_process_teammate: 't',
|
||||
local_workflow: 'w',
|
||||
monitor_mcp: 'm',
|
||||
dream: 'd',
|
||||
}
|
||||
|
||||
// Get task ID prefix
|
||||
function getTaskIdPrefix(type: TaskType): string {
|
||||
return TASK_ID_PREFIXES[type] ?? 'x'
|
||||
}
|
||||
|
||||
// Case-insensitive-safe alphabet (digits + lowercase) for task IDs.
|
||||
// 36^8 ≈ 2.8 trillion combinations, sufficient to resist brute-force symlink attacks.
|
||||
const TASK_ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'
|
||||
|
||||
export function generateTaskId(type: TaskType): string {
|
||||
const prefix = getTaskIdPrefix(type)
|
||||
const bytes = randomBytes(8)
|
||||
let id = prefix
|
||||
for (let i = 0; i < 8; i++) {
|
||||
id += TASK_ID_ALPHABET[bytes[i]! % TASK_ID_ALPHABET.length]
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
export function createTaskStateBase(
|
||||
id: string,
|
||||
type: TaskType,
|
||||
description: string,
|
||||
toolUseId?: string,
|
||||
): TaskStateBase {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
status: 'pending',
|
||||
description,
|
||||
toolUseId,
|
||||
startTime: Date.now(),
|
||||
outputFile: getTaskOutputPath(id),
|
||||
outputOffset: 0,
|
||||
notified: false,
|
||||
}
|
||||
}
|
||||
792
original-source-code/src/Tool.ts
Normal file
792
original-source-code/src/Tool.ts
Normal file
@@ -0,0 +1,792 @@
|
||||
import type {
|
||||
ToolResultBlockParam,
|
||||
ToolUseBlockParam,
|
||||
} from '@anthropic-ai/sdk/resources/index.mjs'
|
||||
import type {
|
||||
ElicitRequestURLParams,
|
||||
ElicitResult,
|
||||
} from '@modelcontextprotocol/sdk/types.js'
|
||||
import type { UUID } from 'crypto'
|
||||
import type { z } from 'zod/v4'
|
||||
import type { Command } from './commands.js'
|
||||
import type { CanUseToolFn } from './hooks/useCanUseTool.js'
|
||||
import type { ThinkingConfig } from './utils/thinking.js'
|
||||
|
||||
export type ToolInputJSONSchema = {
|
||||
[x: string]: unknown
|
||||
type: 'object'
|
||||
properties?: {
|
||||
[x: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
import type { Notification } from './context/notifications.js'
|
||||
import type {
|
||||
MCPServerConnection,
|
||||
ServerResource,
|
||||
} from './services/mcp/types.js'
|
||||
import type {
|
||||
AgentDefinition,
|
||||
AgentDefinitionsResult,
|
||||
} from './tools/AgentTool/loadAgentsDir.js'
|
||||
import type {
|
||||
AssistantMessage,
|
||||
AttachmentMessage,
|
||||
Message,
|
||||
ProgressMessage,
|
||||
SystemLocalCommandMessage,
|
||||
SystemMessage,
|
||||
UserMessage,
|
||||
} from './types/message.js'
|
||||
// Import permission types from centralized location to break import cycles
|
||||
// Import PermissionResult from centralized location to break import cycles
|
||||
import type {
|
||||
AdditionalWorkingDirectory,
|
||||
PermissionMode,
|
||||
PermissionResult,
|
||||
} from './types/permissions.js'
|
||||
// Import tool progress types from centralized location to break import cycles
|
||||
import type {
|
||||
AgentToolProgress,
|
||||
BashProgress,
|
||||
MCPProgress,
|
||||
REPLToolProgress,
|
||||
SkillToolProgress,
|
||||
TaskOutputProgress,
|
||||
ToolProgressData,
|
||||
WebSearchProgress,
|
||||
} from './types/tools.js'
|
||||
import type { FileStateCache } from './utils/fileStateCache.js'
|
||||
import type { DenialTrackingState } from './utils/permissions/denialTracking.js'
|
||||
import type { SystemPrompt } from './utils/systemPromptType.js'
|
||||
import type { ContentReplacementState } from './utils/toolResultStorage.js'
|
||||
|
||||
// Re-export progress types for backwards compatibility
|
||||
export type {
|
||||
AgentToolProgress,
|
||||
BashProgress,
|
||||
MCPProgress,
|
||||
REPLToolProgress,
|
||||
SkillToolProgress,
|
||||
TaskOutputProgress,
|
||||
WebSearchProgress,
|
||||
}
|
||||
|
||||
import type { SpinnerMode } from './components/Spinner.js'
|
||||
import type { QuerySource } from './constants/querySource.js'
|
||||
import type { SDKStatus } from './entrypoints/agentSdkTypes.js'
|
||||
import type { AppState } from './state/AppState.js'
|
||||
import type {
|
||||
HookProgress,
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
} from './types/hooks.js'
|
||||
import type { AgentId } from './types/ids.js'
|
||||
import type { DeepImmutable } from './types/utils.js'
|
||||
import type { AttributionState } from './utils/commitAttribution.js'
|
||||
import type { FileHistoryState } from './utils/fileHistory.js'
|
||||
import type { Theme, ThemeName } from './utils/theme.js'
|
||||
|
||||
export type QueryChainTracking = {
|
||||
chainId: string
|
||||
depth: number
|
||||
}
|
||||
|
||||
export type ValidationResult =
|
||||
| { result: true }
|
||||
| {
|
||||
result: false
|
||||
message: string
|
||||
errorCode: number
|
||||
}
|
||||
|
||||
export type SetToolJSXFn = (
|
||||
args: {
|
||||
jsx: React.ReactNode | null
|
||||
shouldHidePromptInput: boolean
|
||||
shouldContinueAnimation?: true
|
||||
showSpinner?: boolean
|
||||
isLocalJSXCommand?: boolean
|
||||
isImmediate?: boolean
|
||||
/** Set to true to clear a local JSX command (e.g., from its onDone callback) */
|
||||
clearLocalJSX?: boolean
|
||||
} | null,
|
||||
) => void
|
||||
|
||||
// Import tool permission types from centralized location to break import cycles
|
||||
import type { ToolPermissionRulesBySource } from './types/permissions.js'
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
export type { ToolPermissionRulesBySource }
|
||||
|
||||
// Apply DeepImmutable to the imported type
|
||||
export type ToolPermissionContext = DeepImmutable<{
|
||||
mode: PermissionMode
|
||||
additionalWorkingDirectories: Map<string, AdditionalWorkingDirectory>
|
||||
alwaysAllowRules: ToolPermissionRulesBySource
|
||||
alwaysDenyRules: ToolPermissionRulesBySource
|
||||
alwaysAskRules: ToolPermissionRulesBySource
|
||||
isBypassPermissionsModeAvailable: boolean
|
||||
isAutoModeAvailable?: boolean
|
||||
strippedDangerousRules?: ToolPermissionRulesBySource
|
||||
/** When true, permission prompts are auto-denied (e.g., background agents that can't show UI) */
|
||||
shouldAvoidPermissionPrompts?: boolean
|
||||
/** When true, automated checks (classifier, hooks) are awaited before showing the permission dialog (coordinator workers) */
|
||||
awaitAutomatedChecksBeforeDialog?: boolean
|
||||
/** Stores the permission mode before model-initiated plan mode entry, so it can be restored on exit */
|
||||
prePlanMode?: PermissionMode
|
||||
}>
|
||||
|
||||
export const getEmptyToolPermissionContext: () => ToolPermissionContext =
|
||||
() => ({
|
||||
mode: 'default',
|
||||
additionalWorkingDirectories: new Map(),
|
||||
alwaysAllowRules: {},
|
||||
alwaysDenyRules: {},
|
||||
alwaysAskRules: {},
|
||||
isBypassPermissionsModeAvailable: false,
|
||||
})
|
||||
|
||||
export type CompactProgressEvent =
|
||||
| {
|
||||
type: 'hooks_start'
|
||||
hookType: 'pre_compact' | 'post_compact' | 'session_start'
|
||||
}
|
||||
| { type: 'compact_start' }
|
||||
| { type: 'compact_end' }
|
||||
|
||||
export type ToolUseContext = {
|
||||
options: {
|
||||
commands: Command[]
|
||||
debug: boolean
|
||||
mainLoopModel: string
|
||||
tools: Tools
|
||||
verbose: boolean
|
||||
thinkingConfig: ThinkingConfig
|
||||
mcpClients: MCPServerConnection[]
|
||||
mcpResources: Record<string, ServerResource[]>
|
||||
isNonInteractiveSession: boolean
|
||||
agentDefinitions: AgentDefinitionsResult
|
||||
maxBudgetUsd?: number
|
||||
/** Custom system prompt that replaces the default system prompt */
|
||||
customSystemPrompt?: string
|
||||
/** Additional system prompt appended after the main system prompt */
|
||||
appendSystemPrompt?: string
|
||||
/** Override querySource for analytics tracking */
|
||||
querySource?: QuerySource
|
||||
/** Optional callback to get the latest tools (e.g., after MCP servers connect mid-query) */
|
||||
refreshTools?: () => Tools
|
||||
}
|
||||
abortController: AbortController
|
||||
readFileState: FileStateCache
|
||||
getAppState(): AppState
|
||||
setAppState(f: (prev: AppState) => AppState): void
|
||||
/**
|
||||
* Always-shared setAppState for session-scoped infrastructure (background
|
||||
* tasks, session hooks). Unlike setAppState, which is no-op for async agents
|
||||
* (see createSubagentContext), this always reaches the root store so agents
|
||||
* at any nesting depth can register/clean up infrastructure that outlives
|
||||
* a single turn. Only set by createSubagentContext; main-thread contexts
|
||||
* fall back to setAppState.
|
||||
*/
|
||||
setAppStateForTasks?: (f: (prev: AppState) => AppState) => void
|
||||
/**
|
||||
* Optional handler for URL elicitations triggered by tool call errors (-32042).
|
||||
* In print/SDK mode, this delegates to structuredIO.handleElicitation.
|
||||
* In REPL mode, this is undefined and the queue-based UI path is used.
|
||||
*/
|
||||
handleElicitation?: (
|
||||
serverName: string,
|
||||
params: ElicitRequestURLParams,
|
||||
signal: AbortSignal,
|
||||
) => Promise<ElicitResult>
|
||||
setToolJSX?: SetToolJSXFn
|
||||
addNotification?: (notif: Notification) => void
|
||||
/** Append a UI-only system message to the REPL message list. Stripped at the
|
||||
* normalizeMessagesForAPI boundary — the Exclude<> makes that type-enforced. */
|
||||
appendSystemMessage?: (
|
||||
msg: Exclude<SystemMessage, SystemLocalCommandMessage>,
|
||||
) => void
|
||||
/** Send an OS-level notification (iTerm2, Kitty, Ghostty, bell, etc.) */
|
||||
sendOSNotification?: (opts: {
|
||||
message: string
|
||||
notificationType: string
|
||||
}) => void
|
||||
nestedMemoryAttachmentTriggers?: Set<string>
|
||||
/**
|
||||
* CLAUDE.md paths already injected as nested_memory attachments this
|
||||
* session. Dedup for memoryFilesToAttachments — readFileState is an LRU
|
||||
* that evicts entries in busy sessions, so its .has() check alone can
|
||||
* re-inject the same CLAUDE.md dozens of times.
|
||||
*/
|
||||
loadedNestedMemoryPaths?: Set<string>
|
||||
dynamicSkillDirTriggers?: Set<string>
|
||||
/** Skill names surfaced via skill_discovery this session. Telemetry only (feeds was_discovered). */
|
||||
discoveredSkillNames?: Set<string>
|
||||
userModified?: boolean
|
||||
setInProgressToolUseIDs: (f: (prev: Set<string>) => Set<string>) => void
|
||||
/** Only wired in interactive (REPL) contexts; SDK/QueryEngine don't set this. */
|
||||
setHasInterruptibleToolInProgress?: (v: boolean) => void
|
||||
setResponseLength: (f: (prev: number) => number) => void
|
||||
/** Ant-only: push a new API metrics entry for OTPS tracking.
|
||||
* Called by subagent streaming when a new API request starts. */
|
||||
pushApiMetricsEntry?: (ttftMs: number) => void
|
||||
setStreamMode?: (mode: SpinnerMode) => void
|
||||
onCompactProgress?: (event: CompactProgressEvent) => void
|
||||
setSDKStatus?: (status: SDKStatus) => void
|
||||
openMessageSelector?: () => void
|
||||
updateFileHistoryState: (
|
||||
updater: (prev: FileHistoryState) => FileHistoryState,
|
||||
) => void
|
||||
updateAttributionState: (
|
||||
updater: (prev: AttributionState) => AttributionState,
|
||||
) => void
|
||||
setConversationId?: (id: UUID) => void
|
||||
agentId?: AgentId // Only set for subagents; use getSessionId() for session ID. Hooks use this to distinguish subagent calls.
|
||||
agentType?: string // Subagent type name. For the main thread's --agent type, hooks fall back to getMainThreadAgentType().
|
||||
/** When true, canUseTool must always be called even when hooks auto-approve.
|
||||
* Used by speculation for overlay file path rewriting. */
|
||||
requireCanUseTool?: boolean
|
||||
messages: Message[]
|
||||
fileReadingLimits?: {
|
||||
maxTokens?: number
|
||||
maxSizeBytes?: number
|
||||
}
|
||||
globLimits?: {
|
||||
maxResults?: number
|
||||
}
|
||||
toolDecisions?: Map<
|
||||
string,
|
||||
{
|
||||
source: string
|
||||
decision: 'accept' | 'reject'
|
||||
timestamp: number
|
||||
}
|
||||
>
|
||||
queryTracking?: QueryChainTracking
|
||||
/** Callback factory for requesting interactive prompts from the user.
|
||||
* Returns a prompt callback bound to the given source name.
|
||||
* Only available in interactive (REPL) contexts. */
|
||||
requestPrompt?: (
|
||||
sourceName: string,
|
||||
toolInputSummary?: string | null,
|
||||
) => (request: PromptRequest) => Promise<PromptResponse>
|
||||
toolUseId?: string
|
||||
criticalSystemReminder_EXPERIMENTAL?: string
|
||||
/** When true, preserve toolUseResult on messages even for subagents.
|
||||
* Used by in-process teammates whose transcripts are viewable by the user. */
|
||||
preserveToolUseResults?: boolean
|
||||
/** Local denial tracking state for async subagents whose setAppState is a
|
||||
* no-op. Without this, the denial counter never accumulates and the
|
||||
* fallback-to-prompting threshold is never reached. Mutable — the
|
||||
* permissions code updates it in place. */
|
||||
localDenialTracking?: DenialTrackingState
|
||||
/**
|
||||
* Per-conversation-thread content replacement state for the tool result
|
||||
* budget. When present, query.ts applies the aggregate tool result budget.
|
||||
* Main thread: REPL provisions once (never resets — stale UUID keys
|
||||
* are inert). Subagents: createSubagentContext clones the parent's state
|
||||
* by default (cache-sharing forks need identical decisions), or
|
||||
* resumeAgentBackground threads one reconstructed from sidechain records.
|
||||
*/
|
||||
contentReplacementState?: ContentReplacementState
|
||||
/**
|
||||
* Parent's rendered system prompt bytes, frozen at turn start.
|
||||
* Used by fork subagents to share the parent's prompt cache — re-calling
|
||||
* getSystemPrompt() at fork-spawn time can diverge (GrowthBook cold→warm)
|
||||
* and bust the cache. See forkSubagent.ts.
|
||||
*/
|
||||
renderedSystemPrompt?: SystemPrompt
|
||||
}
|
||||
|
||||
// Re-export ToolProgressData from centralized location
|
||||
export type { ToolProgressData }
|
||||
|
||||
export type Progress = ToolProgressData | HookProgress
|
||||
|
||||
export type ToolProgress<P extends ToolProgressData> = {
|
||||
toolUseID: string
|
||||
data: P
|
||||
}
|
||||
|
||||
export function filterToolProgressMessages(
|
||||
progressMessagesForMessage: ProgressMessage[],
|
||||
): ProgressMessage<ToolProgressData>[] {
|
||||
return progressMessagesForMessage.filter(
|
||||
(msg): msg is ProgressMessage<ToolProgressData> =>
|
||||
msg.data?.type !== 'hook_progress',
|
||||
)
|
||||
}
|
||||
|
||||
export type ToolResult<T> = {
|
||||
data: T
|
||||
newMessages?: (
|
||||
| UserMessage
|
||||
| AssistantMessage
|
||||
| AttachmentMessage
|
||||
| SystemMessage
|
||||
)[]
|
||||
// contextModifier is only honored for tools that aren't concurrency safe.
|
||||
contextModifier?: (context: ToolUseContext) => ToolUseContext
|
||||
/** MCP protocol metadata (structuredContent, _meta) to pass through to SDK consumers */
|
||||
mcpMeta?: {
|
||||
_meta?: Record<string, unknown>
|
||||
structuredContent?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export type ToolCallProgress<P extends ToolProgressData = ToolProgressData> = (
|
||||
progress: ToolProgress<P>,
|
||||
) => void
|
||||
|
||||
// Type for any schema that outputs an object with string keys
|
||||
export type AnyObject = z.ZodType<{ [key: string]: unknown }>
|
||||
|
||||
/**
|
||||
* Checks if a tool matches the given name (primary name or alias).
|
||||
*/
|
||||
export function toolMatchesName(
|
||||
tool: { name: string; aliases?: string[] },
|
||||
name: string,
|
||||
): boolean {
|
||||
return tool.name === name || (tool.aliases?.includes(name) ?? false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a tool by name or alias from a list of tools.
|
||||
*/
|
||||
export function findToolByName(tools: Tools, name: string): Tool | undefined {
|
||||
return tools.find(t => toolMatchesName(t, name))
|
||||
}
|
||||
|
||||
export type Tool<
|
||||
Input extends AnyObject = AnyObject,
|
||||
Output = unknown,
|
||||
P extends ToolProgressData = ToolProgressData,
|
||||
> = {
|
||||
/**
|
||||
* Optional aliases for backwards compatibility when a tool is renamed.
|
||||
* The tool can be looked up by any of these names in addition to its primary name.
|
||||
*/
|
||||
aliases?: string[]
|
||||
/**
|
||||
* One-line capability phrase used by ToolSearch for keyword matching.
|
||||
* Helps the model find this tool via keyword search when it's deferred.
|
||||
* 3–10 words, no trailing period.
|
||||
* Prefer terms not already in the tool name (e.g. 'jupyter' for NotebookEdit).
|
||||
*/
|
||||
searchHint?: string
|
||||
call(
|
||||
args: z.infer<Input>,
|
||||
context: ToolUseContext,
|
||||
canUseTool: CanUseToolFn,
|
||||
parentMessage: AssistantMessage,
|
||||
onProgress?: ToolCallProgress<P>,
|
||||
): Promise<ToolResult<Output>>
|
||||
description(
|
||||
input: z.infer<Input>,
|
||||
options: {
|
||||
isNonInteractiveSession: boolean
|
||||
toolPermissionContext: ToolPermissionContext
|
||||
tools: Tools
|
||||
},
|
||||
): Promise<string>
|
||||
readonly inputSchema: Input
|
||||
// Type for MCP tools that can specify their input schema directly in JSON Schema format
|
||||
// rather than converting from Zod schema
|
||||
readonly inputJSONSchema?: ToolInputJSONSchema
|
||||
// Optional because TungstenTool doesn't define this. TODO: Make it required.
|
||||
// When we do that, we can also go through and make this a bit more type-safe.
|
||||
outputSchema?: z.ZodType<unknown>
|
||||
inputsEquivalent?(a: z.infer<Input>, b: z.infer<Input>): boolean
|
||||
isConcurrencySafe(input: z.infer<Input>): boolean
|
||||
isEnabled(): boolean
|
||||
isReadOnly(input: z.infer<Input>): boolean
|
||||
/** Defaults to false. Only set when the tool performs irreversible operations (delete, overwrite, send). */
|
||||
isDestructive?(input: z.infer<Input>): boolean
|
||||
/**
|
||||
* What should happen when the user submits a new message while this tool
|
||||
* is running.
|
||||
*
|
||||
* - `'cancel'` — stop the tool and discard its result
|
||||
* - `'block'` — keep running; the new message waits
|
||||
*
|
||||
* Defaults to `'block'` when not implemented.
|
||||
*/
|
||||
interruptBehavior?(): 'cancel' | 'block'
|
||||
/**
|
||||
* Returns information about whether this tool use is a search or read operation
|
||||
* that should be collapsed into a condensed display in the UI. Examples include
|
||||
* file searching (Grep, Glob), file reading (Read), and bash commands like find,
|
||||
* grep, wc, etc.
|
||||
*
|
||||
* Returns an object indicating whether the operation is a search or read operation:
|
||||
* - `isSearch: true` for search operations (grep, find, glob patterns)
|
||||
* - `isRead: true` for read operations (cat, head, tail, file read)
|
||||
* - `isList: true` for directory-listing operations (ls, tree, du)
|
||||
* - All can be false if the operation shouldn't be collapsed
|
||||
*/
|
||||
isSearchOrReadCommand?(input: z.infer<Input>): {
|
||||
isSearch: boolean
|
||||
isRead: boolean
|
||||
isList?: boolean
|
||||
}
|
||||
isOpenWorld?(input: z.infer<Input>): boolean
|
||||
requiresUserInteraction?(): boolean
|
||||
isMcp?: boolean
|
||||
isLsp?: boolean
|
||||
/**
|
||||
* When true, this tool is deferred (sent with defer_loading: true) and requires
|
||||
* ToolSearch to be used before it can be called.
|
||||
*/
|
||||
readonly shouldDefer?: boolean
|
||||
/**
|
||||
* When true, this tool is never deferred — its full schema appears in the
|
||||
* initial prompt even when ToolSearch is enabled. For MCP tools, set via
|
||||
* `_meta['anthropic/alwaysLoad']`. Use for tools the model must see on
|
||||
* turn 1 without a ToolSearch round-trip.
|
||||
*/
|
||||
readonly alwaysLoad?: boolean
|
||||
/**
|
||||
* For MCP tools: the server and tool names as received from the MCP server (unnormalized).
|
||||
* Present on all MCP tools regardless of whether `name` is prefixed (mcp__server__tool)
|
||||
* or unprefixed (CLAUDE_AGENT_SDK_MCP_NO_PREFIX mode).
|
||||
*/
|
||||
mcpInfo?: { serverName: string; toolName: string }
|
||||
readonly name: string
|
||||
/**
|
||||
* Maximum size in characters for tool result before it gets persisted to disk.
|
||||
* When exceeded, the result is saved to a file and Claude receives a preview
|
||||
* with the file path instead of the full content.
|
||||
*
|
||||
* Set to Infinity for tools whose output must never be persisted (e.g. Read,
|
||||
* where persisting creates a circular Read→file→Read loop and the tool
|
||||
* already self-bounds via its own limits).
|
||||
*/
|
||||
maxResultSizeChars: number
|
||||
/**
|
||||
* When true, enables strict mode for this tool, which causes the API to
|
||||
* more strictly adhere to tool instructions and parameter schemas.
|
||||
* Only applied when the tengu_tool_pear is enabled.
|
||||
*/
|
||||
readonly strict?: boolean
|
||||
|
||||
/**
|
||||
* Called on copies of tool_use input before observers see it (SDK stream,
|
||||
* transcript, canUseTool, PreToolUse/PostToolUse hooks). Mutate in place
|
||||
* to add legacy/derived fields. Must be idempotent. The original API-bound
|
||||
* input is never mutated (preserves prompt cache). Not re-applied when a
|
||||
* hook/permission returns a fresh updatedInput — those own their shape.
|
||||
*/
|
||||
backfillObservableInput?(input: Record<string, unknown>): void
|
||||
|
||||
/**
|
||||
* Determines if this tool is allowed to run with this input in the current context.
|
||||
* It informs the model of why the tool use failed, and does not directly display any UI.
|
||||
* @param input
|
||||
* @param context
|
||||
*/
|
||||
validateInput?(
|
||||
input: z.infer<Input>,
|
||||
context: ToolUseContext,
|
||||
): Promise<ValidationResult>
|
||||
|
||||
/**
|
||||
* Determines if the user is asked for permission. Only called after validateInput() passes.
|
||||
* General permission logic is in permissions.ts. This method contains tool-specific logic.
|
||||
* @param input
|
||||
* @param context
|
||||
*/
|
||||
checkPermissions(
|
||||
input: z.infer<Input>,
|
||||
context: ToolUseContext,
|
||||
): Promise<PermissionResult>
|
||||
|
||||
// Optional method for tools that operate on a file path
|
||||
getPath?(input: z.infer<Input>): string
|
||||
|
||||
/**
|
||||
* Prepare a matcher for hook `if` conditions (permission-rule patterns like
|
||||
* "git *" from "Bash(git *)"). Called once per hook-input pair; any
|
||||
* expensive parsing happens here. Returns a closure that is called per
|
||||
* hook pattern. If not implemented, only tool-name-level matching works.
|
||||
*/
|
||||
preparePermissionMatcher?(
|
||||
input: z.infer<Input>,
|
||||
): Promise<(pattern: string) => boolean>
|
||||
|
||||
prompt(options: {
|
||||
getToolPermissionContext: () => Promise<ToolPermissionContext>
|
||||
tools: Tools
|
||||
agents: AgentDefinition[]
|
||||
allowedAgentTypes?: string[]
|
||||
}): Promise<string>
|
||||
userFacingName(input: Partial<z.infer<Input>> | undefined): string
|
||||
userFacingNameBackgroundColor?(
|
||||
input: Partial<z.infer<Input>> | undefined,
|
||||
): keyof Theme | undefined
|
||||
/**
|
||||
* Transparent wrappers (e.g. REPL) delegate all rendering to their progress
|
||||
* handler, which emits native-looking blocks for each inner tool call.
|
||||
* The wrapper itself shows nothing.
|
||||
*/
|
||||
isTransparentWrapper?(): boolean
|
||||
/**
|
||||
* Returns a short string summary of this tool use for display in compact views.
|
||||
* @param input The tool input
|
||||
* @returns A short string summary, or null to not display
|
||||
*/
|
||||
getToolUseSummary?(input: Partial<z.infer<Input>> | undefined): string | null
|
||||
/**
|
||||
* Returns a human-readable present-tense activity description for spinner display.
|
||||
* Example: "Reading src/foo.ts", "Running bun test", "Searching for pattern"
|
||||
* @param input The tool input
|
||||
* @returns Activity description string, or null to fall back to tool name
|
||||
*/
|
||||
getActivityDescription?(
|
||||
input: Partial<z.infer<Input>> | undefined,
|
||||
): string | null
|
||||
/**
|
||||
* Returns a compact representation of this tool use for the auto-mode
|
||||
* security classifier. Examples: `ls -la` for Bash, `/tmp/x: new content`
|
||||
* for Edit. Return '' to skip this tool in the classifier transcript
|
||||
* (e.g. tools with no security relevance). May return an object to avoid
|
||||
* double-encoding when the caller JSON-wraps the value.
|
||||
*/
|
||||
toAutoClassifierInput(input: z.infer<Input>): unknown
|
||||
mapToolResultToToolResultBlockParam(
|
||||
content: Output,
|
||||
toolUseID: string,
|
||||
): ToolResultBlockParam
|
||||
/**
|
||||
* Optional. When omitted, the tool result renders nothing (same as returning
|
||||
* null). Omit for tools whose results are surfaced elsewhere (e.g., TodoWrite
|
||||
* updates the todo panel, not the transcript).
|
||||
*/
|
||||
renderToolResultMessage?(
|
||||
content: Output,
|
||||
progressMessagesForMessage: ProgressMessage<P>[],
|
||||
options: {
|
||||
style?: 'condensed'
|
||||
theme: ThemeName
|
||||
tools: Tools
|
||||
verbose: boolean
|
||||
isTranscriptMode?: boolean
|
||||
isBriefOnly?: boolean
|
||||
/** Original tool_use input, when available. Useful for compact result
|
||||
* summaries that reference what was requested (e.g. "Sent to #foo"). */
|
||||
input?: unknown
|
||||
},
|
||||
): React.ReactNode
|
||||
/**
|
||||
* Flattened text of what renderToolResultMessage shows IN TRANSCRIPT
|
||||
* MODE (verbose=true, isTranscriptMode=true). For transcript search
|
||||
* indexing: the index counts occurrences in this string, the highlight
|
||||
* overlay scans the actual screen buffer. For count ≡ highlight, this
|
||||
* must return the text that ends up visible — not the model-facing
|
||||
* serialization from mapToolResultToToolResultBlockParam (which adds
|
||||
* system-reminders, persisted-output wrappers).
|
||||
*
|
||||
* Chrome can be skipped (under-count is fine). "Found 3 files in 12ms"
|
||||
* isn't worth indexing. Phantoms are not fine — text that's claimed
|
||||
* here but doesn't render is a count≠highlight bug.
|
||||
*
|
||||
* Optional: omitted → field-name heuristic in transcriptSearch.ts.
|
||||
* Drift caught by test/utils/transcriptSearch.renderFidelity.test.tsx
|
||||
* which renders sample outputs and flags text that's indexed-but-not-
|
||||
* rendered (phantom) or rendered-but-not-indexed (under-count warning).
|
||||
*/
|
||||
extractSearchText?(out: Output): string
|
||||
/**
|
||||
* Render the tool use message. Note that `input` is partial because we render
|
||||
* the message as soon as possible, possibly before tool parameters have fully
|
||||
* streamed in.
|
||||
*/
|
||||
renderToolUseMessage(
|
||||
input: Partial<z.infer<Input>>,
|
||||
options: { theme: ThemeName; verbose: boolean; commands?: Command[] },
|
||||
): React.ReactNode
|
||||
/**
|
||||
* Returns true when the non-verbose rendering of this output is truncated
|
||||
* (i.e., clicking to expand would reveal more content). Gates
|
||||
* click-to-expand in fullscreen — only messages where verbose actually
|
||||
* shows more get a hover/click affordance. Unset means never truncated.
|
||||
*/
|
||||
isResultTruncated?(output: Output): boolean
|
||||
/**
|
||||
* Renders an optional tag to display after the tool use message.
|
||||
* Used for additional metadata like timeout, model, resume ID, etc.
|
||||
* Returns null to not display anything.
|
||||
*/
|
||||
renderToolUseTag?(input: Partial<z.infer<Input>>): React.ReactNode
|
||||
/**
|
||||
* Optional. When omitted, no progress UI is shown while the tool runs.
|
||||
*/
|
||||
renderToolUseProgressMessage?(
|
||||
progressMessagesForMessage: ProgressMessage<P>[],
|
||||
options: {
|
||||
tools: Tools
|
||||
verbose: boolean
|
||||
terminalSize?: { columns: number; rows: number }
|
||||
inProgressToolCallCount?: number
|
||||
isTranscriptMode?: boolean
|
||||
},
|
||||
): React.ReactNode
|
||||
renderToolUseQueuedMessage?(): React.ReactNode
|
||||
/**
|
||||
* Optional. When omitted, falls back to <FallbackToolUseRejectedMessage />.
|
||||
* Only define this for tools that need custom rejection UI (e.g., file edits
|
||||
* that show the rejected diff).
|
||||
*/
|
||||
renderToolUseRejectedMessage?(
|
||||
input: z.infer<Input>,
|
||||
options: {
|
||||
columns: number
|
||||
messages: Message[]
|
||||
style?: 'condensed'
|
||||
theme: ThemeName
|
||||
tools: Tools
|
||||
verbose: boolean
|
||||
progressMessagesForMessage: ProgressMessage<P>[]
|
||||
isTranscriptMode?: boolean
|
||||
},
|
||||
): React.ReactNode
|
||||
/**
|
||||
* Optional. When omitted, falls back to <FallbackToolUseErrorMessage />.
|
||||
* Only define this for tools that need custom error UI (e.g., search tools
|
||||
* that show "File not found" instead of the raw error).
|
||||
*/
|
||||
renderToolUseErrorMessage?(
|
||||
result: ToolResultBlockParam['content'],
|
||||
options: {
|
||||
progressMessagesForMessage: ProgressMessage<P>[]
|
||||
tools: Tools
|
||||
verbose: boolean
|
||||
isTranscriptMode?: boolean
|
||||
},
|
||||
): React.ReactNode
|
||||
|
||||
/**
|
||||
* Renders multiple parallel instances of this tool as a group.
|
||||
* @returns React node to render, or null to fall back to individual rendering
|
||||
*/
|
||||
/**
|
||||
* Renders multiple tool uses as a group (non-verbose mode only).
|
||||
* In verbose mode, individual tool uses render at their original positions.
|
||||
* @returns React node to render, or null to fall back to individual rendering
|
||||
*/
|
||||
renderGroupedToolUse?(
|
||||
toolUses: Array<{
|
||||
param: ToolUseBlockParam
|
||||
isResolved: boolean
|
||||
isError: boolean
|
||||
isInProgress: boolean
|
||||
progressMessages: ProgressMessage<P>[]
|
||||
result?: {
|
||||
param: ToolResultBlockParam
|
||||
output: unknown
|
||||
}
|
||||
}>,
|
||||
options: {
|
||||
shouldAnimate: boolean
|
||||
tools: Tools
|
||||
},
|
||||
): React.ReactNode | null
|
||||
}
|
||||
|
||||
/**
|
||||
* A collection of tools. Use this type instead of `Tool[]` to make it easier
|
||||
* to track where tool sets are assembled, passed, and filtered across the codebase.
|
||||
*/
|
||||
export type Tools = readonly Tool[]
|
||||
|
||||
/**
|
||||
* Methods that `buildTool` supplies a default for. A `ToolDef` may omit these;
|
||||
* the resulting `Tool` always has them.
|
||||
*/
|
||||
type DefaultableToolKeys =
|
||||
| 'isEnabled'
|
||||
| 'isConcurrencySafe'
|
||||
| 'isReadOnly'
|
||||
| 'isDestructive'
|
||||
| 'checkPermissions'
|
||||
| 'toAutoClassifierInput'
|
||||
| 'userFacingName'
|
||||
|
||||
/**
|
||||
* Tool definition accepted by `buildTool`. Same shape as `Tool` but with the
|
||||
* defaultable methods optional — `buildTool` fills them in so callers always
|
||||
* see a complete `Tool`.
|
||||
*/
|
||||
export type ToolDef<
|
||||
Input extends AnyObject = AnyObject,
|
||||
Output = unknown,
|
||||
P extends ToolProgressData = ToolProgressData,
|
||||
> = Omit<Tool<Input, Output, P>, DefaultableToolKeys> &
|
||||
Partial<Pick<Tool<Input, Output, P>, DefaultableToolKeys>>
|
||||
|
||||
/**
|
||||
* Type-level spread mirroring `{ ...TOOL_DEFAULTS, ...def }`. For each
|
||||
* defaultable key: if D provides it (required), D's type wins; if D omits
|
||||
* it or has it optional (inherited from Partial<> in the constraint), the
|
||||
* default fills in. All other keys come from D verbatim — preserving arity,
|
||||
* optional presence, and literal types exactly as `satisfies Tool` did.
|
||||
*/
|
||||
type BuiltTool<D> = Omit<D, DefaultableToolKeys> & {
|
||||
[K in DefaultableToolKeys]-?: K extends keyof D
|
||||
? undefined extends D[K]
|
||||
? ToolDefaults[K]
|
||||
: D[K]
|
||||
: ToolDefaults[K]
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a complete `Tool` from a partial definition, filling in safe defaults
|
||||
* for the commonly-stubbed methods. All tool exports should go through this so
|
||||
* that defaults live in one place and callers never need `?.() ?? default`.
|
||||
*
|
||||
* Defaults (fail-closed where it matters):
|
||||
* - `isEnabled` → `true`
|
||||
* - `isConcurrencySafe` → `false` (assume not safe)
|
||||
* - `isReadOnly` → `false` (assume writes)
|
||||
* - `isDestructive` → `false`
|
||||
* - `checkPermissions` → `{ behavior: 'allow', updatedInput }` (defer to general permission system)
|
||||
* - `toAutoClassifierInput` → `''` (skip classifier — security-relevant tools must override)
|
||||
* - `userFacingName` → `name`
|
||||
*/
|
||||
const TOOL_DEFAULTS = {
|
||||
isEnabled: () => true,
|
||||
isConcurrencySafe: (_input?: unknown) => false,
|
||||
isReadOnly: (_input?: unknown) => false,
|
||||
isDestructive: (_input?: unknown) => false,
|
||||
checkPermissions: (
|
||||
input: { [key: string]: unknown },
|
||||
_ctx?: ToolUseContext,
|
||||
): Promise<PermissionResult> =>
|
||||
Promise.resolve({ behavior: 'allow', updatedInput: input }),
|
||||
toAutoClassifierInput: (_input?: unknown) => '',
|
||||
userFacingName: (_input?: unknown) => '',
|
||||
}
|
||||
|
||||
// The defaults type is the ACTUAL shape of TOOL_DEFAULTS (optional params so
|
||||
// both 0-arg and full-arg call sites type-check — stubs varied in arity and
|
||||
// tests relied on that), not the interface's strict signatures.
|
||||
type ToolDefaults = typeof TOOL_DEFAULTS
|
||||
|
||||
// D infers the concrete object-literal type from the call site. The
|
||||
// constraint provides contextual typing for method parameters; `any` in
|
||||
// constraint position is structural and never leaks into the return type.
|
||||
// BuiltTool<D> mirrors runtime `{...TOOL_DEFAULTS, ...def}` at the type level.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type AnyToolDef = ToolDef<any, any, any>
|
||||
|
||||
export function buildTool<D extends AnyToolDef>(def: D): BuiltTool<D> {
|
||||
// The runtime spread is straightforward; the `as` bridges the gap between
|
||||
// the structural-any constraint and the precise BuiltTool<D> return. The
|
||||
// type semantics are proven by the 0-error typecheck across all 60+ tools.
|
||||
return {
|
||||
...TOOL_DEFAULTS,
|
||||
userFacingName: () => def.name,
|
||||
...def,
|
||||
} as BuiltTool<D>
|
||||
}
|
||||
87
original-source-code/src/assistant/sessionHistory.ts
Normal file
87
original-source-code/src/assistant/sessionHistory.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import axios from 'axios'
|
||||
import { getOauthConfig } from '../constants/oauth.js'
|
||||
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { getOAuthHeaders, prepareApiRequest } from '../utils/teleport/api.js'
|
||||
|
||||
export const HISTORY_PAGE_SIZE = 100
|
||||
|
||||
export type HistoryPage = {
|
||||
/** Chronological order within the page. */
|
||||
events: SDKMessage[]
|
||||
/** Oldest event ID in this page → before_id cursor for next-older page. */
|
||||
firstId: string | null
|
||||
/** true = older events exist. */
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
type SessionEventsResponse = {
|
||||
data: SDKMessage[]
|
||||
has_more: boolean
|
||||
first_id: string | null
|
||||
last_id: string | null
|
||||
}
|
||||
|
||||
export type HistoryAuthCtx = {
|
||||
baseUrl: string
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
/** Prepare auth + headers + base URL once, reuse across pages. */
|
||||
export async function createHistoryAuthCtx(
|
||||
sessionId: string,
|
||||
): Promise<HistoryAuthCtx> {
|
||||
const { accessToken, orgUUID } = await prepareApiRequest()
|
||||
return {
|
||||
baseUrl: `${getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}/events`,
|
||||
headers: {
|
||||
...getOAuthHeaders(accessToken),
|
||||
'anthropic-beta': 'ccr-byoc-2025-07-29',
|
||||
'x-organization-uuid': orgUUID,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPage(
|
||||
ctx: HistoryAuthCtx,
|
||||
params: Record<string, string | number | boolean>,
|
||||
label: string,
|
||||
): Promise<HistoryPage | null> {
|
||||
const resp = await axios
|
||||
.get<SessionEventsResponse>(ctx.baseUrl, {
|
||||
headers: ctx.headers,
|
||||
params,
|
||||
timeout: 15000,
|
||||
validateStatus: () => true,
|
||||
})
|
||||
.catch(() => null)
|
||||
if (!resp || resp.status !== 200) {
|
||||
logForDebugging(`[${label}] HTTP ${resp?.status ?? 'error'}`)
|
||||
return null
|
||||
}
|
||||
return {
|
||||
events: Array.isArray(resp.data.data) ? resp.data.data : [],
|
||||
firstId: resp.data.first_id,
|
||||
hasMore: resp.data.has_more,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Newest page: last `limit` events, chronological, via anchor_to_latest.
|
||||
* has_more=true means older events exist.
|
||||
*/
|
||||
export async function fetchLatestEvents(
|
||||
ctx: HistoryAuthCtx,
|
||||
limit = HISTORY_PAGE_SIZE,
|
||||
): Promise<HistoryPage | null> {
|
||||
return fetchPage(ctx, { limit, anchor_to_latest: true }, 'fetchLatestEvents')
|
||||
}
|
||||
|
||||
/** Older page: events immediately before `beforeId` cursor. */
|
||||
export async function fetchOlderEvents(
|
||||
ctx: HistoryAuthCtx,
|
||||
beforeId: string,
|
||||
limit = HISTORY_PAGE_SIZE,
|
||||
): Promise<HistoryPage | null> {
|
||||
return fetchPage(ctx, { limit, before_id: beforeId }, 'fetchOlderEvents')
|
||||
}
|
||||
1758
original-source-code/src/bootstrap/state.ts
Normal file
1758
original-source-code/src/bootstrap/state.ts
Normal file
File diff suppressed because it is too large
Load Diff
539
original-source-code/src/bridge/bridgeApi.ts
Normal file
539
original-source-code/src/bridge/bridgeApi.ts
Normal file
@@ -0,0 +1,539 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { debugBody, extractErrorDetail } from './debugUtils.js'
|
||||
import {
|
||||
BRIDGE_LOGIN_INSTRUCTION,
|
||||
type BridgeApiClient,
|
||||
type BridgeConfig,
|
||||
type PermissionResponseEvent,
|
||||
type WorkResponse,
|
||||
} from './types.js'
|
||||
|
||||
type BridgeApiDeps = {
|
||||
baseUrl: string
|
||||
getAccessToken: () => string | undefined
|
||||
runnerVersion: string
|
||||
onDebug?: (msg: string) => void
|
||||
/**
|
||||
* Called on 401 to attempt OAuth token refresh. Returns true if refreshed,
|
||||
* in which case the request is retried once. Injected because
|
||||
* handleOAuth401Error from utils/auth.ts transitively pulls in config.ts →
|
||||
* file.ts → permissions/filesystem.ts → sessionStorage.ts → commands.ts
|
||||
* (~1300 modules). Daemon callers using env-var tokens omit this — their
|
||||
* tokens don't refresh, so 401 goes straight to BridgeFatalError.
|
||||
*/
|
||||
onAuth401?: (staleAccessToken: string) => Promise<boolean>
|
||||
/**
|
||||
* Returns the trusted device token to send as X-Trusted-Device-Token on
|
||||
* bridge API calls. Bridge sessions have SecurityTier=ELEVATED on the
|
||||
* server (CCR v2); when the server's enforcement flag is on,
|
||||
* ConnectBridgeWorker requires a trusted device at JWT-issuance.
|
||||
* Optional — when absent or returning undefined, the header is omitted
|
||||
* and the server falls through to its flag-off/no-op path. The CLI-side
|
||||
* gate is tengu_sessions_elevated_auth_enforcement (see trustedDevice.ts).
|
||||
*/
|
||||
getTrustedDeviceToken?: () => string | undefined
|
||||
}
|
||||
|
||||
const BETA_HEADER = 'environments-2025-11-01'
|
||||
|
||||
/** Allowlist pattern for server-provided IDs used in URL path segments. */
|
||||
const SAFE_ID_PATTERN = /^[a-zA-Z0-9_-]+$/
|
||||
|
||||
/**
|
||||
* Validate that a server-provided ID is safe to interpolate into a URL path.
|
||||
* Prevents path traversal (e.g. `../../admin`) and injection via IDs that
|
||||
* contain slashes, dots, or other special characters.
|
||||
*/
|
||||
export function validateBridgeId(id: string, label: string): string {
|
||||
if (!id || !SAFE_ID_PATTERN.test(id)) {
|
||||
throw new Error(`Invalid ${label}: contains unsafe characters`)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
/** Fatal bridge errors that should not be retried (e.g. auth failures). */
|
||||
export class BridgeFatalError extends Error {
|
||||
readonly status: number
|
||||
/** Server-provided error type, e.g. "environment_expired". */
|
||||
readonly errorType: string | undefined
|
||||
constructor(message: string, status: number, errorType?: string) {
|
||||
super(message)
|
||||
this.name = 'BridgeFatalError'
|
||||
this.status = status
|
||||
this.errorType = errorType
|
||||
}
|
||||
}
|
||||
|
||||
export function createBridgeApiClient(deps: BridgeApiDeps): BridgeApiClient {
|
||||
function debug(msg: string): void {
|
||||
deps.onDebug?.(msg)
|
||||
}
|
||||
|
||||
let consecutiveEmptyPolls = 0
|
||||
const EMPTY_POLL_LOG_INTERVAL = 100
|
||||
|
||||
function getHeaders(accessToken: string): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
'anthropic-beta': BETA_HEADER,
|
||||
'x-environment-runner-version': deps.runnerVersion,
|
||||
}
|
||||
const deviceToken = deps.getTrustedDeviceToken?.()
|
||||
if (deviceToken) {
|
||||
headers['X-Trusted-Device-Token'] = deviceToken
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
function resolveAuth(): string {
|
||||
const accessToken = deps.getAccessToken()
|
||||
if (!accessToken) {
|
||||
throw new Error(BRIDGE_LOGIN_INSTRUCTION)
|
||||
}
|
||||
return accessToken
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an OAuth-authenticated request with a single retry on 401.
|
||||
* On 401, attempts token refresh via handleOAuth401Error (same pattern as
|
||||
* withRetry.ts for v1/messages). If refresh succeeds, retries the request
|
||||
* once with the new token. If refresh fails or the retry also returns 401,
|
||||
* the 401 response is returned for handleErrorStatus to throw BridgeFatalError.
|
||||
*/
|
||||
async function withOAuthRetry<T>(
|
||||
fn: (accessToken: string) => Promise<{ status: number; data: T }>,
|
||||
context: string,
|
||||
): Promise<{ status: number; data: T }> {
|
||||
const accessToken = resolveAuth()
|
||||
const response = await fn(accessToken)
|
||||
|
||||
if (response.status !== 401) {
|
||||
return response
|
||||
}
|
||||
|
||||
if (!deps.onAuth401) {
|
||||
debug(`[bridge:api] ${context}: 401 received, no refresh handler`)
|
||||
return response
|
||||
}
|
||||
|
||||
// Attempt token refresh — matches the pattern in withRetry.ts
|
||||
debug(`[bridge:api] ${context}: 401 received, attempting token refresh`)
|
||||
const refreshed = await deps.onAuth401(accessToken)
|
||||
if (refreshed) {
|
||||
debug(`[bridge:api] ${context}: Token refreshed, retrying request`)
|
||||
const newToken = resolveAuth()
|
||||
const retryResponse = await fn(newToken)
|
||||
if (retryResponse.status !== 401) {
|
||||
return retryResponse
|
||||
}
|
||||
debug(`[bridge:api] ${context}: Retry after refresh also got 401`)
|
||||
} else {
|
||||
debug(`[bridge:api] ${context}: Token refresh failed`)
|
||||
}
|
||||
|
||||
// Refresh failed — return 401 for handleErrorStatus to throw
|
||||
return response
|
||||
}
|
||||
|
||||
return {
|
||||
async registerBridgeEnvironment(
|
||||
config: BridgeConfig,
|
||||
): Promise<{ environment_id: string; environment_secret: string }> {
|
||||
debug(
|
||||
`[bridge:api] POST /v1/environments/bridge bridgeId=${config.bridgeId}`,
|
||||
)
|
||||
|
||||
const response = await withOAuthRetry(
|
||||
(token: string) =>
|
||||
axios.post<{
|
||||
environment_id: string
|
||||
environment_secret: string
|
||||
}>(
|
||||
`${deps.baseUrl}/v1/environments/bridge`,
|
||||
{
|
||||
machine_name: config.machineName,
|
||||
directory: config.dir,
|
||||
branch: config.branch,
|
||||
git_repo_url: config.gitRepoUrl,
|
||||
// Advertise session capacity so claude.ai/code can show
|
||||
// "2/4 sessions" badges and only block the picker when
|
||||
// actually at capacity. Backends that don't yet accept
|
||||
// this field will silently ignore it.
|
||||
max_sessions: config.maxSessions,
|
||||
// worker_type lets claude.ai filter environments by origin
|
||||
// (e.g. assistant picker only shows assistant-mode workers).
|
||||
// Desktop cowork app sends "cowork"; we send a distinct value.
|
||||
metadata: { worker_type: config.workerType },
|
||||
// Idempotent re-registration: if we have a backend-issued
|
||||
// environment_id from a prior session (--session-id resume),
|
||||
// send it back so the backend reattaches instead of creating
|
||||
// a new env. The backend may still hand back a fresh ID if
|
||||
// the old one expired — callers must compare the response.
|
||||
...(config.reuseEnvironmentId && {
|
||||
environment_id: config.reuseEnvironmentId,
|
||||
}),
|
||||
},
|
||||
{
|
||||
headers: getHeaders(token),
|
||||
timeout: 15_000,
|
||||
validateStatus: status => status < 500,
|
||||
},
|
||||
),
|
||||
'Registration',
|
||||
)
|
||||
|
||||
handleErrorStatus(response.status, response.data, 'Registration')
|
||||
debug(
|
||||
`[bridge:api] POST /v1/environments/bridge -> ${response.status} environment_id=${response.data.environment_id}`,
|
||||
)
|
||||
debug(
|
||||
`[bridge:api] >>> ${debugBody({ machine_name: config.machineName, directory: config.dir, branch: config.branch, git_repo_url: config.gitRepoUrl, max_sessions: config.maxSessions, metadata: { worker_type: config.workerType } })}`,
|
||||
)
|
||||
debug(`[bridge:api] <<< ${debugBody(response.data)}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async pollForWork(
|
||||
environmentId: string,
|
||||
environmentSecret: string,
|
||||
signal?: AbortSignal,
|
||||
reclaimOlderThanMs?: number,
|
||||
): Promise<WorkResponse | null> {
|
||||
validateBridgeId(environmentId, 'environmentId')
|
||||
|
||||
// Save and reset so errors break the "consecutive empty" streak.
|
||||
// Restored below when the response is truly empty.
|
||||
const prevEmptyPolls = consecutiveEmptyPolls
|
||||
consecutiveEmptyPolls = 0
|
||||
|
||||
const response = await axios.get<WorkResponse | null>(
|
||||
`${deps.baseUrl}/v1/environments/${environmentId}/work/poll`,
|
||||
{
|
||||
headers: getHeaders(environmentSecret),
|
||||
params:
|
||||
reclaimOlderThanMs !== undefined
|
||||
? { reclaim_older_than_ms: reclaimOlderThanMs }
|
||||
: undefined,
|
||||
timeout: 10_000,
|
||||
signal,
|
||||
validateStatus: status => status < 500,
|
||||
},
|
||||
)
|
||||
|
||||
handleErrorStatus(response.status, response.data, 'Poll')
|
||||
|
||||
// Empty body or null = no work available
|
||||
if (!response.data) {
|
||||
consecutiveEmptyPolls = prevEmptyPolls + 1
|
||||
if (
|
||||
consecutiveEmptyPolls === 1 ||
|
||||
consecutiveEmptyPolls % EMPTY_POLL_LOG_INTERVAL === 0
|
||||
) {
|
||||
debug(
|
||||
`[bridge:api] GET .../work/poll -> ${response.status} (no work, ${consecutiveEmptyPolls} consecutive empty polls)`,
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
debug(
|
||||
`[bridge:api] GET .../work/poll -> ${response.status} workId=${response.data.id} type=${response.data.data?.type}${response.data.data?.id ? ` sessionId=${response.data.data.id}` : ''}`,
|
||||
)
|
||||
debug(`[bridge:api] <<< ${debugBody(response.data)}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async acknowledgeWork(
|
||||
environmentId: string,
|
||||
workId: string,
|
||||
sessionToken: string,
|
||||
): Promise<void> {
|
||||
validateBridgeId(environmentId, 'environmentId')
|
||||
validateBridgeId(workId, 'workId')
|
||||
|
||||
debug(`[bridge:api] POST .../work/${workId}/ack`)
|
||||
|
||||
const response = await axios.post(
|
||||
`${deps.baseUrl}/v1/environments/${environmentId}/work/${workId}/ack`,
|
||||
{},
|
||||
{
|
||||
headers: getHeaders(sessionToken),
|
||||
timeout: 10_000,
|
||||
validateStatus: s => s < 500,
|
||||
},
|
||||
)
|
||||
|
||||
handleErrorStatus(response.status, response.data, 'Acknowledge')
|
||||
debug(`[bridge:api] POST .../work/${workId}/ack -> ${response.status}`)
|
||||
},
|
||||
|
||||
async stopWork(
|
||||
environmentId: string,
|
||||
workId: string,
|
||||
force: boolean,
|
||||
): Promise<void> {
|
||||
validateBridgeId(environmentId, 'environmentId')
|
||||
validateBridgeId(workId, 'workId')
|
||||
|
||||
debug(`[bridge:api] POST .../work/${workId}/stop force=${force}`)
|
||||
|
||||
const response = await withOAuthRetry(
|
||||
(token: string) =>
|
||||
axios.post(
|
||||
`${deps.baseUrl}/v1/environments/${environmentId}/work/${workId}/stop`,
|
||||
{ force },
|
||||
{
|
||||
headers: getHeaders(token),
|
||||
timeout: 10_000,
|
||||
validateStatus: s => s < 500,
|
||||
},
|
||||
),
|
||||
'StopWork',
|
||||
)
|
||||
|
||||
handleErrorStatus(response.status, response.data, 'StopWork')
|
||||
debug(`[bridge:api] POST .../work/${workId}/stop -> ${response.status}`)
|
||||
},
|
||||
|
||||
async deregisterEnvironment(environmentId: string): Promise<void> {
|
||||
validateBridgeId(environmentId, 'environmentId')
|
||||
|
||||
debug(`[bridge:api] DELETE /v1/environments/bridge/${environmentId}`)
|
||||
|
||||
const response = await withOAuthRetry(
|
||||
(token: string) =>
|
||||
axios.delete(
|
||||
`${deps.baseUrl}/v1/environments/bridge/${environmentId}`,
|
||||
{
|
||||
headers: getHeaders(token),
|
||||
timeout: 10_000,
|
||||
validateStatus: s => s < 500,
|
||||
},
|
||||
),
|
||||
'Deregister',
|
||||
)
|
||||
|
||||
handleErrorStatus(response.status, response.data, 'Deregister')
|
||||
debug(
|
||||
`[bridge:api] DELETE /v1/environments/bridge/${environmentId} -> ${response.status}`,
|
||||
)
|
||||
},
|
||||
|
||||
async archiveSession(sessionId: string): Promise<void> {
|
||||
validateBridgeId(sessionId, 'sessionId')
|
||||
|
||||
debug(`[bridge:api] POST /v1/sessions/${sessionId}/archive`)
|
||||
|
||||
const response = await withOAuthRetry(
|
||||
(token: string) =>
|
||||
axios.post(
|
||||
`${deps.baseUrl}/v1/sessions/${sessionId}/archive`,
|
||||
{},
|
||||
{
|
||||
headers: getHeaders(token),
|
||||
timeout: 10_000,
|
||||
validateStatus: s => s < 500,
|
||||
},
|
||||
),
|
||||
'ArchiveSession',
|
||||
)
|
||||
|
||||
// 409 = already archived (idempotent, not an error)
|
||||
if (response.status === 409) {
|
||||
debug(
|
||||
`[bridge:api] POST /v1/sessions/${sessionId}/archive -> 409 (already archived)`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
handleErrorStatus(response.status, response.data, 'ArchiveSession')
|
||||
debug(
|
||||
`[bridge:api] POST /v1/sessions/${sessionId}/archive -> ${response.status}`,
|
||||
)
|
||||
},
|
||||
|
||||
async reconnectSession(
|
||||
environmentId: string,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
validateBridgeId(environmentId, 'environmentId')
|
||||
validateBridgeId(sessionId, 'sessionId')
|
||||
|
||||
debug(
|
||||
`[bridge:api] POST /v1/environments/${environmentId}/bridge/reconnect session_id=${sessionId}`,
|
||||
)
|
||||
|
||||
const response = await withOAuthRetry(
|
||||
(token: string) =>
|
||||
axios.post(
|
||||
`${deps.baseUrl}/v1/environments/${environmentId}/bridge/reconnect`,
|
||||
{ session_id: sessionId },
|
||||
{
|
||||
headers: getHeaders(token),
|
||||
timeout: 10_000,
|
||||
validateStatus: s => s < 500,
|
||||
},
|
||||
),
|
||||
'ReconnectSession',
|
||||
)
|
||||
|
||||
handleErrorStatus(response.status, response.data, 'ReconnectSession')
|
||||
debug(`[bridge:api] POST .../bridge/reconnect -> ${response.status}`)
|
||||
},
|
||||
|
||||
async heartbeatWork(
|
||||
environmentId: string,
|
||||
workId: string,
|
||||
sessionToken: string,
|
||||
): Promise<{ lease_extended: boolean; state: string }> {
|
||||
validateBridgeId(environmentId, 'environmentId')
|
||||
validateBridgeId(workId, 'workId')
|
||||
|
||||
debug(`[bridge:api] POST .../work/${workId}/heartbeat`)
|
||||
|
||||
const response = await axios.post<{
|
||||
lease_extended: boolean
|
||||
state: string
|
||||
last_heartbeat: string
|
||||
ttl_seconds: number
|
||||
}>(
|
||||
`${deps.baseUrl}/v1/environments/${environmentId}/work/${workId}/heartbeat`,
|
||||
{},
|
||||
{
|
||||
headers: getHeaders(sessionToken),
|
||||
timeout: 10_000,
|
||||
validateStatus: s => s < 500,
|
||||
},
|
||||
)
|
||||
|
||||
handleErrorStatus(response.status, response.data, 'Heartbeat')
|
||||
debug(
|
||||
`[bridge:api] POST .../work/${workId}/heartbeat -> ${response.status} lease_extended=${response.data.lease_extended} state=${response.data.state}`,
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async sendPermissionResponseEvent(
|
||||
sessionId: string,
|
||||
event: PermissionResponseEvent,
|
||||
sessionToken: string,
|
||||
): Promise<void> {
|
||||
validateBridgeId(sessionId, 'sessionId')
|
||||
|
||||
debug(
|
||||
`[bridge:api] POST /v1/sessions/${sessionId}/events type=${event.type}`,
|
||||
)
|
||||
|
||||
const response = await axios.post(
|
||||
`${deps.baseUrl}/v1/sessions/${sessionId}/events`,
|
||||
{ events: [event] },
|
||||
{
|
||||
headers: getHeaders(sessionToken),
|
||||
timeout: 10_000,
|
||||
validateStatus: s => s < 500,
|
||||
},
|
||||
)
|
||||
|
||||
handleErrorStatus(
|
||||
response.status,
|
||||
response.data,
|
||||
'SendPermissionResponseEvent',
|
||||
)
|
||||
debug(
|
||||
`[bridge:api] POST /v1/sessions/${sessionId}/events -> ${response.status}`,
|
||||
)
|
||||
debug(`[bridge:api] >>> ${debugBody({ events: [event] })}`)
|
||||
debug(`[bridge:api] <<< ${debugBody(response.data)}`)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function handleErrorStatus(
|
||||
status: number,
|
||||
data: unknown,
|
||||
context: string,
|
||||
): void {
|
||||
if (status === 200 || status === 204) {
|
||||
return
|
||||
}
|
||||
const detail = extractErrorDetail(data)
|
||||
const errorType = extractErrorTypeFromData(data)
|
||||
switch (status) {
|
||||
case 401:
|
||||
throw new BridgeFatalError(
|
||||
`${context}: Authentication failed (401)${detail ? `: ${detail}` : ''}. ${BRIDGE_LOGIN_INSTRUCTION}`,
|
||||
401,
|
||||
errorType,
|
||||
)
|
||||
case 403:
|
||||
throw new BridgeFatalError(
|
||||
isExpiredErrorType(errorType)
|
||||
? 'Remote Control session has expired. Please restart with `claude remote-control` or /remote-control.'
|
||||
: `${context}: Access denied (403)${detail ? `: ${detail}` : ''}. Check your organization permissions.`,
|
||||
403,
|
||||
errorType,
|
||||
)
|
||||
case 404:
|
||||
throw new BridgeFatalError(
|
||||
detail ??
|
||||
`${context}: Not found (404). Remote Control may not be available for this organization.`,
|
||||
404,
|
||||
errorType,
|
||||
)
|
||||
case 410:
|
||||
throw new BridgeFatalError(
|
||||
detail ??
|
||||
'Remote Control session has expired. Please restart with `claude remote-control` or /remote-control.',
|
||||
410,
|
||||
errorType ?? 'environment_expired',
|
||||
)
|
||||
case 429:
|
||||
throw new Error(`${context}: Rate limited (429). Polling too frequently.`)
|
||||
default:
|
||||
throw new Error(
|
||||
`${context}: Failed with status ${status}${detail ? `: ${detail}` : ''}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Check whether an error type string indicates a session/environment expiry. */
|
||||
export function isExpiredErrorType(errorType: string | undefined): boolean {
|
||||
if (!errorType) {
|
||||
return false
|
||||
}
|
||||
return errorType.includes('expired') || errorType.includes('lifetime')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a BridgeFatalError is a suppressible 403 permission error.
|
||||
* These are 403 errors for scopes like 'external_poll_sessions' or operations
|
||||
* like StopWork that fail because the user's role lacks 'environments:manage'.
|
||||
* They don't affect core functionality and shouldn't be shown to users.
|
||||
*/
|
||||
export function isSuppressible403(err: BridgeFatalError): boolean {
|
||||
if (err.status !== 403) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
err.message.includes('external_poll_sessions') ||
|
||||
err.message.includes('environments:manage')
|
||||
)
|
||||
}
|
||||
|
||||
function extractErrorTypeFromData(data: unknown): string | undefined {
|
||||
if (data && typeof data === 'object') {
|
||||
if (
|
||||
'error' in data &&
|
||||
data.error &&
|
||||
typeof data.error === 'object' &&
|
||||
'type' in data.error &&
|
||||
typeof data.error.type === 'string'
|
||||
) {
|
||||
return data.error.type
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
48
original-source-code/src/bridge/bridgeConfig.ts
Normal file
48
original-source-code/src/bridge/bridgeConfig.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Shared bridge auth/URL resolution. Consolidates the ant-only
|
||||
* CLAUDE_BRIDGE_* dev overrides that were previously copy-pasted across
|
||||
* a dozen files — inboundAttachments, BriefTool/upload, bridgeMain,
|
||||
* initReplBridge, remoteBridgeCore, daemon workers, /rename,
|
||||
* /remote-control.
|
||||
*
|
||||
* Two layers: *Override() returns the ant-only env var (or undefined);
|
||||
* the non-Override versions fall through to the real OAuth store/config.
|
||||
* Callers that compose with a different auth source (e.g. daemon workers
|
||||
* using IPC auth) use the Override getters directly.
|
||||
*/
|
||||
|
||||
import { getOauthConfig } from '../constants/oauth.js'
|
||||
import { getClaudeAIOAuthTokens } from '../utils/auth.js'
|
||||
|
||||
/** Ant-only dev override: CLAUDE_BRIDGE_OAUTH_TOKEN, else undefined. */
|
||||
export function getBridgeTokenOverride(): string | undefined {
|
||||
return (
|
||||
(process.env.USER_TYPE === 'ant' &&
|
||||
process.env.CLAUDE_BRIDGE_OAUTH_TOKEN) ||
|
||||
undefined
|
||||
)
|
||||
}
|
||||
|
||||
/** Ant-only dev override: CLAUDE_BRIDGE_BASE_URL, else undefined. */
|
||||
export function getBridgeBaseUrlOverride(): string | undefined {
|
||||
return (
|
||||
(process.env.USER_TYPE === 'ant' && process.env.CLAUDE_BRIDGE_BASE_URL) ||
|
||||
undefined
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Access token for bridge API calls: dev override first, then the OAuth
|
||||
* keychain. Undefined means "not logged in".
|
||||
*/
|
||||
export function getBridgeAccessToken(): string | undefined {
|
||||
return getBridgeTokenOverride() ?? getClaudeAIOAuthTokens()?.accessToken
|
||||
}
|
||||
|
||||
/**
|
||||
* Base URL for bridge API calls: dev override first, then the production
|
||||
* OAuth config. Always returns a URL.
|
||||
*/
|
||||
export function getBridgeBaseUrl(): string {
|
||||
return getBridgeBaseUrlOverride() ?? getOauthConfig().BASE_API_URL
|
||||
}
|
||||
135
original-source-code/src/bridge/bridgeDebug.ts
Normal file
135
original-source-code/src/bridge/bridgeDebug.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { BridgeFatalError } from './bridgeApi.js'
|
||||
import type { BridgeApiClient } from './types.js'
|
||||
|
||||
/**
|
||||
* Ant-only fault injection for manually testing bridge recovery paths.
|
||||
*
|
||||
* Real failure modes this targets (BQ 2026-03-12, 7-day window):
|
||||
* poll 404 not_found_error — 147K sessions/week, dead onEnvironmentLost gate
|
||||
* ws_closed 1002/1006 — 22K sessions/week, zombie poll after close
|
||||
* register transient failure — residual: network blips during doReconnect
|
||||
*
|
||||
* Usage: /bridge-kick <subcommand> from the REPL while Remote Control is
|
||||
* connected, then tail debug.log to watch the recovery machinery react.
|
||||
*
|
||||
* Module-level state is intentional here: one bridge per REPL process, the
|
||||
* /bridge-kick slash command has no other way to reach into initBridgeCore's
|
||||
* closures, and teardown clears the slot.
|
||||
*/
|
||||
|
||||
/** One-shot fault to inject on the next matching api call. */
|
||||
type BridgeFault = {
|
||||
method:
|
||||
| 'pollForWork'
|
||||
| 'registerBridgeEnvironment'
|
||||
| 'reconnectSession'
|
||||
| 'heartbeatWork'
|
||||
/** Fatal errors go through handleErrorStatus → BridgeFatalError. Transient
|
||||
* errors surface as plain axios rejections (5xx / network). Recovery code
|
||||
* distinguishes the two: fatal → teardown, transient → retry/backoff. */
|
||||
kind: 'fatal' | 'transient'
|
||||
status: number
|
||||
errorType?: string
|
||||
/** Remaining injections. Decremented on consume; removed at 0. */
|
||||
count: number
|
||||
}
|
||||
|
||||
export type BridgeDebugHandle = {
|
||||
/** Invoke the transport's permanent-close handler directly. Tests the
|
||||
* ws_closed → reconnectEnvironmentWithSession escalation (#22148). */
|
||||
fireClose: (code: number) => void
|
||||
/** Call reconnectEnvironmentWithSession() — same as SIGUSR2 but
|
||||
* reachable from the slash command. */
|
||||
forceReconnect: () => void
|
||||
/** Queue a fault for the next N calls to the named api method. */
|
||||
injectFault: (fault: BridgeFault) => void
|
||||
/** Abort the at-capacity sleep so an injected poll fault lands
|
||||
* immediately instead of up to 10min later. */
|
||||
wakePollLoop: () => void
|
||||
/** env/session IDs for the debug.log grep. */
|
||||
describe: () => string
|
||||
}
|
||||
|
||||
let debugHandle: BridgeDebugHandle | null = null
|
||||
const faultQueue: BridgeFault[] = []
|
||||
|
||||
export function registerBridgeDebugHandle(h: BridgeDebugHandle): void {
|
||||
debugHandle = h
|
||||
}
|
||||
|
||||
export function clearBridgeDebugHandle(): void {
|
||||
debugHandle = null
|
||||
faultQueue.length = 0
|
||||
}
|
||||
|
||||
export function getBridgeDebugHandle(): BridgeDebugHandle | null {
|
||||
return debugHandle
|
||||
}
|
||||
|
||||
export function injectBridgeFault(fault: BridgeFault): void {
|
||||
faultQueue.push(fault)
|
||||
logForDebugging(
|
||||
`[bridge:debug] Queued fault: ${fault.method} ${fault.kind}/${fault.status}${fault.errorType ? `/${fault.errorType}` : ''} ×${fault.count}`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a BridgeApiClient so each call first checks the fault queue. If a
|
||||
* matching fault is queued, throw the specified error instead of calling
|
||||
* through. Delegates everything else to the real client.
|
||||
*
|
||||
* Only called when USER_TYPE === 'ant' — zero overhead in external builds.
|
||||
*/
|
||||
export function wrapApiForFaultInjection(
|
||||
api: BridgeApiClient,
|
||||
): BridgeApiClient {
|
||||
function consume(method: BridgeFault['method']): BridgeFault | null {
|
||||
const idx = faultQueue.findIndex(f => f.method === method)
|
||||
if (idx === -1) return null
|
||||
const fault = faultQueue[idx]!
|
||||
fault.count--
|
||||
if (fault.count <= 0) faultQueue.splice(idx, 1)
|
||||
return fault
|
||||
}
|
||||
|
||||
function throwFault(fault: BridgeFault, context: string): never {
|
||||
logForDebugging(
|
||||
`[bridge:debug] Injecting ${fault.kind} fault into ${context}: status=${fault.status} errorType=${fault.errorType ?? 'none'}`,
|
||||
)
|
||||
if (fault.kind === 'fatal') {
|
||||
throw new BridgeFatalError(
|
||||
`[injected] ${context} ${fault.status}`,
|
||||
fault.status,
|
||||
fault.errorType,
|
||||
)
|
||||
}
|
||||
// Transient: mimic an axios rejection (5xx / network). No .status on
|
||||
// the error itself — that's how the catch blocks distinguish.
|
||||
throw new Error(`[injected transient] ${context} ${fault.status}`)
|
||||
}
|
||||
|
||||
return {
|
||||
...api,
|
||||
async pollForWork(envId, secret, signal, reclaimMs) {
|
||||
const f = consume('pollForWork')
|
||||
if (f) throwFault(f, 'Poll')
|
||||
return api.pollForWork(envId, secret, signal, reclaimMs)
|
||||
},
|
||||
async registerBridgeEnvironment(config) {
|
||||
const f = consume('registerBridgeEnvironment')
|
||||
if (f) throwFault(f, 'Registration')
|
||||
return api.registerBridgeEnvironment(config)
|
||||
},
|
||||
async reconnectSession(envId, sessionId) {
|
||||
const f = consume('reconnectSession')
|
||||
if (f) throwFault(f, 'ReconnectSession')
|
||||
return api.reconnectSession(envId, sessionId)
|
||||
},
|
||||
async heartbeatWork(envId, workId, token) {
|
||||
const f = consume('heartbeatWork')
|
||||
if (f) throwFault(f, 'Heartbeat')
|
||||
return api.heartbeatWork(envId, workId, token)
|
||||
},
|
||||
}
|
||||
}
|
||||
202
original-source-code/src/bridge/bridgeEnabled.ts
Normal file
202
original-source-code/src/bridge/bridgeEnabled.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import {
|
||||
checkGate_CACHED_OR_BLOCKING,
|
||||
getDynamicConfig_CACHED_MAY_BE_STALE,
|
||||
getFeatureValue_CACHED_MAY_BE_STALE,
|
||||
} from '../services/analytics/growthbook.js'
|
||||
// Namespace import breaks the bridgeEnabled → auth → config → bridgeEnabled
|
||||
// cycle — authModule.foo is a live binding, so by the time the helpers below
|
||||
// call it, auth.js is fully loaded. Previously used require() for the same
|
||||
// deferral, but require() hits a CJS cache that diverges from the ESM
|
||||
// namespace after mock.module() (daemon/auth.test.ts), breaking spyOn.
|
||||
import * as authModule from '../utils/auth.js'
|
||||
import { isEnvTruthy } from '../utils/envUtils.js'
|
||||
import { lt } from '../utils/semver.js'
|
||||
|
||||
/**
|
||||
* Runtime check for bridge mode entitlement.
|
||||
*
|
||||
* Remote Control requires a claude.ai subscription (the bridge auths to CCR
|
||||
* with the claude.ai OAuth token). isClaudeAISubscriber() excludes
|
||||
* Bedrock/Vertex/Foundry, apiKeyHelper/gateway deployments, env-var API keys,
|
||||
* and Console API logins — none of which have the OAuth token CCR needs.
|
||||
* See github.com/deshaw/anthropic-issues/issues/24.
|
||||
*
|
||||
* The `feature('BRIDGE_MODE')` guard ensures the GrowthBook string literal
|
||||
* is only referenced when bridge mode is enabled at build time.
|
||||
*/
|
||||
export function isBridgeEnabled(): boolean {
|
||||
// Positive ternary pattern — see docs/feature-gating.md.
|
||||
// Negative pattern (if (!feature(...)) return) does not eliminate
|
||||
// inline string literals from external builds.
|
||||
return feature('BRIDGE_MODE')
|
||||
? isClaudeAISubscriber() &&
|
||||
getFeatureValue_CACHED_MAY_BE_STALE('tengu_ccr_bridge', false)
|
||||
: false
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocking entitlement check for Remote Control.
|
||||
*
|
||||
* Returns cached `true` immediately (fast path). If the disk cache says
|
||||
* `false` or is missing, awaits GrowthBook init and fetches the fresh
|
||||
* server value (slow path, max ~5s), then writes it to disk.
|
||||
*
|
||||
* Use at entitlement gates where a stale `false` would unfairly block access.
|
||||
* For user-facing error paths, prefer `getBridgeDisabledReason()` which gives
|
||||
* a specific diagnostic. For render-body UI visibility checks, use
|
||||
* `isBridgeEnabled()` instead.
|
||||
*/
|
||||
export async function isBridgeEnabledBlocking(): Promise<boolean> {
|
||||
return feature('BRIDGE_MODE')
|
||||
? isClaudeAISubscriber() &&
|
||||
(await checkGate_CACHED_OR_BLOCKING('tengu_ccr_bridge'))
|
||||
: false
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostic message for why Remote Control is unavailable, or null if
|
||||
* it's enabled. Call this instead of a bare `isBridgeEnabledBlocking()`
|
||||
* check when you need to show the user an actionable error.
|
||||
*
|
||||
* The GrowthBook gate targets on organizationUUID, which comes from
|
||||
* config.oauthAccount — populated by /api/oauth/profile during login.
|
||||
* That endpoint requires the user:profile scope. Tokens without it
|
||||
* (setup-token, CLAUDE_CODE_OAUTH_TOKEN env var, or pre-scope-expansion
|
||||
* logins) leave oauthAccount unpopulated, so the gate falls back to
|
||||
* false and users see a dead-end "not enabled" message with no hint
|
||||
* that re-login would fix it. See CC-1165 / gh-33105.
|
||||
*/
|
||||
export async function getBridgeDisabledReason(): Promise<string | null> {
|
||||
if (feature('BRIDGE_MODE')) {
|
||||
if (!isClaudeAISubscriber()) {
|
||||
return 'Remote Control requires a claude.ai subscription. Run `claude auth login` to sign in with your claude.ai account.'
|
||||
}
|
||||
if (!hasProfileScope()) {
|
||||
return 'Remote Control requires a full-scope login token. Long-lived tokens (from `claude setup-token` or CLAUDE_CODE_OAUTH_TOKEN) are limited to inference-only for security reasons. Run `claude auth login` to use Remote Control.'
|
||||
}
|
||||
if (!getOauthAccountInfo()?.organizationUuid) {
|
||||
return 'Unable to determine your organization for Remote Control eligibility. Run `claude auth login` to refresh your account information.'
|
||||
}
|
||||
if (!(await checkGate_CACHED_OR_BLOCKING('tengu_ccr_bridge'))) {
|
||||
return 'Remote Control is not yet enabled for your account.'
|
||||
}
|
||||
return null
|
||||
}
|
||||
return 'Remote Control is not available in this build.'
|
||||
}
|
||||
|
||||
// try/catch: main.tsx:5698 calls isBridgeEnabled() while defining the Commander
|
||||
// program, before enableConfigs() runs. isClaudeAISubscriber() → getGlobalConfig()
|
||||
// throws "Config accessed before allowed" there. Pre-config, no OAuth token can
|
||||
// exist anyway — false is correct. Same swallow getFeatureValue_CACHED_MAY_BE_STALE
|
||||
// already does at growthbook.ts:775-780.
|
||||
function isClaudeAISubscriber(): boolean {
|
||||
try {
|
||||
return authModule.isClaudeAISubscriber()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
function hasProfileScope(): boolean {
|
||||
try {
|
||||
return authModule.hasProfileScope()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
function getOauthAccountInfo(): ReturnType<
|
||||
typeof authModule.getOauthAccountInfo
|
||||
> {
|
||||
try {
|
||||
return authModule.getOauthAccountInfo()
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime check for the env-less (v2) REPL bridge path.
|
||||
* Returns true when the GrowthBook flag `tengu_bridge_repl_v2` is enabled.
|
||||
*
|
||||
* This gates which implementation initReplBridge uses — NOT whether bridge
|
||||
* is available at all (see isBridgeEnabled above). Daemon/print paths stay
|
||||
* on the env-based implementation regardless of this gate.
|
||||
*/
|
||||
export function isEnvLessBridgeEnabled(): boolean {
|
||||
return feature('BRIDGE_MODE')
|
||||
? getFeatureValue_CACHED_MAY_BE_STALE('tengu_bridge_repl_v2', false)
|
||||
: false
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill-switch for the `cse_*` → `session_*` client-side retag shim.
|
||||
*
|
||||
* The shim exists because compat/convert.go:27 validates TagSession and the
|
||||
* claude.ai frontend routes on `session_*`, while v2 worker endpoints hand out
|
||||
* `cse_*`. Once the server tags by environment_kind and the frontend accepts
|
||||
* `cse_*` directly, flip this to false to make toCompatSessionId a no-op.
|
||||
* Defaults to true — the shim stays active until explicitly disabled.
|
||||
*/
|
||||
export function isCseShimEnabled(): boolean {
|
||||
return feature('BRIDGE_MODE')
|
||||
? getFeatureValue_CACHED_MAY_BE_STALE(
|
||||
'tengu_bridge_repl_v2_cse_shim_enabled',
|
||||
true,
|
||||
)
|
||||
: true
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an error message if the current CLI version is below the
|
||||
* minimum required for the v1 (env-based) Remote Control path, or null if the
|
||||
* version is fine. The v2 (env-less) path uses checkEnvLessBridgeMinVersion()
|
||||
* in envLessBridgeConfig.ts instead — the two implementations have independent
|
||||
* version floors.
|
||||
*
|
||||
* Uses cached (non-blocking) GrowthBook config. If GrowthBook hasn't
|
||||
* loaded yet, the default '0.0.0' means the check passes — a safe fallback.
|
||||
*/
|
||||
export function checkBridgeMinVersion(): string | null {
|
||||
// Positive pattern — see docs/feature-gating.md.
|
||||
// Negative pattern (if (!feature(...)) return) does not eliminate
|
||||
// inline string literals from external builds.
|
||||
if (feature('BRIDGE_MODE')) {
|
||||
const config = getDynamicConfig_CACHED_MAY_BE_STALE<{
|
||||
minVersion: string
|
||||
}>('tengu_bridge_min_version', { minVersion: '0.0.0' })
|
||||
if (config.minVersion && lt(MACRO.VERSION, config.minVersion)) {
|
||||
return `Your version of Claude Code (${MACRO.VERSION}) is too old for Remote Control.\nVersion ${config.minVersion} or higher is required. Run \`claude update\` to update.`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Default for remoteControlAtStartup when the user hasn't explicitly set it.
|
||||
* When the CCR_AUTO_CONNECT build flag is present (ant-only) and the
|
||||
* tengu_cobalt_harbor GrowthBook gate is on, all sessions connect to CCR by
|
||||
* default — the user can still opt out by setting remoteControlAtStartup=false
|
||||
* in config (explicit settings always win over this default).
|
||||
*
|
||||
* Defined here rather than in config.ts to avoid a direct
|
||||
* config.ts → growthbook.ts import cycle (growthbook.ts → user.ts → config.ts).
|
||||
*/
|
||||
export function getCcrAutoConnectDefault(): boolean {
|
||||
return feature('CCR_AUTO_CONNECT')
|
||||
? getFeatureValue_CACHED_MAY_BE_STALE('tengu_cobalt_harbor', false)
|
||||
: false
|
||||
}
|
||||
|
||||
/**
|
||||
* Opt-in CCR mirror mode — every local session spawns an outbound-only
|
||||
* Remote Control session that receives forwarded events. Separate from
|
||||
* getCcrAutoConnectDefault (bidirectional Remote Control). Env var wins for
|
||||
* local opt-in; GrowthBook controls rollout.
|
||||
*/
|
||||
export function isCcrMirrorEnabled(): boolean {
|
||||
return feature('CCR_MIRROR')
|
||||
? isEnvTruthy(process.env.CLAUDE_CODE_CCR_MIRROR) ||
|
||||
getFeatureValue_CACHED_MAY_BE_STALE('tengu_ccr_mirror', false)
|
||||
: false
|
||||
}
|
||||
2999
original-source-code/src/bridge/bridgeMain.ts
Normal file
2999
original-source-code/src/bridge/bridgeMain.ts
Normal file
File diff suppressed because it is too large
Load Diff
461
original-source-code/src/bridge/bridgeMessaging.ts
Normal file
461
original-source-code/src/bridge/bridgeMessaging.ts
Normal file
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* Shared transport-layer helpers for bridge message handling.
|
||||
*
|
||||
* Extracted from replBridge.ts so both the env-based core (initBridgeCore)
|
||||
* and the env-less core (initEnvLessBridgeCore) can use the same ingress
|
||||
* parsing, control-request handling, and echo-dedup machinery.
|
||||
*
|
||||
* Everything here is pure — no closure over bridge-specific state. All
|
||||
* collaborators (transport, sessionId, UUID sets, callbacks) are passed
|
||||
* as params.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'crypto'
|
||||
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
|
||||
import type {
|
||||
SDKControlRequest,
|
||||
SDKControlResponse,
|
||||
} from '../entrypoints/sdk/controlTypes.js'
|
||||
import type { SDKResultSuccess } from '../entrypoints/sdk/coreTypes.js'
|
||||
import { logEvent } from '../services/analytics/index.js'
|
||||
import { EMPTY_USAGE } from '../services/api/emptyUsage.js'
|
||||
import type { Message } from '../types/message.js'
|
||||
import { normalizeControlMessageKeys } from '../utils/controlMessageCompat.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { stripDisplayTagsAllowEmpty } from '../utils/displayTags.js'
|
||||
import { errorMessage } from '../utils/errors.js'
|
||||
import type { PermissionMode } from '../utils/permissions/PermissionMode.js'
|
||||
import { jsonParse } from '../utils/slowOperations.js'
|
||||
import type { ReplBridgeTransport } from './replBridgeTransport.js'
|
||||
|
||||
// ─── Type guards ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Type predicate for parsed WebSocket messages. SDKMessage is a
|
||||
* discriminated union on `type` — validating the discriminant is
|
||||
* sufficient for the predicate; callers narrow further via the union. */
|
||||
export function isSDKMessage(value: unknown): value is SDKMessage {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
'type' in value &&
|
||||
typeof value.type === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
/** Type predicate for control_response messages from the server. */
|
||||
export function isSDKControlResponse(
|
||||
value: unknown,
|
||||
): value is SDKControlResponse {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
'type' in value &&
|
||||
value.type === 'control_response' &&
|
||||
'response' in value
|
||||
)
|
||||
}
|
||||
|
||||
/** Type predicate for control_request messages from the server. */
|
||||
export function isSDKControlRequest(
|
||||
value: unknown,
|
||||
): value is SDKControlRequest {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
'type' in value &&
|
||||
value.type === 'control_request' &&
|
||||
'request_id' in value &&
|
||||
'request' in value
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* True for message types that should be forwarded to the bridge transport.
|
||||
* The server only wants user/assistant turns and slash-command system events;
|
||||
* everything else (tool_result, progress, etc.) is internal REPL chatter.
|
||||
*/
|
||||
export function isEligibleBridgeMessage(m: Message): boolean {
|
||||
// Virtual messages (REPL inner calls) are display-only — bridge/SDK
|
||||
// consumers see the REPL tool_use/result which summarizes the work.
|
||||
if ((m.type === 'user' || m.type === 'assistant') && m.isVirtual) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
m.type === 'user' ||
|
||||
m.type === 'assistant' ||
|
||||
(m.type === 'system' && m.subtype === 'local_command')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract title-worthy text from a Message for onUserMessage. Returns
|
||||
* undefined for messages that shouldn't title the session: non-user, meta
|
||||
* (nudges), tool results, compact summaries, non-human origins (task
|
||||
* notifications, channel messages), or pure display-tag content
|
||||
* (<ide_opened_file>, <session-start-hook>, etc.).
|
||||
*
|
||||
* Synthetic interrupts ([Request interrupted by user]) are NOT filtered here —
|
||||
* isSyntheticMessage lives in messages.ts (heavy import, pulls command
|
||||
* registry). The initialMessages path in initReplBridge checks it; the
|
||||
* writeMessages path reaching an interrupt as the *first* message is
|
||||
* implausible (an interrupt implies a prior prompt already flowed through).
|
||||
*/
|
||||
export function extractTitleText(m: Message): string | undefined {
|
||||
if (m.type !== 'user' || m.isMeta || m.toolUseResult || m.isCompactSummary)
|
||||
return undefined
|
||||
if (m.origin && m.origin.kind !== 'human') return undefined
|
||||
const content = m.message.content
|
||||
let raw: string | undefined
|
||||
if (typeof content === 'string') {
|
||||
raw = content
|
||||
} else {
|
||||
for (const block of content) {
|
||||
if (block.type === 'text') {
|
||||
raw = block.text
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!raw) return undefined
|
||||
const clean = stripDisplayTagsAllowEmpty(raw)
|
||||
return clean || undefined
|
||||
}
|
||||
|
||||
// ─── Ingress routing ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse an ingress WebSocket message and route it to the appropriate handler.
|
||||
* Ignores messages whose UUID is in recentPostedUUIDs (echoes of what we sent)
|
||||
* or in recentInboundUUIDs (re-deliveries we've already forwarded — e.g.
|
||||
* server replayed history after a transport swap lost the seq-num cursor).
|
||||
*/
|
||||
export function handleIngressMessage(
|
||||
data: string,
|
||||
recentPostedUUIDs: BoundedUUIDSet,
|
||||
recentInboundUUIDs: BoundedUUIDSet,
|
||||
onInboundMessage: ((msg: SDKMessage) => void | Promise<void>) | undefined,
|
||||
onPermissionResponse?: ((response: SDKControlResponse) => void) | undefined,
|
||||
onControlRequest?: ((request: SDKControlRequest) => void) | undefined,
|
||||
): void {
|
||||
try {
|
||||
const parsed: unknown = normalizeControlMessageKeys(jsonParse(data))
|
||||
|
||||
// control_response is not an SDKMessage — check before the type guard
|
||||
if (isSDKControlResponse(parsed)) {
|
||||
logForDebugging('[bridge:repl] Ingress message type=control_response')
|
||||
onPermissionResponse?.(parsed)
|
||||
return
|
||||
}
|
||||
|
||||
// control_request from the server (initialize, set_model, can_use_tool).
|
||||
// Must respond promptly or the server kills the WS (~10-14s timeout).
|
||||
if (isSDKControlRequest(parsed)) {
|
||||
logForDebugging(
|
||||
`[bridge:repl] Inbound control_request subtype=${parsed.request.subtype}`,
|
||||
)
|
||||
onControlRequest?.(parsed)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isSDKMessage(parsed)) return
|
||||
|
||||
// Check for UUID to detect echoes of our own messages
|
||||
const uuid =
|
||||
'uuid' in parsed && typeof parsed.uuid === 'string'
|
||||
? parsed.uuid
|
||||
: undefined
|
||||
|
||||
if (uuid && recentPostedUUIDs.has(uuid)) {
|
||||
logForDebugging(
|
||||
`[bridge:repl] Ignoring echo: type=${parsed.type} uuid=${uuid}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Defensive dedup: drop inbound prompts we've already forwarded. The
|
||||
// SSE seq-num carryover (lastTransportSequenceNum) is the primary fix
|
||||
// for history-replay; this catches edge cases where that negotiation
|
||||
// fails (server ignores from_sequence_num, transport died before
|
||||
// receiving any frames, etc).
|
||||
if (uuid && recentInboundUUIDs.has(uuid)) {
|
||||
logForDebugging(
|
||||
`[bridge:repl] Ignoring re-delivered inbound: type=${parsed.type} uuid=${uuid}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`[bridge:repl] Ingress message type=${parsed.type}${uuid ? ` uuid=${uuid}` : ''}`,
|
||||
)
|
||||
|
||||
if (parsed.type === 'user') {
|
||||
if (uuid) recentInboundUUIDs.add(uuid)
|
||||
logEvent('tengu_bridge_message_received', {
|
||||
is_repl: true,
|
||||
})
|
||||
// Fire-and-forget — handler may be async (attachment resolution).
|
||||
void onInboundMessage?.(parsed)
|
||||
} else {
|
||||
logForDebugging(
|
||||
`[bridge:repl] Ignoring non-user inbound message: type=${parsed.type}`,
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
logForDebugging(
|
||||
`[bridge:repl] Failed to parse ingress message: ${errorMessage(err)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Server-initiated control requests ───────────────────────────────────────
|
||||
|
||||
export type ServerControlRequestHandlers = {
|
||||
transport: ReplBridgeTransport | null
|
||||
sessionId: string
|
||||
/**
|
||||
* When true, all mutable requests (interrupt, set_model, set_permission_mode,
|
||||
* set_max_thinking_tokens) reply with an error instead of false-success.
|
||||
* initialize still replies success — the server kills the connection otherwise.
|
||||
* Used by the outbound-only bridge mode and the SDK's /bridge subpath so claude.ai sees a
|
||||
* proper error instead of "action succeeded but nothing happened locally".
|
||||
*/
|
||||
outboundOnly?: boolean
|
||||
onInterrupt?: () => void
|
||||
onSetModel?: (model: string | undefined) => void
|
||||
onSetMaxThinkingTokens?: (maxTokens: number | null) => void
|
||||
onSetPermissionMode?: (
|
||||
mode: PermissionMode,
|
||||
) => { ok: true } | { ok: false; error: string }
|
||||
}
|
||||
|
||||
const OUTBOUND_ONLY_ERROR =
|
||||
'This session is outbound-only. Enable Remote Control locally to allow inbound control.'
|
||||
|
||||
/**
|
||||
* Respond to inbound control_request messages from the server. The server
|
||||
* sends these for session lifecycle events (initialize, set_model) and
|
||||
* for turn-level coordination (interrupt, set_max_thinking_tokens). If we
|
||||
* don't respond, the server hangs and kills the WS after ~10-14s.
|
||||
*
|
||||
* Previously a closure inside initBridgeCore's onWorkReceived; now takes
|
||||
* collaborators as params so both cores can use it.
|
||||
*/
|
||||
export function handleServerControlRequest(
|
||||
request: SDKControlRequest,
|
||||
handlers: ServerControlRequestHandlers,
|
||||
): void {
|
||||
const {
|
||||
transport,
|
||||
sessionId,
|
||||
outboundOnly,
|
||||
onInterrupt,
|
||||
onSetModel,
|
||||
onSetMaxThinkingTokens,
|
||||
onSetPermissionMode,
|
||||
} = handlers
|
||||
if (!transport) {
|
||||
logForDebugging(
|
||||
'[bridge:repl] Cannot respond to control_request: transport not configured',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let response: SDKControlResponse
|
||||
|
||||
// Outbound-only: reply error for mutable requests so claude.ai doesn't show
|
||||
// false success. initialize must still succeed (server kills the connection
|
||||
// if it doesn't — see comment above).
|
||||
if (outboundOnly && request.request.subtype !== 'initialize') {
|
||||
response = {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'error',
|
||||
request_id: request.request_id,
|
||||
error: OUTBOUND_ONLY_ERROR,
|
||||
},
|
||||
}
|
||||
const event = { ...response, session_id: sessionId }
|
||||
void transport.write(event)
|
||||
logForDebugging(
|
||||
`[bridge:repl] Rejected ${request.request.subtype} (outbound-only) request_id=${request.request_id}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
switch (request.request.subtype) {
|
||||
case 'initialize':
|
||||
// Respond with minimal capabilities — the REPL handles
|
||||
// commands, models, and account info itself.
|
||||
response = {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: request.request_id,
|
||||
response: {
|
||||
commands: [],
|
||||
output_style: 'normal',
|
||||
available_output_styles: ['normal'],
|
||||
models: [],
|
||||
account: {},
|
||||
pid: process.pid,
|
||||
},
|
||||
},
|
||||
}
|
||||
break
|
||||
|
||||
case 'set_model':
|
||||
onSetModel?.(request.request.model)
|
||||
response = {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: request.request_id,
|
||||
},
|
||||
}
|
||||
break
|
||||
|
||||
case 'set_max_thinking_tokens':
|
||||
onSetMaxThinkingTokens?.(request.request.max_thinking_tokens)
|
||||
response = {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: request.request_id,
|
||||
},
|
||||
}
|
||||
break
|
||||
|
||||
case 'set_permission_mode': {
|
||||
// The callback returns a policy verdict so we can send an error
|
||||
// control_response without importing isAutoModeGateEnabled /
|
||||
// isBypassPermissionsModeDisabled here (bootstrap-isolation). If no
|
||||
// callback is registered (daemon context, which doesn't wire this —
|
||||
// see daemonBridge.ts), return an error verdict rather than a silent
|
||||
// false-success: the mode is never actually applied in that context,
|
||||
// so success would lie to the client.
|
||||
const verdict = onSetPermissionMode?.(request.request.mode) ?? {
|
||||
ok: false,
|
||||
error:
|
||||
'set_permission_mode is not supported in this context (onSetPermissionMode callback not registered)',
|
||||
}
|
||||
if (verdict.ok) {
|
||||
response = {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: request.request_id,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
response = {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'error',
|
||||
request_id: request.request_id,
|
||||
error: verdict.error,
|
||||
},
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'interrupt':
|
||||
onInterrupt?.()
|
||||
response = {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: request.request_id,
|
||||
},
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
// Unknown subtype — respond with error so the server doesn't
|
||||
// hang waiting for a reply that never comes.
|
||||
response = {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'error',
|
||||
request_id: request.request_id,
|
||||
error: `REPL bridge does not handle control_request subtype: ${request.request.subtype}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const event = { ...response, session_id: sessionId }
|
||||
void transport.write(event)
|
||||
logForDebugging(
|
||||
`[bridge:repl] Sent control_response for ${request.request.subtype} request_id=${request.request_id} result=${response.response.subtype}`,
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Result message (for session archival on teardown) ───────────────────────
|
||||
|
||||
/**
|
||||
* Build a minimal `SDKResultSuccess` message for session archival.
|
||||
* The server needs this event before a WS close to trigger archival.
|
||||
*/
|
||||
export function makeResultMessage(sessionId: string): SDKResultSuccess {
|
||||
return {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
duration_ms: 0,
|
||||
duration_api_ms: 0,
|
||||
is_error: false,
|
||||
num_turns: 0,
|
||||
result: '',
|
||||
stop_reason: null,
|
||||
total_cost_usd: 0,
|
||||
usage: { ...EMPTY_USAGE },
|
||||
modelUsage: {},
|
||||
permission_denials: [],
|
||||
session_id: sessionId,
|
||||
uuid: randomUUID(),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── BoundedUUIDSet (echo-dedup ring buffer) ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* FIFO-bounded set backed by a circular buffer. Evicts the oldest entry
|
||||
* when capacity is reached, keeping memory usage constant at O(capacity).
|
||||
*
|
||||
* Messages are added in chronological order, so evicted entries are always
|
||||
* the oldest. The caller relies on external ordering (the hook's
|
||||
* lastWrittenIndexRef) as the primary dedup — this set is a secondary
|
||||
* safety net for echo filtering and race-condition dedup.
|
||||
*/
|
||||
export class BoundedUUIDSet {
|
||||
private readonly capacity: number
|
||||
private readonly ring: (string | undefined)[]
|
||||
private readonly set = new Set<string>()
|
||||
private writeIdx = 0
|
||||
|
||||
constructor(capacity: number) {
|
||||
this.capacity = capacity
|
||||
this.ring = new Array<string | undefined>(capacity)
|
||||
}
|
||||
|
||||
add(uuid: string): void {
|
||||
if (this.set.has(uuid)) return
|
||||
// Evict the entry at the current write position (if occupied)
|
||||
const evicted = this.ring[this.writeIdx]
|
||||
if (evicted !== undefined) {
|
||||
this.set.delete(evicted)
|
||||
}
|
||||
this.ring[this.writeIdx] = uuid
|
||||
this.set.add(uuid)
|
||||
this.writeIdx = (this.writeIdx + 1) % this.capacity
|
||||
}
|
||||
|
||||
has(uuid: string): boolean {
|
||||
return this.set.has(uuid)
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.set.clear()
|
||||
this.ring.fill(undefined)
|
||||
this.writeIdx = 0
|
||||
}
|
||||
}
|
||||
43
original-source-code/src/bridge/bridgePermissionCallbacks.ts
Normal file
43
original-source-code/src/bridge/bridgePermissionCallbacks.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { PermissionUpdate } from '../utils/permissions/PermissionUpdateSchema.js'
|
||||
|
||||
type BridgePermissionResponse = {
|
||||
behavior: 'allow' | 'deny'
|
||||
updatedInput?: Record<string, unknown>
|
||||
updatedPermissions?: PermissionUpdate[]
|
||||
message?: string
|
||||
}
|
||||
|
||||
type BridgePermissionCallbacks = {
|
||||
sendRequest(
|
||||
requestId: string,
|
||||
toolName: string,
|
||||
input: Record<string, unknown>,
|
||||
toolUseId: string,
|
||||
description: string,
|
||||
permissionSuggestions?: PermissionUpdate[],
|
||||
blockedPath?: string,
|
||||
): void
|
||||
sendResponse(requestId: string, response: BridgePermissionResponse): void
|
||||
/** Cancel a pending control_request so the web app can dismiss its prompt. */
|
||||
cancelRequest(requestId: string): void
|
||||
onResponse(
|
||||
requestId: string,
|
||||
handler: (response: BridgePermissionResponse) => void,
|
||||
): () => void // returns unsubscribe
|
||||
}
|
||||
|
||||
/** Type predicate for validating a parsed control_response payload
|
||||
* as a BridgePermissionResponse. Checks the required `behavior`
|
||||
* discriminant rather than using an unsafe `as` cast. */
|
||||
function isBridgePermissionResponse(
|
||||
value: unknown,
|
||||
): value is BridgePermissionResponse {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
return (
|
||||
'behavior' in value &&
|
||||
(value.behavior === 'allow' || value.behavior === 'deny')
|
||||
)
|
||||
}
|
||||
|
||||
export { isBridgePermissionResponse }
|
||||
export type { BridgePermissionCallbacks, BridgePermissionResponse }
|
||||
210
original-source-code/src/bridge/bridgePointer.ts
Normal file
210
original-source-code/src/bridge/bridgePointer.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { mkdir, readFile, stat, unlink, writeFile } from 'fs/promises'
|
||||
import { dirname, join } from 'path'
|
||||
import { z } from 'zod/v4'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { isENOENT } from '../utils/errors.js'
|
||||
import { getWorktreePathsPortable } from '../utils/getWorktreePathsPortable.js'
|
||||
import { lazySchema } from '../utils/lazySchema.js'
|
||||
import {
|
||||
getProjectsDir,
|
||||
sanitizePath,
|
||||
} from '../utils/sessionStoragePortable.js'
|
||||
import { jsonParse, jsonStringify } from '../utils/slowOperations.js'
|
||||
|
||||
/**
|
||||
* Upper bound on worktree fanout. git worktree list is naturally bounded
|
||||
* (50 is a LOT), but this caps the parallel stat() burst and guards against
|
||||
* pathological setups. Above this, --continue falls back to current-dir-only.
|
||||
*/
|
||||
const MAX_WORKTREE_FANOUT = 50
|
||||
|
||||
/**
|
||||
* Crash-recovery pointer for Remote Control sessions.
|
||||
*
|
||||
* Written immediately after a bridge session is created, periodically
|
||||
* refreshed during the session, and cleared on clean shutdown. If the
|
||||
* process dies unclean (crash, kill -9, terminal closed), the pointer
|
||||
* persists. On next startup, `claude remote-control` detects it and offers
|
||||
* to resume via the --session-id flow from #20460.
|
||||
*
|
||||
* Staleness is checked against the file's mtime (not an embedded timestamp)
|
||||
* so that a periodic re-write with the same content serves as a refresh —
|
||||
* matches the backend's rolling BRIDGE_LAST_POLL_TTL (4h) semantics. A
|
||||
* bridge that's been polling for 5+ hours and then crashes still has a
|
||||
* fresh pointer as long as the refresh ran within the window.
|
||||
*
|
||||
* Scoped per working directory (alongside transcript JSONL files) so two
|
||||
* concurrent bridges in different repos don't clobber each other.
|
||||
*/
|
||||
|
||||
export const BRIDGE_POINTER_TTL_MS = 4 * 60 * 60 * 1000
|
||||
|
||||
const BridgePointerSchema = lazySchema(() =>
|
||||
z.object({
|
||||
sessionId: z.string(),
|
||||
environmentId: z.string(),
|
||||
source: z.enum(['standalone', 'repl']),
|
||||
}),
|
||||
)
|
||||
|
||||
export type BridgePointer = z.infer<ReturnType<typeof BridgePointerSchema>>
|
||||
|
||||
export function getBridgePointerPath(dir: string): string {
|
||||
return join(getProjectsDir(), sanitizePath(dir), 'bridge-pointer.json')
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the pointer. Also used to refresh mtime during long sessions —
|
||||
* calling with the same IDs is a cheap no-content-change write that bumps
|
||||
* the staleness clock. Best-effort — a crash-recovery file must never
|
||||
* itself cause a crash. Logs and swallows on error.
|
||||
*/
|
||||
export async function writeBridgePointer(
|
||||
dir: string,
|
||||
pointer: BridgePointer,
|
||||
): Promise<void> {
|
||||
const path = getBridgePointerPath(dir)
|
||||
try {
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(path, jsonStringify(pointer), 'utf8')
|
||||
logForDebugging(`[bridge:pointer] wrote ${path}`)
|
||||
} catch (err: unknown) {
|
||||
logForDebugging(`[bridge:pointer] write failed: ${err}`, { level: 'warn' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the pointer and its age (ms since last write). Operates directly
|
||||
* and handles errors — no existence check (CLAUDE.md TOCTOU rule). Returns
|
||||
* null on any failure: missing file, corrupted JSON, schema mismatch, or
|
||||
* stale (mtime > 4h ago). Stale/invalid pointers are deleted so they don't
|
||||
* keep re-prompting after the backend has already GC'd the env.
|
||||
*/
|
||||
export async function readBridgePointer(
|
||||
dir: string,
|
||||
): Promise<(BridgePointer & { ageMs: number }) | null> {
|
||||
const path = getBridgePointerPath(dir)
|
||||
let raw: string
|
||||
let mtimeMs: number
|
||||
try {
|
||||
// stat for mtime (staleness anchor), then read. Two syscalls, but both
|
||||
// are needed — mtime IS the data we return, not a TOCTOU guard.
|
||||
mtimeMs = (await stat(path)).mtimeMs
|
||||
raw = await readFile(path, 'utf8')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = BridgePointerSchema().safeParse(safeJsonParse(raw))
|
||||
if (!parsed.success) {
|
||||
logForDebugging(`[bridge:pointer] invalid schema, clearing: ${path}`)
|
||||
await clearBridgePointer(dir)
|
||||
return null
|
||||
}
|
||||
|
||||
const ageMs = Math.max(0, Date.now() - mtimeMs)
|
||||
if (ageMs > BRIDGE_POINTER_TTL_MS) {
|
||||
logForDebugging(`[bridge:pointer] stale (>4h mtime), clearing: ${path}`)
|
||||
await clearBridgePointer(dir)
|
||||
return null
|
||||
}
|
||||
|
||||
return { ...parsed.data, ageMs }
|
||||
}
|
||||
|
||||
/**
|
||||
* Worktree-aware read for `--continue`. The REPL bridge writes its pointer
|
||||
* to `getOriginalCwd()` which EnterWorktreeTool/activeWorktreeSession can
|
||||
* mutate to a worktree path — but `claude remote-control --continue` runs
|
||||
* with `resolve('.')` = shell CWD. This fans out across git worktree
|
||||
* siblings to find the freshest pointer, matching /resume's semantics.
|
||||
*
|
||||
* Fast path: checks `dir` first. Only shells out to `git worktree list` if
|
||||
* that misses — the common case (pointer in launch dir) is one stat, zero
|
||||
* exec. Fanout reads run in parallel; capped at MAX_WORKTREE_FANOUT.
|
||||
*
|
||||
* Returns the pointer AND the dir it was found in, so the caller can clear
|
||||
* the right file on resume failure.
|
||||
*/
|
||||
export async function readBridgePointerAcrossWorktrees(
|
||||
dir: string,
|
||||
): Promise<{ pointer: BridgePointer & { ageMs: number }; dir: string } | null> {
|
||||
// Fast path: current dir. Covers standalone bridge (always matches) and
|
||||
// REPL bridge when no worktree mutation happened.
|
||||
const here = await readBridgePointer(dir)
|
||||
if (here) {
|
||||
return { pointer: here, dir }
|
||||
}
|
||||
|
||||
// Fanout: scan worktree siblings. getWorktreePathsPortable has a 5s
|
||||
// timeout and returns [] on any error (not a git repo, git not installed).
|
||||
const worktrees = await getWorktreePathsPortable(dir)
|
||||
if (worktrees.length <= 1) return null
|
||||
if (worktrees.length > MAX_WORKTREE_FANOUT) {
|
||||
logForDebugging(
|
||||
`[bridge:pointer] ${worktrees.length} worktrees exceeds fanout cap ${MAX_WORKTREE_FANOUT}, skipping`,
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
// Dedupe against `dir` so we don't re-stat it. sanitizePath normalizes
|
||||
// case/separators so worktree-list output matches our fast-path key even
|
||||
// on Windows where git may emit C:/ vs stored c:/.
|
||||
const dirKey = sanitizePath(dir)
|
||||
const candidates = worktrees.filter(wt => sanitizePath(wt) !== dirKey)
|
||||
|
||||
// Parallel stat+read. Each readBridgePointer is a stat() that ENOENTs
|
||||
// for worktrees with no pointer (cheap) plus a ~100-byte read for the
|
||||
// rare ones that have one. Promise.all → latency ≈ slowest single stat.
|
||||
const results = await Promise.all(
|
||||
candidates.map(async wt => {
|
||||
const p = await readBridgePointer(wt)
|
||||
return p ? { pointer: p, dir: wt } : null
|
||||
}),
|
||||
)
|
||||
|
||||
// Pick freshest (lowest ageMs). The pointer stores environmentId so
|
||||
// resume reconnects to the right env regardless of which worktree
|
||||
// --continue was invoked from.
|
||||
let freshest: {
|
||||
pointer: BridgePointer & { ageMs: number }
|
||||
dir: string
|
||||
} | null = null
|
||||
for (const r of results) {
|
||||
if (r && (!freshest || r.pointer.ageMs < freshest.pointer.ageMs)) {
|
||||
freshest = r
|
||||
}
|
||||
}
|
||||
if (freshest) {
|
||||
logForDebugging(
|
||||
`[bridge:pointer] fanout found pointer in worktree ${freshest.dir} (ageMs=${freshest.pointer.ageMs})`,
|
||||
)
|
||||
}
|
||||
return freshest
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the pointer. Idempotent — ENOENT is expected when the process
|
||||
* shut down clean previously.
|
||||
*/
|
||||
export async function clearBridgePointer(dir: string): Promise<void> {
|
||||
const path = getBridgePointerPath(dir)
|
||||
try {
|
||||
await unlink(path)
|
||||
logForDebugging(`[bridge:pointer] cleared ${path}`)
|
||||
} catch (err: unknown) {
|
||||
if (!isENOENT(err)) {
|
||||
logForDebugging(`[bridge:pointer] clear failed: ${err}`, {
|
||||
level: 'warn',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function safeJsonParse(raw: string): unknown {
|
||||
try {
|
||||
return jsonParse(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
163
original-source-code/src/bridge/bridgeStatusUtil.ts
Normal file
163
original-source-code/src/bridge/bridgeStatusUtil.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import {
|
||||
getClaudeAiBaseUrl,
|
||||
getRemoteSessionUrl,
|
||||
} from '../constants/product.js'
|
||||
import { stringWidth } from '../ink/stringWidth.js'
|
||||
import { formatDuration, truncateToWidth } from '../utils/format.js'
|
||||
import { getGraphemeSegmenter } from '../utils/intl.js'
|
||||
|
||||
/** Bridge status state machine states. */
|
||||
export type StatusState =
|
||||
| 'idle'
|
||||
| 'attached'
|
||||
| 'titled'
|
||||
| 'reconnecting'
|
||||
| 'failed'
|
||||
|
||||
/** How long a tool activity line stays visible after last tool_start (ms). */
|
||||
export const TOOL_DISPLAY_EXPIRY_MS = 30_000
|
||||
|
||||
/** Interval for the shimmer animation tick (ms). */
|
||||
export const SHIMMER_INTERVAL_MS = 150
|
||||
|
||||
export function timestamp(): string {
|
||||
const now = new Date()
|
||||
const h = String(now.getHours()).padStart(2, '0')
|
||||
const m = String(now.getMinutes()).padStart(2, '0')
|
||||
const s = String(now.getSeconds()).padStart(2, '0')
|
||||
return `${h}:${m}:${s}`
|
||||
}
|
||||
|
||||
export { formatDuration, truncateToWidth as truncatePrompt }
|
||||
|
||||
/** Abbreviate a tool activity summary for the trail display. */
|
||||
export function abbreviateActivity(summary: string): string {
|
||||
return truncateToWidth(summary, 30)
|
||||
}
|
||||
|
||||
/** Build the connect URL shown when the bridge is idle. */
|
||||
export function buildBridgeConnectUrl(
|
||||
environmentId: string,
|
||||
ingressUrl?: string,
|
||||
): string {
|
||||
const baseUrl = getClaudeAiBaseUrl(undefined, ingressUrl)
|
||||
return `${baseUrl}/code?bridge=${environmentId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the session URL shown when a session is attached. Delegates to
|
||||
* getRemoteSessionUrl for the cse_→session_ prefix translation, then appends
|
||||
* the v1-specific ?bridge={environmentId} query.
|
||||
*/
|
||||
export function buildBridgeSessionUrl(
|
||||
sessionId: string,
|
||||
environmentId: string,
|
||||
ingressUrl?: string,
|
||||
): string {
|
||||
return `${getRemoteSessionUrl(sessionId, ingressUrl)}?bridge=${environmentId}`
|
||||
}
|
||||
|
||||
/** Compute the glimmer index for a reverse-sweep shimmer animation. */
|
||||
export function computeGlimmerIndex(
|
||||
tick: number,
|
||||
messageWidth: number,
|
||||
): number {
|
||||
const cycleLength = messageWidth + 20
|
||||
return messageWidth + 10 - (tick % cycleLength)
|
||||
}
|
||||
|
||||
/**
|
||||
* Split text into three segments by visual column position for shimmer rendering.
|
||||
*
|
||||
* Uses grapheme segmentation and `stringWidth` so the split is correct for
|
||||
* multi-byte characters, emoji, and CJK glyphs.
|
||||
*
|
||||
* Returns `{ before, shimmer, after }` strings. Both renderers (chalk in
|
||||
* bridgeUI.ts and React/Ink in bridge.tsx) apply their own coloring to
|
||||
* these segments.
|
||||
*/
|
||||
export function computeShimmerSegments(
|
||||
text: string,
|
||||
glimmerIndex: number,
|
||||
): { before: string; shimmer: string; after: string } {
|
||||
const messageWidth = stringWidth(text)
|
||||
const shimmerStart = glimmerIndex - 1
|
||||
const shimmerEnd = glimmerIndex + 1
|
||||
|
||||
// When shimmer is offscreen, return all text as "before"
|
||||
if (shimmerStart >= messageWidth || shimmerEnd < 0) {
|
||||
return { before: text, shimmer: '', after: '' }
|
||||
}
|
||||
|
||||
// Split into at most 3 segments by visual column position
|
||||
const clampedStart = Math.max(0, shimmerStart)
|
||||
let colPos = 0
|
||||
let before = ''
|
||||
let shimmer = ''
|
||||
let after = ''
|
||||
for (const { segment } of getGraphemeSegmenter().segment(text)) {
|
||||
const segWidth = stringWidth(segment)
|
||||
if (colPos + segWidth <= clampedStart) {
|
||||
before += segment
|
||||
} else if (colPos > shimmerEnd) {
|
||||
after += segment
|
||||
} else {
|
||||
shimmer += segment
|
||||
}
|
||||
colPos += segWidth
|
||||
}
|
||||
|
||||
return { before, shimmer, after }
|
||||
}
|
||||
|
||||
/** Computed bridge status label and color from connection state. */
|
||||
export type BridgeStatusInfo = {
|
||||
label:
|
||||
| 'Remote Control failed'
|
||||
| 'Remote Control reconnecting'
|
||||
| 'Remote Control active'
|
||||
| 'Remote Control connecting\u2026'
|
||||
color: 'error' | 'warning' | 'success'
|
||||
}
|
||||
|
||||
/** Derive a status label and color from the bridge connection state. */
|
||||
export function getBridgeStatus({
|
||||
error,
|
||||
connected,
|
||||
sessionActive,
|
||||
reconnecting,
|
||||
}: {
|
||||
error: string | undefined
|
||||
connected: boolean
|
||||
sessionActive: boolean
|
||||
reconnecting: boolean
|
||||
}): BridgeStatusInfo {
|
||||
if (error) return { label: 'Remote Control failed', color: 'error' }
|
||||
if (reconnecting)
|
||||
return { label: 'Remote Control reconnecting', color: 'warning' }
|
||||
if (sessionActive || connected)
|
||||
return { label: 'Remote Control active', color: 'success' }
|
||||
return { label: 'Remote Control connecting\u2026', color: 'warning' }
|
||||
}
|
||||
|
||||
/** Footer text shown when bridge is idle (Ready state). */
|
||||
export function buildIdleFooterText(url: string): string {
|
||||
return `Code everywhere with the Claude app or ${url}`
|
||||
}
|
||||
|
||||
/** Footer text shown when a session is active (Connected state). */
|
||||
export function buildActiveFooterText(url: string): string {
|
||||
return `Continue coding in the Claude app or ${url}`
|
||||
}
|
||||
|
||||
/** Footer text shown when the bridge has failed. */
|
||||
export const FAILED_FOOTER_TEXT = 'Something went wrong, please try again'
|
||||
|
||||
/**
|
||||
* Wrap text in an OSC 8 terminal hyperlink. Zero visual width for layout purposes.
|
||||
* strip-ansi (used by stringWidth) correctly strips these sequences, so
|
||||
* countVisualLines in bridgeUI.ts remains accurate.
|
||||
*/
|
||||
export function wrapWithOsc8Link(text: string, url: string): string {
|
||||
return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`
|
||||
}
|
||||
530
original-source-code/src/bridge/bridgeUI.ts
Normal file
530
original-source-code/src/bridge/bridgeUI.ts
Normal file
@@ -0,0 +1,530 @@
|
||||
import chalk from 'chalk'
|
||||
import { toString as qrToString } from 'qrcode'
|
||||
import {
|
||||
BRIDGE_FAILED_INDICATOR,
|
||||
BRIDGE_READY_INDICATOR,
|
||||
BRIDGE_SPINNER_FRAMES,
|
||||
} from '../constants/figures.js'
|
||||
import { stringWidth } from '../ink/stringWidth.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import {
|
||||
buildActiveFooterText,
|
||||
buildBridgeConnectUrl,
|
||||
buildBridgeSessionUrl,
|
||||
buildIdleFooterText,
|
||||
FAILED_FOOTER_TEXT,
|
||||
formatDuration,
|
||||
type StatusState,
|
||||
TOOL_DISPLAY_EXPIRY_MS,
|
||||
timestamp,
|
||||
truncatePrompt,
|
||||
wrapWithOsc8Link,
|
||||
} from './bridgeStatusUtil.js'
|
||||
import type {
|
||||
BridgeConfig,
|
||||
BridgeLogger,
|
||||
SessionActivity,
|
||||
SpawnMode,
|
||||
} from './types.js'
|
||||
|
||||
const QR_OPTIONS = {
|
||||
type: 'utf8' as const,
|
||||
errorCorrectionLevel: 'L' as const,
|
||||
small: true,
|
||||
}
|
||||
|
||||
/** Generate a QR code and return its lines. */
|
||||
async function generateQr(url: string): Promise<string[]> {
|
||||
const qr = await qrToString(url, QR_OPTIONS)
|
||||
return qr.split('\n').filter((line: string) => line.length > 0)
|
||||
}
|
||||
|
||||
export function createBridgeLogger(options: {
|
||||
verbose: boolean
|
||||
write?: (s: string) => void
|
||||
}): BridgeLogger {
|
||||
const write = options.write ?? ((s: string) => process.stdout.write(s))
|
||||
const verbose = options.verbose
|
||||
|
||||
// Track how many status lines are currently displayed at the bottom
|
||||
let statusLineCount = 0
|
||||
|
||||
// Status state machine
|
||||
let currentState: StatusState = 'idle'
|
||||
let currentStateText = 'Ready'
|
||||
let repoName = ''
|
||||
let branch = ''
|
||||
let debugLogPath = ''
|
||||
|
||||
// Connect URL (built in printBanner with correct base for staging/prod)
|
||||
let connectUrl = ''
|
||||
let cachedIngressUrl = ''
|
||||
let cachedEnvironmentId = ''
|
||||
let activeSessionUrl: string | null = null
|
||||
|
||||
// QR code lines for the current URL
|
||||
let qrLines: string[] = []
|
||||
let qrVisible = false
|
||||
|
||||
// Tool activity for the second status line
|
||||
let lastToolSummary: string | null = null
|
||||
let lastToolTime = 0
|
||||
|
||||
// Session count indicator (shown when multi-session mode is enabled)
|
||||
let sessionActive = 0
|
||||
let sessionMax = 1
|
||||
// Spawn mode shown in the session-count line + gates the `w` hint
|
||||
let spawnModeDisplay: 'same-dir' | 'worktree' | null = null
|
||||
let spawnMode: SpawnMode = 'single-session'
|
||||
|
||||
// Per-session display info for the multi-session bullet list (keyed by compat sessionId)
|
||||
const sessionDisplayInfo = new Map<
|
||||
string,
|
||||
{ title?: string; url: string; activity?: SessionActivity }
|
||||
>()
|
||||
|
||||
// Connecting spinner state
|
||||
let connectingTimer: ReturnType<typeof setInterval> | null = null
|
||||
let connectingTick = 0
|
||||
|
||||
/**
|
||||
* Count how many visual terminal rows a string occupies, accounting for
|
||||
* line wrapping. Each `\n` is one row, and content wider than the terminal
|
||||
* wraps to additional rows.
|
||||
*/
|
||||
function countVisualLines(text: string): number {
|
||||
// eslint-disable-next-line custom-rules/prefer-use-terminal-size
|
||||
const cols = process.stdout.columns || 80 // non-React CLI context
|
||||
let count = 0
|
||||
// Split on newlines to get logical lines
|
||||
for (const logical of text.split('\n')) {
|
||||
if (logical.length === 0) {
|
||||
// Empty segment between consecutive \n — counts as 1 row
|
||||
count++
|
||||
continue
|
||||
}
|
||||
const width = stringWidth(logical)
|
||||
count += Math.max(1, Math.ceil(width / cols))
|
||||
}
|
||||
// The trailing \n in "line\n" produces an empty last element — don't count it
|
||||
// because the cursor sits at the start of the next line, not a new visual row.
|
||||
if (text.endsWith('\n')) {
|
||||
count--
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
/** Write a status line and track its visual line count. */
|
||||
function writeStatus(text: string): void {
|
||||
write(text)
|
||||
statusLineCount += countVisualLines(text)
|
||||
}
|
||||
|
||||
/** Clear any currently displayed status lines. */
|
||||
function clearStatusLines(): void {
|
||||
if (statusLineCount <= 0) return
|
||||
logForDebugging(`[bridge:ui] clearStatusLines count=${statusLineCount}`)
|
||||
// Move cursor up to the start of the status block, then erase everything below
|
||||
write(`\x1b[${statusLineCount}A`) // cursor up N lines
|
||||
write('\x1b[J') // erase from cursor to end of screen
|
||||
statusLineCount = 0
|
||||
}
|
||||
|
||||
/** Print a permanent log line, clearing status first and restoring after. */
|
||||
function printLog(line: string): void {
|
||||
clearStatusLines()
|
||||
write(line)
|
||||
}
|
||||
|
||||
/** Regenerate the QR code with the given URL. */
|
||||
function regenerateQr(url: string): void {
|
||||
generateQr(url)
|
||||
.then(lines => {
|
||||
qrLines = lines
|
||||
renderStatusLine()
|
||||
})
|
||||
.catch(e => {
|
||||
logForDebugging(`QR code generation failed: ${e}`, { level: 'error' })
|
||||
})
|
||||
}
|
||||
|
||||
/** Render the connecting spinner line (shown before first updateIdleStatus). */
|
||||
function renderConnectingLine(): void {
|
||||
clearStatusLines()
|
||||
|
||||
const frame =
|
||||
BRIDGE_SPINNER_FRAMES[connectingTick % BRIDGE_SPINNER_FRAMES.length]!
|
||||
let suffix = ''
|
||||
if (repoName) {
|
||||
suffix += chalk.dim(' \u00b7 ') + chalk.dim(repoName)
|
||||
}
|
||||
if (branch) {
|
||||
suffix += chalk.dim(' \u00b7 ') + chalk.dim(branch)
|
||||
}
|
||||
writeStatus(
|
||||
`${chalk.yellow(frame)} ${chalk.yellow('Connecting')}${suffix}\n`,
|
||||
)
|
||||
}
|
||||
|
||||
/** Start the connecting spinner. Stopped by first updateIdleStatus(). */
|
||||
function startConnecting(): void {
|
||||
stopConnecting()
|
||||
renderConnectingLine()
|
||||
connectingTimer = setInterval(() => {
|
||||
connectingTick++
|
||||
renderConnectingLine()
|
||||
}, 150)
|
||||
}
|
||||
|
||||
/** Stop the connecting spinner. */
|
||||
function stopConnecting(): void {
|
||||
if (connectingTimer) {
|
||||
clearInterval(connectingTimer)
|
||||
connectingTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Render and write the current status lines based on state. */
|
||||
function renderStatusLine(): void {
|
||||
if (currentState === 'reconnecting' || currentState === 'failed') {
|
||||
// These states are handled separately (updateReconnectingStatus /
|
||||
// updateFailedStatus). Return before clearing so callers like toggleQr
|
||||
// and setSpawnModeDisplay don't blank the display during these states.
|
||||
return
|
||||
}
|
||||
|
||||
clearStatusLines()
|
||||
|
||||
const isIdle = currentState === 'idle'
|
||||
|
||||
// QR code above the status line
|
||||
if (qrVisible) {
|
||||
for (const line of qrLines) {
|
||||
writeStatus(`${chalk.dim(line)}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine indicator and colors based on state
|
||||
const indicator = BRIDGE_READY_INDICATOR
|
||||
const indicatorColor = isIdle ? chalk.green : chalk.cyan
|
||||
const baseColor = isIdle ? chalk.green : chalk.cyan
|
||||
const stateText = baseColor(currentStateText)
|
||||
|
||||
// Build the suffix with repo and branch
|
||||
let suffix = ''
|
||||
if (repoName) {
|
||||
suffix += chalk.dim(' \u00b7 ') + chalk.dim(repoName)
|
||||
}
|
||||
// In worktree mode each session gets its own branch, so showing the
|
||||
// bridge's branch would be misleading.
|
||||
if (branch && spawnMode !== 'worktree') {
|
||||
suffix += chalk.dim(' \u00b7 ') + chalk.dim(branch)
|
||||
}
|
||||
|
||||
if (process.env.USER_TYPE === 'ant' && debugLogPath) {
|
||||
writeStatus(
|
||||
`${chalk.yellow('[ANT-ONLY] Logs:')} ${chalk.dim(debugLogPath)}\n`,
|
||||
)
|
||||
}
|
||||
writeStatus(`${indicatorColor(indicator)} ${stateText}${suffix}\n`)
|
||||
|
||||
// Session count and per-session list (multi-session mode only)
|
||||
if (sessionMax > 1) {
|
||||
const modeHint =
|
||||
spawnMode === 'worktree'
|
||||
? 'New sessions will be created in an isolated worktree'
|
||||
: 'New sessions will be created in the current directory'
|
||||
writeStatus(
|
||||
` ${chalk.dim(`Capacity: ${sessionActive}/${sessionMax} \u00b7 ${modeHint}`)}\n`,
|
||||
)
|
||||
for (const [, info] of sessionDisplayInfo) {
|
||||
const titleText = info.title
|
||||
? truncatePrompt(info.title, 35)
|
||||
: chalk.dim('Attached')
|
||||
const titleLinked = wrapWithOsc8Link(titleText, info.url)
|
||||
const act = info.activity
|
||||
const showAct = act && act.type !== 'result' && act.type !== 'error'
|
||||
const actText = showAct
|
||||
? chalk.dim(` ${truncatePrompt(act.summary, 40)}`)
|
||||
: ''
|
||||
writeStatus(` ${titleLinked}${actText}
|
||||
`)
|
||||
}
|
||||
}
|
||||
|
||||
// Mode line for spawn modes with a single slot (or true single-session mode)
|
||||
if (sessionMax === 1) {
|
||||
const modeText =
|
||||
spawnMode === 'single-session'
|
||||
? 'Single session \u00b7 exits when complete'
|
||||
: spawnMode === 'worktree'
|
||||
? `Capacity: ${sessionActive}/1 \u00b7 New sessions will be created in an isolated worktree`
|
||||
: `Capacity: ${sessionActive}/1 \u00b7 New sessions will be created in the current directory`
|
||||
writeStatus(` ${chalk.dim(modeText)}\n`)
|
||||
}
|
||||
|
||||
// Tool activity line for single-session mode
|
||||
if (
|
||||
sessionMax === 1 &&
|
||||
!isIdle &&
|
||||
lastToolSummary &&
|
||||
Date.now() - lastToolTime < TOOL_DISPLAY_EXPIRY_MS
|
||||
) {
|
||||
writeStatus(` ${chalk.dim(truncatePrompt(lastToolSummary, 60))}\n`)
|
||||
}
|
||||
|
||||
// Blank line separator before footer
|
||||
const url = activeSessionUrl ?? connectUrl
|
||||
if (url) {
|
||||
writeStatus('\n')
|
||||
const footerText = isIdle
|
||||
? buildIdleFooterText(url)
|
||||
: buildActiveFooterText(url)
|
||||
const qrHint = qrVisible
|
||||
? chalk.dim.italic('space to hide QR code')
|
||||
: chalk.dim.italic('space to show QR code')
|
||||
const toggleHint = spawnModeDisplay
|
||||
? chalk.dim.italic(' \u00b7 w to toggle spawn mode')
|
||||
: ''
|
||||
writeStatus(`${chalk.dim(footerText)}\n`)
|
||||
writeStatus(`${qrHint}${toggleHint}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
printBanner(config: BridgeConfig, environmentId: string): void {
|
||||
cachedIngressUrl = config.sessionIngressUrl
|
||||
cachedEnvironmentId = environmentId
|
||||
connectUrl = buildBridgeConnectUrl(environmentId, cachedIngressUrl)
|
||||
regenerateQr(connectUrl)
|
||||
|
||||
if (verbose) {
|
||||
write(chalk.dim(`Remote Control`) + ` v${MACRO.VERSION}\n`)
|
||||
}
|
||||
if (verbose) {
|
||||
if (config.spawnMode !== 'single-session') {
|
||||
write(chalk.dim(`Spawn mode: `) + `${config.spawnMode}\n`)
|
||||
write(
|
||||
chalk.dim(`Max concurrent sessions: `) + `${config.maxSessions}\n`,
|
||||
)
|
||||
}
|
||||
write(chalk.dim(`Environment ID: `) + `${environmentId}\n`)
|
||||
}
|
||||
if (config.sandbox) {
|
||||
write(chalk.dim(`Sandbox: `) + `${chalk.green('Enabled')}\n`)
|
||||
}
|
||||
write('\n')
|
||||
|
||||
// Start connecting spinner — first updateIdleStatus() will stop it
|
||||
startConnecting()
|
||||
},
|
||||
|
||||
logSessionStart(sessionId: string, prompt: string): void {
|
||||
if (verbose) {
|
||||
const short = truncatePrompt(prompt, 80)
|
||||
printLog(
|
||||
chalk.dim(`[${timestamp()}]`) +
|
||||
` Session started: ${chalk.white(`"${short}"`)} (${chalk.dim(sessionId)})\n`,
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
logSessionComplete(sessionId: string, durationMs: number): void {
|
||||
printLog(
|
||||
chalk.dim(`[${timestamp()}]`) +
|
||||
` Session ${chalk.green('completed')} (${formatDuration(durationMs)}) ${chalk.dim(sessionId)}\n`,
|
||||
)
|
||||
},
|
||||
|
||||
logSessionFailed(sessionId: string, error: string): void {
|
||||
printLog(
|
||||
chalk.dim(`[${timestamp()}]`) +
|
||||
` Session ${chalk.red('failed')}: ${error} ${chalk.dim(sessionId)}\n`,
|
||||
)
|
||||
},
|
||||
|
||||
logStatus(message: string): void {
|
||||
printLog(chalk.dim(`[${timestamp()}]`) + ` ${message}\n`)
|
||||
},
|
||||
|
||||
logVerbose(message: string): void {
|
||||
if (verbose) {
|
||||
printLog(chalk.dim(`[${timestamp()}] ${message}`) + '\n')
|
||||
}
|
||||
},
|
||||
|
||||
logError(message: string): void {
|
||||
printLog(chalk.red(`[${timestamp()}] Error: ${message}`) + '\n')
|
||||
},
|
||||
|
||||
logReconnected(disconnectedMs: number): void {
|
||||
printLog(
|
||||
chalk.dim(`[${timestamp()}]`) +
|
||||
` ${chalk.green('Reconnected')} after ${formatDuration(disconnectedMs)}\n`,
|
||||
)
|
||||
},
|
||||
|
||||
setRepoInfo(repo: string, branchName: string): void {
|
||||
repoName = repo
|
||||
branch = branchName
|
||||
},
|
||||
|
||||
setDebugLogPath(path: string): void {
|
||||
debugLogPath = path
|
||||
},
|
||||
|
||||
updateIdleStatus(): void {
|
||||
stopConnecting()
|
||||
|
||||
currentState = 'idle'
|
||||
currentStateText = 'Ready'
|
||||
lastToolSummary = null
|
||||
lastToolTime = 0
|
||||
activeSessionUrl = null
|
||||
regenerateQr(connectUrl)
|
||||
renderStatusLine()
|
||||
},
|
||||
|
||||
setAttached(sessionId: string): void {
|
||||
stopConnecting()
|
||||
currentState = 'attached'
|
||||
currentStateText = 'Connected'
|
||||
lastToolSummary = null
|
||||
lastToolTime = 0
|
||||
// Multi-session: keep footer/QR on the environment connect URL so users
|
||||
// can spawn more sessions. Per-session links are in the bullet list.
|
||||
if (sessionMax <= 1) {
|
||||
activeSessionUrl = buildBridgeSessionUrl(
|
||||
sessionId,
|
||||
cachedEnvironmentId,
|
||||
cachedIngressUrl,
|
||||
)
|
||||
regenerateQr(activeSessionUrl)
|
||||
}
|
||||
renderStatusLine()
|
||||
},
|
||||
|
||||
updateReconnectingStatus(delayStr: string, elapsedStr: string): void {
|
||||
stopConnecting()
|
||||
clearStatusLines()
|
||||
currentState = 'reconnecting'
|
||||
|
||||
// QR code above the status line
|
||||
if (qrVisible) {
|
||||
for (const line of qrLines) {
|
||||
writeStatus(`${chalk.dim(line)}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
const frame =
|
||||
BRIDGE_SPINNER_FRAMES[connectingTick % BRIDGE_SPINNER_FRAMES.length]!
|
||||
connectingTick++
|
||||
writeStatus(
|
||||
`${chalk.yellow(frame)} ${chalk.yellow('Reconnecting')} ${chalk.dim('\u00b7')} ${chalk.dim(`retrying in ${delayStr}`)} ${chalk.dim('\u00b7')} ${chalk.dim(`disconnected ${elapsedStr}`)}\n`,
|
||||
)
|
||||
},
|
||||
|
||||
updateFailedStatus(error: string): void {
|
||||
stopConnecting()
|
||||
clearStatusLines()
|
||||
currentState = 'failed'
|
||||
|
||||
let suffix = ''
|
||||
if (repoName) {
|
||||
suffix += chalk.dim(' \u00b7 ') + chalk.dim(repoName)
|
||||
}
|
||||
if (branch) {
|
||||
suffix += chalk.dim(' \u00b7 ') + chalk.dim(branch)
|
||||
}
|
||||
|
||||
writeStatus(
|
||||
`${chalk.red(BRIDGE_FAILED_INDICATOR)} ${chalk.red('Remote Control Failed')}${suffix}\n`,
|
||||
)
|
||||
writeStatus(`${chalk.dim(FAILED_FOOTER_TEXT)}\n`)
|
||||
|
||||
if (error) {
|
||||
writeStatus(`${chalk.red(error)}\n`)
|
||||
}
|
||||
},
|
||||
|
||||
updateSessionStatus(
|
||||
_sessionId: string,
|
||||
_elapsed: string,
|
||||
activity: SessionActivity,
|
||||
_trail: string[],
|
||||
): void {
|
||||
// Cache tool activity for the second status line
|
||||
if (activity.type === 'tool_start') {
|
||||
lastToolSummary = activity.summary
|
||||
lastToolTime = Date.now()
|
||||
}
|
||||
renderStatusLine()
|
||||
},
|
||||
|
||||
clearStatus(): void {
|
||||
stopConnecting()
|
||||
clearStatusLines()
|
||||
},
|
||||
|
||||
toggleQr(): void {
|
||||
qrVisible = !qrVisible
|
||||
renderStatusLine()
|
||||
},
|
||||
|
||||
updateSessionCount(active: number, max: number, mode: SpawnMode): void {
|
||||
if (sessionActive === active && sessionMax === max && spawnMode === mode)
|
||||
return
|
||||
sessionActive = active
|
||||
sessionMax = max
|
||||
spawnMode = mode
|
||||
// Don't re-render here — the status ticker calls renderStatusLine
|
||||
// on its own cadence, and the next tick will pick up the new values.
|
||||
},
|
||||
|
||||
setSpawnModeDisplay(mode: 'same-dir' | 'worktree' | null): void {
|
||||
if (spawnModeDisplay === mode) return
|
||||
spawnModeDisplay = mode
|
||||
// Also sync the #21118-added spawnMode so the next render shows correct
|
||||
// mode hint + branch visibility. Don't render here — matches
|
||||
// updateSessionCount: called before printBanner (initial setup) and
|
||||
// again from the `w` handler (which follows with refreshDisplay).
|
||||
if (mode) spawnMode = mode
|
||||
},
|
||||
|
||||
addSession(sessionId: string, url: string): void {
|
||||
sessionDisplayInfo.set(sessionId, { url })
|
||||
},
|
||||
|
||||
updateSessionActivity(sessionId: string, activity: SessionActivity): void {
|
||||
const info = sessionDisplayInfo.get(sessionId)
|
||||
if (!info) return
|
||||
info.activity = activity
|
||||
},
|
||||
|
||||
setSessionTitle(sessionId: string, title: string): void {
|
||||
const info = sessionDisplayInfo.get(sessionId)
|
||||
if (!info) return
|
||||
info.title = title
|
||||
// Guard against reconnecting/failed — renderStatusLine clears then returns
|
||||
// early for those states, which would erase the spinner/error.
|
||||
if (currentState === 'reconnecting' || currentState === 'failed') return
|
||||
if (sessionMax === 1) {
|
||||
// Single-session: show title in the main status line too.
|
||||
currentState = 'titled'
|
||||
currentStateText = truncatePrompt(title, 40)
|
||||
}
|
||||
renderStatusLine()
|
||||
},
|
||||
|
||||
removeSession(sessionId: string): void {
|
||||
sessionDisplayInfo.delete(sessionId)
|
||||
},
|
||||
|
||||
refreshDisplay(): void {
|
||||
// Skip during reconnecting/failed — renderStatusLine clears then returns
|
||||
// early for those states, which would erase the spinner/error.
|
||||
if (currentState === 'reconnecting' || currentState === 'failed') return
|
||||
renderStatusLine()
|
||||
},
|
||||
}
|
||||
}
|
||||
56
original-source-code/src/bridge/capacityWake.ts
Normal file
56
original-source-code/src/bridge/capacityWake.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Shared capacity-wake primitive for bridge poll loops.
|
||||
*
|
||||
* Both replBridge.ts and bridgeMain.ts need to sleep while "at capacity"
|
||||
* but wake early when either (a) the outer loop signal aborts (shutdown),
|
||||
* or (b) capacity frees up (session done / transport lost). This module
|
||||
* encapsulates the mutable wake-controller + two-signal merger that both
|
||||
* poll loops previously duplicated byte-for-byte.
|
||||
*/
|
||||
|
||||
export type CapacitySignal = { signal: AbortSignal; cleanup: () => void }
|
||||
|
||||
export type CapacityWake = {
|
||||
/**
|
||||
* Create a signal that aborts when either the outer loop signal or the
|
||||
* capacity-wake controller fires. Returns the merged signal and a cleanup
|
||||
* function that removes listeners when the sleep resolves normally
|
||||
* (without abort).
|
||||
*/
|
||||
signal(): CapacitySignal
|
||||
/**
|
||||
* Abort the current at-capacity sleep and arm a fresh controller so the
|
||||
* poll loop immediately re-checks for new work.
|
||||
*/
|
||||
wake(): void
|
||||
}
|
||||
|
||||
export function createCapacityWake(outerSignal: AbortSignal): CapacityWake {
|
||||
let wakeController = new AbortController()
|
||||
|
||||
function wake(): void {
|
||||
wakeController.abort()
|
||||
wakeController = new AbortController()
|
||||
}
|
||||
|
||||
function signal(): CapacitySignal {
|
||||
const merged = new AbortController()
|
||||
const abort = (): void => merged.abort()
|
||||
if (outerSignal.aborted || wakeController.signal.aborted) {
|
||||
merged.abort()
|
||||
return { signal: merged.signal, cleanup: () => {} }
|
||||
}
|
||||
outerSignal.addEventListener('abort', abort, { once: true })
|
||||
const capSig = wakeController.signal
|
||||
capSig.addEventListener('abort', abort, { once: true })
|
||||
return {
|
||||
signal: merged.signal,
|
||||
cleanup: () => {
|
||||
outerSignal.removeEventListener('abort', abort)
|
||||
capSig.removeEventListener('abort', abort)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return { signal, wake }
|
||||
}
|
||||
168
original-source-code/src/bridge/codeSessionApi.ts
Normal file
168
original-source-code/src/bridge/codeSessionApi.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Thin HTTP wrappers for the CCR v2 code-session API.
|
||||
*
|
||||
* Separate file from remoteBridgeCore.ts so the SDK /bridge subpath can
|
||||
* export createCodeSession + fetchRemoteCredentials without bundling the
|
||||
* heavy CLI tree (analytics, transport, etc.). Callers supply explicit
|
||||
* accessToken + baseUrl — no implicit auth or config reads.
|
||||
*/
|
||||
|
||||
import axios from 'axios'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { errorMessage } from '../utils/errors.js'
|
||||
import { jsonStringify } from '../utils/slowOperations.js'
|
||||
import { extractErrorDetail } from './debugUtils.js'
|
||||
|
||||
const ANTHROPIC_VERSION = '2023-06-01'
|
||||
|
||||
function oauthHeaders(accessToken: string): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': ANTHROPIC_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCodeSession(
|
||||
baseUrl: string,
|
||||
accessToken: string,
|
||||
title: string,
|
||||
timeoutMs: number,
|
||||
tags?: string[],
|
||||
): Promise<string | null> {
|
||||
const url = `${baseUrl}/v1/code/sessions`
|
||||
let response
|
||||
try {
|
||||
response = await axios.post(
|
||||
url,
|
||||
// bridge: {} is the positive signal for the oneof runner — omitting it
|
||||
// (or sending environment_id: "") now 400s. BridgeRunner is an empty
|
||||
// message today; it's a placeholder for future bridge-specific options.
|
||||
{ title, bridge: {}, ...(tags?.length ? { tags } : {}) },
|
||||
{
|
||||
headers: oauthHeaders(accessToken),
|
||||
timeout: timeoutMs,
|
||||
validateStatus: s => s < 500,
|
||||
},
|
||||
)
|
||||
} catch (err: unknown) {
|
||||
logForDebugging(
|
||||
`[code-session] Session create request failed: ${errorMessage(err)}`,
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
if (response.status !== 200 && response.status !== 201) {
|
||||
const detail = extractErrorDetail(response.data)
|
||||
logForDebugging(
|
||||
`[code-session] Session create failed ${response.status}${detail ? `: ${detail}` : ''}`,
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const data: unknown = response.data
|
||||
if (
|
||||
!data ||
|
||||
typeof data !== 'object' ||
|
||||
!('session' in data) ||
|
||||
!data.session ||
|
||||
typeof data.session !== 'object' ||
|
||||
!('id' in data.session) ||
|
||||
typeof data.session.id !== 'string' ||
|
||||
!data.session.id.startsWith('cse_')
|
||||
) {
|
||||
logForDebugging(
|
||||
`[code-session] No session.id (cse_*) in response: ${jsonStringify(data).slice(0, 200)}`,
|
||||
)
|
||||
return null
|
||||
}
|
||||
return data.session.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Credentials from POST /bridge. JWT is opaque — do not decode.
|
||||
* Each /bridge call bumps worker_epoch server-side (it IS the register).
|
||||
*/
|
||||
export type RemoteCredentials = {
|
||||
worker_jwt: string
|
||||
api_base_url: string
|
||||
expires_in: number
|
||||
worker_epoch: number
|
||||
}
|
||||
|
||||
export async function fetchRemoteCredentials(
|
||||
sessionId: string,
|
||||
baseUrl: string,
|
||||
accessToken: string,
|
||||
timeoutMs: number,
|
||||
trustedDeviceToken?: string,
|
||||
): Promise<RemoteCredentials | null> {
|
||||
const url = `${baseUrl}/v1/code/sessions/${sessionId}/bridge`
|
||||
const headers = oauthHeaders(accessToken)
|
||||
if (trustedDeviceToken) {
|
||||
headers['X-Trusted-Device-Token'] = trustedDeviceToken
|
||||
}
|
||||
let response
|
||||
try {
|
||||
response = await axios.post(
|
||||
url,
|
||||
{},
|
||||
{
|
||||
headers,
|
||||
timeout: timeoutMs,
|
||||
validateStatus: s => s < 500,
|
||||
},
|
||||
)
|
||||
} catch (err: unknown) {
|
||||
logForDebugging(
|
||||
`[code-session] /bridge request failed: ${errorMessage(err)}`,
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
if (response.status !== 200) {
|
||||
const detail = extractErrorDetail(response.data)
|
||||
logForDebugging(
|
||||
`[code-session] /bridge failed ${response.status}${detail ? `: ${detail}` : ''}`,
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const data: unknown = response.data
|
||||
if (
|
||||
data === null ||
|
||||
typeof data !== 'object' ||
|
||||
!('worker_jwt' in data) ||
|
||||
typeof data.worker_jwt !== 'string' ||
|
||||
!('expires_in' in data) ||
|
||||
typeof data.expires_in !== 'number' ||
|
||||
!('api_base_url' in data) ||
|
||||
typeof data.api_base_url !== 'string' ||
|
||||
!('worker_epoch' in data)
|
||||
) {
|
||||
logForDebugging(
|
||||
`[code-session] /bridge response malformed (need worker_jwt, expires_in, api_base_url, worker_epoch): ${jsonStringify(data).slice(0, 200)}`,
|
||||
)
|
||||
return null
|
||||
}
|
||||
// protojson serializes int64 as a string to avoid JS precision loss;
|
||||
// Go may also return a number depending on encoder settings.
|
||||
const rawEpoch = data.worker_epoch
|
||||
const epoch = typeof rawEpoch === 'string' ? Number(rawEpoch) : rawEpoch
|
||||
if (
|
||||
typeof epoch !== 'number' ||
|
||||
!Number.isFinite(epoch) ||
|
||||
!Number.isSafeInteger(epoch)
|
||||
) {
|
||||
logForDebugging(
|
||||
`[code-session] /bridge worker_epoch invalid: ${jsonStringify(rawEpoch)}`,
|
||||
)
|
||||
return null
|
||||
}
|
||||
return {
|
||||
worker_jwt: data.worker_jwt,
|
||||
api_base_url: data.api_base_url,
|
||||
expires_in: data.expires_in,
|
||||
worker_epoch: epoch,
|
||||
}
|
||||
}
|
||||
384
original-source-code/src/bridge/createSession.ts
Normal file
384
original-source-code/src/bridge/createSession.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { errorMessage } from '../utils/errors.js'
|
||||
import { extractErrorDetail } from './debugUtils.js'
|
||||
import { toCompatSessionId } from './sessionIdCompat.js'
|
||||
|
||||
type GitSource = {
|
||||
type: 'git_repository'
|
||||
url: string
|
||||
revision?: string
|
||||
}
|
||||
|
||||
type GitOutcome = {
|
||||
type: 'git_repository'
|
||||
git_info: { type: 'github'; repo: string; branches: string[] }
|
||||
}
|
||||
|
||||
// Events must be wrapped in { type: 'event', data: <sdk_message> } for the
|
||||
// POST /v1/sessions endpoint (discriminated union format).
|
||||
type SessionEvent = {
|
||||
type: 'event'
|
||||
data: SDKMessage
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session on a bridge environment via POST /v1/sessions.
|
||||
*
|
||||
* Used by both `claude remote-control` (empty session so the user has somewhere to
|
||||
* type immediately) and `/remote-control` (session pre-populated with conversation
|
||||
* history).
|
||||
*
|
||||
* Returns the session ID on success, or null if creation fails (non-fatal).
|
||||
*/
|
||||
export async function createBridgeSession({
|
||||
environmentId,
|
||||
title,
|
||||
events,
|
||||
gitRepoUrl,
|
||||
branch,
|
||||
signal,
|
||||
baseUrl: baseUrlOverride,
|
||||
getAccessToken,
|
||||
permissionMode,
|
||||
}: {
|
||||
environmentId: string
|
||||
title?: string
|
||||
events: SessionEvent[]
|
||||
gitRepoUrl: string | null
|
||||
branch: string
|
||||
signal: AbortSignal
|
||||
baseUrl?: string
|
||||
getAccessToken?: () => string | undefined
|
||||
permissionMode?: string
|
||||
}): Promise<string | null> {
|
||||
const { getClaudeAIOAuthTokens } = await import('../utils/auth.js')
|
||||
const { getOrganizationUUID } = await import('../services/oauth/client.js')
|
||||
const { getOauthConfig } = await import('../constants/oauth.js')
|
||||
const { getOAuthHeaders } = await import('../utils/teleport/api.js')
|
||||
const { parseGitHubRepository } = await import('../utils/detectRepository.js')
|
||||
const { getDefaultBranch } = await import('../utils/git.js')
|
||||
const { getMainLoopModel } = await import('../utils/model/model.js')
|
||||
const { default: axios } = await import('axios')
|
||||
|
||||
const accessToken =
|
||||
getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
|
||||
if (!accessToken) {
|
||||
logForDebugging('[bridge] No access token for session creation')
|
||||
return null
|
||||
}
|
||||
|
||||
const orgUUID = await getOrganizationUUID()
|
||||
if (!orgUUID) {
|
||||
logForDebugging('[bridge] No org UUID for session creation')
|
||||
return null
|
||||
}
|
||||
|
||||
// Build git source and outcome context
|
||||
let gitSource: GitSource | null = null
|
||||
let gitOutcome: GitOutcome | null = null
|
||||
|
||||
if (gitRepoUrl) {
|
||||
const { parseGitRemote } = await import('../utils/detectRepository.js')
|
||||
const parsed = parseGitRemote(gitRepoUrl)
|
||||
if (parsed) {
|
||||
const { host, owner, name } = parsed
|
||||
const revision = branch || (await getDefaultBranch()) || undefined
|
||||
gitSource = {
|
||||
type: 'git_repository',
|
||||
url: `https://${host}/${owner}/${name}`,
|
||||
revision,
|
||||
}
|
||||
gitOutcome = {
|
||||
type: 'git_repository',
|
||||
git_info: {
|
||||
type: 'github',
|
||||
repo: `${owner}/${name}`,
|
||||
branches: [`claude/${branch || 'task'}`],
|
||||
},
|
||||
}
|
||||
} else {
|
||||
// Fallback: try parseGitHubRepository for owner/repo format
|
||||
const ownerRepo = parseGitHubRepository(gitRepoUrl)
|
||||
if (ownerRepo) {
|
||||
const [owner, name] = ownerRepo.split('/')
|
||||
if (owner && name) {
|
||||
const revision = branch || (await getDefaultBranch()) || undefined
|
||||
gitSource = {
|
||||
type: 'git_repository',
|
||||
url: `https://github.com/${owner}/${name}`,
|
||||
revision,
|
||||
}
|
||||
gitOutcome = {
|
||||
type: 'git_repository',
|
||||
git_info: {
|
||||
type: 'github',
|
||||
repo: `${owner}/${name}`,
|
||||
branches: [`claude/${branch || 'task'}`],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
...(title !== undefined && { title }),
|
||||
events,
|
||||
session_context: {
|
||||
sources: gitSource ? [gitSource] : [],
|
||||
outcomes: gitOutcome ? [gitOutcome] : [],
|
||||
model: getMainLoopModel(),
|
||||
},
|
||||
environment_id: environmentId,
|
||||
source: 'remote-control',
|
||||
...(permissionMode && { permission_mode: permissionMode }),
|
||||
}
|
||||
|
||||
const headers = {
|
||||
...getOAuthHeaders(accessToken),
|
||||
'anthropic-beta': 'ccr-byoc-2025-07-29',
|
||||
'x-organization-uuid': orgUUID,
|
||||
}
|
||||
|
||||
const url = `${baseUrlOverride ?? getOauthConfig().BASE_API_URL}/v1/sessions`
|
||||
let response
|
||||
try {
|
||||
response = await axios.post(url, requestBody, {
|
||||
headers,
|
||||
signal,
|
||||
validateStatus: s => s < 500,
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
logForDebugging(
|
||||
`[bridge] Session creation request failed: ${errorMessage(err)}`,
|
||||
)
|
||||
return null
|
||||
}
|
||||
const isSuccess = response.status === 200 || response.status === 201
|
||||
|
||||
if (!isSuccess) {
|
||||
const detail = extractErrorDetail(response.data)
|
||||
logForDebugging(
|
||||
`[bridge] Session creation failed with status ${response.status}${detail ? `: ${detail}` : ''}`,
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const sessionData: unknown = response.data
|
||||
if (
|
||||
!sessionData ||
|
||||
typeof sessionData !== 'object' ||
|
||||
!('id' in sessionData) ||
|
||||
typeof sessionData.id !== 'string'
|
||||
) {
|
||||
logForDebugging('[bridge] No session ID in response')
|
||||
return null
|
||||
}
|
||||
|
||||
return sessionData.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a bridge session via GET /v1/sessions/{id}.
|
||||
*
|
||||
* Returns the session's environment_id (for `--session-id` resume) and title.
|
||||
* Uses the same org-scoped headers as create/archive — the environments-level
|
||||
* client in bridgeApi.ts uses a different beta header and no org UUID, which
|
||||
* makes the Sessions API return 404.
|
||||
*/
|
||||
export async function getBridgeSession(
|
||||
sessionId: string,
|
||||
opts?: { baseUrl?: string; getAccessToken?: () => string | undefined },
|
||||
): Promise<{ environment_id?: string; title?: string } | null> {
|
||||
const { getClaudeAIOAuthTokens } = await import('../utils/auth.js')
|
||||
const { getOrganizationUUID } = await import('../services/oauth/client.js')
|
||||
const { getOauthConfig } = await import('../constants/oauth.js')
|
||||
const { getOAuthHeaders } = await import('../utils/teleport/api.js')
|
||||
const { default: axios } = await import('axios')
|
||||
|
||||
const accessToken =
|
||||
opts?.getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
|
||||
if (!accessToken) {
|
||||
logForDebugging('[bridge] No access token for session fetch')
|
||||
return null
|
||||
}
|
||||
|
||||
const orgUUID = await getOrganizationUUID()
|
||||
if (!orgUUID) {
|
||||
logForDebugging('[bridge] No org UUID for session fetch')
|
||||
return null
|
||||
}
|
||||
|
||||
const headers = {
|
||||
...getOAuthHeaders(accessToken),
|
||||
'anthropic-beta': 'ccr-byoc-2025-07-29',
|
||||
'x-organization-uuid': orgUUID,
|
||||
}
|
||||
|
||||
const url = `${opts?.baseUrl ?? getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}`
|
||||
logForDebugging(`[bridge] Fetching session ${sessionId}`)
|
||||
|
||||
let response
|
||||
try {
|
||||
response = await axios.get<{ environment_id?: string; title?: string }>(
|
||||
url,
|
||||
{ headers, timeout: 10_000, validateStatus: s => s < 500 },
|
||||
)
|
||||
} catch (err: unknown) {
|
||||
logForDebugging(
|
||||
`[bridge] Session fetch request failed: ${errorMessage(err)}`,
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
if (response.status !== 200) {
|
||||
const detail = extractErrorDetail(response.data)
|
||||
logForDebugging(
|
||||
`[bridge] Session fetch failed with status ${response.status}${detail ? `: ${detail}` : ''}`,
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive a bridge session via POST /v1/sessions/{id}/archive.
|
||||
*
|
||||
* The CCR server never auto-archives sessions — archival is always an
|
||||
* explicit client action. Both `claude remote-control` (standalone bridge) and the
|
||||
* always-on `/remote-control` REPL bridge call this during shutdown to archive any
|
||||
* sessions that are still alive.
|
||||
*
|
||||
* The archive endpoint accepts sessions in any status (running, idle,
|
||||
* requires_action, pending) and returns 409 if already archived, making
|
||||
* it safe to call even if the server-side runner already archived the
|
||||
* session.
|
||||
*
|
||||
* Callers must handle errors — this function has no try/catch; 5xx,
|
||||
* timeouts, and network errors throw. Archival is best-effort during
|
||||
* cleanup; call sites wrap with .catch().
|
||||
*/
|
||||
export async function archiveBridgeSession(
|
||||
sessionId: string,
|
||||
opts?: {
|
||||
baseUrl?: string
|
||||
getAccessToken?: () => string | undefined
|
||||
timeoutMs?: number
|
||||
},
|
||||
): Promise<void> {
|
||||
const { getClaudeAIOAuthTokens } = await import('../utils/auth.js')
|
||||
const { getOrganizationUUID } = await import('../services/oauth/client.js')
|
||||
const { getOauthConfig } = await import('../constants/oauth.js')
|
||||
const { getOAuthHeaders } = await import('../utils/teleport/api.js')
|
||||
const { default: axios } = await import('axios')
|
||||
|
||||
const accessToken =
|
||||
opts?.getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
|
||||
if (!accessToken) {
|
||||
logForDebugging('[bridge] No access token for session archive')
|
||||
return
|
||||
}
|
||||
|
||||
const orgUUID = await getOrganizationUUID()
|
||||
if (!orgUUID) {
|
||||
logForDebugging('[bridge] No org UUID for session archive')
|
||||
return
|
||||
}
|
||||
|
||||
const headers = {
|
||||
...getOAuthHeaders(accessToken),
|
||||
'anthropic-beta': 'ccr-byoc-2025-07-29',
|
||||
'x-organization-uuid': orgUUID,
|
||||
}
|
||||
|
||||
const url = `${opts?.baseUrl ?? getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}/archive`
|
||||
logForDebugging(`[bridge] Archiving session ${sessionId}`)
|
||||
|
||||
const response = await axios.post(
|
||||
url,
|
||||
{},
|
||||
{
|
||||
headers,
|
||||
timeout: opts?.timeoutMs ?? 10_000,
|
||||
validateStatus: s => s < 500,
|
||||
},
|
||||
)
|
||||
|
||||
if (response.status === 200) {
|
||||
logForDebugging(`[bridge] Session ${sessionId} archived successfully`)
|
||||
} else {
|
||||
const detail = extractErrorDetail(response.data)
|
||||
logForDebugging(
|
||||
`[bridge] Session archive failed with status ${response.status}${detail ? `: ${detail}` : ''}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the title of a bridge session via PATCH /v1/sessions/{id}.
|
||||
*
|
||||
* Called when the user renames a session via /rename while a bridge
|
||||
* connection is active, so the title stays in sync on claude.ai/code.
|
||||
*
|
||||
* Errors are swallowed — title sync is best-effort.
|
||||
*/
|
||||
export async function updateBridgeSessionTitle(
|
||||
sessionId: string,
|
||||
title: string,
|
||||
opts?: { baseUrl?: string; getAccessToken?: () => string | undefined },
|
||||
): Promise<void> {
|
||||
const { getClaudeAIOAuthTokens } = await import('../utils/auth.js')
|
||||
const { getOrganizationUUID } = await import('../services/oauth/client.js')
|
||||
const { getOauthConfig } = await import('../constants/oauth.js')
|
||||
const { getOAuthHeaders } = await import('../utils/teleport/api.js')
|
||||
const { default: axios } = await import('axios')
|
||||
|
||||
const accessToken =
|
||||
opts?.getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
|
||||
if (!accessToken) {
|
||||
logForDebugging('[bridge] No access token for session title update')
|
||||
return
|
||||
}
|
||||
|
||||
const orgUUID = await getOrganizationUUID()
|
||||
if (!orgUUID) {
|
||||
logForDebugging('[bridge] No org UUID for session title update')
|
||||
return
|
||||
}
|
||||
|
||||
const headers = {
|
||||
...getOAuthHeaders(accessToken),
|
||||
'anthropic-beta': 'ccr-byoc-2025-07-29',
|
||||
'x-organization-uuid': orgUUID,
|
||||
}
|
||||
|
||||
// Compat gateway only accepts session_* (compat/convert.go:27). v2 callers
|
||||
// pass raw cse_*; retag here so all callers can pass whatever they hold.
|
||||
// Idempotent for v1's session_* and bridgeMain's pre-converted compatSessionId.
|
||||
const compatId = toCompatSessionId(sessionId)
|
||||
const url = `${opts?.baseUrl ?? getOauthConfig().BASE_API_URL}/v1/sessions/${compatId}`
|
||||
logForDebugging(`[bridge] Updating session title: ${compatId} → ${title}`)
|
||||
|
||||
try {
|
||||
const response = await axios.patch(
|
||||
url,
|
||||
{ title },
|
||||
{ headers, timeout: 10_000, validateStatus: s => s < 500 },
|
||||
)
|
||||
|
||||
if (response.status === 200) {
|
||||
logForDebugging(`[bridge] Session title updated successfully`)
|
||||
} else {
|
||||
const detail = extractErrorDetail(response.data)
|
||||
logForDebugging(
|
||||
`[bridge] Session title update failed with status ${response.status}${detail ? `: ${detail}` : ''}`,
|
||||
)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
logForDebugging(
|
||||
`[bridge] Session title update request failed: ${errorMessage(err)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
141
original-source-code/src/bridge/debugUtils.ts
Normal file
141
original-source-code/src/bridge/debugUtils.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from '../services/analytics/index.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { errorMessage } from '../utils/errors.js'
|
||||
import { jsonStringify } from '../utils/slowOperations.js'
|
||||
|
||||
const DEBUG_MSG_LIMIT = 2000
|
||||
|
||||
const SECRET_FIELD_NAMES = [
|
||||
'session_ingress_token',
|
||||
'environment_secret',
|
||||
'access_token',
|
||||
'secret',
|
||||
'token',
|
||||
]
|
||||
|
||||
const SECRET_PATTERN = new RegExp(
|
||||
`"(${SECRET_FIELD_NAMES.join('|')})"\\s*:\\s*"([^"]*)"`,
|
||||
'g',
|
||||
)
|
||||
|
||||
const REDACT_MIN_LENGTH = 16
|
||||
|
||||
export function redactSecrets(s: string): string {
|
||||
return s.replace(SECRET_PATTERN, (_match, field: string, value: string) => {
|
||||
if (value.length < REDACT_MIN_LENGTH) {
|
||||
return `"${field}":"[REDACTED]"`
|
||||
}
|
||||
const redacted = `${value.slice(0, 8)}...${value.slice(-4)}`
|
||||
return `"${field}":"${redacted}"`
|
||||
})
|
||||
}
|
||||
|
||||
/** Truncate a string for debug logging, collapsing newlines. */
|
||||
export function debugTruncate(s: string): string {
|
||||
const flat = s.replace(/\n/g, '\\n')
|
||||
if (flat.length <= DEBUG_MSG_LIMIT) {
|
||||
return flat
|
||||
}
|
||||
return flat.slice(0, DEBUG_MSG_LIMIT) + `... (${flat.length} chars)`
|
||||
}
|
||||
|
||||
/** Truncate a JSON-serializable value for debug logging. */
|
||||
export function debugBody(data: unknown): string {
|
||||
const raw = typeof data === 'string' ? data : jsonStringify(data)
|
||||
const s = redactSecrets(raw)
|
||||
if (s.length <= DEBUG_MSG_LIMIT) {
|
||||
return s
|
||||
}
|
||||
return s.slice(0, DEBUG_MSG_LIMIT) + `... (${s.length} chars)`
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a descriptive error message from an axios error (or any error).
|
||||
* For HTTP errors, appends the server's response body message if available,
|
||||
* since axios's default message only includes the status code.
|
||||
*/
|
||||
export function describeAxiosError(err: unknown): string {
|
||||
const msg = errorMessage(err)
|
||||
if (err && typeof err === 'object' && 'response' in err) {
|
||||
const response = (err as { response?: { data?: unknown } }).response
|
||||
if (response?.data && typeof response.data === 'object') {
|
||||
const data = response.data as Record<string, unknown>
|
||||
const detail =
|
||||
typeof data.message === 'string'
|
||||
? data.message
|
||||
: typeof data.error === 'object' &&
|
||||
data.error &&
|
||||
'message' in data.error &&
|
||||
typeof (data.error as Record<string, unknown>).message ===
|
||||
'string'
|
||||
? (data.error as Record<string, unknown>).message
|
||||
: undefined
|
||||
if (detail) {
|
||||
return `${msg}: ${detail}`
|
||||
}
|
||||
}
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the HTTP status code from an axios error, if present.
|
||||
* Returns undefined for non-HTTP errors (e.g. network failures).
|
||||
*/
|
||||
export function extractHttpStatus(err: unknown): number | undefined {
|
||||
if (
|
||||
err &&
|
||||
typeof err === 'object' &&
|
||||
'response' in err &&
|
||||
(err as { response?: { status?: unknown } }).response &&
|
||||
typeof (err as { response: { status?: unknown } }).response.status ===
|
||||
'number'
|
||||
) {
|
||||
return (err as { response: { status: number } }).response.status
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull a human-readable message out of an API error response body.
|
||||
* Checks `data.message` first, then `data.error.message`.
|
||||
*/
|
||||
export function extractErrorDetail(data: unknown): string | undefined {
|
||||
if (!data || typeof data !== 'object') return undefined
|
||||
if ('message' in data && typeof data.message === 'string') {
|
||||
return data.message
|
||||
}
|
||||
if (
|
||||
'error' in data &&
|
||||
data.error !== null &&
|
||||
typeof data.error === 'object' &&
|
||||
'message' in data.error &&
|
||||
typeof data.error.message === 'string'
|
||||
) {
|
||||
return data.error.message
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a bridge init skip — debug message + `tengu_bridge_repl_skipped`
|
||||
* analytics event. Centralizes the event name and the AnalyticsMetadata
|
||||
* cast so call sites don't each repeat the 5-line boilerplate.
|
||||
*/
|
||||
export function logBridgeSkip(
|
||||
reason: string,
|
||||
debugMsg?: string,
|
||||
v2?: boolean,
|
||||
): void {
|
||||
if (debugMsg) {
|
||||
logForDebugging(debugMsg)
|
||||
}
|
||||
logEvent('tengu_bridge_repl_skipped', {
|
||||
reason:
|
||||
reason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
...(v2 !== undefined && { v2 }),
|
||||
})
|
||||
}
|
||||
165
original-source-code/src/bridge/envLessBridgeConfig.ts
Normal file
165
original-source-code/src/bridge/envLessBridgeConfig.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { z } from 'zod/v4'
|
||||
import { getFeatureValue_DEPRECATED } from '../services/analytics/growthbook.js'
|
||||
import { lazySchema } from '../utils/lazySchema.js'
|
||||
import { lt } from '../utils/semver.js'
|
||||
import { isEnvLessBridgeEnabled } from './bridgeEnabled.js'
|
||||
|
||||
export type EnvLessBridgeConfig = {
|
||||
// withRetry — init-phase backoff (createSession, POST /bridge, recovery /bridge)
|
||||
init_retry_max_attempts: number
|
||||
init_retry_base_delay_ms: number
|
||||
init_retry_jitter_fraction: number
|
||||
init_retry_max_delay_ms: number
|
||||
// axios timeout for POST /sessions, POST /bridge, POST /archive
|
||||
http_timeout_ms: number
|
||||
// BoundedUUIDSet ring size (echo + re-delivery dedup)
|
||||
uuid_dedup_buffer_size: number
|
||||
// CCRClient worker heartbeat cadence. Server TTL is 60s — 20s gives 3× margin.
|
||||
heartbeat_interval_ms: number
|
||||
// ±fraction of interval — per-beat jitter to spread fleet load.
|
||||
heartbeat_jitter_fraction: number
|
||||
// Fire proactive JWT refresh this long before expires_in. Larger buffer =
|
||||
// more frequent refresh (refresh cadence ≈ expires_in - buffer).
|
||||
token_refresh_buffer_ms: number
|
||||
// Archive POST timeout in teardown(). Distinct from http_timeout_ms because
|
||||
// gracefulShutdown races runCleanupFunctions() against a 2s cap — a 10s
|
||||
// axios timeout on a slow/stalled archive burns the whole budget on a
|
||||
// request that forceExit will kill anyway.
|
||||
teardown_archive_timeout_ms: number
|
||||
// Deadline for onConnect after transport.connect(). If neither onConnect
|
||||
// nor onClose fires before this, emit tengu_bridge_repl_connect_timeout
|
||||
// — the only telemetry for the ~1% of sessions that emit `started` then
|
||||
// go silent (no error, no event, just nothing).
|
||||
connect_timeout_ms: number
|
||||
// Semver floor for the env-less bridge path. Separate from the v1
|
||||
// tengu_bridge_min_version config so a v2-specific bug can force upgrades
|
||||
// without blocking v1 (env-based) clients, and vice versa.
|
||||
min_version: string
|
||||
// When true, tell users their claude.ai app may be too old to see v2
|
||||
// sessions — lets us roll the v2 bridge before the app ships the new
|
||||
// session-list query.
|
||||
should_show_app_upgrade_message: boolean
|
||||
}
|
||||
|
||||
export const DEFAULT_ENV_LESS_BRIDGE_CONFIG: EnvLessBridgeConfig = {
|
||||
init_retry_max_attempts: 3,
|
||||
init_retry_base_delay_ms: 500,
|
||||
init_retry_jitter_fraction: 0.25,
|
||||
init_retry_max_delay_ms: 4000,
|
||||
http_timeout_ms: 10_000,
|
||||
uuid_dedup_buffer_size: 2000,
|
||||
heartbeat_interval_ms: 20_000,
|
||||
heartbeat_jitter_fraction: 0.1,
|
||||
token_refresh_buffer_ms: 300_000,
|
||||
teardown_archive_timeout_ms: 1500,
|
||||
connect_timeout_ms: 15_000,
|
||||
min_version: '0.0.0',
|
||||
should_show_app_upgrade_message: false,
|
||||
}
|
||||
|
||||
// Floors reject the whole object on violation (fall back to DEFAULT) rather
|
||||
// than partially trusting — same defense-in-depth as pollConfig.ts.
|
||||
const envLessBridgeConfigSchema = lazySchema(() =>
|
||||
z.object({
|
||||
init_retry_max_attempts: z.number().int().min(1).max(10).default(3),
|
||||
init_retry_base_delay_ms: z.number().int().min(100).default(500),
|
||||
init_retry_jitter_fraction: z.number().min(0).max(1).default(0.25),
|
||||
init_retry_max_delay_ms: z.number().int().min(500).default(4000),
|
||||
http_timeout_ms: z.number().int().min(2000).default(10_000),
|
||||
uuid_dedup_buffer_size: z.number().int().min(100).max(50_000).default(2000),
|
||||
// Server TTL is 60s. Floor 5s prevents thrash; cap 30s keeps ≥2× margin.
|
||||
heartbeat_interval_ms: z
|
||||
.number()
|
||||
.int()
|
||||
.min(5000)
|
||||
.max(30_000)
|
||||
.default(20_000),
|
||||
// ±fraction per beat. Cap 0.5: at max interval (30s) × 1.5 = 45s worst case,
|
||||
// still under the 60s TTL.
|
||||
heartbeat_jitter_fraction: z.number().min(0).max(0.5).default(0.1),
|
||||
// Floor 30s prevents tight-looping. Cap 30min rejects buffer-vs-delay
|
||||
// semantic inversion: ops entering expires_in-5min (the *delay until
|
||||
// refresh*) instead of 5min (the *buffer before expiry*) yields
|
||||
// delayMs = expires_in - buffer ≈ 5min instead of ≈4h. Both are positive
|
||||
// durations so .min() alone can't distinguish; .max() catches the
|
||||
// inverted value since buffer ≥ 30min is nonsensical for a multi-hour JWT.
|
||||
token_refresh_buffer_ms: z
|
||||
.number()
|
||||
.int()
|
||||
.min(30_000)
|
||||
.max(1_800_000)
|
||||
.default(300_000),
|
||||
// Cap 2000 keeps this under gracefulShutdown's 2s cleanup race — a higher
|
||||
// timeout just lies to axios since forceExit kills the socket regardless.
|
||||
teardown_archive_timeout_ms: z
|
||||
.number()
|
||||
.int()
|
||||
.min(500)
|
||||
.max(2000)
|
||||
.default(1500),
|
||||
// Observed p99 connect is ~2-3s; 15s is ~5× headroom. Floor 5s bounds
|
||||
// false-positive rate under transient slowness; cap 60s bounds how long
|
||||
// a truly-stalled session stays dark.
|
||||
connect_timeout_ms: z.number().int().min(5_000).max(60_000).default(15_000),
|
||||
min_version: z
|
||||
.string()
|
||||
.refine(v => {
|
||||
try {
|
||||
lt(v, '0.0.0')
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
.default('0.0.0'),
|
||||
should_show_app_upgrade_message: z.boolean().default(false),
|
||||
}),
|
||||
)
|
||||
|
||||
/**
|
||||
* Fetch the env-less bridge timing config from GrowthBook. Read once per
|
||||
* initEnvLessBridgeCore call — config is fixed for the lifetime of a bridge
|
||||
* session.
|
||||
*
|
||||
* Uses the blocking getter (not _CACHED_MAY_BE_STALE) because /remote-control
|
||||
* runs well after GrowthBook init — initializeGrowthBook() resolves instantly,
|
||||
* so there's no startup penalty, and we get the fresh in-memory remoteEval
|
||||
* value instead of the stale-on-first-read disk cache. The _DEPRECATED suffix
|
||||
* warns against startup-path usage, which this isn't.
|
||||
*/
|
||||
export async function getEnvLessBridgeConfig(): Promise<EnvLessBridgeConfig> {
|
||||
const raw = await getFeatureValue_DEPRECATED<unknown>(
|
||||
'tengu_bridge_repl_v2_config',
|
||||
DEFAULT_ENV_LESS_BRIDGE_CONFIG,
|
||||
)
|
||||
const parsed = envLessBridgeConfigSchema().safeParse(raw)
|
||||
return parsed.success ? parsed.data : DEFAULT_ENV_LESS_BRIDGE_CONFIG
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an error message if the current CLI version is below the minimum
|
||||
* required for the env-less (v2) bridge path, or null if the version is fine.
|
||||
*
|
||||
* v2 analogue of checkBridgeMinVersion() — reads from tengu_bridge_repl_v2_config
|
||||
* instead of tengu_bridge_min_version so the two implementations can enforce
|
||||
* independent floors.
|
||||
*/
|
||||
export async function checkEnvLessBridgeMinVersion(): Promise<string | null> {
|
||||
const cfg = await getEnvLessBridgeConfig()
|
||||
if (cfg.min_version && lt(MACRO.VERSION, cfg.min_version)) {
|
||||
return `Your version of Claude Code (${MACRO.VERSION}) is too old for Remote Control.\nVersion ${cfg.min_version} or higher is required. Run \`claude update\` to update.`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to nudge users toward upgrading their claude.ai app when a
|
||||
* Remote Control session starts. True only when the v2 bridge is active
|
||||
* AND the should_show_app_upgrade_message config bit is set — lets us
|
||||
* roll the v2 bridge before the app ships the new session-list query.
|
||||
*/
|
||||
export async function shouldShowAppUpgradeMessage(): Promise<boolean> {
|
||||
if (!isEnvLessBridgeEnabled()) return false
|
||||
const cfg = await getEnvLessBridgeConfig()
|
||||
return cfg.should_show_app_upgrade_message
|
||||
}
|
||||
71
original-source-code/src/bridge/flushGate.ts
Normal file
71
original-source-code/src/bridge/flushGate.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* State machine for gating message writes during an initial flush.
|
||||
*
|
||||
* When a bridge session starts, historical messages are flushed to the
|
||||
* server via a single HTTP POST. During that flush, new messages must
|
||||
* be queued to prevent them from arriving at the server interleaved
|
||||
* with the historical messages.
|
||||
*
|
||||
* Lifecycle:
|
||||
* start() → enqueue() returns true, items are queued
|
||||
* end() → returns queued items for draining, enqueue() returns false
|
||||
* drop() → discards queued items (permanent transport close)
|
||||
* deactivate() → clears active flag without dropping items
|
||||
* (transport replacement — new transport will drain)
|
||||
*/
|
||||
export class FlushGate<T> {
|
||||
private _active = false
|
||||
private _pending: T[] = []
|
||||
|
||||
get active(): boolean {
|
||||
return this._active
|
||||
}
|
||||
|
||||
get pendingCount(): number {
|
||||
return this._pending.length
|
||||
}
|
||||
|
||||
/** Mark flush as in-progress. enqueue() will start queuing items. */
|
||||
start(): void {
|
||||
this._active = true
|
||||
}
|
||||
|
||||
/**
|
||||
* End the flush and return any queued items for draining.
|
||||
* Caller is responsible for sending the returned items.
|
||||
*/
|
||||
end(): T[] {
|
||||
this._active = false
|
||||
return this._pending.splice(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* If flush is active, queue the items and return true.
|
||||
* If flush is not active, return false (caller should send directly).
|
||||
*/
|
||||
enqueue(...items: T[]): boolean {
|
||||
if (!this._active) return false
|
||||
this._pending.push(...items)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard all queued items (permanent transport close).
|
||||
* Returns the number of items dropped.
|
||||
*/
|
||||
drop(): number {
|
||||
this._active = false
|
||||
const count = this._pending.length
|
||||
this._pending.length = 0
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the active flag without dropping queued items.
|
||||
* Used when the transport is replaced (onWorkReceived) — the new
|
||||
* transport's flush will drain the pending items.
|
||||
*/
|
||||
deactivate(): void {
|
||||
this._active = false
|
||||
}
|
||||
}
|
||||
175
original-source-code/src/bridge/inboundAttachments.ts
Normal file
175
original-source-code/src/bridge/inboundAttachments.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Resolve file_uuid attachments on inbound bridge user messages.
|
||||
*
|
||||
* Web composer uploads via cookie-authed /api/{org}/upload, sends file_uuid
|
||||
* alongside the message. Here we fetch each via GET /api/oauth/files/{uuid}/content
|
||||
* (oauth-authed, same store), write to ~/.claude/uploads/{sessionId}/, and
|
||||
* return @path refs to prepend. Claude's Read tool takes it from there.
|
||||
*
|
||||
* Best-effort: any failure (no token, network, non-2xx, disk) logs debug and
|
||||
* skips that attachment. The message still reaches Claude, just without @path.
|
||||
*/
|
||||
|
||||
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'
|
||||
import axios from 'axios'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { mkdir, writeFile } from 'fs/promises'
|
||||
import { basename, join } from 'path'
|
||||
import { z } from 'zod/v4'
|
||||
import { getSessionId } from '../bootstrap/state.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
|
||||
import { lazySchema } from '../utils/lazySchema.js'
|
||||
import { getBridgeAccessToken, getBridgeBaseUrl } from './bridgeConfig.js'
|
||||
|
||||
const DOWNLOAD_TIMEOUT_MS = 30_000
|
||||
|
||||
function debug(msg: string): void {
|
||||
logForDebugging(`[bridge:inbound-attach] ${msg}`)
|
||||
}
|
||||
|
||||
const attachmentSchema = lazySchema(() =>
|
||||
z.object({
|
||||
file_uuid: z.string(),
|
||||
file_name: z.string(),
|
||||
}),
|
||||
)
|
||||
const attachmentsArraySchema = lazySchema(() => z.array(attachmentSchema()))
|
||||
|
||||
export type InboundAttachment = z.infer<ReturnType<typeof attachmentSchema>>
|
||||
|
||||
/** Pull file_attachments off a loosely-typed inbound message. */
|
||||
export function extractInboundAttachments(msg: unknown): InboundAttachment[] {
|
||||
if (typeof msg !== 'object' || msg === null || !('file_attachments' in msg)) {
|
||||
return []
|
||||
}
|
||||
const parsed = attachmentsArraySchema().safeParse(msg.file_attachments)
|
||||
return parsed.success ? parsed.data : []
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip path components and keep only filename-safe chars. file_name comes
|
||||
* from the network (web composer), so treat it as untrusted even though the
|
||||
* composer controls it.
|
||||
*/
|
||||
function sanitizeFileName(name: string): string {
|
||||
const base = basename(name).replace(/[^a-zA-Z0-9._-]/g, '_')
|
||||
return base || 'attachment'
|
||||
}
|
||||
|
||||
function uploadsDir(): string {
|
||||
return join(getClaudeConfigHomeDir(), 'uploads', getSessionId())
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch + write one attachment. Returns the absolute path on success,
|
||||
* undefined on any failure.
|
||||
*/
|
||||
async function resolveOne(att: InboundAttachment): Promise<string | undefined> {
|
||||
const token = getBridgeAccessToken()
|
||||
if (!token) {
|
||||
debug('skip: no oauth token')
|
||||
return undefined
|
||||
}
|
||||
|
||||
let data: Buffer
|
||||
try {
|
||||
// getOauthConfig() (via getBridgeBaseUrl) throws on a non-allowlisted
|
||||
// CLAUDE_CODE_CUSTOM_OAUTH_URL — keep it inside the try so a bad
|
||||
// FedStart URL degrades to "no @path" instead of crashing print.ts's
|
||||
// reader loop (which has no catch around the await).
|
||||
const url = `${getBridgeBaseUrl()}/api/oauth/files/${encodeURIComponent(att.file_uuid)}/content`
|
||||
const response = await axios.get(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
responseType: 'arraybuffer',
|
||||
timeout: DOWNLOAD_TIMEOUT_MS,
|
||||
validateStatus: () => true,
|
||||
})
|
||||
if (response.status !== 200) {
|
||||
debug(`fetch ${att.file_uuid} failed: status=${response.status}`)
|
||||
return undefined
|
||||
}
|
||||
data = Buffer.from(response.data)
|
||||
} catch (e) {
|
||||
debug(`fetch ${att.file_uuid} threw: ${e}`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
// uuid-prefix makes collisions impossible across messages and within one
|
||||
// (same filename, different files). 8 chars is enough — this isn't security.
|
||||
const safeName = sanitizeFileName(att.file_name)
|
||||
const prefix = (
|
||||
att.file_uuid.slice(0, 8) || randomUUID().slice(0, 8)
|
||||
).replace(/[^a-zA-Z0-9_-]/g, '_')
|
||||
const dir = uploadsDir()
|
||||
const outPath = join(dir, `${prefix}-${safeName}`)
|
||||
|
||||
try {
|
||||
await mkdir(dir, { recursive: true })
|
||||
await writeFile(outPath, data)
|
||||
} catch (e) {
|
||||
debug(`write ${outPath} failed: ${e}`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
debug(`resolved ${att.file_uuid} → ${outPath} (${data.length} bytes)`)
|
||||
return outPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve all attachments on an inbound message to a prefix string of
|
||||
* @path refs. Empty string if none resolved.
|
||||
*/
|
||||
export async function resolveInboundAttachments(
|
||||
attachments: InboundAttachment[],
|
||||
): Promise<string> {
|
||||
if (attachments.length === 0) return ''
|
||||
debug(`resolving ${attachments.length} attachment(s)`)
|
||||
const paths = await Promise.all(attachments.map(resolveOne))
|
||||
const ok = paths.filter((p): p is string => p !== undefined)
|
||||
if (ok.length === 0) return ''
|
||||
// Quoted form — extractAtMentionedFiles truncates unquoted @refs at the
|
||||
// first space, which breaks any home dir with spaces (/Users/John Smith/).
|
||||
return ok.map(p => `@"${p}"`).join(' ') + ' '
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepend @path refs to content, whichever form it's in.
|
||||
* Targets the LAST text block — processUserInputBase reads inputString
|
||||
* from processedBlocks[processedBlocks.length - 1], so putting refs in
|
||||
* block[0] means they're silently ignored for [text, image] content.
|
||||
*/
|
||||
export function prependPathRefs(
|
||||
content: string | Array<ContentBlockParam>,
|
||||
prefix: string,
|
||||
): string | Array<ContentBlockParam> {
|
||||
if (!prefix) return content
|
||||
if (typeof content === 'string') return prefix + content
|
||||
const i = content.findLastIndex(b => b.type === 'text')
|
||||
if (i !== -1) {
|
||||
const b = content[i]!
|
||||
if (b.type === 'text') {
|
||||
return [
|
||||
...content.slice(0, i),
|
||||
{ ...b, text: prefix + b.text },
|
||||
...content.slice(i + 1),
|
||||
]
|
||||
}
|
||||
}
|
||||
// No text block — append one at the end so it's last.
|
||||
return [...content, { type: 'text', text: prefix.trimEnd() }]
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: extract + resolve + prepend. No-op when the message has no
|
||||
* file_attachments field (fast path — no network, returns same reference).
|
||||
*/
|
||||
export async function resolveAndPrepend(
|
||||
msg: unknown,
|
||||
content: string | Array<ContentBlockParam>,
|
||||
): Promise<string | Array<ContentBlockParam>> {
|
||||
const attachments = extractInboundAttachments(msg)
|
||||
if (attachments.length === 0) return content
|
||||
const prefix = await resolveInboundAttachments(attachments)
|
||||
return prependPathRefs(content, prefix)
|
||||
}
|
||||
80
original-source-code/src/bridge/inboundMessages.ts
Normal file
80
original-source-code/src/bridge/inboundMessages.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type {
|
||||
Base64ImageSource,
|
||||
ContentBlockParam,
|
||||
ImageBlockParam,
|
||||
} from '@anthropic-ai/sdk/resources/messages.mjs'
|
||||
import type { UUID } from 'crypto'
|
||||
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
|
||||
import { detectImageFormatFromBase64 } from '../utils/imageResizer.js'
|
||||
|
||||
/**
|
||||
* Process an inbound user message from the bridge, extracting content
|
||||
* and UUID for enqueueing. Supports both string content and
|
||||
* ContentBlockParam[] (e.g. messages containing images).
|
||||
*
|
||||
* Normalizes image blocks from bridge clients that may use camelCase
|
||||
* `mediaType` instead of snake_case `media_type` (mobile-apps#5825).
|
||||
*
|
||||
* Returns the extracted fields, or undefined if the message should be
|
||||
* skipped (non-user type, missing/empty content).
|
||||
*/
|
||||
export function extractInboundMessageFields(
|
||||
msg: SDKMessage,
|
||||
):
|
||||
| { content: string | Array<ContentBlockParam>; uuid: UUID | undefined }
|
||||
| undefined {
|
||||
if (msg.type !== 'user') return undefined
|
||||
const content = msg.message?.content
|
||||
if (!content) return undefined
|
||||
if (Array.isArray(content) && content.length === 0) return undefined
|
||||
|
||||
const uuid =
|
||||
'uuid' in msg && typeof msg.uuid === 'string'
|
||||
? (msg.uuid as UUID)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
content: Array.isArray(content) ? normalizeImageBlocks(content) : content,
|
||||
uuid,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize image content blocks from bridge clients. iOS/web clients may
|
||||
* send `mediaType` (camelCase) instead of `media_type` (snake_case), or
|
||||
* omit the field entirely. Without normalization, the bad block poisons
|
||||
* the session — every subsequent API call fails with
|
||||
* "media_type: Field required".
|
||||
*
|
||||
* Fast-path scan returns the original array reference when no
|
||||
* normalization is needed (zero allocation on the happy path).
|
||||
*/
|
||||
export function normalizeImageBlocks(
|
||||
blocks: Array<ContentBlockParam>,
|
||||
): Array<ContentBlockParam> {
|
||||
if (!blocks.some(isMalformedBase64Image)) return blocks
|
||||
|
||||
return blocks.map(block => {
|
||||
if (!isMalformedBase64Image(block)) return block
|
||||
const src = block.source as unknown as Record<string, unknown>
|
||||
const mediaType =
|
||||
typeof src.mediaType === 'string' && src.mediaType
|
||||
? src.mediaType
|
||||
: detectImageFormatFromBase64(block.source.data)
|
||||
return {
|
||||
...block,
|
||||
source: {
|
||||
type: 'base64' as const,
|
||||
media_type: mediaType as Base64ImageSource['media_type'],
|
||||
data: block.source.data,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function isMalformedBase64Image(
|
||||
block: ContentBlockParam,
|
||||
): block is ImageBlockParam & { source: Base64ImageSource } {
|
||||
if (block.type !== 'image' || block.source?.type !== 'base64') return false
|
||||
return !(block.source as unknown as Record<string, unknown>).media_type
|
||||
}
|
||||
569
original-source-code/src/bridge/initReplBridge.ts
Normal file
569
original-source-code/src/bridge/initReplBridge.ts
Normal file
@@ -0,0 +1,569 @@
|
||||
/**
|
||||
* REPL-specific wrapper around initBridgeCore. Owns the parts that read
|
||||
* bootstrap state — gates, cwd, session ID, git context, OAuth, title
|
||||
* derivation — then delegates to the bootstrap-free core.
|
||||
*
|
||||
* Split out of replBridge.ts because the sessionStorage import
|
||||
* (getCurrentSessionTitle) transitively pulls in src/commands.ts → the
|
||||
* entire slash command + React component tree (~1300 modules). Keeping
|
||||
* initBridgeCore in a file that doesn't touch sessionStorage lets
|
||||
* daemonBridge.ts import the core without bloating the Agent SDK bundle.
|
||||
*
|
||||
* Called via dynamic import by useReplBridge (auto-start) and print.ts
|
||||
* (SDK -p mode via query.enableRemoteControl).
|
||||
*/
|
||||
|
||||
import { feature } from 'bun:bundle'
|
||||
import { hostname } from 'os'
|
||||
import { getOriginalCwd, getSessionId } from '../bootstrap/state.js'
|
||||
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
|
||||
import type { SDKControlResponse } from '../entrypoints/sdk/controlTypes.js'
|
||||
import { getFeatureValue_CACHED_WITH_REFRESH } from '../services/analytics/growthbook.js'
|
||||
import { getOrganizationUUID } from '../services/oauth/client.js'
|
||||
import {
|
||||
isPolicyAllowed,
|
||||
waitForPolicyLimitsToLoad,
|
||||
} from '../services/policyLimits/index.js'
|
||||
import type { Message } from '../types/message.js'
|
||||
import {
|
||||
checkAndRefreshOAuthTokenIfNeeded,
|
||||
getClaudeAIOAuthTokens,
|
||||
handleOAuth401Error,
|
||||
} from '../utils/auth.js'
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { stripDisplayTagsAllowEmpty } from '../utils/displayTags.js'
|
||||
import { errorMessage } from '../utils/errors.js'
|
||||
import { getBranch, getRemoteUrl } from '../utils/git.js'
|
||||
import { toSDKMessages } from '../utils/messages/mappers.js'
|
||||
import {
|
||||
getContentText,
|
||||
getMessagesAfterCompactBoundary,
|
||||
isSyntheticMessage,
|
||||
} from '../utils/messages.js'
|
||||
import type { PermissionMode } from '../utils/permissions/PermissionMode.js'
|
||||
import { getCurrentSessionTitle } from '../utils/sessionStorage.js'
|
||||
import {
|
||||
extractConversationText,
|
||||
generateSessionTitle,
|
||||
} from '../utils/sessionTitle.js'
|
||||
import { generateShortWordSlug } from '../utils/words.js'
|
||||
import {
|
||||
getBridgeAccessToken,
|
||||
getBridgeBaseUrl,
|
||||
getBridgeTokenOverride,
|
||||
} from './bridgeConfig.js'
|
||||
import {
|
||||
checkBridgeMinVersion,
|
||||
isBridgeEnabledBlocking,
|
||||
isCseShimEnabled,
|
||||
isEnvLessBridgeEnabled,
|
||||
} from './bridgeEnabled.js'
|
||||
import {
|
||||
archiveBridgeSession,
|
||||
createBridgeSession,
|
||||
updateBridgeSessionTitle,
|
||||
} from './createSession.js'
|
||||
import { logBridgeSkip } from './debugUtils.js'
|
||||
import { checkEnvLessBridgeMinVersion } from './envLessBridgeConfig.js'
|
||||
import { getPollIntervalConfig } from './pollConfig.js'
|
||||
import type { BridgeState, ReplBridgeHandle } from './replBridge.js'
|
||||
import { initBridgeCore } from './replBridge.js'
|
||||
import { setCseShimGate } from './sessionIdCompat.js'
|
||||
import type { BridgeWorkerType } from './types.js'
|
||||
|
||||
export type InitBridgeOptions = {
|
||||
onInboundMessage?: (msg: SDKMessage) => void | Promise<void>
|
||||
onPermissionResponse?: (response: SDKControlResponse) => void
|
||||
onInterrupt?: () => void
|
||||
onSetModel?: (model: string | undefined) => void
|
||||
onSetMaxThinkingTokens?: (maxTokens: number | null) => void
|
||||
onSetPermissionMode?: (
|
||||
mode: PermissionMode,
|
||||
) => { ok: true } | { ok: false; error: string }
|
||||
onStateChange?: (state: BridgeState, detail?: string) => void
|
||||
initialMessages?: Message[]
|
||||
// Explicit session name from `/remote-control <name>`. When set, overrides
|
||||
// the title derived from the conversation or /rename.
|
||||
initialName?: string
|
||||
// Fresh view of the full conversation at call time. Used by onUserMessage's
|
||||
// count-3 derivation to call generateSessionTitle over the full conversation.
|
||||
// Optional — print.ts's SDK enableRemoteControl path has no REPL message
|
||||
// array; count-3 falls back to the single message text when absent.
|
||||
getMessages?: () => Message[]
|
||||
// UUIDs already flushed in a prior bridge session. Messages with these
|
||||
// UUIDs are excluded from the initial flush to avoid poisoning the
|
||||
// server (duplicate UUIDs across sessions cause the WS to be killed).
|
||||
// Mutated in place — newly flushed UUIDs are added after each flush.
|
||||
previouslyFlushedUUIDs?: Set<string>
|
||||
/** See BridgeCoreParams.perpetual. */
|
||||
perpetual?: boolean
|
||||
/**
|
||||
* When true, the bridge only forwards events outbound (no SSE inbound
|
||||
* stream). Used by CCR mirror mode — local sessions visible on claude.ai
|
||||
* without enabling inbound control.
|
||||
*/
|
||||
outboundOnly?: boolean
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export async function initReplBridge(
|
||||
options?: InitBridgeOptions,
|
||||
): Promise<ReplBridgeHandle | null> {
|
||||
const {
|
||||
onInboundMessage,
|
||||
onPermissionResponse,
|
||||
onInterrupt,
|
||||
onSetModel,
|
||||
onSetMaxThinkingTokens,
|
||||
onSetPermissionMode,
|
||||
onStateChange,
|
||||
initialMessages,
|
||||
getMessages,
|
||||
previouslyFlushedUUIDs,
|
||||
initialName,
|
||||
perpetual,
|
||||
outboundOnly,
|
||||
tags,
|
||||
} = options ?? {}
|
||||
|
||||
// Wire the cse_ shim kill switch so toCompatSessionId respects the
|
||||
// GrowthBook gate. Daemon/SDK paths skip this — shim defaults to active.
|
||||
setCseShimGate(isCseShimEnabled)
|
||||
|
||||
// 1. Runtime gate
|
||||
if (!(await isBridgeEnabledBlocking())) {
|
||||
logBridgeSkip('not_enabled', '[bridge:repl] Skipping: bridge not enabled')
|
||||
return null
|
||||
}
|
||||
|
||||
// 1b. Minimum version check — deferred to after the v1/v2 branch below,
|
||||
// since each implementation has its own floor (tengu_bridge_min_version
|
||||
// for v1, tengu_bridge_repl_v2_config.min_version for v2).
|
||||
|
||||
// 2. Check OAuth — must be signed in with claude.ai. Runs before the
|
||||
// policy check so console-auth users get the actionable "/login" hint
|
||||
// instead of a misleading policy error from a stale/wrong-org cache.
|
||||
if (!getBridgeAccessToken()) {
|
||||
logBridgeSkip('no_oauth', '[bridge:repl] Skipping: no OAuth tokens')
|
||||
onStateChange?.('failed', '/login')
|
||||
return null
|
||||
}
|
||||
|
||||
// 3. Check organization policy — remote control may be disabled
|
||||
await waitForPolicyLimitsToLoad()
|
||||
if (!isPolicyAllowed('allow_remote_control')) {
|
||||
logBridgeSkip(
|
||||
'policy_denied',
|
||||
'[bridge:repl] Skipping: allow_remote_control policy not allowed',
|
||||
)
|
||||
onStateChange?.('failed', "disabled by your organization's policy")
|
||||
return null
|
||||
}
|
||||
|
||||
// When CLAUDE_BRIDGE_OAUTH_TOKEN is set (ant-only local dev), the bridge
|
||||
// uses that token directly via getBridgeAccessToken() — keychain state is
|
||||
// irrelevant. Skip 2b/2c to preserve that decoupling: an expired keychain
|
||||
// token shouldn't block a bridge connection that doesn't use it.
|
||||
if (!getBridgeTokenOverride()) {
|
||||
// 2a. Cross-process backoff. If N prior processes already saw this exact
|
||||
// dead token (matched by expiresAt), skip silently — no event, no refresh
|
||||
// attempt. The count threshold tolerates transient refresh failures (auth
|
||||
// server 5xx, lockfile errors per auth.ts:1437/1444/1485): each process
|
||||
// independently retries until 3 consecutive failures prove the token dead.
|
||||
// Mirrors useReplBridge's MAX_CONSECUTIVE_INIT_FAILURES for in-process.
|
||||
// The expiresAt key is content-addressed: /login → new token → new expiresAt
|
||||
// → this stops matching without any explicit clear.
|
||||
const cfg = getGlobalConfig()
|
||||
if (
|
||||
cfg.bridgeOauthDeadExpiresAt != null &&
|
||||
(cfg.bridgeOauthDeadFailCount ?? 0) >= 3 &&
|
||||
getClaudeAIOAuthTokens()?.expiresAt === cfg.bridgeOauthDeadExpiresAt
|
||||
) {
|
||||
logForDebugging(
|
||||
`[bridge:repl] Skipping: cross-process backoff (dead token seen ${cfg.bridgeOauthDeadFailCount} times)`,
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
// 2b. Proactively refresh if expired. Mirrors bridgeMain.ts:2096 — the REPL
|
||||
// bridge fires at useEffect mount BEFORE any v1/messages call, making this
|
||||
// usually the first OAuth request of the session. Without this, ~9% of
|
||||
// registrations hit the server with a >8h-expired token → 401 → withOAuthRetry
|
||||
// recovers, but the server logs a 401 we can avoid. VPN egress IPs observed
|
||||
// at 30:1 401:200 when many unrelated users cluster at the 8h TTL boundary.
|
||||
//
|
||||
// Fresh-token cost: one memoized read + one Date.now() comparison (~µs).
|
||||
// checkAndRefreshOAuthTokenIfNeeded clears its own cache in every path that
|
||||
// touches the keychain (refresh success, lockfile race, throw), so no
|
||||
// explicit clearOAuthTokenCache() here — that would force a blocking
|
||||
// keychain spawn on the 91%+ fresh-token path.
|
||||
await checkAndRefreshOAuthTokenIfNeeded()
|
||||
|
||||
// 2c. Skip if token is still expired post-refresh-attempt. Env-var / FD
|
||||
// tokens (auth.ts:894-917) have expiresAt=null → never trip this. But a
|
||||
// keychain token whose refresh token is dead (password change, org left,
|
||||
// token GC'd) has expiresAt<now AND refresh just failed — the client would
|
||||
// otherwise loop 401 forever: withOAuthRetry → handleOAuth401Error →
|
||||
// refresh fails again → retry with same stale token → 401 again.
|
||||
// Datadog 2026-03-08: single IPs generating 2,879 such 401s/day. Skip the
|
||||
// guaranteed-fail API call; useReplBridge surfaces the failure.
|
||||
//
|
||||
// Intentionally NOT using isOAuthTokenExpired here — that has a 5-minute
|
||||
// proactive-refresh buffer, which is the right heuristic for "should
|
||||
// refresh soon" but wrong for "provably unusable". A token with 3min left
|
||||
// + transient refresh endpoint blip (5xx/timeout/wifi-reconnect) would
|
||||
// falsely trip a buffered check; the still-valid token would connect fine.
|
||||
// Check actual expiry instead: past-expiry AND refresh-failed → truly dead.
|
||||
const tokens = getClaudeAIOAuthTokens()
|
||||
if (tokens && tokens.expiresAt !== null && tokens.expiresAt <= Date.now()) {
|
||||
logBridgeSkip(
|
||||
'oauth_expired_unrefreshable',
|
||||
'[bridge:repl] Skipping: OAuth token expired and refresh failed (re-login required)',
|
||||
)
|
||||
onStateChange?.('failed', '/login')
|
||||
// Persist for the next process. Increments failCount when re-discovering
|
||||
// the same dead token (matched by expiresAt); resets to 1 for a different
|
||||
// token. Once count reaches 3, step 2a's early-return fires and this path
|
||||
// is never reached again — writes are capped at 3 per dead token.
|
||||
// Local const captures the narrowed type (closure loses !==null narrowing).
|
||||
const deadExpiresAt = tokens.expiresAt
|
||||
saveGlobalConfig(c => ({
|
||||
...c,
|
||||
bridgeOauthDeadExpiresAt: deadExpiresAt,
|
||||
bridgeOauthDeadFailCount:
|
||||
c.bridgeOauthDeadExpiresAt === deadExpiresAt
|
||||
? (c.bridgeOauthDeadFailCount ?? 0) + 1
|
||||
: 1,
|
||||
}))
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Compute baseUrl — needed by both v1 (env-based) and v2 (env-less)
|
||||
// paths. Hoisted above the v2 gate so both can use it.
|
||||
const baseUrl = getBridgeBaseUrl()
|
||||
|
||||
// 5. Derive session title. Precedence: explicit initialName → /rename
|
||||
// (session storage) → last meaningful user message → generated slug.
|
||||
// Cosmetic only (claude.ai session list); the model never sees it.
|
||||
// Two flags: `hasExplicitTitle` (initialName or /rename — never auto-
|
||||
// overwrite) vs. `hasTitle` (any title, including auto-derived — blocks
|
||||
// the count-1 re-derivation but not count-3). The onUserMessage callback
|
||||
// (wired to both v1 and v2 below) derives from the 1st prompt and again
|
||||
// from the 3rd so mobile/web show a title that reflects more context.
|
||||
// The slug fallback (e.g. "remote-control-graceful-unicorn") makes
|
||||
// auto-started sessions distinguishable in the claude.ai list before the
|
||||
// first prompt.
|
||||
let title = `remote-control-${generateShortWordSlug()}`
|
||||
let hasTitle = false
|
||||
let hasExplicitTitle = false
|
||||
if (initialName) {
|
||||
title = initialName
|
||||
hasTitle = true
|
||||
hasExplicitTitle = true
|
||||
} else {
|
||||
const sessionId = getSessionId()
|
||||
const customTitle = sessionId
|
||||
? getCurrentSessionTitle(sessionId)
|
||||
: undefined
|
||||
if (customTitle) {
|
||||
title = customTitle
|
||||
hasTitle = true
|
||||
hasExplicitTitle = true
|
||||
} else if (initialMessages && initialMessages.length > 0) {
|
||||
// Find the last user message that has meaningful content. Skip meta
|
||||
// (nudges), tool results, compact summaries ("This session is being
|
||||
// continued…"), non-human origins (task notifications, channel pushes),
|
||||
// and synthetic interrupts ([Request interrupted by user]) — none are
|
||||
// human-authored. Same filter as extractTitleText + isSyntheticMessage.
|
||||
for (let i = initialMessages.length - 1; i >= 0; i--) {
|
||||
const msg = initialMessages[i]!
|
||||
if (
|
||||
msg.type !== 'user' ||
|
||||
msg.isMeta ||
|
||||
msg.toolUseResult ||
|
||||
msg.isCompactSummary ||
|
||||
(msg.origin && msg.origin.kind !== 'human') ||
|
||||
isSyntheticMessage(msg)
|
||||
)
|
||||
continue
|
||||
const rawContent = getContentText(msg.message.content)
|
||||
if (!rawContent) continue
|
||||
const derived = deriveTitle(rawContent)
|
||||
if (!derived) continue
|
||||
title = derived
|
||||
hasTitle = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shared by both v1 and v2 — fires on every title-worthy user message until
|
||||
// it returns true. At count 1: deriveTitle placeholder immediately, then
|
||||
// generateSessionTitle (Haiku, sentence-case) fire-and-forget upgrade. At
|
||||
// count 3: re-generate over the full conversation. Skips entirely if the
|
||||
// title is explicit (/remote-control <name> or /rename) — re-checks
|
||||
// sessionStorage at call time so /rename between messages isn't clobbered.
|
||||
// Skips count 1 if initialMessages already derived (that title is fresh);
|
||||
// still refreshes at count 3. v2 passes cse_*; updateBridgeSessionTitle
|
||||
// retags internally.
|
||||
let userMessageCount = 0
|
||||
let lastBridgeSessionId: string | undefined
|
||||
let genSeq = 0
|
||||
const patch = (
|
||||
derived: string,
|
||||
bridgeSessionId: string,
|
||||
atCount: number,
|
||||
): void => {
|
||||
hasTitle = true
|
||||
title = derived
|
||||
logForDebugging(
|
||||
`[bridge:repl] derived title from message ${atCount}: ${derived}`,
|
||||
)
|
||||
void updateBridgeSessionTitle(bridgeSessionId, derived, {
|
||||
baseUrl,
|
||||
getAccessToken: getBridgeAccessToken,
|
||||
}).catch(() => {})
|
||||
}
|
||||
// Fire-and-forget Haiku generation with post-await guards. Re-checks /rename
|
||||
// (sessionStorage), v1 env-lost (lastBridgeSessionId), and same-session
|
||||
// out-of-order resolution (genSeq — count-1's Haiku resolving after count-3
|
||||
// would clobber the richer title). generateSessionTitle never rejects.
|
||||
const generateAndPatch = (input: string, bridgeSessionId: string): void => {
|
||||
const gen = ++genSeq
|
||||
const atCount = userMessageCount
|
||||
void generateSessionTitle(input, AbortSignal.timeout(15_000)).then(
|
||||
generated => {
|
||||
if (
|
||||
generated &&
|
||||
gen === genSeq &&
|
||||
lastBridgeSessionId === bridgeSessionId &&
|
||||
!getCurrentSessionTitle(getSessionId())
|
||||
) {
|
||||
patch(generated, bridgeSessionId, atCount)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
const onUserMessage = (text: string, bridgeSessionId: string): boolean => {
|
||||
if (hasExplicitTitle || getCurrentSessionTitle(getSessionId())) {
|
||||
return true
|
||||
}
|
||||
// v1 env-lost re-creates the session with a new ID. Reset the count so
|
||||
// the new session gets its own count-3 derivation; hasTitle stays true
|
||||
// (new session was created via getCurrentTitle(), which reads the count-1
|
||||
// title from this closure), so count-1 of the fresh cycle correctly skips.
|
||||
if (
|
||||
lastBridgeSessionId !== undefined &&
|
||||
lastBridgeSessionId !== bridgeSessionId
|
||||
) {
|
||||
userMessageCount = 0
|
||||
}
|
||||
lastBridgeSessionId = bridgeSessionId
|
||||
userMessageCount++
|
||||
if (userMessageCount === 1 && !hasTitle) {
|
||||
const placeholder = deriveTitle(text)
|
||||
if (placeholder) patch(placeholder, bridgeSessionId, userMessageCount)
|
||||
generateAndPatch(text, bridgeSessionId)
|
||||
} else if (userMessageCount === 3) {
|
||||
const msgs = getMessages?.()
|
||||
const input = msgs
|
||||
? extractConversationText(getMessagesAfterCompactBoundary(msgs))
|
||||
: text
|
||||
generateAndPatch(input, bridgeSessionId)
|
||||
}
|
||||
// Also re-latches if v1 env-lost resets the transport's done flag past 3.
|
||||
return userMessageCount >= 3
|
||||
}
|
||||
|
||||
const initialHistoryCap = getFeatureValue_CACHED_WITH_REFRESH(
|
||||
'tengu_bridge_initial_history_cap',
|
||||
200,
|
||||
5 * 60 * 1000,
|
||||
)
|
||||
|
||||
// Fetch orgUUID before the v1/v2 branch — both paths need it. v1 for
|
||||
// environment registration; v2 for archive (which lives at the compat
|
||||
// /v1/sessions/{id}/archive, not /v1/code/sessions). Without it, v2
|
||||
// archive 404s and sessions stay alive in CCR after /exit.
|
||||
const orgUUID = await getOrganizationUUID()
|
||||
if (!orgUUID) {
|
||||
logBridgeSkip('no_org_uuid', '[bridge:repl] Skipping: no org UUID')
|
||||
onStateChange?.('failed', '/login')
|
||||
return null
|
||||
}
|
||||
|
||||
// ── GrowthBook gate: env-less bridge ──────────────────────────────────
|
||||
// When enabled, skips the Environments API layer entirely (no register/
|
||||
// poll/ack/heartbeat) and connects directly via POST /bridge → worker_jwt.
|
||||
// See server PR #292605 (renamed in #293280). REPL-only — daemon/print stay
|
||||
// on env-based.
|
||||
//
|
||||
// NAMING: "env-less" is distinct from "CCR v2" (the /worker/* transport).
|
||||
// The env-based path below can ALSO use CCR v2 via CLAUDE_CODE_USE_CCR_V2.
|
||||
// tengu_bridge_repl_v2 gates env-less (no poll loop), not transport version.
|
||||
//
|
||||
// perpetual (assistant-mode session continuity via bridge-pointer.json) is
|
||||
// env-coupled and not yet implemented here — fall back to env-based when set
|
||||
// so KAIROS users don't silently lose cross-restart continuity.
|
||||
if (isEnvLessBridgeEnabled() && !perpetual) {
|
||||
const versionError = await checkEnvLessBridgeMinVersion()
|
||||
if (versionError) {
|
||||
logBridgeSkip(
|
||||
'version_too_old',
|
||||
`[bridge:repl] Skipping: ${versionError}`,
|
||||
true,
|
||||
)
|
||||
onStateChange?.('failed', 'run `claude update` to upgrade')
|
||||
return null
|
||||
}
|
||||
logForDebugging(
|
||||
'[bridge:repl] Using env-less bridge path (tengu_bridge_repl_v2)',
|
||||
)
|
||||
const { initEnvLessBridgeCore } = await import('./remoteBridgeCore.js')
|
||||
return initEnvLessBridgeCore({
|
||||
baseUrl,
|
||||
orgUUID,
|
||||
title,
|
||||
getAccessToken: getBridgeAccessToken,
|
||||
onAuth401: handleOAuth401Error,
|
||||
toSDKMessages,
|
||||
initialHistoryCap,
|
||||
initialMessages,
|
||||
// v2 always creates a fresh server session (new cse_* id), so
|
||||
// previouslyFlushedUUIDs is not passed — there's no cross-session
|
||||
// UUID collision risk, and the ref persists across enable→disable→
|
||||
// re-enable cycles which would cause the new session to receive zero
|
||||
// history (all UUIDs already in the set from the prior enable).
|
||||
// v1 handles this by calling previouslyFlushedUUIDs.clear() on fresh
|
||||
// session creation (replBridge.ts:768); v2 skips the param entirely.
|
||||
onInboundMessage,
|
||||
onUserMessage,
|
||||
onPermissionResponse,
|
||||
onInterrupt,
|
||||
onSetModel,
|
||||
onSetMaxThinkingTokens,
|
||||
onSetPermissionMode,
|
||||
onStateChange,
|
||||
outboundOnly,
|
||||
tags,
|
||||
})
|
||||
}
|
||||
|
||||
// ── v1 path: env-based (register/poll/ack/heartbeat) ──────────────────
|
||||
|
||||
const versionError = checkBridgeMinVersion()
|
||||
if (versionError) {
|
||||
logBridgeSkip('version_too_old', `[bridge:repl] Skipping: ${versionError}`)
|
||||
onStateChange?.('failed', 'run `claude update` to upgrade')
|
||||
return null
|
||||
}
|
||||
|
||||
// Gather git context — this is the bootstrap-read boundary.
|
||||
// Everything from here down is passed explicitly to bridgeCore.
|
||||
const branch = await getBranch()
|
||||
const gitRepoUrl = await getRemoteUrl()
|
||||
const sessionIngressUrl =
|
||||
process.env.USER_TYPE === 'ant' &&
|
||||
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
|
||||
? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
|
||||
: baseUrl
|
||||
|
||||
// Assistant-mode sessions advertise a distinct worker_type so the web UI
|
||||
// can filter them into a dedicated picker. KAIROS guard keeps the
|
||||
// assistant module out of external builds entirely.
|
||||
let workerType: BridgeWorkerType = 'claude_code'
|
||||
if (feature('KAIROS')) {
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const { isAssistantMode } =
|
||||
require('../assistant/index.js') as typeof import('../assistant/index.js')
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
if (isAssistantMode()) {
|
||||
workerType = 'claude_code_assistant'
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Delegate. BridgeCoreHandle is a structural superset of
|
||||
// ReplBridgeHandle (adds writeSdkMessages which REPL callers don't use),
|
||||
// so no adapter needed — just the narrower type on the way out.
|
||||
return initBridgeCore({
|
||||
dir: getOriginalCwd(),
|
||||
machineName: hostname(),
|
||||
branch,
|
||||
gitRepoUrl,
|
||||
title,
|
||||
baseUrl,
|
||||
sessionIngressUrl,
|
||||
workerType,
|
||||
getAccessToken: getBridgeAccessToken,
|
||||
createSession: opts =>
|
||||
createBridgeSession({
|
||||
...opts,
|
||||
events: [],
|
||||
baseUrl,
|
||||
getAccessToken: getBridgeAccessToken,
|
||||
}),
|
||||
archiveSession: sessionId =>
|
||||
archiveBridgeSession(sessionId, {
|
||||
baseUrl,
|
||||
getAccessToken: getBridgeAccessToken,
|
||||
// gracefulShutdown.ts:407 races runCleanupFunctions against 2s.
|
||||
// Teardown also does stopWork (parallel) + deregister (sequential),
|
||||
// so archive can't have the full budget. 1.5s matches v2's
|
||||
// teardown_archive_timeout_ms default.
|
||||
timeoutMs: 1500,
|
||||
}).catch((err: unknown) => {
|
||||
// archiveBridgeSession has no try/catch — 5xx/timeout/network throw
|
||||
// straight through. Previously swallowed silently, making archive
|
||||
// failures BQ-invisible and undiagnosable from debug logs.
|
||||
logForDebugging(
|
||||
`[bridge:repl] archiveBridgeSession threw: ${errorMessage(err)}`,
|
||||
{ level: 'error' },
|
||||
)
|
||||
}),
|
||||
// getCurrentTitle is read on reconnect-after-env-lost to re-title the new
|
||||
// session. /rename writes to session storage; onUserMessage mutates
|
||||
// `title` directly — both paths are picked up here.
|
||||
getCurrentTitle: () => getCurrentSessionTitle(getSessionId()) ?? title,
|
||||
onUserMessage,
|
||||
toSDKMessages,
|
||||
onAuth401: handleOAuth401Error,
|
||||
getPollIntervalConfig,
|
||||
initialHistoryCap,
|
||||
initialMessages,
|
||||
previouslyFlushedUUIDs,
|
||||
onInboundMessage,
|
||||
onPermissionResponse,
|
||||
onInterrupt,
|
||||
onSetModel,
|
||||
onSetMaxThinkingTokens,
|
||||
onSetPermissionMode,
|
||||
onStateChange,
|
||||
perpetual,
|
||||
})
|
||||
}
|
||||
|
||||
const TITLE_MAX_LEN = 50
|
||||
|
||||
/**
|
||||
* Quick placeholder title: strip display tags, take the first sentence,
|
||||
* collapse whitespace, truncate to 50 chars. Returns undefined if the result
|
||||
* is empty (e.g. message was only <local-command-stdout>). Replaced by
|
||||
* generateSessionTitle once Haiku resolves (~1-15s).
|
||||
*/
|
||||
function deriveTitle(raw: string): string | undefined {
|
||||
// Strip <ide_opened_file>, <session-start-hook>, etc. — these appear in
|
||||
// user messages when IDE/hooks inject context. stripDisplayTagsAllowEmpty
|
||||
// returns '' (not the original) so pure-tag messages are skipped.
|
||||
const clean = stripDisplayTagsAllowEmpty(raw)
|
||||
// First sentence is usually the intent; rest is often context/detail.
|
||||
// Capture group instead of lookbehind — keeps YARR JIT happy.
|
||||
const firstSentence = /^(.*?[.!?])\s/.exec(clean)?.[1] ?? clean
|
||||
// Collapse newlines/tabs — titles are single-line in the claude.ai list.
|
||||
const flat = firstSentence.replace(/\s+/g, ' ').trim()
|
||||
if (!flat) return undefined
|
||||
return flat.length > TITLE_MAX_LEN
|
||||
? flat.slice(0, TITLE_MAX_LEN - 1) + '\u2026'
|
||||
: flat
|
||||
}
|
||||
256
original-source-code/src/bridge/jwtUtils.ts
Normal file
256
original-source-code/src/bridge/jwtUtils.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { logEvent } from '../services/analytics/index.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { logForDiagnosticsNoPII } from '../utils/diagLogs.js'
|
||||
import { errorMessage } from '../utils/errors.js'
|
||||
import { jsonParse } from '../utils/slowOperations.js'
|
||||
|
||||
/** Format a millisecond duration as a human-readable string (e.g. "5m 30s"). */
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 60_000) return `${Math.round(ms / 1000)}s`
|
||||
const m = Math.floor(ms / 60_000)
|
||||
const s = Math.round((ms % 60_000) / 1000)
|
||||
return s > 0 ? `${m}m ${s}s` : `${m}m`
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a JWT's payload segment without verifying the signature.
|
||||
* Strips the `sk-ant-si-` session-ingress prefix if present.
|
||||
* Returns the parsed JSON payload as `unknown`, or `null` if the
|
||||
* token is malformed or the payload is not valid JSON.
|
||||
*/
|
||||
export function decodeJwtPayload(token: string): unknown | null {
|
||||
const jwt = token.startsWith('sk-ant-si-')
|
||||
? token.slice('sk-ant-si-'.length)
|
||||
: token
|
||||
const parts = jwt.split('.')
|
||||
if (parts.length !== 3 || !parts[1]) return null
|
||||
try {
|
||||
return jsonParse(Buffer.from(parts[1], 'base64url').toString('utf8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the `exp` (expiry) claim from a JWT without verifying the signature.
|
||||
* @returns The `exp` value in Unix seconds, or `null` if unparseable
|
||||
*/
|
||||
export function decodeJwtExpiry(token: string): number | null {
|
||||
const payload = decodeJwtPayload(token)
|
||||
if (
|
||||
payload !== null &&
|
||||
typeof payload === 'object' &&
|
||||
'exp' in payload &&
|
||||
typeof payload.exp === 'number'
|
||||
) {
|
||||
return payload.exp
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Refresh buffer: request a new token before expiry. */
|
||||
const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000
|
||||
|
||||
/** Fallback refresh interval when the new token's expiry is unknown. */
|
||||
const FALLBACK_REFRESH_INTERVAL_MS = 30 * 60 * 1000 // 30 minutes
|
||||
|
||||
/** Max consecutive failures before giving up on the refresh chain. */
|
||||
const MAX_REFRESH_FAILURES = 3
|
||||
|
||||
/** Retry delay when getAccessToken returns undefined. */
|
||||
const REFRESH_RETRY_DELAY_MS = 60_000
|
||||
|
||||
/**
|
||||
* Creates a token refresh scheduler that proactively refreshes session tokens
|
||||
* before they expire. Used by both the standalone bridge and the REPL bridge.
|
||||
*
|
||||
* When a token is about to expire, the scheduler calls `onRefresh` with the
|
||||
* session ID and the bridge's OAuth access token. The caller is responsible
|
||||
* for delivering the token to the appropriate transport (child process stdin
|
||||
* for standalone bridge, WebSocket reconnect for REPL bridge).
|
||||
*/
|
||||
export function createTokenRefreshScheduler({
|
||||
getAccessToken,
|
||||
onRefresh,
|
||||
label,
|
||||
refreshBufferMs = TOKEN_REFRESH_BUFFER_MS,
|
||||
}: {
|
||||
getAccessToken: () => string | undefined | Promise<string | undefined>
|
||||
onRefresh: (sessionId: string, oauthToken: string) => void
|
||||
label: string
|
||||
/** How long before expiry to fire refresh. Defaults to 5 min. */
|
||||
refreshBufferMs?: number
|
||||
}): {
|
||||
schedule: (sessionId: string, token: string) => void
|
||||
scheduleFromExpiresIn: (sessionId: string, expiresInSeconds: number) => void
|
||||
cancel: (sessionId: string) => void
|
||||
cancelAll: () => void
|
||||
} {
|
||||
const timers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
const failureCounts = new Map<string, number>()
|
||||
// Generation counter per session — incremented by schedule() and cancel()
|
||||
// so that in-flight async doRefresh() calls can detect when they've been
|
||||
// superseded and should skip setting follow-up timers.
|
||||
const generations = new Map<string, number>()
|
||||
|
||||
function nextGeneration(sessionId: string): number {
|
||||
const gen = (generations.get(sessionId) ?? 0) + 1
|
||||
generations.set(sessionId, gen)
|
||||
return gen
|
||||
}
|
||||
|
||||
function schedule(sessionId: string, token: string): void {
|
||||
const expiry = decodeJwtExpiry(token)
|
||||
if (!expiry) {
|
||||
// Token is not a decodable JWT (e.g. an OAuth token passed from the
|
||||
// REPL bridge WebSocket open handler). Preserve any existing timer
|
||||
// (such as the follow-up refresh set by doRefresh) so the refresh
|
||||
// chain is not broken.
|
||||
logForDebugging(
|
||||
`[${label}:token] Could not decode JWT expiry for sessionId=${sessionId}, token prefix=${token.slice(0, 15)}…, keeping existing timer`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Clear any existing refresh timer — we have a concrete expiry to replace it.
|
||||
const existing = timers.get(sessionId)
|
||||
if (existing) {
|
||||
clearTimeout(existing)
|
||||
}
|
||||
|
||||
// Bump generation to invalidate any in-flight async doRefresh.
|
||||
const gen = nextGeneration(sessionId)
|
||||
|
||||
const expiryDate = new Date(expiry * 1000).toISOString()
|
||||
const delayMs = expiry * 1000 - Date.now() - refreshBufferMs
|
||||
if (delayMs <= 0) {
|
||||
logForDebugging(
|
||||
`[${label}:token] Token for sessionId=${sessionId} expires=${expiryDate} (past or within buffer), refreshing immediately`,
|
||||
)
|
||||
void doRefresh(sessionId, gen)
|
||||
return
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`[${label}:token] Scheduled token refresh for sessionId=${sessionId} in ${formatDuration(delayMs)} (expires=${expiryDate}, buffer=${refreshBufferMs / 1000}s)`,
|
||||
)
|
||||
|
||||
const timer = setTimeout(doRefresh, delayMs, sessionId, gen)
|
||||
timers.set(sessionId, timer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule refresh using an explicit TTL (seconds until expiry) rather
|
||||
* than decoding a JWT's exp claim. Used by callers whose JWT is opaque
|
||||
* (e.g. POST /v1/code/sessions/{id}/bridge returns expires_in directly).
|
||||
*/
|
||||
function scheduleFromExpiresIn(
|
||||
sessionId: string,
|
||||
expiresInSeconds: number,
|
||||
): void {
|
||||
const existing = timers.get(sessionId)
|
||||
if (existing) clearTimeout(existing)
|
||||
const gen = nextGeneration(sessionId)
|
||||
// Clamp to 30s floor — if refreshBufferMs exceeds the server's expires_in
|
||||
// (e.g. very large buffer for frequent-refresh testing, or server shortens
|
||||
// expires_in unexpectedly), unclamped delayMs ≤ 0 would tight-loop.
|
||||
const delayMs = Math.max(expiresInSeconds * 1000 - refreshBufferMs, 30_000)
|
||||
logForDebugging(
|
||||
`[${label}:token] Scheduled token refresh for sessionId=${sessionId} in ${formatDuration(delayMs)} (expires_in=${expiresInSeconds}s, buffer=${refreshBufferMs / 1000}s)`,
|
||||
)
|
||||
const timer = setTimeout(doRefresh, delayMs, sessionId, gen)
|
||||
timers.set(sessionId, timer)
|
||||
}
|
||||
|
||||
async function doRefresh(sessionId: string, gen: number): Promise<void> {
|
||||
let oauthToken: string | undefined
|
||||
try {
|
||||
oauthToken = await getAccessToken()
|
||||
} catch (err) {
|
||||
logForDebugging(
|
||||
`[${label}:token] getAccessToken threw for sessionId=${sessionId}: ${errorMessage(err)}`,
|
||||
{ level: 'error' },
|
||||
)
|
||||
}
|
||||
|
||||
// If the session was cancelled or rescheduled while we were awaiting,
|
||||
// the generation will have changed — bail out to avoid orphaned timers.
|
||||
if (generations.get(sessionId) !== gen) {
|
||||
logForDebugging(
|
||||
`[${label}:token] doRefresh for sessionId=${sessionId} stale (gen ${gen} vs ${generations.get(sessionId)}), skipping`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (!oauthToken) {
|
||||
const failures = (failureCounts.get(sessionId) ?? 0) + 1
|
||||
failureCounts.set(sessionId, failures)
|
||||
logForDebugging(
|
||||
`[${label}:token] No OAuth token available for refresh, sessionId=${sessionId} (failure ${failures}/${MAX_REFRESH_FAILURES})`,
|
||||
{ level: 'error' },
|
||||
)
|
||||
logForDiagnosticsNoPII('error', 'bridge_token_refresh_no_oauth')
|
||||
// Schedule a retry so the refresh chain can recover if the token
|
||||
// becomes available again (e.g. transient cache clear during refresh).
|
||||
// Cap retries to avoid spamming on genuine failures.
|
||||
if (failures < MAX_REFRESH_FAILURES) {
|
||||
const retryTimer = setTimeout(
|
||||
doRefresh,
|
||||
REFRESH_RETRY_DELAY_MS,
|
||||
sessionId,
|
||||
gen,
|
||||
)
|
||||
timers.set(sessionId, retryTimer)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Reset failure counter on successful token retrieval
|
||||
failureCounts.delete(sessionId)
|
||||
|
||||
logForDebugging(
|
||||
`[${label}:token] Refreshing token for sessionId=${sessionId}: new token prefix=${oauthToken.slice(0, 15)}…`,
|
||||
)
|
||||
logEvent('tengu_bridge_token_refreshed', {})
|
||||
onRefresh(sessionId, oauthToken)
|
||||
|
||||
// Schedule a follow-up refresh so long-running sessions stay authenticated.
|
||||
// Without this, the initial one-shot timer leaves the session vulnerable
|
||||
// to token expiry if it runs past the first refresh window.
|
||||
const timer = setTimeout(
|
||||
doRefresh,
|
||||
FALLBACK_REFRESH_INTERVAL_MS,
|
||||
sessionId,
|
||||
gen,
|
||||
)
|
||||
timers.set(sessionId, timer)
|
||||
logForDebugging(
|
||||
`[${label}:token] Scheduled follow-up refresh for sessionId=${sessionId} in ${formatDuration(FALLBACK_REFRESH_INTERVAL_MS)}`,
|
||||
)
|
||||
}
|
||||
|
||||
function cancel(sessionId: string): void {
|
||||
// Bump generation to invalidate any in-flight async doRefresh.
|
||||
nextGeneration(sessionId)
|
||||
const timer = timers.get(sessionId)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timers.delete(sessionId)
|
||||
}
|
||||
failureCounts.delete(sessionId)
|
||||
}
|
||||
|
||||
function cancelAll(): void {
|
||||
// Bump all generations so in-flight doRefresh calls are invalidated.
|
||||
for (const sessionId of generations.keys()) {
|
||||
nextGeneration(sessionId)
|
||||
}
|
||||
for (const timer of timers.values()) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
timers.clear()
|
||||
failureCounts.clear()
|
||||
}
|
||||
|
||||
return { schedule, scheduleFromExpiresIn, cancel, cancelAll }
|
||||
}
|
||||
110
original-source-code/src/bridge/pollConfig.ts
Normal file
110
original-source-code/src/bridge/pollConfig.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { z } from 'zod/v4'
|
||||
import { getFeatureValue_CACHED_WITH_REFRESH } from '../services/analytics/growthbook.js'
|
||||
import { lazySchema } from '../utils/lazySchema.js'
|
||||
import {
|
||||
DEFAULT_POLL_CONFIG,
|
||||
type PollIntervalConfig,
|
||||
} from './pollConfigDefaults.js'
|
||||
|
||||
// .min(100) on the seek-work intervals restores the old Math.max(..., 100)
|
||||
// defense-in-depth floor against fat-fingered GrowthBook values. Unlike a
|
||||
// clamp, Zod rejects the whole object on violation — a config with one bad
|
||||
// field falls back to DEFAULT_POLL_CONFIG entirely rather than being
|
||||
// partially trusted.
|
||||
//
|
||||
// The at_capacity intervals use a 0-or-≥100 refinement: 0 means "disabled"
|
||||
// (heartbeat-only mode), ≥100 is the fat-finger floor. Values 1–99 are
|
||||
// rejected so unit confusion (ops thinks seconds, enters 10) doesn't poll
|
||||
// every 10ms against the VerifyEnvironmentSecretAuth DB path.
|
||||
//
|
||||
// The object-level refines require at least one at-capacity liveness
|
||||
// mechanism enabled: heartbeat OR the relevant poll interval. Without this,
|
||||
// the hb=0, atCapMs=0 drift config (ops disables heartbeat without
|
||||
// restoring at_capacity) falls through every throttle site with no sleep —
|
||||
// tight-looping /poll at HTTP-round-trip speed.
|
||||
const zeroOrAtLeast100 = {
|
||||
message: 'must be 0 (disabled) or ≥100ms',
|
||||
}
|
||||
const pollIntervalConfigSchema = lazySchema(() =>
|
||||
z
|
||||
.object({
|
||||
poll_interval_ms_not_at_capacity: z.number().int().min(100),
|
||||
// 0 = no at-capacity polling. Independent of heartbeat — both can be
|
||||
// enabled (heartbeat runs, periodically breaks out to poll).
|
||||
poll_interval_ms_at_capacity: z
|
||||
.number()
|
||||
.int()
|
||||
.refine(v => v === 0 || v >= 100, zeroOrAtLeast100),
|
||||
// 0 = disabled; positive value = heartbeat at this interval while at
|
||||
// capacity. Runs alongside at-capacity polling, not instead of it.
|
||||
// Named non_exclusive to distinguish from the old heartbeat_interval_ms
|
||||
// (either-or semantics in pre-#22145 clients). .default(0) so existing
|
||||
// GrowthBook configs without this field parse successfully.
|
||||
non_exclusive_heartbeat_interval_ms: z.number().int().min(0).default(0),
|
||||
// Multisession (bridgeMain.ts) intervals. Defaults match the
|
||||
// single-session values so existing configs without these fields
|
||||
// preserve current behavior.
|
||||
multisession_poll_interval_ms_not_at_capacity: z
|
||||
.number()
|
||||
.int()
|
||||
.min(100)
|
||||
.default(
|
||||
DEFAULT_POLL_CONFIG.multisession_poll_interval_ms_not_at_capacity,
|
||||
),
|
||||
multisession_poll_interval_ms_partial_capacity: z
|
||||
.number()
|
||||
.int()
|
||||
.min(100)
|
||||
.default(
|
||||
DEFAULT_POLL_CONFIG.multisession_poll_interval_ms_partial_capacity,
|
||||
),
|
||||
multisession_poll_interval_ms_at_capacity: z
|
||||
.number()
|
||||
.int()
|
||||
.refine(v => v === 0 || v >= 100, zeroOrAtLeast100)
|
||||
.default(DEFAULT_POLL_CONFIG.multisession_poll_interval_ms_at_capacity),
|
||||
// .min(1) matches the server's ge=1 constraint (work_v1.py:230).
|
||||
reclaim_older_than_ms: z.number().int().min(1).default(5000),
|
||||
session_keepalive_interval_v2_ms: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.default(120_000),
|
||||
})
|
||||
.refine(
|
||||
cfg =>
|
||||
cfg.non_exclusive_heartbeat_interval_ms > 0 ||
|
||||
cfg.poll_interval_ms_at_capacity > 0,
|
||||
{
|
||||
message:
|
||||
'at-capacity liveness requires non_exclusive_heartbeat_interval_ms > 0 or poll_interval_ms_at_capacity > 0',
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
cfg =>
|
||||
cfg.non_exclusive_heartbeat_interval_ms > 0 ||
|
||||
cfg.multisession_poll_interval_ms_at_capacity > 0,
|
||||
{
|
||||
message:
|
||||
'at-capacity liveness requires non_exclusive_heartbeat_interval_ms > 0 or multisession_poll_interval_ms_at_capacity > 0',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Fetch the bridge poll interval config from GrowthBook with a 5-minute
|
||||
* refresh window. Validates the served JSON against the schema; falls back
|
||||
* to defaults if the flag is absent, malformed, or partially-specified.
|
||||
*
|
||||
* Shared by bridgeMain.ts (standalone) and replBridge.ts (REPL) so ops
|
||||
* can tune both poll rates fleet-wide with a single config push.
|
||||
*/
|
||||
export function getPollIntervalConfig(): PollIntervalConfig {
|
||||
const raw = getFeatureValue_CACHED_WITH_REFRESH<unknown>(
|
||||
'tengu_bridge_poll_interval_config',
|
||||
DEFAULT_POLL_CONFIG,
|
||||
5 * 60 * 1000,
|
||||
)
|
||||
const parsed = pollIntervalConfigSchema().safeParse(raw)
|
||||
return parsed.success ? parsed.data : DEFAULT_POLL_CONFIG
|
||||
}
|
||||
82
original-source-code/src/bridge/pollConfigDefaults.ts
Normal file
82
original-source-code/src/bridge/pollConfigDefaults.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Bridge poll interval defaults. Extracted from pollConfig.ts so callers
|
||||
* that don't need live GrowthBook tuning (daemon via Agent SDK) can avoid
|
||||
* the growthbook.ts → config.ts → file.ts → sessionStorage.ts → commands.ts
|
||||
* transitive dependency chain.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Poll interval when actively seeking work (no transport / below maxSessions).
|
||||
* Governs user-visible "connecting…" latency on initial work pickup and
|
||||
* recovery speed after the server re-dispatches a work item.
|
||||
*/
|
||||
const POLL_INTERVAL_MS_NOT_AT_CAPACITY = 2000
|
||||
|
||||
/**
|
||||
* Poll interval when the transport is connected. Runs independently of
|
||||
* heartbeat — when both are enabled, the heartbeat loop breaks out to poll
|
||||
* at this interval. Set to 0 to disable at-capacity polling entirely.
|
||||
*
|
||||
* Server-side constraints that bound this value:
|
||||
* - BRIDGE_LAST_POLL_TTL = 4h (Redis key expiry → environment auto-archived)
|
||||
* - max_poll_stale_seconds = 24h (session-creation health gate, currently disabled)
|
||||
*
|
||||
* 10 minutes gives 24× headroom on the Redis TTL while still picking up
|
||||
* server-initiated token-rotation redispatches within one poll cycle.
|
||||
* The transport auto-reconnects internally for 10 minutes on transient WS
|
||||
* failures, so poll is not the recovery path — it's strictly a liveness
|
||||
* signal plus a backstop for permanent close.
|
||||
*/
|
||||
const POLL_INTERVAL_MS_AT_CAPACITY = 600_000
|
||||
|
||||
/**
|
||||
* Multisession bridge (bridgeMain.ts) poll intervals. Defaults match the
|
||||
* single-session values so existing GrowthBook configs without these fields
|
||||
* preserve current behavior. Ops can tune these independently via the
|
||||
* tengu_bridge_poll_interval_config GB flag.
|
||||
*/
|
||||
const MULTISESSION_POLL_INTERVAL_MS_NOT_AT_CAPACITY =
|
||||
POLL_INTERVAL_MS_NOT_AT_CAPACITY
|
||||
const MULTISESSION_POLL_INTERVAL_MS_PARTIAL_CAPACITY =
|
||||
POLL_INTERVAL_MS_NOT_AT_CAPACITY
|
||||
const MULTISESSION_POLL_INTERVAL_MS_AT_CAPACITY = POLL_INTERVAL_MS_AT_CAPACITY
|
||||
|
||||
export type PollIntervalConfig = {
|
||||
poll_interval_ms_not_at_capacity: number
|
||||
poll_interval_ms_at_capacity: number
|
||||
non_exclusive_heartbeat_interval_ms: number
|
||||
multisession_poll_interval_ms_not_at_capacity: number
|
||||
multisession_poll_interval_ms_partial_capacity: number
|
||||
multisession_poll_interval_ms_at_capacity: number
|
||||
reclaim_older_than_ms: number
|
||||
session_keepalive_interval_v2_ms: number
|
||||
}
|
||||
|
||||
export const DEFAULT_POLL_CONFIG: PollIntervalConfig = {
|
||||
poll_interval_ms_not_at_capacity: POLL_INTERVAL_MS_NOT_AT_CAPACITY,
|
||||
poll_interval_ms_at_capacity: POLL_INTERVAL_MS_AT_CAPACITY,
|
||||
// 0 = disabled. When > 0, at-capacity loops send per-work-item heartbeats
|
||||
// at this interval. Independent of poll_interval_ms_at_capacity — both may
|
||||
// run (heartbeat periodically yields to poll). 60s gives 5× headroom under
|
||||
// the server's 300s heartbeat TTL. Named non_exclusive to distinguish from
|
||||
// the old heartbeat_interval_ms field (either-or semantics in pre-#22145
|
||||
// clients — heartbeat suppressed poll). Old clients ignore this key; ops
|
||||
// can set both fields during rollout.
|
||||
non_exclusive_heartbeat_interval_ms: 0,
|
||||
multisession_poll_interval_ms_not_at_capacity:
|
||||
MULTISESSION_POLL_INTERVAL_MS_NOT_AT_CAPACITY,
|
||||
multisession_poll_interval_ms_partial_capacity:
|
||||
MULTISESSION_POLL_INTERVAL_MS_PARTIAL_CAPACITY,
|
||||
multisession_poll_interval_ms_at_capacity:
|
||||
MULTISESSION_POLL_INTERVAL_MS_AT_CAPACITY,
|
||||
// Poll query param: reclaim unacknowledged work items older than this.
|
||||
// Matches the server's DEFAULT_RECLAIM_OLDER_THAN_MS (work_service.py:24).
|
||||
// Enables picking up stale-pending work after JWT expiry, when the prior
|
||||
// ack failed because the session_ingress_token was already stale.
|
||||
reclaim_older_than_ms: 5000,
|
||||
// 0 = disabled. When > 0, push a silent {type:'keep_alive'} frame to
|
||||
// session-ingress at this interval so upstream proxies don't GC an idle
|
||||
// remote-control session. 2 min is the default. _v2: bridge-only gate
|
||||
// (pre-v2 clients read the old key, new clients ignore it).
|
||||
session_keepalive_interval_v2_ms: 120_000,
|
||||
}
|
||||
1008
original-source-code/src/bridge/remoteBridgeCore.ts
Normal file
1008
original-source-code/src/bridge/remoteBridgeCore.ts
Normal file
File diff suppressed because it is too large
Load Diff
2406
original-source-code/src/bridge/replBridge.ts
Normal file
2406
original-source-code/src/bridge/replBridge.ts
Normal file
File diff suppressed because it is too large
Load Diff
36
original-source-code/src/bridge/replBridgeHandle.ts
Normal file
36
original-source-code/src/bridge/replBridgeHandle.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { updateSessionBridgeId } from '../utils/concurrentSessions.js'
|
||||
import type { ReplBridgeHandle } from './replBridge.js'
|
||||
import { toCompatSessionId } from './sessionIdCompat.js'
|
||||
|
||||
/**
|
||||
* Global pointer to the active REPL bridge handle, so callers outside
|
||||
* useReplBridge's React tree (tools, slash commands) can invoke handle methods
|
||||
* like subscribePR. Same one-bridge-per-process justification as bridgeDebug.ts
|
||||
* — the handle's closure captures the sessionId and getAccessToken that created
|
||||
* the session, and re-deriving those independently (BriefTool/upload.ts pattern)
|
||||
* risks staging/prod token divergence.
|
||||
*
|
||||
* Set from useReplBridge.tsx when init completes; cleared on teardown.
|
||||
*/
|
||||
|
||||
let handle: ReplBridgeHandle | null = null
|
||||
|
||||
export function setReplBridgeHandle(h: ReplBridgeHandle | null): void {
|
||||
handle = h
|
||||
// Publish (or clear) our bridge session ID in the session record so other
|
||||
// local peers can dedup us out of their bridge list — local is preferred.
|
||||
void updateSessionBridgeId(getSelfBridgeCompatId() ?? null).catch(() => {})
|
||||
}
|
||||
|
||||
export function getReplBridgeHandle(): ReplBridgeHandle | null {
|
||||
return handle
|
||||
}
|
||||
|
||||
/**
|
||||
* Our own bridge session ID in the session_* compat format the API returns
|
||||
* in /v1/sessions responses — or undefined if bridge isn't connected.
|
||||
*/
|
||||
export function getSelfBridgeCompatId(): string | undefined {
|
||||
const h = getReplBridgeHandle()
|
||||
return h ? toCompatSessionId(h.bridgeSessionId) : undefined
|
||||
}
|
||||
370
original-source-code/src/bridge/replBridgeTransport.ts
Normal file
370
original-source-code/src/bridge/replBridgeTransport.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
import type { StdoutMessage } from 'src/entrypoints/sdk/controlTypes.js'
|
||||
import { CCRClient } from '../cli/transports/ccrClient.js'
|
||||
import type { HybridTransport } from '../cli/transports/HybridTransport.js'
|
||||
import { SSETransport } from '../cli/transports/SSETransport.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { errorMessage } from '../utils/errors.js'
|
||||
import { updateSessionIngressAuthToken } from '../utils/sessionIngressAuth.js'
|
||||
import type { SessionState } from '../utils/sessionState.js'
|
||||
import { registerWorker } from './workSecret.js'
|
||||
|
||||
/**
|
||||
* Transport abstraction for replBridge. Covers exactly the surface that
|
||||
* replBridge.ts uses against HybridTransport so the v1/v2 choice is
|
||||
* confined to the construction site.
|
||||
*
|
||||
* - v1: HybridTransport (WS reads + POST writes to Session-Ingress)
|
||||
* - v2: SSETransport (reads) + CCRClient (writes to CCR v2 /worker/*)
|
||||
*
|
||||
* The v2 write path goes through CCRClient.writeEvent → SerialBatchEventUploader,
|
||||
* NOT through SSETransport.write() — SSETransport.write() targets the
|
||||
* Session-Ingress POST URL shape, which is wrong for CCR v2.
|
||||
*/
|
||||
export type ReplBridgeTransport = {
|
||||
write(message: StdoutMessage): Promise<void>
|
||||
writeBatch(messages: StdoutMessage[]): Promise<void>
|
||||
close(): void
|
||||
isConnectedStatus(): boolean
|
||||
getStateLabel(): string
|
||||
setOnData(callback: (data: string) => void): void
|
||||
setOnClose(callback: (closeCode?: number) => void): void
|
||||
setOnConnect(callback: () => void): void
|
||||
connect(): void
|
||||
/**
|
||||
* High-water mark of the underlying read stream's event sequence numbers.
|
||||
* replBridge reads this before swapping transports so the new one can
|
||||
* resume from where the old one left off (otherwise the server replays
|
||||
* the entire session history from seq 0).
|
||||
*
|
||||
* v1 returns 0 — Session-Ingress WS doesn't use SSE sequence numbers;
|
||||
* replay-on-reconnect is handled by the server-side message cursor.
|
||||
*/
|
||||
getLastSequenceNum(): number
|
||||
/**
|
||||
* Monotonic count of batches dropped via maxConsecutiveFailures.
|
||||
* Snapshot before writeBatch() and compare after to detect silent drops
|
||||
* (writeBatch() resolves normally even when batches were dropped).
|
||||
* v2 returns 0 — the v2 write path doesn't set maxConsecutiveFailures.
|
||||
*/
|
||||
readonly droppedBatchCount: number
|
||||
/**
|
||||
* PUT /worker state (v2 only; v1 is a no-op). `requires_action` tells
|
||||
* the backend a permission prompt is pending — claude.ai shows the
|
||||
* "waiting for input" indicator. REPL/daemon callers don't need this
|
||||
* (user watches the REPL locally); multi-session worker callers do.
|
||||
*/
|
||||
reportState(state: SessionState): void
|
||||
/** PUT /worker external_metadata (v2 only; v1 is a no-op). */
|
||||
reportMetadata(metadata: Record<string, unknown>): void
|
||||
/**
|
||||
* POST /worker/events/{id}/delivery (v2 only; v1 is a no-op). Populates
|
||||
* CCR's processing_at/processed_at columns. `received` is auto-fired by
|
||||
* CCRClient on every SSE frame and is not exposed here.
|
||||
*/
|
||||
reportDelivery(eventId: string, status: 'processing' | 'processed'): void
|
||||
/**
|
||||
* Drain the write queue before close() (v2 only; v1 resolves
|
||||
* immediately — HybridTransport POSTs are already awaited per-write).
|
||||
*/
|
||||
flush(): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* v1 adapter: HybridTransport already has the full surface (it extends
|
||||
* WebSocketTransport which has setOnConnect + getStateLabel). This is a
|
||||
* no-op wrapper that exists only so replBridge's `transport` variable
|
||||
* has a single type.
|
||||
*/
|
||||
export function createV1ReplTransport(
|
||||
hybrid: HybridTransport,
|
||||
): ReplBridgeTransport {
|
||||
return {
|
||||
write: msg => hybrid.write(msg),
|
||||
writeBatch: msgs => hybrid.writeBatch(msgs),
|
||||
close: () => hybrid.close(),
|
||||
isConnectedStatus: () => hybrid.isConnectedStatus(),
|
||||
getStateLabel: () => hybrid.getStateLabel(),
|
||||
setOnData: cb => hybrid.setOnData(cb),
|
||||
setOnClose: cb => hybrid.setOnClose(cb),
|
||||
setOnConnect: cb => hybrid.setOnConnect(cb),
|
||||
connect: () => void hybrid.connect(),
|
||||
// v1 Session-Ingress WS doesn't use SSE sequence numbers; replay
|
||||
// semantics are different. Always return 0 so the seq-num carryover
|
||||
// logic in replBridge is a no-op for v1.
|
||||
getLastSequenceNum: () => 0,
|
||||
get droppedBatchCount() {
|
||||
return hybrid.droppedBatchCount
|
||||
},
|
||||
reportState: () => {},
|
||||
reportMetadata: () => {},
|
||||
reportDelivery: () => {},
|
||||
flush: () => Promise.resolve(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v2 adapter: wrap SSETransport (reads) + CCRClient (writes, heartbeat,
|
||||
* state, delivery tracking).
|
||||
*
|
||||
* Auth: v2 endpoints validate the JWT's session_id claim (register_worker.go:32)
|
||||
* and worker role (environment_auth.py:856). OAuth tokens have neither.
|
||||
* This is the inverse of the v1 replBridge path, which deliberately uses OAuth.
|
||||
* The JWT is refreshed when the poll loop re-dispatches work — the caller
|
||||
* invokes createV2ReplTransport again with the fresh token.
|
||||
*
|
||||
* Registration happens here (not in the caller) so the entire v2 handshake
|
||||
* is one async step. registerWorker failure propagates — replBridge will
|
||||
* catch it and stay on the poll loop.
|
||||
*/
|
||||
export async function createV2ReplTransport(opts: {
|
||||
sessionUrl: string
|
||||
ingressToken: string
|
||||
sessionId: string
|
||||
/**
|
||||
* SSE sequence-number high-water mark from the previous transport.
|
||||
* Passed to the new SSETransport so its first connect() sends
|
||||
* from_sequence_num / Last-Event-ID and the server resumes from where
|
||||
* the old stream left off. Without this, every transport swap asks the
|
||||
* server to replay the entire session history from seq 0.
|
||||
*/
|
||||
initialSequenceNum?: number
|
||||
/**
|
||||
* Worker epoch from POST /bridge response. When provided, the server
|
||||
* already bumped epoch (the /bridge call IS the register — see server
|
||||
* PR #293280). When omitted (v1 CCR-v2 path via replBridge.ts poll loop),
|
||||
* call registerWorker as before.
|
||||
*/
|
||||
epoch?: number
|
||||
/** CCRClient heartbeat interval. Defaults to 20s when omitted. */
|
||||
heartbeatIntervalMs?: number
|
||||
/** ±fraction per-beat jitter. Defaults to 0 (no jitter) when omitted. */
|
||||
heartbeatJitterFraction?: number
|
||||
/**
|
||||
* When true, skip opening the SSE read stream — only the CCRClient write
|
||||
* path is activated. Use for mirror-mode attachments that forward events
|
||||
* but never receive inbound prompts or control requests.
|
||||
*/
|
||||
outboundOnly?: boolean
|
||||
/**
|
||||
* Per-instance auth header source. When provided, CCRClient + SSETransport
|
||||
* read auth from this closure instead of the process-wide
|
||||
* CLAUDE_CODE_SESSION_ACCESS_TOKEN env var. Required for callers managing
|
||||
* multiple concurrent sessions — the env-var path stomps across sessions.
|
||||
* When omitted, falls back to the env var (single-session callers).
|
||||
*/
|
||||
getAuthToken?: () => string | undefined
|
||||
}): Promise<ReplBridgeTransport> {
|
||||
const {
|
||||
sessionUrl,
|
||||
ingressToken,
|
||||
sessionId,
|
||||
initialSequenceNum,
|
||||
getAuthToken,
|
||||
} = opts
|
||||
|
||||
// Auth header builder. If getAuthToken is provided, read from it
|
||||
// (per-instance, multi-session safe). Otherwise write ingressToken to
|
||||
// the process-wide env var (legacy single-session path — CCRClient's
|
||||
// default getAuthHeaders reads it via getSessionIngressAuthHeaders).
|
||||
let getAuthHeaders: (() => Record<string, string>) | undefined
|
||||
if (getAuthToken) {
|
||||
getAuthHeaders = (): Record<string, string> => {
|
||||
const token = getAuthToken()
|
||||
if (!token) return {}
|
||||
return { Authorization: `Bearer ${token}` }
|
||||
}
|
||||
} else {
|
||||
// CCRClient.request() and SSETransport.connect() both read auth via
|
||||
// getSessionIngressAuthHeaders() → this env var. Set it before either
|
||||
// touches the network.
|
||||
updateSessionIngressAuthToken(ingressToken)
|
||||
}
|
||||
|
||||
const epoch = opts.epoch ?? (await registerWorker(sessionUrl, ingressToken))
|
||||
logForDebugging(
|
||||
`[bridge:repl] CCR v2: worker sessionId=${sessionId} epoch=${epoch}${opts.epoch !== undefined ? ' (from /bridge)' : ' (via registerWorker)'}`,
|
||||
)
|
||||
|
||||
// Derive SSE stream URL. Same logic as transportUtils.ts:26-33 but
|
||||
// starting from an http(s) base instead of a --sdk-url that might be ws://.
|
||||
const sseUrl = new URL(sessionUrl)
|
||||
sseUrl.pathname = sseUrl.pathname.replace(/\/$/, '') + '/worker/events/stream'
|
||||
|
||||
const sse = new SSETransport(
|
||||
sseUrl,
|
||||
{},
|
||||
sessionId,
|
||||
undefined,
|
||||
initialSequenceNum,
|
||||
getAuthHeaders,
|
||||
)
|
||||
let onCloseCb: ((closeCode?: number) => void) | undefined
|
||||
const ccr = new CCRClient(sse, new URL(sessionUrl), {
|
||||
getAuthHeaders,
|
||||
heartbeatIntervalMs: opts.heartbeatIntervalMs,
|
||||
heartbeatJitterFraction: opts.heartbeatJitterFraction,
|
||||
// Default is process.exit(1) — correct for spawn-mode children. In-process,
|
||||
// that kills the REPL. Close instead: replBridge's onClose wakes the poll
|
||||
// loop, which picks up the server's re-dispatch (with fresh epoch).
|
||||
onEpochMismatch: () => {
|
||||
logForDebugging(
|
||||
'[bridge:repl] CCR v2: epoch superseded (409) — closing for poll-loop recovery',
|
||||
)
|
||||
// Close resources in a try block so the throw always executes.
|
||||
// If ccr.close() or sse.close() throw, we still need to unwind
|
||||
// the caller (request()) — otherwise handleEpochMismatch's `never`
|
||||
// return type is violated at runtime and control falls through.
|
||||
try {
|
||||
ccr.close()
|
||||
sse.close()
|
||||
onCloseCb?.(4090)
|
||||
} catch (closeErr: unknown) {
|
||||
logForDebugging(
|
||||
`[bridge:repl] CCR v2: error during epoch-mismatch cleanup: ${errorMessage(closeErr)}`,
|
||||
{ level: 'error' },
|
||||
)
|
||||
}
|
||||
// Don't return — the calling request() code continues after the 409
|
||||
// branch, so callers see the logged warning and a false return. We
|
||||
// throw to unwind; the uploaders catch it as a send failure.
|
||||
throw new Error('epoch superseded')
|
||||
},
|
||||
})
|
||||
|
||||
// CCRClient's constructor wired sse.setOnEvent → reportDelivery('received').
|
||||
// remoteIO.ts additionally sends 'processing'/'processed' via
|
||||
// setCommandLifecycleListener, which the in-process query loop fires. This
|
||||
// transport's only caller (replBridge/daemonBridge) has no such wiring — the
|
||||
// daemon's agent child is a separate process (ProcessTransport), and its
|
||||
// notifyCommandLifecycle calls fire with listener=null in its own module
|
||||
// scope. So events stay at 'received' forever, and reconnectSession re-queues
|
||||
// them on every daemon restart (observed: 21→24→25 phantom prompts as
|
||||
// "user sent a new message while you were working" system-reminders).
|
||||
//
|
||||
// Fix: ACK 'processed' immediately alongside 'received'. The window between
|
||||
// SSE receipt and transcript-write is narrow (queue → SDK → child stdin →
|
||||
// model); a crash there loses one prompt vs. the observed N-prompt flood on
|
||||
// every restart. Overwrite the constructor's wiring to do both — setOnEvent
|
||||
// replaces, not appends (SSETransport.ts:658).
|
||||
sse.setOnEvent(event => {
|
||||
ccr.reportDelivery(event.event_id, 'received')
|
||||
ccr.reportDelivery(event.event_id, 'processed')
|
||||
})
|
||||
|
||||
// Both sse.connect() and ccr.initialize() are deferred to connect() below.
|
||||
// replBridge's calling order is newTransport → setOnConnect → setOnData →
|
||||
// setOnClose → connect(), and both calls need those callbacks wired first:
|
||||
// sse.connect() opens the stream (events flow to onData/onClose immediately),
|
||||
// and ccr.initialize().then() fires onConnectCb.
|
||||
//
|
||||
// onConnect fires once ccr.initialize() resolves. Writes go via
|
||||
// CCRClient HTTP POST (SerialBatchEventUploader), not SSE, so the
|
||||
// write path is ready the moment workerEpoch is set. SSE.connect()
|
||||
// awaits its read loop and never resolves — don't gate on it.
|
||||
// The SSE stream opens in parallel (~30ms) and starts delivering
|
||||
// inbound events via setOnData; outbound doesn't need to wait for it.
|
||||
let onConnectCb: (() => void) | undefined
|
||||
let ccrInitialized = false
|
||||
let closed = false
|
||||
|
||||
return {
|
||||
write(msg) {
|
||||
return ccr.writeEvent(msg)
|
||||
},
|
||||
async writeBatch(msgs) {
|
||||
// SerialBatchEventUploader already batches internally (maxBatchSize=100);
|
||||
// sequential enqueue preserves order and the uploader coalesces.
|
||||
// Check closed between writes to avoid sending partial batches after
|
||||
// transport teardown (epoch mismatch, SSE drop).
|
||||
for (const m of msgs) {
|
||||
if (closed) break
|
||||
await ccr.writeEvent(m)
|
||||
}
|
||||
},
|
||||
close() {
|
||||
closed = true
|
||||
ccr.close()
|
||||
sse.close()
|
||||
},
|
||||
isConnectedStatus() {
|
||||
// Write-readiness, not read-readiness — replBridge checks this
|
||||
// before calling writeBatch. SSE open state is orthogonal.
|
||||
return ccrInitialized
|
||||
},
|
||||
getStateLabel() {
|
||||
// SSETransport doesn't expose its state string; synthesize from
|
||||
// what we can observe. replBridge only uses this for debug logging.
|
||||
if (sse.isClosedStatus()) return 'closed'
|
||||
if (sse.isConnectedStatus()) return ccrInitialized ? 'connected' : 'init'
|
||||
return 'connecting'
|
||||
},
|
||||
setOnData(cb) {
|
||||
sse.setOnData(cb)
|
||||
},
|
||||
setOnClose(cb) {
|
||||
onCloseCb = cb
|
||||
// SSE reconnect-budget exhaustion fires onClose(undefined) — map to
|
||||
// 4092 so ws_closed telemetry can distinguish it from HTTP-status
|
||||
// closes (SSETransport:280 passes response.status). Stop CCRClient's
|
||||
// heartbeat timer before notifying replBridge. (sse.close() doesn't
|
||||
// invoke this, so the epoch-mismatch path above isn't double-firing.)
|
||||
sse.setOnClose(code => {
|
||||
ccr.close()
|
||||
cb(code ?? 4092)
|
||||
})
|
||||
},
|
||||
setOnConnect(cb) {
|
||||
onConnectCb = cb
|
||||
},
|
||||
getLastSequenceNum() {
|
||||
return sse.getLastSequenceNum()
|
||||
},
|
||||
// v2 write path (CCRClient) doesn't set maxConsecutiveFailures — no drops.
|
||||
droppedBatchCount: 0,
|
||||
reportState(state) {
|
||||
ccr.reportState(state)
|
||||
},
|
||||
reportMetadata(metadata) {
|
||||
ccr.reportMetadata(metadata)
|
||||
},
|
||||
reportDelivery(eventId, status) {
|
||||
ccr.reportDelivery(eventId, status)
|
||||
},
|
||||
flush() {
|
||||
return ccr.flush()
|
||||
},
|
||||
connect() {
|
||||
// Outbound-only: skip the SSE read stream entirely — no inbound
|
||||
// events to receive, no delivery ACKs to send. Only the CCRClient
|
||||
// write path (POST /worker/events) and heartbeat are needed.
|
||||
if (!opts.outboundOnly) {
|
||||
// Fire-and-forget — SSETransport.connect() awaits readStream()
|
||||
// (the read loop) and only resolves on stream close/error. The
|
||||
// spawn-mode path in remoteIO.ts does the same void discard.
|
||||
void sse.connect()
|
||||
}
|
||||
void ccr.initialize(epoch).then(
|
||||
() => {
|
||||
ccrInitialized = true
|
||||
logForDebugging(
|
||||
`[bridge:repl] v2 transport ready for writes (epoch=${epoch}, sse=${sse.isConnectedStatus() ? 'open' : 'opening'})`,
|
||||
)
|
||||
onConnectCb?.()
|
||||
},
|
||||
(err: unknown) => {
|
||||
logForDebugging(
|
||||
`[bridge:repl] CCR v2 initialize failed: ${errorMessage(err)}`,
|
||||
{ level: 'error' },
|
||||
)
|
||||
// Close transport resources and notify replBridge via onClose
|
||||
// so the poll loop can retry on the next work dispatch.
|
||||
// Without this callback, replBridge never learns the transport
|
||||
// failed to initialize and sits with transport === null forever.
|
||||
ccr.close()
|
||||
sse.close()
|
||||
onCloseCb?.(4091) // 4091 = init failure, distinguishable from 4090 epoch mismatch
|
||||
},
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
57
original-source-code/src/bridge/sessionIdCompat.ts
Normal file
57
original-source-code/src/bridge/sessionIdCompat.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Session ID tag translation helpers for the CCR v2 compat layer.
|
||||
*
|
||||
* Lives in its own file (rather than workSecret.ts) so that sessionHandle.ts
|
||||
* and replBridgeTransport.ts (bridge.mjs entry points) can import from
|
||||
* workSecret.ts without pulling in these retag functions.
|
||||
*
|
||||
* The isCseShimEnabled kill switch is injected via setCseShimGate() to avoid
|
||||
* a static import of bridgeEnabled.ts → growthbook.ts → config.ts — all
|
||||
* banned from the sdk.mjs bundle (scripts/build-agent-sdk.sh). Callers that
|
||||
* already import bridgeEnabled.ts register the gate; the SDK path never does,
|
||||
* so the shim defaults to active (matching isCseShimEnabled()'s own default).
|
||||
*/
|
||||
|
||||
let _isCseShimEnabled: (() => boolean) | undefined
|
||||
|
||||
/**
|
||||
* Register the GrowthBook gate for the cse_ shim. Called from bridge
|
||||
* init code that already imports bridgeEnabled.ts.
|
||||
*/
|
||||
export function setCseShimGate(gate: () => boolean): void {
|
||||
_isCseShimEnabled = gate
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-tag a `cse_*` session ID to `session_*` for use with the v1 compat API.
|
||||
*
|
||||
* Worker endpoints (/v1/code/sessions/{id}/worker/*) want `cse_*`; that's
|
||||
* what the work poll delivers. Client-facing compat endpoints
|
||||
* (/v1/sessions/{id}, /v1/sessions/{id}/archive, /v1/sessions/{id}/events)
|
||||
* want `session_*` — compat/convert.go:27 validates TagSession. Same UUID,
|
||||
* different costume. No-op for IDs that aren't `cse_*`.
|
||||
*
|
||||
* bridgeMain holds one sessionId variable for both worker registration and
|
||||
* session-management calls. It arrives as `cse_*` from the work poll under
|
||||
* the compat gate, so archiveSession/fetchSessionTitle need this re-tag.
|
||||
*/
|
||||
export function toCompatSessionId(id: string): string {
|
||||
if (!id.startsWith('cse_')) return id
|
||||
if (_isCseShimEnabled && !_isCseShimEnabled()) return id
|
||||
return 'session_' + id.slice('cse_'.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-tag a `session_*` session ID to `cse_*` for infrastructure-layer calls.
|
||||
*
|
||||
* Inverse of toCompatSessionId. POST /v1/environments/{id}/bridge/reconnect
|
||||
* lives below the compat layer: once ccr_v2_compat_enabled is on server-side,
|
||||
* it looks sessions up by their infra tag (`cse_*`). createBridgeSession still
|
||||
* returns `session_*` (compat/convert.go:41) and that's what bridge-pointer
|
||||
* stores — so perpetual reconnect passes the wrong costume and gets "Session
|
||||
* not found" back. Same UUID, wrong tag. No-op for IDs that aren't `session_*`.
|
||||
*/
|
||||
export function toInfraSessionId(id: string): string {
|
||||
if (!id.startsWith('session_')) return id
|
||||
return 'cse_' + id.slice('session_'.length)
|
||||
}
|
||||
550
original-source-code/src/bridge/sessionRunner.ts
Normal file
550
original-source-code/src/bridge/sessionRunner.ts
Normal file
@@ -0,0 +1,550 @@
|
||||
import { type ChildProcess, spawn } from 'child_process'
|
||||
import { createWriteStream, type WriteStream } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { dirname, join } from 'path'
|
||||
import { createInterface } from 'readline'
|
||||
import { jsonParse, jsonStringify } from '../utils/slowOperations.js'
|
||||
import { debugTruncate } from './debugUtils.js'
|
||||
import type {
|
||||
SessionActivity,
|
||||
SessionDoneStatus,
|
||||
SessionHandle,
|
||||
SessionSpawner,
|
||||
SessionSpawnOpts,
|
||||
} from './types.js'
|
||||
|
||||
const MAX_ACTIVITIES = 10
|
||||
const MAX_STDERR_LINES = 10
|
||||
|
||||
/**
|
||||
* Sanitize a session ID for use in file names.
|
||||
* Strips any characters that could cause path traversal (e.g. `../`, `/`)
|
||||
* or other filesystem issues, replacing them with underscores.
|
||||
*/
|
||||
export function safeFilenameId(id: string): string {
|
||||
return id.replace(/[^a-zA-Z0-9_-]/g, '_')
|
||||
}
|
||||
|
||||
/**
|
||||
* A control_request emitted by the child CLI when it needs permission to
|
||||
* execute a **specific** tool invocation (not a general capability check).
|
||||
* The bridge forwards this to the server so the user can approve/deny.
|
||||
*/
|
||||
export type PermissionRequest = {
|
||||
type: 'control_request'
|
||||
request_id: string
|
||||
request: {
|
||||
/** Per-invocation permission check — "may I run this tool with these inputs?" */
|
||||
subtype: 'can_use_tool'
|
||||
tool_name: string
|
||||
input: Record<string, unknown>
|
||||
tool_use_id: string
|
||||
}
|
||||
}
|
||||
|
||||
type SessionSpawnerDeps = {
|
||||
execPath: string
|
||||
/**
|
||||
* Arguments that must precede the CLI flags when spawning. Empty for
|
||||
* compiled binaries (where execPath is the claude binary itself); contains
|
||||
* the script path (process.argv[1]) for npm installs where execPath is the
|
||||
* node runtime. Without this, node sees --sdk-url as a node option and
|
||||
* exits with "bad option: --sdk-url" (see anthropics/claude-code#28334).
|
||||
*/
|
||||
scriptArgs: string[]
|
||||
env: NodeJS.ProcessEnv
|
||||
verbose: boolean
|
||||
sandbox: boolean
|
||||
debugFile?: string
|
||||
permissionMode?: string
|
||||
onDebug: (msg: string) => void
|
||||
onActivity?: (sessionId: string, activity: SessionActivity) => void
|
||||
onPermissionRequest?: (
|
||||
sessionId: string,
|
||||
request: PermissionRequest,
|
||||
accessToken: string,
|
||||
) => void
|
||||
}
|
||||
|
||||
/** Map tool names to human-readable verbs for the status display. */
|
||||
const TOOL_VERBS: Record<string, string> = {
|
||||
Read: 'Reading',
|
||||
Write: 'Writing',
|
||||
Edit: 'Editing',
|
||||
MultiEdit: 'Editing',
|
||||
Bash: 'Running',
|
||||
Glob: 'Searching',
|
||||
Grep: 'Searching',
|
||||
WebFetch: 'Fetching',
|
||||
WebSearch: 'Searching',
|
||||
Task: 'Running task',
|
||||
FileReadTool: 'Reading',
|
||||
FileWriteTool: 'Writing',
|
||||
FileEditTool: 'Editing',
|
||||
GlobTool: 'Searching',
|
||||
GrepTool: 'Searching',
|
||||
BashTool: 'Running',
|
||||
NotebookEditTool: 'Editing notebook',
|
||||
LSP: 'LSP',
|
||||
}
|
||||
|
||||
function toolSummary(name: string, input: Record<string, unknown>): string {
|
||||
const verb = TOOL_VERBS[name] ?? name
|
||||
const target =
|
||||
(input.file_path as string) ??
|
||||
(input.filePath as string) ??
|
||||
(input.pattern as string) ??
|
||||
(input.command as string | undefined)?.slice(0, 60) ??
|
||||
(input.url as string) ??
|
||||
(input.query as string) ??
|
||||
''
|
||||
if (target) {
|
||||
return `${verb} ${target}`
|
||||
}
|
||||
return verb
|
||||
}
|
||||
|
||||
function extractActivities(
|
||||
line: string,
|
||||
sessionId: string,
|
||||
onDebug: (msg: string) => void,
|
||||
): SessionActivity[] {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = jsonParse(line)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return []
|
||||
}
|
||||
|
||||
const msg = parsed as Record<string, unknown>
|
||||
const activities: SessionActivity[] = []
|
||||
const now = Date.now()
|
||||
|
||||
switch (msg.type) {
|
||||
case 'assistant': {
|
||||
const message = msg.message as Record<string, unknown> | undefined
|
||||
if (!message) break
|
||||
const content = message.content
|
||||
if (!Array.isArray(content)) break
|
||||
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== 'object') continue
|
||||
const b = block as Record<string, unknown>
|
||||
|
||||
if (b.type === 'tool_use') {
|
||||
const name = (b.name as string) ?? 'Tool'
|
||||
const input = (b.input as Record<string, unknown>) ?? {}
|
||||
const summary = toolSummary(name, input)
|
||||
activities.push({
|
||||
type: 'tool_start',
|
||||
summary,
|
||||
timestamp: now,
|
||||
})
|
||||
onDebug(
|
||||
`[bridge:activity] sessionId=${sessionId} tool_use name=${name} ${inputPreview(input)}`,
|
||||
)
|
||||
} else if (b.type === 'text') {
|
||||
const text = (b.text as string) ?? ''
|
||||
if (text.length > 0) {
|
||||
activities.push({
|
||||
type: 'text',
|
||||
summary: text.slice(0, 80),
|
||||
timestamp: now,
|
||||
})
|
||||
onDebug(
|
||||
`[bridge:activity] sessionId=${sessionId} text "${text.slice(0, 100)}"`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'result': {
|
||||
const subtype = msg.subtype as string | undefined
|
||||
if (subtype === 'success') {
|
||||
activities.push({
|
||||
type: 'result',
|
||||
summary: 'Session completed',
|
||||
timestamp: now,
|
||||
})
|
||||
onDebug(
|
||||
`[bridge:activity] sessionId=${sessionId} result subtype=success`,
|
||||
)
|
||||
} else if (subtype) {
|
||||
const errors = msg.errors as string[] | undefined
|
||||
const errorSummary = errors?.[0] ?? `Error: ${subtype}`
|
||||
activities.push({
|
||||
type: 'error',
|
||||
summary: errorSummary,
|
||||
timestamp: now,
|
||||
})
|
||||
onDebug(
|
||||
`[bridge:activity] sessionId=${sessionId} result subtype=${subtype} error="${errorSummary}"`,
|
||||
)
|
||||
} else {
|
||||
onDebug(
|
||||
`[bridge:activity] sessionId=${sessionId} result subtype=undefined`,
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
return activities
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract plain text from a replayed SDKUserMessage NDJSON line. Returns the
|
||||
* trimmed text if this looks like a real human-authored message, otherwise
|
||||
* undefined so the caller keeps waiting for the first real message.
|
||||
*/
|
||||
function extractUserMessageText(
|
||||
msg: Record<string, unknown>,
|
||||
): string | undefined {
|
||||
// Skip tool-result user messages (wrapped subagent results) and synthetic
|
||||
// caveat messages — neither is human-authored.
|
||||
if (msg.parent_tool_use_id != null || msg.isSynthetic || msg.isReplay)
|
||||
return undefined
|
||||
|
||||
const message = msg.message as Record<string, unknown> | undefined
|
||||
const content = message?.content
|
||||
let text: string | undefined
|
||||
if (typeof content === 'string') {
|
||||
text = content
|
||||
} else if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (
|
||||
block &&
|
||||
typeof block === 'object' &&
|
||||
(block as Record<string, unknown>).type === 'text'
|
||||
) {
|
||||
text = (block as Record<string, unknown>).text as string | undefined
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
text = text?.trim()
|
||||
return text ? text : undefined
|
||||
}
|
||||
|
||||
/** Build a short preview of tool input for debug logging. */
|
||||
function inputPreview(input: Record<string, unknown>): string {
|
||||
const parts: string[] = []
|
||||
for (const [key, val] of Object.entries(input)) {
|
||||
if (typeof val === 'string') {
|
||||
parts.push(`${key}="${val.slice(0, 100)}"`)
|
||||
}
|
||||
if (parts.length >= 3) break
|
||||
}
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
export function createSessionSpawner(deps: SessionSpawnerDeps): SessionSpawner {
|
||||
return {
|
||||
spawn(opts: SessionSpawnOpts, dir: string): SessionHandle {
|
||||
// Debug file resolution:
|
||||
// 1. If deps.debugFile is provided, use it with session ID suffix for uniqueness
|
||||
// 2. If verbose or ant build, auto-generate a temp file path
|
||||
// 3. Otherwise, no debug file
|
||||
const safeId = safeFilenameId(opts.sessionId)
|
||||
let debugFile: string | undefined
|
||||
if (deps.debugFile) {
|
||||
const ext = deps.debugFile.lastIndexOf('.')
|
||||
if (ext > 0) {
|
||||
debugFile = `${deps.debugFile.slice(0, ext)}-${safeId}${deps.debugFile.slice(ext)}`
|
||||
} else {
|
||||
debugFile = `${deps.debugFile}-${safeId}`
|
||||
}
|
||||
} else if (deps.verbose || process.env.USER_TYPE === 'ant') {
|
||||
debugFile = join(tmpdir(), 'claude', `bridge-session-${safeId}.log`)
|
||||
}
|
||||
|
||||
// Transcript file: write raw NDJSON lines for post-hoc analysis.
|
||||
// Placed alongside the debug file when one is configured.
|
||||
let transcriptStream: WriteStream | null = null
|
||||
let transcriptPath: string | undefined
|
||||
if (deps.debugFile) {
|
||||
transcriptPath = join(
|
||||
dirname(deps.debugFile),
|
||||
`bridge-transcript-${safeId}.jsonl`,
|
||||
)
|
||||
transcriptStream = createWriteStream(transcriptPath, { flags: 'a' })
|
||||
transcriptStream.on('error', err => {
|
||||
deps.onDebug(
|
||||
`[bridge:session] Transcript write error: ${err.message}`,
|
||||
)
|
||||
transcriptStream = null
|
||||
})
|
||||
deps.onDebug(`[bridge:session] Transcript log: ${transcriptPath}`)
|
||||
}
|
||||
|
||||
const args = [
|
||||
...deps.scriptArgs,
|
||||
'--print',
|
||||
'--sdk-url',
|
||||
opts.sdkUrl,
|
||||
'--session-id',
|
||||
opts.sessionId,
|
||||
'--input-format',
|
||||
'stream-json',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--replay-user-messages',
|
||||
...(deps.verbose ? ['--verbose'] : []),
|
||||
...(debugFile ? ['--debug-file', debugFile] : []),
|
||||
...(deps.permissionMode
|
||||
? ['--permission-mode', deps.permissionMode]
|
||||
: []),
|
||||
]
|
||||
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...deps.env,
|
||||
// Strip the bridge's OAuth token so the child CC process uses
|
||||
// the session access token for inference instead.
|
||||
CLAUDE_CODE_OAUTH_TOKEN: undefined,
|
||||
CLAUDE_CODE_ENVIRONMENT_KIND: 'bridge',
|
||||
...(deps.sandbox && { CLAUDE_CODE_FORCE_SANDBOX: '1' }),
|
||||
CLAUDE_CODE_SESSION_ACCESS_TOKEN: opts.accessToken,
|
||||
// v1: HybridTransport (WS reads + POST writes) to Session-Ingress.
|
||||
// Harmless in v2 mode — transportUtils checks CLAUDE_CODE_USE_CCR_V2 first.
|
||||
CLAUDE_CODE_POST_FOR_SESSION_INGRESS_V2: '1',
|
||||
// v2: SSETransport + CCRClient to CCR's /v1/code/sessions/* endpoints.
|
||||
// Same env vars environment-manager sets in the container path.
|
||||
...(opts.useCcrV2 && {
|
||||
CLAUDE_CODE_USE_CCR_V2: '1',
|
||||
CLAUDE_CODE_WORKER_EPOCH: String(opts.workerEpoch),
|
||||
}),
|
||||
}
|
||||
|
||||
deps.onDebug(
|
||||
`[bridge:session] Spawning sessionId=${opts.sessionId} sdkUrl=${opts.sdkUrl} accessToken=${opts.accessToken ? 'present' : 'MISSING'}`,
|
||||
)
|
||||
deps.onDebug(`[bridge:session] Child args: ${args.join(' ')}`)
|
||||
if (debugFile) {
|
||||
deps.onDebug(`[bridge:session] Debug log: ${debugFile}`)
|
||||
}
|
||||
|
||||
// Pipe all three streams: stdin for control, stdout for NDJSON parsing,
|
||||
// stderr for error capture and diagnostics.
|
||||
const child: ChildProcess = spawn(deps.execPath, args, {
|
||||
cwd: dir,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env,
|
||||
windowsHide: true,
|
||||
})
|
||||
|
||||
deps.onDebug(
|
||||
`[bridge:session] sessionId=${opts.sessionId} pid=${child.pid}`,
|
||||
)
|
||||
|
||||
const activities: SessionActivity[] = []
|
||||
let currentActivity: SessionActivity | null = null
|
||||
const lastStderr: string[] = []
|
||||
let sigkillSent = false
|
||||
let firstUserMessageSeen = false
|
||||
|
||||
// Buffer stderr for error diagnostics
|
||||
if (child.stderr) {
|
||||
const stderrRl = createInterface({ input: child.stderr })
|
||||
stderrRl.on('line', line => {
|
||||
// Forward stderr to bridge's stderr in verbose mode
|
||||
if (deps.verbose) {
|
||||
process.stderr.write(line + '\n')
|
||||
}
|
||||
// Ring buffer of last N lines
|
||||
if (lastStderr.length >= MAX_STDERR_LINES) {
|
||||
lastStderr.shift()
|
||||
}
|
||||
lastStderr.push(line)
|
||||
})
|
||||
}
|
||||
|
||||
// Parse NDJSON from child stdout
|
||||
if (child.stdout) {
|
||||
const rl = createInterface({ input: child.stdout })
|
||||
rl.on('line', line => {
|
||||
// Write raw NDJSON to transcript file
|
||||
if (transcriptStream) {
|
||||
transcriptStream.write(line + '\n')
|
||||
}
|
||||
|
||||
// Log all messages flowing from the child CLI to the bridge
|
||||
deps.onDebug(
|
||||
`[bridge:ws] sessionId=${opts.sessionId} <<< ${debugTruncate(line)}`,
|
||||
)
|
||||
|
||||
// In verbose mode, forward raw output to stderr
|
||||
if (deps.verbose) {
|
||||
process.stderr.write(line + '\n')
|
||||
}
|
||||
|
||||
const extracted = extractActivities(
|
||||
line,
|
||||
opts.sessionId,
|
||||
deps.onDebug,
|
||||
)
|
||||
for (const activity of extracted) {
|
||||
// Maintain ring buffer
|
||||
if (activities.length >= MAX_ACTIVITIES) {
|
||||
activities.shift()
|
||||
}
|
||||
activities.push(activity)
|
||||
currentActivity = activity
|
||||
|
||||
deps.onActivity?.(opts.sessionId, activity)
|
||||
}
|
||||
|
||||
// Detect control_request and replayed user messages.
|
||||
// extractActivities parses the same line but swallows parse errors
|
||||
// and skips 'user' type — re-parse here is cheap (NDJSON lines are
|
||||
// small) and keeps each path self-contained.
|
||||
{
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = jsonParse(line)
|
||||
} catch {
|
||||
// Non-JSON line, skip detection
|
||||
}
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const msg = parsed as Record<string, unknown>
|
||||
|
||||
if (msg.type === 'control_request') {
|
||||
const request = msg.request as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
if (
|
||||
request?.subtype === 'can_use_tool' &&
|
||||
deps.onPermissionRequest
|
||||
) {
|
||||
deps.onPermissionRequest(
|
||||
opts.sessionId,
|
||||
parsed as PermissionRequest,
|
||||
opts.accessToken,
|
||||
)
|
||||
}
|
||||
// interrupt is turn-level; the child handles it internally (print.ts)
|
||||
} else if (
|
||||
msg.type === 'user' &&
|
||||
!firstUserMessageSeen &&
|
||||
opts.onFirstUserMessage
|
||||
) {
|
||||
const text = extractUserMessageText(msg)
|
||||
if (text) {
|
||||
firstUserMessageSeen = true
|
||||
opts.onFirstUserMessage(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const done = new Promise<SessionDoneStatus>(resolve => {
|
||||
child.on('close', (code, signal) => {
|
||||
// Close transcript stream on exit
|
||||
if (transcriptStream) {
|
||||
transcriptStream.end()
|
||||
transcriptStream = null
|
||||
}
|
||||
|
||||
if (signal === 'SIGTERM' || signal === 'SIGINT') {
|
||||
deps.onDebug(
|
||||
`[bridge:session] sessionId=${opts.sessionId} interrupted signal=${signal} pid=${child.pid}`,
|
||||
)
|
||||
resolve('interrupted')
|
||||
} else if (code === 0) {
|
||||
deps.onDebug(
|
||||
`[bridge:session] sessionId=${opts.sessionId} completed exit_code=0 pid=${child.pid}`,
|
||||
)
|
||||
resolve('completed')
|
||||
} else {
|
||||
deps.onDebug(
|
||||
`[bridge:session] sessionId=${opts.sessionId} failed exit_code=${code} pid=${child.pid}`,
|
||||
)
|
||||
resolve('failed')
|
||||
}
|
||||
})
|
||||
|
||||
child.on('error', err => {
|
||||
deps.onDebug(
|
||||
`[bridge:session] sessionId=${opts.sessionId} spawn error: ${err.message}`,
|
||||
)
|
||||
resolve('failed')
|
||||
})
|
||||
})
|
||||
|
||||
const handle: SessionHandle = {
|
||||
sessionId: opts.sessionId,
|
||||
done,
|
||||
activities,
|
||||
accessToken: opts.accessToken,
|
||||
lastStderr,
|
||||
get currentActivity(): SessionActivity | null {
|
||||
return currentActivity
|
||||
},
|
||||
kill(): void {
|
||||
if (!child.killed) {
|
||||
deps.onDebug(
|
||||
`[bridge:session] Sending SIGTERM to sessionId=${opts.sessionId} pid=${child.pid}`,
|
||||
)
|
||||
// On Windows, child.kill('SIGTERM') throws; use default signal.
|
||||
if (process.platform === 'win32') {
|
||||
child.kill()
|
||||
} else {
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
}
|
||||
},
|
||||
forceKill(): void {
|
||||
// Use separate flag because child.killed is set when kill() is called,
|
||||
// not when the process exits. We need to send SIGKILL even after SIGTERM.
|
||||
if (!sigkillSent && child.pid) {
|
||||
sigkillSent = true
|
||||
deps.onDebug(
|
||||
`[bridge:session] Sending SIGKILL to sessionId=${opts.sessionId} pid=${child.pid}`,
|
||||
)
|
||||
if (process.platform === 'win32') {
|
||||
child.kill()
|
||||
} else {
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
}
|
||||
},
|
||||
writeStdin(data: string): void {
|
||||
if (child.stdin && !child.stdin.destroyed) {
|
||||
deps.onDebug(
|
||||
`[bridge:ws] sessionId=${opts.sessionId} >>> ${debugTruncate(data)}`,
|
||||
)
|
||||
child.stdin.write(data)
|
||||
}
|
||||
},
|
||||
updateAccessToken(token: string): void {
|
||||
handle.accessToken = token
|
||||
// Send the fresh token to the child process via stdin. The child's
|
||||
// StructuredIO handles update_environment_variables messages by
|
||||
// setting process.env directly, so getSessionIngressAuthToken()
|
||||
// picks up the new token on the next refreshHeaders call.
|
||||
handle.writeStdin(
|
||||
jsonStringify({
|
||||
type: 'update_environment_variables',
|
||||
variables: { CLAUDE_CODE_SESSION_ACCESS_TOKEN: token },
|
||||
}) + '\n',
|
||||
)
|
||||
deps.onDebug(
|
||||
`[bridge:session] Sent token refresh via stdin for sessionId=${opts.sessionId}`,
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
return handle
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export { extractActivities as _extractActivitiesForTesting }
|
||||
210
original-source-code/src/bridge/trustedDevice.ts
Normal file
210
original-source-code/src/bridge/trustedDevice.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import axios from 'axios'
|
||||
import memoize from 'lodash-es/memoize.js'
|
||||
import { hostname } from 'os'
|
||||
import { getOauthConfig } from '../constants/oauth.js'
|
||||
import {
|
||||
checkGate_CACHED_OR_BLOCKING,
|
||||
getFeatureValue_CACHED_MAY_BE_STALE,
|
||||
} from '../services/analytics/growthbook.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { errorMessage } from '../utils/errors.js'
|
||||
import { isEssentialTrafficOnly } from '../utils/privacyLevel.js'
|
||||
import { getSecureStorage } from '../utils/secureStorage/index.js'
|
||||
import { jsonStringify } from '../utils/slowOperations.js'
|
||||
|
||||
/**
|
||||
* Trusted device token source for bridge (remote-control) sessions.
|
||||
*
|
||||
* Bridge sessions have SecurityTier=ELEVATED on the server (CCR v2).
|
||||
* The server gates ConnectBridgeWorker on its own flag
|
||||
* (sessions_elevated_auth_enforcement in Anthropic Main); this CLI-side
|
||||
* flag controls whether the CLI sends X-Trusted-Device-Token at all.
|
||||
* Two flags so rollout can be staged: flip CLI-side first (headers
|
||||
* start flowing, server still no-ops), then flip server-side.
|
||||
*
|
||||
* Enrollment (POST /auth/trusted_devices) is gated server-side by
|
||||
* account_session.created_at < 10min, so it must happen during /login.
|
||||
* Token is persistent (90d rolling expiry) and stored in keychain.
|
||||
*
|
||||
* See anthropics/anthropic#274559 (spec), #310375 (B1b tenant RPCs),
|
||||
* #295987 (B2 Python routes), #307150 (C1' CCR v2 gate).
|
||||
*/
|
||||
|
||||
const TRUSTED_DEVICE_GATE = 'tengu_sessions_elevated_auth_enforcement'
|
||||
|
||||
function isGateEnabled(): boolean {
|
||||
return getFeatureValue_CACHED_MAY_BE_STALE(TRUSTED_DEVICE_GATE, false)
|
||||
}
|
||||
|
||||
// Memoized — secureStorage.read() spawns a macOS `security` subprocess (~40ms).
|
||||
// bridgeApi.ts calls this from getHeaders() on every poll/heartbeat/ack.
|
||||
// Cache cleared after enrollment (below) and on logout (clearAuthRelatedCaches).
|
||||
//
|
||||
// Only the storage read is memoized — the GrowthBook gate is checked live so
|
||||
// that a gate flip after GrowthBook refresh takes effect without a restart.
|
||||
const readStoredToken = memoize((): string | undefined => {
|
||||
// Env var takes precedence for testing/canary.
|
||||
const envToken = process.env.CLAUDE_TRUSTED_DEVICE_TOKEN
|
||||
if (envToken) {
|
||||
return envToken
|
||||
}
|
||||
return getSecureStorage().read()?.trustedDeviceToken
|
||||
})
|
||||
|
||||
export function getTrustedDeviceToken(): string | undefined {
|
||||
if (!isGateEnabled()) {
|
||||
return undefined
|
||||
}
|
||||
return readStoredToken()
|
||||
}
|
||||
|
||||
export function clearTrustedDeviceTokenCache(): void {
|
||||
readStoredToken.cache?.clear?.()
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the stored trusted device token from secure storage and the memo cache.
|
||||
* Called before enrollTrustedDevice() during /login so a stale token from the
|
||||
* previous account isn't sent as X-Trusted-Device-Token while enrollment is
|
||||
* in-flight (enrollTrustedDevice is async — bridge API calls between login and
|
||||
* enrollment completion would otherwise still read the old cached token).
|
||||
*/
|
||||
export function clearTrustedDeviceToken(): void {
|
||||
if (!isGateEnabled()) {
|
||||
return
|
||||
}
|
||||
const secureStorage = getSecureStorage()
|
||||
try {
|
||||
const data = secureStorage.read()
|
||||
if (data?.trustedDeviceToken) {
|
||||
delete data.trustedDeviceToken
|
||||
secureStorage.update(data)
|
||||
}
|
||||
} catch {
|
||||
// Best-effort — don't block login if storage is inaccessible
|
||||
}
|
||||
readStoredToken.cache?.clear?.()
|
||||
}
|
||||
|
||||
/**
|
||||
* Enroll this device via POST /auth/trusted_devices and persist the token
|
||||
* to keychain. Best-effort — logs and returns on failure so callers
|
||||
* (post-login hooks) don't block the login flow.
|
||||
*
|
||||
* The server gates enrollment on account_session.created_at < 10min, so
|
||||
* this must be called immediately after a fresh /login. Calling it later
|
||||
* (e.g. lazy enrollment on /bridge 403) will fail with 403 stale_session.
|
||||
*/
|
||||
export async function enrollTrustedDevice(): Promise<void> {
|
||||
try {
|
||||
// checkGate_CACHED_OR_BLOCKING awaits any in-flight GrowthBook re-init
|
||||
// (triggered by refreshGrowthBookAfterAuthChange in login.tsx) before
|
||||
// reading the gate, so we get the post-refresh value.
|
||||
if (!(await checkGate_CACHED_OR_BLOCKING(TRUSTED_DEVICE_GATE))) {
|
||||
logForDebugging(
|
||||
`[trusted-device] Gate ${TRUSTED_DEVICE_GATE} is off, skipping enrollment`,
|
||||
)
|
||||
return
|
||||
}
|
||||
// If CLAUDE_TRUSTED_DEVICE_TOKEN is set (e.g. by an enterprise wrapper),
|
||||
// skip enrollment — the env var takes precedence in readStoredToken() so
|
||||
// any enrolled token would be shadowed and never used.
|
||||
if (process.env.CLAUDE_TRUSTED_DEVICE_TOKEN) {
|
||||
logForDebugging(
|
||||
'[trusted-device] CLAUDE_TRUSTED_DEVICE_TOKEN env var is set, skipping enrollment (env var takes precedence)',
|
||||
)
|
||||
return
|
||||
}
|
||||
// Lazy require — utils/auth.ts transitively pulls ~1300 modules
|
||||
// (config → file → permissions → sessionStorage → commands). Daemon callers
|
||||
// of getTrustedDeviceToken() don't need this; only /login does.
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const { getClaudeAIOAuthTokens } =
|
||||
require('../utils/auth.js') as typeof import('../utils/auth.js')
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
const accessToken = getClaudeAIOAuthTokens()?.accessToken
|
||||
if (!accessToken) {
|
||||
logForDebugging('[trusted-device] No OAuth token, skipping enrollment')
|
||||
return
|
||||
}
|
||||
// Always re-enroll on /login — the existing token may belong to a
|
||||
// different account (account-switch without /logout). Skipping enrollment
|
||||
// would send the old account's token on the new account's bridge calls.
|
||||
const secureStorage = getSecureStorage()
|
||||
|
||||
if (isEssentialTrafficOnly()) {
|
||||
logForDebugging(
|
||||
'[trusted-device] Essential traffic only, skipping enrollment',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const baseUrl = getOauthConfig().BASE_API_URL
|
||||
let response
|
||||
try {
|
||||
response = await axios.post<{
|
||||
device_token?: string
|
||||
device_id?: string
|
||||
}>(
|
||||
`${baseUrl}/api/auth/trusted_devices`,
|
||||
{ display_name: `Claude Code on ${hostname()} · ${process.platform}` },
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 10_000,
|
||||
validateStatus: s => s < 500,
|
||||
},
|
||||
)
|
||||
} catch (err: unknown) {
|
||||
logForDebugging(
|
||||
`[trusted-device] Enrollment request failed: ${errorMessage(err)}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (response.status !== 200 && response.status !== 201) {
|
||||
logForDebugging(
|
||||
`[trusted-device] Enrollment failed ${response.status}: ${jsonStringify(response.data).slice(0, 200)}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const token = response.data?.device_token
|
||||
if (!token || typeof token !== 'string') {
|
||||
logForDebugging(
|
||||
'[trusted-device] Enrollment response missing device_token field',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const storageData = secureStorage.read()
|
||||
if (!storageData) {
|
||||
logForDebugging(
|
||||
'[trusted-device] Cannot read storage, skipping token persist',
|
||||
)
|
||||
return
|
||||
}
|
||||
storageData.trustedDeviceToken = token
|
||||
const result = secureStorage.update(storageData)
|
||||
if (!result.success) {
|
||||
logForDebugging(
|
||||
`[trusted-device] Failed to persist token: ${result.warning ?? 'unknown'}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
readStoredToken.cache?.clear?.()
|
||||
logForDebugging(
|
||||
`[trusted-device] Enrolled device_id=${response.data.device_id ?? 'unknown'}`,
|
||||
)
|
||||
} catch (err: unknown) {
|
||||
logForDebugging(
|
||||
`[trusted-device] Storage write failed: ${errorMessage(err)}`,
|
||||
)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
logForDebugging(`[trusted-device] Enrollment error: ${errorMessage(err)}`)
|
||||
}
|
||||
}
|
||||
262
original-source-code/src/bridge/types.ts
Normal file
262
original-source-code/src/bridge/types.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
/** Default per-session timeout (24 hours). */
|
||||
export const DEFAULT_SESSION_TIMEOUT_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
/** Reusable login guidance appended to bridge auth errors. */
|
||||
export const BRIDGE_LOGIN_INSTRUCTION =
|
||||
'Remote Control is only available with claude.ai subscriptions. Please use `/login` to sign in with your claude.ai account.'
|
||||
|
||||
/** Full error printed when `claude remote-control` is run without auth. */
|
||||
export const BRIDGE_LOGIN_ERROR =
|
||||
'Error: You must be logged in to use Remote Control.\n\n' +
|
||||
BRIDGE_LOGIN_INSTRUCTION
|
||||
|
||||
/** Shown when the user disconnects Remote Control (via /remote-control or ultraplan launch). */
|
||||
export const REMOTE_CONTROL_DISCONNECTED_MSG = 'Remote Control disconnected.'
|
||||
|
||||
// --- Protocol types for the environments API ---
|
||||
|
||||
export type WorkData = {
|
||||
type: 'session' | 'healthcheck'
|
||||
id: string
|
||||
}
|
||||
|
||||
export type WorkResponse = {
|
||||
id: string
|
||||
type: 'work'
|
||||
environment_id: string
|
||||
state: string
|
||||
data: WorkData
|
||||
secret: string // base64url-encoded JSON
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type WorkSecret = {
|
||||
version: number
|
||||
session_ingress_token: string
|
||||
api_base_url: string
|
||||
sources: Array<{
|
||||
type: string
|
||||
git_info?: { type: string; repo: string; ref?: string; token?: string }
|
||||
}>
|
||||
auth: Array<{ type: string; token: string }>
|
||||
claude_code_args?: Record<string, string> | null
|
||||
mcp_config?: unknown | null
|
||||
environment_variables?: Record<string, string> | null
|
||||
/**
|
||||
* Server-driven CCR v2 selector. Set by prepare_work_secret() when the
|
||||
* session was created via the v2 compat layer (ccr_v2_compat_enabled).
|
||||
* Same field the BYOC runner reads at environment-runner/sessionExecutor.ts.
|
||||
*/
|
||||
use_code_sessions?: boolean
|
||||
}
|
||||
|
||||
export type SessionDoneStatus = 'completed' | 'failed' | 'interrupted'
|
||||
|
||||
export type SessionActivityType = 'tool_start' | 'text' | 'result' | 'error'
|
||||
|
||||
export type SessionActivity = {
|
||||
type: SessionActivityType
|
||||
summary: string // e.g. "Editing src/foo.ts", "Reading package.json"
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
/**
|
||||
* How `claude remote-control` chooses session working directories.
|
||||
* - `single-session`: one session in cwd, bridge tears down when it ends
|
||||
* - `worktree`: persistent server, every session gets an isolated git worktree
|
||||
* - `same-dir`: persistent server, every session shares cwd (can stomp each other)
|
||||
*/
|
||||
export type SpawnMode = 'single-session' | 'worktree' | 'same-dir'
|
||||
|
||||
/**
|
||||
* Well-known worker_type values THIS codebase produces. Sent as
|
||||
* `metadata.worker_type` at environment registration so claude.ai can filter
|
||||
* the session picker by origin (e.g. assistant tab only shows assistant
|
||||
* workers). The backend treats this as an opaque string — desktop cowork
|
||||
* sends `"cowork"`, which isn't in this union. REPL code uses this narrow
|
||||
* type for its own exhaustiveness; wire-level fields accept any string.
|
||||
*/
|
||||
export type BridgeWorkerType = 'claude_code' | 'claude_code_assistant'
|
||||
|
||||
export type BridgeConfig = {
|
||||
dir: string
|
||||
machineName: string
|
||||
branch: string
|
||||
gitRepoUrl: string | null
|
||||
maxSessions: number
|
||||
spawnMode: SpawnMode
|
||||
verbose: boolean
|
||||
sandbox: boolean
|
||||
/** Client-generated UUID identifying this bridge instance. */
|
||||
bridgeId: string
|
||||
/**
|
||||
* Sent as metadata.worker_type so web clients can filter by origin.
|
||||
* Backend treats this as opaque — any string, not just BridgeWorkerType.
|
||||
*/
|
||||
workerType: string
|
||||
/** Client-generated UUID for idempotent environment registration. */
|
||||
environmentId: string
|
||||
/**
|
||||
* Backend-issued environment_id to reuse on re-register. When set, the
|
||||
* backend treats registration as a reconnect to the existing environment
|
||||
* instead of creating a new one. Used by `claude remote-control
|
||||
* --session-id` resume. Must be a backend-format ID — client UUIDs are
|
||||
* rejected with 400.
|
||||
*/
|
||||
reuseEnvironmentId?: string
|
||||
/** API base URL the bridge is connected to (used for polling). */
|
||||
apiBaseUrl: string
|
||||
/** Session ingress base URL for WebSocket connections (may differ from apiBaseUrl locally). */
|
||||
sessionIngressUrl: string
|
||||
/** Debug file path passed via --debug-file. */
|
||||
debugFile?: string
|
||||
/** Per-session timeout in milliseconds. Sessions exceeding this are killed. */
|
||||
sessionTimeoutMs?: number
|
||||
}
|
||||
|
||||
// --- Dependency interfaces (for testability) ---
|
||||
|
||||
/**
|
||||
* A control_response event sent back to a session (e.g. a permission decision).
|
||||
* The `subtype` is `'success'` per the SDK protocol; the inner `response`
|
||||
* carries the permission decision payload (e.g. `{ behavior: 'allow' }`).
|
||||
*/
|
||||
export type PermissionResponseEvent = {
|
||||
type: 'control_response'
|
||||
response: {
|
||||
subtype: 'success'
|
||||
request_id: string
|
||||
response: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export type BridgeApiClient = {
|
||||
registerBridgeEnvironment(config: BridgeConfig): Promise<{
|
||||
environment_id: string
|
||||
environment_secret: string
|
||||
}>
|
||||
pollForWork(
|
||||
environmentId: string,
|
||||
environmentSecret: string,
|
||||
signal?: AbortSignal,
|
||||
reclaimOlderThanMs?: number,
|
||||
): Promise<WorkResponse | null>
|
||||
acknowledgeWork(
|
||||
environmentId: string,
|
||||
workId: string,
|
||||
sessionToken: string,
|
||||
): Promise<void>
|
||||
/** Stop a work item via the environments API. */
|
||||
stopWork(environmentId: string, workId: string, force: boolean): Promise<void>
|
||||
/** Deregister/delete the bridge environment on graceful shutdown. */
|
||||
deregisterEnvironment(environmentId: string): Promise<void>
|
||||
/** Send a permission response (control_response) to a session via the session events API. */
|
||||
sendPermissionResponseEvent(
|
||||
sessionId: string,
|
||||
event: PermissionResponseEvent,
|
||||
sessionToken: string,
|
||||
): Promise<void>
|
||||
/** Archive a session so it no longer appears as active on the server. */
|
||||
archiveSession(sessionId: string): Promise<void>
|
||||
/**
|
||||
* Force-stop stale worker instances and re-queue a session on an environment.
|
||||
* Used by `--session-id` to resume a session after the original bridge died.
|
||||
*/
|
||||
reconnectSession(environmentId: string, sessionId: string): Promise<void>
|
||||
/**
|
||||
* Send a lightweight heartbeat for an active work item, extending its lease.
|
||||
* Uses SessionIngressAuth (JWT, no DB hit) instead of EnvironmentSecretAuth.
|
||||
* Returns the server's response with lease status.
|
||||
*/
|
||||
heartbeatWork(
|
||||
environmentId: string,
|
||||
workId: string,
|
||||
sessionToken: string,
|
||||
): Promise<{ lease_extended: boolean; state: string }>
|
||||
}
|
||||
|
||||
export type SessionHandle = {
|
||||
sessionId: string
|
||||
done: Promise<SessionDoneStatus>
|
||||
kill(): void
|
||||
forceKill(): void
|
||||
activities: SessionActivity[] // ring buffer of recent activities (last ~10)
|
||||
currentActivity: SessionActivity | null // most recent
|
||||
accessToken: string // session_ingress_token for API calls
|
||||
lastStderr: string[] // ring buffer of last stderr lines
|
||||
writeStdin(data: string): void // write directly to child stdin
|
||||
/** Update the access token for a running session (e.g. after token refresh). */
|
||||
updateAccessToken(token: string): void
|
||||
}
|
||||
|
||||
export type SessionSpawnOpts = {
|
||||
sessionId: string
|
||||
sdkUrl: string
|
||||
accessToken: string
|
||||
/** When true, spawn the child with CCR v2 env vars (SSE transport + CCRClient). */
|
||||
useCcrV2?: boolean
|
||||
/** Required when useCcrV2 is true. Obtained from POST /worker/register. */
|
||||
workerEpoch?: number
|
||||
/**
|
||||
* Fires once with the text of the first real user message seen on the
|
||||
* child's stdout (via --replay-user-messages). Lets the caller derive a
|
||||
* session title when none exists yet. Tool-result and synthetic user
|
||||
* messages are skipped.
|
||||
*/
|
||||
onFirstUserMessage?: (text: string) => void
|
||||
}
|
||||
|
||||
export type SessionSpawner = {
|
||||
spawn(opts: SessionSpawnOpts, dir: string): SessionHandle
|
||||
}
|
||||
|
||||
export type BridgeLogger = {
|
||||
printBanner(config: BridgeConfig, environmentId: string): void
|
||||
logSessionStart(sessionId: string, prompt: string): void
|
||||
logSessionComplete(sessionId: string, durationMs: number): void
|
||||
logSessionFailed(sessionId: string, error: string): void
|
||||
logStatus(message: string): void
|
||||
logVerbose(message: string): void
|
||||
logError(message: string): void
|
||||
/** Log a reconnection success event after recovering from connection errors. */
|
||||
logReconnected(disconnectedMs: number): void
|
||||
/** Show idle status with repo/branch info and shimmer animation. */
|
||||
updateIdleStatus(): void
|
||||
/** Show reconnecting status in the live display. */
|
||||
updateReconnectingStatus(delayStr: string, elapsedStr: string): void
|
||||
updateSessionStatus(
|
||||
sessionId: string,
|
||||
elapsed: string,
|
||||
activity: SessionActivity,
|
||||
trail: string[],
|
||||
): void
|
||||
clearStatus(): void
|
||||
/** Set repository info for status line display. */
|
||||
setRepoInfo(repoName: string, branch: string): void
|
||||
/** Set debug log glob shown above the status line (ant users). */
|
||||
setDebugLogPath(path: string): void
|
||||
/** Transition to "Attached" state when a session starts. */
|
||||
setAttached(sessionId: string): void
|
||||
/** Show failed status in the live display. */
|
||||
updateFailedStatus(error: string): void
|
||||
/** Toggle QR code visibility. */
|
||||
toggleQr(): void
|
||||
/** Update the "<n> of <m> sessions" indicator and spawn mode hint. */
|
||||
updateSessionCount(active: number, max: number, mode: SpawnMode): void
|
||||
/** Update the spawn mode shown in the session-count line. Pass null to hide (single-session or toggle unavailable). */
|
||||
setSpawnModeDisplay(mode: 'same-dir' | 'worktree' | null): void
|
||||
/** Register a new session for multi-session display (called after spawn succeeds). */
|
||||
addSession(sessionId: string, url: string): void
|
||||
/** Update the per-session activity summary (tool being run) in the multi-session list. */
|
||||
updateSessionActivity(sessionId: string, activity: SessionActivity): void
|
||||
/**
|
||||
* Set a session's display title. In multi-session mode, updates the bullet list
|
||||
* entry. In single-session mode, also shows the title in the main status line.
|
||||
* Triggers a render (guarded against reconnecting/failed states).
|
||||
*/
|
||||
setSessionTitle(sessionId: string, title: string): void
|
||||
/** Remove a session from the multi-session display when it ends. */
|
||||
removeSession(sessionId: string): void
|
||||
/** Force a re-render of the status display (for multi-session activity refresh). */
|
||||
refreshDisplay(): void
|
||||
}
|
||||
127
original-source-code/src/bridge/workSecret.ts
Normal file
127
original-source-code/src/bridge/workSecret.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import axios from 'axios'
|
||||
import { jsonParse, jsonStringify } from '../utils/slowOperations.js'
|
||||
import type { WorkSecret } from './types.js'
|
||||
|
||||
/** Decode a base64url-encoded work secret and validate its version. */
|
||||
export function decodeWorkSecret(secret: string): WorkSecret {
|
||||
const json = Buffer.from(secret, 'base64url').toString('utf-8')
|
||||
const parsed: unknown = jsonParse(json)
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed !== 'object' ||
|
||||
!('version' in parsed) ||
|
||||
parsed.version !== 1
|
||||
) {
|
||||
throw new Error(
|
||||
`Unsupported work secret version: ${parsed && typeof parsed === 'object' && 'version' in parsed ? parsed.version : 'unknown'}`,
|
||||
)
|
||||
}
|
||||
const obj = parsed as Record<string, unknown>
|
||||
if (
|
||||
typeof obj.session_ingress_token !== 'string' ||
|
||||
obj.session_ingress_token.length === 0
|
||||
) {
|
||||
throw new Error(
|
||||
'Invalid work secret: missing or empty session_ingress_token',
|
||||
)
|
||||
}
|
||||
if (typeof obj.api_base_url !== 'string') {
|
||||
throw new Error('Invalid work secret: missing api_base_url')
|
||||
}
|
||||
return parsed as WorkSecret
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a WebSocket SDK URL from the API base URL and session ID.
|
||||
* Strips the HTTP(S) protocol and constructs a ws(s):// ingress URL.
|
||||
*
|
||||
* Uses /v2/ for localhost (direct to session-ingress, no Envoy rewrite)
|
||||
* and /v1/ for production (Envoy rewrites /v1/ → /v2/).
|
||||
*/
|
||||
export function buildSdkUrl(apiBaseUrl: string, sessionId: string): string {
|
||||
const isLocalhost =
|
||||
apiBaseUrl.includes('localhost') || apiBaseUrl.includes('127.0.0.1')
|
||||
const protocol = isLocalhost ? 'ws' : 'wss'
|
||||
const version = isLocalhost ? 'v2' : 'v1'
|
||||
const host = apiBaseUrl.replace(/^https?:\/\//, '').replace(/\/+$/, '')
|
||||
return `${protocol}://${host}/${version}/session_ingress/ws/${sessionId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two session IDs regardless of their tagged-ID prefix.
|
||||
*
|
||||
* Tagged IDs have the form {tag}_{body} or {tag}_staging_{body}, where the
|
||||
* body encodes a UUID. CCR v2's compat layer returns `session_*` to v1 API
|
||||
* clients (compat/convert.go:41) but the infrastructure layer (sandbox-gateway
|
||||
* work queue, work poll response) uses `cse_*` (compat/CLAUDE.md:13). Both
|
||||
* have the same underlying UUID.
|
||||
*
|
||||
* Without this, replBridge rejects its own session as "foreign" at the
|
||||
* work-received check when the ccr_v2_compat_enabled gate is on.
|
||||
*/
|
||||
export function sameSessionId(a: string, b: string): boolean {
|
||||
if (a === b) return true
|
||||
// The body is everything after the last underscore — this handles both
|
||||
// `{tag}_{body}` and `{tag}_staging_{body}`.
|
||||
const aBody = a.slice(a.lastIndexOf('_') + 1)
|
||||
const bBody = b.slice(b.lastIndexOf('_') + 1)
|
||||
// Guard against IDs with no underscore (bare UUIDs): lastIndexOf returns -1,
|
||||
// slice(0) returns the whole string, and we already checked a === b above.
|
||||
// Require a minimum length to avoid accidental matches on short suffixes
|
||||
// (e.g. single-char tag remnants from malformed IDs).
|
||||
return aBody.length >= 4 && aBody === bBody
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a CCR v2 session URL from the API base URL and session ID.
|
||||
* Unlike buildSdkUrl, this returns an HTTP(S) URL (not ws://) and points at
|
||||
* /v1/code/sessions/{id} — the child CC will derive the SSE stream path
|
||||
* and worker endpoints from this base.
|
||||
*/
|
||||
export function buildCCRv2SdkUrl(
|
||||
apiBaseUrl: string,
|
||||
sessionId: string,
|
||||
): string {
|
||||
const base = apiBaseUrl.replace(/\/+$/, '')
|
||||
return `${base}/v1/code/sessions/${sessionId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Register this bridge as the worker for a CCR v2 session.
|
||||
* Returns the worker_epoch, which must be passed to the child CC process
|
||||
* so its CCRClient can include it in every heartbeat/state/event request.
|
||||
*
|
||||
* Mirrors what environment-manager does in the container path
|
||||
* (api-go/environment-manager/cmd/cmd_task_run.go RegisterWorker).
|
||||
*/
|
||||
export async function registerWorker(
|
||||
sessionUrl: string,
|
||||
accessToken: string,
|
||||
): Promise<number> {
|
||||
const response = await axios.post(
|
||||
`${sessionUrl}/worker/register`,
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
timeout: 10_000,
|
||||
},
|
||||
)
|
||||
// protojson serializes int64 as a string to avoid JS number precision loss;
|
||||
// the Go side may also return a number depending on encoder settings.
|
||||
const raw = response.data?.worker_epoch
|
||||
const epoch = typeof raw === 'string' ? Number(raw) : raw
|
||||
if (
|
||||
typeof epoch !== 'number' ||
|
||||
!Number.isFinite(epoch) ||
|
||||
!Number.isSafeInteger(epoch)
|
||||
) {
|
||||
throw new Error(
|
||||
`registerWorker: invalid worker_epoch in response: ${jsonStringify(response.data)}`,
|
||||
)
|
||||
}
|
||||
return epoch
|
||||
}
|
||||
371
original-source-code/src/buddy/CompanionSprite.tsx
Normal file
371
original-source-code/src/buddy/CompanionSprite.tsx
Normal file
File diff suppressed because one or more lines are too long
133
original-source-code/src/buddy/companion.ts
Normal file
133
original-source-code/src/buddy/companion.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { getGlobalConfig } from '../utils/config.js'
|
||||
import {
|
||||
type Companion,
|
||||
type CompanionBones,
|
||||
EYES,
|
||||
HATS,
|
||||
RARITIES,
|
||||
RARITY_WEIGHTS,
|
||||
type Rarity,
|
||||
SPECIES,
|
||||
STAT_NAMES,
|
||||
type StatName,
|
||||
} from './types.js'
|
||||
|
||||
// Mulberry32 — tiny seeded PRNG, good enough for picking ducks
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0
|
||||
return function () {
|
||||
a |= 0
|
||||
a = (a + 0x6d2b79f5) | 0
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
function hashString(s: string): number {
|
||||
if (typeof Bun !== 'undefined') {
|
||||
return Number(BigInt(Bun.hash(s)) & 0xffffffffn)
|
||||
}
|
||||
let h = 2166136261
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i)
|
||||
h = Math.imul(h, 16777619)
|
||||
}
|
||||
return h >>> 0
|
||||
}
|
||||
|
||||
function pick<T>(rng: () => number, arr: readonly T[]): T {
|
||||
return arr[Math.floor(rng() * arr.length)]!
|
||||
}
|
||||
|
||||
function rollRarity(rng: () => number): Rarity {
|
||||
const total = Object.values(RARITY_WEIGHTS).reduce((a, b) => a + b, 0)
|
||||
let roll = rng() * total
|
||||
for (const rarity of RARITIES) {
|
||||
roll -= RARITY_WEIGHTS[rarity]
|
||||
if (roll < 0) return rarity
|
||||
}
|
||||
return 'common'
|
||||
}
|
||||
|
||||
const RARITY_FLOOR: Record<Rarity, number> = {
|
||||
common: 5,
|
||||
uncommon: 15,
|
||||
rare: 25,
|
||||
epic: 35,
|
||||
legendary: 50,
|
||||
}
|
||||
|
||||
// One peak stat, one dump stat, rest scattered. Rarity bumps the floor.
|
||||
function rollStats(
|
||||
rng: () => number,
|
||||
rarity: Rarity,
|
||||
): Record<StatName, number> {
|
||||
const floor = RARITY_FLOOR[rarity]
|
||||
const peak = pick(rng, STAT_NAMES)
|
||||
let dump = pick(rng, STAT_NAMES)
|
||||
while (dump === peak) dump = pick(rng, STAT_NAMES)
|
||||
|
||||
const stats = {} as Record<StatName, number>
|
||||
for (const name of STAT_NAMES) {
|
||||
if (name === peak) {
|
||||
stats[name] = Math.min(100, floor + 50 + Math.floor(rng() * 30))
|
||||
} else if (name === dump) {
|
||||
stats[name] = Math.max(1, floor - 10 + Math.floor(rng() * 15))
|
||||
} else {
|
||||
stats[name] = floor + Math.floor(rng() * 40)
|
||||
}
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
const SALT = 'friend-2026-401'
|
||||
|
||||
export type Roll = {
|
||||
bones: CompanionBones
|
||||
inspirationSeed: number
|
||||
}
|
||||
|
||||
function rollFrom(rng: () => number): Roll {
|
||||
const rarity = rollRarity(rng)
|
||||
const bones: CompanionBones = {
|
||||
rarity,
|
||||
species: pick(rng, SPECIES),
|
||||
eye: pick(rng, EYES),
|
||||
hat: rarity === 'common' ? 'none' : pick(rng, HATS),
|
||||
shiny: rng() < 0.01,
|
||||
stats: rollStats(rng, rarity),
|
||||
}
|
||||
return { bones, inspirationSeed: Math.floor(rng() * 1e9) }
|
||||
}
|
||||
|
||||
// Called from three hot paths (500ms sprite tick, per-keystroke PromptInput,
|
||||
// per-turn observer) with the same userId → cache the deterministic result.
|
||||
let rollCache: { key: string; value: Roll } | undefined
|
||||
export function roll(userId: string): Roll {
|
||||
const key = userId + SALT
|
||||
if (rollCache?.key === key) return rollCache.value
|
||||
const value = rollFrom(mulberry32(hashString(key)))
|
||||
rollCache = { key, value }
|
||||
return value
|
||||
}
|
||||
|
||||
export function rollWithSeed(seed: string): Roll {
|
||||
return rollFrom(mulberry32(hashString(seed)))
|
||||
}
|
||||
|
||||
export function companionUserId(): string {
|
||||
const config = getGlobalConfig()
|
||||
return config.oauthAccount?.accountUuid ?? config.userID ?? 'anon'
|
||||
}
|
||||
|
||||
// Regenerate bones from userId, merge with stored soul. Bones never persist
|
||||
// so species renames and SPECIES-array edits can't break stored companions,
|
||||
// and editing config.companion can't fake a rarity.
|
||||
export function getCompanion(): Companion | undefined {
|
||||
const stored = getGlobalConfig().companion
|
||||
if (!stored) return undefined
|
||||
const { bones } = roll(companionUserId())
|
||||
// bones last so stale bones fields in old-format configs get overridden
|
||||
return { ...stored, ...bones }
|
||||
}
|
||||
36
original-source-code/src/buddy/prompt.ts
Normal file
36
original-source-code/src/buddy/prompt.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import type { Message } from '../types/message.js'
|
||||
import type { Attachment } from '../utils/attachments.js'
|
||||
import { getGlobalConfig } from '../utils/config.js'
|
||||
import { getCompanion } from './companion.js'
|
||||
|
||||
export function companionIntroText(name: string, species: string): string {
|
||||
return `# Companion
|
||||
|
||||
A small ${species} named ${name} sits beside the user's input box and occasionally comments in a speech bubble. You're not ${name} — it's a separate watcher.
|
||||
|
||||
When the user addresses ${name} directly (by name), its bubble will answer. Your job in that moment is to stay out of the way: respond in ONE line or less, or just answer any part of the message meant for you. Don't explain that you're not ${name} — they know. Don't narrate what ${name} might say — the bubble handles that.`
|
||||
}
|
||||
|
||||
export function getCompanionIntroAttachment(
|
||||
messages: Message[] | undefined,
|
||||
): Attachment[] {
|
||||
if (!feature('BUDDY')) return []
|
||||
const companion = getCompanion()
|
||||
if (!companion || getGlobalConfig().companionMuted) return []
|
||||
|
||||
// Skip if already announced for this companion.
|
||||
for (const msg of messages ?? []) {
|
||||
if (msg.type !== 'attachment') continue
|
||||
if (msg.attachment.type !== 'companion_intro') continue
|
||||
if (msg.attachment.name === companion.name) return []
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
type: 'companion_intro',
|
||||
name: companion.name,
|
||||
species: companion.species,
|
||||
},
|
||||
]
|
||||
}
|
||||
514
original-source-code/src/buddy/sprites.ts
Normal file
514
original-source-code/src/buddy/sprites.ts
Normal file
@@ -0,0 +1,514 @@
|
||||
import type { CompanionBones, Eye, Hat, Species } from './types.js'
|
||||
import {
|
||||
axolotl,
|
||||
blob,
|
||||
cactus,
|
||||
capybara,
|
||||
cat,
|
||||
chonk,
|
||||
dragon,
|
||||
duck,
|
||||
ghost,
|
||||
goose,
|
||||
mushroom,
|
||||
octopus,
|
||||
owl,
|
||||
penguin,
|
||||
rabbit,
|
||||
robot,
|
||||
snail,
|
||||
turtle,
|
||||
} from './types.js'
|
||||
|
||||
// Each sprite is 5 lines tall, 12 wide (after {E}→1char substitution).
|
||||
// Multiple frames per species for idle fidget animation.
|
||||
// Line 0 is the hat slot — must be blank in frames 0-1; frame 2 may use it.
|
||||
const BODIES: Record<Species, string[][]> = {
|
||||
[duck]: [
|
||||
[
|
||||
' ',
|
||||
' __ ',
|
||||
' <({E} )___ ',
|
||||
' ( ._> ',
|
||||
' `--´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' __ ',
|
||||
' <({E} )___ ',
|
||||
' ( ._> ',
|
||||
' `--´~ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' __ ',
|
||||
' <({E} )___ ',
|
||||
' ( .__> ',
|
||||
' `--´ ',
|
||||
],
|
||||
],
|
||||
[goose]: [
|
||||
[
|
||||
' ',
|
||||
' ({E}> ',
|
||||
' || ',
|
||||
' _(__)_ ',
|
||||
' ^^^^ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' ({E}> ',
|
||||
' || ',
|
||||
' _(__)_ ',
|
||||
' ^^^^ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' ({E}>> ',
|
||||
' || ',
|
||||
' _(__)_ ',
|
||||
' ^^^^ ',
|
||||
],
|
||||
],
|
||||
[blob]: [
|
||||
[
|
||||
' ',
|
||||
' .----. ',
|
||||
' ( {E} {E} ) ',
|
||||
' ( ) ',
|
||||
' `----´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' .------. ',
|
||||
' ( {E} {E} ) ',
|
||||
' ( ) ',
|
||||
' `------´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' .--. ',
|
||||
' ({E} {E}) ',
|
||||
' ( ) ',
|
||||
' `--´ ',
|
||||
],
|
||||
],
|
||||
[cat]: [
|
||||
[
|
||||
' ',
|
||||
' /\\_/\\ ',
|
||||
' ( {E} {E}) ',
|
||||
' ( ω ) ',
|
||||
' (")_(") ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' /\\_/\\ ',
|
||||
' ( {E} {E}) ',
|
||||
' ( ω ) ',
|
||||
' (")_(")~ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' /\\-/\\ ',
|
||||
' ( {E} {E}) ',
|
||||
' ( ω ) ',
|
||||
' (")_(") ',
|
||||
],
|
||||
],
|
||||
[dragon]: [
|
||||
[
|
||||
' ',
|
||||
' /^\\ /^\\ ',
|
||||
' < {E} {E} > ',
|
||||
' ( ~~ ) ',
|
||||
' `-vvvv-´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' /^\\ /^\\ ',
|
||||
' < {E} {E} > ',
|
||||
' ( ) ',
|
||||
' `-vvvv-´ ',
|
||||
],
|
||||
[
|
||||
' ~ ~ ',
|
||||
' /^\\ /^\\ ',
|
||||
' < {E} {E} > ',
|
||||
' ( ~~ ) ',
|
||||
' `-vvvv-´ ',
|
||||
],
|
||||
],
|
||||
[octopus]: [
|
||||
[
|
||||
' ',
|
||||
' .----. ',
|
||||
' ( {E} {E} ) ',
|
||||
' (______) ',
|
||||
' /\\/\\/\\/\\ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' .----. ',
|
||||
' ( {E} {E} ) ',
|
||||
' (______) ',
|
||||
' \\/\\/\\/\\/ ',
|
||||
],
|
||||
[
|
||||
' o ',
|
||||
' .----. ',
|
||||
' ( {E} {E} ) ',
|
||||
' (______) ',
|
||||
' /\\/\\/\\/\\ ',
|
||||
],
|
||||
],
|
||||
[owl]: [
|
||||
[
|
||||
' ',
|
||||
' /\\ /\\ ',
|
||||
' (({E})({E})) ',
|
||||
' ( >< ) ',
|
||||
' `----´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' /\\ /\\ ',
|
||||
' (({E})({E})) ',
|
||||
' ( >< ) ',
|
||||
' .----. ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' /\\ /\\ ',
|
||||
' (({E})(-)) ',
|
||||
' ( >< ) ',
|
||||
' `----´ ',
|
||||
],
|
||||
],
|
||||
[penguin]: [
|
||||
[
|
||||
' ',
|
||||
' .---. ',
|
||||
' ({E}>{E}) ',
|
||||
' /( )\\ ',
|
||||
' `---´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' .---. ',
|
||||
' ({E}>{E}) ',
|
||||
' |( )| ',
|
||||
' `---´ ',
|
||||
],
|
||||
[
|
||||
' .---. ',
|
||||
' ({E}>{E}) ',
|
||||
' /( )\\ ',
|
||||
' `---´ ',
|
||||
' ~ ~ ',
|
||||
],
|
||||
],
|
||||
[turtle]: [
|
||||
[
|
||||
' ',
|
||||
' _,--._ ',
|
||||
' ( {E} {E} ) ',
|
||||
' /[______]\\ ',
|
||||
' `` `` ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' _,--._ ',
|
||||
' ( {E} {E} ) ',
|
||||
' /[______]\\ ',
|
||||
' `` `` ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' _,--._ ',
|
||||
' ( {E} {E} ) ',
|
||||
' /[======]\\ ',
|
||||
' `` `` ',
|
||||
],
|
||||
],
|
||||
[snail]: [
|
||||
[
|
||||
' ',
|
||||
' {E} .--. ',
|
||||
' \\ ( @ ) ',
|
||||
' \\_`--´ ',
|
||||
' ~~~~~~~ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' {E} .--. ',
|
||||
' | ( @ ) ',
|
||||
' \\_`--´ ',
|
||||
' ~~~~~~~ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' {E} .--. ',
|
||||
' \\ ( @ ) ',
|
||||
' \\_`--´ ',
|
||||
' ~~~~~~ ',
|
||||
],
|
||||
],
|
||||
[ghost]: [
|
||||
[
|
||||
' ',
|
||||
' .----. ',
|
||||
' / {E} {E} \\ ',
|
||||
' | | ',
|
||||
' ~`~``~`~ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' .----. ',
|
||||
' / {E} {E} \\ ',
|
||||
' | | ',
|
||||
' `~`~~`~` ',
|
||||
],
|
||||
[
|
||||
' ~ ~ ',
|
||||
' .----. ',
|
||||
' / {E} {E} \\ ',
|
||||
' | | ',
|
||||
' ~~`~~`~~ ',
|
||||
],
|
||||
],
|
||||
[axolotl]: [
|
||||
[
|
||||
' ',
|
||||
'}~(______)~{',
|
||||
'}~({E} .. {E})~{',
|
||||
' ( .--. ) ',
|
||||
' (_/ \\_) ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
'~}(______){~',
|
||||
'~}({E} .. {E}){~',
|
||||
' ( .--. ) ',
|
||||
' (_/ \\_) ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
'}~(______)~{',
|
||||
'}~({E} .. {E})~{',
|
||||
' ( -- ) ',
|
||||
' ~_/ \\_~ ',
|
||||
],
|
||||
],
|
||||
[capybara]: [
|
||||
[
|
||||
' ',
|
||||
' n______n ',
|
||||
' ( {E} {E} ) ',
|
||||
' ( oo ) ',
|
||||
' `------´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' n______n ',
|
||||
' ( {E} {E} ) ',
|
||||
' ( Oo ) ',
|
||||
' `------´ ',
|
||||
],
|
||||
[
|
||||
' ~ ~ ',
|
||||
' u______n ',
|
||||
' ( {E} {E} ) ',
|
||||
' ( oo ) ',
|
||||
' `------´ ',
|
||||
],
|
||||
],
|
||||
[cactus]: [
|
||||
[
|
||||
' ',
|
||||
' n ____ n ',
|
||||
' | |{E} {E}| | ',
|
||||
' |_| |_| ',
|
||||
' | | ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' ____ ',
|
||||
' n |{E} {E}| n ',
|
||||
' |_| |_| ',
|
||||
' | | ',
|
||||
],
|
||||
[
|
||||
' n n ',
|
||||
' | ____ | ',
|
||||
' | |{E} {E}| | ',
|
||||
' |_| |_| ',
|
||||
' | | ',
|
||||
],
|
||||
],
|
||||
[robot]: [
|
||||
[
|
||||
' ',
|
||||
' .[||]. ',
|
||||
' [ {E} {E} ] ',
|
||||
' [ ==== ] ',
|
||||
' `------´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' .[||]. ',
|
||||
' [ {E} {E} ] ',
|
||||
' [ -==- ] ',
|
||||
' `------´ ',
|
||||
],
|
||||
[
|
||||
' * ',
|
||||
' .[||]. ',
|
||||
' [ {E} {E} ] ',
|
||||
' [ ==== ] ',
|
||||
' `------´ ',
|
||||
],
|
||||
],
|
||||
[rabbit]: [
|
||||
[
|
||||
' ',
|
||||
' (\\__/) ',
|
||||
' ( {E} {E} ) ',
|
||||
' =( .. )= ',
|
||||
' (")__(") ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' (|__/) ',
|
||||
' ( {E} {E} ) ',
|
||||
' =( .. )= ',
|
||||
' (")__(") ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' (\\__/) ',
|
||||
' ( {E} {E} ) ',
|
||||
' =( . . )= ',
|
||||
' (")__(") ',
|
||||
],
|
||||
],
|
||||
[mushroom]: [
|
||||
[
|
||||
' ',
|
||||
' .-o-OO-o-. ',
|
||||
'(__________)',
|
||||
' |{E} {E}| ',
|
||||
' |____| ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' .-O-oo-O-. ',
|
||||
'(__________)',
|
||||
' |{E} {E}| ',
|
||||
' |____| ',
|
||||
],
|
||||
[
|
||||
' . o . ',
|
||||
' .-o-OO-o-. ',
|
||||
'(__________)',
|
||||
' |{E} {E}| ',
|
||||
' |____| ',
|
||||
],
|
||||
],
|
||||
[chonk]: [
|
||||
[
|
||||
' ',
|
||||
' /\\ /\\ ',
|
||||
' ( {E} {E} ) ',
|
||||
' ( .. ) ',
|
||||
' `------´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' /\\ /| ',
|
||||
' ( {E} {E} ) ',
|
||||
' ( .. ) ',
|
||||
' `------´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' /\\ /\\ ',
|
||||
' ( {E} {E} ) ',
|
||||
' ( .. ) ',
|
||||
' `------´~ ',
|
||||
],
|
||||
],
|
||||
}
|
||||
|
||||
const HAT_LINES: Record<Hat, string> = {
|
||||
none: '',
|
||||
crown: ' \\^^^/ ',
|
||||
tophat: ' [___] ',
|
||||
propeller: ' -+- ',
|
||||
halo: ' ( ) ',
|
||||
wizard: ' /^\\ ',
|
||||
beanie: ' (___) ',
|
||||
tinyduck: ' ,> ',
|
||||
}
|
||||
|
||||
export function renderSprite(bones: CompanionBones, frame = 0): string[] {
|
||||
const frames = BODIES[bones.species]
|
||||
const body = frames[frame % frames.length]!.map(line =>
|
||||
line.replaceAll('{E}', bones.eye),
|
||||
)
|
||||
const lines = [...body]
|
||||
// Only replace with hat if line 0 is empty (some fidget frames use it for smoke etc)
|
||||
if (bones.hat !== 'none' && !lines[0]!.trim()) {
|
||||
lines[0] = HAT_LINES[bones.hat]
|
||||
}
|
||||
// Drop blank hat slot — wastes a row in the Card and ambient sprite when
|
||||
// there's no hat and the frame isn't using it for smoke/antenna/etc.
|
||||
// Only safe when ALL frames have blank line 0; otherwise heights oscillate.
|
||||
if (!lines[0]!.trim() && frames.every(f => !f[0]!.trim())) lines.shift()
|
||||
return lines
|
||||
}
|
||||
|
||||
export function spriteFrameCount(species: Species): number {
|
||||
return BODIES[species].length
|
||||
}
|
||||
|
||||
export function renderFace(bones: CompanionBones): string {
|
||||
const eye: Eye = bones.eye
|
||||
switch (bones.species) {
|
||||
case duck:
|
||||
case goose:
|
||||
return `(${eye}>`
|
||||
case blob:
|
||||
return `(${eye}${eye})`
|
||||
case cat:
|
||||
return `=${eye}ω${eye}=`
|
||||
case dragon:
|
||||
return `<${eye}~${eye}>`
|
||||
case octopus:
|
||||
return `~(${eye}${eye})~`
|
||||
case owl:
|
||||
return `(${eye})(${eye})`
|
||||
case penguin:
|
||||
return `(${eye}>)`
|
||||
case turtle:
|
||||
return `[${eye}_${eye}]`
|
||||
case snail:
|
||||
return `${eye}(@)`
|
||||
case ghost:
|
||||
return `/${eye}${eye}\\`
|
||||
case axolotl:
|
||||
return `}${eye}.${eye}{`
|
||||
case capybara:
|
||||
return `(${eye}oo${eye})`
|
||||
case cactus:
|
||||
return `|${eye} ${eye}|`
|
||||
case robot:
|
||||
return `[${eye}${eye}]`
|
||||
case rabbit:
|
||||
return `(${eye}..${eye})`
|
||||
case mushroom:
|
||||
return `|${eye} ${eye}|`
|
||||
case chonk:
|
||||
return `(${eye}.${eye})`
|
||||
}
|
||||
}
|
||||
148
original-source-code/src/buddy/types.ts
Normal file
148
original-source-code/src/buddy/types.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
export const RARITIES = [
|
||||
'common',
|
||||
'uncommon',
|
||||
'rare',
|
||||
'epic',
|
||||
'legendary',
|
||||
] as const
|
||||
export type Rarity = (typeof RARITIES)[number]
|
||||
|
||||
// One species name collides with a model-codename canary in excluded-strings.txt.
|
||||
// The check greps build output (not source), so runtime-constructing the value keeps
|
||||
// the literal out of the bundle while the check stays armed for the actual codename.
|
||||
// All species encoded uniformly; `as` casts are type-position only (erased pre-bundle).
|
||||
const c = String.fromCharCode
|
||||
// biome-ignore format: keep the species list compact
|
||||
|
||||
export const duck = c(0x64,0x75,0x63,0x6b) as 'duck'
|
||||
export const goose = c(0x67, 0x6f, 0x6f, 0x73, 0x65) as 'goose'
|
||||
export const blob = c(0x62, 0x6c, 0x6f, 0x62) as 'blob'
|
||||
export const cat = c(0x63, 0x61, 0x74) as 'cat'
|
||||
export const dragon = c(0x64, 0x72, 0x61, 0x67, 0x6f, 0x6e) as 'dragon'
|
||||
export const octopus = c(0x6f, 0x63, 0x74, 0x6f, 0x70, 0x75, 0x73) as 'octopus'
|
||||
export const owl = c(0x6f, 0x77, 0x6c) as 'owl'
|
||||
export const penguin = c(0x70, 0x65, 0x6e, 0x67, 0x75, 0x69, 0x6e) as 'penguin'
|
||||
export const turtle = c(0x74, 0x75, 0x72, 0x74, 0x6c, 0x65) as 'turtle'
|
||||
export const snail = c(0x73, 0x6e, 0x61, 0x69, 0x6c) as 'snail'
|
||||
export const ghost = c(0x67, 0x68, 0x6f, 0x73, 0x74) as 'ghost'
|
||||
export const axolotl = c(0x61, 0x78, 0x6f, 0x6c, 0x6f, 0x74, 0x6c) as 'axolotl'
|
||||
export const capybara = c(
|
||||
0x63,
|
||||
0x61,
|
||||
0x70,
|
||||
0x79,
|
||||
0x62,
|
||||
0x61,
|
||||
0x72,
|
||||
0x61,
|
||||
) as 'capybara'
|
||||
export const cactus = c(0x63, 0x61, 0x63, 0x74, 0x75, 0x73) as 'cactus'
|
||||
export const robot = c(0x72, 0x6f, 0x62, 0x6f, 0x74) as 'robot'
|
||||
export const rabbit = c(0x72, 0x61, 0x62, 0x62, 0x69, 0x74) as 'rabbit'
|
||||
export const mushroom = c(
|
||||
0x6d,
|
||||
0x75,
|
||||
0x73,
|
||||
0x68,
|
||||
0x72,
|
||||
0x6f,
|
||||
0x6f,
|
||||
0x6d,
|
||||
) as 'mushroom'
|
||||
export const chonk = c(0x63, 0x68, 0x6f, 0x6e, 0x6b) as 'chonk'
|
||||
|
||||
export const SPECIES = [
|
||||
duck,
|
||||
goose,
|
||||
blob,
|
||||
cat,
|
||||
dragon,
|
||||
octopus,
|
||||
owl,
|
||||
penguin,
|
||||
turtle,
|
||||
snail,
|
||||
ghost,
|
||||
axolotl,
|
||||
capybara,
|
||||
cactus,
|
||||
robot,
|
||||
rabbit,
|
||||
mushroom,
|
||||
chonk,
|
||||
] as const
|
||||
export type Species = (typeof SPECIES)[number] // biome-ignore format: keep compact
|
||||
|
||||
export const EYES = ['·', '✦', '×', '◉', '@', '°'] as const
|
||||
export type Eye = (typeof EYES)[number]
|
||||
|
||||
export const HATS = [
|
||||
'none',
|
||||
'crown',
|
||||
'tophat',
|
||||
'propeller',
|
||||
'halo',
|
||||
'wizard',
|
||||
'beanie',
|
||||
'tinyduck',
|
||||
] as const
|
||||
export type Hat = (typeof HATS)[number]
|
||||
|
||||
export const STAT_NAMES = [
|
||||
'DEBUGGING',
|
||||
'PATIENCE',
|
||||
'CHAOS',
|
||||
'WISDOM',
|
||||
'SNARK',
|
||||
] as const
|
||||
export type StatName = (typeof STAT_NAMES)[number]
|
||||
|
||||
// Deterministic parts — derived from hash(userId)
|
||||
export type CompanionBones = {
|
||||
rarity: Rarity
|
||||
species: Species
|
||||
eye: Eye
|
||||
hat: Hat
|
||||
shiny: boolean
|
||||
stats: Record<StatName, number>
|
||||
}
|
||||
|
||||
// Model-generated soul — stored in config after first hatch
|
||||
export type CompanionSoul = {
|
||||
name: string
|
||||
personality: string
|
||||
}
|
||||
|
||||
export type Companion = CompanionBones &
|
||||
CompanionSoul & {
|
||||
hatchedAt: number
|
||||
}
|
||||
|
||||
// What actually persists in config. Bones are regenerated from hash(userId)
|
||||
// on every read so species renames don't break stored companions and users
|
||||
// can't edit their way to a legendary.
|
||||
export type StoredCompanion = CompanionSoul & { hatchedAt: number }
|
||||
|
||||
export const RARITY_WEIGHTS = {
|
||||
common: 60,
|
||||
uncommon: 25,
|
||||
rare: 10,
|
||||
epic: 4,
|
||||
legendary: 1,
|
||||
} as const satisfies Record<Rarity, number>
|
||||
|
||||
export const RARITY_STARS = {
|
||||
common: '★',
|
||||
uncommon: '★★',
|
||||
rare: '★★★',
|
||||
epic: '★★★★',
|
||||
legendary: '★★★★★',
|
||||
} as const satisfies Record<Rarity, string>
|
||||
|
||||
export const RARITY_COLORS = {
|
||||
common: 'inactive',
|
||||
uncommon: 'success',
|
||||
rare: 'permission',
|
||||
epic: 'autoAccept',
|
||||
legendary: 'warning',
|
||||
} as const satisfies Record<Rarity, keyof import('../utils/theme.js').Theme>
|
||||
98
original-source-code/src/buddy/useBuddyNotification.tsx
Normal file
98
original-source-code/src/buddy/useBuddyNotification.tsx
Normal file
File diff suppressed because one or more lines are too long
31
original-source-code/src/cli/exit.ts
Normal file
31
original-source-code/src/cli/exit.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* CLI exit helpers for subcommand handlers.
|
||||
*
|
||||
* Consolidates the 4-5 line "print + lint-suppress + exit" block that was
|
||||
* copy-pasted ~60 times across `claude mcp *` / `claude plugin *` handlers.
|
||||
* The `: never` return type lets TypeScript narrow control flow at call sites
|
||||
* without a trailing `return`.
|
||||
*/
|
||||
/* eslint-disable custom-rules/no-process-exit -- centralized CLI exit point */
|
||||
|
||||
// `return undefined as never` (not a post-exit throw) — tests spy on
|
||||
// process.exit and let it return. Call sites write `return cliError(...)`
|
||||
// where subsequent code would dereference narrowed-away values under mock.
|
||||
// cliError uses console.error (tests spy on console.error); cliOk uses
|
||||
// process.stdout.write (tests spy on process.stdout.write — Bun's console.log
|
||||
// doesn't route through a spied process.stdout.write).
|
||||
|
||||
/** Write an error message to stderr (if given) and exit with code 1. */
|
||||
export function cliError(msg?: string): never {
|
||||
// biome-ignore lint/suspicious/noConsole: centralized CLI error output
|
||||
if (msg) console.error(msg)
|
||||
process.exit(1)
|
||||
return undefined as never
|
||||
}
|
||||
|
||||
/** Write a message to stdout (if given) and exit with code 0. */
|
||||
export function cliOk(msg?: string): never {
|
||||
if (msg) process.stdout.write(msg + '\n')
|
||||
process.exit(0)
|
||||
return undefined as never
|
||||
}
|
||||
70
original-source-code/src/cli/handlers/agents.ts
Normal file
70
original-source-code/src/cli/handlers/agents.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Agents subcommand handler — prints the list of configured agents.
|
||||
* Dynamically imported only when `claude agents` runs.
|
||||
*/
|
||||
|
||||
import {
|
||||
AGENT_SOURCE_GROUPS,
|
||||
compareAgentsByName,
|
||||
getOverrideSourceLabel,
|
||||
type ResolvedAgent,
|
||||
resolveAgentModelDisplay,
|
||||
resolveAgentOverrides,
|
||||
} from '../../tools/AgentTool/agentDisplay.js'
|
||||
import {
|
||||
getActiveAgentsFromList,
|
||||
getAgentDefinitionsWithOverrides,
|
||||
} from '../../tools/AgentTool/loadAgentsDir.js'
|
||||
import { getCwd } from '../../utils/cwd.js'
|
||||
|
||||
function formatAgent(agent: ResolvedAgent): string {
|
||||
const model = resolveAgentModelDisplay(agent)
|
||||
const parts = [agent.agentType]
|
||||
if (model) {
|
||||
parts.push(model)
|
||||
}
|
||||
if (agent.memory) {
|
||||
parts.push(`${agent.memory} memory`)
|
||||
}
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
export async function agentsHandler(): Promise<void> {
|
||||
const cwd = getCwd()
|
||||
const { allAgents } = await getAgentDefinitionsWithOverrides(cwd)
|
||||
const activeAgents = getActiveAgentsFromList(allAgents)
|
||||
const resolvedAgents = resolveAgentOverrides(allAgents, activeAgents)
|
||||
|
||||
const lines: string[] = []
|
||||
let totalActive = 0
|
||||
|
||||
for (const { label, source } of AGENT_SOURCE_GROUPS) {
|
||||
const groupAgents = resolvedAgents
|
||||
.filter(a => a.source === source)
|
||||
.sort(compareAgentsByName)
|
||||
|
||||
if (groupAgents.length === 0) continue
|
||||
|
||||
lines.push(`${label}:`)
|
||||
for (const agent of groupAgents) {
|
||||
if (agent.overriddenBy) {
|
||||
const winnerSource = getOverrideSourceLabel(agent.overriddenBy)
|
||||
lines.push(` (shadowed by ${winnerSource}) ${formatAgent(agent)}`)
|
||||
} else {
|
||||
lines.push(` ${formatAgent(agent)}`)
|
||||
totalActive++
|
||||
}
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log('No agents found.')
|
||||
} else {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`${totalActive} active agents\n`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(lines.join('\n').trimEnd())
|
||||
}
|
||||
}
|
||||
330
original-source-code/src/cli/handlers/auth.ts
Normal file
330
original-source-code/src/cli/handlers/auth.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
/* eslint-disable custom-rules/no-process-exit -- CLI subcommand handler intentionally exits */
|
||||
|
||||
import {
|
||||
clearAuthRelatedCaches,
|
||||
performLogout,
|
||||
} from '../../commands/logout/logout.js'
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from '../../services/analytics/index.js'
|
||||
import { getSSLErrorHint } from '../../services/api/errorUtils.js'
|
||||
import { fetchAndStoreClaudeCodeFirstTokenDate } from '../../services/api/firstTokenDate.js'
|
||||
import {
|
||||
createAndStoreApiKey,
|
||||
fetchAndStoreUserRoles,
|
||||
refreshOAuthToken,
|
||||
shouldUseClaudeAIAuth,
|
||||
storeOAuthAccountInfo,
|
||||
} from '../../services/oauth/client.js'
|
||||
import { getOauthProfileFromOauthToken } from '../../services/oauth/getOauthProfile.js'
|
||||
import { OAuthService } from '../../services/oauth/index.js'
|
||||
import type { OAuthTokens } from '../../services/oauth/types.js'
|
||||
import {
|
||||
clearOAuthTokenCache,
|
||||
getAnthropicApiKeyWithSource,
|
||||
getAuthTokenSource,
|
||||
getOauthAccountInfo,
|
||||
getSubscriptionType,
|
||||
isUsing3PServices,
|
||||
saveOAuthTokensIfNeeded,
|
||||
validateForceLoginOrg,
|
||||
} from '../../utils/auth.js'
|
||||
import { saveGlobalConfig } from '../../utils/config.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { isRunningOnHomespace } from '../../utils/envUtils.js'
|
||||
import { errorMessage } from '../../utils/errors.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
import { getAPIProvider } from '../../utils/model/providers.js'
|
||||
import { getInitialSettings } from '../../utils/settings/settings.js'
|
||||
import { jsonStringify } from '../../utils/slowOperations.js'
|
||||
import {
|
||||
buildAccountProperties,
|
||||
buildAPIProviderProperties,
|
||||
} from '../../utils/status.js'
|
||||
|
||||
/**
|
||||
* Shared post-token-acquisition logic. Saves tokens, fetches profile/roles,
|
||||
* and sets up the local auth state.
|
||||
*/
|
||||
export async function installOAuthTokens(tokens: OAuthTokens): Promise<void> {
|
||||
// Clear old state before saving new credentials
|
||||
await performLogout({ clearOnboarding: false })
|
||||
|
||||
// Reuse pre-fetched profile if available, otherwise fetch fresh
|
||||
const profile =
|
||||
tokens.profile ?? (await getOauthProfileFromOauthToken(tokens.accessToken))
|
||||
if (profile) {
|
||||
storeOAuthAccountInfo({
|
||||
accountUuid: profile.account.uuid,
|
||||
emailAddress: profile.account.email,
|
||||
organizationUuid: profile.organization.uuid,
|
||||
displayName: profile.account.display_name || undefined,
|
||||
hasExtraUsageEnabled:
|
||||
profile.organization.has_extra_usage_enabled ?? undefined,
|
||||
billingType: profile.organization.billing_type ?? undefined,
|
||||
subscriptionCreatedAt:
|
||||
profile.organization.subscription_created_at ?? undefined,
|
||||
accountCreatedAt: profile.account.created_at,
|
||||
})
|
||||
} else if (tokens.tokenAccount) {
|
||||
// Fallback to token exchange account data when profile endpoint fails
|
||||
storeOAuthAccountInfo({
|
||||
accountUuid: tokens.tokenAccount.uuid,
|
||||
emailAddress: tokens.tokenAccount.emailAddress,
|
||||
organizationUuid: tokens.tokenAccount.organizationUuid,
|
||||
})
|
||||
}
|
||||
|
||||
const storageResult = saveOAuthTokensIfNeeded(tokens)
|
||||
clearOAuthTokenCache()
|
||||
|
||||
if (storageResult.warning) {
|
||||
logEvent('tengu_oauth_storage_warning', {
|
||||
warning:
|
||||
storageResult.warning as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
}
|
||||
|
||||
// Roles and first-token-date may fail for limited-scope tokens (e.g.
|
||||
// inference-only from setup-token). They're not required for core auth.
|
||||
await fetchAndStoreUserRoles(tokens.accessToken).catch(err =>
|
||||
logForDebugging(String(err), { level: 'error' }),
|
||||
)
|
||||
|
||||
if (shouldUseClaudeAIAuth(tokens.scopes)) {
|
||||
await fetchAndStoreClaudeCodeFirstTokenDate().catch(err =>
|
||||
logForDebugging(String(err), { level: 'error' }),
|
||||
)
|
||||
} else {
|
||||
// API key creation is critical for Console users — let it throw.
|
||||
const apiKey = await createAndStoreApiKey(tokens.accessToken)
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
'Unable to create API key. The server accepted the request but did not return a key.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await clearAuthRelatedCaches()
|
||||
}
|
||||
|
||||
export async function authLogin({
|
||||
email,
|
||||
sso,
|
||||
console: useConsole,
|
||||
claudeai,
|
||||
}: {
|
||||
email?: string
|
||||
sso?: boolean
|
||||
console?: boolean
|
||||
claudeai?: boolean
|
||||
}): Promise<void> {
|
||||
if (useConsole && claudeai) {
|
||||
process.stderr.write(
|
||||
'Error: --console and --claudeai cannot be used together.\n',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const settings = getInitialSettings()
|
||||
// forceLoginMethod is a hard constraint (enterprise setting) — matches ConsoleOAuthFlow behavior.
|
||||
// Without it, --console selects Console; --claudeai (or no flag) selects claude.ai.
|
||||
const loginWithClaudeAi = settings.forceLoginMethod
|
||||
? settings.forceLoginMethod === 'claudeai'
|
||||
: !useConsole
|
||||
const orgUUID = settings.forceLoginOrgUUID
|
||||
|
||||
// Fast path: if a refresh token is provided via env var, skip the browser
|
||||
// OAuth flow and exchange it directly for tokens.
|
||||
const envRefreshToken = process.env.CLAUDE_CODE_OAUTH_REFRESH_TOKEN
|
||||
if (envRefreshToken) {
|
||||
const envScopes = process.env.CLAUDE_CODE_OAUTH_SCOPES
|
||||
if (!envScopes) {
|
||||
process.stderr.write(
|
||||
'CLAUDE_CODE_OAUTH_SCOPES is required when using CLAUDE_CODE_OAUTH_REFRESH_TOKEN.\n' +
|
||||
'Set it to the space-separated scopes the refresh token was issued with\n' +
|
||||
'(e.g. "user:inference" or "user:profile user:inference user:sessions:claude_code user:mcp_servers").\n',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const scopes = envScopes.split(/\s+/).filter(Boolean)
|
||||
|
||||
try {
|
||||
logEvent('tengu_login_from_refresh_token', {})
|
||||
|
||||
const tokens = await refreshOAuthToken(envRefreshToken, { scopes })
|
||||
await installOAuthTokens(tokens)
|
||||
|
||||
const orgResult = await validateForceLoginOrg()
|
||||
if (!orgResult.valid) {
|
||||
process.stderr.write(orgResult.message + '\n')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Mark onboarding complete — interactive paths handle this via
|
||||
// the Onboarding component, but the env var path skips it.
|
||||
saveGlobalConfig(current => {
|
||||
if (current.hasCompletedOnboarding) return current
|
||||
return { ...current, hasCompletedOnboarding: true }
|
||||
})
|
||||
|
||||
logEvent('tengu_oauth_success', {
|
||||
loginWithClaudeAi: shouldUseClaudeAIAuth(tokens.scopes),
|
||||
})
|
||||
process.stdout.write('Login successful.\n')
|
||||
process.exit(0)
|
||||
} catch (err) {
|
||||
logError(err)
|
||||
const sslHint = getSSLErrorHint(err)
|
||||
process.stderr.write(
|
||||
`Login failed: ${errorMessage(err)}\n${sslHint ? sslHint + '\n' : ''}`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedLoginMethod = sso ? 'sso' : undefined
|
||||
|
||||
const oauthService = new OAuthService()
|
||||
|
||||
try {
|
||||
logEvent('tengu_oauth_flow_start', { loginWithClaudeAi })
|
||||
|
||||
const result = await oauthService.startOAuthFlow(
|
||||
async url => {
|
||||
process.stdout.write('Opening browser to sign in…\n')
|
||||
process.stdout.write(`If the browser didn't open, visit: ${url}\n`)
|
||||
},
|
||||
{
|
||||
loginWithClaudeAi,
|
||||
loginHint: email,
|
||||
loginMethod: resolvedLoginMethod,
|
||||
orgUUID,
|
||||
},
|
||||
)
|
||||
|
||||
await installOAuthTokens(result)
|
||||
|
||||
const orgResult = await validateForceLoginOrg()
|
||||
if (!orgResult.valid) {
|
||||
process.stderr.write(orgResult.message + '\n')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
logEvent('tengu_oauth_success', { loginWithClaudeAi })
|
||||
|
||||
process.stdout.write('Login successful.\n')
|
||||
process.exit(0)
|
||||
} catch (err) {
|
||||
logError(err)
|
||||
const sslHint = getSSLErrorHint(err)
|
||||
process.stderr.write(
|
||||
`Login failed: ${errorMessage(err)}\n${sslHint ? sslHint + '\n' : ''}`,
|
||||
)
|
||||
process.exit(1)
|
||||
} finally {
|
||||
oauthService.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
export async function authStatus(opts: {
|
||||
json?: boolean
|
||||
text?: boolean
|
||||
}): Promise<void> {
|
||||
const { source: authTokenSource, hasToken } = getAuthTokenSource()
|
||||
const { source: apiKeySource } = getAnthropicApiKeyWithSource()
|
||||
const hasApiKeyEnvVar =
|
||||
!!process.env.ANTHROPIC_API_KEY && !isRunningOnHomespace()
|
||||
const oauthAccount = getOauthAccountInfo()
|
||||
const subscriptionType = getSubscriptionType()
|
||||
const using3P = isUsing3PServices()
|
||||
const loggedIn =
|
||||
hasToken || apiKeySource !== 'none' || hasApiKeyEnvVar || using3P
|
||||
|
||||
// Determine auth method
|
||||
let authMethod: string = 'none'
|
||||
if (using3P) {
|
||||
authMethod = 'third_party'
|
||||
} else if (authTokenSource === 'claude.ai') {
|
||||
authMethod = 'claude.ai'
|
||||
} else if (authTokenSource === 'apiKeyHelper') {
|
||||
authMethod = 'api_key_helper'
|
||||
} else if (authTokenSource !== 'none') {
|
||||
authMethod = 'oauth_token'
|
||||
} else if (apiKeySource === 'ANTHROPIC_API_KEY' || hasApiKeyEnvVar) {
|
||||
authMethod = 'api_key'
|
||||
} else if (apiKeySource === '/login managed key') {
|
||||
authMethod = 'claude.ai'
|
||||
}
|
||||
|
||||
if (opts.text) {
|
||||
const properties = [
|
||||
...buildAccountProperties(),
|
||||
...buildAPIProviderProperties(),
|
||||
]
|
||||
let hasAuthProperty = false
|
||||
for (const prop of properties) {
|
||||
const value =
|
||||
typeof prop.value === 'string'
|
||||
? prop.value
|
||||
: Array.isArray(prop.value)
|
||||
? prop.value.join(', ')
|
||||
: null
|
||||
if (value === null || value === 'none') {
|
||||
continue
|
||||
}
|
||||
hasAuthProperty = true
|
||||
if (prop.label) {
|
||||
process.stdout.write(`${prop.label}: ${value}\n`)
|
||||
} else {
|
||||
process.stdout.write(`${value}\n`)
|
||||
}
|
||||
}
|
||||
if (!hasAuthProperty && hasApiKeyEnvVar) {
|
||||
process.stdout.write('API key: ANTHROPIC_API_KEY\n')
|
||||
}
|
||||
if (!loggedIn) {
|
||||
process.stdout.write(
|
||||
'Not logged in. Run claude auth login to authenticate.\n',
|
||||
)
|
||||
}
|
||||
} else {
|
||||
const apiProvider = getAPIProvider()
|
||||
const resolvedApiKeySource =
|
||||
apiKeySource !== 'none'
|
||||
? apiKeySource
|
||||
: hasApiKeyEnvVar
|
||||
? 'ANTHROPIC_API_KEY'
|
||||
: null
|
||||
const output: Record<string, string | boolean | null> = {
|
||||
loggedIn,
|
||||
authMethod,
|
||||
apiProvider,
|
||||
}
|
||||
if (resolvedApiKeySource) {
|
||||
output.apiKeySource = resolvedApiKeySource
|
||||
}
|
||||
if (authMethod === 'claude.ai') {
|
||||
output.email = oauthAccount?.emailAddress ?? null
|
||||
output.orgId = oauthAccount?.organizationUuid ?? null
|
||||
output.orgName = oauthAccount?.organizationName ?? null
|
||||
output.subscriptionType = subscriptionType ?? null
|
||||
}
|
||||
|
||||
process.stdout.write(jsonStringify(output, null, 2) + '\n')
|
||||
}
|
||||
process.exit(loggedIn ? 0 : 1)
|
||||
}
|
||||
|
||||
export async function authLogout(): Promise<void> {
|
||||
try {
|
||||
await performLogout({ clearOnboarding: false })
|
||||
} catch {
|
||||
process.stderr.write('Failed to log out.\n')
|
||||
process.exit(1)
|
||||
}
|
||||
process.stdout.write('Successfully logged out from your Anthropic account.\n')
|
||||
process.exit(0)
|
||||
}
|
||||
170
original-source-code/src/cli/handlers/autoMode.ts
Normal file
170
original-source-code/src/cli/handlers/autoMode.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Auto mode subcommand handlers — dump default/merged classifier rules and
|
||||
* critique user-written rules. Dynamically imported when `claude auto-mode ...` runs.
|
||||
*/
|
||||
|
||||
import { errorMessage } from '../../utils/errors.js'
|
||||
import {
|
||||
getMainLoopModel,
|
||||
parseUserSpecifiedModel,
|
||||
} from '../../utils/model/model.js'
|
||||
import {
|
||||
type AutoModeRules,
|
||||
buildDefaultExternalSystemPrompt,
|
||||
getDefaultExternalAutoModeRules,
|
||||
} from '../../utils/permissions/yoloClassifier.js'
|
||||
import { getAutoModeConfig } from '../../utils/settings/settings.js'
|
||||
import { sideQuery } from '../../utils/sideQuery.js'
|
||||
import { jsonStringify } from '../../utils/slowOperations.js'
|
||||
|
||||
function writeRules(rules: AutoModeRules): void {
|
||||
process.stdout.write(jsonStringify(rules, null, 2) + '\n')
|
||||
}
|
||||
|
||||
export function autoModeDefaultsHandler(): void {
|
||||
writeRules(getDefaultExternalAutoModeRules())
|
||||
}
|
||||
|
||||
/**
|
||||
* Dump the effective auto mode config: user settings where provided, external
|
||||
* defaults otherwise. Per-section REPLACE semantics — matches how
|
||||
* buildYoloSystemPrompt resolves the external template (a non-empty user
|
||||
* section replaces that section's defaults entirely; an empty/absent section
|
||||
* falls through to defaults).
|
||||
*/
|
||||
export function autoModeConfigHandler(): void {
|
||||
const config = getAutoModeConfig()
|
||||
const defaults = getDefaultExternalAutoModeRules()
|
||||
writeRules({
|
||||
allow: config?.allow?.length ? config.allow : defaults.allow,
|
||||
soft_deny: config?.soft_deny?.length
|
||||
? config.soft_deny
|
||||
: defaults.soft_deny,
|
||||
environment: config?.environment?.length
|
||||
? config.environment
|
||||
: defaults.environment,
|
||||
})
|
||||
}
|
||||
|
||||
const CRITIQUE_SYSTEM_PROMPT =
|
||||
'You are an expert reviewer of auto mode classifier rules for Claude Code.\n' +
|
||||
'\n' +
|
||||
'Claude Code has an "auto mode" that uses an AI classifier to decide whether ' +
|
||||
'tool calls should be auto-approved or require user confirmation. Users can ' +
|
||||
'write custom rules in three categories:\n' +
|
||||
'\n' +
|
||||
'- **allow**: Actions the classifier should auto-approve\n' +
|
||||
'- **soft_deny**: Actions the classifier should block (require user confirmation)\n' +
|
||||
"- **environment**: Context about the user's setup that helps the classifier make decisions\n" +
|
||||
'\n' +
|
||||
"Your job is to critique the user's custom rules for clarity, completeness, " +
|
||||
'and potential issues. The classifier is an LLM that reads these rules as ' +
|
||||
'part of its system prompt.\n' +
|
||||
'\n' +
|
||||
'For each rule, evaluate:\n' +
|
||||
'1. **Clarity**: Is the rule unambiguous? Could the classifier misinterpret it?\n' +
|
||||
"2. **Completeness**: Are there gaps or edge cases the rule doesn't cover?\n" +
|
||||
'3. **Conflicts**: Do any of the rules conflict with each other?\n' +
|
||||
'4. **Actionability**: Is the rule specific enough for the classifier to act on?\n' +
|
||||
'\n' +
|
||||
'Be concise and constructive. Only comment on rules that could be improved. ' +
|
||||
'If all rules look good, say so.'
|
||||
|
||||
export async function autoModeCritiqueHandler(options: {
|
||||
model?: string
|
||||
}): Promise<void> {
|
||||
const config = getAutoModeConfig()
|
||||
const hasCustomRules =
|
||||
(config?.allow?.length ?? 0) > 0 ||
|
||||
(config?.soft_deny?.length ?? 0) > 0 ||
|
||||
(config?.environment?.length ?? 0) > 0
|
||||
|
||||
if (!hasCustomRules) {
|
||||
process.stdout.write(
|
||||
'No custom auto mode rules found.\n\n' +
|
||||
'Add rules to your settings file under autoMode.{allow, soft_deny, environment}.\n' +
|
||||
'Run `claude auto-mode defaults` to see the default rules for reference.\n',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const model = options.model
|
||||
? parseUserSpecifiedModel(options.model)
|
||||
: getMainLoopModel()
|
||||
|
||||
const defaults = getDefaultExternalAutoModeRules()
|
||||
const classifierPrompt = buildDefaultExternalSystemPrompt()
|
||||
|
||||
const userRulesSummary =
|
||||
formatRulesForCritique('allow', config?.allow ?? [], defaults.allow) +
|
||||
formatRulesForCritique(
|
||||
'soft_deny',
|
||||
config?.soft_deny ?? [],
|
||||
defaults.soft_deny,
|
||||
) +
|
||||
formatRulesForCritique(
|
||||
'environment',
|
||||
config?.environment ?? [],
|
||||
defaults.environment,
|
||||
)
|
||||
|
||||
process.stdout.write('Analyzing your auto mode rules…\n\n')
|
||||
|
||||
let response
|
||||
try {
|
||||
response = await sideQuery({
|
||||
querySource: 'auto_mode_critique',
|
||||
model,
|
||||
system: CRITIQUE_SYSTEM_PROMPT,
|
||||
skipSystemPromptPrefix: true,
|
||||
max_tokens: 4096,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content:
|
||||
'Here is the full classifier system prompt that the auto mode classifier receives:\n\n' +
|
||||
'<classifier_system_prompt>\n' +
|
||||
classifierPrompt +
|
||||
'\n</classifier_system_prompt>\n\n' +
|
||||
"Here are the user's custom rules that REPLACE the corresponding default sections:\n\n" +
|
||||
userRulesSummary +
|
||||
'\nPlease critique these custom rules.',
|
||||
},
|
||||
],
|
||||
})
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
'Failed to analyze rules: ' + errorMessage(error) + '\n',
|
||||
)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const textBlock = response.content.find(block => block.type === 'text')
|
||||
if (textBlock?.type === 'text') {
|
||||
process.stdout.write(textBlock.text + '\n')
|
||||
} else {
|
||||
process.stdout.write('No critique was generated. Please try again.\n')
|
||||
}
|
||||
}
|
||||
|
||||
function formatRulesForCritique(
|
||||
section: string,
|
||||
userRules: string[],
|
||||
defaultRules: string[],
|
||||
): string {
|
||||
if (userRules.length === 0) return ''
|
||||
const customLines = userRules.map(r => '- ' + r).join('\n')
|
||||
const defaultLines = defaultRules.map(r => '- ' + r).join('\n')
|
||||
return (
|
||||
'## ' +
|
||||
section +
|
||||
' (custom rules replacing defaults)\n' +
|
||||
'Custom:\n' +
|
||||
customLines +
|
||||
'\n\n' +
|
||||
'Defaults being replaced:\n' +
|
||||
defaultLines +
|
||||
'\n\n'
|
||||
)
|
||||
}
|
||||
362
original-source-code/src/cli/handlers/mcp.tsx
Normal file
362
original-source-code/src/cli/handlers/mcp.tsx
Normal file
File diff suppressed because one or more lines are too long
878
original-source-code/src/cli/handlers/plugins.ts
Normal file
878
original-source-code/src/cli/handlers/plugins.ts
Normal file
@@ -0,0 +1,878 @@
|
||||
/**
|
||||
* Plugin and marketplace subcommand handlers — extracted from main.tsx for lazy loading.
|
||||
* These are dynamically imported only when `claude plugin *` or `claude plugin marketplace *` runs.
|
||||
*/
|
||||
/* eslint-disable custom-rules/no-process-exit -- CLI subcommand handlers intentionally exit */
|
||||
import figures from 'figures'
|
||||
import { basename, dirname } from 'path'
|
||||
import { setUseCoworkPlugins } from '../../bootstrap/state.js'
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
||||
logEvent,
|
||||
} from '../../services/analytics/index.js'
|
||||
import {
|
||||
disableAllPlugins,
|
||||
disablePlugin,
|
||||
enablePlugin,
|
||||
installPlugin,
|
||||
uninstallPlugin,
|
||||
updatePluginCli,
|
||||
VALID_INSTALLABLE_SCOPES,
|
||||
VALID_UPDATE_SCOPES,
|
||||
} from '../../services/plugins/pluginCliCommands.js'
|
||||
import { getPluginErrorMessage } from '../../types/plugin.js'
|
||||
import { errorMessage } from '../../utils/errors.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
import { clearAllCaches } from '../../utils/plugins/cacheUtils.js'
|
||||
import { getInstallCounts } from '../../utils/plugins/installCounts.js'
|
||||
import {
|
||||
isPluginInstalled,
|
||||
loadInstalledPluginsV2,
|
||||
} from '../../utils/plugins/installedPluginsManager.js'
|
||||
import {
|
||||
createPluginId,
|
||||
loadMarketplacesWithGracefulDegradation,
|
||||
} from '../../utils/plugins/marketplaceHelpers.js'
|
||||
import {
|
||||
addMarketplaceSource,
|
||||
loadKnownMarketplacesConfig,
|
||||
refreshAllMarketplaces,
|
||||
refreshMarketplace,
|
||||
removeMarketplaceSource,
|
||||
saveMarketplaceToSettings,
|
||||
} from '../../utils/plugins/marketplaceManager.js'
|
||||
import { loadPluginMcpServers } from '../../utils/plugins/mcpPluginIntegration.js'
|
||||
import { parseMarketplaceInput } from '../../utils/plugins/parseMarketplaceInput.js'
|
||||
import {
|
||||
parsePluginIdentifier,
|
||||
scopeToSettingSource,
|
||||
} from '../../utils/plugins/pluginIdentifier.js'
|
||||
import { loadAllPlugins } from '../../utils/plugins/pluginLoader.js'
|
||||
import type { PluginSource } from '../../utils/plugins/schemas.js'
|
||||
import {
|
||||
type ValidationResult,
|
||||
validateManifest,
|
||||
validatePluginContents,
|
||||
} from '../../utils/plugins/validatePlugin.js'
|
||||
import { jsonStringify } from '../../utils/slowOperations.js'
|
||||
import { plural } from '../../utils/stringUtils.js'
|
||||
import { cliError, cliOk } from '../exit.js'
|
||||
|
||||
// Re-export for main.tsx to reference in option definitions
|
||||
export { VALID_INSTALLABLE_SCOPES, VALID_UPDATE_SCOPES }
|
||||
|
||||
/**
|
||||
* Helper function to handle marketplace command errors consistently.
|
||||
*/
|
||||
export function handleMarketplaceError(error: unknown, action: string): never {
|
||||
logError(error)
|
||||
cliError(`${figures.cross} Failed to ${action}: ${errorMessage(error)}`)
|
||||
}
|
||||
|
||||
function printValidationResult(result: ValidationResult): void {
|
||||
if (result.errors.length > 0) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(
|
||||
`${figures.cross} Found ${result.errors.length} ${plural(result.errors.length, 'error')}:\n`,
|
||||
)
|
||||
result.errors.forEach(error => {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` ${figures.pointer} ${error.path}: ${error.message}`)
|
||||
})
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log('')
|
||||
}
|
||||
if (result.warnings.length > 0) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(
|
||||
`${figures.warning} Found ${result.warnings.length} ${plural(result.warnings.length, 'warning')}:\n`,
|
||||
)
|
||||
result.warnings.forEach(warning => {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` ${figures.pointer} ${warning.path}: ${warning.message}`)
|
||||
})
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log('')
|
||||
}
|
||||
}
|
||||
|
||||
// plugin validate
|
||||
export async function pluginValidateHandler(
|
||||
manifestPath: string,
|
||||
options: { cowork?: boolean },
|
||||
): Promise<void> {
|
||||
if (options.cowork) setUseCoworkPlugins(true)
|
||||
try {
|
||||
const result = await validateManifest(manifestPath)
|
||||
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`Validating ${result.fileType} manifest: ${result.filePath}\n`)
|
||||
printValidationResult(result)
|
||||
|
||||
// If this is a plugin manifest located inside a .claude-plugin directory,
|
||||
// also validate the plugin's content files (skills, agents, commands,
|
||||
// hooks). Works whether the user passed a directory or the plugin.json
|
||||
// path directly.
|
||||
let contentResults: ValidationResult[] = []
|
||||
if (result.fileType === 'plugin') {
|
||||
const manifestDir = dirname(result.filePath)
|
||||
if (basename(manifestDir) === '.claude-plugin') {
|
||||
contentResults = await validatePluginContents(dirname(manifestDir))
|
||||
for (const r of contentResults) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`Validating ${r.fileType}: ${r.filePath}\n`)
|
||||
printValidationResult(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allSuccess = result.success && contentResults.every(r => r.success)
|
||||
const hasWarnings =
|
||||
result.warnings.length > 0 ||
|
||||
contentResults.some(r => r.warnings.length > 0)
|
||||
|
||||
if (allSuccess) {
|
||||
cliOk(
|
||||
hasWarnings
|
||||
? `${figures.tick} Validation passed with warnings`
|
||||
: `${figures.tick} Validation passed`,
|
||||
)
|
||||
} else {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`${figures.cross} Validation failed`)
|
||||
process.exit(1)
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.error(
|
||||
`${figures.cross} Unexpected error during validation: ${errorMessage(error)}`,
|
||||
)
|
||||
process.exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
// plugin list (lines 5217–5416)
|
||||
export async function pluginListHandler(options: {
|
||||
json?: boolean
|
||||
available?: boolean
|
||||
cowork?: boolean
|
||||
}): Promise<void> {
|
||||
if (options.cowork) setUseCoworkPlugins(true)
|
||||
logEvent('tengu_plugin_list_command', {})
|
||||
|
||||
const installedData = loadInstalledPluginsV2()
|
||||
const { getPluginEditableScopes } = await import(
|
||||
'../../utils/plugins/pluginStartupCheck.js'
|
||||
)
|
||||
const enabledPlugins = getPluginEditableScopes()
|
||||
|
||||
const pluginIds = Object.keys(installedData.plugins)
|
||||
|
||||
// Load all plugins once. The JSON and human paths both need:
|
||||
// - loadErrors (to show load failures per plugin)
|
||||
// - inline plugins (session-only via --plugin-dir, source='name@inline')
|
||||
// which are NOT in installedData.plugins (V2 bookkeeping) — they must
|
||||
// be surfaced separately or `plugin list` silently ignores --plugin-dir.
|
||||
const {
|
||||
enabled: loadedEnabled,
|
||||
disabled: loadedDisabled,
|
||||
errors: loadErrors,
|
||||
} = await loadAllPlugins()
|
||||
const allLoadedPlugins = [...loadedEnabled, ...loadedDisabled]
|
||||
const inlinePlugins = allLoadedPlugins.filter(p =>
|
||||
p.source.endsWith('@inline'),
|
||||
)
|
||||
// Path-level inline failures (dir doesn't exist, parse error before
|
||||
// manifest is read) use source='inline[N]'. Plugin-level errors after
|
||||
// manifest read use source='name@inline'. Collect both for the session
|
||||
// section — these are otherwise invisible since they have no pluginId.
|
||||
const inlineLoadErrors = loadErrors.filter(
|
||||
e => e.source.endsWith('@inline') || e.source.startsWith('inline['),
|
||||
)
|
||||
|
||||
if (options.json) {
|
||||
// Create a map of plugin source to loaded plugin for quick lookup
|
||||
const loadedPluginMap = new Map(allLoadedPlugins.map(p => [p.source, p]))
|
||||
|
||||
const plugins: Array<{
|
||||
id: string
|
||||
version: string
|
||||
scope: string
|
||||
enabled: boolean
|
||||
installPath: string
|
||||
installedAt?: string
|
||||
lastUpdated?: string
|
||||
projectPath?: string
|
||||
mcpServers?: Record<string, unknown>
|
||||
errors?: string[]
|
||||
}> = []
|
||||
|
||||
for (const pluginId of pluginIds.sort()) {
|
||||
const installations = installedData.plugins[pluginId]
|
||||
if (!installations || installations.length === 0) continue
|
||||
|
||||
// Find loading errors for this plugin
|
||||
const pluginName = parsePluginIdentifier(pluginId).name
|
||||
const pluginErrors = loadErrors
|
||||
.filter(
|
||||
e =>
|
||||
e.source === pluginId || ('plugin' in e && e.plugin === pluginName),
|
||||
)
|
||||
.map(getPluginErrorMessage)
|
||||
|
||||
for (const installation of installations) {
|
||||
// Try to find the loaded plugin to get MCP servers
|
||||
const loadedPlugin = loadedPluginMap.get(pluginId)
|
||||
let mcpServers: Record<string, unknown> | undefined
|
||||
|
||||
if (loadedPlugin) {
|
||||
// Load MCP servers if not already cached
|
||||
const servers =
|
||||
loadedPlugin.mcpServers ||
|
||||
(await loadPluginMcpServers(loadedPlugin))
|
||||
if (servers && Object.keys(servers).length > 0) {
|
||||
mcpServers = servers
|
||||
}
|
||||
}
|
||||
|
||||
plugins.push({
|
||||
id: pluginId,
|
||||
version: installation.version || 'unknown',
|
||||
scope: installation.scope,
|
||||
enabled: enabledPlugins.has(pluginId),
|
||||
installPath: installation.installPath,
|
||||
installedAt: installation.installedAt,
|
||||
lastUpdated: installation.lastUpdated,
|
||||
projectPath: installation.projectPath,
|
||||
mcpServers,
|
||||
errors: pluginErrors.length > 0 ? pluginErrors : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Session-only plugins: scope='session', no install metadata.
|
||||
// Filter from inlineLoadErrors (not loadErrors) so an installed plugin
|
||||
// with the same manifest name doesn't cross-contaminate via e.plugin.
|
||||
// The e.plugin fallback catches the dirName≠manifestName case:
|
||||
// createPluginFromPath tags errors with `${dirName}@inline` but
|
||||
// plugin.source is reassigned to `${manifest.name}@inline` afterward
|
||||
// (pluginLoader.ts loadInlinePlugins), so e.source !== p.source when
|
||||
// a dev checkout dir like ~/code/my-fork/ has manifest name 'cool-plugin'.
|
||||
for (const p of inlinePlugins) {
|
||||
const servers = p.mcpServers || (await loadPluginMcpServers(p))
|
||||
const pErrors = inlineLoadErrors
|
||||
.filter(
|
||||
e => e.source === p.source || ('plugin' in e && e.plugin === p.name),
|
||||
)
|
||||
.map(getPluginErrorMessage)
|
||||
plugins.push({
|
||||
id: p.source,
|
||||
version: p.manifest.version ?? 'unknown',
|
||||
scope: 'session',
|
||||
enabled: p.enabled !== false,
|
||||
installPath: p.path,
|
||||
mcpServers:
|
||||
servers && Object.keys(servers).length > 0 ? servers : undefined,
|
||||
errors: pErrors.length > 0 ? pErrors : undefined,
|
||||
})
|
||||
}
|
||||
// Path-level inline failures (--plugin-dir /nonexistent): no LoadedPlugin
|
||||
// exists so the loop above can't surface them. Mirror the human-path
|
||||
// handling so JSON consumers see the failure instead of silent omission.
|
||||
for (const e of inlineLoadErrors.filter(e =>
|
||||
e.source.startsWith('inline['),
|
||||
)) {
|
||||
plugins.push({
|
||||
id: e.source,
|
||||
version: 'unknown',
|
||||
scope: 'session',
|
||||
enabled: false,
|
||||
installPath: 'path' in e ? e.path : '',
|
||||
errors: [getPluginErrorMessage(e)],
|
||||
})
|
||||
}
|
||||
|
||||
// If --available is set, also load available plugins from marketplaces
|
||||
if (options.available) {
|
||||
const available: Array<{
|
||||
pluginId: string
|
||||
name: string
|
||||
description?: string
|
||||
marketplaceName: string
|
||||
version?: string
|
||||
source: PluginSource
|
||||
installCount?: number
|
||||
}> = []
|
||||
|
||||
try {
|
||||
const [config, installCounts] = await Promise.all([
|
||||
loadKnownMarketplacesConfig(),
|
||||
getInstallCounts(),
|
||||
])
|
||||
const { marketplaces } =
|
||||
await loadMarketplacesWithGracefulDegradation(config)
|
||||
|
||||
for (const {
|
||||
name: marketplaceName,
|
||||
data: marketplace,
|
||||
} of marketplaces) {
|
||||
if (marketplace) {
|
||||
for (const entry of marketplace.plugins) {
|
||||
const pluginId = createPluginId(entry.name, marketplaceName)
|
||||
// Only include plugins that are not already installed
|
||||
if (!isPluginInstalled(pluginId)) {
|
||||
available.push({
|
||||
pluginId,
|
||||
name: entry.name,
|
||||
description: entry.description,
|
||||
marketplaceName,
|
||||
version: entry.version,
|
||||
source: entry.source,
|
||||
installCount: installCounts?.get(pluginId),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore marketplace loading errors
|
||||
}
|
||||
|
||||
cliOk(jsonStringify({ installed: plugins, available }, null, 2))
|
||||
} else {
|
||||
cliOk(jsonStringify(plugins, null, 2))
|
||||
}
|
||||
}
|
||||
|
||||
if (pluginIds.length === 0 && inlinePlugins.length === 0) {
|
||||
// inlineLoadErrors can exist with zero inline plugins (e.g. --plugin-dir
|
||||
// points at a nonexistent path). Don't early-exit over them — fall
|
||||
// through to the session section so the failure is visible.
|
||||
if (inlineLoadErrors.length === 0) {
|
||||
cliOk(
|
||||
'No plugins installed. Use `claude plugin install` to install a plugin.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (pluginIds.length > 0) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log('Installed plugins:\n')
|
||||
}
|
||||
|
||||
for (const pluginId of pluginIds.sort()) {
|
||||
const installations = installedData.plugins[pluginId]
|
||||
if (!installations || installations.length === 0) continue
|
||||
|
||||
// Find loading errors for this plugin
|
||||
const pluginName = parsePluginIdentifier(pluginId).name
|
||||
const pluginErrors = loadErrors.filter(
|
||||
e => e.source === pluginId || ('plugin' in e && e.plugin === pluginName),
|
||||
)
|
||||
|
||||
for (const installation of installations) {
|
||||
const isEnabled = enabledPlugins.has(pluginId)
|
||||
const status =
|
||||
pluginErrors.length > 0
|
||||
? `${figures.cross} failed to load`
|
||||
: isEnabled
|
||||
? `${figures.tick} enabled`
|
||||
: `${figures.cross} disabled`
|
||||
const version = installation.version || 'unknown'
|
||||
const scope = installation.scope
|
||||
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` ${figures.pointer} ${pluginId}`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Version: ${version}`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Scope: ${scope}`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Status: ${status}`)
|
||||
for (const error of pluginErrors) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Error: ${getPluginErrorMessage(error)}`)
|
||||
}
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log('')
|
||||
}
|
||||
}
|
||||
|
||||
if (inlinePlugins.length > 0 || inlineLoadErrors.length > 0) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log('Session-only plugins (--plugin-dir):\n')
|
||||
for (const p of inlinePlugins) {
|
||||
// Same dirName≠manifestName fallback as the JSON path above — error
|
||||
// sources use the dir basename but p.source uses the manifest name.
|
||||
const pErrors = inlineLoadErrors.filter(
|
||||
e => e.source === p.source || ('plugin' in e && e.plugin === p.name),
|
||||
)
|
||||
const status =
|
||||
pErrors.length > 0
|
||||
? `${figures.cross} loaded with errors`
|
||||
: `${figures.tick} loaded`
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` ${figures.pointer} ${p.source}`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Version: ${p.manifest.version ?? 'unknown'}`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Path: ${p.path}`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Status: ${status}`)
|
||||
for (const e of pErrors) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Error: ${getPluginErrorMessage(e)}`)
|
||||
}
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log('')
|
||||
}
|
||||
// Path-level failures: no LoadedPlugin object exists. Show them so
|
||||
// `--plugin-dir /typo` doesn't just silently produce nothing.
|
||||
for (const e of inlineLoadErrors.filter(e =>
|
||||
e.source.startsWith('inline['),
|
||||
)) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(
|
||||
` ${figures.pointer} ${e.source}: ${figures.cross} ${getPluginErrorMessage(e)}\n`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
cliOk()
|
||||
}
|
||||
|
||||
// marketplace add (lines 5433–5487)
|
||||
export async function marketplaceAddHandler(
|
||||
source: string,
|
||||
options: { cowork?: boolean; sparse?: string[]; scope?: string },
|
||||
): Promise<void> {
|
||||
if (options.cowork) setUseCoworkPlugins(true)
|
||||
try {
|
||||
const parsed = await parseMarketplaceInput(source)
|
||||
|
||||
if (!parsed) {
|
||||
cliError(
|
||||
`${figures.cross} Invalid marketplace source format. Try: owner/repo, https://..., or ./path`,
|
||||
)
|
||||
}
|
||||
|
||||
if ('error' in parsed) {
|
||||
cliError(`${figures.cross} ${parsed.error}`)
|
||||
}
|
||||
|
||||
// Validate scope
|
||||
const scope = options.scope ?? 'user'
|
||||
if (scope !== 'user' && scope !== 'project' && scope !== 'local') {
|
||||
cliError(
|
||||
`${figures.cross} Invalid scope '${scope}'. Use: user, project, or local`,
|
||||
)
|
||||
}
|
||||
const settingSource = scopeToSettingSource(scope)
|
||||
|
||||
let marketplaceSource = parsed
|
||||
|
||||
if (options.sparse && options.sparse.length > 0) {
|
||||
if (
|
||||
marketplaceSource.source === 'github' ||
|
||||
marketplaceSource.source === 'git'
|
||||
) {
|
||||
marketplaceSource = {
|
||||
...marketplaceSource,
|
||||
sparsePaths: options.sparse,
|
||||
}
|
||||
} else {
|
||||
cliError(
|
||||
`${figures.cross} --sparse is only supported for github and git marketplace sources (got: ${marketplaceSource.source})`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log('Adding marketplace...')
|
||||
|
||||
const { name, alreadyMaterialized, resolvedSource } =
|
||||
await addMarketplaceSource(marketplaceSource, message => {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(message)
|
||||
})
|
||||
|
||||
// Write intent to settings at the requested scope
|
||||
saveMarketplaceToSettings(name, { source: resolvedSource }, settingSource)
|
||||
|
||||
clearAllCaches()
|
||||
|
||||
let sourceType = marketplaceSource.source
|
||||
if (marketplaceSource.source === 'github') {
|
||||
sourceType =
|
||||
marketplaceSource.repo as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||||
}
|
||||
logEvent('tengu_marketplace_added', {
|
||||
source_type:
|
||||
sourceType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
|
||||
cliOk(
|
||||
alreadyMaterialized
|
||||
? `${figures.tick} Marketplace '${name}' already on disk — declared in ${scope} settings`
|
||||
: `${figures.tick} Successfully added marketplace: ${name} (declared in ${scope} settings)`,
|
||||
)
|
||||
} catch (error) {
|
||||
handleMarketplaceError(error, 'add marketplace')
|
||||
}
|
||||
}
|
||||
|
||||
// marketplace list (lines 5497–5565)
|
||||
export async function marketplaceListHandler(options: {
|
||||
json?: boolean
|
||||
cowork?: boolean
|
||||
}): Promise<void> {
|
||||
if (options.cowork) setUseCoworkPlugins(true)
|
||||
try {
|
||||
const config = await loadKnownMarketplacesConfig()
|
||||
const names = Object.keys(config)
|
||||
|
||||
if (options.json) {
|
||||
const marketplaces = names.sort().map(name => {
|
||||
const marketplace = config[name]
|
||||
const source = marketplace?.source
|
||||
return {
|
||||
name,
|
||||
source: source?.source,
|
||||
...(source?.source === 'github' && { repo: source.repo }),
|
||||
...(source?.source === 'git' && { url: source.url }),
|
||||
...(source?.source === 'url' && { url: source.url }),
|
||||
...(source?.source === 'directory' && { path: source.path }),
|
||||
...(source?.source === 'file' && { path: source.path }),
|
||||
installLocation: marketplace?.installLocation,
|
||||
}
|
||||
})
|
||||
cliOk(jsonStringify(marketplaces, null, 2))
|
||||
}
|
||||
|
||||
if (names.length === 0) {
|
||||
cliOk('No marketplaces configured')
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log('Configured marketplaces:\n')
|
||||
names.forEach(name => {
|
||||
const marketplace = config[name]
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` ${figures.pointer} ${name}`)
|
||||
|
||||
if (marketplace?.source) {
|
||||
const src = marketplace.source
|
||||
if (src.source === 'github') {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Source: GitHub (${src.repo})`)
|
||||
} else if (src.source === 'git') {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Source: Git (${src.url})`)
|
||||
} else if (src.source === 'url') {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Source: URL (${src.url})`)
|
||||
} else if (src.source === 'directory') {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Source: Directory (${src.path})`)
|
||||
} else if (src.source === 'file') {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Source: File (${src.path})`)
|
||||
}
|
||||
}
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log('')
|
||||
})
|
||||
|
||||
cliOk()
|
||||
} catch (error) {
|
||||
handleMarketplaceError(error, 'list marketplaces')
|
||||
}
|
||||
}
|
||||
|
||||
// marketplace remove (lines 5576–5598)
|
||||
export async function marketplaceRemoveHandler(
|
||||
name: string,
|
||||
options: { cowork?: boolean },
|
||||
): Promise<void> {
|
||||
if (options.cowork) setUseCoworkPlugins(true)
|
||||
try {
|
||||
await removeMarketplaceSource(name)
|
||||
clearAllCaches()
|
||||
|
||||
logEvent('tengu_marketplace_removed', {
|
||||
marketplace_name:
|
||||
name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
|
||||
cliOk(`${figures.tick} Successfully removed marketplace: ${name}`)
|
||||
} catch (error) {
|
||||
handleMarketplaceError(error, 'remove marketplace')
|
||||
}
|
||||
}
|
||||
|
||||
// marketplace update (lines 5609–5672)
|
||||
export async function marketplaceUpdateHandler(
|
||||
name: string | undefined,
|
||||
options: { cowork?: boolean },
|
||||
): Promise<void> {
|
||||
if (options.cowork) setUseCoworkPlugins(true)
|
||||
try {
|
||||
if (name) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`Updating marketplace: ${name}...`)
|
||||
|
||||
await refreshMarketplace(name, message => {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(message)
|
||||
})
|
||||
|
||||
clearAllCaches()
|
||||
|
||||
logEvent('tengu_marketplace_updated', {
|
||||
marketplace_name:
|
||||
name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
|
||||
cliOk(`${figures.tick} Successfully updated marketplace: ${name}`)
|
||||
} else {
|
||||
const config = await loadKnownMarketplacesConfig()
|
||||
const marketplaceNames = Object.keys(config)
|
||||
|
||||
if (marketplaceNames.length === 0) {
|
||||
cliOk('No marketplaces configured')
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`Updating ${marketplaceNames.length} marketplace(s)...`)
|
||||
|
||||
await refreshAllMarketplaces()
|
||||
clearAllCaches()
|
||||
|
||||
logEvent('tengu_marketplace_updated_all', {
|
||||
count:
|
||||
marketplaceNames.length as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
|
||||
cliOk(
|
||||
`${figures.tick} Successfully updated ${marketplaceNames.length} marketplace(s)`,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
handleMarketplaceError(error, 'update marketplace(s)')
|
||||
}
|
||||
}
|
||||
|
||||
// plugin install (lines 5690–5721)
|
||||
export async function pluginInstallHandler(
|
||||
plugin: string,
|
||||
options: { scope?: string; cowork?: boolean },
|
||||
): Promise<void> {
|
||||
if (options.cowork) setUseCoworkPlugins(true)
|
||||
const scope = options.scope || 'user'
|
||||
if (options.cowork && scope !== 'user') {
|
||||
cliError('--cowork can only be used with user scope')
|
||||
}
|
||||
if (
|
||||
!VALID_INSTALLABLE_SCOPES.includes(
|
||||
scope as (typeof VALID_INSTALLABLE_SCOPES)[number],
|
||||
)
|
||||
) {
|
||||
cliError(
|
||||
`Invalid scope: ${scope}. Must be one of: ${VALID_INSTALLABLE_SCOPES.join(', ')}.`,
|
||||
)
|
||||
}
|
||||
// _PROTO_* routes to PII-tagged plugin_name/marketplace_name BQ columns.
|
||||
// Unredacted plugin arg was previously logged to general-access
|
||||
// additional_metadata for all users — dropped in favor of the privileged
|
||||
// column route. marketplace may be undefined (fires before resolution).
|
||||
const { name, marketplace } = parsePluginIdentifier(plugin)
|
||||
logEvent('tengu_plugin_install_command', {
|
||||
_PROTO_plugin_name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
||||
...(marketplace && {
|
||||
_PROTO_marketplace_name:
|
||||
marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
||||
}),
|
||||
scope: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
|
||||
await installPlugin(plugin, scope as 'user' | 'project' | 'local')
|
||||
}
|
||||
|
||||
// plugin uninstall (lines 5738–5769)
|
||||
export async function pluginUninstallHandler(
|
||||
plugin: string,
|
||||
options: { scope?: string; cowork?: boolean; keepData?: boolean },
|
||||
): Promise<void> {
|
||||
if (options.cowork) setUseCoworkPlugins(true)
|
||||
const scope = options.scope || 'user'
|
||||
if (options.cowork && scope !== 'user') {
|
||||
cliError('--cowork can only be used with user scope')
|
||||
}
|
||||
if (
|
||||
!VALID_INSTALLABLE_SCOPES.includes(
|
||||
scope as (typeof VALID_INSTALLABLE_SCOPES)[number],
|
||||
)
|
||||
) {
|
||||
cliError(
|
||||
`Invalid scope: ${scope}. Must be one of: ${VALID_INSTALLABLE_SCOPES.join(', ')}.`,
|
||||
)
|
||||
}
|
||||
const { name, marketplace } = parsePluginIdentifier(plugin)
|
||||
logEvent('tengu_plugin_uninstall_command', {
|
||||
_PROTO_plugin_name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
||||
...(marketplace && {
|
||||
_PROTO_marketplace_name:
|
||||
marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
||||
}),
|
||||
scope: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
|
||||
await uninstallPlugin(
|
||||
plugin,
|
||||
scope as 'user' | 'project' | 'local',
|
||||
options.keepData,
|
||||
)
|
||||
}
|
||||
|
||||
// plugin enable (lines 5783–5818)
|
||||
export async function pluginEnableHandler(
|
||||
plugin: string,
|
||||
options: { scope?: string; cowork?: boolean },
|
||||
): Promise<void> {
|
||||
if (options.cowork) setUseCoworkPlugins(true)
|
||||
let scope: (typeof VALID_INSTALLABLE_SCOPES)[number] | undefined
|
||||
if (options.scope) {
|
||||
if (
|
||||
!VALID_INSTALLABLE_SCOPES.includes(
|
||||
options.scope as (typeof VALID_INSTALLABLE_SCOPES)[number],
|
||||
)
|
||||
) {
|
||||
cliError(
|
||||
`Invalid scope "${options.scope}". Valid scopes: ${VALID_INSTALLABLE_SCOPES.join(', ')}`,
|
||||
)
|
||||
}
|
||||
scope = options.scope as (typeof VALID_INSTALLABLE_SCOPES)[number]
|
||||
}
|
||||
if (options.cowork && scope !== undefined && scope !== 'user') {
|
||||
cliError('--cowork can only be used with user scope')
|
||||
}
|
||||
|
||||
// --cowork always operates at user scope
|
||||
if (options.cowork && scope === undefined) {
|
||||
scope = 'user'
|
||||
}
|
||||
|
||||
const { name, marketplace } = parsePluginIdentifier(plugin)
|
||||
logEvent('tengu_plugin_enable_command', {
|
||||
_PROTO_plugin_name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
||||
...(marketplace && {
|
||||
_PROTO_marketplace_name:
|
||||
marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
||||
}),
|
||||
scope: (scope ??
|
||||
'auto') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
|
||||
await enablePlugin(plugin, scope)
|
||||
}
|
||||
|
||||
// plugin disable (lines 5833–5902)
|
||||
export async function pluginDisableHandler(
|
||||
plugin: string | undefined,
|
||||
options: { scope?: string; cowork?: boolean; all?: boolean },
|
||||
): Promise<void> {
|
||||
if (options.all && plugin) {
|
||||
cliError('Cannot use --all with a specific plugin')
|
||||
}
|
||||
|
||||
if (!options.all && !plugin) {
|
||||
cliError('Please specify a plugin name or use --all to disable all plugins')
|
||||
}
|
||||
|
||||
if (options.cowork) setUseCoworkPlugins(true)
|
||||
|
||||
if (options.all) {
|
||||
if (options.scope) {
|
||||
cliError('Cannot use --scope with --all')
|
||||
}
|
||||
|
||||
// No _PROTO_plugin_name here — --all disables all plugins.
|
||||
// Distinguishable from the specific-plugin branch by plugin_name IS NULL.
|
||||
logEvent('tengu_plugin_disable_command', {})
|
||||
|
||||
await disableAllPlugins()
|
||||
return
|
||||
}
|
||||
|
||||
let scope: (typeof VALID_INSTALLABLE_SCOPES)[number] | undefined
|
||||
if (options.scope) {
|
||||
if (
|
||||
!VALID_INSTALLABLE_SCOPES.includes(
|
||||
options.scope as (typeof VALID_INSTALLABLE_SCOPES)[number],
|
||||
)
|
||||
) {
|
||||
cliError(
|
||||
`Invalid scope "${options.scope}". Valid scopes: ${VALID_INSTALLABLE_SCOPES.join(', ')}`,
|
||||
)
|
||||
}
|
||||
scope = options.scope as (typeof VALID_INSTALLABLE_SCOPES)[number]
|
||||
}
|
||||
if (options.cowork && scope !== undefined && scope !== 'user') {
|
||||
cliError('--cowork can only be used with user scope')
|
||||
}
|
||||
|
||||
// --cowork always operates at user scope
|
||||
if (options.cowork && scope === undefined) {
|
||||
scope = 'user'
|
||||
}
|
||||
|
||||
const { name, marketplace } = parsePluginIdentifier(plugin!)
|
||||
logEvent('tengu_plugin_disable_command', {
|
||||
_PROTO_plugin_name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
||||
...(marketplace && {
|
||||
_PROTO_marketplace_name:
|
||||
marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
||||
}),
|
||||
scope: (scope ??
|
||||
'auto') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
|
||||
await disablePlugin(plugin!, scope)
|
||||
}
|
||||
|
||||
// plugin update (lines 5918–5948)
|
||||
export async function pluginUpdateHandler(
|
||||
plugin: string,
|
||||
options: { scope?: string; cowork?: boolean },
|
||||
): Promise<void> {
|
||||
if (options.cowork) setUseCoworkPlugins(true)
|
||||
const { name, marketplace } = parsePluginIdentifier(plugin)
|
||||
logEvent('tengu_plugin_update_command', {
|
||||
_PROTO_plugin_name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
||||
...(marketplace && {
|
||||
_PROTO_marketplace_name:
|
||||
marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
||||
}),
|
||||
})
|
||||
|
||||
let scope: (typeof VALID_UPDATE_SCOPES)[number] = 'user'
|
||||
if (options.scope) {
|
||||
if (
|
||||
!VALID_UPDATE_SCOPES.includes(
|
||||
options.scope as (typeof VALID_UPDATE_SCOPES)[number],
|
||||
)
|
||||
) {
|
||||
cliError(
|
||||
`Invalid scope "${options.scope}". Valid scopes: ${VALID_UPDATE_SCOPES.join(', ')}`,
|
||||
)
|
||||
}
|
||||
scope = options.scope as (typeof VALID_UPDATE_SCOPES)[number]
|
||||
}
|
||||
if (options.cowork && scope !== 'user') {
|
||||
cliError('--cowork can only be used with user scope')
|
||||
}
|
||||
|
||||
await updatePluginCli(plugin, scope)
|
||||
}
|
||||
110
original-source-code/src/cli/handlers/util.tsx
Normal file
110
original-source-code/src/cli/handlers/util.tsx
Normal file
File diff suppressed because one or more lines are too long
32
original-source-code/src/cli/ndjsonSafeStringify.ts
Normal file
32
original-source-code/src/cli/ndjsonSafeStringify.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { jsonStringify } from '../utils/slowOperations.js'
|
||||
|
||||
// JSON.stringify emits U+2028/U+2029 raw (valid per ECMA-404). When the
|
||||
// output is a single NDJSON line, any receiver that uses JavaScript
|
||||
// line-terminator semantics (ECMA-262 §11.3 — \n \r U+2028 U+2029) to
|
||||
// split the stream will cut the JSON mid-string. ProcessTransport now
|
||||
// silently skips non-JSON lines rather than crashing (gh-28405), but
|
||||
// the truncated fragment is still lost — the message is silently dropped.
|
||||
//
|
||||
// The \uXXXX form is equivalent JSON (parses to the same string) but
|
||||
// can never be mistaken for a line terminator by ANY receiver. This is
|
||||
// what ES2019's "Subsume JSON" proposal and Node's util.inspect do.
|
||||
//
|
||||
// Single regex with alternation: the callback's one dispatch per match
|
||||
// is cheaper than two full-string scans.
|
||||
const JS_LINE_TERMINATORS = /\u2028|\u2029/g
|
||||
|
||||
function escapeJsLineTerminators(json: string): string {
|
||||
return json.replace(JS_LINE_TERMINATORS, c =>
|
||||
c === '\u2028' ? '\\u2028' : '\\u2029',
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON.stringify for one-message-per-line transports. Escapes U+2028
|
||||
* LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR so the serialized output
|
||||
* cannot be broken by a line-splitting receiver. Output is still valid
|
||||
* JSON and parses to the same value.
|
||||
*/
|
||||
export function ndjsonSafeStringify(value: unknown): string {
|
||||
return escapeJsLineTerminators(jsonStringify(value))
|
||||
}
|
||||
5594
original-source-code/src/cli/print.ts
Normal file
5594
original-source-code/src/cli/print.ts
Normal file
File diff suppressed because it is too large
Load Diff
255
original-source-code/src/cli/remoteIO.ts
Normal file
255
original-source-code/src/cli/remoteIO.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import type { StdoutMessage } from 'src/entrypoints/sdk/controlTypes.js'
|
||||
import { PassThrough } from 'stream'
|
||||
import { URL } from 'url'
|
||||
import { getSessionId } from '../bootstrap/state.js'
|
||||
import { getPollIntervalConfig } from '../bridge/pollConfig.js'
|
||||
import { registerCleanup } from '../utils/cleanupRegistry.js'
|
||||
import { setCommandLifecycleListener } from '../utils/commandLifecycle.js'
|
||||
import { isDebugMode, logForDebugging } from '../utils/debug.js'
|
||||
import { logForDiagnosticsNoPII } from '../utils/diagLogs.js'
|
||||
import { isEnvTruthy } from '../utils/envUtils.js'
|
||||
import { errorMessage } from '../utils/errors.js'
|
||||
import { gracefulShutdown } from '../utils/gracefulShutdown.js'
|
||||
import { logError } from '../utils/log.js'
|
||||
import { writeToStdout } from '../utils/process.js'
|
||||
import { getSessionIngressAuthToken } from '../utils/sessionIngressAuth.js'
|
||||
import {
|
||||
setSessionMetadataChangedListener,
|
||||
setSessionStateChangedListener,
|
||||
} from '../utils/sessionState.js'
|
||||
import {
|
||||
setInternalEventReader,
|
||||
setInternalEventWriter,
|
||||
} from '../utils/sessionStorage.js'
|
||||
import { ndjsonSafeStringify } from './ndjsonSafeStringify.js'
|
||||
import { StructuredIO } from './structuredIO.js'
|
||||
import { CCRClient, CCRInitError } from './transports/ccrClient.js'
|
||||
import { SSETransport } from './transports/SSETransport.js'
|
||||
import type { Transport } from './transports/Transport.js'
|
||||
import { getTransportForUrl } from './transports/transportUtils.js'
|
||||
|
||||
/**
|
||||
* Bidirectional streaming for SDK mode with session tracking
|
||||
* Supports WebSocket transport
|
||||
*/
|
||||
export class RemoteIO extends StructuredIO {
|
||||
private url: URL
|
||||
private transport: Transport
|
||||
private inputStream: PassThrough
|
||||
private readonly isBridge: boolean = false
|
||||
private readonly isDebug: boolean = false
|
||||
private ccrClient: CCRClient | null = null
|
||||
private keepAliveTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
constructor(
|
||||
streamUrl: string,
|
||||
initialPrompt?: AsyncIterable<string>,
|
||||
replayUserMessages?: boolean,
|
||||
) {
|
||||
const inputStream = new PassThrough({ encoding: 'utf8' })
|
||||
super(inputStream, replayUserMessages)
|
||||
this.inputStream = inputStream
|
||||
this.url = new URL(streamUrl)
|
||||
|
||||
// Prepare headers with session token if available
|
||||
const headers: Record<string, string> = {}
|
||||
const sessionToken = getSessionIngressAuthToken()
|
||||
if (sessionToken) {
|
||||
headers['Authorization'] = `Bearer ${sessionToken}`
|
||||
} else {
|
||||
logForDebugging('[remote-io] No session ingress token available', {
|
||||
level: 'error',
|
||||
})
|
||||
}
|
||||
|
||||
// Add environment runner version if available (set by Environment Manager)
|
||||
const erVersion = process.env.CLAUDE_CODE_ENVIRONMENT_RUNNER_VERSION
|
||||
if (erVersion) {
|
||||
headers['x-environment-runner-version'] = erVersion
|
||||
}
|
||||
|
||||
// Provide a callback that re-reads the session token dynamically.
|
||||
// When the parent process refreshes the token (via token file or env var),
|
||||
// the transport can pick it up on reconnection.
|
||||
const refreshHeaders = (): Record<string, string> => {
|
||||
const h: Record<string, string> = {}
|
||||
const freshToken = getSessionIngressAuthToken()
|
||||
if (freshToken) {
|
||||
h['Authorization'] = `Bearer ${freshToken}`
|
||||
}
|
||||
const freshErVersion = process.env.CLAUDE_CODE_ENVIRONMENT_RUNNER_VERSION
|
||||
if (freshErVersion) {
|
||||
h['x-environment-runner-version'] = freshErVersion
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// Get appropriate transport based on URL protocol
|
||||
this.transport = getTransportForUrl(
|
||||
this.url,
|
||||
headers,
|
||||
getSessionId(),
|
||||
refreshHeaders,
|
||||
)
|
||||
|
||||
// Set up data callback
|
||||
this.isBridge = process.env.CLAUDE_CODE_ENVIRONMENT_KIND === 'bridge'
|
||||
this.isDebug = isDebugMode()
|
||||
this.transport.setOnData((data: string) => {
|
||||
this.inputStream.write(data)
|
||||
if (this.isBridge && this.isDebug) {
|
||||
writeToStdout(data.endsWith('\n') ? data : data + '\n')
|
||||
}
|
||||
})
|
||||
|
||||
// Set up close callback to handle connection failures
|
||||
this.transport.setOnClose(() => {
|
||||
// End the input stream to trigger graceful shutdown
|
||||
this.inputStream.end()
|
||||
})
|
||||
|
||||
// Initialize CCR v2 client (heartbeats, epoch, state reporting, event writes).
|
||||
// The CCRClient constructor wires the SSE received-ack handler
|
||||
// synchronously, so new CCRClient() MUST run before transport.connect() —
|
||||
// otherwise early SSE frames hit an unwired onEventCallback and their
|
||||
// 'received' delivery acks are silently dropped.
|
||||
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_CCR_V2)) {
|
||||
// CCR v2 is SSE+POST by definition. getTransportForUrl returns
|
||||
// SSETransport under the same env var, but the two checks live in
|
||||
// different files — assert the invariant so a future decoupling
|
||||
// fails loudly here instead of confusingly inside CCRClient.
|
||||
if (!(this.transport instanceof SSETransport)) {
|
||||
throw new Error(
|
||||
'CCR v2 requires SSETransport; check getTransportForUrl',
|
||||
)
|
||||
}
|
||||
this.ccrClient = new CCRClient(this.transport, this.url)
|
||||
const init = this.ccrClient.initialize()
|
||||
this.restoredWorkerState = init.catch(() => null)
|
||||
init.catch((error: unknown) => {
|
||||
logForDiagnosticsNoPII('error', 'cli_worker_lifecycle_init_failed', {
|
||||
reason: error instanceof CCRInitError ? error.reason : 'unknown',
|
||||
})
|
||||
logError(
|
||||
new Error(`CCRClient initialization failed: ${errorMessage(error)}`),
|
||||
)
|
||||
void gracefulShutdown(1, 'other')
|
||||
})
|
||||
registerCleanup(async () => this.ccrClient?.close())
|
||||
|
||||
// Register internal event writer for transcript persistence.
|
||||
// When set, sessionStorage writes transcript messages as CCR v2
|
||||
// internal events instead of v1 Session Ingress.
|
||||
setInternalEventWriter((eventType, payload, options) =>
|
||||
this.ccrClient!.writeInternalEvent(eventType, payload, options),
|
||||
)
|
||||
|
||||
// Register internal event readers for session resume.
|
||||
// When set, hydrateFromCCRv2InternalEvents() can fetch foreground
|
||||
// and subagent internal events to reconstruct conversation state.
|
||||
setInternalEventReader(
|
||||
() => this.ccrClient!.readInternalEvents(),
|
||||
() => this.ccrClient!.readSubagentInternalEvents(),
|
||||
)
|
||||
|
||||
const LIFECYCLE_TO_DELIVERY = {
|
||||
started: 'processing',
|
||||
completed: 'processed',
|
||||
} as const
|
||||
setCommandLifecycleListener((uuid, state) => {
|
||||
this.ccrClient?.reportDelivery(uuid, LIFECYCLE_TO_DELIVERY[state])
|
||||
})
|
||||
setSessionStateChangedListener((state, details) => {
|
||||
this.ccrClient?.reportState(state, details)
|
||||
})
|
||||
setSessionMetadataChangedListener(metadata => {
|
||||
this.ccrClient?.reportMetadata(metadata)
|
||||
})
|
||||
}
|
||||
|
||||
// Start connection only after all callbacks are wired (setOnData above,
|
||||
// setOnEvent inside new CCRClient() when CCR v2 is enabled).
|
||||
void this.transport.connect()
|
||||
|
||||
// Push a silent keep_alive frame on a fixed interval so upstream
|
||||
// proxies and the session-ingress layer don't GC an otherwise-idle
|
||||
// remote control session. The keep_alive type is filtered before
|
||||
// reaching any client UI (Query.ts drops it; structuredIO.ts drops it;
|
||||
// web/iOS/Android never see it in their message loop). Interval comes
|
||||
// from GrowthBook (tengu_bridge_poll_interval_config
|
||||
// session_keepalive_interval_v2_ms, default 120s); 0 = disabled.
|
||||
// Bridge-only: fixes Envoy idle timeout on bridge-topology sessions
|
||||
// (#21931). byoc workers ran without this before #21931 and do not
|
||||
// need it — different network path.
|
||||
const keepAliveIntervalMs =
|
||||
getPollIntervalConfig().session_keepalive_interval_v2_ms
|
||||
if (this.isBridge && keepAliveIntervalMs > 0) {
|
||||
this.keepAliveTimer = setInterval(() => {
|
||||
logForDebugging('[remote-io] keep_alive sent')
|
||||
void this.write({ type: 'keep_alive' }).catch(err => {
|
||||
logForDebugging(
|
||||
`[remote-io] keep_alive write failed: ${errorMessage(err)}`,
|
||||
)
|
||||
})
|
||||
}, keepAliveIntervalMs)
|
||||
this.keepAliveTimer.unref?.()
|
||||
}
|
||||
|
||||
// Register for graceful shutdown cleanup
|
||||
registerCleanup(async () => this.close())
|
||||
|
||||
// If initial prompt is provided, send it through the input stream
|
||||
if (initialPrompt) {
|
||||
// Convert the initial prompt to the input stream format.
|
||||
// Chunks from stdin may already contain trailing newlines, so strip
|
||||
// them before appending our own to avoid double-newline issues that
|
||||
// cause structuredIO to parse empty lines. String() handles both
|
||||
// string chunks and Buffer objects from process.stdin.
|
||||
const stream = this.inputStream
|
||||
void (async () => {
|
||||
for await (const chunk of initialPrompt) {
|
||||
stream.write(String(chunk).replace(/\n$/, '') + '\n')
|
||||
}
|
||||
})()
|
||||
}
|
||||
}
|
||||
|
||||
override flushInternalEvents(): Promise<void> {
|
||||
return this.ccrClient?.flushInternalEvents() ?? Promise.resolve()
|
||||
}
|
||||
|
||||
override get internalEventsPending(): number {
|
||||
return this.ccrClient?.internalEventsPending ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Send output to the transport.
|
||||
* In bridge mode, control_request messages are always echoed to stdout so the
|
||||
* bridge parent can detect permission requests. Other messages are echoed only
|
||||
* in debug mode.
|
||||
*/
|
||||
async write(message: StdoutMessage): Promise<void> {
|
||||
if (this.ccrClient) {
|
||||
await this.ccrClient.writeEvent(message)
|
||||
} else {
|
||||
await this.transport.write(message)
|
||||
}
|
||||
if (this.isBridge) {
|
||||
if (message.type === 'control_request' || this.isDebug) {
|
||||
writeToStdout(ndjsonSafeStringify(message) + '\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up connections gracefully
|
||||
*/
|
||||
close(): void {
|
||||
if (this.keepAliveTimer) {
|
||||
clearInterval(this.keepAliveTimer)
|
||||
this.keepAliveTimer = null
|
||||
}
|
||||
this.transport.close()
|
||||
this.inputStream.end()
|
||||
}
|
||||
}
|
||||
859
original-source-code/src/cli/structuredIO.ts
Normal file
859
original-source-code/src/cli/structuredIO.ts
Normal file
@@ -0,0 +1,859 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import type {
|
||||
ElicitResult,
|
||||
JSONRPCMessage,
|
||||
} from '@modelcontextprotocol/sdk/types.js'
|
||||
import { randomUUID } from 'crypto'
|
||||
import type { AssistantMessage } from 'src//types/message.js'
|
||||
import type {
|
||||
HookInput,
|
||||
HookJSONOutput,
|
||||
PermissionUpdate,
|
||||
SDKMessage,
|
||||
SDKUserMessage,
|
||||
} from 'src/entrypoints/agentSdkTypes.js'
|
||||
import { SDKControlElicitationResponseSchema } from 'src/entrypoints/sdk/controlSchemas.js'
|
||||
import type {
|
||||
SDKControlRequest,
|
||||
SDKControlResponse,
|
||||
StdinMessage,
|
||||
StdoutMessage,
|
||||
} from 'src/entrypoints/sdk/controlTypes.js'
|
||||
import type { CanUseToolFn } from 'src/hooks/useCanUseTool.js'
|
||||
import type { Tool, ToolUseContext } from 'src/Tool.js'
|
||||
import { type HookCallback, hookJSONOutputSchema } from 'src/types/hooks.js'
|
||||
import { logForDebugging } from 'src/utils/debug.js'
|
||||
import { logForDiagnosticsNoPII } from 'src/utils/diagLogs.js'
|
||||
import { AbortError } from 'src/utils/errors.js'
|
||||
import {
|
||||
type Output as PermissionToolOutput,
|
||||
permissionPromptToolResultToPermissionDecision,
|
||||
outputSchema as permissionToolOutputSchema,
|
||||
} from 'src/utils/permissions/PermissionPromptToolResultSchema.js'
|
||||
import type {
|
||||
PermissionDecision,
|
||||
PermissionDecisionReason,
|
||||
} from 'src/utils/permissions/PermissionResult.js'
|
||||
import { hasPermissionsToUseTool } from 'src/utils/permissions/permissions.js'
|
||||
import { writeToStdout } from 'src/utils/process.js'
|
||||
import { jsonStringify } from 'src/utils/slowOperations.js'
|
||||
import { z } from 'zod/v4'
|
||||
import { notifyCommandLifecycle } from '../utils/commandLifecycle.js'
|
||||
import { normalizeControlMessageKeys } from '../utils/controlMessageCompat.js'
|
||||
import { executePermissionRequestHooks } from '../utils/hooks.js'
|
||||
import {
|
||||
applyPermissionUpdates,
|
||||
persistPermissionUpdates,
|
||||
} from '../utils/permissions/PermissionUpdate.js'
|
||||
import {
|
||||
notifySessionStateChanged,
|
||||
type RequiresActionDetails,
|
||||
type SessionExternalMetadata,
|
||||
} from '../utils/sessionState.js'
|
||||
import { jsonParse } from '../utils/slowOperations.js'
|
||||
import { Stream } from '../utils/stream.js'
|
||||
import { ndjsonSafeStringify } from './ndjsonSafeStringify.js'
|
||||
|
||||
/**
|
||||
* Synthetic tool name used when forwarding sandbox network permission
|
||||
* requests via the can_use_tool control_request protocol. SDK hosts
|
||||
* see this as a normal tool permission prompt.
|
||||
*/
|
||||
export const SANDBOX_NETWORK_ACCESS_TOOL_NAME = 'SandboxNetworkAccess'
|
||||
|
||||
function serializeDecisionReason(
|
||||
reason: PermissionDecisionReason | undefined,
|
||||
): string | undefined {
|
||||
if (!reason) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (
|
||||
(feature('BASH_CLASSIFIER') || feature('TRANSCRIPT_CLASSIFIER')) &&
|
||||
reason.type === 'classifier'
|
||||
) {
|
||||
return reason.reason
|
||||
}
|
||||
switch (reason.type) {
|
||||
case 'rule':
|
||||
case 'mode':
|
||||
case 'subcommandResults':
|
||||
case 'permissionPromptTool':
|
||||
return undefined
|
||||
case 'hook':
|
||||
case 'asyncAgent':
|
||||
case 'sandboxOverride':
|
||||
case 'workingDir':
|
||||
case 'safetyCheck':
|
||||
case 'other':
|
||||
return reason.reason
|
||||
}
|
||||
}
|
||||
|
||||
function buildRequiresActionDetails(
|
||||
tool: Tool,
|
||||
input: Record<string, unknown>,
|
||||
toolUseID: string,
|
||||
requestId: string,
|
||||
): RequiresActionDetails {
|
||||
// Per-tool summary methods may throw on malformed input; permission
|
||||
// handling must not break because of a bad description.
|
||||
let description: string
|
||||
try {
|
||||
description =
|
||||
tool.getActivityDescription?.(input) ??
|
||||
tool.getToolUseSummary?.(input) ??
|
||||
tool.userFacingName(input)
|
||||
} catch {
|
||||
description = tool.name
|
||||
}
|
||||
return {
|
||||
tool_name: tool.name,
|
||||
action_description: description,
|
||||
tool_use_id: toolUseID,
|
||||
request_id: requestId,
|
||||
input,
|
||||
}
|
||||
}
|
||||
|
||||
type PendingRequest<T> = {
|
||||
resolve: (result: T) => void
|
||||
reject: (error: unknown) => void
|
||||
schema?: z.Schema
|
||||
request: SDKControlRequest
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a structured way to read and write SDK messages from stdio,
|
||||
* capturing the SDK protocol.
|
||||
*/
|
||||
// Maximum number of resolved tool_use IDs to track. Once exceeded, the oldest
|
||||
// entry is evicted. This bounds memory in very long sessions while keeping
|
||||
// enough history to catch duplicate control_response deliveries.
|
||||
const MAX_RESOLVED_TOOL_USE_IDS = 1000
|
||||
|
||||
export class StructuredIO {
|
||||
readonly structuredInput: AsyncGenerator<StdinMessage | SDKMessage>
|
||||
private readonly pendingRequests = new Map<string, PendingRequest<unknown>>()
|
||||
|
||||
// CCR external_metadata read back on worker start; null when the
|
||||
// transport doesn't restore. Assigned by RemoteIO.
|
||||
restoredWorkerState: Promise<SessionExternalMetadata | null> =
|
||||
Promise.resolve(null)
|
||||
|
||||
private inputClosed = false
|
||||
private unexpectedResponseCallback?: (
|
||||
response: SDKControlResponse,
|
||||
) => Promise<void>
|
||||
|
||||
// Tracks tool_use IDs that have been resolved through the normal permission
|
||||
// flow (or aborted by a hook). When a duplicate control_response arrives
|
||||
// after the original was already handled, this Set prevents the orphan
|
||||
// handler from re-processing it — which would push duplicate assistant
|
||||
// messages into mutableMessages and cause a 400 "tool_use ids must be unique"
|
||||
// error from the API.
|
||||
private readonly resolvedToolUseIds = new Set<string>()
|
||||
private prependedLines: string[] = []
|
||||
private onControlRequestSent?: (request: SDKControlRequest) => void
|
||||
private onControlRequestResolved?: (requestId: string) => void
|
||||
|
||||
// sendRequest() and print.ts both enqueue here; the drain loop is the
|
||||
// only writer. Prevents control_request from overtaking queued stream_events.
|
||||
readonly outbound = new Stream<StdoutMessage>()
|
||||
|
||||
constructor(
|
||||
private readonly input: AsyncIterable<string>,
|
||||
private readonly replayUserMessages?: boolean,
|
||||
) {
|
||||
this.input = input
|
||||
this.structuredInput = this.read()
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a tool_use ID as resolved so that late/duplicate control_response
|
||||
* messages for the same tool are ignored by the orphan handler.
|
||||
*/
|
||||
private trackResolvedToolUseId(request: SDKControlRequest): void {
|
||||
if (request.request.subtype === 'can_use_tool') {
|
||||
this.resolvedToolUseIds.add(request.request.tool_use_id)
|
||||
if (this.resolvedToolUseIds.size > MAX_RESOLVED_TOOL_USE_IDS) {
|
||||
// Evict the oldest entry (Sets iterate in insertion order)
|
||||
const first = this.resolvedToolUseIds.values().next().value
|
||||
if (first !== undefined) {
|
||||
this.resolvedToolUseIds.delete(first)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Flush pending internal events. No-op for non-remote IO. Overridden by RemoteIO. */
|
||||
flushInternalEvents(): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/** Internal-event queue depth. Overridden by RemoteIO; zero otherwise. */
|
||||
get internalEventsPending(): number {
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a user turn to be yielded before the next message from this.input.
|
||||
* Works before iteration starts and mid-stream — read() re-checks
|
||||
* prependedLines between each yielded message.
|
||||
*/
|
||||
prependUserMessage(content: string): void {
|
||||
this.prependedLines.push(
|
||||
jsonStringify({
|
||||
type: 'user',
|
||||
session_id: '',
|
||||
message: { role: 'user', content },
|
||||
parent_tool_use_id: null,
|
||||
} satisfies SDKUserMessage) + '\n',
|
||||
)
|
||||
}
|
||||
|
||||
private async *read() {
|
||||
let content = ''
|
||||
|
||||
// Called once before for-await (an empty this.input otherwise skips the
|
||||
// loop body entirely), then again per block. prependedLines re-check is
|
||||
// inside the while so a prepend pushed between two messages in the SAME
|
||||
// block still lands first.
|
||||
const splitAndProcess = async function* (this: StructuredIO) {
|
||||
for (;;) {
|
||||
if (this.prependedLines.length > 0) {
|
||||
content = this.prependedLines.join('') + content
|
||||
this.prependedLines = []
|
||||
}
|
||||
const newline = content.indexOf('\n')
|
||||
if (newline === -1) break
|
||||
const line = content.slice(0, newline)
|
||||
content = content.slice(newline + 1)
|
||||
const message = await this.processLine(line)
|
||||
if (message) {
|
||||
logForDiagnosticsNoPII('info', 'cli_stdin_message_parsed', {
|
||||
type: message.type,
|
||||
})
|
||||
yield message
|
||||
}
|
||||
}
|
||||
}.bind(this)
|
||||
|
||||
yield* splitAndProcess()
|
||||
|
||||
for await (const block of this.input) {
|
||||
content += block
|
||||
yield* splitAndProcess()
|
||||
}
|
||||
if (content) {
|
||||
const message = await this.processLine(content)
|
||||
if (message) {
|
||||
yield message
|
||||
}
|
||||
}
|
||||
this.inputClosed = true
|
||||
for (const request of this.pendingRequests.values()) {
|
||||
// Reject all pending requests if the input stream
|
||||
request.reject(
|
||||
new Error('Tool permission stream closed before response received'),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
getPendingPermissionRequests() {
|
||||
return Array.from(this.pendingRequests.values())
|
||||
.map(entry => entry.request)
|
||||
.filter(pr => pr.request.subtype === 'can_use_tool')
|
||||
}
|
||||
|
||||
setUnexpectedResponseCallback(
|
||||
callback: (response: SDKControlResponse) => Promise<void>,
|
||||
): void {
|
||||
this.unexpectedResponseCallback = callback
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject a control_response message to resolve a pending permission request.
|
||||
* Used by the bridge to feed permission responses from claude.ai into the
|
||||
* SDK permission flow.
|
||||
*
|
||||
* Also sends a control_cancel_request to the SDK consumer so its canUseTool
|
||||
* callback is aborted via the signal — otherwise the callback hangs.
|
||||
*/
|
||||
injectControlResponse(response: SDKControlResponse): void {
|
||||
const requestId = response.response?.request_id
|
||||
if (!requestId) return
|
||||
const request = this.pendingRequests.get(requestId)
|
||||
if (!request) return
|
||||
this.trackResolvedToolUseId(request.request)
|
||||
this.pendingRequests.delete(requestId)
|
||||
// Cancel the SDK consumer's canUseTool callback — the bridge won.
|
||||
void this.write({
|
||||
type: 'control_cancel_request',
|
||||
request_id: requestId,
|
||||
})
|
||||
if (response.response.subtype === 'error') {
|
||||
request.reject(new Error(response.response.error))
|
||||
} else {
|
||||
const result = response.response.response
|
||||
if (request.schema) {
|
||||
try {
|
||||
request.resolve(request.schema.parse(result))
|
||||
} catch (error) {
|
||||
request.reject(error)
|
||||
}
|
||||
} else {
|
||||
request.resolve({})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback invoked whenever a can_use_tool control_request
|
||||
* is written to stdout. Used by the bridge to forward permission
|
||||
* requests to claude.ai.
|
||||
*/
|
||||
setOnControlRequestSent(
|
||||
callback: ((request: SDKControlRequest) => void) | undefined,
|
||||
): void {
|
||||
this.onControlRequestSent = callback
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback invoked when a can_use_tool control_response arrives
|
||||
* from the SDK consumer (via stdin). Used by the bridge to cancel the
|
||||
* stale permission prompt on claude.ai when the SDK consumer wins the race.
|
||||
*/
|
||||
setOnControlRequestResolved(
|
||||
callback: ((requestId: string) => void) | undefined,
|
||||
): void {
|
||||
this.onControlRequestResolved = callback
|
||||
}
|
||||
|
||||
private async processLine(
|
||||
line: string,
|
||||
): Promise<StdinMessage | SDKMessage | undefined> {
|
||||
// Skip empty lines (e.g. from double newlines in piped stdin)
|
||||
if (!line) {
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
const message = normalizeControlMessageKeys(jsonParse(line)) as
|
||||
| StdinMessage
|
||||
| SDKMessage
|
||||
if (message.type === 'keep_alive') {
|
||||
// Silently ignore keep-alive messages
|
||||
return undefined
|
||||
}
|
||||
if (message.type === 'update_environment_variables') {
|
||||
// Apply environment variable updates directly to process.env.
|
||||
// Used by bridge session runner for auth token refresh
|
||||
// (CLAUDE_CODE_SESSION_ACCESS_TOKEN) which must be readable
|
||||
// by the REPL process itself, not just child Bash commands.
|
||||
const keys = Object.keys(message.variables)
|
||||
for (const [key, value] of Object.entries(message.variables)) {
|
||||
process.env[key] = value
|
||||
}
|
||||
logForDebugging(
|
||||
`[structuredIO] applied update_environment_variables: ${keys.join(', ')}`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
if (message.type === 'control_response') {
|
||||
// Close lifecycle for every control_response, including duplicates
|
||||
// and orphans — orphans don't yield to print.ts's main loop, so this
|
||||
// is the only path that sees them. uuid is server-injected into the
|
||||
// payload.
|
||||
const uuid =
|
||||
'uuid' in message && typeof message.uuid === 'string'
|
||||
? message.uuid
|
||||
: undefined
|
||||
if (uuid) {
|
||||
notifyCommandLifecycle(uuid, 'completed')
|
||||
}
|
||||
const request = this.pendingRequests.get(message.response.request_id)
|
||||
if (!request) {
|
||||
// Check if this tool_use was already resolved through the normal
|
||||
// permission flow. Duplicate control_response deliveries (e.g. from
|
||||
// WebSocket reconnects) arrive after the original was handled, and
|
||||
// re-processing them would push duplicate assistant messages into
|
||||
// the conversation, causing API 400 errors.
|
||||
const responsePayload =
|
||||
message.response.subtype === 'success'
|
||||
? message.response.response
|
||||
: undefined
|
||||
const toolUseID = responsePayload?.toolUseID
|
||||
if (
|
||||
typeof toolUseID === 'string' &&
|
||||
this.resolvedToolUseIds.has(toolUseID)
|
||||
) {
|
||||
logForDebugging(
|
||||
`Ignoring duplicate control_response for already-resolved toolUseID=${toolUseID} request_id=${message.response.request_id}`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
if (this.unexpectedResponseCallback) {
|
||||
await this.unexpectedResponseCallback(message)
|
||||
}
|
||||
return undefined // Ignore responses for requests we don't know about
|
||||
}
|
||||
this.trackResolvedToolUseId(request.request)
|
||||
this.pendingRequests.delete(message.response.request_id)
|
||||
// Notify the bridge when the SDK consumer resolves a can_use_tool
|
||||
// request, so it can cancel the stale permission prompt on claude.ai.
|
||||
if (
|
||||
request.request.request.subtype === 'can_use_tool' &&
|
||||
this.onControlRequestResolved
|
||||
) {
|
||||
this.onControlRequestResolved(message.response.request_id)
|
||||
}
|
||||
|
||||
if (message.response.subtype === 'error') {
|
||||
request.reject(new Error(message.response.error))
|
||||
return undefined
|
||||
}
|
||||
const result = message.response.response
|
||||
if (request.schema) {
|
||||
try {
|
||||
request.resolve(request.schema.parse(result))
|
||||
} catch (error) {
|
||||
request.reject(error)
|
||||
}
|
||||
} else {
|
||||
request.resolve({})
|
||||
}
|
||||
// Propagate control responses when replay is enabled
|
||||
if (this.replayUserMessages) {
|
||||
return message
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
if (
|
||||
message.type !== 'user' &&
|
||||
message.type !== 'control_request' &&
|
||||
message.type !== 'assistant' &&
|
||||
message.type !== 'system'
|
||||
) {
|
||||
logForDebugging(`Ignoring unknown message type: ${message.type}`, {
|
||||
level: 'warn',
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
if (message.type === 'control_request') {
|
||||
if (!message.request) {
|
||||
exitWithMessage(`Error: Missing request on control_request`)
|
||||
}
|
||||
return message
|
||||
}
|
||||
if (message.type === 'assistant' || message.type === 'system') {
|
||||
return message
|
||||
}
|
||||
if (message.message.role !== 'user') {
|
||||
exitWithMessage(
|
||||
`Error: Expected message role 'user', got '${message.message.role}'`,
|
||||
)
|
||||
}
|
||||
return message
|
||||
} catch (error) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.error(`Error parsing streaming input line: ${line}: ${error}`)
|
||||
// eslint-disable-next-line custom-rules/no-process-exit
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
async write(message: StdoutMessage): Promise<void> {
|
||||
writeToStdout(ndjsonSafeStringify(message) + '\n')
|
||||
}
|
||||
|
||||
private async sendRequest<Response>(
|
||||
request: SDKControlRequest['request'],
|
||||
schema: z.Schema,
|
||||
signal?: AbortSignal,
|
||||
requestId: string = randomUUID(),
|
||||
): Promise<Response> {
|
||||
const message: SDKControlRequest = {
|
||||
type: 'control_request',
|
||||
request_id: requestId,
|
||||
request,
|
||||
}
|
||||
if (this.inputClosed) {
|
||||
throw new Error('Stream closed')
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
throw new Error('Request aborted')
|
||||
}
|
||||
this.outbound.enqueue(message)
|
||||
if (request.subtype === 'can_use_tool' && this.onControlRequestSent) {
|
||||
this.onControlRequestSent(message)
|
||||
}
|
||||
const aborted = () => {
|
||||
this.outbound.enqueue({
|
||||
type: 'control_cancel_request',
|
||||
request_id: requestId,
|
||||
})
|
||||
// Immediately reject the outstanding promise, without
|
||||
// waiting for the host to acknowledge the cancellation.
|
||||
const request = this.pendingRequests.get(requestId)
|
||||
if (request) {
|
||||
// Track the tool_use ID as resolved before rejecting, so that a
|
||||
// late response from the host is ignored by the orphan handler.
|
||||
this.trackResolvedToolUseId(request.request)
|
||||
request.reject(new AbortError())
|
||||
}
|
||||
}
|
||||
if (signal) {
|
||||
signal.addEventListener('abort', aborted, {
|
||||
once: true,
|
||||
})
|
||||
}
|
||||
try {
|
||||
return await new Promise<Response>((resolve, reject) => {
|
||||
this.pendingRequests.set(requestId, {
|
||||
request: {
|
||||
type: 'control_request',
|
||||
request_id: requestId,
|
||||
request,
|
||||
},
|
||||
resolve: result => {
|
||||
resolve(result as Response)
|
||||
},
|
||||
reject,
|
||||
schema,
|
||||
})
|
||||
})
|
||||
} finally {
|
||||
if (signal) {
|
||||
signal.removeEventListener('abort', aborted)
|
||||
}
|
||||
this.pendingRequests.delete(requestId)
|
||||
}
|
||||
}
|
||||
|
||||
createCanUseTool(
|
||||
onPermissionPrompt?: (details: RequiresActionDetails) => void,
|
||||
): CanUseToolFn {
|
||||
return async (
|
||||
tool: Tool,
|
||||
input: { [key: string]: unknown },
|
||||
toolUseContext: ToolUseContext,
|
||||
assistantMessage: AssistantMessage,
|
||||
toolUseID: string,
|
||||
forceDecision?: PermissionDecision,
|
||||
): Promise<PermissionDecision> => {
|
||||
const mainPermissionResult =
|
||||
forceDecision ??
|
||||
(await hasPermissionsToUseTool(
|
||||
tool,
|
||||
input,
|
||||
toolUseContext,
|
||||
assistantMessage,
|
||||
toolUseID,
|
||||
))
|
||||
// If the tool is allowed or denied, return the result
|
||||
if (
|
||||
mainPermissionResult.behavior === 'allow' ||
|
||||
mainPermissionResult.behavior === 'deny'
|
||||
) {
|
||||
return mainPermissionResult
|
||||
}
|
||||
|
||||
// Run PermissionRequest hooks in parallel with the SDK permission
|
||||
// prompt. In the terminal CLI, hooks race against the interactive
|
||||
// prompt so that e.g. a hook with --delay 20 doesn't block the UI.
|
||||
// We need the same behavior here: the SDK host (VS Code, etc.) shows
|
||||
// its permission dialog immediately while hooks run in the background.
|
||||
// Whichever resolves first wins; the loser is cancelled/ignored.
|
||||
|
||||
// AbortController used to cancel the SDK request if a hook decides first
|
||||
const hookAbortController = new AbortController()
|
||||
const parentSignal = toolUseContext.abortController.signal
|
||||
// Forward parent abort to our local controller
|
||||
const onParentAbort = () => hookAbortController.abort()
|
||||
parentSignal.addEventListener('abort', onParentAbort, { once: true })
|
||||
|
||||
try {
|
||||
// Start the hook evaluation (runs in background)
|
||||
const hookPromise = executePermissionRequestHooksForSDK(
|
||||
tool.name,
|
||||
toolUseID,
|
||||
input,
|
||||
toolUseContext,
|
||||
mainPermissionResult.suggestions,
|
||||
).then(decision => ({ source: 'hook' as const, decision }))
|
||||
|
||||
// Start the SDK permission prompt immediately (don't wait for hooks)
|
||||
const requestId = randomUUID()
|
||||
onPermissionPrompt?.(
|
||||
buildRequiresActionDetails(tool, input, toolUseID, requestId),
|
||||
)
|
||||
const sdkPromise = this.sendRequest<PermissionToolOutput>(
|
||||
{
|
||||
subtype: 'can_use_tool',
|
||||
tool_name: tool.name,
|
||||
input,
|
||||
permission_suggestions: mainPermissionResult.suggestions,
|
||||
blocked_path: mainPermissionResult.blockedPath,
|
||||
decision_reason: serializeDecisionReason(
|
||||
mainPermissionResult.decisionReason,
|
||||
),
|
||||
tool_use_id: toolUseID,
|
||||
agent_id: toolUseContext.agentId,
|
||||
},
|
||||
permissionToolOutputSchema(),
|
||||
hookAbortController.signal,
|
||||
requestId,
|
||||
).then(result => ({ source: 'sdk' as const, result }))
|
||||
|
||||
// Race: hook completion vs SDK prompt response.
|
||||
// The hook promise always resolves (never rejects), returning
|
||||
// undefined if no hook made a decision.
|
||||
const winner = await Promise.race([hookPromise, sdkPromise])
|
||||
|
||||
if (winner.source === 'hook') {
|
||||
if (winner.decision) {
|
||||
// Hook decided — abort the pending SDK request.
|
||||
// Suppress the expected AbortError rejection from sdkPromise.
|
||||
sdkPromise.catch(() => {})
|
||||
hookAbortController.abort()
|
||||
return winner.decision
|
||||
}
|
||||
// Hook passed through (no decision) — wait for the SDK prompt
|
||||
const sdkResult = await sdkPromise
|
||||
return permissionPromptToolResultToPermissionDecision(
|
||||
sdkResult.result,
|
||||
tool,
|
||||
input,
|
||||
toolUseContext,
|
||||
)
|
||||
}
|
||||
|
||||
// SDK prompt responded first — use its result (hook still running
|
||||
// in background but its result will be ignored)
|
||||
return permissionPromptToolResultToPermissionDecision(
|
||||
winner.result,
|
||||
tool,
|
||||
input,
|
||||
toolUseContext,
|
||||
)
|
||||
} catch (error) {
|
||||
return permissionPromptToolResultToPermissionDecision(
|
||||
{
|
||||
behavior: 'deny',
|
||||
message: `Tool permission request failed: ${error}`,
|
||||
toolUseID,
|
||||
},
|
||||
tool,
|
||||
input,
|
||||
toolUseContext,
|
||||
)
|
||||
} finally {
|
||||
// Only transition back to 'running' if no other permission prompts
|
||||
// are pending (concurrent tool execution can have multiple in-flight).
|
||||
if (this.getPendingPermissionRequests().length === 0) {
|
||||
notifySessionStateChanged('running')
|
||||
}
|
||||
parentSignal.removeEventListener('abort', onParentAbort)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createHookCallback(callbackId: string, timeout?: number): HookCallback {
|
||||
return {
|
||||
type: 'callback',
|
||||
timeout,
|
||||
callback: async (
|
||||
input: HookInput,
|
||||
toolUseID: string | null,
|
||||
abort: AbortSignal | undefined,
|
||||
): Promise<HookJSONOutput> => {
|
||||
try {
|
||||
const result = await this.sendRequest<HookJSONOutput>(
|
||||
{
|
||||
subtype: 'hook_callback',
|
||||
callback_id: callbackId,
|
||||
input,
|
||||
tool_use_id: toolUseID || undefined,
|
||||
},
|
||||
hookJSONOutputSchema(),
|
||||
abort,
|
||||
)
|
||||
return result
|
||||
} catch (error) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.error(`Error in hook callback ${callbackId}:`, error)
|
||||
return {}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an elicitation request to the SDK consumer and returns the response.
|
||||
*/
|
||||
async handleElicitation(
|
||||
serverName: string,
|
||||
message: string,
|
||||
requestedSchema?: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
mode?: 'form' | 'url',
|
||||
url?: string,
|
||||
elicitationId?: string,
|
||||
): Promise<ElicitResult> {
|
||||
try {
|
||||
const result = await this.sendRequest<ElicitResult>(
|
||||
{
|
||||
subtype: 'elicitation',
|
||||
mcp_server_name: serverName,
|
||||
message,
|
||||
mode,
|
||||
url,
|
||||
elicitation_id: elicitationId,
|
||||
requested_schema: requestedSchema,
|
||||
},
|
||||
SDKControlElicitationResponseSchema(),
|
||||
signal,
|
||||
)
|
||||
return result
|
||||
} catch {
|
||||
return { action: 'cancel' as const }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a SandboxAskCallback that forwards sandbox network permission
|
||||
* requests to the SDK host as can_use_tool control_requests.
|
||||
*
|
||||
* This piggybacks on the existing can_use_tool protocol with a synthetic
|
||||
* tool name so that SDK hosts (VS Code, CCR, etc.) can prompt the user
|
||||
* for network access without requiring a new protocol subtype.
|
||||
*/
|
||||
createSandboxAskCallback(): (hostPattern: {
|
||||
host: string
|
||||
port?: number
|
||||
}) => Promise<boolean> {
|
||||
return async (hostPattern): Promise<boolean> => {
|
||||
try {
|
||||
const result = await this.sendRequest<PermissionToolOutput>(
|
||||
{
|
||||
subtype: 'can_use_tool',
|
||||
tool_name: SANDBOX_NETWORK_ACCESS_TOOL_NAME,
|
||||
input: { host: hostPattern.host },
|
||||
tool_use_id: randomUUID(),
|
||||
description: `Allow network connection to ${hostPattern.host}?`,
|
||||
},
|
||||
permissionToolOutputSchema(),
|
||||
)
|
||||
return result.behavior === 'allow'
|
||||
} catch {
|
||||
// If the request fails (stream closed, abort, etc.), deny the connection
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an MCP message to an SDK server and waits for the response
|
||||
*/
|
||||
async sendMcpMessage(
|
||||
serverName: string,
|
||||
message: JSONRPCMessage,
|
||||
): Promise<JSONRPCMessage> {
|
||||
const response = await this.sendRequest<{ mcp_response: JSONRPCMessage }>(
|
||||
{
|
||||
subtype: 'mcp_message',
|
||||
server_name: serverName,
|
||||
message,
|
||||
},
|
||||
z.object({
|
||||
mcp_response: z.any() as z.Schema<JSONRPCMessage>,
|
||||
}),
|
||||
)
|
||||
return response.mcp_response
|
||||
}
|
||||
}
|
||||
|
||||
function exitWithMessage(message: string): never {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.error(message)
|
||||
// eslint-disable-next-line custom-rules/no-process-exit
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute PermissionRequest hooks and return a decision if one is made.
|
||||
* Returns undefined if no hook made a decision.
|
||||
*/
|
||||
async function executePermissionRequestHooksForSDK(
|
||||
toolName: string,
|
||||
toolUseID: string,
|
||||
input: Record<string, unknown>,
|
||||
toolUseContext: ToolUseContext,
|
||||
suggestions: PermissionUpdate[] | undefined,
|
||||
): Promise<PermissionDecision | undefined> {
|
||||
const appState = toolUseContext.getAppState()
|
||||
const permissionMode = appState.toolPermissionContext.mode
|
||||
|
||||
// Iterate directly over the generator instead of using `all`
|
||||
const hookGenerator = executePermissionRequestHooks(
|
||||
toolName,
|
||||
toolUseID,
|
||||
input,
|
||||
toolUseContext,
|
||||
permissionMode,
|
||||
suggestions,
|
||||
toolUseContext.abortController.signal,
|
||||
)
|
||||
|
||||
for await (const hookResult of hookGenerator) {
|
||||
if (
|
||||
hookResult.permissionRequestResult &&
|
||||
(hookResult.permissionRequestResult.behavior === 'allow' ||
|
||||
hookResult.permissionRequestResult.behavior === 'deny')
|
||||
) {
|
||||
const decision = hookResult.permissionRequestResult
|
||||
if (decision.behavior === 'allow') {
|
||||
const finalInput = decision.updatedInput || input
|
||||
|
||||
// Apply permission updates if provided by hook ("always allow")
|
||||
const permissionUpdates = decision.updatedPermissions ?? []
|
||||
if (permissionUpdates.length > 0) {
|
||||
persistPermissionUpdates(permissionUpdates)
|
||||
const currentAppState = toolUseContext.getAppState()
|
||||
const updatedContext = applyPermissionUpdates(
|
||||
currentAppState.toolPermissionContext,
|
||||
permissionUpdates,
|
||||
)
|
||||
// Update permission context via setAppState
|
||||
toolUseContext.setAppState(prev => {
|
||||
if (prev.toolPermissionContext === updatedContext) return prev
|
||||
return { ...prev, toolPermissionContext: updatedContext }
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
behavior: 'allow',
|
||||
updatedInput: finalInput,
|
||||
userModified: false,
|
||||
decisionReason: {
|
||||
type: 'hook',
|
||||
hookName: 'PermissionRequest',
|
||||
},
|
||||
}
|
||||
} else {
|
||||
// Hook denied the permission
|
||||
return {
|
||||
behavior: 'deny',
|
||||
message:
|
||||
decision.message || 'Permission denied by PermissionRequest hook',
|
||||
decisionReason: {
|
||||
type: 'hook',
|
||||
hookName: 'PermissionRequest',
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
282
original-source-code/src/cli/transports/HybridTransport.ts
Normal file
282
original-source-code/src/cli/transports/HybridTransport.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import axios, { type AxiosError } from 'axios'
|
||||
import type { StdoutMessage } from 'src/entrypoints/sdk/controlTypes.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { logForDiagnosticsNoPII } from '../../utils/diagLogs.js'
|
||||
import { getSessionIngressAuthToken } from '../../utils/sessionIngressAuth.js'
|
||||
import { SerialBatchEventUploader } from './SerialBatchEventUploader.js'
|
||||
import {
|
||||
WebSocketTransport,
|
||||
type WebSocketTransportOptions,
|
||||
} from './WebSocketTransport.js'
|
||||
|
||||
const BATCH_FLUSH_INTERVAL_MS = 100
|
||||
// Per-attempt POST timeout. Bounds how long a single stuck POST can block
|
||||
// the serialized queue. Without this, a hung connection stalls all writes.
|
||||
const POST_TIMEOUT_MS = 15_000
|
||||
// Grace period for queued writes on close(). Covers a healthy POST (~100ms)
|
||||
// plus headroom; best-effort, not a delivery guarantee under degraded network.
|
||||
// Void-ed (nothing awaits it) so this is a last resort — replBridge teardown
|
||||
// now closes AFTER archive so archive latency is the primary drain window.
|
||||
// NOTE: gracefulShutdown's cleanup budget is 2s (not the 5s outer failsafe);
|
||||
// 3s here exceeds it, but the process lives ~2s longer for hooks+analytics.
|
||||
const CLOSE_GRACE_MS = 3000
|
||||
|
||||
/**
|
||||
* Hybrid transport: WebSocket for reads, HTTP POST for writes.
|
||||
*
|
||||
* Write flow:
|
||||
*
|
||||
* write(stream_event) ─┐
|
||||
* │ (100ms timer)
|
||||
* │
|
||||
* ▼
|
||||
* write(other) ────► uploader.enqueue() (SerialBatchEventUploader)
|
||||
* ▲ │
|
||||
* writeBatch() ────────┘ │ serial, batched, retries indefinitely,
|
||||
* │ backpressure at maxQueueSize
|
||||
* ▼
|
||||
* postOnce() (single HTTP POST, throws on retryable)
|
||||
*
|
||||
* stream_event messages accumulate in streamEventBuffer for up to 100ms
|
||||
* before enqueue (reduces POST count for high-volume content deltas). A
|
||||
* non-stream write flushes any buffered stream_events first to preserve order.
|
||||
*
|
||||
* Serialization + retry + backpressure are delegated to SerialBatchEventUploader
|
||||
* (same primitive CCR uses). At most one POST in-flight; events arriving during
|
||||
* a POST batch into the next one. On failure, the uploader re-queues and retries
|
||||
* with exponential backoff + jitter. If the queue fills past maxQueueSize,
|
||||
* enqueue() blocks — giving awaiting callers backpressure.
|
||||
*
|
||||
* Why serialize? Bridge mode fires writes via `void transport.write()`
|
||||
* (fire-and-forget). Without this, concurrent POSTs → concurrent Firestore
|
||||
* writes to the same document → collisions → retry storms → pages oncall.
|
||||
*/
|
||||
export class HybridTransport extends WebSocketTransport {
|
||||
private postUrl: string
|
||||
private uploader: SerialBatchEventUploader<StdoutMessage>
|
||||
|
||||
// stream_event delay buffer — accumulates content deltas for up to
|
||||
// BATCH_FLUSH_INTERVAL_MS before enqueueing (reduces POST count)
|
||||
private streamEventBuffer: StdoutMessage[] = []
|
||||
private streamEventTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
constructor(
|
||||
url: URL,
|
||||
headers: Record<string, string> = {},
|
||||
sessionId?: string,
|
||||
refreshHeaders?: () => Record<string, string>,
|
||||
options?: WebSocketTransportOptions & {
|
||||
maxConsecutiveFailures?: number
|
||||
onBatchDropped?: (batchSize: number, failures: number) => void
|
||||
},
|
||||
) {
|
||||
super(url, headers, sessionId, refreshHeaders, options)
|
||||
const { maxConsecutiveFailures, onBatchDropped } = options ?? {}
|
||||
this.postUrl = convertWsUrlToPostUrl(url)
|
||||
this.uploader = new SerialBatchEventUploader<StdoutMessage>({
|
||||
// Large cap — session-ingress accepts arbitrary batch sizes. Events
|
||||
// naturally batch during in-flight POSTs; this just bounds the payload.
|
||||
maxBatchSize: 500,
|
||||
// Bridge callers use `void transport.write()` — backpressure doesn't
|
||||
// apply (they don't await). A batch >maxQueueSize deadlocks (see
|
||||
// SerialBatchEventUploader backpressure check). So set it high enough
|
||||
// to be a memory bound only. Wire real backpressure in a follow-up
|
||||
// once callers await.
|
||||
maxQueueSize: 100_000,
|
||||
baseDelayMs: 500,
|
||||
maxDelayMs: 8000,
|
||||
jitterMs: 1000,
|
||||
// Optional cap so a persistently-failing server can't pin the drain
|
||||
// loop for the lifetime of the process. Undefined = indefinite retry.
|
||||
// replBridge sets this; the 1P transportUtils path does not.
|
||||
maxConsecutiveFailures,
|
||||
onBatchDropped: (batchSize, failures) => {
|
||||
logForDiagnosticsNoPII(
|
||||
'error',
|
||||
'cli_hybrid_batch_dropped_max_failures',
|
||||
{
|
||||
batchSize,
|
||||
failures,
|
||||
},
|
||||
)
|
||||
onBatchDropped?.(batchSize, failures)
|
||||
},
|
||||
send: batch => this.postOnce(batch),
|
||||
})
|
||||
logForDebugging(`HybridTransport: POST URL = ${this.postUrl}`)
|
||||
logForDiagnosticsNoPII('info', 'cli_hybrid_transport_initialized')
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue a message and wait for the queue to drain. Returning flush()
|
||||
* preserves the contract that `await write()` resolves after the event is
|
||||
* POSTed (relied on by tests and replBridge's initial flush). Fire-and-forget
|
||||
* callers (`void transport.write()`) are unaffected — they don't await,
|
||||
* so the later resolution doesn't add latency.
|
||||
*/
|
||||
override async write(message: StdoutMessage): Promise<void> {
|
||||
if (message.type === 'stream_event') {
|
||||
// Delay: accumulate stream_events briefly before enqueueing.
|
||||
// Promise resolves immediately — callers don't await stream_events.
|
||||
this.streamEventBuffer.push(message)
|
||||
if (!this.streamEventTimer) {
|
||||
this.streamEventTimer = setTimeout(
|
||||
() => this.flushStreamEvents(),
|
||||
BATCH_FLUSH_INTERVAL_MS,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Immediate: flush any buffered stream_events (ordering), then this event.
|
||||
await this.uploader.enqueue([...this.takeStreamEvents(), message])
|
||||
return this.uploader.flush()
|
||||
}
|
||||
|
||||
async writeBatch(messages: StdoutMessage[]): Promise<void> {
|
||||
await this.uploader.enqueue([...this.takeStreamEvents(), ...messages])
|
||||
return this.uploader.flush()
|
||||
}
|
||||
|
||||
/** Snapshot before/after writeBatch() to detect silent drops. */
|
||||
get droppedBatchCount(): number {
|
||||
return this.uploader.droppedBatchCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Block until all pending events are POSTed. Used by bridge's initial
|
||||
* history flush so onStateChange('connected') fires after persistence.
|
||||
*/
|
||||
flush(): Promise<void> {
|
||||
void this.uploader.enqueue(this.takeStreamEvents())
|
||||
return this.uploader.flush()
|
||||
}
|
||||
|
||||
/** Take ownership of buffered stream_events and clear the delay timer. */
|
||||
private takeStreamEvents(): StdoutMessage[] {
|
||||
if (this.streamEventTimer) {
|
||||
clearTimeout(this.streamEventTimer)
|
||||
this.streamEventTimer = null
|
||||
}
|
||||
const buffered = this.streamEventBuffer
|
||||
this.streamEventBuffer = []
|
||||
return buffered
|
||||
}
|
||||
|
||||
/** Delay timer fired — enqueue accumulated stream_events. */
|
||||
private flushStreamEvents(): void {
|
||||
this.streamEventTimer = null
|
||||
void this.uploader.enqueue(this.takeStreamEvents())
|
||||
}
|
||||
|
||||
override close(): void {
|
||||
if (this.streamEventTimer) {
|
||||
clearTimeout(this.streamEventTimer)
|
||||
this.streamEventTimer = null
|
||||
}
|
||||
this.streamEventBuffer = []
|
||||
// Grace period for queued writes — fallback. replBridge teardown now
|
||||
// awaits archive between write and close (see CLOSE_GRACE_MS), so
|
||||
// archive latency is the primary drain window and this is a last
|
||||
// resort. Keep close() sync (returns immediately) but defer
|
||||
// uploader.close() so any remaining queue gets a chance to finish.
|
||||
const uploader = this.uploader
|
||||
let graceTimer: ReturnType<typeof setTimeout> | undefined
|
||||
void Promise.race([
|
||||
uploader.flush(),
|
||||
new Promise<void>(r => {
|
||||
// eslint-disable-next-line no-restricted-syntax -- need timer ref for clearTimeout
|
||||
graceTimer = setTimeout(r, CLOSE_GRACE_MS)
|
||||
}),
|
||||
]).finally(() => {
|
||||
clearTimeout(graceTimer)
|
||||
uploader.close()
|
||||
})
|
||||
super.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-attempt POST. Throws on retryable failures (429, 5xx, network)
|
||||
* so SerialBatchEventUploader re-queues and retries. Returns on success
|
||||
* and on permanent failures (4xx non-429, no token) so the uploader moves on.
|
||||
*/
|
||||
private async postOnce(events: StdoutMessage[]): Promise<void> {
|
||||
const sessionToken = getSessionIngressAuthToken()
|
||||
if (!sessionToken) {
|
||||
logForDebugging('HybridTransport: No session token available for POST')
|
||||
logForDiagnosticsNoPII('warn', 'cli_hybrid_post_no_token')
|
||||
return
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${sessionToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
let response
|
||||
try {
|
||||
response = await axios.post(
|
||||
this.postUrl,
|
||||
{ events },
|
||||
{
|
||||
headers,
|
||||
validateStatus: () => true,
|
||||
timeout: POST_TIMEOUT_MS,
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError
|
||||
logForDebugging(`HybridTransport: POST error: ${axiosError.message}`)
|
||||
logForDiagnosticsNoPII('warn', 'cli_hybrid_post_network_error')
|
||||
throw error
|
||||
}
|
||||
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
logForDebugging(`HybridTransport: POST success count=${events.length}`)
|
||||
return
|
||||
}
|
||||
|
||||
// 4xx (except 429) are permanent — drop, don't retry.
|
||||
if (
|
||||
response.status >= 400 &&
|
||||
response.status < 500 &&
|
||||
response.status !== 429
|
||||
) {
|
||||
logForDebugging(
|
||||
`HybridTransport: POST returned ${response.status} (permanent), dropping`,
|
||||
)
|
||||
logForDiagnosticsNoPII('warn', 'cli_hybrid_post_client_error', {
|
||||
status: response.status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 429 / 5xx — retryable. Throw so uploader re-queues and backs off.
|
||||
logForDebugging(
|
||||
`HybridTransport: POST returned ${response.status} (retryable)`,
|
||||
)
|
||||
logForDiagnosticsNoPII('warn', 'cli_hybrid_post_retryable_error', {
|
||||
status: response.status,
|
||||
})
|
||||
throw new Error(`POST failed with ${response.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a WebSocket URL to the HTTP POST endpoint URL.
|
||||
* From: wss://api.example.com/v2/session_ingress/ws/<session_id>
|
||||
* To: https://api.example.com/v2/session_ingress/session/<session_id>/events
|
||||
*/
|
||||
function convertWsUrlToPostUrl(wsUrl: URL): string {
|
||||
const protocol = wsUrl.protocol === 'wss:' ? 'https:' : 'http:'
|
||||
|
||||
// Replace /ws/ with /session/ and append /events
|
||||
let pathname = wsUrl.pathname
|
||||
pathname = pathname.replace('/ws/', '/session/')
|
||||
if (!pathname.endsWith('/events')) {
|
||||
pathname = pathname.endsWith('/')
|
||||
? pathname + 'events'
|
||||
: pathname + '/events'
|
||||
}
|
||||
|
||||
return `${protocol}//${wsUrl.host}${pathname}${wsUrl.search}`
|
||||
}
|
||||
711
original-source-code/src/cli/transports/SSETransport.ts
Normal file
711
original-source-code/src/cli/transports/SSETransport.ts
Normal file
@@ -0,0 +1,711 @@
|
||||
import axios, { type AxiosError } from 'axios'
|
||||
import type { StdoutMessage } from 'src/entrypoints/sdk/controlTypes.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { logForDiagnosticsNoPII } from '../../utils/diagLogs.js'
|
||||
import { errorMessage } from '../../utils/errors.js'
|
||||
import { getSessionIngressAuthHeaders } from '../../utils/sessionIngressAuth.js'
|
||||
import { sleep } from '../../utils/sleep.js'
|
||||
import { jsonParse, jsonStringify } from '../../utils/slowOperations.js'
|
||||
import { getClaudeCodeUserAgent } from '../../utils/userAgent.js'
|
||||
import type { Transport } from './Transport.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RECONNECT_BASE_DELAY_MS = 1000
|
||||
const RECONNECT_MAX_DELAY_MS = 30_000
|
||||
/** Time budget for reconnection attempts before giving up (10 minutes). */
|
||||
const RECONNECT_GIVE_UP_MS = 600_000
|
||||
/** Server sends keepalives every 15s; treat connection as dead after 45s of silence. */
|
||||
const LIVENESS_TIMEOUT_MS = 45_000
|
||||
|
||||
/**
|
||||
* HTTP status codes that indicate a permanent server-side rejection.
|
||||
* The transport transitions to 'closed' immediately without retrying.
|
||||
*/
|
||||
const PERMANENT_HTTP_CODES = new Set([401, 403, 404])
|
||||
|
||||
// POST retry configuration (matches HybridTransport)
|
||||
const POST_MAX_RETRIES = 10
|
||||
const POST_BASE_DELAY_MS = 500
|
||||
const POST_MAX_DELAY_MS = 8000
|
||||
|
||||
/** Hoisted TextDecoder options to avoid per-chunk allocation in readStream. */
|
||||
const STREAM_DECODE_OPTS: TextDecodeOptions = { stream: true }
|
||||
|
||||
/** Hoisted axios validateStatus callback to avoid per-request closure allocation. */
|
||||
function alwaysValidStatus(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSE Frame Parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type SSEFrame = {
|
||||
event?: string
|
||||
id?: string
|
||||
data?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Incrementally parse SSE frames from a text buffer.
|
||||
* Returns parsed frames and the remaining (incomplete) buffer.
|
||||
*
|
||||
* @internal exported for testing
|
||||
*/
|
||||
export function parseSSEFrames(buffer: string): {
|
||||
frames: SSEFrame[]
|
||||
remaining: string
|
||||
} {
|
||||
const frames: SSEFrame[] = []
|
||||
let pos = 0
|
||||
|
||||
// SSE frames are delimited by double newlines
|
||||
let idx: number
|
||||
while ((idx = buffer.indexOf('\n\n', pos)) !== -1) {
|
||||
const rawFrame = buffer.slice(pos, idx)
|
||||
pos = idx + 2
|
||||
|
||||
// Skip empty frames
|
||||
if (!rawFrame.trim()) continue
|
||||
|
||||
const frame: SSEFrame = {}
|
||||
let isComment = false
|
||||
|
||||
for (const line of rawFrame.split('\n')) {
|
||||
if (line.startsWith(':')) {
|
||||
// SSE comment (e.g., `:keepalive`)
|
||||
isComment = true
|
||||
continue
|
||||
}
|
||||
|
||||
const colonIdx = line.indexOf(':')
|
||||
if (colonIdx === -1) continue
|
||||
|
||||
const field = line.slice(0, colonIdx)
|
||||
// Per SSE spec, strip one leading space after colon if present
|
||||
const value =
|
||||
line[colonIdx + 1] === ' '
|
||||
? line.slice(colonIdx + 2)
|
||||
: line.slice(colonIdx + 1)
|
||||
|
||||
switch (field) {
|
||||
case 'event':
|
||||
frame.event = value
|
||||
break
|
||||
case 'id':
|
||||
frame.id = value
|
||||
break
|
||||
case 'data':
|
||||
// Per SSE spec, multiple data: lines are concatenated with \n
|
||||
frame.data = frame.data ? frame.data + '\n' + value : value
|
||||
break
|
||||
// Ignore other fields (retry:, etc.)
|
||||
}
|
||||
}
|
||||
|
||||
// Only emit frames that have data (or are pure comments which reset liveness)
|
||||
if (frame.data || isComment) {
|
||||
frames.push(frame)
|
||||
}
|
||||
}
|
||||
|
||||
return { frames, remaining: buffer.slice(pos) }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type SSETransportState =
|
||||
| 'idle'
|
||||
| 'connected'
|
||||
| 'reconnecting'
|
||||
| 'closing'
|
||||
| 'closed'
|
||||
|
||||
/**
|
||||
* Payload for `event: client_event` frames, matching the StreamClientEvent
|
||||
* proto message in session_stream.proto. This is the only event type sent
|
||||
* to worker subscribers — delivery_update, session_update, ephemeral_event,
|
||||
* and catch_up_truncated are client-channel-only (see notifier.go and
|
||||
* event_stream.go SubscriberClient guard).
|
||||
*/
|
||||
export type StreamClientEvent = {
|
||||
event_id: string
|
||||
sequence_num: number
|
||||
event_type: string
|
||||
source: string
|
||||
payload: Record<string, unknown>
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSETransport
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Transport that uses SSE for reading and HTTP POST for writing.
|
||||
*
|
||||
* Reads events via Server-Sent Events from the CCR v2 event stream endpoint.
|
||||
* Writes events via HTTP POST with retry logic (same pattern as HybridTransport).
|
||||
*
|
||||
* Each `event: client_event` frame carries a StreamClientEvent proto JSON
|
||||
* directly in `data:`. The transport extracts `payload` and passes it to
|
||||
* `onData` as newline-delimited JSON for StructuredIO consumers.
|
||||
*
|
||||
* Supports automatic reconnection with exponential backoff and Last-Event-ID
|
||||
* for resumption after disconnection.
|
||||
*/
|
||||
export class SSETransport implements Transport {
|
||||
private state: SSETransportState = 'idle'
|
||||
private onData?: (data: string) => void
|
||||
private onCloseCallback?: (closeCode?: number) => void
|
||||
private onEventCallback?: (event: StreamClientEvent) => void
|
||||
private headers: Record<string, string>
|
||||
private sessionId?: string
|
||||
private refreshHeaders?: () => Record<string, string>
|
||||
private readonly getAuthHeaders: () => Record<string, string>
|
||||
|
||||
// SSE connection state
|
||||
private abortController: AbortController | null = null
|
||||
private lastSequenceNum = 0
|
||||
private seenSequenceNums = new Set<number>()
|
||||
|
||||
// Reconnection state
|
||||
private reconnectAttempts = 0
|
||||
private reconnectStartTime: number | null = null
|
||||
private reconnectTimer: NodeJS.Timeout | null = null
|
||||
|
||||
// Liveness detection
|
||||
private livenessTimer: NodeJS.Timeout | null = null
|
||||
|
||||
// POST URL (derived from SSE URL)
|
||||
private postUrl: string
|
||||
|
||||
// Runtime epoch for CCR v2 event format
|
||||
|
||||
constructor(
|
||||
private readonly url: URL,
|
||||
headers: Record<string, string> = {},
|
||||
sessionId?: string,
|
||||
refreshHeaders?: () => Record<string, string>,
|
||||
initialSequenceNum?: number,
|
||||
/**
|
||||
* Per-instance auth header source. Omit to read the process-wide
|
||||
* CLAUDE_CODE_SESSION_ACCESS_TOKEN (single-session callers). Required
|
||||
* for concurrent multi-session callers — the env-var path is a process
|
||||
* global and would stomp across sessions.
|
||||
*/
|
||||
getAuthHeaders?: () => Record<string, string>,
|
||||
) {
|
||||
this.headers = headers
|
||||
this.sessionId = sessionId
|
||||
this.refreshHeaders = refreshHeaders
|
||||
this.getAuthHeaders = getAuthHeaders ?? getSessionIngressAuthHeaders
|
||||
this.postUrl = convertSSEUrlToPostUrl(url)
|
||||
// Seed with a caller-provided high-water mark so the first connect()
|
||||
// sends from_sequence_num / Last-Event-ID. Without this, a fresh
|
||||
// SSETransport always asks the server to replay from sequence 0 —
|
||||
// the entire session history on every transport swap.
|
||||
if (initialSequenceNum !== undefined && initialSequenceNum > 0) {
|
||||
this.lastSequenceNum = initialSequenceNum
|
||||
}
|
||||
logForDebugging(`SSETransport: SSE URL = ${url.href}`)
|
||||
logForDebugging(`SSETransport: POST URL = ${this.postUrl}`)
|
||||
logForDiagnosticsNoPII('info', 'cli_sse_transport_initialized')
|
||||
}
|
||||
|
||||
/**
|
||||
* High-water mark of sequence numbers seen on this stream. Callers that
|
||||
* recreate the transport (e.g. replBridge onWorkReceived) read this before
|
||||
* close() and pass it as `initialSequenceNum` to the next instance so the
|
||||
* server resumes from the right point instead of replaying everything.
|
||||
*/
|
||||
getLastSequenceNum(): number {
|
||||
return this.lastSequenceNum
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (this.state !== 'idle' && this.state !== 'reconnecting') {
|
||||
logForDebugging(
|
||||
`SSETransport: Cannot connect, current state is ${this.state}`,
|
||||
{ level: 'error' },
|
||||
)
|
||||
logForDiagnosticsNoPII('error', 'cli_sse_connect_failed')
|
||||
return
|
||||
}
|
||||
|
||||
this.state = 'reconnecting'
|
||||
const connectStartTime = Date.now()
|
||||
|
||||
// Build SSE URL with sequence number for resumption
|
||||
const sseUrl = new URL(this.url.href)
|
||||
if (this.lastSequenceNum > 0) {
|
||||
sseUrl.searchParams.set('from_sequence_num', String(this.lastSequenceNum))
|
||||
}
|
||||
|
||||
// Build headers -- use fresh auth headers (supports Cookie for session keys).
|
||||
// Remove stale Authorization header from this.headers when Cookie auth is used,
|
||||
// since sending both confuses the auth interceptor.
|
||||
const authHeaders = this.getAuthHeaders()
|
||||
const headers: Record<string, string> = {
|
||||
...this.headers,
|
||||
...authHeaders,
|
||||
Accept: 'text/event-stream',
|
||||
'anthropic-version': '2023-06-01',
|
||||
'User-Agent': getClaudeCodeUserAgent(),
|
||||
}
|
||||
if (authHeaders['Cookie']) {
|
||||
delete headers['Authorization']
|
||||
}
|
||||
if (this.lastSequenceNum > 0) {
|
||||
headers['Last-Event-ID'] = String(this.lastSequenceNum)
|
||||
}
|
||||
|
||||
logForDebugging(`SSETransport: Opening ${sseUrl.href}`)
|
||||
logForDiagnosticsNoPII('info', 'cli_sse_connect_opening')
|
||||
|
||||
this.abortController = new AbortController()
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins
|
||||
const response = await fetch(sseUrl.href, {
|
||||
headers,
|
||||
signal: this.abortController.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const isPermanent = PERMANENT_HTTP_CODES.has(response.status)
|
||||
logForDebugging(
|
||||
`SSETransport: HTTP ${response.status}${isPermanent ? ' (permanent)' : ''}`,
|
||||
{ level: 'error' },
|
||||
)
|
||||
logForDiagnosticsNoPII('error', 'cli_sse_connect_http_error', {
|
||||
status: response.status,
|
||||
})
|
||||
|
||||
if (isPermanent) {
|
||||
this.state = 'closed'
|
||||
this.onCloseCallback?.(response.status)
|
||||
return
|
||||
}
|
||||
|
||||
this.handleConnectionError()
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
logForDebugging('SSETransport: No response body')
|
||||
this.handleConnectionError()
|
||||
return
|
||||
}
|
||||
|
||||
// Successfully connected
|
||||
const connectDuration = Date.now() - connectStartTime
|
||||
logForDebugging('SSETransport: Connected')
|
||||
logForDiagnosticsNoPII('info', 'cli_sse_connect_connected', {
|
||||
duration_ms: connectDuration,
|
||||
})
|
||||
|
||||
this.state = 'connected'
|
||||
this.reconnectAttempts = 0
|
||||
this.reconnectStartTime = null
|
||||
this.resetLivenessTimer()
|
||||
|
||||
// Read the SSE stream
|
||||
await this.readStream(response.body)
|
||||
} catch (error) {
|
||||
if (this.abortController?.signal.aborted) {
|
||||
// Intentional close
|
||||
return
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`SSETransport: Connection error: ${errorMessage(error)}`,
|
||||
{ level: 'error' },
|
||||
)
|
||||
logForDiagnosticsNoPII('error', 'cli_sse_connect_error')
|
||||
this.handleConnectionError()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and process the SSE stream body.
|
||||
*/
|
||||
// eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins
|
||||
private async readStream(body: ReadableStream<Uint8Array>): Promise<void> {
|
||||
const reader = body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, STREAM_DECODE_OPTS)
|
||||
const { frames, remaining } = parseSSEFrames(buffer)
|
||||
buffer = remaining
|
||||
|
||||
for (const frame of frames) {
|
||||
// Any frame (including keepalive comments) proves the connection is alive
|
||||
this.resetLivenessTimer()
|
||||
|
||||
if (frame.id) {
|
||||
const seqNum = parseInt(frame.id, 10)
|
||||
if (!isNaN(seqNum)) {
|
||||
if (this.seenSequenceNums.has(seqNum)) {
|
||||
logForDebugging(
|
||||
`SSETransport: DUPLICATE frame seq=${seqNum} (lastSequenceNum=${this.lastSequenceNum}, seenCount=${this.seenSequenceNums.size})`,
|
||||
{ level: 'warn' },
|
||||
)
|
||||
logForDiagnosticsNoPII('warn', 'cli_sse_duplicate_sequence')
|
||||
} else {
|
||||
this.seenSequenceNums.add(seqNum)
|
||||
// Prevent unbounded growth: once we have many entries, prune
|
||||
// old sequence numbers that are well below the high-water mark.
|
||||
// Only sequence numbers near lastSequenceNum matter for dedup.
|
||||
if (this.seenSequenceNums.size > 1000) {
|
||||
const threshold = this.lastSequenceNum - 200
|
||||
for (const s of this.seenSequenceNums) {
|
||||
if (s < threshold) {
|
||||
this.seenSequenceNums.delete(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (seqNum > this.lastSequenceNum) {
|
||||
this.lastSequenceNum = seqNum
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (frame.event && frame.data) {
|
||||
this.handleSSEFrame(frame.event, frame.data)
|
||||
} else if (frame.data) {
|
||||
// data: without event: — server is emitting the old envelope format
|
||||
// or a bug. Log so incidents show as a signal instead of silent drops.
|
||||
logForDebugging(
|
||||
'SSETransport: Frame has data: but no event: field — dropped',
|
||||
{ level: 'warn' },
|
||||
)
|
||||
logForDiagnosticsNoPII('warn', 'cli_sse_frame_missing_event_field')
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.abortController?.signal.aborted) return
|
||||
logForDebugging(
|
||||
`SSETransport: Stream read error: ${errorMessage(error)}`,
|
||||
{ level: 'error' },
|
||||
)
|
||||
logForDiagnosticsNoPII('error', 'cli_sse_stream_read_error')
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
|
||||
// Stream ended — reconnect unless we're closing
|
||||
if (this.state !== 'closing' && this.state !== 'closed') {
|
||||
logForDebugging('SSETransport: Stream ended, reconnecting')
|
||||
this.handleConnectionError()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a single SSE frame. The event: field names the variant; data:
|
||||
* carries the inner proto JSON directly (no envelope).
|
||||
*
|
||||
* Worker subscribers only receive client_event frames (see notifier.go) —
|
||||
* any other event type indicates a server-side change that CC doesn't yet
|
||||
* understand. Log a diagnostic so we notice in telemetry.
|
||||
*/
|
||||
private handleSSEFrame(eventType: string, data: string): void {
|
||||
if (eventType !== 'client_event') {
|
||||
logForDebugging(
|
||||
`SSETransport: Unexpected SSE event type '${eventType}' on worker stream`,
|
||||
{ level: 'warn' },
|
||||
)
|
||||
logForDiagnosticsNoPII('warn', 'cli_sse_unexpected_event_type', {
|
||||
event_type: eventType,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
let ev: StreamClientEvent
|
||||
try {
|
||||
ev = jsonParse(data) as StreamClientEvent
|
||||
} catch (error) {
|
||||
logForDebugging(
|
||||
`SSETransport: Failed to parse client_event data: ${errorMessage(error)}`,
|
||||
{ level: 'error' },
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const payload = ev.payload
|
||||
if (payload && typeof payload === 'object' && 'type' in payload) {
|
||||
const sessionLabel = this.sessionId ? ` session=${this.sessionId}` : ''
|
||||
logForDebugging(
|
||||
`SSETransport: Event seq=${ev.sequence_num} event_id=${ev.event_id} event_type=${ev.event_type} payload_type=${String(payload.type)}${sessionLabel}`,
|
||||
)
|
||||
logForDiagnosticsNoPII('info', 'cli_sse_message_received')
|
||||
// Pass the unwrapped payload as newline-delimited JSON,
|
||||
// matching the format that StructuredIO/WebSocketTransport consumers expect
|
||||
this.onData?.(jsonStringify(payload) + '\n')
|
||||
} else {
|
||||
logForDebugging(
|
||||
`SSETransport: Ignoring client_event with no type in payload: event_id=${ev.event_id}`,
|
||||
)
|
||||
}
|
||||
|
||||
this.onEventCallback?.(ev)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle connection errors with exponential backoff and time budget.
|
||||
*/
|
||||
private handleConnectionError(): void {
|
||||
this.clearLivenessTimer()
|
||||
|
||||
if (this.state === 'closing' || this.state === 'closed') return
|
||||
|
||||
// Abort any in-flight SSE fetch
|
||||
this.abortController?.abort()
|
||||
this.abortController = null
|
||||
|
||||
const now = Date.now()
|
||||
if (!this.reconnectStartTime) {
|
||||
this.reconnectStartTime = now
|
||||
}
|
||||
|
||||
const elapsed = now - this.reconnectStartTime
|
||||
if (elapsed < RECONNECT_GIVE_UP_MS) {
|
||||
// Clear any existing timer
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
|
||||
// Refresh headers before reconnecting
|
||||
if (this.refreshHeaders) {
|
||||
const freshHeaders = this.refreshHeaders()
|
||||
Object.assign(this.headers, freshHeaders)
|
||||
logForDebugging('SSETransport: Refreshed headers for reconnect')
|
||||
}
|
||||
|
||||
this.state = 'reconnecting'
|
||||
this.reconnectAttempts++
|
||||
|
||||
const baseDelay = Math.min(
|
||||
RECONNECT_BASE_DELAY_MS * Math.pow(2, this.reconnectAttempts - 1),
|
||||
RECONNECT_MAX_DELAY_MS,
|
||||
)
|
||||
// Add ±25% jitter
|
||||
const delay = Math.max(
|
||||
0,
|
||||
baseDelay + baseDelay * 0.25 * (2 * Math.random() - 1),
|
||||
)
|
||||
|
||||
logForDebugging(
|
||||
`SSETransport: Reconnecting in ${Math.round(delay)}ms (attempt ${this.reconnectAttempts}, ${Math.round(elapsed / 1000)}s elapsed)`,
|
||||
)
|
||||
logForDiagnosticsNoPII('error', 'cli_sse_reconnect_attempt', {
|
||||
reconnectAttempts: this.reconnectAttempts,
|
||||
})
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
void this.connect()
|
||||
}, delay)
|
||||
} else {
|
||||
logForDebugging(
|
||||
`SSETransport: Reconnection time budget exhausted after ${Math.round(elapsed / 1000)}s`,
|
||||
{ level: 'error' },
|
||||
)
|
||||
logForDiagnosticsNoPII('error', 'cli_sse_reconnect_exhausted', {
|
||||
reconnectAttempts: this.reconnectAttempts,
|
||||
elapsedMs: elapsed,
|
||||
})
|
||||
this.state = 'closed'
|
||||
this.onCloseCallback?.()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bound timeout callback. Hoisted from an inline closure so that
|
||||
* resetLivenessTimer (called per-frame) does not allocate a new closure
|
||||
* on every SSE frame.
|
||||
*/
|
||||
private readonly onLivenessTimeout = (): void => {
|
||||
this.livenessTimer = null
|
||||
logForDebugging('SSETransport: Liveness timeout, reconnecting', {
|
||||
level: 'error',
|
||||
})
|
||||
logForDiagnosticsNoPII('error', 'cli_sse_liveness_timeout')
|
||||
this.abortController?.abort()
|
||||
this.handleConnectionError()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the liveness timer. If no SSE frame arrives within the timeout,
|
||||
* treat the connection as dead and reconnect.
|
||||
*/
|
||||
private resetLivenessTimer(): void {
|
||||
this.clearLivenessTimer()
|
||||
this.livenessTimer = setTimeout(this.onLivenessTimeout, LIVENESS_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
private clearLivenessTimer(): void {
|
||||
if (this.livenessTimer) {
|
||||
clearTimeout(this.livenessTimer)
|
||||
this.livenessTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Write (HTTP POST) — same pattern as HybridTransport
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
async write(message: StdoutMessage): Promise<void> {
|
||||
const authHeaders = this.getAuthHeaders()
|
||||
if (Object.keys(authHeaders).length === 0) {
|
||||
logForDebugging('SSETransport: No session token available for POST')
|
||||
logForDiagnosticsNoPII('warn', 'cli_sse_post_no_token')
|
||||
return
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
...authHeaders,
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
'User-Agent': getClaudeCodeUserAgent(),
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`SSETransport: POST body keys=${Object.keys(message as Record<string, unknown>).join(',')}`,
|
||||
)
|
||||
|
||||
for (let attempt = 1; attempt <= POST_MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const response = await axios.post(this.postUrl, message, {
|
||||
headers,
|
||||
validateStatus: alwaysValidStatus,
|
||||
})
|
||||
|
||||
if (response.status === 200 || response.status === 201) {
|
||||
logForDebugging(`SSETransport: POST success type=${message.type}`)
|
||||
return
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`SSETransport: POST ${response.status} body=${jsonStringify(response.data).slice(0, 200)}`,
|
||||
)
|
||||
// 4xx errors (except 429) are permanent - don't retry
|
||||
if (
|
||||
response.status >= 400 &&
|
||||
response.status < 500 &&
|
||||
response.status !== 429
|
||||
) {
|
||||
logForDebugging(
|
||||
`SSETransport: POST returned ${response.status} (client error), not retrying`,
|
||||
)
|
||||
logForDiagnosticsNoPII('warn', 'cli_sse_post_client_error', {
|
||||
status: response.status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 429 or 5xx - retry
|
||||
logForDebugging(
|
||||
`SSETransport: POST returned ${response.status}, attempt ${attempt}/${POST_MAX_RETRIES}`,
|
||||
)
|
||||
logForDiagnosticsNoPII('warn', 'cli_sse_post_retryable_error', {
|
||||
status: response.status,
|
||||
attempt,
|
||||
})
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError
|
||||
logForDebugging(
|
||||
`SSETransport: POST error: ${axiosError.message}, attempt ${attempt}/${POST_MAX_RETRIES}`,
|
||||
)
|
||||
logForDiagnosticsNoPII('warn', 'cli_sse_post_network_error', {
|
||||
attempt,
|
||||
})
|
||||
}
|
||||
|
||||
if (attempt === POST_MAX_RETRIES) {
|
||||
logForDebugging(
|
||||
`SSETransport: POST failed after ${POST_MAX_RETRIES} attempts, continuing`,
|
||||
)
|
||||
logForDiagnosticsNoPII('warn', 'cli_sse_post_retries_exhausted')
|
||||
return
|
||||
}
|
||||
|
||||
const delayMs = Math.min(
|
||||
POST_BASE_DELAY_MS * Math.pow(2, attempt - 1),
|
||||
POST_MAX_DELAY_MS,
|
||||
)
|
||||
await sleep(delayMs)
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Transport interface
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
isConnectedStatus(): boolean {
|
||||
return this.state === 'connected'
|
||||
}
|
||||
|
||||
isClosedStatus(): boolean {
|
||||
return this.state === 'closed'
|
||||
}
|
||||
|
||||
setOnData(callback: (data: string) => void): void {
|
||||
this.onData = callback
|
||||
}
|
||||
|
||||
setOnClose(callback: (closeCode?: number) => void): void {
|
||||
this.onCloseCallback = callback
|
||||
}
|
||||
|
||||
setOnEvent(callback: (event: StreamClientEvent) => void): void {
|
||||
this.onEventCallback = callback
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
this.clearLivenessTimer()
|
||||
|
||||
this.state = 'closing'
|
||||
this.abortController?.abort()
|
||||
this.abortController = null
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// URL Conversion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Convert an SSE URL to the HTTP POST endpoint URL.
|
||||
* The SSE stream URL and POST URL share the same base; the POST endpoint
|
||||
* is at `/events` (without `/stream`).
|
||||
*
|
||||
* From: https://api.example.com/v2/session_ingress/session/<session_id>/events/stream
|
||||
* To: https://api.example.com/v2/session_ingress/session/<session_id>/events
|
||||
*/
|
||||
function convertSSEUrlToPostUrl(sseUrl: URL): string {
|
||||
let pathname = sseUrl.pathname
|
||||
// Remove /stream suffix to get the POST events endpoint
|
||||
if (pathname.endsWith('/stream')) {
|
||||
pathname = pathname.slice(0, -'/stream'.length)
|
||||
}
|
||||
return `${sseUrl.protocol}//${sseUrl.host}${pathname}`
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { jsonStringify } from '../../utils/slowOperations.js'
|
||||
|
||||
/**
|
||||
* Serial ordered event uploader with batching, retry, and backpressure.
|
||||
*
|
||||
* - enqueue() adds events to a pending buffer
|
||||
* - At most 1 POST in-flight at a time
|
||||
* - Drains up to maxBatchSize items per POST
|
||||
* - New events accumulate while in-flight
|
||||
* - On failure: exponential backoff (clamped), retries indefinitely
|
||||
* until success or close() — unless maxConsecutiveFailures is set,
|
||||
* in which case the failing batch is dropped and drain advances
|
||||
* - flush() blocks until pending is empty and kicks drain if needed
|
||||
* - Backpressure: enqueue() blocks when maxQueueSize is reached
|
||||
*/
|
||||
|
||||
/**
|
||||
* Throw from config.send() to make the uploader wait a server-supplied
|
||||
* duration before retrying (e.g. 429 with Retry-After). When retryAfterMs
|
||||
* is set, it overrides exponential backoff for that attempt — clamped to
|
||||
* [baseDelayMs, maxDelayMs] and jittered so a misbehaving server can
|
||||
* neither hot-loop nor stall the client, and many sessions sharing a rate
|
||||
* limit don't all pounce at the same instant. Without retryAfterMs, behaves
|
||||
* like any other thrown error (exponential backoff).
|
||||
*/
|
||||
export class RetryableError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly retryAfterMs?: number,
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
type SerialBatchEventUploaderConfig<T> = {
|
||||
/** Max items per POST (1 = no batching) */
|
||||
maxBatchSize: number
|
||||
/**
|
||||
* Max serialized bytes per POST. First item always goes in regardless of
|
||||
* size; subsequent items only if cumulative JSON bytes stay under this.
|
||||
* Undefined = no byte limit (count-only batching).
|
||||
*/
|
||||
maxBatchBytes?: number
|
||||
/** Max pending items before enqueue() blocks */
|
||||
maxQueueSize: number
|
||||
/** The actual HTTP call — caller controls payload format */
|
||||
send: (batch: T[]) => Promise<void>
|
||||
/** Base delay for exponential backoff (ms) */
|
||||
baseDelayMs: number
|
||||
/** Max delay cap (ms) */
|
||||
maxDelayMs: number
|
||||
/** Random jitter range added to retry delay (ms) */
|
||||
jitterMs: number
|
||||
/**
|
||||
* After this many consecutive send() failures, drop the failing batch
|
||||
* and move on to the next pending item with a fresh failure budget.
|
||||
* Undefined = retry indefinitely (default).
|
||||
*/
|
||||
maxConsecutiveFailures?: number
|
||||
/** Called when a batch is dropped for hitting maxConsecutiveFailures. */
|
||||
onBatchDropped?: (batchSize: number, failures: number) => void
|
||||
}
|
||||
|
||||
export class SerialBatchEventUploader<T> {
|
||||
private pending: T[] = []
|
||||
private pendingAtClose = 0
|
||||
private draining = false
|
||||
private closed = false
|
||||
private backpressureResolvers: Array<() => void> = []
|
||||
private sleepResolve: (() => void) | null = null
|
||||
private flushResolvers: Array<() => void> = []
|
||||
private droppedBatches = 0
|
||||
private readonly config: SerialBatchEventUploaderConfig<T>
|
||||
|
||||
constructor(config: SerialBatchEventUploaderConfig<T>) {
|
||||
this.config = config
|
||||
}
|
||||
|
||||
/**
|
||||
* Monotonic count of batches dropped via maxConsecutiveFailures. Callers
|
||||
* can snapshot before flush() and compare after to detect silent drops
|
||||
* (flush() resolves normally even when batches were dropped).
|
||||
*/
|
||||
get droppedBatchCount(): number {
|
||||
return this.droppedBatches
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending queue depth. After close(), returns the count at close time —
|
||||
* close() clears the queue but shutdown diagnostics may read this after.
|
||||
*/
|
||||
get pendingCount(): number {
|
||||
return this.closed ? this.pendingAtClose : this.pending.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Add events to the pending buffer. Returns immediately if space is
|
||||
* available. Blocks (awaits) if the buffer is full — caller pauses
|
||||
* until drain frees space.
|
||||
*/
|
||||
async enqueue(events: T | T[]): Promise<void> {
|
||||
if (this.closed) return
|
||||
const items = Array.isArray(events) ? events : [events]
|
||||
if (items.length === 0) return
|
||||
|
||||
// Backpressure: wait until there's space
|
||||
while (
|
||||
this.pending.length + items.length > this.config.maxQueueSize &&
|
||||
!this.closed
|
||||
) {
|
||||
await new Promise<void>(resolve => {
|
||||
this.backpressureResolvers.push(resolve)
|
||||
})
|
||||
}
|
||||
|
||||
if (this.closed) return
|
||||
this.pending.push(...items)
|
||||
void this.drain()
|
||||
}
|
||||
|
||||
/**
|
||||
* Block until all pending events have been sent.
|
||||
* Used at turn boundaries and graceful shutdown.
|
||||
*/
|
||||
flush(): Promise<void> {
|
||||
if (this.pending.length === 0 && !this.draining) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
void this.drain()
|
||||
return new Promise<void>(resolve => {
|
||||
this.flushResolvers.push(resolve)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop pending events and stop processing.
|
||||
* Resolves any blocked enqueue() and flush() callers.
|
||||
*/
|
||||
close(): void {
|
||||
if (this.closed) return
|
||||
this.closed = true
|
||||
this.pendingAtClose = this.pending.length
|
||||
this.pending = []
|
||||
this.sleepResolve?.()
|
||||
this.sleepResolve = null
|
||||
for (const resolve of this.backpressureResolvers) resolve()
|
||||
this.backpressureResolvers = []
|
||||
for (const resolve of this.flushResolvers) resolve()
|
||||
this.flushResolvers = []
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain loop. At most one instance runs at a time (guarded by this.draining).
|
||||
* Sends batches serially. On failure, backs off and retries indefinitely.
|
||||
*/
|
||||
private async drain(): Promise<void> {
|
||||
if (this.draining || this.closed) return
|
||||
this.draining = true
|
||||
let failures = 0
|
||||
|
||||
try {
|
||||
while (this.pending.length > 0 && !this.closed) {
|
||||
const batch = this.takeBatch()
|
||||
if (batch.length === 0) continue
|
||||
|
||||
try {
|
||||
await this.config.send(batch)
|
||||
failures = 0
|
||||
} catch (err) {
|
||||
failures++
|
||||
if (
|
||||
this.config.maxConsecutiveFailures !== undefined &&
|
||||
failures >= this.config.maxConsecutiveFailures
|
||||
) {
|
||||
this.droppedBatches++
|
||||
this.config.onBatchDropped?.(batch.length, failures)
|
||||
failures = 0
|
||||
this.releaseBackpressure()
|
||||
continue
|
||||
}
|
||||
// Re-queue the failed batch at the front. Use concat (single
|
||||
// allocation) instead of unshift(...batch) which shifts every
|
||||
// pending item batch.length times. Only hit on failure path.
|
||||
this.pending = batch.concat(this.pending)
|
||||
const retryAfterMs =
|
||||
err instanceof RetryableError ? err.retryAfterMs : undefined
|
||||
await this.sleep(this.retryDelay(failures, retryAfterMs))
|
||||
continue
|
||||
}
|
||||
|
||||
// Release backpressure waiters if space opened up
|
||||
this.releaseBackpressure()
|
||||
}
|
||||
} finally {
|
||||
this.draining = false
|
||||
// Notify flush waiters if queue is empty
|
||||
if (this.pending.length === 0) {
|
||||
for (const resolve of this.flushResolvers) resolve()
|
||||
this.flushResolvers = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the next batch from pending. Respects both maxBatchSize and
|
||||
* maxBatchBytes. The first item is always taken; subsequent items only
|
||||
* if adding them keeps the cumulative JSON size under maxBatchBytes.
|
||||
*
|
||||
* Un-serializable items (BigInt, circular refs, throwing toJSON) are
|
||||
* dropped in place — they can never be sent and leaving them at
|
||||
* pending[0] would poison the queue and hang flush() forever.
|
||||
*/
|
||||
private takeBatch(): T[] {
|
||||
const { maxBatchSize, maxBatchBytes } = this.config
|
||||
if (maxBatchBytes === undefined) {
|
||||
return this.pending.splice(0, maxBatchSize)
|
||||
}
|
||||
let bytes = 0
|
||||
let count = 0
|
||||
while (count < this.pending.length && count < maxBatchSize) {
|
||||
let itemBytes: number
|
||||
try {
|
||||
itemBytes = Buffer.byteLength(jsonStringify(this.pending[count]))
|
||||
} catch {
|
||||
this.pending.splice(count, 1)
|
||||
continue
|
||||
}
|
||||
if (count > 0 && bytes + itemBytes > maxBatchBytes) break
|
||||
bytes += itemBytes
|
||||
count++
|
||||
}
|
||||
return this.pending.splice(0, count)
|
||||
}
|
||||
|
||||
private retryDelay(failures: number, retryAfterMs?: number): number {
|
||||
const jitter = Math.random() * this.config.jitterMs
|
||||
if (retryAfterMs !== undefined) {
|
||||
// Jitter on top of the server's hint prevents thundering herd when
|
||||
// many sessions share a rate limit and all receive the same
|
||||
// Retry-After. Clamp first, then spread — same shape as the
|
||||
// exponential path (effective ceiling is maxDelayMs + jitterMs).
|
||||
const clamped = Math.max(
|
||||
this.config.baseDelayMs,
|
||||
Math.min(retryAfterMs, this.config.maxDelayMs),
|
||||
)
|
||||
return clamped + jitter
|
||||
}
|
||||
const exponential = Math.min(
|
||||
this.config.baseDelayMs * 2 ** (failures - 1),
|
||||
this.config.maxDelayMs,
|
||||
)
|
||||
return exponential + jitter
|
||||
}
|
||||
|
||||
private releaseBackpressure(): void {
|
||||
const resolvers = this.backpressureResolvers
|
||||
this.backpressureResolvers = []
|
||||
for (const resolve of resolvers) resolve()
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
this.sleepResolve = resolve
|
||||
setTimeout(
|
||||
(self, resolve) => {
|
||||
self.sleepResolve = null
|
||||
resolve()
|
||||
},
|
||||
ms,
|
||||
this,
|
||||
resolve,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
800
original-source-code/src/cli/transports/WebSocketTransport.ts
Normal file
800
original-source-code/src/cli/transports/WebSocketTransport.ts
Normal file
@@ -0,0 +1,800 @@
|
||||
import type { StdoutMessage } from 'src/entrypoints/sdk/controlTypes.js'
|
||||
import type WsWebSocket from 'ws'
|
||||
import { logEvent } from '../../services/analytics/index.js'
|
||||
import { CircularBuffer } from '../../utils/CircularBuffer.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { logForDiagnosticsNoPII } from '../../utils/diagLogs.js'
|
||||
import { isEnvTruthy } from '../../utils/envUtils.js'
|
||||
import { getWebSocketTLSOptions } from '../../utils/mtls.js'
|
||||
import {
|
||||
getWebSocketProxyAgent,
|
||||
getWebSocketProxyUrl,
|
||||
} from '../../utils/proxy.js'
|
||||
import {
|
||||
registerSessionActivityCallback,
|
||||
unregisterSessionActivityCallback,
|
||||
} from '../../utils/sessionActivity.js'
|
||||
import { jsonStringify } from '../../utils/slowOperations.js'
|
||||
import type { Transport } from './Transport.js'
|
||||
|
||||
const KEEP_ALIVE_FRAME = '{"type":"keep_alive"}\n'
|
||||
|
||||
const DEFAULT_MAX_BUFFER_SIZE = 1000
|
||||
const DEFAULT_BASE_RECONNECT_DELAY = 1000
|
||||
const DEFAULT_MAX_RECONNECT_DELAY = 30000
|
||||
/** Time budget for reconnection attempts before giving up (10 minutes). */
|
||||
const DEFAULT_RECONNECT_GIVE_UP_MS = 600_000
|
||||
const DEFAULT_PING_INTERVAL = 10000
|
||||
const DEFAULT_KEEPALIVE_INTERVAL = 300_000 // 5 minutes
|
||||
|
||||
/**
|
||||
* Threshold for detecting system sleep/wake. If the gap between consecutive
|
||||
* reconnection attempts exceeds this, the machine likely slept. We reset
|
||||
* the reconnection budget and retry — the server will reject with permanent
|
||||
* close codes (4001/1002) if the session was reaped during sleep.
|
||||
*/
|
||||
const SLEEP_DETECTION_THRESHOLD_MS = DEFAULT_MAX_RECONNECT_DELAY * 2 // 60s
|
||||
|
||||
/**
|
||||
* WebSocket close codes that indicate a permanent server-side rejection.
|
||||
* The transport transitions to 'closed' immediately without retrying.
|
||||
*/
|
||||
const PERMANENT_CLOSE_CODES = new Set([
|
||||
1002, // protocol error — server rejected handshake (e.g. session reaped)
|
||||
4001, // session expired / not found
|
||||
4003, // unauthorized
|
||||
])
|
||||
|
||||
export type WebSocketTransportOptions = {
|
||||
/** When false, the transport does not attempt automatic reconnection on
|
||||
* disconnect. Use this when the caller has its own recovery mechanism
|
||||
* (e.g. the REPL bridge poll loop). Defaults to true. */
|
||||
autoReconnect?: boolean
|
||||
/** Gates the tengu_ws_transport_* telemetry events. Set true at the
|
||||
* REPL-bridge construction site so only Remote Control sessions (the
|
||||
* Cloudflare-idle-timeout population) emit; print-mode workers stay
|
||||
* silent. Defaults to false. */
|
||||
isBridge?: boolean
|
||||
}
|
||||
|
||||
type WebSocketTransportState =
|
||||
| 'idle'
|
||||
| 'connected'
|
||||
| 'reconnecting'
|
||||
| 'closing'
|
||||
| 'closed'
|
||||
|
||||
// Common interface between globalThis.WebSocket and ws.WebSocket
|
||||
type WebSocketLike = {
|
||||
close(): void
|
||||
send(data: string): void
|
||||
ping?(): void // Bun & ws both support this
|
||||
}
|
||||
|
||||
export class WebSocketTransport implements Transport {
|
||||
private ws: WebSocketLike | null = null
|
||||
private lastSentId: string | null = null
|
||||
protected url: URL
|
||||
protected state: WebSocketTransportState = 'idle'
|
||||
protected onData?: (data: string) => void
|
||||
private onCloseCallback?: (closeCode?: number) => void
|
||||
private onConnectCallback?: () => void
|
||||
private headers: Record<string, string>
|
||||
private sessionId?: string
|
||||
private autoReconnect: boolean
|
||||
private isBridge: boolean
|
||||
|
||||
// Reconnection state
|
||||
private reconnectAttempts = 0
|
||||
private reconnectStartTime: number | null = null
|
||||
private reconnectTimer: NodeJS.Timeout | null = null
|
||||
private lastReconnectAttemptTime: number | null = null
|
||||
// Wall-clock of last WS data-frame activity (inbound message or outbound
|
||||
// ws.send). Used to compute idle time at close — the signal for diagnosing
|
||||
// proxy idle-timeout RSTs (e.g. Cloudflare 5-min). Excludes ping/pong
|
||||
// control frames (proxies don't count those).
|
||||
private lastActivityTime = 0
|
||||
|
||||
// Ping interval for connection health checks
|
||||
private pingInterval: NodeJS.Timeout | null = null
|
||||
private pongReceived = true
|
||||
|
||||
// Periodic keep_alive data frames to reset proxy idle timers
|
||||
private keepAliveInterval: NodeJS.Timeout | null = null
|
||||
|
||||
// Message buffering for replay on reconnection
|
||||
private messageBuffer: CircularBuffer<StdoutMessage>
|
||||
// Track which runtime's WS we're using so we can detach listeners
|
||||
// with the matching API (removeEventListener vs. off).
|
||||
private isBunWs = false
|
||||
|
||||
// Captured at connect() time for handleOpenEvent timing. Stored as an
|
||||
// instance field so the onOpen handler can be a stable class-property
|
||||
// arrow function (removable in doDisconnect) instead of a closure over
|
||||
// a local variable.
|
||||
private connectStartTime = 0
|
||||
|
||||
private refreshHeaders?: () => Record<string, string>
|
||||
|
||||
constructor(
|
||||
url: URL,
|
||||
headers: Record<string, string> = {},
|
||||
sessionId?: string,
|
||||
refreshHeaders?: () => Record<string, string>,
|
||||
options?: WebSocketTransportOptions,
|
||||
) {
|
||||
this.url = url
|
||||
this.headers = headers
|
||||
this.sessionId = sessionId
|
||||
this.refreshHeaders = refreshHeaders
|
||||
this.autoReconnect = options?.autoReconnect ?? true
|
||||
this.isBridge = options?.isBridge ?? false
|
||||
this.messageBuffer = new CircularBuffer(DEFAULT_MAX_BUFFER_SIZE)
|
||||
}
|
||||
|
||||
public async connect(): Promise<void> {
|
||||
if (this.state !== 'idle' && this.state !== 'reconnecting') {
|
||||
logForDebugging(
|
||||
`WebSocketTransport: Cannot connect, current state is ${this.state}`,
|
||||
{ level: 'error' },
|
||||
)
|
||||
logForDiagnosticsNoPII('error', 'cli_websocket_connect_failed')
|
||||
return
|
||||
}
|
||||
this.state = 'reconnecting'
|
||||
|
||||
this.connectStartTime = Date.now()
|
||||
logForDebugging(`WebSocketTransport: Opening ${this.url.href}`)
|
||||
logForDiagnosticsNoPII('info', 'cli_websocket_connect_opening')
|
||||
|
||||
// Start with provided headers and add runtime headers
|
||||
const headers = { ...this.headers }
|
||||
if (this.lastSentId) {
|
||||
headers['X-Last-Request-Id'] = this.lastSentId
|
||||
logForDebugging(
|
||||
`WebSocketTransport: Adding X-Last-Request-Id header: ${this.lastSentId}`,
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof Bun !== 'undefined') {
|
||||
// Bun's WebSocket supports headers/proxy options but the DOM typings don't
|
||||
// eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins
|
||||
const ws = new globalThis.WebSocket(this.url.href, {
|
||||
headers,
|
||||
proxy: getWebSocketProxyUrl(this.url.href),
|
||||
tls: getWebSocketTLSOptions() || undefined,
|
||||
} as unknown as string[])
|
||||
this.ws = ws
|
||||
this.isBunWs = true
|
||||
|
||||
ws.addEventListener('open', this.onBunOpen)
|
||||
ws.addEventListener('message', this.onBunMessage)
|
||||
ws.addEventListener('error', this.onBunError)
|
||||
// eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins
|
||||
ws.addEventListener('close', this.onBunClose)
|
||||
// 'pong' is Bun-specific — not in DOM typings.
|
||||
ws.addEventListener('pong', this.onPong)
|
||||
} else {
|
||||
const { default: WS } = await import('ws')
|
||||
const ws = new WS(this.url.href, {
|
||||
headers,
|
||||
agent: getWebSocketProxyAgent(this.url.href),
|
||||
...getWebSocketTLSOptions(),
|
||||
})
|
||||
this.ws = ws
|
||||
this.isBunWs = false
|
||||
|
||||
ws.on('open', this.onNodeOpen)
|
||||
ws.on('message', this.onNodeMessage)
|
||||
ws.on('error', this.onNodeError)
|
||||
ws.on('close', this.onNodeClose)
|
||||
ws.on('pong', this.onPong)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Bun (native WebSocket) event handlers ---
|
||||
// Stored as class-property arrow functions so they can be removed in
|
||||
// doDisconnect(). Without removal, each reconnect orphans the old WS
|
||||
// object + its 5 closures until GC, which accumulates under network
|
||||
// instability. Mirrors the pattern in src/utils/mcpWebSocketTransport.ts.
|
||||
|
||||
private onBunOpen = () => {
|
||||
this.handleOpenEvent()
|
||||
// Bun's WebSocket doesn't expose upgrade response headers,
|
||||
// so replay all buffered messages. The server deduplicates by UUID.
|
||||
if (this.lastSentId) {
|
||||
this.replayBufferedMessages('')
|
||||
}
|
||||
}
|
||||
|
||||
private onBunMessage = (event: MessageEvent) => {
|
||||
const message =
|
||||
typeof event.data === 'string' ? event.data : String(event.data)
|
||||
this.lastActivityTime = Date.now()
|
||||
logForDiagnosticsNoPII('info', 'cli_websocket_message_received', {
|
||||
length: message.length,
|
||||
})
|
||||
if (this.onData) {
|
||||
this.onData(message)
|
||||
}
|
||||
}
|
||||
|
||||
private onBunError = () => {
|
||||
logForDebugging('WebSocketTransport: Error', {
|
||||
level: 'error',
|
||||
})
|
||||
logForDiagnosticsNoPII('error', 'cli_websocket_connect_error')
|
||||
// close event fires after error — let it call handleConnectionError
|
||||
}
|
||||
|
||||
// eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins
|
||||
private onBunClose = (event: CloseEvent) => {
|
||||
const isClean = event.code === 1000 || event.code === 1001
|
||||
logForDebugging(
|
||||
`WebSocketTransport: Closed: ${event.code}`,
|
||||
isClean ? undefined : { level: 'error' },
|
||||
)
|
||||
logForDiagnosticsNoPII('error', 'cli_websocket_connect_closed')
|
||||
this.handleConnectionError(event.code)
|
||||
}
|
||||
|
||||
// --- Node (ws package) event handlers ---
|
||||
|
||||
private onNodeOpen = () => {
|
||||
// Capture ws before handleOpenEvent() invokes onConnectCallback — if the
|
||||
// callback synchronously closes the transport, this.ws becomes null.
|
||||
// The old inline-closure code had this safety implicitly via closure capture.
|
||||
const ws = this.ws
|
||||
this.handleOpenEvent()
|
||||
if (!ws) return
|
||||
// Check for last-id in upgrade response headers (ws package only)
|
||||
const nws = ws as unknown as WsWebSocket & {
|
||||
upgradeReq?: { headers?: Record<string, string> }
|
||||
}
|
||||
const upgradeResponse = nws.upgradeReq
|
||||
if (upgradeResponse?.headers?.['x-last-request-id']) {
|
||||
const serverLastId = upgradeResponse.headers['x-last-request-id']
|
||||
this.replayBufferedMessages(serverLastId)
|
||||
}
|
||||
}
|
||||
|
||||
private onNodeMessage = (data: Buffer) => {
|
||||
const message = data.toString()
|
||||
this.lastActivityTime = Date.now()
|
||||
logForDiagnosticsNoPII('info', 'cli_websocket_message_received', {
|
||||
length: message.length,
|
||||
})
|
||||
if (this.onData) {
|
||||
this.onData(message)
|
||||
}
|
||||
}
|
||||
|
||||
private onNodeError = (err: Error) => {
|
||||
logForDebugging(`WebSocketTransport: Error: ${err.message}`, {
|
||||
level: 'error',
|
||||
})
|
||||
logForDiagnosticsNoPII('error', 'cli_websocket_connect_error')
|
||||
// close event fires after error — let it call handleConnectionError
|
||||
}
|
||||
|
||||
private onNodeClose = (code: number, _reason: Buffer) => {
|
||||
const isClean = code === 1000 || code === 1001
|
||||
logForDebugging(
|
||||
`WebSocketTransport: Closed: ${code}`,
|
||||
isClean ? undefined : { level: 'error' },
|
||||
)
|
||||
logForDiagnosticsNoPII('error', 'cli_websocket_connect_closed')
|
||||
this.handleConnectionError(code)
|
||||
}
|
||||
|
||||
// --- Shared handlers ---
|
||||
|
||||
private onPong = () => {
|
||||
this.pongReceived = true
|
||||
}
|
||||
|
||||
private handleOpenEvent(): void {
|
||||
const connectDuration = Date.now() - this.connectStartTime
|
||||
logForDebugging('WebSocketTransport: Connected')
|
||||
logForDiagnosticsNoPII('info', 'cli_websocket_connect_connected', {
|
||||
duration_ms: connectDuration,
|
||||
})
|
||||
|
||||
// Reconnect success — capture attempt count + downtime before resetting.
|
||||
// reconnectStartTime is null on first connect, non-null on reopen.
|
||||
if (this.isBridge && this.reconnectStartTime !== null) {
|
||||
logEvent('tengu_ws_transport_reconnected', {
|
||||
attempts: this.reconnectAttempts,
|
||||
downtimeMs: Date.now() - this.reconnectStartTime,
|
||||
})
|
||||
}
|
||||
|
||||
this.reconnectAttempts = 0
|
||||
this.reconnectStartTime = null
|
||||
this.lastReconnectAttemptTime = null
|
||||
this.lastActivityTime = Date.now()
|
||||
this.state = 'connected'
|
||||
this.onConnectCallback?.()
|
||||
|
||||
// Start periodic pings to detect dead connections
|
||||
this.startPingInterval()
|
||||
|
||||
// Start periodic keep_alive data frames to reset proxy idle timers
|
||||
this.startKeepaliveInterval()
|
||||
|
||||
// Register callback for session activity signals
|
||||
registerSessionActivityCallback(() => {
|
||||
void this.write({ type: 'keep_alive' })
|
||||
})
|
||||
}
|
||||
|
||||
protected sendLine(line: string): boolean {
|
||||
if (!this.ws || this.state !== 'connected') {
|
||||
logForDebugging('WebSocketTransport: Not connected')
|
||||
logForDiagnosticsNoPII('info', 'cli_websocket_send_not_connected')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
this.ws.send(line)
|
||||
this.lastActivityTime = Date.now()
|
||||
return true
|
||||
} catch (error) {
|
||||
logForDebugging(`WebSocketTransport: Failed to send: ${error}`, {
|
||||
level: 'error',
|
||||
})
|
||||
logForDiagnosticsNoPII('error', 'cli_websocket_send_error')
|
||||
// Don't null this.ws here — let doDisconnect() (via handleConnectionError)
|
||||
// handle cleanup so listeners are removed before the WS is released.
|
||||
this.handleConnectionError()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all listeners attached in connect() for the given WebSocket.
|
||||
* Without this, each reconnect orphans the old WS object + its closures
|
||||
* until GC — these accumulate under network instability. Mirrors the
|
||||
* pattern in src/utils/mcpWebSocketTransport.ts.
|
||||
*/
|
||||
private removeWsListeners(ws: WebSocketLike): void {
|
||||
if (this.isBunWs) {
|
||||
const nws = ws as unknown as globalThis.WebSocket
|
||||
nws.removeEventListener('open', this.onBunOpen)
|
||||
nws.removeEventListener('message', this.onBunMessage)
|
||||
nws.removeEventListener('error', this.onBunError)
|
||||
// eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins
|
||||
nws.removeEventListener('close', this.onBunClose)
|
||||
// 'pong' is Bun-specific — not in DOM typings
|
||||
nws.removeEventListener('pong' as 'message', this.onPong)
|
||||
} else {
|
||||
const nws = ws as unknown as WsWebSocket
|
||||
nws.off('open', this.onNodeOpen)
|
||||
nws.off('message', this.onNodeMessage)
|
||||
nws.off('error', this.onNodeError)
|
||||
nws.off('close', this.onNodeClose)
|
||||
nws.off('pong', this.onPong)
|
||||
}
|
||||
}
|
||||
|
||||
protected doDisconnect(): void {
|
||||
// Stop pinging and keepalive when disconnecting
|
||||
this.stopPingInterval()
|
||||
this.stopKeepaliveInterval()
|
||||
|
||||
// Unregister session activity callback
|
||||
unregisterSessionActivityCallback()
|
||||
|
||||
if (this.ws) {
|
||||
// Remove listeners BEFORE close() so the old WS + closures can be
|
||||
// GC'd promptly instead of lingering until the next mark-and-sweep.
|
||||
this.removeWsListeners(this.ws)
|
||||
this.ws.close()
|
||||
this.ws = null
|
||||
}
|
||||
}
|
||||
|
||||
private handleConnectionError(closeCode?: number): void {
|
||||
logForDebugging(
|
||||
`WebSocketTransport: Disconnected from ${this.url.href}` +
|
||||
(closeCode != null ? ` (code ${closeCode})` : ''),
|
||||
)
|
||||
logForDiagnosticsNoPII('info', 'cli_websocket_disconnected')
|
||||
if (this.isBridge) {
|
||||
// Fire on every close — including intermediate ones during a reconnect
|
||||
// storm (those never surface to the onCloseCallback consumer). For the
|
||||
// Cloudflare-5min-idle hypothesis: cluster msSinceLastActivity; if the
|
||||
// peak sits at ~300s with closeCode 1006, that's the proxy RST.
|
||||
logEvent('tengu_ws_transport_closed', {
|
||||
closeCode,
|
||||
msSinceLastActivity:
|
||||
this.lastActivityTime > 0 ? Date.now() - this.lastActivityTime : -1,
|
||||
// 'connected' = healthy drop (the Cloudflare case); 'reconnecting' =
|
||||
// connect-rejection mid-storm. State isn't mutated until the branches
|
||||
// below, so this reads the pre-close value.
|
||||
wasConnected: this.state === 'connected',
|
||||
reconnectAttempts: this.reconnectAttempts,
|
||||
})
|
||||
}
|
||||
this.doDisconnect()
|
||||
|
||||
if (this.state === 'closing' || this.state === 'closed') return
|
||||
|
||||
// Permanent codes: don't retry — server has definitively ended the session.
|
||||
// Exception: 4003 (unauthorized) can be retried when refreshHeaders is
|
||||
// available and returns a new token (e.g. after the parent process mints
|
||||
// a fresh session ingress token during reconnection).
|
||||
let headersRefreshed = false
|
||||
if (closeCode === 4003 && this.refreshHeaders) {
|
||||
const freshHeaders = this.refreshHeaders()
|
||||
if (freshHeaders.Authorization !== this.headers.Authorization) {
|
||||
Object.assign(this.headers, freshHeaders)
|
||||
headersRefreshed = true
|
||||
logForDebugging(
|
||||
'WebSocketTransport: 4003 received but headers refreshed, scheduling reconnect',
|
||||
)
|
||||
logForDiagnosticsNoPII('info', 'cli_websocket_4003_token_refreshed')
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
closeCode != null &&
|
||||
PERMANENT_CLOSE_CODES.has(closeCode) &&
|
||||
!headersRefreshed
|
||||
) {
|
||||
logForDebugging(
|
||||
`WebSocketTransport: Permanent close code ${closeCode}, not reconnecting`,
|
||||
{ level: 'error' },
|
||||
)
|
||||
logForDiagnosticsNoPII('error', 'cli_websocket_permanent_close', {
|
||||
closeCode,
|
||||
})
|
||||
this.state = 'closed'
|
||||
this.onCloseCallback?.(closeCode)
|
||||
return
|
||||
}
|
||||
|
||||
// When autoReconnect is disabled, go straight to closed state.
|
||||
// The caller (e.g. REPL bridge poll loop) handles recovery.
|
||||
if (!this.autoReconnect) {
|
||||
this.state = 'closed'
|
||||
this.onCloseCallback?.(closeCode)
|
||||
return
|
||||
}
|
||||
|
||||
// Schedule reconnection with exponential backoff and time budget
|
||||
const now = Date.now()
|
||||
if (!this.reconnectStartTime) {
|
||||
this.reconnectStartTime = now
|
||||
}
|
||||
|
||||
// Detect system sleep/wake: if the gap since our last reconnection
|
||||
// attempt greatly exceeds the max delay, the machine likely slept
|
||||
// (e.g. laptop lid closed). Reset the budget and retry from scratch —
|
||||
// the server will reject with permanent close codes (4001/1002) if
|
||||
// the session was reaped while we were asleep.
|
||||
if (
|
||||
this.lastReconnectAttemptTime !== null &&
|
||||
now - this.lastReconnectAttemptTime > SLEEP_DETECTION_THRESHOLD_MS
|
||||
) {
|
||||
logForDebugging(
|
||||
`WebSocketTransport: Detected system sleep (${Math.round((now - this.lastReconnectAttemptTime) / 1000)}s gap), resetting reconnection budget`,
|
||||
)
|
||||
logForDiagnosticsNoPII('info', 'cli_websocket_sleep_detected', {
|
||||
gapMs: now - this.lastReconnectAttemptTime,
|
||||
})
|
||||
this.reconnectStartTime = now
|
||||
this.reconnectAttempts = 0
|
||||
}
|
||||
this.lastReconnectAttemptTime = now
|
||||
|
||||
const elapsed = now - this.reconnectStartTime
|
||||
if (elapsed < DEFAULT_RECONNECT_GIVE_UP_MS) {
|
||||
// Clear any existing reconnection timer to avoid duplicates
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
|
||||
// Refresh headers before reconnecting (e.g. to pick up a new session token).
|
||||
// Skip if already refreshed by the 4003 path above.
|
||||
if (!headersRefreshed && this.refreshHeaders) {
|
||||
const freshHeaders = this.refreshHeaders()
|
||||
Object.assign(this.headers, freshHeaders)
|
||||
logForDebugging('WebSocketTransport: Refreshed headers for reconnect')
|
||||
}
|
||||
|
||||
this.state = 'reconnecting'
|
||||
this.reconnectAttempts++
|
||||
|
||||
const baseDelay = Math.min(
|
||||
DEFAULT_BASE_RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts - 1),
|
||||
DEFAULT_MAX_RECONNECT_DELAY,
|
||||
)
|
||||
// Add ±25% jitter to avoid thundering herd
|
||||
const delay = Math.max(
|
||||
0,
|
||||
baseDelay + baseDelay * 0.25 * (2 * Math.random() - 1),
|
||||
)
|
||||
|
||||
logForDebugging(
|
||||
`WebSocketTransport: Reconnecting in ${Math.round(delay)}ms (attempt ${this.reconnectAttempts}, ${Math.round(elapsed / 1000)}s elapsed)`,
|
||||
)
|
||||
logForDiagnosticsNoPII('error', 'cli_websocket_reconnect_attempt', {
|
||||
reconnectAttempts: this.reconnectAttempts,
|
||||
})
|
||||
if (this.isBridge) {
|
||||
logEvent('tengu_ws_transport_reconnecting', {
|
||||
attempt: this.reconnectAttempts,
|
||||
elapsedMs: elapsed,
|
||||
delayMs: Math.round(delay),
|
||||
})
|
||||
}
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
void this.connect()
|
||||
}, delay)
|
||||
} else {
|
||||
logForDebugging(
|
||||
`WebSocketTransport: Reconnection time budget exhausted after ${Math.round(elapsed / 1000)}s for ${this.url.href}`,
|
||||
{ level: 'error' },
|
||||
)
|
||||
logForDiagnosticsNoPII('error', 'cli_websocket_reconnect_exhausted', {
|
||||
reconnectAttempts: this.reconnectAttempts,
|
||||
elapsedMs: elapsed,
|
||||
})
|
||||
this.state = 'closed'
|
||||
|
||||
// Notify close callback
|
||||
if (this.onCloseCallback) {
|
||||
this.onCloseCallback(closeCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
// Clear any pending reconnection timer
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
|
||||
// Clear ping and keepalive intervals
|
||||
this.stopPingInterval()
|
||||
this.stopKeepaliveInterval()
|
||||
|
||||
// Unregister session activity callback
|
||||
unregisterSessionActivityCallback()
|
||||
|
||||
this.state = 'closing'
|
||||
this.doDisconnect()
|
||||
}
|
||||
|
||||
private replayBufferedMessages(lastId: string): void {
|
||||
const messages = this.messageBuffer.toArray()
|
||||
if (messages.length === 0) return
|
||||
|
||||
// Find where to start replay based on server's last received message
|
||||
let startIndex = 0
|
||||
if (lastId) {
|
||||
const lastConfirmedIndex = messages.findIndex(
|
||||
message => 'uuid' in message && message.uuid === lastId,
|
||||
)
|
||||
if (lastConfirmedIndex >= 0) {
|
||||
// Server confirmed messages up to lastConfirmedIndex — evict them
|
||||
startIndex = lastConfirmedIndex + 1
|
||||
// Rebuild the buffer with only unconfirmed messages
|
||||
const remaining = messages.slice(startIndex)
|
||||
this.messageBuffer.clear()
|
||||
this.messageBuffer.addAll(remaining)
|
||||
if (remaining.length === 0) {
|
||||
this.lastSentId = null
|
||||
}
|
||||
logForDebugging(
|
||||
`WebSocketTransport: Evicted ${startIndex} confirmed messages, ${remaining.length} remaining`,
|
||||
)
|
||||
logForDiagnosticsNoPII(
|
||||
'info',
|
||||
'cli_websocket_evicted_confirmed_messages',
|
||||
{
|
||||
evicted: startIndex,
|
||||
remaining: remaining.length,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const messagesToReplay = messages.slice(startIndex)
|
||||
if (messagesToReplay.length === 0) {
|
||||
logForDebugging('WebSocketTransport: No new messages to replay')
|
||||
logForDiagnosticsNoPII('info', 'cli_websocket_no_messages_to_replay')
|
||||
return
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`WebSocketTransport: Replaying ${messagesToReplay.length} buffered messages`,
|
||||
)
|
||||
logForDiagnosticsNoPII('info', 'cli_websocket_messages_to_replay', {
|
||||
count: messagesToReplay.length,
|
||||
})
|
||||
|
||||
for (const message of messagesToReplay) {
|
||||
const line = jsonStringify(message) + '\n'
|
||||
const success = this.sendLine(line)
|
||||
if (!success) {
|
||||
this.handleConnectionError()
|
||||
break
|
||||
}
|
||||
}
|
||||
// Do NOT clear the buffer after replay — messages remain buffered until
|
||||
// the server confirms receipt on the next reconnection. This prevents
|
||||
// message loss if the connection drops after replay but before the server
|
||||
// processes the messages.
|
||||
}
|
||||
|
||||
isConnectedStatus(): boolean {
|
||||
return this.state === 'connected'
|
||||
}
|
||||
|
||||
isClosedStatus(): boolean {
|
||||
return this.state === 'closed'
|
||||
}
|
||||
|
||||
setOnData(callback: (data: string) => void): void {
|
||||
this.onData = callback
|
||||
}
|
||||
|
||||
setOnConnect(callback: () => void): void {
|
||||
this.onConnectCallback = callback
|
||||
}
|
||||
|
||||
setOnClose(callback: (closeCode?: number) => void): void {
|
||||
this.onCloseCallback = callback
|
||||
}
|
||||
|
||||
getStateLabel(): string {
|
||||
return this.state
|
||||
}
|
||||
|
||||
async write(message: StdoutMessage): Promise<void> {
|
||||
if ('uuid' in message && typeof message.uuid === 'string') {
|
||||
this.messageBuffer.add(message)
|
||||
this.lastSentId = message.uuid
|
||||
}
|
||||
|
||||
const line = jsonStringify(message) + '\n'
|
||||
|
||||
if (this.state !== 'connected') {
|
||||
// Message buffered for replay when connected (if it has a UUID)
|
||||
return
|
||||
}
|
||||
|
||||
const sessionLabel = this.sessionId ? ` session=${this.sessionId}` : ''
|
||||
const detailLabel = this.getControlMessageDetailLabel(message)
|
||||
|
||||
logForDebugging(
|
||||
`WebSocketTransport: Sending message type=${message.type}${sessionLabel}${detailLabel}`,
|
||||
)
|
||||
|
||||
this.sendLine(line)
|
||||
}
|
||||
|
||||
private getControlMessageDetailLabel(message: StdoutMessage): string {
|
||||
if (message.type === 'control_request') {
|
||||
const { request_id, request } = message
|
||||
const toolName =
|
||||
request.subtype === 'can_use_tool' ? request.tool_name : ''
|
||||
return ` subtype=${request.subtype} request_id=${request_id}${toolName ? ` tool=${toolName}` : ''}`
|
||||
}
|
||||
if (message.type === 'control_response') {
|
||||
const { subtype, request_id } = message.response
|
||||
return ` subtype=${subtype} request_id=${request_id}`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
private startPingInterval(): void {
|
||||
// Clear any existing interval
|
||||
this.stopPingInterval()
|
||||
|
||||
this.pongReceived = true
|
||||
let lastTickTime = Date.now()
|
||||
|
||||
// Send ping periodically to detect dead connections.
|
||||
// If the previous ping got no pong, treat the connection as dead.
|
||||
this.pingInterval = setInterval(() => {
|
||||
if (this.state === 'connected' && this.ws) {
|
||||
const now = Date.now()
|
||||
const gap = now - lastTickTime
|
||||
lastTickTime = now
|
||||
|
||||
// Process-suspension detector. If the wall-clock gap between ticks
|
||||
// greatly exceeds the 10s interval, the process was suspended
|
||||
// (laptop lid, SIGSTOP, VM pause). setInterval does not queue
|
||||
// missed ticks — it coalesces — so on wake this callback fires
|
||||
// once with a huge gap. The socket is almost certainly dead:
|
||||
// NAT mappings drop in 30s–5min, and the server has been
|
||||
// retransmitting into the void. Don't wait for a ping/pong
|
||||
// round-trip to confirm (ws.ping() on a dead socket returns
|
||||
// immediately with no error — bytes go into the kernel send
|
||||
// buffer). Assume dead and reconnect now. A spurious reconnect
|
||||
// after a short sleep is cheap — replayBufferedMessages() handles
|
||||
// it and the server dedups by UUID.
|
||||
if (gap > SLEEP_DETECTION_THRESHOLD_MS) {
|
||||
logForDebugging(
|
||||
`WebSocketTransport: ${Math.round(gap / 1000)}s tick gap detected — process was suspended, forcing reconnect`,
|
||||
)
|
||||
logForDiagnosticsNoPII(
|
||||
'info',
|
||||
'cli_websocket_sleep_detected_on_ping',
|
||||
{ gapMs: gap },
|
||||
)
|
||||
this.handleConnectionError()
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.pongReceived) {
|
||||
logForDebugging(
|
||||
'WebSocketTransport: No pong received, connection appears dead',
|
||||
{ level: 'error' },
|
||||
)
|
||||
logForDiagnosticsNoPII('error', 'cli_websocket_pong_timeout')
|
||||
this.handleConnectionError()
|
||||
return
|
||||
}
|
||||
|
||||
this.pongReceived = false
|
||||
try {
|
||||
this.ws.ping?.()
|
||||
} catch (error) {
|
||||
logForDebugging(`WebSocketTransport: Ping failed: ${error}`, {
|
||||
level: 'error',
|
||||
})
|
||||
logForDiagnosticsNoPII('error', 'cli_websocket_ping_failed')
|
||||
}
|
||||
}
|
||||
}, DEFAULT_PING_INTERVAL)
|
||||
}
|
||||
|
||||
private stopPingInterval(): void {
|
||||
if (this.pingInterval) {
|
||||
clearInterval(this.pingInterval)
|
||||
this.pingInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
private startKeepaliveInterval(): void {
|
||||
this.stopKeepaliveInterval()
|
||||
|
||||
// In CCR sessions, session activity heartbeats handle keep-alives
|
||||
if (isEnvTruthy(process.env.CLAUDE_CODE_REMOTE)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.keepAliveInterval = setInterval(() => {
|
||||
if (this.state === 'connected' && this.ws) {
|
||||
try {
|
||||
this.ws.send(KEEP_ALIVE_FRAME)
|
||||
this.lastActivityTime = Date.now()
|
||||
logForDebugging(
|
||||
'WebSocketTransport: Sent periodic keep_alive data frame',
|
||||
)
|
||||
} catch (error) {
|
||||
logForDebugging(
|
||||
`WebSocketTransport: Periodic keep_alive failed: ${error}`,
|
||||
{ level: 'error' },
|
||||
)
|
||||
logForDiagnosticsNoPII('error', 'cli_websocket_keepalive_failed')
|
||||
}
|
||||
}
|
||||
}, DEFAULT_KEEPALIVE_INTERVAL)
|
||||
}
|
||||
|
||||
private stopKeepaliveInterval(): void {
|
||||
if (this.keepAliveInterval) {
|
||||
clearInterval(this.keepAliveInterval)
|
||||
this.keepAliveInterval = null
|
||||
}
|
||||
}
|
||||
}
|
||||
131
original-source-code/src/cli/transports/WorkerStateUploader.ts
Normal file
131
original-source-code/src/cli/transports/WorkerStateUploader.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { sleep } from '../../utils/sleep.js'
|
||||
|
||||
/**
|
||||
* Coalescing uploader for PUT /worker (session state + metadata).
|
||||
*
|
||||
* - 1 in-flight PUT + 1 pending patch
|
||||
* - New calls coalesce into pending (never grows beyond 1 slot)
|
||||
* - On success: send pending if exists
|
||||
* - On failure: exponential backoff (clamped), retries indefinitely
|
||||
* until success or close(). Absorbs any pending patches before each retry.
|
||||
* - No backpressure needed — naturally bounded at 2 slots
|
||||
*
|
||||
* Coalescing rules:
|
||||
* - Top-level keys (worker_status, external_metadata) — last value wins
|
||||
* - Inside external_metadata / internal_metadata — RFC 7396 merge:
|
||||
* keys are added/overwritten, null values preserved (server deletes)
|
||||
*/
|
||||
|
||||
type WorkerStateUploaderConfig = {
|
||||
send: (body: Record<string, unknown>) => Promise<boolean>
|
||||
/** Base delay for exponential backoff (ms) */
|
||||
baseDelayMs: number
|
||||
/** Max delay cap (ms) */
|
||||
maxDelayMs: number
|
||||
/** Random jitter range added to retry delay (ms) */
|
||||
jitterMs: number
|
||||
}
|
||||
|
||||
export class WorkerStateUploader {
|
||||
private inflight: Promise<void> | null = null
|
||||
private pending: Record<string, unknown> | null = null
|
||||
private closed = false
|
||||
private readonly config: WorkerStateUploaderConfig
|
||||
|
||||
constructor(config: WorkerStateUploaderConfig) {
|
||||
this.config = config
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue a patch to PUT /worker. Coalesces with any existing pending
|
||||
* patch. Fire-and-forget — callers don't need to await.
|
||||
*/
|
||||
enqueue(patch: Record<string, unknown>): void {
|
||||
if (this.closed) return
|
||||
this.pending = this.pending ? coalescePatches(this.pending, patch) : patch
|
||||
void this.drain()
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true
|
||||
this.pending = null
|
||||
}
|
||||
|
||||
private async drain(): Promise<void> {
|
||||
if (this.inflight || this.closed) return
|
||||
if (!this.pending) return
|
||||
|
||||
const payload = this.pending
|
||||
this.pending = null
|
||||
|
||||
this.inflight = this.sendWithRetry(payload).then(() => {
|
||||
this.inflight = null
|
||||
if (this.pending && !this.closed) {
|
||||
void this.drain()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Retries indefinitely with exponential backoff until success or close(). */
|
||||
private async sendWithRetry(payload: Record<string, unknown>): Promise<void> {
|
||||
let current = payload
|
||||
let failures = 0
|
||||
while (!this.closed) {
|
||||
const ok = await this.config.send(current)
|
||||
if (ok) return
|
||||
|
||||
failures++
|
||||
await sleep(this.retryDelay(failures))
|
||||
|
||||
// Absorb any patches that arrived during the retry
|
||||
if (this.pending && !this.closed) {
|
||||
current = coalescePatches(current, this.pending)
|
||||
this.pending = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private retryDelay(failures: number): number {
|
||||
const exponential = Math.min(
|
||||
this.config.baseDelayMs * 2 ** (failures - 1),
|
||||
this.config.maxDelayMs,
|
||||
)
|
||||
const jitter = Math.random() * this.config.jitterMs
|
||||
return exponential + jitter
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Coalesce two patches for PUT /worker.
|
||||
*
|
||||
* Top-level keys: overlay replaces base (last value wins).
|
||||
* Metadata keys (external_metadata, internal_metadata): RFC 7396 merge
|
||||
* one level deep — overlay keys are added/overwritten, null values
|
||||
* preserved for server-side delete.
|
||||
*/
|
||||
function coalescePatches(
|
||||
base: Record<string, unknown>,
|
||||
overlay: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const merged = { ...base }
|
||||
|
||||
for (const [key, value] of Object.entries(overlay)) {
|
||||
if (
|
||||
(key === 'external_metadata' || key === 'internal_metadata') &&
|
||||
merged[key] &&
|
||||
typeof merged[key] === 'object' &&
|
||||
typeof value === 'object' &&
|
||||
value !== null
|
||||
) {
|
||||
// RFC 7396 merge — overlay keys win, nulls preserved for server
|
||||
merged[key] = {
|
||||
...(merged[key] as Record<string, unknown>),
|
||||
...(value as Record<string, unknown>),
|
||||
}
|
||||
} else {
|
||||
merged[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
998
original-source-code/src/cli/transports/ccrClient.ts
Normal file
998
original-source-code/src/cli/transports/ccrClient.ts
Normal file
@@ -0,0 +1,998 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import type {
|
||||
SDKPartialAssistantMessage,
|
||||
StdoutMessage,
|
||||
} from 'src/entrypoints/sdk/controlTypes.js'
|
||||
import { decodeJwtExpiry } from '../../bridge/jwtUtils.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { logForDiagnosticsNoPII } from '../../utils/diagLogs.js'
|
||||
import { errorMessage, getErrnoCode } from '../../utils/errors.js'
|
||||
import { createAxiosInstance } from '../../utils/proxy.js'
|
||||
import {
|
||||
registerSessionActivityCallback,
|
||||
unregisterSessionActivityCallback,
|
||||
} from '../../utils/sessionActivity.js'
|
||||
import {
|
||||
getSessionIngressAuthHeaders,
|
||||
getSessionIngressAuthToken,
|
||||
} from '../../utils/sessionIngressAuth.js'
|
||||
import type {
|
||||
RequiresActionDetails,
|
||||
SessionState,
|
||||
} from '../../utils/sessionState.js'
|
||||
import { sleep } from '../../utils/sleep.js'
|
||||
import { getClaudeCodeUserAgent } from '../../utils/userAgent.js'
|
||||
import {
|
||||
RetryableError,
|
||||
SerialBatchEventUploader,
|
||||
} from './SerialBatchEventUploader.js'
|
||||
import type { SSETransport, StreamClientEvent } from './SSETransport.js'
|
||||
import { WorkerStateUploader } from './WorkerStateUploader.js'
|
||||
|
||||
/** Default interval between heartbeat events (20s; server TTL is 60s). */
|
||||
const DEFAULT_HEARTBEAT_INTERVAL_MS = 20_000
|
||||
|
||||
/**
|
||||
* stream_event messages accumulate in a delay buffer for up to this many ms
|
||||
* before enqueue. Mirrors HybridTransport's batching window. text_delta
|
||||
* events for the same content block accumulate into a single full-so-far
|
||||
* snapshot per flush — each emitted event is self-contained so a client
|
||||
* connecting mid-stream sees complete text, not a fragment.
|
||||
*/
|
||||
const STREAM_EVENT_FLUSH_INTERVAL_MS = 100
|
||||
|
||||
/** Hoisted axios validateStatus callback to avoid per-request closure allocation. */
|
||||
function alwaysValidStatus(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
export type CCRInitFailReason =
|
||||
| 'no_auth_headers'
|
||||
| 'missing_epoch'
|
||||
| 'worker_register_failed'
|
||||
|
||||
/** Thrown by initialize(); carries a typed reason for the diag classifier. */
|
||||
export class CCRInitError extends Error {
|
||||
constructor(readonly reason: CCRInitFailReason) {
|
||||
super(`CCRClient init failed: ${reason}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Consecutive 401/403 with a VALID-LOOKING token before giving up. An
|
||||
* expired JWT short-circuits this (exits immediately — deterministic,
|
||||
* retry is futile). This threshold is for the uncertain case: token's
|
||||
* exp is in the future but server says 401 (userauth down, KMS hiccup,
|
||||
* clock skew). 10 × 20s heartbeat ≈ 200s to ride it out.
|
||||
*/
|
||||
const MAX_CONSECUTIVE_AUTH_FAILURES = 10
|
||||
|
||||
type EventPayload = {
|
||||
uuid: string
|
||||
type: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type ClientEvent = {
|
||||
payload: EventPayload
|
||||
ephemeral?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural subset of a stream_event carrying a text_delta. Not a narrowing
|
||||
* of SDKPartialAssistantMessage — RawMessageStreamEvent's delta is a union and
|
||||
* narrowing through two levels defeats the discriminant.
|
||||
*/
|
||||
type CoalescedStreamEvent = {
|
||||
type: 'stream_event'
|
||||
uuid: string
|
||||
session_id: string
|
||||
parent_tool_use_id: string | null
|
||||
event: {
|
||||
type: 'content_block_delta'
|
||||
index: number
|
||||
delta: { type: 'text_delta'; text: string }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accumulator state for text_delta coalescing. Keyed by API message ID so
|
||||
* lifetime is tied to the assistant message — cleared when the complete
|
||||
* SDKAssistantMessage arrives (writeEvent), which is reliable even when
|
||||
* abort/error paths skip content_block_stop/message_stop delivery.
|
||||
*/
|
||||
export type StreamAccumulatorState = {
|
||||
/** API message ID (msg_...) → blocks[blockIndex] → chunk array. */
|
||||
byMessage: Map<string, string[][]>
|
||||
/**
|
||||
* {session_id}:{parent_tool_use_id} → active message ID.
|
||||
* content_block_delta events don't carry the message ID (only
|
||||
* message_start does), so we track which message is currently streaming
|
||||
* for each scope. At most one message streams per scope at a time.
|
||||
*/
|
||||
scopeToMessage: Map<string, string>
|
||||
}
|
||||
|
||||
export function createStreamAccumulator(): StreamAccumulatorState {
|
||||
return { byMessage: new Map(), scopeToMessage: new Map() }
|
||||
}
|
||||
|
||||
function scopeKey(m: {
|
||||
session_id: string
|
||||
parent_tool_use_id: string | null
|
||||
}): string {
|
||||
return `${m.session_id}:${m.parent_tool_use_id ?? ''}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Accumulate text_delta stream_events into full-so-far snapshots per content
|
||||
* block. Each flush emits ONE event per touched block containing the FULL
|
||||
* accumulated text from the start of the block — a client connecting
|
||||
* mid-stream receives a self-contained snapshot, not a fragment.
|
||||
*
|
||||
* Non-text-delta events pass through unchanged. message_start records the
|
||||
* active message ID for the scope; content_block_delta appends chunks;
|
||||
* the snapshot event reuses the first text_delta UUID seen for that block in
|
||||
* this flush so server-side idempotency remains stable across retries.
|
||||
*
|
||||
* Cleanup happens in writeEvent when the complete assistant message arrives
|
||||
* (reliable), not here on stop events (abort/error paths skip those).
|
||||
*/
|
||||
export function accumulateStreamEvents(
|
||||
buffer: SDKPartialAssistantMessage[],
|
||||
state: StreamAccumulatorState,
|
||||
): EventPayload[] {
|
||||
const out: EventPayload[] = []
|
||||
// chunks[] → snapshot already in `out` this flush. Keyed by the chunks
|
||||
// array reference (stable per {messageId, index}) so subsequent deltas
|
||||
// rewrite the same entry instead of emitting one event per delta.
|
||||
const touched = new Map<string[], CoalescedStreamEvent>()
|
||||
for (const msg of buffer) {
|
||||
switch (msg.event.type) {
|
||||
case 'message_start': {
|
||||
const id = msg.event.message.id
|
||||
const prevId = state.scopeToMessage.get(scopeKey(msg))
|
||||
if (prevId) state.byMessage.delete(prevId)
|
||||
state.scopeToMessage.set(scopeKey(msg), id)
|
||||
state.byMessage.set(id, [])
|
||||
out.push(msg)
|
||||
break
|
||||
}
|
||||
case 'content_block_delta': {
|
||||
if (msg.event.delta.type !== 'text_delta') {
|
||||
out.push(msg)
|
||||
break
|
||||
}
|
||||
const messageId = state.scopeToMessage.get(scopeKey(msg))
|
||||
const blocks = messageId ? state.byMessage.get(messageId) : undefined
|
||||
if (!blocks) {
|
||||
// Delta without a preceding message_start (reconnect mid-stream,
|
||||
// or message_start was in a prior buffer that got dropped). Pass
|
||||
// through raw — can't produce a full-so-far snapshot without the
|
||||
// prior chunks anyway.
|
||||
out.push(msg)
|
||||
break
|
||||
}
|
||||
const chunks = (blocks[msg.event.index] ??= [])
|
||||
chunks.push(msg.event.delta.text)
|
||||
const existing = touched.get(chunks)
|
||||
if (existing) {
|
||||
existing.event.delta.text = chunks.join('')
|
||||
break
|
||||
}
|
||||
const snapshot: CoalescedStreamEvent = {
|
||||
type: 'stream_event',
|
||||
uuid: msg.uuid,
|
||||
session_id: msg.session_id,
|
||||
parent_tool_use_id: msg.parent_tool_use_id,
|
||||
event: {
|
||||
type: 'content_block_delta',
|
||||
index: msg.event.index,
|
||||
delta: { type: 'text_delta', text: chunks.join('') },
|
||||
},
|
||||
}
|
||||
touched.set(chunks, snapshot)
|
||||
out.push(snapshot)
|
||||
break
|
||||
}
|
||||
default:
|
||||
out.push(msg)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear accumulator entries for a completed assistant message. Called from
|
||||
* writeEvent when the SDKAssistantMessage arrives — the reliable end-of-stream
|
||||
* signal that fires even when abort/interrupt/error skip SSE stop events.
|
||||
*/
|
||||
export function clearStreamAccumulatorForMessage(
|
||||
state: StreamAccumulatorState,
|
||||
assistant: {
|
||||
session_id: string
|
||||
parent_tool_use_id: string | null
|
||||
message: { id: string }
|
||||
},
|
||||
): void {
|
||||
state.byMessage.delete(assistant.message.id)
|
||||
const scope = scopeKey(assistant)
|
||||
if (state.scopeToMessage.get(scope) === assistant.message.id) {
|
||||
state.scopeToMessage.delete(scope)
|
||||
}
|
||||
}
|
||||
|
||||
type RequestResult = { ok: true } | { ok: false; retryAfterMs?: number }
|
||||
|
||||
type WorkerEvent = {
|
||||
payload: EventPayload
|
||||
is_compaction?: boolean
|
||||
agent_id?: string
|
||||
}
|
||||
|
||||
export type InternalEvent = {
|
||||
event_id: string
|
||||
event_type: string
|
||||
payload: Record<string, unknown>
|
||||
event_metadata?: Record<string, unknown> | null
|
||||
is_compaction: boolean
|
||||
created_at: string
|
||||
agent_id?: string
|
||||
}
|
||||
|
||||
type ListInternalEventsResponse = {
|
||||
data: InternalEvent[]
|
||||
next_cursor?: string
|
||||
}
|
||||
|
||||
type WorkerStateResponse = {
|
||||
worker?: {
|
||||
external_metadata?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the worker lifecycle protocol with CCR v2:
|
||||
* - Epoch management: reads worker_epoch from CLAUDE_CODE_WORKER_EPOCH env var
|
||||
* - Runtime state reporting: PUT /sessions/{id}/worker
|
||||
* - Heartbeat: POST /sessions/{id}/worker/heartbeat for liveness detection
|
||||
*
|
||||
* All writes go through this.request().
|
||||
*/
|
||||
export class CCRClient {
|
||||
private workerEpoch = 0
|
||||
private readonly heartbeatIntervalMs: number
|
||||
private readonly heartbeatJitterFraction: number
|
||||
private heartbeatTimer: NodeJS.Timeout | null = null
|
||||
private heartbeatInFlight = false
|
||||
private closed = false
|
||||
private consecutiveAuthFailures = 0
|
||||
private currentState: SessionState | null = null
|
||||
private readonly sessionBaseUrl: string
|
||||
private readonly sessionId: string
|
||||
private readonly http = createAxiosInstance({ keepAlive: true })
|
||||
|
||||
// stream_event delay buffer — accumulates content deltas for up to
|
||||
// STREAM_EVENT_FLUSH_INTERVAL_MS before enqueueing (reduces POST count
|
||||
// and enables text_delta coalescing). Mirrors HybridTransport's pattern.
|
||||
private streamEventBuffer: SDKPartialAssistantMessage[] = []
|
||||
private streamEventTimer: ReturnType<typeof setTimeout> | null = null
|
||||
// Full-so-far text accumulator. Persists across flushes so each emitted
|
||||
// text_delta event carries the complete text from the start of the block —
|
||||
// mid-stream reconnects see a self-contained snapshot. Keyed by API message
|
||||
// ID; cleared in writeEvent when the complete assistant message arrives.
|
||||
private streamTextAccumulator = createStreamAccumulator()
|
||||
|
||||
private readonly workerState: WorkerStateUploader
|
||||
private readonly eventUploader: SerialBatchEventUploader<ClientEvent>
|
||||
private readonly internalEventUploader: SerialBatchEventUploader<WorkerEvent>
|
||||
private readonly deliveryUploader: SerialBatchEventUploader<{
|
||||
eventId: string
|
||||
status: 'received' | 'processing' | 'processed'
|
||||
}>
|
||||
|
||||
/**
|
||||
* Called when the server returns 409 (a newer worker epoch superseded ours).
|
||||
* Default: process.exit(1) — correct for spawn-mode children where the
|
||||
* parent bridge re-spawns. In-process callers (replBridge) MUST override
|
||||
* this to close gracefully instead; exit would kill the user's REPL.
|
||||
*/
|
||||
private readonly onEpochMismatch: () => never
|
||||
|
||||
/**
|
||||
* Auth header source. Defaults to the process-wide session-ingress token
|
||||
* (CLAUDE_CODE_SESSION_ACCESS_TOKEN env var). Callers managing multiple
|
||||
* concurrent sessions with distinct JWTs MUST inject this — the env-var
|
||||
* path is a process global and would stomp across sessions.
|
||||
*/
|
||||
private readonly getAuthHeaders: () => Record<string, string>
|
||||
|
||||
constructor(
|
||||
transport: SSETransport,
|
||||
sessionUrl: URL,
|
||||
opts?: {
|
||||
onEpochMismatch?: () => never
|
||||
heartbeatIntervalMs?: number
|
||||
heartbeatJitterFraction?: number
|
||||
/**
|
||||
* Per-instance auth header source. Omit to read the process-wide
|
||||
* CLAUDE_CODE_SESSION_ACCESS_TOKEN (single-session callers — REPL,
|
||||
* daemon). Required for concurrent multi-session callers.
|
||||
*/
|
||||
getAuthHeaders?: () => Record<string, string>
|
||||
},
|
||||
) {
|
||||
this.onEpochMismatch =
|
||||
opts?.onEpochMismatch ??
|
||||
(() => {
|
||||
// eslint-disable-next-line custom-rules/no-process-exit
|
||||
process.exit(1)
|
||||
})
|
||||
this.heartbeatIntervalMs =
|
||||
opts?.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS
|
||||
this.heartbeatJitterFraction = opts?.heartbeatJitterFraction ?? 0
|
||||
this.getAuthHeaders = opts?.getAuthHeaders ?? getSessionIngressAuthHeaders
|
||||
// Session URL: https://host/v1/code/sessions/{id}
|
||||
if (sessionUrl.protocol !== 'http:' && sessionUrl.protocol !== 'https:') {
|
||||
throw new Error(
|
||||
`CCRClient: Expected http(s) URL, got ${sessionUrl.protocol}`,
|
||||
)
|
||||
}
|
||||
const pathname = sessionUrl.pathname.replace(/\/$/, '')
|
||||
this.sessionBaseUrl = `${sessionUrl.protocol}//${sessionUrl.host}${pathname}`
|
||||
// Extract session ID from the URL path (last segment)
|
||||
this.sessionId = pathname.split('/').pop() || ''
|
||||
|
||||
this.workerState = new WorkerStateUploader({
|
||||
send: body =>
|
||||
this.request(
|
||||
'put',
|
||||
'/worker',
|
||||
{ worker_epoch: this.workerEpoch, ...body },
|
||||
'PUT worker',
|
||||
).then(r => r.ok),
|
||||
baseDelayMs: 500,
|
||||
maxDelayMs: 30_000,
|
||||
jitterMs: 500,
|
||||
})
|
||||
|
||||
this.eventUploader = new SerialBatchEventUploader<ClientEvent>({
|
||||
maxBatchSize: 100,
|
||||
maxBatchBytes: 10 * 1024 * 1024,
|
||||
// flushStreamEventBuffer() enqueues a full 100ms window of accumulated
|
||||
// stream_events in one call. A burst of mixed delta types that don't
|
||||
// fold into a single snapshot could exceed the old cap (50) and deadlock
|
||||
// on the SerialBatchEventUploader backpressure check. Match
|
||||
// HybridTransport's bound — high enough to be memory-only.
|
||||
maxQueueSize: 100_000,
|
||||
send: async batch => {
|
||||
const result = await this.request(
|
||||
'post',
|
||||
'/worker/events',
|
||||
{ worker_epoch: this.workerEpoch, events: batch },
|
||||
'client events',
|
||||
)
|
||||
if (!result.ok) {
|
||||
throw new RetryableError(
|
||||
'client event POST failed',
|
||||
result.retryAfterMs,
|
||||
)
|
||||
}
|
||||
},
|
||||
baseDelayMs: 500,
|
||||
maxDelayMs: 30_000,
|
||||
jitterMs: 500,
|
||||
})
|
||||
|
||||
this.internalEventUploader = new SerialBatchEventUploader<WorkerEvent>({
|
||||
maxBatchSize: 100,
|
||||
maxBatchBytes: 10 * 1024 * 1024,
|
||||
maxQueueSize: 200,
|
||||
send: async batch => {
|
||||
const result = await this.request(
|
||||
'post',
|
||||
'/worker/internal-events',
|
||||
{ worker_epoch: this.workerEpoch, events: batch },
|
||||
'internal events',
|
||||
)
|
||||
if (!result.ok) {
|
||||
throw new RetryableError(
|
||||
'internal event POST failed',
|
||||
result.retryAfterMs,
|
||||
)
|
||||
}
|
||||
},
|
||||
baseDelayMs: 500,
|
||||
maxDelayMs: 30_000,
|
||||
jitterMs: 500,
|
||||
})
|
||||
|
||||
this.deliveryUploader = new SerialBatchEventUploader<{
|
||||
eventId: string
|
||||
status: 'received' | 'processing' | 'processed'
|
||||
}>({
|
||||
maxBatchSize: 64,
|
||||
maxQueueSize: 64,
|
||||
send: async batch => {
|
||||
const result = await this.request(
|
||||
'post',
|
||||
'/worker/events/delivery',
|
||||
{
|
||||
worker_epoch: this.workerEpoch,
|
||||
updates: batch.map(d => ({
|
||||
event_id: d.eventId,
|
||||
status: d.status,
|
||||
})),
|
||||
},
|
||||
'delivery batch',
|
||||
)
|
||||
if (!result.ok) {
|
||||
throw new RetryableError('delivery POST failed', result.retryAfterMs)
|
||||
}
|
||||
},
|
||||
baseDelayMs: 500,
|
||||
maxDelayMs: 30_000,
|
||||
jitterMs: 500,
|
||||
})
|
||||
|
||||
// Ack each received client_event so CCR can track delivery status.
|
||||
// Wired here (not in initialize()) so the callback is registered the
|
||||
// moment new CCRClient() returns — remoteIO must be free to call
|
||||
// transport.connect() immediately after without racing the first
|
||||
// SSE catch-up frame against an unwired onEventCallback.
|
||||
transport.setOnEvent((event: StreamClientEvent) => {
|
||||
this.reportDelivery(event.event_id, 'received')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the session worker:
|
||||
* 1. Take worker_epoch from the argument, or fall back to
|
||||
* CLAUDE_CODE_WORKER_EPOCH (set by env-manager / bridge spawner)
|
||||
* 2. Report state as 'idle'
|
||||
* 3. Start heartbeat timer
|
||||
*
|
||||
* In-process callers (replBridge) pass the epoch directly — they
|
||||
* registered the worker themselves and there is no parent process
|
||||
* setting env vars.
|
||||
*/
|
||||
async initialize(epoch?: number): Promise<Record<string, unknown> | null> {
|
||||
const startMs = Date.now()
|
||||
if (Object.keys(this.getAuthHeaders()).length === 0) {
|
||||
throw new CCRInitError('no_auth_headers')
|
||||
}
|
||||
if (epoch === undefined) {
|
||||
const rawEpoch = process.env.CLAUDE_CODE_WORKER_EPOCH
|
||||
epoch = rawEpoch ? parseInt(rawEpoch, 10) : NaN
|
||||
}
|
||||
if (isNaN(epoch)) {
|
||||
throw new CCRInitError('missing_epoch')
|
||||
}
|
||||
this.workerEpoch = epoch
|
||||
|
||||
// Concurrent with the init PUT — neither depends on the other.
|
||||
const restoredPromise = this.getWorkerState()
|
||||
|
||||
const result = await this.request(
|
||||
'put',
|
||||
'/worker',
|
||||
{
|
||||
worker_status: 'idle',
|
||||
worker_epoch: this.workerEpoch,
|
||||
// Clear stale pending_action/task_summary left by a prior
|
||||
// worker crash — the in-session clears don't survive process restart.
|
||||
external_metadata: {
|
||||
pending_action: null,
|
||||
task_summary: null,
|
||||
},
|
||||
},
|
||||
'PUT worker (init)',
|
||||
)
|
||||
if (!result.ok) {
|
||||
// 409 → onEpochMismatch may throw, but request() catches it and returns
|
||||
// false. Without this check we'd continue to startHeartbeat(), leaking a
|
||||
// 20s timer against a dead epoch. Throw so connect()'s rejection handler
|
||||
// fires instead of the success path.
|
||||
throw new CCRInitError('worker_register_failed')
|
||||
}
|
||||
this.currentState = 'idle'
|
||||
this.startHeartbeat()
|
||||
|
||||
// sessionActivity's refcount-gated timer fires while an API call or tool
|
||||
// is in-flight; without a write the container lease can expire mid-wait.
|
||||
// v1 wires this in WebSocketTransport per-connection.
|
||||
registerSessionActivityCallback(() => {
|
||||
void this.writeEvent({ type: 'keep_alive' })
|
||||
})
|
||||
|
||||
logForDebugging(`CCRClient: initialized, epoch=${this.workerEpoch}`)
|
||||
logForDiagnosticsNoPII('info', 'cli_worker_lifecycle_initialized', {
|
||||
epoch: this.workerEpoch,
|
||||
duration_ms: Date.now() - startMs,
|
||||
})
|
||||
|
||||
// Await the concurrent GET and log state_restored here, after the PUT
|
||||
// has succeeded — logging inside getWorkerState() raced: if the GET
|
||||
// resolved before the PUT failed, diagnostics showed both init_failed
|
||||
// and state_restored for the same session.
|
||||
const { metadata, durationMs } = await restoredPromise
|
||||
if (!this.closed) {
|
||||
logForDiagnosticsNoPII('info', 'cli_worker_state_restored', {
|
||||
duration_ms: durationMs,
|
||||
had_state: metadata !== null,
|
||||
})
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
// Control_requests are marked processed and not re-delivered on
|
||||
// restart, so read back what the prior worker wrote.
|
||||
private async getWorkerState(): Promise<{
|
||||
metadata: Record<string, unknown> | null
|
||||
durationMs: number
|
||||
}> {
|
||||
const startMs = Date.now()
|
||||
const authHeaders = this.getAuthHeaders()
|
||||
if (Object.keys(authHeaders).length === 0) {
|
||||
return { metadata: null, durationMs: 0 }
|
||||
}
|
||||
const data = await this.getWithRetry<WorkerStateResponse>(
|
||||
`${this.sessionBaseUrl}/worker`,
|
||||
authHeaders,
|
||||
'worker_state',
|
||||
)
|
||||
return {
|
||||
metadata: data?.worker?.external_metadata ?? null,
|
||||
durationMs: Date.now() - startMs,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an authenticated HTTP request to CCR. Handles auth headers,
|
||||
* 409 epoch mismatch, and error logging. Returns { ok: true } on 2xx.
|
||||
* On 429, reads Retry-After (integer seconds) so the uploader can honor
|
||||
* the server's backoff hint instead of blindly exponentiating.
|
||||
*/
|
||||
private async request(
|
||||
method: 'post' | 'put',
|
||||
path: string,
|
||||
body: unknown,
|
||||
label: string,
|
||||
{ timeout = 10_000 }: { timeout?: number } = {},
|
||||
): Promise<RequestResult> {
|
||||
const authHeaders = this.getAuthHeaders()
|
||||
if (Object.keys(authHeaders).length === 0) return { ok: false }
|
||||
|
||||
try {
|
||||
const response = await this.http[method](
|
||||
`${this.sessionBaseUrl}${path}`,
|
||||
body,
|
||||
{
|
||||
headers: {
|
||||
...authHeaders,
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
'User-Agent': getClaudeCodeUserAgent(),
|
||||
},
|
||||
validateStatus: alwaysValidStatus,
|
||||
timeout,
|
||||
},
|
||||
)
|
||||
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
this.consecutiveAuthFailures = 0
|
||||
return { ok: true }
|
||||
}
|
||||
if (response.status === 409) {
|
||||
this.handleEpochMismatch()
|
||||
}
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
// A 401 with an expired JWT is deterministic — no retry will
|
||||
// ever succeed. Check the token's own exp before burning
|
||||
// wall-clock on the threshold loop.
|
||||
const tok = getSessionIngressAuthToken()
|
||||
const exp = tok ? decodeJwtExpiry(tok) : null
|
||||
if (exp !== null && exp * 1000 < Date.now()) {
|
||||
logForDebugging(
|
||||
`CCRClient: session_token expired (exp=${new Date(exp * 1000).toISOString()}) — no refresh was delivered, exiting`,
|
||||
{ level: 'error' },
|
||||
)
|
||||
logForDiagnosticsNoPII('error', 'cli_worker_token_expired_no_refresh')
|
||||
this.onEpochMismatch()
|
||||
}
|
||||
// Token looks valid but server says 401 — possible server-side
|
||||
// blip (userauth down, KMS hiccup). Count toward threshold.
|
||||
this.consecutiveAuthFailures++
|
||||
if (this.consecutiveAuthFailures >= MAX_CONSECUTIVE_AUTH_FAILURES) {
|
||||
logForDebugging(
|
||||
`CCRClient: ${this.consecutiveAuthFailures} consecutive auth failures with a valid-looking token — server-side auth unrecoverable, exiting`,
|
||||
{ level: 'error' },
|
||||
)
|
||||
logForDiagnosticsNoPII('error', 'cli_worker_auth_failures_exhausted')
|
||||
this.onEpochMismatch()
|
||||
}
|
||||
}
|
||||
logForDebugging(`CCRClient: ${label} returned ${response.status}`, {
|
||||
level: 'warn',
|
||||
})
|
||||
logForDiagnosticsNoPII('warn', 'cli_worker_request_failed', {
|
||||
method,
|
||||
path,
|
||||
status: response.status,
|
||||
})
|
||||
if (response.status === 429) {
|
||||
const raw = response.headers?.['retry-after']
|
||||
const seconds = typeof raw === 'string' ? parseInt(raw, 10) : NaN
|
||||
if (!isNaN(seconds) && seconds >= 0) {
|
||||
return { ok: false, retryAfterMs: seconds * 1000 }
|
||||
}
|
||||
}
|
||||
return { ok: false }
|
||||
} catch (error) {
|
||||
logForDebugging(`CCRClient: ${label} failed: ${errorMessage(error)}`, {
|
||||
level: 'warn',
|
||||
})
|
||||
logForDiagnosticsNoPII('warn', 'cli_worker_request_error', {
|
||||
method,
|
||||
path,
|
||||
error_code: getErrnoCode(error),
|
||||
})
|
||||
return { ok: false }
|
||||
}
|
||||
}
|
||||
|
||||
/** Report worker state to CCR via PUT /sessions/{id}/worker. */
|
||||
reportState(state: SessionState, details?: RequiresActionDetails): void {
|
||||
if (state === this.currentState && !details) return
|
||||
this.currentState = state
|
||||
this.workerState.enqueue({
|
||||
worker_status: state,
|
||||
requires_action_details: details
|
||||
? {
|
||||
tool_name: details.tool_name,
|
||||
action_description: details.action_description,
|
||||
request_id: details.request_id,
|
||||
}
|
||||
: null,
|
||||
})
|
||||
}
|
||||
|
||||
/** Report external metadata to CCR via PUT /worker. */
|
||||
reportMetadata(metadata: Record<string, unknown>): void {
|
||||
this.workerState.enqueue({ external_metadata: metadata })
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle epoch mismatch (409 Conflict). A newer CC instance has replaced
|
||||
* this one — exit immediately.
|
||||
*/
|
||||
private handleEpochMismatch(): never {
|
||||
logForDebugging('CCRClient: Epoch mismatch (409), shutting down', {
|
||||
level: 'error',
|
||||
})
|
||||
logForDiagnosticsNoPII('error', 'cli_worker_epoch_mismatch')
|
||||
this.onEpochMismatch()
|
||||
}
|
||||
|
||||
/** Start periodic heartbeat. */
|
||||
private startHeartbeat(): void {
|
||||
this.stopHeartbeat()
|
||||
const schedule = (): void => {
|
||||
const jitter =
|
||||
this.heartbeatIntervalMs *
|
||||
this.heartbeatJitterFraction *
|
||||
(2 * Math.random() - 1)
|
||||
this.heartbeatTimer = setTimeout(tick, this.heartbeatIntervalMs + jitter)
|
||||
}
|
||||
const tick = (): void => {
|
||||
void this.sendHeartbeat()
|
||||
// stopHeartbeat nulls the timer; check after the fire-and-forget send
|
||||
// but before rescheduling so close() during sendHeartbeat is honored.
|
||||
if (this.heartbeatTimer === null) return
|
||||
schedule()
|
||||
}
|
||||
schedule()
|
||||
}
|
||||
|
||||
/** Stop heartbeat timer. */
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearTimeout(this.heartbeatTimer)
|
||||
this.heartbeatTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Send a heartbeat via POST /sessions/{id}/worker/heartbeat. */
|
||||
private async sendHeartbeat(): Promise<void> {
|
||||
if (this.heartbeatInFlight) return
|
||||
this.heartbeatInFlight = true
|
||||
try {
|
||||
const result = await this.request(
|
||||
'post',
|
||||
'/worker/heartbeat',
|
||||
{ session_id: this.sessionId, worker_epoch: this.workerEpoch },
|
||||
'Heartbeat',
|
||||
{ timeout: 5_000 },
|
||||
)
|
||||
if (result.ok) {
|
||||
logForDebugging('CCRClient: Heartbeat sent')
|
||||
}
|
||||
} finally {
|
||||
this.heartbeatInFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a StdoutMessage as a client event via POST /sessions/{id}/worker/events.
|
||||
* These events are visible to frontend clients via the SSE stream.
|
||||
* Injects a UUID if missing to ensure server-side idempotency on retry.
|
||||
*
|
||||
* stream_event messages are held in a 100ms delay buffer and accumulated
|
||||
* (text_deltas for the same content block emit a full-so-far snapshot per
|
||||
* flush). A non-stream_event write flushes the buffer first so downstream
|
||||
* ordering is preserved.
|
||||
*/
|
||||
async writeEvent(message: StdoutMessage): Promise<void> {
|
||||
if (message.type === 'stream_event') {
|
||||
this.streamEventBuffer.push(message)
|
||||
if (!this.streamEventTimer) {
|
||||
this.streamEventTimer = setTimeout(
|
||||
() => void this.flushStreamEventBuffer(),
|
||||
STREAM_EVENT_FLUSH_INTERVAL_MS,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
await this.flushStreamEventBuffer()
|
||||
if (message.type === 'assistant') {
|
||||
clearStreamAccumulatorForMessage(this.streamTextAccumulator, message)
|
||||
}
|
||||
await this.eventUploader.enqueue(this.toClientEvent(message))
|
||||
}
|
||||
|
||||
/** Wrap a StdoutMessage as a ClientEvent, injecting a UUID if missing. */
|
||||
private toClientEvent(message: StdoutMessage): ClientEvent {
|
||||
const msg = message as unknown as Record<string, unknown>
|
||||
return {
|
||||
payload: {
|
||||
...msg,
|
||||
uuid: typeof msg.uuid === 'string' ? msg.uuid : randomUUID(),
|
||||
} as EventPayload,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain the stream_event delay buffer: accumulate text_deltas into
|
||||
* full-so-far snapshots, clear the timer, enqueue the resulting events.
|
||||
* Called from the timer, from writeEvent on a non-stream message, and from
|
||||
* flush(). close() drops the buffer — call flush() first if you need
|
||||
* delivery.
|
||||
*/
|
||||
private async flushStreamEventBuffer(): Promise<void> {
|
||||
if (this.streamEventTimer) {
|
||||
clearTimeout(this.streamEventTimer)
|
||||
this.streamEventTimer = null
|
||||
}
|
||||
if (this.streamEventBuffer.length === 0) return
|
||||
const buffered = this.streamEventBuffer
|
||||
this.streamEventBuffer = []
|
||||
const payloads = accumulateStreamEvents(
|
||||
buffered,
|
||||
this.streamTextAccumulator,
|
||||
)
|
||||
await this.eventUploader.enqueue(
|
||||
payloads.map(payload => ({ payload, ephemeral: true })),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an internal worker event via POST /sessions/{id}/worker/internal-events.
|
||||
* These events are NOT visible to frontend clients — they store worker-internal
|
||||
* state (transcript messages, compaction markers) needed for session resume.
|
||||
*/
|
||||
async writeInternalEvent(
|
||||
eventType: string,
|
||||
payload: Record<string, unknown>,
|
||||
{
|
||||
isCompaction = false,
|
||||
agentId,
|
||||
}: {
|
||||
isCompaction?: boolean
|
||||
agentId?: string
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
const event: WorkerEvent = {
|
||||
payload: {
|
||||
type: eventType,
|
||||
...payload,
|
||||
uuid: typeof payload.uuid === 'string' ? payload.uuid : randomUUID(),
|
||||
} as EventPayload,
|
||||
...(isCompaction && { is_compaction: true }),
|
||||
...(agentId && { agent_id: agentId }),
|
||||
}
|
||||
await this.internalEventUploader.enqueue(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush pending internal events. Call between turns and on shutdown
|
||||
* to ensure transcript entries are persisted.
|
||||
*/
|
||||
flushInternalEvents(): Promise<void> {
|
||||
return this.internalEventUploader.flush()
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush pending client events (writeEvent queue). Call before close()
|
||||
* when the caller needs delivery confirmation — close() abandons the
|
||||
* queue. Resolves once the uploader drains or rejects; returns
|
||||
* regardless of whether individual POSTs succeeded (check server state
|
||||
* separately if that matters).
|
||||
*/
|
||||
async flush(): Promise<void> {
|
||||
await this.flushStreamEventBuffer()
|
||||
return this.eventUploader.flush()
|
||||
}
|
||||
|
||||
/**
|
||||
* Read foreground agent internal events from
|
||||
* GET /sessions/{id}/worker/internal-events.
|
||||
* Returns transcript entries from the last compaction boundary, or null on failure.
|
||||
* Used for session resume.
|
||||
*/
|
||||
async readInternalEvents(): Promise<InternalEvent[] | null> {
|
||||
return this.paginatedGet('/worker/internal-events', {}, 'internal_events')
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all subagent internal events from
|
||||
* GET /sessions/{id}/worker/internal-events?subagents=true.
|
||||
* Returns a merged stream across all non-foreground agents, each from its
|
||||
* compaction point. Used for session resume.
|
||||
*/
|
||||
async readSubagentInternalEvents(): Promise<InternalEvent[] | null> {
|
||||
return this.paginatedGet(
|
||||
'/worker/internal-events',
|
||||
{ subagents: 'true' },
|
||||
'subagent_events',
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated GET with retry. Fetches all pages from a list endpoint,
|
||||
* retrying each page on failure with exponential backoff + jitter.
|
||||
*/
|
||||
private async paginatedGet(
|
||||
path: string,
|
||||
params: Record<string, string>,
|
||||
context: string,
|
||||
): Promise<InternalEvent[] | null> {
|
||||
const authHeaders = this.getAuthHeaders()
|
||||
if (Object.keys(authHeaders).length === 0) return null
|
||||
|
||||
const allEvents: InternalEvent[] = []
|
||||
let cursor: string | undefined
|
||||
|
||||
do {
|
||||
const url = new URL(`${this.sessionBaseUrl}${path}`)
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
url.searchParams.set(k, v)
|
||||
}
|
||||
if (cursor) {
|
||||
url.searchParams.set('cursor', cursor)
|
||||
}
|
||||
|
||||
const page = await this.getWithRetry<ListInternalEventsResponse>(
|
||||
url.toString(),
|
||||
authHeaders,
|
||||
context,
|
||||
)
|
||||
if (!page) return null
|
||||
|
||||
allEvents.push(...(page.data ?? []))
|
||||
cursor = page.next_cursor
|
||||
} while (cursor)
|
||||
|
||||
logForDebugging(
|
||||
`CCRClient: Read ${allEvents.length} internal events from ${path}${params.subagents ? ' (subagents)' : ''}`,
|
||||
)
|
||||
return allEvents
|
||||
}
|
||||
|
||||
/**
|
||||
* Single GET request with retry. Returns the parsed response body
|
||||
* on success, null if all retries are exhausted.
|
||||
*/
|
||||
private async getWithRetry<T>(
|
||||
url: string,
|
||||
authHeaders: Record<string, string>,
|
||||
context: string,
|
||||
): Promise<T | null> {
|
||||
for (let attempt = 1; attempt <= 10; attempt++) {
|
||||
let response
|
||||
try {
|
||||
response = await this.http.get<T>(url, {
|
||||
headers: {
|
||||
...authHeaders,
|
||||
'anthropic-version': '2023-06-01',
|
||||
'User-Agent': getClaudeCodeUserAgent(),
|
||||
},
|
||||
validateStatus: alwaysValidStatus,
|
||||
timeout: 30_000,
|
||||
})
|
||||
} catch (error) {
|
||||
logForDebugging(
|
||||
`CCRClient: GET ${url} failed (attempt ${attempt}/10): ${errorMessage(error)}`,
|
||||
{ level: 'warn' },
|
||||
)
|
||||
if (attempt < 10) {
|
||||
const delay =
|
||||
Math.min(500 * 2 ** (attempt - 1), 30_000) + Math.random() * 500
|
||||
await sleep(delay)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
return response.data
|
||||
}
|
||||
if (response.status === 409) {
|
||||
this.handleEpochMismatch()
|
||||
}
|
||||
logForDebugging(
|
||||
`CCRClient: GET ${url} returned ${response.status} (attempt ${attempt}/10)`,
|
||||
{ level: 'warn' },
|
||||
)
|
||||
|
||||
if (attempt < 10) {
|
||||
const delay =
|
||||
Math.min(500 * 2 ** (attempt - 1), 30_000) + Math.random() * 500
|
||||
await sleep(delay)
|
||||
}
|
||||
}
|
||||
|
||||
logForDebugging('CCRClient: GET retries exhausted', { level: 'error' })
|
||||
logForDiagnosticsNoPII('error', 'cli_worker_get_retries_exhausted', {
|
||||
context,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Report delivery status for a client-to-worker event.
|
||||
* POST /v1/code/sessions/{id}/worker/events/delivery (batch endpoint)
|
||||
*/
|
||||
reportDelivery(
|
||||
eventId: string,
|
||||
status: 'received' | 'processing' | 'processed',
|
||||
): void {
|
||||
void this.deliveryUploader.enqueue({ eventId, status })
|
||||
}
|
||||
|
||||
/** Get the current epoch (for external use). */
|
||||
getWorkerEpoch(): number {
|
||||
return this.workerEpoch
|
||||
}
|
||||
|
||||
/** Internal-event queue depth — shutdown-snapshot backpressure signal. */
|
||||
get internalEventsPending(): number {
|
||||
return this.internalEventUploader.pendingCount
|
||||
}
|
||||
|
||||
/** Clean up uploaders and timers. */
|
||||
close(): void {
|
||||
this.closed = true
|
||||
this.stopHeartbeat()
|
||||
unregisterSessionActivityCallback()
|
||||
if (this.streamEventTimer) {
|
||||
clearTimeout(this.streamEventTimer)
|
||||
this.streamEventTimer = null
|
||||
}
|
||||
this.streamEventBuffer = []
|
||||
this.streamTextAccumulator.byMessage.clear()
|
||||
this.streamTextAccumulator.scopeToMessage.clear()
|
||||
this.workerState.close()
|
||||
this.eventUploader.close()
|
||||
this.internalEventUploader.close()
|
||||
this.deliveryUploader.close()
|
||||
}
|
||||
}
|
||||
45
original-source-code/src/cli/transports/transportUtils.ts
Normal file
45
original-source-code/src/cli/transports/transportUtils.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { URL } from 'url'
|
||||
import { isEnvTruthy } from '../../utils/envUtils.js'
|
||||
import { HybridTransport } from './HybridTransport.js'
|
||||
import { SSETransport } from './SSETransport.js'
|
||||
import type { Transport } from './Transport.js'
|
||||
import { WebSocketTransport } from './WebSocketTransport.js'
|
||||
|
||||
/**
|
||||
* Helper function to get the appropriate transport for a URL.
|
||||
*
|
||||
* Transport selection priority:
|
||||
* 1. SSETransport (SSE reads + POST writes) when CLAUDE_CODE_USE_CCR_V2 is set
|
||||
* 2. HybridTransport (WS reads + POST writes) when CLAUDE_CODE_POST_FOR_SESSION_INGRESS_V2 is set
|
||||
* 3. WebSocketTransport (WS reads + WS writes) — default
|
||||
*/
|
||||
export function getTransportForUrl(
|
||||
url: URL,
|
||||
headers: Record<string, string> = {},
|
||||
sessionId?: string,
|
||||
refreshHeaders?: () => Record<string, string>,
|
||||
): Transport {
|
||||
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_CCR_V2)) {
|
||||
// v2: SSE for reads, HTTP POST for writes
|
||||
// --sdk-url is the session URL (.../sessions/{id});
|
||||
// derive the SSE stream URL by appending /worker/events/stream
|
||||
const sseUrl = new URL(url.href)
|
||||
if (sseUrl.protocol === 'wss:') {
|
||||
sseUrl.protocol = 'https:'
|
||||
} else if (sseUrl.protocol === 'ws:') {
|
||||
sseUrl.protocol = 'http:'
|
||||
}
|
||||
sseUrl.pathname =
|
||||
sseUrl.pathname.replace(/\/$/, '') + '/worker/events/stream'
|
||||
return new SSETransport(sseUrl, headers, sessionId, refreshHeaders)
|
||||
}
|
||||
|
||||
if (url.protocol === 'ws:' || url.protocol === 'wss:') {
|
||||
if (isEnvTruthy(process.env.CLAUDE_CODE_POST_FOR_SESSION_INGRESS_V2)) {
|
||||
return new HybridTransport(url, headers, sessionId, refreshHeaders)
|
||||
}
|
||||
return new WebSocketTransport(url, headers, sessionId, refreshHeaders)
|
||||
} else {
|
||||
throw new Error(`Unsupported protocol: ${url.protocol}`)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user